openvibers/openvibe.network Commits

Theme history and rollback: every change through PUT /api/themes/me keeps what it replaced (last 50), GET /api/themes/me/history and POST …/history/:id/restore; exported and deleted with the account (#389)

OP OpenVibers committed 16 days ago · 5e5ecc2391 · 6 files, +133 −3

Changes

ddiff --git a/migrations/0026_theme_history.sql b/migrations/0026_theme_history.sql
nnew file mode 100644
iindex 00000000..ccf93feb
---- /dev/null
++++ b/migrations/0026_theme_history.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- Theme history (plan 2026-10-28 §5.2, the Design studio's rollback): each theme or custom-colour change a person
++-- makes keeps the setting it replaced, newest first; the routes keep the last 50 per person.
++CREATE TABLE IF NOT EXISTS user_theme_history (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
++ theme_id text COLLATE "C" NOT NULL,
++ custom_theme_variables text COLLATE "C",
++ source text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now()
++);
++CREATE INDEX IF NOT EXISTS user_theme_history_user_idx ON user_theme_history (user_id, id DESC);
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 50c9ab50..638fb225 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -182,6 +182,7 @@ async function networkPart(db, userId) {
notifications: (await rowsOf(db, 'notification_preferences', '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,
++ theme_history: (await rowsOf(db, 'user_theme_history', '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);
@@@ -335,7 +336,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', 'user_preferences', 'user_effects', 'push_subscriptions', 'user_history', 'username_history', 'user_profile_changes',
-- 'ip_log', 'email_delivery_log']) await del(t, `user_id IN ${inU}`, userIds);
++ 'ip_log', 'email_delivery_log', 'user_theme_history']) 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 38b9156f..bfe60778 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -199,6 +199,9 @@ router.put('/me', requireAuth, async (req, res) => {
req.body.custom_variables = v.variables;
}
++ // Keep what this change replaces, so the person can go back to it (the Design studio's history and rollback).
++ await keepHistory(db, userId, req.body && req.body.source, { theme_id: theme_id || null, custom: req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null });
++
await db.prepare(`
INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
VALUES (?, ?, ?, ov_now())
@@@ -217,6 +220,53 @@ router.put('/me', requireAuth, async (req, res) => {
res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });
});
++// ── 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 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);
++ if (!cur || !cur.theme_id) 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;
++ 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);
++}
++const historyRow = (r, names) => {
++ let vars = null;
++ try { vars = r.custom_theme_variables ? JSON.parse(r.custom_theme_variables) : null; } catch { vars = null; }
++ return { id: Number(r.id), theme_id: r.theme_id, theme_name: names.get(r.theme_id) || null, custom_variables: vars, source: r.source || null, created_at: r.created_at };
++};
++
++router.get('/me/history', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const rows = await db.prepare(`SELECT id, theme_id, custom_theme_variables, source, created_at FROM user_theme_history WHERE user_id = ? ORDER BY id DESC LIMIT ${HISTORY_KEEP}`).all(userId);
++ const ids = [...new Set(rows.map((r) => r.theme_id))];
++ const names = new Map();
++ for (const id of ids) { 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({ history: rows.map((r) => historyRow(r, names)) });
++});
++
++router.post('/me/history/:id/restore', requireAuth, async (req, res) => {
++ const db = await getDb(req);
++ const userId = req.user.sub || req.user.id;
++ const id = Number(req.params.id);
++ if (!Number.isSafeInteger(id) || id < 1) return res.status(400).json({ error: 'Bad history id', code: 'theme.bad_history' });
++ const row = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_theme_history WHERE id = ? AND user_id = ?').get(id, userId);
++ if (!row) return res.status(404).json({ error: 'No such change in your history', code: 'theme.history_not_found' });
++ const theme = await db.prepare('SELECT id, author_id, is_public, review_status FROM themes WHERE id = ?').get(row.theme_id);
++ if (!visibleTo(theme, userId)) return res.status(409).json({ error: 'That theme is no longer available', code: 'theme.gone' });
++ await keepHistory(db, userId, 'restore', { theme_id: row.theme_id, custom: row.custom_theme_variables || null });
++ await db.prepare(`INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at) VALUES (?, ?, ?, ov_now())
++ ON CONFLICT(user_id) DO UPDATE SET theme_id = ?, custom_theme_variables = ?, updated_at = ov_now()`)
++ .run(userId, row.theme_id, row.custom_theme_variables || null, row.theme_id, row.custom_theme_variables || null);
++ let vars = null; try { vars = row.custom_theme_variables ? JSON.parse(row.custom_theme_variables) : null; } catch { vars = null; }
++ res.json({ success: true, theme_id: row.theme_id, custom_variables: vars });
++});
++
// ── Submit a community theme (pending review) ─────────────────
async function submitTheme(db, userId, body) {
ensureReview(db);
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex bdf2f9d1..f0b4cfdd 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']]);
++ 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']]);
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-history.test.js b/test/theme-history.test.js
nnew file mode 100644
iindex 00000000..cb00a976
---- /dev/null
++++ b/test/theme-history.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++// Theme history and rollback (plan 2026-10-28 §5.2, the Design studio): every change through PUT /api/themes/me keeps
++// the setting it replaced, a person reads only their own, a restore brings one back (and is itself undoable), the
++// last 50 are kept, and an unchanged save adds nothing.
++// node --import ./test/helpers/pg-preload.mjs test/theme-history.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { getDb } = require('../server/db/database');
++
++(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 (31, 'hana', 'x', 'user'), (32, 'ivo', '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) VALUES (?, ?, ?, 'dark', '{}', 1, 1) 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) }));
++
++ assert.strictEqual((await call('GET', '/api/themes/me/history')).status, 401, 'history is a person\'s own');
++ // First choice: nothing to keep yet.
++ await call('PUT', '/api/themes/me', { as: 31, body: { theme_id: 'vibe' } });
++ assert.deepStrictEqual((await call('GET', '/api/themes/me/history', { as: 31 })).body.history, [], 'the first setting replaces nothing');
++ await call('PUT', '/api/themes/me', { as: 31, body: { theme_id: 'cloud', source: 'studio' } });
++ await call('PUT', '/api/themes/me', { as: 31, body: { theme_id: 'ember', custom_variables: { '--accent': '#ff6a3d' } } });
++ let h = (await call('GET', '/api/themes/me/history', { as: 31 })).body.history;
++ assert.deepStrictEqual(h.map((x) => x.theme_id), ['cloud', 'vibe'], 'newest first');
++ assert.ok(h.every((x) => typeof x.theme_name === 'string' && x.theme_name), 'each with its theme\'s name');
++ assert.strictEqual(h[1].source, 'studio', 'the change that replaced Vibe came from the studio');
++ // The same setting saved again keeps nothing new.
++ await call('PUT', '/api/themes/me', { as: 31, body: { theme_id: 'ember', custom_variables: { '--accent': '#ff6a3d' } } });
++ assert.strictEqual((await call('GET', '/api/themes/me/history', { as: 31 })).body.history.length, 2, 'saving the same setting again keeps nothing');
++ // Restore Vibe: the current one is kept, so the restore can be undone.
++ const vibeEntry = (await call('GET', '/api/themes/me/history', { as: 31 })).body.history.find((x) => x.theme_id === 'vibe');
++ const r = await call('POST', `/api/themes/me/history/${vibeEntry.id}/restore`, { as: 31 });
++ assert.deepStrictEqual([r.status, r.body.theme_id], [200, 'vibe']);
++ const pref = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = 31').get();
++ assert.deepStrictEqual([pref.theme_id, pref.custom_theme_variables], ['vibe', null], 'Vibe is the active theme again, without the custom accent');
++ h = (await call('GET', '/api/themes/me/history', { as: 31 })).body.history;
++ assert.strictEqual(h[0].theme_id, 'ember', 'what the restore replaced is first in the history');
++ assert.deepStrictEqual(h[0].custom_variables, { '--accent': '#ff6a3d' });
++ assert.strictEqual(h[0].source, 'restore');
++ // Someone else's history is not reachable.
++ assert.strictEqual((await call('POST', `/api/themes/me/history/${vibeEntry.id}/restore`, { as: 32 })).status, 404);
++ assert.strictEqual((await call('POST', '/api/themes/me/history/zero/restore', { as: 31 })).status, 400);
++ // The last 50 are kept.
++ for (let i = 0; i < 60; i++) await call('PUT', '/api/themes/me', { as: 32, body: { theme_id: i % 2 ? 'cloud' : 'vibe' } });
++ assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM user_theme_history WHERE user_id = 32').get()).n, 50);
++
++ server.close();
++ console.log('theme history: kept, private, restorable, capped 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 d05a6cb0..ede56cdd 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']]);
++ 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']]);
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');