openvibers/openvibe.network Commits

The account's look is dated: an older write no longer undoes a newer choice (L42) (#458)

OP OpenVibers committed 13 days ago · 5da6f1f67a · 5 files, +128 −6

Changes

ddiff --git a/migrations/0034_look_dates.sql b/migrations/0034_look_dates.sql
nnew file mode 100644
iindex 00000000..1044a900
---- /dev/null
++++ b/migrations/0034_look_dates.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++-- One look everywhere (plan L42, openvibe-shared 4.64.0; the owner, 10-31: seasonal turned off on Network was still on,
++-- and orange, on Live). Every copy of a person's look is dated, and the newest wins: these say when the account's copy
++-- was chosen (ms since the epoch, the browser's own clock for display settings), so an older copy arriving from a
++-- stale tab no longer undoes a newer choice, and a site can tell its own copy is out of date. Null for choices made
++-- before this release; an older release never reads them.
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS display_at bigint;
++ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS theme_at bigint;
ddiff --git a/server/themes/routes.js b/server/themes/routes.js
iindex 5feed125..cd76e556 100644
---- a/server/themes/routes.js
++++ b/server/themes/routes.js
@@@ -125,6 +125,22 @@ async function overrideOf(db, userId, service) {
} catch { return null; /* the table arrives with migration 0028 */ }
}
++// ── When the account's look was chosen (plan L42, Shared 4.64.0) ──
++// Each site keeps its own copy of the look, so every copy is dated and the newest wins: a write older than the account's
++// copy is refused (the answer says so and carries the newer copy), and /me/active hands out both dates. Dates are ms
++// since the epoch; a date ahead of this server's clock (a fast device clock) counts as at most a minute ahead.
++const lookAt = (v) => { const n = Math.floor(Number(v)); const now = Date.now(); return Number.isFinite(n) && n > 0 ? Math.min(n, now + 60000) : now; };
++async function lookDates(db, userId) {
++ try {
++ const row = await db.prepare('SELECT display_at, theme_at FROM user_preferences WHERE user_id = ?').get(userId);
++ return { display_at: Number(row && row.display_at) || 0, theme_at: Number(row && row.theme_at) || 0 };
++ } catch { return { display_at: 0, theme_at: 0 }; /* the columns arrive with migration 0034 */ }
++}
++async function setLookDate(db, userId, column, at) {
++ if (column !== 'display_at' && column !== 'theme_at') throw new Error('not a look date');
++ try { await db.prepare(`UPDATE user_preferences SET ${column} = ? WHERE user_id = ?`).run(at, userId); } catch { /* migration 0034 */ }
++}
++
// ── 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.
@@@ -143,12 +159,14 @@ router.get('/me/active', (req, res, next) => {
const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
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 });
++ const { display_at, theme_at } = await lookDates(db, userId);
++ const dated = { ...(display_at ? { display_at } : {}) };
++ if (override) return res.json({ theme_id: override.theme_id, custom_variables: varsOf(override.custom_variables), display, ...dated, 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 {}
-- res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, override: false });
++ res.json({ theme_id: prefs.theme_id, custom_variables: custom, display, ...dated, ...(theme_at ? { theme_at } : {}), override: false });
});
// ── Display preferences — follow the account to every site ──
@@@ -171,9 +189,18 @@ router.put('/me/display', requireAuth, async (req, res) => {
const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const display = cleanDisplay(req.body) || {};
++ const at = lookAt(req.body && req.body.at);
++ const { display_at } = await lookDates(db, userId);
++ if (display_at && display_at > at) {
++ // A stale tab or a slow request: the account already holds a newer choice, which stays (and is the answer).
++ const d = await db.prepare('SELECT display_prefs FROM user_preferences WHERE user_id = ?').get(userId);
++ let kept = null; try { kept = cleanDisplay(d && d.display_prefs ? JSON.parse(d.display_prefs) : null); } catch { /* */ }
++ return res.json({ ok: true, stale: true, display: kept || {}, display_at });
++ }
await db.prepare(`INSERT INTO user_preferences (user_id, display_prefs, updated_at) VALUES (?, ?, ov_now())
ON CONFLICT(user_id) DO UPDATE SET display_prefs = excluded.display_prefs, updated_at = ov_now()`).run(userId, JSON.stringify(display));
-- res.json({ ok: true, display });
++ await setLookDate(db, userId, 'display_at', at);
++ res.json({ ok: true, display, display_at: at });
});
// ── Your sites in the site switcher (openvibe-shared switcher.js, 2026-10-30) ──
@@@ -255,6 +282,12 @@ router.put('/me', requireAuth, async (req, res) => {
const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const { theme_id, custom_variables } = req.body;
++ const at = lookAt(req.body && req.body.at);
++ const { theme_at } = await lookDates(db, userId);
++ if (theme_at && theme_at > at) {
++ const kept = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
++ return res.json({ success: true, stale: true, theme_id: kept && kept.theme_id, custom_variables: varsOf(kept && kept.custom_theme_variables), theme_at });
++ }
ensureReview(db);
if (theme_id) {
@@@ -285,7 +318,8 @@ router.put('/me', requireAuth, async (req, res) => {
req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null
);
-- res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null });
++ await setLookDate(db, userId, 'theme_at', at);
++ res.json({ success: true, theme_id, custom_variables: req.body.custom_variables || null, theme_at: at });
});
// ── Per-site theme overrides ─────────────────────────────────
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 426eee51..5290b74a 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'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', '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'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', '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-look-dates.test.js b/test/theme-look-dates.test.js
nnew file mode 100644
iindex 00000000..ef51375b
---- /dev/null
++++ b/test/theme-look-dates.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++// One look everywhere (plan L42, openvibe-shared 4.64.0): the account's copy of a person's look is dated, and the
++// newest wins. A write older than the account's copy (a stale tab, a slow request) is refused and answered with the
++// newer copy; /me/active hands out both dates so a site can tell its own copy is out of date.
++// node --import ./test/helpers/pg-preload.mjs test/theme-look-dates.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 (61, 'priya', 'x', 'user') ON CONFLICT DO NOTHING").run();
++ for (const [id, name] of [['vibe', 'Vibe'], ['nord', 'Nord'], ['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 bearer = `Bearer ${await signToken(await db.prepare('SELECT * FROM users WHERE id = 61').get(), keys.privateKey, config)}`;
++ const call = (method, p, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json', authorization: bearer }, body: body ? JSON.stringify(body) : undefined })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++ const now = Date.now();
++
++ // ── Before any dated choice, nothing is dated ──
++ let r = await call('GET', '/api/themes/me/active');
++ assert.ok(!('display_at' in r.body) && !('theme_at' in r.body), 'no dates before a dated choice');
++
++ // ── Display settings: the browser's date is kept and handed out ──
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'off', motion: 'reduced', at: now - 1000 });
++ assert.deepStrictEqual([r.status, r.body.display.seasonal, r.body.display_at], [200, 'off', now - 1000]);
++ r = await call('GET', '/api/themes/me/active');
++ assert.deepStrictEqual([r.body.display.seasonal, r.body.display_at], ['off', now - 1000], 'the account says when');
++
++ // A stale tab still showing "on" writes later with its older date: the newer "off" stays, and the answer carries it.
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'on', at: now - 60000 });
++ assert.deepStrictEqual([r.status, r.body.stale, r.body.display.seasonal, r.body.display_at], [200, true, 'off', now - 1000], 'an older write is refused');
++ r = await call('GET', '/api/themes/me/active');
++ assert.strictEqual(r.body.display.seasonal, 'off', 'seasonal stays off');
++
++ // A newer one wins; an undated one (an older release) counts as now; one far in the future counts as now.
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'on', at: now });
++ assert.deepStrictEqual([r.body.stale, r.body.display.seasonal], [undefined, 'on']);
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'off' });
++ assert.ok(!r.body.stale && r.body.display_at >= now, 'undated: now');
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'on', at: 'soon' });
++ assert.ok(!r.body.stale && r.body.display_at >= now, 'nonsense dates count as now');
++ r = await call('PUT', '/api/themes/me/display', { seasonal: 'off', at: now + 86400000 * 365 });
++ assert.ok(r.body.display_at <= Date.now() + 60000, 'a date from a wrong clock cannot lock the account for a year');
++
++ // ── The theme: dated by the server unless the browser says when ──
++ r = await call('PUT', '/api/themes/me', { theme_id: 'nord' });
++ const nordAt = r.body.theme_at;
++ assert.ok(r.body.success && nordAt >= now, 'dated');
++ r = await call('GET', '/api/themes/me/active');
++ assert.deepStrictEqual([r.body.theme_id, r.body.theme_at], ['nord', nordAt]);
++ r = await call('PUT', '/api/themes/me', { theme_id: 'ember', at: nordAt - 5000 });
++ assert.deepStrictEqual([r.body.stale, r.body.theme_id], [true, 'nord'], 'an older theme choice is refused');
++ const history = await db.prepare('SELECT COUNT(*) AS n FROM user_theme_history WHERE user_id = 61').get().catch(() => ({ n: null }));
++ r = await call('PUT', '/api/themes/me', { theme_id: 'ember' });
++ assert.deepStrictEqual([r.body.stale, r.body.theme_id], [undefined, 'ember']);
++ if (history.n != null) {
++ const after = await db.prepare('SELECT COUNT(*) AS n FROM user_theme_history WHERE user_id = 61').get();
++ assert.strictEqual(Number(after.n), Number(history.n) + 1, 'only the accepted change is kept in the history');
++ }
++
++ server.close();
++ console.log('theme look dates: dated copies, an older write refused with the newer copy, wrong clocks clamped');
++ 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 5073ab12..e63d8070 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'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', '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'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', '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');