Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions middleware/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -2446,6 +2447,18 @@ async function main(): Promise<void> {
);
}

// 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
Expand Down
4 changes: 4 additions & 0 deletions middleware/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ export function createAuthRouter(deps: AuthDeps): Router {
}
}
res.clearCookie(SESSION_COOKIE, { path: '/' });
// Also clear the non-secret UI-prefs cookie (1-year max-age). On a shared
// browser this stops the next user's first server paint from rendering the
// previous user's palette/theme until the client's getUiPrefs() corrects it.
res.clearCookie('omadia-ui-prefs', { path: '/' });

// Surface the IdP-side logout URL when the session was minted from an
// oidc-provider so the SPA can bounce the browser to it. For local
Expand Down
148 changes: 148 additions & 0 deletions middleware/src/routes/uiPrefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
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/<tenantId>/<userId>/<contextKey>/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 <html> 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<typeof UiPrefsSchema>;

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<UiPrefs> {
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 {
// `omadia_user_id` (the KG cluster id) is OPTIONAL on a live session — it is
// only set when resolveChannelIdentity resolves, which returns undefined in
// the eventual-consistency window of a new user's first OIDC login and on any
// KG hiccup. Falling back to `sub` (a required claim, guaranteed by
// requireAuth) keys the store on an id that is always present, so a live
// session never 401s here and the client is never bounced to /login. The 401
// then only fires for a genuinely session-less request.
const id = req.session?.omadia_user_id ?? req.session?.sub;
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<void> => {
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<void> => {
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;
}
12 changes: 9 additions & 3 deletions middleware/src/services/memoryPurge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,22 @@ 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',
'_brand',
'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';
Expand Down
Loading
Loading