openvibers/openvibe.network Commits

Themes with per-site exceptions: one theme everywhere, an override per service (#419)

OP OpenVibers committed 15 days ago · 72fec4c056 · 7 files, +276 −11

A person's theme follows them to every OpenVibe site; this adds the exceptions ("Games in Neon
Tokyo", "Live in Daylight"):

- migrations/0028_theme_site_overrides.sql: user_theme_site_overrides (user_id, service, theme_id,
  custom_variables jsonb, updated_at), primary key (user_id, service).
- GET /api/themes/me/active?service=<id> answers that service's theme (override: true, service named)
  or the global theme (override: false); a missing or unknown service answers exactly as before.
- GET /api/themes/me/overrides, PUT /api/themes/me/overrides/:service, DELETE /api/themes/me/overrides/:service:
  the person's own list, at most 50 per person, the theme and the custom colours validated as PUT /api/themes/me
  does, and every change kept in the theme history with the service recorded.
- The overrides travel with the account (ADR-033): in the export and erased with it.

Changes

ddiff --git a/README.md b/README.md
iindex 91d21e05..1ba3923d 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/migrations/0028_theme_site_overrides.sql b/migrations/0028_theme_site_overrides.sql
nnew file mode 100644
iindex 00000000..d46f02e1
---- /dev/null
++++ b/migrations/0028_theme_site_overrides.sql
@@@ -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);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex c453ccce..0f09e618 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -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);
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex bfe60778..d46d8380 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -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;
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 4825aeb0..0110a126 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -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.
ddiff --git a/test/theme-site-overrides.test.js b/test/theme-site-overrides.test.js
nnew file mode 100644
iindex 00000000..3ef03623
---- /dev/null
++++ b/test/theme-site-overrides.test.js
@@@ -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); });
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 9078a775..a048cf08 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -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 twice
assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');