import { NextRequest, NextResponse } from 'next/server' import { requireRole } from '@/lib/auth' import { getDatabase, logAuditEvent } from '@/lib/db' import { config } from '@/lib/config' import { mutationLimiter } from '@/lib/rate-limit' import { validateBody, updateSettingsSchema } from '@/lib/validation' interface SettingRow { key: string value: string description: string | null category: string updated_by: string | null updated_at: number } // Default settings definitions (category, description, default value) const settingDefinitions: Record = { // Retention 'retention.activities_days': { category: 'retention', description: 'Days to keep activity records', default: String(config.retention.activities) }, 'retention.audit_log_days': { category: 'retention', description: 'Days to keep audit log entries', default: String(config.retention.auditLog) }, 'retention.logs_days': { category: 'retention', description: 'Days to keep log files', default: String(config.retention.logs) }, 'retention.notifications_days': { category: 'retention', description: 'Days to keep notifications', default: String(config.retention.notifications) }, 'retention.pipeline_runs_days': { category: 'retention', description: 'Days to keep pipeline run history', default: String(config.retention.pipelineRuns) }, 'retention.token_usage_days': { category: 'retention', description: 'Days to keep token usage data', default: String(config.retention.tokenUsage) }, 'retention.gateway_sessions_days': { category: 'retention', description: 'Days to keep inactive gateway session metadata', default: String(config.retention.gatewaySessions) }, // Gateway 'gateway.host': { category: 'gateway', description: 'Gateway hostname', default: config.gatewayHost }, 'gateway.port': { category: 'gateway', description: 'Gateway port number', default: String(config.gatewayPort) }, // Chat 'chat.coordinator_target_agent': { category: 'chat', description: 'Optional coordinator routing target (agent name or openclawId). When set, coordinator inbox messages are forwarded to this agent before default/main-session fallback.', default: '', }, // General 'general.site_name': { category: 'general', description: 'Mission Control display name', default: 'Mission Control' }, 'general.auto_cleanup': { category: 'general', description: 'Enable automatic data cleanup', default: 'false' }, 'general.auto_backup': { category: 'general', description: 'Enable automatic daily backups', default: 'false' }, 'general.backup_retention_count': { category: 'general', description: 'Number of backup files to keep', default: '10' }, // Subscription overrides 'subscription.plan_override': { category: 'general', description: 'Override auto-detected subscription plan (e.g. max, max_5x, pro)', default: '' }, 'subscription.codex_plan': { category: 'general', description: 'Codex/OpenAI subscription plan (e.g. chatgpt, plus, pro)', default: '' }, // Interface 'general.interface_mode': { category: 'general', description: 'Interface complexity (essential or full)', default: 'essential' }, // Onboarding 'onboarding.completed': { category: 'onboarding', description: 'Whether onboarding has been completed', default: 'false' }, 'onboarding.completed_at': { category: 'onboarding', description: 'Timestamp when onboarding was completed', default: '' }, 'onboarding.skipped': { category: 'onboarding', description: 'Whether onboarding was skipped', default: 'false' }, 'onboarding.completed_steps': { category: 'onboarding', description: 'JSON array of completed step IDs', default: '[]' }, 'onboarding.checklist_dismissed': { category: 'onboarding', description: 'Whether the onboarding checklist has been dismissed', default: 'false' }, } /** * GET /api/settings - List all settings (grouped by category) */ export async function GET(request: NextRequest) { const auth = requireRole(request, 'admin') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const db = getDatabase() const rows = db.prepare('SELECT * FROM settings ORDER BY category, key').all() as SettingRow[] const stored = new Map(rows.map(r => [r.key, r])) // Merge defaults with stored values const settings: Array<{ key: string value: string description: string category: string updated_by: string | null updated_at: number | null is_default: boolean }> = [] for (const [key, def] of Object.entries(settingDefinitions)) { const row = stored.get(key) settings.push({ key, value: row?.value ?? def.default, description: row?.description ?? def.description, category: row?.category ?? def.category, updated_by: row?.updated_by ?? null, updated_at: row?.updated_at ?? null, is_default: !row, }) } // Also include any custom settings not in definitions for (const row of rows) { if (!settingDefinitions[row.key]) { settings.push({ key: row.key, value: row.value, description: row.description ?? '', category: row.category, updated_by: row.updated_by, updated_at: row.updated_at, is_default: false, }) } } // Group by category const grouped: Record = {} for (const s of settings) { if (!grouped[s.category]) grouped[s.category] = [] grouped[s.category].push(s) } return NextResponse.json({ settings, grouped }) } /** * PUT /api/settings - Update one or more settings * Body: { settings: { key: value, ... } } */ export async function PUT(request: NextRequest) { const auth = requireRole(request, 'admin') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = mutationLimiter(request) if (rateCheck) return rateCheck const result = await validateBody(request, updateSettingsSchema) if ('error' in result) return result.error const body = result.data const db = getDatabase() const upsert = db.prepare(` INSERT INTO settings (key, value, description, category, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, unixepoch()) ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_by = excluded.updated_by, updated_at = unixepoch() `) const updated: string[] = [] const changes: Record = {} const txn = db.transaction(() => { for (const [key, value] of Object.entries(body.settings)) { const strValue = String(value) const def = settingDefinitions[key] const category = def?.category ?? 'custom' const description = def?.description ?? null // Get old value for audit const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined changes[key] = { old: existing?.value ?? null, new: strValue } upsert.run(key, strValue, description, category, auth.user.username) updated.push(key) } }) txn() // Audit log const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' logAuditEvent({ action: 'settings_update', actor: auth.user.username, actor_id: auth.user.id, detail: { updated_keys: updated, changes }, ip_address: ipAddress, }) return NextResponse.json({ updated, count: updated.length }) } /** * DELETE /api/settings?key=... - Reset a setting to default */ export async function DELETE(request: NextRequest) { const auth = requireRole(request, 'admin') if ('error' in auth) return NextResponse.json({ error: auth.error }, { status: auth.status }) const rateCheck = mutationLimiter(request) if (rateCheck) return rateCheck let body: any try { body = await request.json() } catch { return NextResponse.json({ error: 'Request body required' }, { status: 400 }) } const key = body.key if (!key) { return NextResponse.json({ error: 'key parameter required' }, { status: 400 }) } const db = getDatabase() const existing = db.prepare('SELECT value FROM settings WHERE key = ?').get(key) as { value: string } | undefined if (!existing) { return NextResponse.json({ error: 'Setting not found or already at default' }, { status: 404 }) } db.prepare('DELETE FROM settings WHERE key = ?').run(key) const ipAddress = request.headers.get('x-forwarded-for') || request.headers.get('x-real-ip') || 'unknown' logAuditEvent({ action: 'settings_reset', actor: auth.user.username, actor_id: auth.user.id, detail: { key, old_value: existing.value }, ip_address: ipAddress, }) return NextResponse.json({ reset: key, default_value: settingDefinitions[key]?.default ?? null }) }