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)
Changes
nnew file mode 100644@@@ -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);@@@ -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);@@@ -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);@@@ -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.nnew file mode 100644@@@ -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); });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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);
@@ -182,6 +182,7 @@ async function networkPart(db, userId) {
@@ -182,6 +182,7 @@ async function networkPart(db, userId) {
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
notifications: (await rowsOf(db, 'notification_preferences', 'user_id = ?', [u.id])).rows,
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,
});
});
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);
@@ -335,7 +336,7 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
@@ -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',
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',
'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);
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);
@@ -199,6 +199,9 @@ router.put('/me', requireAuth, async (req, res) => {
@@ -199,6 +199,9 @@ router.put('/me', requireAuth, async (req, res) => {
req.body.custom_variables = v.variables;
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(`
await db.prepare(`
INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
INSERT INTO user_preferences (user_id, theme_id, custom_theme_variables, updated_at)
VALUES (?, ?, ?, ov_now())
VALUES (?, ?, ?, ov_now())
@@ -217,6 +220,53 @@ router.put('/me', requireAuth, async (req, res) => {
@@ -217,6 +220,53 @@ 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 });
});
});
+// ── 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) ─────────────────
// ── Submit a community theme (pending review) ─────────────────
async function submitTheme(db, userId, body) {
async function submitTheme(db, userId, body) {
ensureReview(db);
ensureReview(db);
@@ -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']]);
+ 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.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,67 @@
@@ -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); });
@@ -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']]);
+ 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');
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');