openvibers/openvibe.network Commits
The account's look is dated: an older write no longer undoes a newer choice (L42) (#458)
Changes
nnew file mode 100644@@@ -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;@@@ -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 ─────────────────────────────────@@@ -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.nnew file mode 100644@@@ -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); });@@@ -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 twiceassert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -125,6 +125,22 @@ async function overrideOf(db, userId, service) {
@@ -125,6 +125,22 @@ async function overrideOf(db, userId, service) {
} catch { return null; /* the table arrives with migration 0028 */ }
} 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 ──────────────────────────────────
// ── Get User's Active Theme ──────────────────────────────────
// A request with no credentials at all is a guest asking "do I have a theme?": answer "no" with
// A request with no credentials at all is a guest asking "do I have a theme?": answer "no" with
// a 200 so every page view by a guest does not log a failed request in the console.
// a 200 so every page view by a guest does not log a failed request in the console.
@@ -143,12 +159,14 @@ router.get('/me/active', (req, res, next) => {
@@ -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);
const prefs = await db.prepare('SELECT theme_id, custom_theme_variables FROM user_preferences WHERE user_id = ?').get(userId);
let display = 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 */ }
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 });
if (!prefs) return res.json({ theme_id: 'vibe', custom_variables: null, override: false });
let custom = null;
let custom = null;
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
try { custom = prefs.custom_theme_variables ? JSON.parse(prefs.custom_theme_variables) : null; } catch {}
- 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 ──
// ── Display preferences — follow the account to every site ──
@@ -171,9 +189,18 @@ router.put('/me/display', requireAuth, async (req, res) => {
@@ -171,9 +189,18 @@ router.put('/me/display', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
const display = cleanDisplay(req.body) || {};
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())
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));
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) ──
// ── Your sites in the site switcher (openvibe-shared switcher.js, 2026-10-30) ──
@@ -255,6 +282,12 @@ router.put('/me', requireAuth, async (req, res) => {
@@ -255,6 +282,12 @@ router.put('/me', requireAuth, async (req, res) => {
const db = await getDb(req);
const db = await getDb(req);
const userId = req.user.sub || req.user.id;
const userId = req.user.sub || req.user.id;
const { theme_id, custom_variables } = req.body;
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);
ensureReview(db);
if (theme_id) {
if (theme_id) {
@@ -285,7 +318,8 @@ router.put('/me', requireAuth, async (req, res) => {
@@ -285,7 +318,8 @@ router.put('/me', requireAuth, async (req, res) => {
req.body.custom_variables ? JSON.stringify(req.body.custom_variables) : null
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 ─────────────────────────────────
// ── Per-site theme overrides ─────────────────────────────────
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['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.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,80 @@
@@ -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); });
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
@@ -52,7 +52,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['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');
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');