nyk
fix: dynamic coordinator routing + boxed doctor parsing (#279)
cd054b5 unverified
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<string, { category: string; description: string; default: string }> = {
// 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<string, typeof settings> = {}
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<string, { old: string | null; new: string }> = {}
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 })
}