/** * The shared settings-PUT body parsers — one seam per settings surface. * Extracted verbatim from the per-chat route (default-settings wave, * 2026-09-18): identical parse, identical errors. The section parsers * (settings-storage wave, 2026-09-19) follow the same house rules. * * Replace-whole semantics: the client sends the complete desired SECTION; * absent fields are absent overrides ({} clears everything). No merges, so * a stale client can never resurrect a cleared override. Unknown body keys * are dropped (same tolerance as parseSettingsBody) — unknown DISK keys are * a different concern and are preserved by the sanitizer (core/settings.ts). */ import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import { GRAPH_CONTEXT_TOKENS_MAX, isHexColor, type ChatSettings } from "../core"; import type { AgentSettings, AppearanceSettings, BackgroundModelRole, GeneralSettings, GraphSettings, RoleModelOverride, } from "../core/settings.ts"; import { BACKGROUND_MODEL_ROLES } from "../core/settings.ts"; import { TOGGLEABLE_TOOLS } from "../core/pending.ts"; /** Parsed-or-rejected — `ok: false` carries the 400's error message. */ export type SettingsBodyResult = | { ok: true; settings: T } | { ok: false; error: string }; export function parseSettingsBody(body: unknown): SettingsBodyResult { if (!body || typeof body !== "object" || Array.isArray(body)) { return { ok: false, error: "Invalid JSON body" }; } const raw = body as { model?: { provider?: unknown; id?: unknown }; thinkingLevel?: unknown; graphContextTokens?: unknown; }; const settings: ChatSettings = {}; if (raw.model !== undefined) { if ( typeof raw.model !== "object" || raw.model === null || typeof raw.model.provider !== "string" || typeof raw.model.id !== "string" ) { return { ok: false, error: "model must be {provider, id}" }; } settings.model = { provider: raw.model.provider, id: raw.model.id }; } if (raw.thinkingLevel !== undefined) { if (typeof raw.thinkingLevel !== "string") { return { ok: false, error: "thinkingLevel must be a string" }; } settings.thinkingLevel = raw.thinkingLevel as ThinkingLevel; } // Graph context injection budget (vision-context-injection.md): integer // tokens, 0 (off) to GRAPH_CONTEXT_TOKENS_MAX, no silent clamping — the // slider is the only writer, and it only ever sends multiples of the step. if (raw.graphContextTokens !== undefined) { if ( typeof raw.graphContextTokens !== "number" || !Number.isInteger(raw.graphContextTokens) || raw.graphContextTokens < 0 || raw.graphContextTokens > GRAPH_CONTEXT_TOKENS_MAX ) { return { ok: false, error: `graphContextTokens must be an integer between 0 and ${GRAPH_CONTEXT_TOKENS_MAX}`, }; } settings.graphContextTokens = raw.graphContextTokens; } return { ok: true, settings }; } // ─── Section parsers (settings-storage wave, 2026-09-19) ──────────────────── // One parser per settings.json section; the route maps the URL section to // its parser via SETTINGS_SECTIONS below. Same discipline as // parseSettingsBody: strict on every known field (a bad type/shape is a // 400, never a silent clamp/drop), unknown keys dropped. function asRecord(body: unknown): Record | null { if (!body || typeof body !== "object" || Array.isArray(body)) return null; return body as Record; } /** general: { autoUpdate?: boolean } */ export function parseGeneralSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: GeneralSettings = {}; if (raw.autoUpdate !== undefined) { if (typeof raw.autoUpdate !== "boolean") { return { ok: false, error: "autoUpdate must be a boolean" }; } settings.autoUpdate = raw.autoUpdate; } return { ok: true, settings }; } /** agent.models: only the six known background roles; each override's * fields mirror ChatSettings (model pair + independent thinkingLevel). */ function parseRoleOverride(value: unknown, role: string): { ok: true; override: RoleModelOverride } | { ok: false; error: string } { const raw = asRecord(value); if (!raw) return { ok: false, error: `${role} must be an object` }; const override: RoleModelOverride = {}; if (raw.model !== undefined) { if ( typeof raw.model !== "object" || raw.model === null || typeof (raw.model as Record).provider !== "string" || typeof (raw.model as Record).id !== "string" ) { return { ok: false, error: `${role}.model must be {provider, id}` }; } override.model = { provider: (raw.model as { provider: string }).provider, id: (raw.model as { id: string }).id, }; } if (raw.thinkingLevel !== undefined) { if (typeof raw.thinkingLevel !== "string") { return { ok: false, error: `${role}.thinkingLevel must be a string` }; } override.thinkingLevel = raw.thinkingLevel as ThinkingLevel; } return { ok: true, override }; } /** A boolean map (tools / prompts switches): every value must be boolean. */ function parseBooleanMap(value: unknown, field: string): { map?: Record; error?: string } { if (value === undefined) return {}; const raw = asRecord(value); if (!raw) return { error: `${field} must be an object` }; const out: Record = {}; for (const [key, entry] of Object.entries(raw)) { if (typeof entry !== "boolean") return { error: `${field}.${key} must be a boolean` }; out[key] = entry; } return { map: out }; } /** agent: { models?, tools?, prompts? } — model/thinking validity against * the catalog is checked by the route (validateChatSettings discipline); * prompt switch-on membership is checked by the route (needs the store). */ export function parseAgentSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: AgentSettings = {}; if (raw.models !== undefined) { const models = asRecord(raw.models); if (!models) return { ok: false, error: "models must be an object" }; const known = BACKGROUND_MODEL_ROLES as readonly string[]; const parsed: NonNullable = {}; for (const [role, value] of Object.entries(models)) { if (!known.includes(role)) { return { ok: false, error: `unknown role "${role}" (supported: ${known.join(", ")})` }; } const override = parseRoleOverride(value, role); if (!override.ok) return { ok: false, error: override.error }; if (Object.keys(override.override).length) parsed[role as BackgroundModelRole] = override.override; } if (Object.keys(parsed).length) settings.models = parsed; } const tools = parseBooleanMap(raw.tools, "tools"); if (tools.error) return { ok: false, error: tools.error }; if (tools.map) { // Unknown tool keys are rejected at WRITE time (user decision // 2026-09-19): the delta is inert while unread, but once the bridge // honors it a persisted `quiz: false` would genuinely drop the quiz — // and the definitive list is the adjudicated job-tools-only set. // The read-side sanitizer stays lenient for forward-compat. const supported = TOGGLEABLE_TOOLS as readonly string[]; for (const key of Object.keys(tools.map)) { if (!supported.includes(key)) { return { ok: false, error: `unknown tool "${key}" (supported: ${supported.join(", ")})` }; } } settings.tools = tools.map; } const prompts = parseBooleanMap(raw.prompts, "prompts"); if (prompts.error) return { ok: false, error: prompts.error }; if (prompts.map) settings.prompts = prompts.map; if (raw.search !== undefined) { const search = asRecord(raw.search); if (!search) return { ok: false, error: "search must be an object" }; if (search.engine !== undefined) { if (search.engine !== "tavily" && search.engine !== "brave") { return { ok: false, error: 'search.engine must be "tavily" or "brave"' }; } settings.search = { engine: search.engine }; } } return { ok: true, settings }; } /** A hex-or-null color field (appearance colors): null = default. */ function parseHexOrNull(value: unknown, field: string): { color?: string | null; error?: string } { if (value === null) return { color: null }; if (typeof value !== "string" || !isHexColor(value)) { return { error: `${field} must be a #hex color or null` }; } return { color: value }; } /** appearance: { mode?, background?, fontFamily?, proseFontSize?, * sessionNameSerif?, mdRendering? } */ export function parseAppearanceSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: AppearanceSettings = {}; if (raw.mode !== undefined) { if (raw.mode !== "dark" && raw.mode !== "light") { return { ok: false, error: "mode must be \"dark\" or \"light\"" }; } settings.mode = raw.mode; } if (raw.background !== undefined) { const parsed = parseHexOrNull(raw.background, "background"); if (parsed.error) return { ok: false, error: parsed.error }; settings.background = parsed.color ?? null; } if (raw.fontFamily !== undefined) { if (raw.fontFamily !== null && (typeof raw.fontFamily !== "string" || !raw.fontFamily.trim())) { return { ok: false, error: "fontFamily must be a non-empty string or null" }; } settings.fontFamily = raw.fontFamily as string | null; } if (raw.proseFontSize !== undefined) { if ( typeof raw.proseFontSize !== "number" || !Number.isInteger(raw.proseFontSize) || raw.proseFontSize < 12 || raw.proseFontSize > 24 ) { return { ok: false, error: "proseFontSize must be an integer between 12 and 24" }; } settings.proseFontSize = raw.proseFontSize; } if (raw.sessionNameSerif !== undefined) { if (typeof raw.sessionNameSerif !== "boolean") { return { ok: false, error: "sessionNameSerif must be a boolean" }; } settings.sessionNameSerif = raw.sessionNameSerif; } if (raw.mdRendering !== undefined) { const md = asRecord(raw.mdRendering); if (!md) return { ok: false, error: "mdRendering must be an object" }; const rendering: NonNullable = {}; if (md.headingWeight !== undefined) { if ( typeof md.headingWeight !== "number" || !Number.isInteger(md.headingWeight) || md.headingWeight < 100 || md.headingWeight > 900 ) { return { ok: false, error: "mdRendering.headingWeight must be an integer between 100 and 900" }; } rendering.headingWeight = md.headingWeight; } if (md.boldColor !== undefined) { const parsed = parseHexOrNull(md.boldColor, "mdRendering.boldColor"); if (parsed.error) return { ok: false, error: parsed.error }; rendering.boldColor = parsed.color ?? null; } if (md.italicColor !== undefined) { const parsed = parseHexOrNull(md.italicColor, "mdRendering.italicColor"); if (parsed.error) return { ok: false, error: parsed.error }; rendering.italicColor = parsed.color ?? null; } if (Object.keys(rendering).length) settings.mdRendering = rendering; } return { ok: true, settings }; } /** graph: the four-rung ladder { enabled?, embedding?, clustering?, labeling? }. */ export function parseGraphSettingsBody(body: unknown): SettingsBodyResult { const raw = asRecord(body); if (!raw) return { ok: false, error: "Invalid JSON body" }; const settings: GraphSettings = {}; for (const key of ["enabled", "embedding", "clustering", "labeling"] as const) { if (raw[key] !== undefined) { if (typeof raw[key] !== "boolean") { return { ok: false, error: `${key} must be a boolean` }; } settings[key] = raw[key]; } } return { ok: true, settings }; } /** The section→parser map for PUT /api/settings/[section] (the route also * carries the special prompts sub-resource). */ export const SETTINGS_SECTIONS = { general: parseGeneralSettingsBody, chat: parseSettingsBody, agent: parseAgentSettingsBody, appearance: parseAppearanceSettingsBody, graph: parseGraphSettingsBody, } as const; export type SettingsSectionName = keyof typeof SETTINGS_SECTIONS;