openvibers/openvibe.network Commits
Themes with per-site exceptions: one theme everywhere, an override per service (#419)
Changes
@@@ -18,6 +18,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA- **Anonymous Users** — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.- **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.++- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).@@@ -782,7 +783,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:MIT (`license` in [package.json](package.json)).<!-- versions:start -->--- openvibe-contracts: v0.238.0++- openvibe-contracts: v0.232.0- openvibe-sdk: v0.67.0--- openvibe-shared: v4.39.0++- openvibe-shared: v4.33.0<!-- versions:end -->nnew file mode 100644@@@ -0,0 +1,14 @@++-- phase: expand++-- Per-site theme overrides (the design overhaul, pillar A): one theme follows a person to every OpenVibe site, and++-- this table holds their exceptions ("Games in Neon Tokyo", "Live in Daylight"). One row per person and service; the++-- person's global theme stays in user_preferences, so an older release (and every site that asks for no service)++-- keeps reading the same rows it always did.++CREATE TABLE IF NOT EXISTS user_theme_site_overrides (++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,++ service text COLLATE "C" NOT NULL CHECK (service ~ '^[a-z][a-z0-9-]{1,31}$'),++ theme_id text COLLATE "C" NOT NULL,++ custom_variables jsonb,++ updated_at text COLLATE "C" DEFAULT ov_now(),++ PRIMARY KEY (user_id, service)++);++CREATE INDEX IF NOT EXISTS user_theme_site_overrides_service_idx ON user_theme_site_overrides (service);@@@ -184,6 +184,7 @@ async function networkPart(db, userId) {effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,++ theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,});await byUser('history.json', 'user_history');add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);@@@ -337,7 +338,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications','notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',-- 'ip_log', 'email_delivery_log', 'user_theme_history']) await del(t, `user_id IN ${inU}`, userIds);++ 'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);// Notifications this person sent others stay, without their name or picture.if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);@@@ -106,6 +106,25 @@ router.get('/seasons', (req, res) => {res.set('Cache-Control', 'public, max-age=3600').json({ seasons: list, now: now ? { id: now.id, name: now.name, theme: now.theme, ...(now.light ? { light: now.light } : {}) } : null });});++// ── Per-site theme overrides (the design overhaul, pillar A) ──++// One theme follows a person to every site; an override is their exception for one service ("Games in Neon Tokyo",++// "Live in Daylight"). The service id is the one the sites use: lower case, digits and hyphens.++const SERVICE_ID = /^[a-z][a-z0-9-]{1,31}$/;++const MAX_OVERRIDES = 50;++const serviceId = (s) => (typeof s === 'string' && SERVICE_ID.test(s) ? s : null);++/** A theme's custom variables as they are stored: jsonb in the overrides, text everywhere else. */++const varsOf = (v) => {++ if (v == null) return null;++ if (typeof v === 'string') { try { return JSON.parse(v); } catch { return null; } }++ return v;++};++/** The person's override for one service, or null. */++async function overrideOf(db, userId, service) {++ try {++ return await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service) || null;++ } catch { return null; /* the table arrives with migration 0028 */ }++}++// ── Get User's Active Theme ──────────────────────────────────// A request with no credentials at all is a guest asking "do I have a theme?": answer "no" with// a 200 so every page view by a guest does not log a failed request in the console.@@@ -116,14 +135,20 @@ router.get('/me/active', (req, res, next) => {}, requireAuth, async (req, res) => {const db = await getDb(req);const userId = req.user.sub || req.user.id;++ // A site asks for its own look ("Games in Neon Tokyo"): the person's override for that service, when they have one.++ // A missing or unknown service answers exactly as it always did.++ const service = serviceId(req.query.service);++ const override = service ? await overrideOf(db, userId, service) : null;++const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);-- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });++ let display = null;++ try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }++ if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, override: true, service });++ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false });let custom = null;try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}-- let display = null;-- try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }-- res.json({ theme_id: prefs.theme_id, custom_variables: custom, display });++ res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, override: false });});// ── Display preferences (motion, text size) — follow the account to every site ──@@@ -220,10 +245,80 @@ router.put('/me', requireAuth, async (req, res) => {res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });});++// ── Per-site theme overrides ─────────────────────────────────++// The person's own list, one theme per service. A site asks for its service id (GET /me/active?service=games) and++// gets that theme; every other site, and every service without an override, keeps the person's global theme.++router.get('/me/overrides', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const userId = req.user.sub || req.user.id;++ const rows = await db.prepare('SELECT service, theme_id, custom_variables, updated_at FROM user_theme_site_overrides WHERE user_id = ? ORDER BY service').all(userId);++ const names = new Map();++ for (const id of [...new Set(rows.map((r) => r.theme_id))]) { const t = await db.prepare('SELECT name FROM themes WHERE id = ?').get(id); if (t) names.set(id, t.name); }++ res.set('Cache-Control', 'private, no-store').json({++ overrides: rows.map((r) => ({ service: r.service, theme_id: r.theme_id, theme_name: names.get(r.theme_id) || null, custom_variables: varsOf(r.custom_variables), updated_at: r.updated_at })),++ });++});++++// Set one site's theme. The theme is validated exactly as PUT /me does it: a theme the person may not see is a 404,++// and custom colours are the same allow-list.++router.put('/me/overrides/:service', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const userId = req.user.sub || req.user.id;++ const service = serviceId(req.params.service);++ if (!service) return res.status(400).json({ error: 'service must be a service id like live or games', code: 'theme.bad_service' });++ const { theme_id, custom_variables } = req.body || {};++ if (!theme_id) return res.status(400).json({ error: 'theme_id is required', code: 'theme.missing_theme' });++++ ensureReview(db);++ const theme = await db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);++ if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });++ let vars = null;++ if (custom_variables) {++ const v = cleanVariables(custom_variables);++ if (!v.ok) return res.status(422).json({ error: 'Custom colours rejected', code: 'theme.invalid_variables', errors: v.errors });++ vars = v.variables;++ }++++ const existing = await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service);++ if (!existing) {++ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = ?').get(userId)).n;++ if (n >= MAX_OVERRIDES) return res.status(429).json({ error: `You have ${n} site overrides; at most ${MAX_OVERRIDES} are kept`, code: 'theme.too_many_overrides' });++ }++++ // Keep what this change replaces for that site, as every other theme change does, with the service recorded.++ const replaced = existing || (await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId));++ if (replaced && replaced.theme_id) await recordHistory(db, userId, { theme_id: replaced.theme_id, custom: existing ? existing.custom_variables : replaced.custom_theme_variables }, service);++++ await db.prepare(`INSERT INTO user_theme_site_overrides (user_id, service, theme_id, custom_variables, updated_at)++ VALUES (?, ?, ?, ?::jsonb, ov_now())++ ON CONFLICT(user_id, service) DO UPDATE SET theme_id = excluded.theme_id, custom_variables = excluded.custom_variables, updated_at = ov_now()`)++ .run(userId, service, theme_id, vars ? JSON.stringify(vars) : null);++ res.json({ success: true, service, theme_id, custom_variables: vars, override: true });++});++++// Remove one site's override: that site falls back to the person's global theme again.++router.delete('/me/overrides/:service', requireAuth, async (req, res) => {++ const db = await getDb(req);++ const userId = req.user.sub || req.user.id;++ const service = serviceId(req.params.service);++ if (!service) return res.status(400).json({ error: 'service must be a service id like live or games', code: 'theme.bad_service' });++ const existing = await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service);++ if (!existing) return res.status(404).json({ error: 'No override for that service', code: 'theme.override_not_found' });++ // The override that is gone is kept in the history, with the service recorded, like any other theme change.++ await recordHistory(db, userId, { theme_id: existing.theme_id, custom: existing.custom_variables }, service);++ await db.prepare('DELETE FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').run(userId, service);++ res.json({ success: true, service, deleted: true });++});++// ── History and rollback (plan 2026-10-28 §5.2) ───────────────// Every change through PUT /me keeps the setting it replaced (the last 50 per person); a restore is itself a change, so// it can be undone the same way. Nothing here is public: a person reads only their own history.const HISTORY_KEEP = 50;++/** Keep the last HISTORY_KEEP changes of one person. */++async function trimHistory(db, userId) {++ await db.prepare(`DELETE FROM user_theme_history WHERE user_id = ? AND id NOT IN (++ SELECT id FROM user_theme_history WHERE user_id = ? ORDER BY id DESC LIMIT ${HISTORY_KEEP})`).run(userId, userId);++}/** Keep the current setting before `next` replaces it ({ theme_id (null: unchanged), custom }); nothing when nothing changes. */async function keepHistory(db, userId, source, next) {const cur = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);@@@ -231,8 +326,14 @@ async function keepHistory(db, userId, source, next) {if ((next.theme_id || cur.theme_id) === cur.theme_id && (next.custom || null) === (cur.custom_theme_variables || null)) return;const src = typeof source === 'string' && /^[a-z][a-z0-9-]{0,23}$/.test(source) ? source : null;await db.prepare('INSERT INTO user_theme_history (user_id, theme_id, custom_theme_variables, source) VALUES (?, ?, ?, ?)').run(userId, cur.theme_id, cur.custom_theme_variables || null, src);-- await db.prepare(`DELETE FROM user_theme_history WHERE user_id = ? AND id NOT IN (-- SELECT id FROM user_theme_history WHERE user_id = ? ORDER BY id DESC LIMIT ${HISTORY_KEEP})`).run(userId, userId);++ await trimHistory(db, userId);++}++/** Keep what a change replaces in the history, with the service it applies to as the source (a site override). */++async function recordHistory(db, userId, { theme_id, custom }, source) {++ if (!theme_id) return;++ const vars = custom == null ? null : (typeof custom === 'string' ? custom : JSON.stringify(custom));++ await db.prepare('INSERT INTO user_theme_history (user_id, theme_id, custom_theme_variables, source) VALUES (?, ?, ?, ?)').run(userId, theme_id, vars, source);++ await trimHistory(db, userId);}const historyRow = (r, names) => {let vars = null;@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };// The real directory: 0016 applies on top of 0015, once.const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);assert.ok(await hasAgents());assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++// Per-site theme overrides (the design overhaul, pillar A): one theme follows a person to every site, and an override++// is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). The routes answer per service, fall++// back to the global theme, keep the history, and travel with the account (ADR-033: the export and the deletion).++// node --import ./test/helpers/pg-preload.mjs test/theme-site-overrides.test.js++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++const express = require('express');++const { getDb } = require('../server/db/database');++const subjects = require('../server/identity/subjects');++const accountData = require('../server/identity/account-data');++++(async () => {++ const log = console.log; console.log = () => {};++ const db = getDb();++ console.log = log;++ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (41, 'nadia', 'x', 'user'), (42, 'omar', 'x', 'user') ON CONFLICT DO NOTHING").run();++ for (const [id, name] of [['vibe', 'Vibe'], ['cloud', 'Cloud'], ['ember', 'Ember']]) {++ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public, review_status) VALUES (?, ?, ?, 'dark', '{}', 1, 1, 'approved') ON CONFLICT DO NOTHING").run(id, name, id);++ }++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });++ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };++ const { signToken } = require('../server/auth/routes');++ const app = express();++ app.use(express.json());++ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });++ app.use('/api/themes', require('../server/themes/routes'));++ const server = http.createServer(app);++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);++ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));++++ // ── The person's own routes are theirs alone.++ assert.strictEqual((await call('GET', '/api/themes/me/overrides')).status, 401, 'the list is a person\'s own');++ assert.strictEqual((await call('PUT', '/api/themes/me/overrides/games', { body: { theme_id: 'ember' } })).status, 401, 'setting one is a person\'s own');++ assert.strictEqual((await call('DELETE', '/api/themes/me/overrides/games')).status, 401, 'removing one is a person\'s own');++++ // ── The global theme, as it is today.++ await call('PUT', '/api/themes/me', { as: 41, body: { theme_id: 'cloud' } });++ let r = await call('GET', '/api/themes/me/active', { as: 41 });++ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], 'without a service the answer is the global theme');++ assert.ok(!('service' in r.body), 'no service is named when none was asked for');++++ // ── One site asks for its own look; it gets the global theme until the person says otherwise.++ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });++ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], 'no override yet: the global theme');++ r = await call('GET', '/api/themes/me/active?service=live', { as: 42 });++ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['vibe', false], 'another person has none either');++++ // ── Set one.++ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--accent': '#ff6a3d' } } });++ assert.deepStrictEqual([r.status, r.body.service, r.body.theme_id, r.body.custom_variables], [200, 'games', 'ember', { '--accent': '#ff6a3d' }], 'the override is set');++ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });++ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override, r.body.service], [200, 'ember', true, 'games'], 'the site sees its own theme');++ assert.deepStrictEqual(r.body.custom_variables, { '--accent': '#ff6a3d' }, 'with the custom colours it was set with');++ r = await call('GET', '/api/themes/me/active', { as: 41 });++ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'every other site keeps the global theme');++ r = await call('GET', '/api/themes/me/active?service=live', { as: 41 });++ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'a service without an override keeps the global theme');++ r = await call('GET', '/api/themes/me/active?service=games', { as: 42 });++ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['vibe', false], "another person's override is not reachable");++++ // A missing or invalid service answers exactly as today.++ for (const q of ['', '?service=', '?service=Games', '?service=1', '?service=a']) {++ r = await call('GET', `/api/themes/me/active${q}`, { as: 41 });++ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], `service ${JSON.stringify(q)} is not a service id: the answer is as it was`);++ }++++ // ── The list.++ r = await call('GET', '/api/themes/me/overrides', { as: 41 });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.body.overrides.length, 1);++ assert.deepStrictEqual([r.body.overrides[0].service, r.body.overrides[0].theme_id, r.body.overrides[0].theme_name], ['games', 'ember', 'Ember'], 'the list names the service, the theme and its name');++ assert.deepStrictEqual(r.body.overrides[0].custom_variables, { '--accent': '#ff6a3d' });++ assert.deepStrictEqual((await call('GET', '/api/themes/me/overrides', { as: 42 })).body.overrides, [], "another person's list is empty");++++ // ── Validation, as PUT /me does it.++ r = await call('PUT', '/api/themes/me/overrides/Games', { as: 41, body: { theme_id: 'ember' } });++ assert.deepStrictEqual([r.status, r.body.code], [400, 'theme.bad_service'], 'a service id is lower case');++ r = await call('PUT', '/api/themes/me/overrides/x', { as: 41, body: { theme_id: 'ember' } });++ assert.strictEqual(r.status, 400, 'a service id is at least two characters');++ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'no-such-theme' } });++ assert.deepStrictEqual([r.status, r.body.error], [404, 'Theme not found'], 'a theme that does not exist');++ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--not-a-token': '#fff' } } });++ assert.deepStrictEqual([r.status, r.body.code], [422, 'theme.invalid_variables'], 'the same custom-variable rule as PUT /me');++ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--accent': 'url(https://example.com/x)' } } });++ assert.strictEqual(r.status, 422, 'a value cannot fetch anything');++ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: {} });++ assert.strictEqual(r.status, 400, 'a theme is required');++ r = await call('DELETE', '/api/themes/me/overrides/live', { as: 41 });++ assert.deepStrictEqual([r.status, r.body.code], [404, 'theme.override_not_found'], 'there is no override to remove');++++ // ── The history keeps what each change replaced, with the service recorded.++ let h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;++ const forGames = h.filter((x) => x.source === 'games');++ assert.strictEqual(forGames.length, 1, 'the change is in the history');++ assert.strictEqual(forGames[0].theme_id, 'cloud', 'the global theme it replaced');++ await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'cloud' } });++ h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;++ assert.strictEqual(h.filter((x) => x.source === 'games').length, 2, 'a second change is kept too');++ assert.strictEqual(h.find((x) => x.source === 'games').theme_id, 'ember', 'the newest first: the theme it replaced');++++ // ── Remove one: that site falls back to the global theme again.++ r = await call('DELETE', '/api/themes/me/overrides/games', { as: 41 });++ assert.deepStrictEqual([r.status, r.body.service, r.body.deleted], [200, 'games', true], 'the override is gone');++ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });++ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'the global theme again');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 41').get()).n, 0, 'the row is gone');++ h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;++ assert.ok(h.some((x) => x.source === 'games'), 'the removal is in the history too');++++ // ── At most 50 per person.++ for (let i = 1; i <= 50; i++) {++ const res = await call('PUT', `/api/themes/me/overrides/s${i}`, { as: 42, body: { theme_id: 'vibe' } });++ assert.strictEqual(res.status, 200, `override ${i} is set`);++ }++ r = await call('PUT', '/api/themes/me/overrides/s51', { as: 42, body: { theme_id: 'vibe' } });++ assert.deepStrictEqual([r.status, r.body.code], [429, 'theme.too_many_overrides'], 'the 51st is refused');++ r = await call('PUT', '/api/themes/me/overrides/s1', { as: 42, body: { theme_id: 'cloud' } });++ assert.strictEqual(r.status, 200, 'changing one that is already there is not a new one');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 42').get()).n, 50);++++ // ── ADR-033: the overrides travel with the account (the export) and go with it (the deletion).++ await accountData.ensureSchema(db);++ await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember' } });++ const nadia = await db.prepare('SELECT * FROM users WHERE id = 41').get();++ await subjects.ensureUserSubject(db, nadia);++ const part = await accountData.networkPart(db, nadia.id);++ const prefs = part.files.find((f) => f.name === 'preferences.json').content;++ assert.strictEqual(prefs.theme_site_overrides.length, 1, 'the export holds the override');++ assert.deepStrictEqual([prefs.theme_site_overrides[0].service, prefs.theme_site_overrides[0].theme_id], ['games', 'ember']);++++ const { ids } = require('openvibe-contracts');++ const deletionId = `del_${ids.ulid()}`;++ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')++ .run(deletionId, nadia.id, nadia.subject_id, new Date().toISOString(), new Date(Date.now() + 86400000).toISOString());++ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId);++ const erased = await accountData.erase(db, deletion, { now: Date.now() });++ assert.strictEqual(erased.user_theme_site_overrides, 1, 'the deletion erases the override');++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 41').get()).n, 0);++++ server.close();++ console.log('theme site overrides: per service, private, capped, exported and erased ok');++ process.exit(0);++})().catch((err) => { console.error(err); process.exit(1); });@@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };// ── 0020 applies on top of 0019, once, and rewrites nothing.const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
@@ -18,6 +18,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
@@ -18,6 +18,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
- **Anonymous Users** — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.
- **Anonymous Users** — Browse and interact without an account. Anon users receive a unique number, can accumulate stats, and optionally link to a registered account later.
- **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.
- **Multi-Account Switching** — Google-style account management. Users can add multiple accounts and switch instantly, including an anonymous mode.
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
- **Theme Catalog** — Shared theme system with ~30 built-in themes and community submissions. Theme preferences sync across all services.
+- **Per-site theme overrides** — one theme follows a person to every site, and an override is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). `GET /api/themes/me/active?service=<id>` answers that service's theme (`override: true`, with the `service` named) or the person's global theme (`override: false`); a missing or unknown service answers exactly as before. The person owns their own list at `GET /api/themes/me/overrides`, `PUT /api/themes/me/overrides/:service` and `DELETE /api/themes/me/overrides/:service` (the theme and the custom colours are validated as `PUT /api/themes/me` does), at most 50 per person, and every change is kept in the theme history with the service recorded.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Admin Panel** — email configuration, user management (role changes, bans), broadcast notifications, system health dashboard, and audit log.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Internal API** — Server-to-server endpoints for token verification, user lookup, notification push, account linking, and audit logging. Every route takes a capability-scoped service token; the deployment-wide key they used to accept was retired on 2026-09-29 (plan T2, [docs/retirement.md](docs/retirement.md)) and opens nothing.
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
- **Developer projects (foundation)** — projects, members, apps (OAuth clients with `app_` subjects), hashed client secrets with rotation and revocation, capability grants within a staff-set allowance (sandbox apps also get a default sandbox allowance of public Media, Events-app and Tools-job capabilities, so a new project works without staff), recorded quotas, per-project usage from the services' hourly rollups (`GET /api/v1/projects/:project/usage`) and an append-only audit at `/api/v1/projects`. Its events can be relayed to OpenVibe.Events through an outbox when `OV_EVENTS_INTERNAL_URL` is set. Bearer user tokens only; the console over this API is OpenVibe.Services at openvibe.services (it moved there from OpenVibe.Codes on 2026-10-08). See [docs/developer-projects.md](docs/developer-projects.md).
@@ -782,7 +783,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
@@ -782,7 +783,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md). The rules the code keeps:
MIT (`license` in [package.json](package.json)).
MIT (`license` in [package.json](package.json)).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.238.0
+- openvibe-contracts: v0.232.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
-- openvibe-shared: v4.39.0
+- openvibe-shared: v4.33.0
<!-- versions:end -->
<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
+-- phase: expand
+-- Per-site theme overrides (the design overhaul, pillar A): one theme follows a person to every OpenVibe site, and
+-- this table holds their exceptions ("Games in Neon Tokyo", "Live in Daylight"). One row per person and service; the
+-- person's global theme stays in user_preferences, so an older release (and every site that asks for no service)
+-- keeps reading the same rows it always did.
+CREATE TABLE IF NOT EXISTS user_theme_site_overrides (
+ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
+ service text COLLATE "C" NOT NULL CHECK (service ~ '^[a-z][a-z0-9-]{1,31}$'),
+ theme_id text COLLATE "C" NOT NULL,
+ custom_variables jsonb,
+ updated_at text COLLATE "C" DEFAULT ov_now(),
+ PRIMARY KEY (user_id, service)
+);
+CREATE INDEX IF NOT EXISTS user_theme_site_overrides_service_idx ON user_theme_site_overrides (service);
@@ -184,6 +184,7 @@ async function networkPart(db, userId) {
@@ -184,6 +184,7 @@ async function networkPart(db, userId) {
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
effects: (await rowsOf(db, 'user_effects', 'user_id = ?', [u.id])).rows,
push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,
push_devices: (await rowsOf(db, 'push_subscriptions', 'user_id = ?', [u.id], { omit: ['endpoint'] })).rows,
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
theme_history: (await rowsOf(db, 'user_theme_history', 'user_id = ?', [u.id])).rows,
+ theme_site_overrides: (await rowsOf(db, 'user_theme_site_overrides', 'user_id = ?', [u.id])).rows,
});
});
await byUser('history.json', 'user_history');
await byUser('history.json', 'user_history');
add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
add('username_history.json', (await rowsOf(db, 'username_history', 'user_id = ?', [u.id], { omit: ['changed_by'] })).rows);
@@ -337,7 +338,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -337,7 +338,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
for (const t of ['linked_accounts', 'user_sessions', 'oauth_tokens', 'oauth_codes', 'email_verification_tokens', 'password_reset_tokens', 'notifications',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
'notification_preferences', 'notification_quiet_hours', 'notification_digest_runs', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
- 'ip_log', 'email_delivery_log', 'user_theme_history']) await del(t, `user_id IN ${inU}`, userIds);
+ 'ip_log', 'email_delivery_log', 'user_theme_history', 'user_theme_site_overrides']) await del(t, `user_id IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
if (await tableExists(db, 'account_merge_intents')) await del('account_merge_intents', `into_user_id IN ${inU}`, userIds);
// Notifications this person sent others stay, without their name or picture.
// Notifications this person sent others stay, without their name or picture.
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
if (await tableExists(db, 'notifications')) await db.prepare(`UPDATE notifications SET sender_id = NULL, sender_name = 'Deleted account', sender_avatar = NULL WHERE sender_id IN ${inU}`).run(...userIds);
@@ -106,6 +106,25 @@ router.get('/seasons', (req, res) => {
@@ -106,6 +106,25 @@ router.get('/seasons', (req, res) => {
res.set('Cache-Control', 'public, max-age=3600').json({ seasons: list, now: now ? { id: now.id, name: now.name, theme: now.theme, ...(now.light ? { light: now.light } : {}) } : null });
res.set('Cache-Control', 'public, max-age=3600').json({ seasons: list, now: now ? { id: now.id, name: now.name, theme: now.theme, ...(now.light ? { light: now.light } : {}) } : null });
});
});
+// ── Per-site theme overrides (the design overhaul, pillar A) ──
+// One theme follows a person to every site; an override is their exception for one service ("Games in Neon Tokyo",
+// "Live in Daylight"). The service id is the one the sites use: lower case, digits and hyphens.
+const SERVICE_ID = /^[a-z][a-z0-9-]{1,31}$/;
+const MAX_OVERRIDES = 50;
+const serviceId = (s) => (typeof s === 'string' && SERVICE_ID.test(s) ? s : null);
+/** A theme's custom variables as they are stored: jsonb in the overrides, text everywhere else. */
+const varsOf = (v) => {
+ if (v == null) return null;
+ if (typeof v === 'string') { try { return JSON.parse(v); } catch { return null; } }
+ return v;
+};
+/** The person's override for one service, or null. */
+async function overrideOf(db, userId, service) {
+ try {
+ return await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service) || null;
+ } catch { return null; /* the table arrives with migration 0028 */ }
+}
+
// ── Get User's Active Theme ──────────────────────────────────
// ── Get User's Active Theme ──────────────────────────────────
// A request with no credentials at all is a guest asking "do I have a theme?": answer "no" with
// A request with no credentials at all is a guest asking "do I have a theme?": answer "no" with
// a 200 so every page view by a guest does not log a failed request in the console.
// a 200 so every page view by a guest does not log a failed request in the console.
@@ -116,14 +135,20 @@ router.get('/me/active', (req, res, next) => {
@@ -116,14 +135,20 @@ router.get('/me/active', (req, res, next) => {
}, requireAuth, async (req, res) => {
}, requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
+ // A site asks for its own look ("Games in Neon Tokyo"): the person's override for that service, when they have one.
+ // A missing or unknown service answers exactly as it always did.
+ const service = serviceId(req.query.service);
+ const override = service ? await overrideOf(db, userId, service) : null;
+
const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
- if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null });
+ let display = null;
+ try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
+ if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, override: true, service });
+ if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false });
let custom = null;
let custom = null;
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
- let display = null;
- try { const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId); display = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* column arrives with the migration */ }
- res.json({ theme_id: prefs.theme_id, custom_variables: custom, display });
+ res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, override: false });
});
});
// ── Display preferences (motion, text size) — follow the account to every site ──
// ── Display preferences (motion, text size) — follow the account to every site ──
@@ -220,10 +245,80 @@ router.put('/me', requireAuth, async (req, res) => {
@@ -220,10 +245,80 @@ router.put('/me', requireAuth, async (req, res) => {
res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });
res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });
});
});
+// ── Per-site theme overrides ─────────────────────────────────
+// The person's own list, one theme per service. A site asks for its service id (GET /me/active?service=games) and
+// gets that theme; every other site, and every service without an override, keeps the person's global theme.
+router.get('/me/overrides', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const userId = req.user.sub || req.user.id;
+ const rows = await db.prepare('SELECT service, theme_id, custom_variables, updated_at FROM user_theme_site_overrides WHERE user_id = ? ORDER BY service').all(userId);
+ const names = new Map();
+ for (const id of [...new Set(rows.map((r) => r.theme_id))]) { const t = await db.prepare('SELECT name FROM themes WHERE id = ?').get(id); if (t) names.set(id, t.name); }
+ res.set('Cache-Control', 'private, no-store').json({
+ overrides: rows.map((r) => ({ service: r.service, theme_id: r.theme_id, theme_name: names.get(r.theme_id) || null, custom_variables: varsOf(r.custom_variables), updated_at: r.updated_at })),
+ });
+});
+
+// Set one site's theme. The theme is validated exactly as PUT /me does it: a theme the person may not see is a 404,
+// and custom colours are the same allow-list.
+router.put('/me/overrides/:service', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const userId = req.user.sub || req.user.id;
+ const service = serviceId(req.params.service);
+ if (!service) return res.status(400).json({ error: 'service must be a service id like live or games', code: 'theme.bad_service' });
+ const { theme_id, custom_variables } = req.body || {};
+ if (!theme_id) return res.status(400).json({ error: 'theme_id is required', code: 'theme.missing_theme' });
+
+ ensureReview(db);
+ const theme = await db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ? OR slug = ?').get(theme_id, theme_id);
+ if (!visibleTo(theme, userId)) return res.status(404).json({ error: 'Theme not found' });
+ let vars = null;
+ if (custom_variables) {
+ const v = cleanVariables(custom_variables);
+ if (!v.ok) return res.status(422).json({ error: 'Custom colours rejected', code: 'theme.invalid_variables', errors: v.errors });
+ vars = v.variables;
+ }
+
+ const existing = await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service);
+ if (!existing) {
+ const n = (await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = ?').get(userId)).n;
+ if (n >= MAX_OVERRIDES) return res.status(429).json({ error: `You have ${n} site overrides; at most ${MAX_OVERRIDES} are kept`, code: 'theme.too_many_overrides' });
+ }
+
+ // Keep what this change replaces for that site, as every other theme change does, with the service recorded.
+ const replaced = existing || (await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId));
+ if (replaced && replaced.theme_id) await recordHistory(db, userId, { theme_id: replaced.theme_id, custom: existing ? existing.custom_variables : replaced.custom_theme_variables }, service);
+
+ await db.prepare(`INSERT INTO user_theme_site_overrides (user_id, service, theme_id, custom_variables, updated_at)
+ VALUES (?, ?, ?, ?::jsonb, ov_now())
+ ON CONFLICT(user_id, service) DO UPDATE SET theme_id = excluded.theme_id, custom_variables = excluded.custom_variables, updated_at = ov_now()`)
+ .run(userId, service, theme_id, vars ? JSON.stringify(vars) : null);
+ res.json({ success: true, service, theme_id, custom_variables: vars, override: true });
+});
+
+// Remove one site's override: that site falls back to the person's global theme again.
+router.delete('/me/overrides/:service', requireAuth, async (req, res) => {
+ const db = await getDb(req);
+ const userId = req.user.sub || req.user.id;
+ const service = serviceId(req.params.service);
+ if (!service) return res.status(400).json({ error: 'service must be a service id like live or games', code: 'theme.bad_service' });
+ const existing = await db.prepare('SELECT theme_id, custom_variables FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').get(userId, service);
+ if (!existing) return res.status(404).json({ error: 'No override for that service', code: 'theme.override_not_found' });
+ // The override that is gone is kept in the history, with the service recorded, like any other theme change.
+ await recordHistory(db, userId, { theme_id: existing.theme_id, custom: existing.custom_variables }, service);
+ await db.prepare('DELETE FROM user_theme_site_overrides WHERE user_id = ? AND service = ?').run(userId, service);
+ res.json({ success: true, service, deleted: true });
+});
+
// ── History and rollback (plan 2026-10-28 §5.2) ───────────────
// ── History and rollback (plan 2026-10-28 §5.2) ───────────────
// Every change through PUT /me keeps the setting it replaced (the last 50 per person); a restore is itself a change, so
// Every change through PUT /me keeps the setting it replaced (the last 50 per person); a restore is itself a change, so
// it can be undone the same way. Nothing here is public: a person reads only their own history.
// it can be undone the same way. Nothing here is public: a person reads only their own history.
const HISTORY_KEEP = 50;
const HISTORY_KEEP = 50;
+/** Keep the last HISTORY_KEEP changes of one person. */
+async function trimHistory(db, userId) {
+ await db.prepare(`DELETE FROM user_theme_history WHERE user_id = ? AND id NOT IN (
+ SELECT id FROM user_theme_history WHERE user_id = ? ORDER BY id DESC LIMIT ${HISTORY_KEEP})`).run(userId, userId);
+}
/** Keep the current setting before `next` replaces it ({ theme_id (null: unchanged), custom }); nothing when nothing changes. */
/** Keep the current setting before `next` replaces it ({ theme_id (null: unchanged), custom }); nothing when nothing changes. */
async function keepHistory(db, userId, source, next) {
async function keepHistory(db, userId, source, next) {
const cur = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
const cur = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
@@ -231,8 +326,14 @@ async function keepHistory(db, userId, source, next) {
@@ -231,8 +326,14 @@ async function keepHistory(db, userId, source, next) {
if ((next.theme_id || cur.theme_id) === cur.theme_id && (next.custom || null) === (cur.custom_theme_variables || null)) return;
if ((next.theme_id || cur.theme_id) === cur.theme_id && (next.custom || null) === (cur.custom_theme_variables || null)) return;
const src = typeof source === 'string' && /^[a-z][a-z0-9-]{0,23}$/.test(source) ? source : null;
const src = typeof source === 'string' && /^[a-z][a-z0-9-]{0,23}$/.test(source) ? source : null;
await db.prepare('INSERT INTO user_theme_history (user_id, theme_id, custom_theme_variables, source) VALUES (?, ?, ?, ?)').run(userId, cur.theme_id, cur.custom_theme_variables || null, src);
await db.prepare('INSERT INTO user_theme_history (user_id, theme_id, custom_theme_variables, source) VALUES (?, ?, ?, ?)').run(userId, cur.theme_id, cur.custom_theme_variables || null, src);
- await db.prepare(`DELETE FROM user_theme_history WHERE user_id = ? AND id NOT IN (
- SELECT id FROM user_theme_history WHERE user_id = ? ORDER BY id DESC LIMIT ${HISTORY_KEEP})`).run(userId, userId);
+ await trimHistory(db, userId);
+}
+/** Keep what a change replaces in the history, with the service it applies to as the source (a site override). */
+async function recordHistory(db, userId, { theme_id, custom }, source) {
+ if (!theme_id) return;
+ const vars = custom == null ? null : (typeof custom === 'string' ? custom : JSON.stringify(custom));
+ await db.prepare('INSERT INTO user_theme_history (user_id, theme_id, custom_theme_variables, source) VALUES (?, ?, ?, ?)').run(userId, theme_id, vars, source);
+ await trimHistory(db, userId);
}
}
const historyRow = (r, names) => {
const historyRow = (r, names) => {
let vars = null;
let vars = null;
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);
assert.ok(await hasAgents());
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+// Per-site theme overrides (the design overhaul, pillar A): one theme follows a person to every site, and an override
+// is their exception for one service ("Games in Neon Tokyo", "Live in Daylight"). The routes answer per service, fall
+// back to the global theme, keep the history, and travel with the account (ADR-033: the export and the deletion).
+// node --import ./test/helpers/pg-preload.mjs test/theme-site-overrides.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+const express = require('express');
+const { getDb } = require('../server/db/database');
+const subjects = require('../server/identity/subjects');
+const accountData = require('../server/identity/account-data');
+
+(async () => {
+ const log = console.log; console.log = () => {};
+ const db = getDb();
+ console.log = log;
+ await db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (41, 'nadia', 'x', 'user'), (42, 'omar', 'x', 'user') ON CONFLICT DO NOTHING").run();
+ for (const [id, name] of [['vibe', 'Vibe'], ['cloud', 'Cloud'], ['ember', 'Ember']]) {
+ await db.prepare("INSERT INTO themes (id, name, slug, mode, variables, is_builtin, is_public, review_status) VALUES (?, ?, ?, 'dark', '{}', 1, 1, 'approved') ON CONFLICT DO NOTHING").run(id, name, id);
+ }
+ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
+ const config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
+ const { signToken } = require('../server/auth/routes');
+ const app = express();
+ app.use(express.json());
+ Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
+ app.use('/api/themes', require('../server/themes/routes'));
+ const server = http.createServer(app);
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const token = async (id) => signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config);
+ const call = async (method, p, { body, as } = {}) => fetch(base + p, { method, headers: { 'content-type': 'application/json', ...(as ? { authorization: `Bearer ${await token(as)}` } : {}) }, body: body ? JSON.stringify(body) : undefined })
+ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
+
+ // ── The person's own routes are theirs alone.
+ assert.strictEqual((await call('GET', '/api/themes/me/overrides')).status, 401, 'the list is a person\'s own');
+ assert.strictEqual((await call('PUT', '/api/themes/me/overrides/games', { body: { theme_id: 'ember' } })).status, 401, 'setting one is a person\'s own');
+ assert.strictEqual((await call('DELETE', '/api/themes/me/overrides/games')).status, 401, 'removing one is a person\'s own');
+
+ // ── The global theme, as it is today.
+ await call('PUT', '/api/themes/me', { as: 41, body: { theme_id: 'cloud' } });
+ let r = await call('GET', '/api/themes/me/active', { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], 'without a service the answer is the global theme');
+ assert.ok(!('service' in r.body), 'no service is named when none was asked for');
+
+ // ── One site asks for its own look; it gets the global theme until the person says otherwise.
+ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], 'no override yet: the global theme');
+ r = await call('GET', '/api/themes/me/active?service=live', { as: 42 });
+ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['vibe', false], 'another person has none either');
+
+ // ── Set one.
+ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--accent': '#ff6a3d' } } });
+ assert.deepStrictEqual([r.status, r.body.service, r.body.theme_id, r.body.custom_variables], [200, 'games', 'ember', { '--accent': '#ff6a3d' }], 'the override is set');
+ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override, r.body.service], [200, 'ember', true, 'games'], 'the site sees its own theme');
+ assert.deepStrictEqual(r.body.custom_variables, { '--accent': '#ff6a3d' }, 'with the custom colours it was set with');
+ r = await call('GET', '/api/themes/me/active', { as: 41 });
+ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'every other site keeps the global theme');
+ r = await call('GET', '/api/themes/me/active?service=live', { as: 41 });
+ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'a service without an override keeps the global theme');
+ r = await call('GET', '/api/themes/me/active?service=games', { as: 42 });
+ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['vibe', false], "another person's override is not reachable");
+
+ // A missing or invalid service answers exactly as today.
+ for (const q of ['', '?service=', '?service=Games', '?service=1', '?service=a']) {
+ r = await call('GET', `/api/themes/me/active${q}`, { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.theme_id, r.body.override], [200, 'cloud', false], `service ${JSON.stringify(q)} is not a service id: the answer is as it was`);
+ }
+
+ // ── The list.
+ r = await call('GET', '/api/themes/me/overrides', { as: 41 });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.body.overrides.length, 1);
+ assert.deepStrictEqual([r.body.overrides[0].service, r.body.overrides[0].theme_id, r.body.overrides[0].theme_name], ['games', 'ember', 'Ember'], 'the list names the service, the theme and its name');
+ assert.deepStrictEqual(r.body.overrides[0].custom_variables, { '--accent': '#ff6a3d' });
+ assert.deepStrictEqual((await call('GET', '/api/themes/me/overrides', { as: 42 })).body.overrides, [], "another person's list is empty");
+
+ // ── Validation, as PUT /me does it.
+ r = await call('PUT', '/api/themes/me/overrides/Games', { as: 41, body: { theme_id: 'ember' } });
+ assert.deepStrictEqual([r.status, r.body.code], [400, 'theme.bad_service'], 'a service id is lower case');
+ r = await call('PUT', '/api/themes/me/overrides/x', { as: 41, body: { theme_id: 'ember' } });
+ assert.strictEqual(r.status, 400, 'a service id is at least two characters');
+ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'no-such-theme' } });
+ assert.deepStrictEqual([r.status, r.body.error], [404, 'Theme not found'], 'a theme that does not exist');
+ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--not-a-token': '#fff' } } });
+ assert.deepStrictEqual([r.status, r.body.code], [422, 'theme.invalid_variables'], 'the same custom-variable rule as PUT /me');
+ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember', custom_variables: { '--accent': 'url(https://example.com/x)' } } });
+ assert.strictEqual(r.status, 422, 'a value cannot fetch anything');
+ r = await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: {} });
+ assert.strictEqual(r.status, 400, 'a theme is required');
+ r = await call('DELETE', '/api/themes/me/overrides/live', { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.code], [404, 'theme.override_not_found'], 'there is no override to remove');
+
+ // ── The history keeps what each change replaced, with the service recorded.
+ let h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;
+ const forGames = h.filter((x) => x.source === 'games');
+ assert.strictEqual(forGames.length, 1, 'the change is in the history');
+ assert.strictEqual(forGames[0].theme_id, 'cloud', 'the global theme it replaced');
+ await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'cloud' } });
+ h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;
+ assert.strictEqual(h.filter((x) => x.source === 'games').length, 2, 'a second change is kept too');
+ assert.strictEqual(h.find((x) => x.source === 'games').theme_id, 'ember', 'the newest first: the theme it replaced');
+
+ // ── Remove one: that site falls back to the global theme again.
+ r = await call('DELETE', '/api/themes/me/overrides/games', { as: 41 });
+ assert.deepStrictEqual([r.status, r.body.service, r.body.deleted], [200, 'games', true], 'the override is gone');
+ r = await call('GET', '/api/themes/me/active?service=games', { as: 41 });
+ assert.deepStrictEqual([r.body.theme_id, r.body.override], ['cloud', false], 'the global theme again');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 41').get()).n, 0, 'the row is gone');
+ h = (await call('GET', '/api/themes/me/history', { as: 41 })).body.history;
+ assert.ok(h.some((x) => x.source === 'games'), 'the removal is in the history too');
+
+ // ── At most 50 per person.
+ for (let i = 1; i <= 50; i++) {
+ const res = await call('PUT', `/api/themes/me/overrides/s${i}`, { as: 42, body: { theme_id: 'vibe' } });
+ assert.strictEqual(res.status, 200, `override ${i} is set`);
+ }
+ r = await call('PUT', '/api/themes/me/overrides/s51', { as: 42, body: { theme_id: 'vibe' } });
+ assert.deepStrictEqual([r.status, r.body.code], [429, 'theme.too_many_overrides'], 'the 51st is refused');
+ r = await call('PUT', '/api/themes/me/overrides/s1', { as: 42, body: { theme_id: 'cloud' } });
+ assert.strictEqual(r.status, 200, 'changing one that is already there is not a new one');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 42').get()).n, 50);
+
+ // ── ADR-033: the overrides travel with the account (the export) and go with it (the deletion).
+ await accountData.ensureSchema(db);
+ await call('PUT', '/api/themes/me/overrides/games', { as: 41, body: { theme_id: 'ember' } });
+ const nadia = await db.prepare('SELECT * FROM users WHERE id = 41').get();
+ await subjects.ensureUserSubject(db, nadia);
+ const part = await accountData.networkPart(db, nadia.id);
+ const prefs = part.files.find((f) => f.name === 'preferences.json').content;
+ assert.strictEqual(prefs.theme_site_overrides.length, 1, 'the export holds the override');
+ assert.deepStrictEqual([prefs.theme_site_overrides[0].service, prefs.theme_site_overrides[0].theme_id], ['games', 'ember']);
+
+ const { ids } = require('openvibe-contracts');
+ const deletionId = `del_${ids.ulid()}`;
+ await db.prepare('INSERT INTO account_deletions (id, user_id, subject, requested_at, delete_after) VALUES (?, ?, ?, ?, ?)')
+ .run(deletionId, nadia.id, nadia.subject_id, new Date().toISOString(), new Date(Date.now() + 86400000).toISOString());
+ const deletion = await db.prepare('SELECT * FROM account_deletions WHERE id = ?').get(deletionId);
+ const erased = await accountData.erase(db, deletion, { now: Date.now() });
+ assert.strictEqual(erased.user_theme_site_overrides, 1, 'the deletion erases the override');
+ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_site_overrides WHERE user_id = 41').get()).n, 0);
+
+ server.close();
+ console.log('theme site overrides: per service, private, capped, exported and erased ok');
+ process.exit(0);
+})().catch((err) => { console.error(err); process.exit(1); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand']]);
+ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');