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): 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.publicPresets !== undefined) { if (!Array.isArray(body.publicPresets)) return 'publicPresets must be an array'; const ids = new Set(); for (const preset of body.publicPresets) { if ( typeof preset !== 'object' || preset === null || typeof preset.id !== 'string' || !preset.id || typeof preset.name !== 'string' || !preset.name || typeof preset.baseUrl !== 'string' || !preset.baseUrl || typeof preset.model !== 'string' || !preset.model || typeof preset.apiKeyEnvVar !== 'string' || !preset.apiKeyEnvVar ) { return 'each publicPresets entry needs non-empty id, name, baseUrl, model, apiKeyEnvVar'; } if (ids.has(preset.id)) return `duplicate publicPresets id "${preset.id}"`; ids.add(preset.id); } } 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; 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, publicPresetsCount: next.publicPresets.length, }, 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 }); } }