From 103ccddec74f1fafdff564d481eb89bd2a8b4513 Mon Sep 17 00:00:00 2001 From: Sophie Neumann Date: Tue, 30 Jun 2026 15:08:04 +0200 Subject: [PATCH 1/2] feat(ui-prefs): persist Lume palette/appearance server-side per user (#287) --- middleware/src/index.ts | 13 ++ middleware/src/routes/uiPrefs.ts | 141 ++++++++++++++ middleware/src/services/memoryPurge.ts | 12 +- middleware/test/uiPrefsRoute.test.ts | 230 +++++++++++++++++++++++ web-ui/app/_components/ThemeControls.tsx | 119 ++++++++---- web-ui/app/_lib/api.ts | 37 ++++ web-ui/app/_lib/uiPrefs.ts | 57 ++++++ web-ui/app/layout.tsx | 26 ++- 8 files changed, 589 insertions(+), 46 deletions(-) create mode 100644 middleware/src/routes/uiPrefs.ts create mode 100644 middleware/test/uiPrefsRoute.test.ts create mode 100644 web-ui/app/_lib/uiPrefs.ts diff --git a/middleware/src/index.ts b/middleware/src/index.ts index 8df65766..59756490 100644 --- a/middleware/src/index.ts +++ b/middleware/src/index.ts @@ -250,6 +250,7 @@ import { } from './plugins/routines/index.js'; import { ROUTINES_INTEGRATION_SERVICE_NAME } from '@omadia/plugin-api'; import { createRoutinesRouter } from './routes/routines.js'; +import { createUiPrefsRouter } from './routes/uiPrefs.js'; import { ExpressRouteRegistry } from './channels/routeRegistry.js'; import { WebSocketRegistry } from './channels/webSocketRegistry.js'; import { createCoreApi } from './channels/coreApi.js'; @@ -2446,6 +2447,18 @@ async function main(): Promise { ); } + // Per-user UI preferences (issue #287) — server-side home for the Lume + // palette + appearance choice, backed by the MemoryStore. Replaces the + // per-browser localStorage from #284 with a cross-device store. + app.use( + '/api/v1/ui-prefs', + requireAuth, + createUiPrefsRouter({ store: memoryStore, log: (m) => console.log(m) }), + ); + console.log( + '[middleware] ui-prefs endpoints ready at /api/v1/ui-prefs (auth: required)', + ); + // `packageUploadService` is declared in the outer `main` scope so the // builder install endpoint (B.6-1) can reference it. When PACKAGE_UPLOAD_- // ENABLED is false the variable stays null and the install route is omitted diff --git a/middleware/src/routes/uiPrefs.ts b/middleware/src/routes/uiPrefs.ts new file mode 100644 index 00000000..05ee701e --- /dev/null +++ b/middleware/src/routes/uiPrefs.ts @@ -0,0 +1,141 @@ +import { Router } from 'express'; +import type { Request, Response } from 'express'; +import { z } from 'zod'; + +import type { MemoryStore } from '@omadia/plugin-api'; + +/** + * Per-user UI preferences — the server-side home for the Lume palette + + * appearance choice (issue #287, visual-spec §2.5.4). + * + * §2.5.4 binds the accent palette per + * `memory://ui-prefs////accent`. The first Lume + * integration (#284) parked the palette + appearance choice in localStorage — + * per browser, no multi-device sync. This router moves it into the MemoryStore + * so the choice follows the user across devices. The operator UI is a single + * context today, so `tenantId` and `contextKey` collapse to the constants + * `default` / `operator` (per the ticket: "contextKey can collapse to a single + * operator context for now"). + * + * The web-ui mirrors the value into a non-secret cookie so the pre-paint + * bootstrap can set `data-palette`/`data-theme` on with no FOUC; this + * store stays the source of truth that seeds that cookie on a fresh device. + * + * Mounted under `/api/v1/ui-prefs`, gated by `requireAuth`. + * GET / → current prefs ({} when none stored yet) + * PUT / → upsert { palette?, appearance? } + */ + +const PALETTES = ['lagoon', 'petrol', 'atelier'] as const; +const APPEARANCES = ['system', 'light', 'dark'] as const; + +const UiPrefsSchema = z + .object({ + palette: z.enum(PALETTES).optional(), + appearance: z.enum(APPEARANCES).optional(), + }) + .strict(); + +export type UiPrefs = z.infer; + +export interface UiPrefsRouterDeps { + store: MemoryStore; + log?: (msg: string) => void; +} + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** Map a session user id onto a path-safe MemoryStore segment. `omadia_user_id` + * is a KG cluster id, but escaping keeps the path valid regardless of the id + * shape the provider mints. The escaping is INJECTIVE — every char outside + * `[A-Za-z0-9-]`, including `.` and `_` themselves, becomes `_<4-hex code + * unit>` — so (a) two distinct ids can never collapse onto the same path + * (which would leak one user's prefs to another), and (b) no segment can ever + * be `.` or `..`, so a `..` id can't traverse out of the per-user directory. */ +function safeSegment(id: string): string { + return id.replace( + /[^A-Za-z0-9-]/g, + (c) => `_${c.charCodeAt(0).toString(16).padStart(4, '0')}`, + ); +} + +function prefsPath(userId: string): string { + return `/memories/ui-prefs/default/${safeSegment(userId)}/operator.json`; +} + +/** Read + validate the stored prefs, or `{}` when absent. A stored value that + * is corrupt (non-JSON), pre-dates a schema change, or got hand-edited is + * treated as unset rather than surfaced as an error — the client falls back + * to its defaults, and crucially a corrupt file does not brick the read-merge + * PUT path (which would otherwise 500 on every write, leaving no way to + * overwrite the bad value). Real IO errors still propagate to the caller. */ +async function readPrefs(store: MemoryStore, path: string): Promise { + if (!(await store.fileExists(path))) return {}; + const raw = await store.readFile(path); + try { + const parsed = UiPrefsSchema.safeParse(JSON.parse(raw)); + return parsed.success ? parsed.data : {}; + } catch { + return {}; + } +} + +function requireSessionUserId(req: Request, res: Response): string | null { + const id = req.session?.omadia_user_id; + if (!id) { + res.status(401).json({ code: 'auth.required', message: 'login required' }); + return null; + } + return id; +} + +export function createUiPrefsRouter(deps: UiPrefsRouterDeps): Router { + const router = Router(); + const log = deps.log ?? ((m) => console.log(m)); + + router.get('/', async (req: Request, res: Response): Promise => { + const userId = requireSessionUserId(req, res); + if (!userId) return; + try { + res.json(await readPrefs(deps.store, prefsPath(userId))); + } catch (err) { + // Log the detail; return a generic message so an internal store error + // (paths, driver internals) is not echoed back to the client. + log(`[ui-prefs/route] GET / failed: ${errMsg(err)}`); + res + .status(500) + .json({ code: 'ui_prefs.read_failed', message: 'failed to read ui prefs' }); + } + }); + + router.put('/', async (req: Request, res: Response): Promise => { + const userId = requireSessionUserId(req, res); + if (!userId) return; + const parsed = UiPrefsSchema.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ + code: 'ui_prefs.invalid_request', + issues: parsed.error.issues, + }); + return; + } + try { + // MERGE, not replace: the fields are individually optional, so a PUT of + // `{ palette }` must leave a previously stored `appearance` intact (and + // vice versa) rather than silently dropping the other key. + const path = prefsPath(userId); + const next = { ...(await readPrefs(deps.store, path)), ...parsed.data }; + await deps.store.writeFile(path, JSON.stringify(next)); + res.status(204).end(); + } catch (err) { + log(`[ui-prefs/route] PUT / failed: ${errMsg(err)}`); + res + .status(500) + .json({ code: 'ui_prefs.write_failed', message: 'failed to write ui prefs' }); + } + }); + + return router; +} diff --git a/middleware/src/services/memoryPurge.ts b/middleware/src/services/memoryPurge.ts index 7f3e54ff..83d46604 100644 --- a/middleware/src/services/memoryPurge.ts +++ b/middleware/src/services/memoryPurge.ts @@ -25,9 +25,10 @@ import type { MemoryStore } from '@omadia/plugin-api'; export type MemoryPurgeAxis = 'all' | 'agent' | 'user' | 'team' | 'channel'; /** - * Top-level `/memories/...` entries that hold seed / shared kernel data. - * Protected from `axis: 'all'` unless `reseed` is requested. Stored as the - * leaf entry names (the segment directly under `/memories`). + * Top-level `/memories/...` entries that hold seed / shared kernel data, plus + * durable per-user settings that a scratch purge must not wipe. Protected from + * `axis: 'all'` unless `reseed` is requested. Stored as the leaf entry names + * (the segment directly under `/memories`). */ export const PROTECTED_SEED_ENTRIES: readonly string[] = [ '_rules', @@ -35,6 +36,11 @@ export const PROTECTED_SEED_ENTRIES: readonly string[] = [ 'core', 'sessions', 'chat-sessions', + // Per-user UI preferences (Lume palette/appearance, issue #287). Not seed + // data, but a durable cross-device user setting — a Danger-Zone scratch + // purge should not silently reset every operator's palette. A full `reseed` + // purge still clears it (the explicit "wipe everything" path). + 'ui-prefs', ]; const MEMORIES_ROOT = '/memories'; diff --git a/middleware/test/uiPrefsRoute.test.ts b/middleware/test/uiPrefsRoute.test.ts new file mode 100644 index 00000000..8f2af68c --- /dev/null +++ b/middleware/test/uiPrefsRoute.test.ts @@ -0,0 +1,230 @@ +import { strict as assert } from 'node:assert'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { describe, it } from 'node:test'; + +import express from 'express'; +import type { Request, Response, NextFunction } from 'express'; + +import { InMemoryMemoryStore } from '@omadia/memory'; + +import { createUiPrefsRouter } from '../src/routes/uiPrefs.js'; + +/** + * HTTP integration test for the per-user UI-prefs router (issue #287), + * mounted in prod at `/api/v1/ui-prefs` behind `requireAuth`. Drives the REAL + * router end-to-end over an express `listen(0)` server with a real + * `InMemoryMemoryStore` (same MemoryStore contract as prod). `requireAuth` + * runs at MOUNT time in prod, not inside the router, so the harness injects a + * `req.session` with the user id the router reads (or omits it to exercise the + * router's own 401 guard). + */ + +const MOUNT = '/api/v1/ui-prefs'; + +interface Harness { + baseUrl: string; + store: InMemoryMemoryStore; + close: () => Promise; +} + +async function makeHarness( + userId: string | null, + sharedStore?: InMemoryMemoryStore, +): Promise { + const store = sharedStore ?? new InMemoryMemoryStore(); + + const app = express(); + app.use(express.json()); + if (userId) { + app.use((req: Request, _res: Response, next: NextFunction) => { + (req as unknown as { session: { omadia_user_id: string } }).session = { + omadia_user_id: userId, + }; + next(); + }); + } + app.use(MOUNT, createUiPrefsRouter({ store, log: () => {} })); + + const server: Server = app.listen(0); + await new Promise((resolve) => server.once('listening', resolve)); + const { port } = server.address() as AddressInfo; + + return { + baseUrl: `http://127.0.0.1:${String(port)}${MOUNT}`, + store, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + }, + }; +} + +async function req( + url: string, + method: 'GET' | 'PUT', + body?: unknown, +): Promise<{ status: number; body: Record }> { + const res = await fetch(url, { + method, + headers: { 'content-type': 'application/json' }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + const text = await res.text(); + let parsed: Record; + try { + parsed = text ? (JSON.parse(text) as Record) : {}; + } catch { + parsed = { raw: text }; + } + return { status: res.status, body: parsed }; +} + +describe('ui-prefs router', () => { + it('returns {} when the user has no stored prefs', async () => { + const h = await makeHarness('user-1'); + try { + const res = await req(h.baseUrl, 'GET'); + assert.equal(res.status, 200); + assert.deepEqual(res.body, {}); + } finally { + await h.close(); + } + }); + + it('round-trips a PUT then GET, scoped per user', async () => { + const h = await makeHarness('user-1'); + try { + const put = await req(h.baseUrl, 'PUT', { + palette: 'petrol', + appearance: 'dark', + }); + assert.equal(put.status, 204); + + const get = await req(h.baseUrl, 'GET'); + assert.equal(get.status, 200); + assert.deepEqual(get.body, { palette: 'petrol', appearance: 'dark' }); + + // Stored under the collapsed default/operator scope for this user. + const raw = await h.store.readFile( + '/memories/ui-prefs/default/user-1/operator.json', + ); + assert.deepEqual(JSON.parse(raw), { palette: 'petrol', appearance: 'dark' }); + } finally { + await h.close(); + } + }); + + it('accepts a partial PUT (palette only)', async () => { + const h = await makeHarness('user-1'); + try { + const put = await req(h.baseUrl, 'PUT', { palette: 'atelier' }); + assert.equal(put.status, 204); + const get = await req(h.baseUrl, 'GET'); + assert.deepEqual(get.body, { palette: 'atelier' }); + } finally { + await h.close(); + } + }); + + it('merges a partial PUT into the stored prefs (does not drop the other key)', async () => { + const h = await makeHarness('user-1'); + try { + let put = await req(h.baseUrl, 'PUT', { + palette: 'petrol', + appearance: 'dark', + }); + assert.equal(put.status, 204); + + // A palette-only PUT must leave the stored appearance intact. + put = await req(h.baseUrl, 'PUT', { palette: 'atelier' }); + assert.equal(put.status, 204); + + const get = await req(h.baseUrl, 'GET'); + assert.deepEqual(get.body, { palette: 'atelier', appearance: 'dark' }); + } finally { + await h.close(); + } + }); + + it('isolates collision-prone ids on a shared store', async () => { + // `safeSegment` must escape injectively: "a b" and "a_b" are distinct ids + // that must not collapse onto the same storage path. Both users share ONE + // store so a path collision would actually surface as cross-user reads. + const store = new InMemoryMemoryStore(); + const a = await makeHarness('a b', store); + const b = await makeHarness('a_b', store); + try { + assert.equal((await req(a.baseUrl, 'PUT', { palette: 'petrol' })).status, 204); + assert.equal((await req(b.baseUrl, 'PUT', { palette: 'atelier' })).status, 204); + assert.deepEqual((await req(a.baseUrl, 'GET')).body, { palette: 'petrol' }); + assert.deepEqual((await req(b.baseUrl, 'GET')).body, { palette: 'atelier' }); + } finally { + await a.close(); + await b.close(); + } + }); + + it('rejects an invalid palette or unknown key with 400', async () => { + const h = await makeHarness('user-1'); + try { + const bad = await req(h.baseUrl, 'PUT', { palette: 'neon' }); + assert.equal(bad.status, 400); + assert.equal(bad.body['code'], 'ui_prefs.invalid_request'); + + const extra = await req(h.baseUrl, 'PUT', { palette: 'lagoon', x: 1 }); + assert.equal(extra.status, 400); + } finally { + await h.close(); + } + }); + + it("contains a '..' user id inside the per-user dir (no traversal)", async () => { + const store = new InMemoryMemoryStore(); + const h = await makeHarness('..', store); + try { + assert.equal((await req(h.baseUrl, 'PUT', { palette: 'petrol' })).status, 204); + // The '..' must be escaped, NOT left as a real parent-dir segment that + // would land the file at /memories/ui-prefs/operator.json. + assert.equal( + await store.fileExists('/memories/ui-prefs/operator.json'), + false, + ); + assert.deepEqual((await req(h.baseUrl, 'GET')).body, { palette: 'petrol' }); + } finally { + await h.close(); + } + }); + + it('recovers from a corrupt stored value: PUT overwrites, not 500s', async () => { + const store = new InMemoryMemoryStore(); + const h = await makeHarness('user-1', store); + try { + // Simulate a hand-edited / partially-written file at the user's path. + await store.writeFile( + '/memories/ui-prefs/default/user-1/operator.json', + '{ this is not json', + ); + // GET treats corrupt as unset rather than erroring. + const get = await req(h.baseUrl, 'GET'); + assert.equal(get.status, 200); + assert.deepEqual(get.body, {}); + // The read-merge PUT must still succeed (corrupt value can't brick writes). + const put = await req(h.baseUrl, 'PUT', { palette: 'lagoon' }); + assert.equal(put.status, 204); + assert.deepEqual((await req(h.baseUrl, 'GET')).body, { palette: 'lagoon' }); + } finally { + await h.close(); + } + }); + + it('401s when there is no session', async () => { + const h = await makeHarness(null); + try { + const res = await req(h.baseUrl, 'GET'); + assert.equal(res.status, 401); + assert.equal(res.body['code'], 'auth.required'); + } finally { + await h.close(); + } + }); +}); diff --git a/web-ui/app/_components/ThemeControls.tsx b/web-ui/app/_components/ThemeControls.tsx index 9b7cce18..ed742ba1 100644 --- a/web-ui/app/_components/ThemeControls.tsx +++ b/web-ui/app/_components/ThemeControls.tsx @@ -2,37 +2,49 @@ import { Moon, Palette, Sun } from 'lucide-react'; import { useTranslations } from 'next-intl'; -import { useSyncExternalStore } from 'react'; +import { useEffect, useRef, useSyncExternalStore } from 'react'; + +import { getUiPrefs, putUiPrefs } from '../_lib/api'; +import { + APPEARANCES, + PALETTES, + UI_PREFS_COOKIE, + isAppearance, + isPalette, + type Appearance, + type PaletteName, +} from '../_lib/uiPrefs'; /** - * Lume palette + appearance controls (issue #282). + * Lume palette + appearance controls (issue #282, server-side store #287). * * Palette binds one of the three Lume accent palettes (Petrol, Atelier, * Lagoon — spec §2.5) to the single accent slot via `data-palette` on . * Appearance pins light/dark (or follows the OS) via `data-theme`, which flips * the `color-scheme` that the token layer's `light-dark()` resolves against. * - * The attributes are the single source of truth: the pre-paint - * bootstrap script in layout.tsx seeds them from localStorage before first - * paint (no FOUC), and the selects read them via useSyncExternalStore — so - * SSR renders the defaults and React reconciles to the real value right - * after hydration (no suppressed-mismatch staleness). + * The attributes are the single source of truth: the RSC layout seeds + * them from the `omadia-ui-prefs` cookie before first paint (no FOUC), and the + * selects read them via useSyncExternalStore — so SSR renders the cookie value + * and React reconciles after hydration (no suppressed-mismatch staleness). + * + * Persistence (§2.5.4): the choice lives in a per-user server store + * (/api/v1/ui-prefs). On change we apply the attribute live, mirror it into + * the cookie (for the next pre-paint), and PUT it to the store. On mount we + * re-read the store to seed/correct the cookie on a fresh device. */ -const PALETTES = ['lagoon', 'petrol', 'atelier'] as const; -type PaletteName = (typeof PALETTES)[number]; - -const THEMES = ['system', 'light', 'dark'] as const; -type Theme = (typeof THEMES)[number]; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 365; // 1 year — a stable UI preference. -const PALETTE_KEY = 'omadia-palette'; -const THEME_KEY = 'omadia-theme'; - -function isPalette(v: string | null): v is PaletteName { - return v === 'lagoon' || v === 'petrol' || v === 'atelier'; -} -function isTheme(v: string | null): v is Theme { - return v === 'system' || v === 'light' || v === 'dark'; +/** Mirror the choice into the non-secret cookie the RSC layout reads pre-paint. + * Lax + path=/ so it rides every same-site navigation; not httpOnly because + * it carries no secret and the client both writes and (via SSR) consumes it. + * `Secure` over HTTPS so it is never sent on a downgraded plain-HTTP request; + * omitted on http (localhost dev) where browsers reject Secure cookies. */ +function writeUiPrefsCookie(palette: PaletteName, appearance: Appearance): void { + const value = encodeURIComponent(JSON.stringify({ palette, appearance })); + const secure = window.location.protocol === 'https:' ? ';secure' : ''; + document.cookie = `${UI_PREFS_COOKIE}=${value};path=/;max-age=${COOKIE_MAX_AGE};samesite=lax${secure}`; } /** Re-render whenever the theme attributes change. */ @@ -49,9 +61,9 @@ function readPalette(): PaletteName { const v = document.documentElement.getAttribute('data-palette'); return isPalette(v) ? v : 'lagoon'; } -function readTheme(): Theme { +function readTheme(): Appearance { const v = document.documentElement.getAttribute('data-theme'); - return isTheme(v) ? v : 'system'; + return isAppearance(v) ? v : 'system'; } const selectClass = @@ -64,6 +76,51 @@ export function ThemeControls(): React.ReactElement { const palette = useSyncExternalStore(subscribeToRootAttrs, readPalette, () => 'lagoon' as const); const theme = useSyncExternalStore(subscribeToRootAttrs, readTheme, () => 'system' as const); + // Guards the mount hydration against a race: if the user picks a palette/ + // appearance before the in-flight GET resolves, the stale server value must + // NOT clobber their fresh choice. Set on the first user change; checked when + // the GET lands. A ref (not state) so it never triggers a re-render. + const userTouched = useRef(false); + + // Hydrate from the server store on mount: the cookie/SSR value may be stale + // (or absent on a fresh device). Apply the stored choice to — the + // MutationObserver re-renders the selects — and refresh the cookie so the + // next pre-paint on this device matches. No PUT here: the store is already + // the source. Stays silent when logged out / offline (cookie value holds), + // or when the user has already made a choice this session (their PUT wins). + useEffect(() => { + let cancelled = false; + void getUiPrefs() + .then((p) => { + if (cancelled || userTouched.current) return; + const root = document.documentElement; + const nextPalette = isPalette(p.palette) ? p.palette : readPalette(); + const nextTheme = isAppearance(p.appearance) ? p.appearance : readTheme(); + if (nextPalette !== readPalette()) root.setAttribute('data-palette', nextPalette); + if (nextTheme === 'system') root.removeAttribute('data-theme'); + else if (nextTheme !== readTheme()) root.setAttribute('data-theme', nextTheme); + writeUiPrefsCookie(nextPalette, nextTheme); + }) + .catch(() => { + /* unauthenticated / offline — keep the cookie-seeded values */ + }); + return () => { + cancelled = true; + }; + }, []); + + /** Mirror the current choice into the cookie + per-user server store. */ + function persist(nextPalette: PaletteName, nextTheme: Appearance): void { + userTouched.current = true; + writeUiPrefsCookie(nextPalette, nextTheme); + void putUiPrefs({ palette: nextPalette, appearance: nextTheme }).catch(() => { + /* Best-effort cross-device sync. On a network/5xx error the cookie + live + * attribute already applied, so the choice holds for this session. A 401 + * is the exception: putUiPrefs → maybeNavigateToLogin bounces to /login + * before this catch runs (the session is dead, re-auth is required). */ + }); + } + function applyPalette(next: PaletteName): void { const root = document.documentElement; // §6.6: palette changes crossfade over motion.smooth. The transient class @@ -71,25 +128,17 @@ export function ThemeControls(): React.ReactElement { root.classList.add('lume-xfade'); root.setAttribute('data-palette', next); window.setTimeout(() => root.classList.remove('lume-xfade'), 280); - try { - localStorage.setItem(PALETTE_KEY, next); - } catch { - /* storage unavailable — selection still applies for this session */ - } + persist(next, readTheme()); } - function applyTheme(next: Theme): void { + function applyTheme(next: Appearance): void { const root = document.documentElement; if (next === 'system') { root.removeAttribute('data-theme'); } else { root.setAttribute('data-theme', next); } - try { - localStorage.setItem(THEME_KEY, next); - } catch { - /* storage unavailable */ - } + persist(readPalette(), next); } return ( @@ -126,10 +175,10 @@ export function ThemeControls(): React.ReactElement { )}