import type { FastifyInstance } from 'fastify'; import { z } from 'zod'; import type { Sql } from '../db.js'; export async function getSetting( sql: Sql, key: string ): Promise { const rows = await sql<{ value: T }[]>`SELECT value FROM settings WHERE key = ${key}`; return rows[0]?.value ?? null; } export async function setSetting( sql: Sql, key: string, value: unknown ): Promise { await sql` INSERT INTO settings (key, value) VALUES (${key}, ${sql.json(value as never)}) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value `; } // themes-v1: whitelist of the 18 preset theme ids. Kept in sync with // docs/themes_v1.md ยง1 and apps/web/src/lib/theme.ts THEMES. const THEME_IDS = [ 'obsidian', 'gunmetal', 'espresso', 'volcanic-brown', 'copper', 'gold', 'oxblood', 'crimson', 'elderflower', 'plum', 'steel-pink', 'fuchsia-noir', 'matrix', 'sage', 'ivory', 'chalk', 'cobalt', 'midnight-sapphire', ] as const; const THEME_MODES = ['dark', 'light', 'system'] as const; // PATCH body is still a free-form key/value bag for everything except the // two theme keys, which carry strict per-key validation. Anything outside // THEME_IDS / THEME_MODES on those keys is rejected with 400. function validateThemeKeys(body: Record): string | null { if ('theme_id' in body) { const v = body.theme_id; if (typeof v !== 'string' || !(THEME_IDS as readonly string[]).includes(v)) { return `theme_id must be one of: ${THEME_IDS.join(', ')}`; } } if ('theme_mode' in body) { const v = body.theme_mode; if (typeof v !== 'string' || !(THEME_MODES as readonly string[]).includes(v)) { return `theme_mode must be one of: ${THEME_MODES.join(', ')}`; } } return null; } const PatchBody = z.record(z.string(), z.unknown()); export function registerSettingsRoutes(app: FastifyInstance, sql: Sql): void { app.get('/api/settings', async () => { const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`; const out: Record = {}; for (const r of rows) out[r.key] = r.value; return out; }); app.patch('/api/settings', async (req, reply) => { const parsed = PatchBody.safeParse(req.body); if (!parsed.success) { reply.code(400); return { error: 'invalid body', details: parsed.error.flatten() }; } const themeError = validateThemeKeys(parsed.data); if (themeError) { reply.code(400); return { error: themeError }; } for (const [k, v] of Object.entries(parsed.data)) { await setSetting(sql, k, v); } const rows = await sql<{ key: string; value: unknown }[]>`SELECT key, value FROM settings`; const out: Record = {}; for (const r of rows) out[r.key] = r.value; return out; }); }