feat(admin): build the AI Policy console (§6) — approved, spec now implemented

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.
This commit is contained in:
Bernd Rodler
2026-08-06 08:48:30 +02:00
parent 61651b1ed1
commit 30e5059b94
12 changed files with 591 additions and 5 deletions
+29
View File
@@ -3,6 +3,7 @@ import { logger } from '@/lib/logger';
import { readFileEnv } from '@/lib/read-file-env';
import { CONFIG_ENV_MAP, DEFAULT_FEATURE_GATES, DEFAULT_POLICY, DEFAULT_THEME_POLICY, type SettingsPolicy } from './types';
import { ensureConfigDir, getConfigPath, assertWritable } from './paths';
import { DEFAULT_AI_CONSOLE_CONFIG, type AiConsoleConfig } from '@/lib/ai/types';
function parseEnvValue(value: string, type: string): unknown {
switch (type) {
@@ -26,6 +27,7 @@ function parseEnvValue(value: string, type: string): unknown {
class ConfigManager {
private adminConfig: Record<string, unknown> = {};
private policyCache: SettingsPolicy = { ...DEFAULT_POLICY };
private aiConsoleConfigCache: AiConsoleConfig = { ...DEFAULT_AI_CONSOLE_CONFIG };
private loaded = false;
/** Load admin config and policy from disk. Called once at startup and on reload. */
@@ -42,6 +44,12 @@ class ConfigManager {
} else {
this.policyCache = { ...DEFAULT_POLICY };
}
const aiConsoleConfig = await this.readJsonFile('ai-policy.json');
this.aiConsoleConfigCache = {
...DEFAULT_AI_CONSOLE_CONFIG,
...aiConsoleConfig,
classesEnabled: { ...DEFAULT_AI_CONSOLE_CONFIG.classesEnabled, ...(aiConsoleConfig?.classesEnabled as object | undefined) },
} as AiConsoleConfig;
this.loaded = true;
logger.debug('ConfigManager loaded', { configKeys: Object.keys(this.adminConfig).length });
}
@@ -175,6 +183,27 @@ class ConfigManager {
await this.writeJsonFile('policy.json', this.policyCache as unknown as Record<string, unknown>);
}
/**
* Get the current AI console config (docs/ADMIN-AI-POLICY-CONSOLE-SPEC.md §6).
*/
getAiConsoleConfig(): AiConsoleConfig {
return this.aiConsoleConfigCache;
}
/**
* Update the AI console config. Writes to disk.
*/
async setAiConsoleConfig(config: Partial<AiConsoleConfig>): Promise<AiConsoleConfig> {
assertWritable('update AI console config');
this.aiConsoleConfigCache = {
...this.aiConsoleConfigCache,
...config,
classesEnabled: { ...this.aiConsoleConfigCache.classesEnabled, ...(config.classesEnabled || {}) },
};
await this.writeJsonFile('ai-policy.json', this.aiConsoleConfigCache as unknown as Record<string, unknown>);
return this.aiConsoleConfigCache;
}
/**
* Migrates deprecated feature gates forward. The standalone "All Mail" view
* (`allMailViewEnabled`) was folded into the unified "All mail" entry, so an