openvibers/openvibe.network Commits

Site settings as revisioned configuration (WS-C task 7): network.site_settings on openvibe-shared/config 1.14.0 journals every admin change (PUT /api/admin/settings, /email, /discord) as a revision with who and why, rows keep their type (a new row takes the caller's), rows written around it are recorded as a sync revision first, history with secrets as keyed fingerprints and owner-only rollback (GET/POST /api/admin/config…); readers unchanged; migration flags and the push key pair stay outside

OP OpenVibers committed 26 Sept 2026 · cc405d5841 · 6 files, +273 −18


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 83afe7be..7bfc90f2 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
@@@ -1490,9 +1490,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "1.13.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
-- "integrity": "sha512-aodJtwID5ZVMc8usa0hxf2YZoG8AivhI65mrk0sv6i3EHYCV9vTJCtaALpxvgsZnTzYtSg08CtzQh8GfXmcmtg==",
++ "version": "1.14.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
++ "integrity": "sha512-pID6sc4YQVyI0AeSmQGms3KGWxBxvES8dOXdkYmitIieINwSjeBbFcmzBTwhWoob2i02Pt/FT3rBBrykajKTLA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex f60d7363..6cee0ccb 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
@@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.13.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.14.0",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
}
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex fa72dac2..57da5aaf 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -168,6 +168,7 @@ async function refreshServiceByKey(req, key) {
}
function createAdminRoutes(db, notificationService, emailService, requireAuth) {
++ const siteConfig = require('./site-config');
function getEmailMetrics() {
const summary = {
@@@ -250,14 +251,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
});
// PUT /api/admin/email — update email config (owner-only: writes resend_api_key)
-- router.put('/email', requireOwner, (req, res) => {
++ router.put('/email', requireOwner, async (req, res) => {
try {
const { enabled, api_key, from_email, from_name,
from_email_openvibelive, from_email_openvibegames, from_email_openvibenetwork } = req.body;
-- const setSetting = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ // One revision of network.site_settings (./site-config.js): who, why, history, rollback.
++ const changes = { set: {}, types: {} };
++ const setSetting = { run: (k, v, type) => { changes.set[k] = v; changes.types[k] = type; } };
const skipped = [];
-- const tx = db.transaction(() => {
++ const collect = () => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
// Only update API key if it's not a masked placeholder, and never while RESEND_API_KEY
// provides it (server/secrets.js): the environment is the source then.
@@@ -271,8 +274,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (from_email_openvibelive !== undefined) setSetting.run('email_from_openvibelive', from_email_openvibelive, 'string');
if (from_email_openvibegames !== undefined) setSetting.run('email_from_openvibegames', from_email_openvibegames, 'string');
if (from_email_openvibenetwork !== undefined) setSetting.run('email_from_openvibenetwork', from_email_openvibenetwork, 'string');
-- });
-- tx();
++ };
++ collect();
++ if (Object.keys(changes.set).length) await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || 'email settings').slice(0, 300) });
emailService.reload();
@@@ -283,6 +287,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
res.json({ ok: true, email: emailService.getStatus(), ...(skipped.length ? { skipped, note: 'set in the environment; not saved to the database' } : {}) });
} catch (err) {
++ if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code, errors: err.errors });
res.status(500).json({ ok: false, error: err.message });
}
});
@@@ -347,7 +352,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
});
-- router.put('/settings', requireOwner, (req, res) => {
++ router.put('/settings', requireOwner, async (req, res) => {
try {
const { key, value, type } = req.body;
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
@@@ -363,12 +368,41 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (secrets.isManaged(key) && secrets.source(db, key) === 'env') {
return res.json({ ok: true, skipped: true, source: 'env', env: secrets.envName(key), note: `set in the environment (${secrets.envName(key)}); not saved to the database` });
}
-- db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');
++ let revision = null;
++ if (siteConfig.isConfigKey(key)) {
++ revision = (await siteConfig.forDb(db).change({ set: { [key]: String(value) }, types: { [key]: type || 'string' } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `setting ${key}`).slice(0, 300) })).revision;
++ } else {
++ db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');
++ }
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'setting_update', JSON.stringify({ key })
);
-- res.json({ ok: true });
++ res.json({ ok: true, ...(revision ? { revision } : {}) });
++ } catch (err) {
++ if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code, errors: err.errors });
++ res.status(500).json({ ok: false, error: err.message });
++ }
++ });
++
++ // ── Configuration history (openvibe-shared/config, namespace network.site_settings), owner only ──
++ // GET /config, /config/:namespace, /config/:namespace/history; POST /config/:namespace/rollback { to?, reason }
++ // (the rollback first records rows changed outside the journal, so it only undoes configuration changes).
++ let configRoutes = null;
++ const configHandlers = () => configRoutes || (configRoutes = require('openvibe-shared/config').adminRoutes([siteConfig.forDb(db).store], {
++ requireAdmin: (_q, _s, next) => next(), basePath: '/config', actor: (q) => siteConfig.actorOf(q.user),
++ }));
++ router.get('/config', requireOwner, (req, res, next) => configHandlers().list(req, res, next));
++ router.get('/config/:namespace', requireOwner, (req, res, next) => configHandlers().get(req, res, next));
++ router.get('/config/:namespace/history', requireOwner, (req, res, next) => configHandlers().history(req, res, next));
++ router.post('/config/:namespace/rollback', requireOwner, async (req, res) => {
++ try {
++ if (req.params.namespace !== 'network.site_settings') return res.status(404).json({ ok: false, error: 'No such configuration namespace' });
++ const to = req.body && req.body.to != null ? Number(req.body.to) : undefined;
++ const snap = await siteConfig.forDb(db).rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });
++ db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'setting_rollback', JSON.stringify({ revision: snap.revision }));
++ res.json(snap);
} catch (err) {
++ if (err && err.status && err.code) return res.status(err.status).json({ ok: false, error: err.message, code: err.code });
res.status(500).json({ ok: false, error: err.message });
}
});
ddiff --git a/server/admin/site-config.js b/server/admin/site-config.js
nnew file mode 100644
iindex 00000000..b85d6496
---- /dev/null
++++ b/server/admin/site-config.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * Network's site settings as revisioned configuration (roadmap WS-C task 7; openvibe-shared/config).
++ *
++ * `site_settings` stays what everything reads: db getSetting (environment first for managed secrets,
++ * server/secrets.js, then the typed row) is unchanged. The namespace `network.site_settings` is the
++ * journal of changes to those rows:
++ * - an admin change (PUT /api/admin/settings, /api/admin/email, /api/admin/discord) is one revision,
++ * recorded with who made it and why, and written to the rows from the revision (only the keys it
++ * changes; a row keeps its type, and a new row takes the type its caller gave);
++ * - history and rollback come with it (GET /api/admin/config…, POST …/rollback, owner only);
++ * - secret-class values (type `secret` rows, API keys, tokens, secrets) are only ever shown as keyed
++ * fingerprints.
++ * Rows written around the journal (a migration, an older code path) are recorded as a "sync" revision
++ * before the next change, so a change or a rollback never reverts them.
++ *
++ * Not configuration, never in the namespace (NOT_CONFIG): one-off migration flags, and the generated
++ * push key pair (vapid_*), which a rollback must never swap.
++ */
++const config = require('openvibe-shared/config');
++
++const NOT_CONFIG = /^(migr_|vapid_)/;
++const SECRET = /(api[_-]?key|secret|token|password|private[_-]?key|access_key)/i;
++const PUBLIC_KEYS = new Set(['platform_name', 'registration_open', 'default_theme']);
++const SYSTEM = { type: 'service', id: 'network' };
++const byDb = new WeakMap();
++
++const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
++
++/** The journal for one database handle. */
++function forDb(db) {
++ if (byDb.has(db)) return byDb.get(db);
++ const types = new Map(); // key → the row type last seen (a rollback that re-creates a row reuses it)
++ const secretRows = new Set(); // keys stored with type 'secret'
++
++ function rowsNow() {
++ const out = {};
++ for (const r of db.prepare('SELECT key, value, type FROM site_settings').all()) {
++ if (!isConfigKey(r.key)) continue;
++ out[r.key] = r.value == null ? '' : String(r.value);
++ types.set(r.key, r.type || 'string');
++ if (r.type === 'secret') secretRows.add(r.key);
++ }
++ return out;
++ }
++ const classify = (key) => (secretRows.has(key) || SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
++
++ let pendingTypes = {}; // the types the change being applied names for new rows
++ function writeRows(target) {
++ const now = rowsNow();
++ const upd = db.prepare('UPDATE site_settings SET value = ? WHERE key = ?');
++ const ins = db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ const del = db.prepare('DELETE FROM site_settings WHERE key = ?');
++ db.transaction(() => {
++ for (const [k, v] of Object.entries(target)) {
++ if (!isConfigKey(k)) continue;
++ if (!(k in now)) ins.run(k, String(v), pendingTypes[k] || types.get(k) || 'string');
++ else if (now[k] !== String(v)) upd.run(String(v), k);
++ }
++ for (const k of Object.keys(now)) if (!(k in target)) del.run(k);
++ })();
++ }
++
++ rowsNow(); // learn the types and secret rows before the store classifies anything
++ const store = config.createConfigStore({
++ db, service: 'network', namespace: 'network.site_settings',
++ classify,
++ legacy: () => rowsNow(),
++ onActivate: async (values) => writeRows(values),
++ log: { info: (m) => console.log(`[Config] ${m}`), warn: (m) => console.warn(`[Config] ${m}`), error: (m) => console.error(`[Config] ${m}`) },
++ });
++ const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
++
++ /** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */
++ async function sync() {
++ const now = rowsNow();
++ if (canonical({ ...store.get() }) === canonical(now)) return null;
++ return store.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
++ }
++
++ /** Change settings as one revision: set (key → value), types (key → row type for new rows), unset (keys). */
++ async function change({ set = {}, unset = [], types: t = {} } = {}, { actor = SYSTEM, reason = null } = {}) {
++ const bad = [...Object.keys(set), ...unset].filter((k) => !isConfigKey(k));
++ if (bad.length) throw Object.assign(new Error(`not configuration: ${bad.join(', ')}`), { status: 400, code: 'config.not_configuration' });
++ await sync();
++ for (const [k, ty] of Object.entries(t)) if (ty === 'secret') secretRows.add(k);
++ const values = {};
++ for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');
++ pendingTypes = t;
++ try { return await store.apply(values, { merge: true, unset, actor, reason }); } finally { pendingTypes = {}; }
++ }
++
++ async function rollback({ actor = SYSTEM, reason = null, to } = {}) {
++ await sync();
++ return store.rollback({ actor, reason, to });
++ }
++
++ const out = { store, change, rollback, sync, isConfigKey };
++ byDb.set(db, out);
++ return out;
++}
++
++/** A person as the journal records them. */
++function actorOf(user) {
++ return user && user.subject_id ? { type: 'user', id: String(user.subject_id) } : SYSTEM;
++}
++
++module.exports = { forDb, actorOf, isConfigKey, NOT_CONFIG };
ddiff --git a/server/discord/routes.js b/server/discord/routes.js
iindex 8bc849b7..3e2ce7ea 100644
---- a/server/discord/routes.js
++++ b/server/discord/routes.js
@@@ -46,9 +46,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
'discord_oauth_client_id', 'discord_oauth_client_secret',
];
-- const upsert = db.prepare(
-- 'INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)'
-- );
++ const changes = { set: {}, types: {} };
let tokenChanged = false;
const owner = isOwner(req.user);
@@@ -65,7 +63,17 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
if (key === 'discord_oauth_client_secret' && (strVal === '••••••••' || strVal === '')) continue;
if (key === 'discord_bot_token') tokenChanged = true;
const type = key === 'discord_dedupe_minutes' ? 'number' : 'string';
-- upsert.run(key, strVal, type);
++ changes.set[key] = strVal;
++ changes.types[key] = type;
++ }
++ // One revision of network.site_settings (server/admin/site-config.js): who, why, history, rollback.
++ if (Object.keys(changes.set).length) {
++ const siteConfig = require('../admin/site-config');
++ try {
++ await siteConfig.forDb(db).change(changes, { actor: siteConfig.actorOf(req.user), reason: 'Discord settings' });
++ } catch (err) {
++ return res.status(err.status || 500).json({ ok: false, error: err.message, code: err.code, errors: err.errors });
++ }
}
// Reinit bot if token changed
ddiff --git a/test/site-config.test.js b/test/site-config.test.js
nnew file mode 100644
iindex 00000000..5274378f
---- /dev/null
++++ b/test/site-config.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++// Network's site settings as revisioned configuration (WS-C task 7; server/admin/site-config.js):
++// revision 1 is the configuration rows (migration flags and the push key pair stay out); PUT
++// /api/admin/settings, /api/admin/email and /api/admin/discord each make one revision with who and why,
++// written to the rows (types kept, a new row takes the caller's type); db.getSetting reads what it read
++// before; a row written around the journal is recorded as a sync revision first and never reverted;
++// history shows secret-class values only as fingerprints; the owner rolls back.
++// node test/site-config.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { initDb } = require('../server/db/database');
++for (const n of ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY']) delete process.env[n];
++process.env.OWNER_USERNAME = 'owner';
++const quiet = async (fn) => { const l = console.log, w = console.warn; console.log = () => {}; console.warn = () => {}; try { return await fn(); } finally { console.log = l; console.warn = w; } };
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-netcfg-'));
++ const db = await quiet(() => initDb(path.join(dir, 'network.db')));
++ const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ const row = (k) => db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(k);
++ set.run('discord_bot_token', 'example-bot-token-not-real', 'secret');
++ set.run('migr_pref_email_null', 'true', 'boolean');
++ set.run('vapid_private_key', 'example-vapid-private', 'secret');
++
++ const createAdminRoutes = require('../server/admin/routes');
++ const createDiscordRoutes = require('../server/discord/routes');
++ const { EmailService } = require('../server/notifications/email-service');
++ const email = await quiet(() => new EmailService(db));
++ const app = express();
++ app.use(express.json());
++ app.locals.db = db;
++ let who = { id: 1, username: 'owner', role: 'admin', subject_id: 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' };
++ const requireAuth = (req, _res, next) => { req.user = who; next(); };
++ const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
++ db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin'), (2, 'helper', 'x', 'admin')").run();
++ app.use('/api/admin/discord', createDiscordRoutes(db, { getStatus: () => ({ connected: false }), reinit: async () => {} }, requireAuth, requireAdmin));
++ app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const call = (method, p, body) => fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined })
++ .then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
++ const cfg = require('../server/admin/site-config').forDb(db);
++
++ // ── Revision 1: configuration only ──
++ await quiet(() => cfg.sync());
++ const keys = Object.keys(cfg.store.get());
++ assert.ok(keys.includes('discord_bot_token') && keys.includes('email_user_daily_cap'), 'configuration is in');
++ assert.ok(!keys.includes('migr_pref_email_null') && !keys.includes('vapid_private_key'), 'migration flags and the push key pair are not');
++ const rev1 = cfg.store.revision();
++
++ // ── A setting: one revision, the row written with its type, readers unchanged ──
++ let r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '9', type: 'number', reason: 'fewer alerts' }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.revision, rev1 + 1);
++ assert.deepStrictEqual(row('stream_live_daily_cap'), { value: '9', type: 'number' });
++ assert.strictEqual(db.getSetting('stream_live_daily_cap'), 9);
++ r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'brand_new_flag', value: 'true', type: 'boolean' }));
++ assert.deepStrictEqual(row('brand_new_flag'), { value: 'true', type: 'boolean' }, 'a new row takes the given type');
++
++ // ── Email and Discord changes are revisions too ──
++ r = await quiet(() => call('PUT', '/api/admin/email', { from_name: 'OpenVibe Team' }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(row('email_from_name').value, 'OpenVibe Team');
++ r = await quiet(() => call('PUT', '/api/admin/discord', { settings: { discord_dedupe_minutes: '15', discord_bot_token: 'example-new-bot-token' } }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(row('discord_dedupe_minutes'), { value: '15', type: 'number' });
++ assert.deepStrictEqual(row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');
++
++ // ── Written around the journal: recorded first, never reverted ──
++ set.run('email_daily_cap', '777', 'number');
++ const before = cfg.store.revision();
++ r = await quiet(() => call('PUT', '/api/admin/settings', { key: 'stream_live_daily_cap', value: '12', type: 'number' }));
++ assert.strictEqual(cfg.store.revision(), before + 2, 'a sync revision, then the change');
++ assert.strictEqual(row('email_daily_cap').value, '777');
++
++ // ── History: who, why, secrets as fingerprints ──
++ r = await call('GET', '/api/admin/config/network.site_settings/history?limit=50');
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ const text = JSON.stringify(r.body);
++ assert.ok(!text.includes('example-new-bot-token') && !text.includes('example-bot-token-not-real'), 'no secret value leaves');
++ assert.strictEqual(r.body.snapshots[0].values.discord_bot_token.redacted, true);
++ const change = r.body.snapshots.find((x) => x.reason === 'fewer alerts');
++ assert.deepStrictEqual(change.created_by, { type: 'user', id: 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' });
++ assert.ok(r.body.snapshots.some((x) => /^sync: /.test(x.reason)));
++
++ // ── Rollback undoes the last change only; the secret and the outside row stay ──
++ r = await quiet(() => call('POST', '/api/admin/config/network.site_settings/rollback', { reason: 'too many' }));
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([row('stream_live_daily_cap').value, row('email_daily_cap').value, row('discord_bot_token').value], ['9', '777', 'example-new-bot-token']);
++ assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);
++
++ // Only the owner reads or rolls back the configuration.
++ who = { id: 2, username: 'helper', role: 'admin', subject_id: 'usr_01KRMBAEEGCF1Z34D5WXXAP3TA' };
++ assert.strictEqual((await call('GET', '/api/admin/config')).status, 403);
++ assert.strictEqual((await call('POST', '/api/admin/config/network.site_settings/rollback', {})).status, 403);
++
++ server.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ console.log('site config: all checks passed');
++ process.exit(0);
++})().catch((err) => { console.error(err); process.exit(1); });