New admin tab "AI" (app/(main)/admin/_tabs/ai-policy.tsx): provider-class toggles, server model allow-list, BYOK provider allow-list, seats/usage (front-end for the already-real lib/ai/entitlement.ts), retrieval on/off, consent text + version bump. Real backend, not cosmetic: AiConsoleConfig persisted via config-manager (lib/ai/types.ts, ai-policy.json in the CONFIG dir). New GET/PUT /api/admin/ai/policy. Enforcement wired at every real chokepoint, not just the picker: /api/ai/server/chat checks classesEnabled.server and the model allow-list, /api/ai/retrieve checks retrievalEnabled, /api/ai/server/models filters by allow-list. GET /api/ai/policy folds classesEnabled into the classes list clients see. Resolved the spec's 3 open questions as recommended: BYOK allow-list stays client-side/advisory (wired into ai-assistant-settings.tsx's addProfile), tier picker stays cosmetic, master aiAssistantEnabled toggle stays in the existing Policy tab (this tab links to it instead of duplicating it). Defaults preserve today's behavior exactly (classesEnabled/allowlists all start empty/null) — turning this on changes nothing until an admin touches it.
93 lines
4.0 KiB
TypeScript
93 lines
4.0 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|
import { auditLog } from '@/lib/admin/audit';
|
|
import { logger } from '@/lib/logger';
|
|
import type { AiConsoleConfig, AiClass } from '@/lib/ai/types';
|
|
|
|
export const runtime = 'nodejs';
|
|
|
|
const VALID_CLASSES: AiClass[] = ['local', 'server', 'public'];
|
|
|
|
/**
|
|
* GET/PUT /api/admin/ai/policy - the admin console's writable config
|
|
* (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6): per-class enable, model/
|
|
* provider allow-lists, retrieval on/off, BYOK consent text. Separate from
|
|
* /api/admin/ai/entitlement (seats/ledger - runtime state) and from the
|
|
* generic /api/admin/policy (FeatureGates - the master aiAssistantEnabled
|
|
* toggle stays there, this console only links to it, per spec §6 open
|
|
* question 3).
|
|
*/
|
|
export async function GET(request: NextRequest) {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
|
|
try {
|
|
await configManager.ensureLoaded();
|
|
return NextResponse.json(configManager.getAiConsoleConfig(), { headers: { 'Cache-Control': 'no-store' } });
|
|
} catch (error) {
|
|
logger.error('ai console policy read error', { error: error instanceof Error ? error.message : String(error) });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|
|
|
|
function validate(body: Partial<AiConsoleConfig>): string | null {
|
|
if (body.classesEnabled !== undefined) {
|
|
if (typeof body.classesEnabled !== 'object' || body.classesEnabled === null) return 'classesEnabled must be an object';
|
|
for (const key of Object.keys(body.classesEnabled)) {
|
|
if (!VALID_CLASSES.includes(key as AiClass)) return `classesEnabled has an unknown class "${key}"`;
|
|
}
|
|
}
|
|
if (body.serverModelAllowlist !== undefined && body.serverModelAllowlist !== null) {
|
|
if (!Array.isArray(body.serverModelAllowlist) || !body.serverModelAllowlist.every((m) => typeof m === 'string')) {
|
|
return 'serverModelAllowlist must be an array of strings or null';
|
|
}
|
|
}
|
|
if (body.publicProviderAllowlist !== undefined && body.publicProviderAllowlist !== null) {
|
|
if (!Array.isArray(body.publicProviderAllowlist) || !body.publicProviderAllowlist.every((m) => typeof m === 'string')) {
|
|
return 'publicProviderAllowlist must be an array of strings or null';
|
|
}
|
|
}
|
|
if (body.retrievalEnabled !== undefined && typeof body.retrievalEnabled !== 'boolean') {
|
|
return 'retrievalEnabled must be a boolean';
|
|
}
|
|
if (body.consent !== undefined && body.consent !== null) {
|
|
if (typeof body.consent !== 'object' || typeof body.consent.version !== 'string' || typeof body.consent.text !== 'string') {
|
|
return 'consent must be { version: string, text: string } or null';
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export async function PUT(request: NextRequest) {
|
|
const result = await requireAdminAuth(request);
|
|
if ('error' in result) return result.error;
|
|
const ip = getClientIP(request);
|
|
|
|
let body: Partial<AiConsoleConfig>;
|
|
try {
|
|
body = await request.json();
|
|
} catch {
|
|
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
|
|
}
|
|
|
|
const validationError = validate(body);
|
|
if (validationError) return NextResponse.json({ error: validationError }, { status: 400 });
|
|
|
|
try {
|
|
await configManager.ensureLoaded();
|
|
const next = await configManager.setAiConsoleConfig(body);
|
|
await auditLog('ai.console_policy.update', {
|
|
classesEnabled: next.classesEnabled,
|
|
retrievalEnabled: next.retrievalEnabled,
|
|
consentVersion: next.consent?.version ?? null,
|
|
serverModelAllowlistCount: next.serverModelAllowlist?.length ?? null,
|
|
publicProviderAllowlistCount: next.publicProviderAllowlist?.length ?? null,
|
|
}, ip);
|
|
return NextResponse.json(next);
|
|
} catch (error) {
|
|
logger.error('ai console policy update error', { error: error instanceof Error ? error.message : String(error) });
|
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
|
}
|
|
}
|