openvibers/openvibe.live Commits

Site settings as revisioned configuration (WS-C task 7): live.site_settings on openvibe-shared/config 1.14.0 journals every admin change to site_settings as a revision with who and why (bulk and single PUT, DELETE), rows written around it are recorded as a sync revision first so nothing is reverted, history with secrets as keyed fingerprints (GET /api/admin/config…), owner-only rollback; readers (db.getSetting) unchanged; job state and the money freeze stay outside; pins shared 1.14.0, contracts 0.52.0

OP OpenVibers committed 26 Sept 2026 · c7cb6285a0 · 5 files, +277 −17


Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 4892c234..82fec607 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -25,9 +25,9 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.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",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
@@@ -2814,9 +2814,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.49.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
-- "integrity": "sha512-lapsLoBm3ZpndgiOMELdCBh2PJtuuyqj6SUhQsfwJkxnLdQfyhXfRFgiuLh+iQrg4kqtG9611191Mp8zAfk7fQ==",
++ "version": "0.52.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.0",
++ "integrity": "sha512-EVkhyQwoA291JmH9K1Yt9lddL1SJgjJxXB2ej7mkCjGaHnn7GHuSyXddwmd8SS6WWxvZvrV8w+glZaGTRwz/Pg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -2839,9 +2839,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 e14cd882..0c2bb62d 100644
---- a/package.json
++++ b/package.json
@@@ -43,9 +43,9 @@
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.49.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.52.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",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex e94678c5..34598a8b 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -638,7 +638,11 @@ router.post('/ai/streamer/:userId/overview', async (req, res) => {
});
// ── Update Settings (bulk) ───────────────────────────────────
--router.put('/settings', (req, res) => {
++// Configuration keys are one revision of live.site_settings (server/admin/site-config.js: who, why,
++// history, rollback); keys that are not configuration (job state) are written as before.
++const siteConfig = require('./site-config');
++const configError = (res, err) => (err && err.status && err.code ? res.status(err.status).json({ error: err.message, code: err.code, errors: err.errors || undefined }) : null);
++router.put('/settings', async (req, res) => {
try {
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
@@@ -676,8 +680,15 @@ router.put('/settings', (req, res) => {
if (Object.keys(rejected).length) {
return res.status(400).json({ error: `Rejected ${Object.keys(rejected).length} invalid setting(s): ${Object.keys(rejected).join(', ')}`, rejected, saved: [] });
}
-- db.getDb().transaction(() => { for (const [k, v] of writes) db.setSetting(k, v); })();
-- if (writes.length) console.log(`[Admin] Settings updated by ${req.user?.username || req.user?.id}: ${writes.map(w => w[0]).join(', ')}`);
++ const configWrites = writes.filter(([k]) => siteConfig.isConfigKey(k));
++ const otherWrites = writes.filter(([k]) => !siteConfig.isConfigKey(k));
++ let revision = null;
++ if (configWrites.length) {
++ const snap = await siteConfig.change({ set: Object.fromEntries(configWrites) }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `admin settings: ${configWrites.map((w) => w[0]).join(', ')}`).slice(0, 300) });
++ revision = snap.revision;
++ }
++ if (otherWrites.length) db.getDb().transaction(() => { for (const [k, v] of otherWrites) db.setSetting(k, v); })();
++ if (writes.length) console.log(`[Admin] Settings updated by ${req.user?.username || req.user?.id}: ${writes.map(w => w[0]).join(', ')}${revision ? ` (revision ${revision})` : ''}`);
const parts = [`${writes.length} updated`];
if (unchanged) parts.push(`${unchanged} unchanged`);
if (blocked) parts.push(`${blocked} owner-only setting${blocked === 1 ? '' : 's'} skipped`);
@@@ -685,16 +696,18 @@ router.put('/settings', (req, res) => {
message: `Settings updated (${parts.join(', ')})`,
saved: writes.map(w => w[0]),
rejected,
++ revision,
settings: permissions.redactSettingsForUser(db.getAllSettings(), req.user),
});
} catch (err) {
++ if (configError(res, err)) return;
console.error('[Admin] Settings update error:', err.message);
res.status(500).json({ error: 'Failed to update settings' });
}
});
// ── Update Single Setting ────────────────────────────────────
--router.put('/settings/:key', (req, res) => {
++router.put('/settings/:key', async (req, res) => {
try {
const { value } = req.body;
if (value === undefined) {
@@@ -703,26 +716,56 @@ router.put('/settings/:key', (req, res) => {
if (permissions.isSensitiveSettingKey(req.params.key) && !permissions.isOwner(req.user)) {
return res.status(403).json({ error: 'Only the owner can change API keys / money settings' });
}
-- db.setSetting(req.params.key, value);
++ if (siteConfig.isConfigKey(req.params.key)) {
++ await siteConfig.change({ set: { [req.params.key]: value } }, { actor: siteConfig.actorOf(req.user), reason: String(req.body.reason || `admin setting ${req.params.key}`).slice(0, 300) });
++ } else db.setSetting(req.params.key, value);
res.json({ message: 'Setting updated', setting: db.getSettingRow(req.params.key) });
} catch (err) {
++ if (configError(res, err)) return;
res.status(500).json({ error: 'Failed to update setting' });
}
});
// ── Delete Setting ───────────────────────────────────────────
--router.delete('/settings/:key', (req, res) => {
++router.delete('/settings/:key', async (req, res) => {
try {
if (permissions.isSensitiveSettingKey(req.params.key) && !permissions.isOwner(req.user)) {
return res.status(403).json({ error: 'Only the owner can change API keys / money settings' });
}
-- db.deleteSetting(req.params.key);
++ if (siteConfig.isConfigKey(req.params.key)) {
++ await siteConfig.change({ unset: [req.params.key] }, { actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || `admin deleted ${req.params.key}`).slice(0, 300) });
++ } else db.deleteSetting(req.params.key);
res.json({ message: 'Setting deleted' });
} catch (err) {
++ if (configError(res, err)) return;
res.status(500).json({ error: 'Failed to delete setting' });
}
});
++// ── Configuration history (openvibe-shared/config, namespace live.site_settings) ──
++// GET /api/admin/config (current revision, values with secrets as fingerprints), GET /config/:namespace,
++// GET /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.getStore()], {
++ requireAdmin: (_q, _s, next) => next(), basePath: '/config', actor: (q) => siteConfig.actorOf(q.user),
++}));
++router.get('/config', (req, res, next) => configHandlers().list(req, res, next));
++router.get('/config/:namespace', (req, res, next) => configHandlers().get(req, res, next));
++router.get('/config/:namespace/history', (req, res, next) => configHandlers().history(req, res, next));
++router.post('/config/:namespace/rollback', async (req, res) => {
++ try {
++ if (req.params.namespace !== 'live.site_settings') return res.status(404).json({ error: 'No such configuration namespace' });
++ if (!permissions.isOwner(req.user)) return res.status(403).json({ error: 'Only the owner can roll the site configuration back' });
++ const to = req.body && req.body.to != null ? Number(req.body.to) : undefined;
++ const snap = await siteConfig.rollback({ actor: siteConfig.actorOf(req.user), reason: String((req.body && req.body.reason) || 'rollback').slice(0, 300), to });
++ res.json(snap);
++ } catch (err) {
++ if (configError(res, err)) return;
++ res.status(500).json({ error: 'Failed to roll back' });
++ }
++});
++
// ═══════════════════════════════════════════════════════════════
// Global Moderators
// ═══════════════════════════════════════════════════════════════
ddiff --git a/server/admin/site-config.js b/server/admin/site-config.js
nnew file mode 100644
iindex 00000000..21f79e4f
---- /dev/null
++++ b/server/admin/site-config.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * Live's site settings as revisioned configuration (roadmap WS-C task 7; openvibe-shared/config).
++ *
++ * `site_settings` stays what every reader reads: db.getSetting is unchanged for its callers. The
++ * namespace `live.site_settings` is the journal of changes to those rows:
++ * - an admin change is one revision, recorded with who made it and why, and written to the rows from
++ * the revision (only the keys it changes);
++ * - history and rollback come with it (GET /api/admin/config…, POST …/rollback);
++ * - secret-class values (API keys, client secrets, webhook secrets, salts) are only ever shown as
++ * keyed fingerprints.
++ * Rows written around the journal (a direct db.setSetting by a job or 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, and never in the namespace (NOT_CONFIG): machine state that jobs keep in
++ * site_settings (cursors, the daily star, the last deploy notice, one-off migration flags, old
++ * storage_tier.* rows), and the money freeze, which has its own owner-only, audited path
++ * (server/monetization/money-authority.js) that no configuration rollback may undo.
++ */
++const config = require('openvibe-shared/config');
++const db = require('../db/database');
++
++const NOT_CONFIG = /^(arena_backfill_cursor_|star_streamer$|deploy_last_announced$|relay_users_backfilled$|bucks_bits_migration_done$|stats_vibes_reset_at$|storage_tier\.|money_writes_frozen)/;
++const SECRET = /(api[_-]?key|secret|token|password|client_secret|service_account|private[_-]?key|salt|access_key)/i;
++const PUBLIC_KEYS = new Set(['site_name', 'site_description', 'motd', 'registration_open', 'require_email', 'nsfw_enabled']);
++const SYSTEM = { type: 'service', id: 'live' };
++
++const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
++const classify = (key) => (SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
++
++/** The configuration rows as they are now: key → the stored string. */
++function rowsNow() {
++ const out = {};
++ for (const r of db.getAllSettings()) if (isConfigKey(r.key)) out[r.key] = r.value == null ? '' : String(r.value);
++ return out;
++}
++
++/** Write the revision's values to the rows: set what differs, delete what the revision no longer has. */
++function writeRows(target) {
++ const now = rowsNow();
++ db.getDb().transaction(() => {
++ for (const [k, v] of Object.entries(target)) if (isConfigKey(k) && now[k] !== String(v)) db.setSetting(k, String(v));
++ for (const k of Object.keys(now)) if (!(k in target)) db.deleteSetting(k);
++ })();
++}
++
++let store = null;
++function getStore() {
++ if (store) return store;
++ store = config.createConfigStore({
++ db: db.getDb(), service: 'live', namespace: 'live.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}`) },
++ });
++ return store;
++}
++
++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 s = getStore();
++ const now = rowsNow();
++ if (canonical({ ...s.get() }) === canonical(now)) return null;
++ return s.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
++}
++
++/**
++ * Change settings: set (key → string) and unset (keys), as one revision by `actor` for `reason`.
++ * Keys that are not configuration are refused (the caller writes those directly). → the new snapshot
++ */
++async function change({ set = {}, unset = [] } = {}, { 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();
++ const values = {};
++ for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');
++ return getStore().apply(values, { merge: true, unset, actor, reason });
++}
++
++/** Roll back to the previous good revision (or `to`) after recording any outside changes. */
++async function rollback({ actor = SYSTEM, reason = null, to } = {}) {
++ await sync();
++ return getStore().rollback({ actor, reason, to });
++}
++
++/** A person as the journal records them. */
++function actorOf(user) {
++ if (user && user.subject_id) return { type: 'user', id: String(user.subject_id) };
++ return SYSTEM;
++}
++
++module.exports = { getStore, change, rollback, sync, isConfigKey, classify, actorOf, NOT_CONFIG, _reset: () => { store = null; } };
ddiff --git a/test/site-config.test.js b/test/site-config.test.js
nnew file mode 100644
iindex 00000000..d1c6180f
---- /dev/null
++++ b/test/site-config.test.js
@@@ -0,0 +1,122 @@
++/**
++ * Live's site settings as revisioned configuration (WS-C task 7; server/admin/site-config.js).
++ *
++ * - revision 1 is the configuration rows as they were; job state and the money freeze are not in it;
++ * - an admin change through /api/admin/settings is one revision with who and why, written to the rows;
++ * db.getSetting reads exactly what it read before;
++ * - a row written around the journal (a job) is recorded as a sync revision before the next change, so
++ * neither the change nor a rollback reverts it;
++ * - history shows secrets only as fingerprints; the owner may roll back, an admin may not.
++ *
++ * node test/site-config.test.js
++ */
++'use strict';
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const tmp = path.join(os.tmpdir(), `ov-site-config-${process.pid}.db`);
++process.env.DB_PATH = tmp;
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++console.error = () => {};
++const db = require('../server/db/database');
++db.initDb();
++const raw = db.getDb();
++const auth = require('../server/auth/auth');
++const permissions = require('../server/auth/permissions');
++const signIn = (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? db.getUserById(id) : null;
++ if (u) { req.user = { ...u, subject_id: u.id === 1 ? 'usr_01KKT9AC60KM7CRTB3WN1Z8P56' : 'usr_01KRMBAEEGCF1Z34D5WXXAP3TA' }; req.authSource = 'network'; }
++ return u;
++};
++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
++permissions.requireAdmin = (req, res, next) => (req.user && ['admin'].includes(req.user.role) ? next() : res.status(403).json({ error: 'admin only' }));
++const add = (id, username, role) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
++add(1, 'owner', 'admin');
++add(2, 'helper', 'admin');
++const realIsOwner = permissions.isOwner;
++permissions.isOwner = (u) => !!u && Number(u.id) === 1;
++
++// What production looks like: configuration, a secret, job state and the money freeze in one table.
++for (const [k, v, t] of [['motd', 'hello', 'string'], ['max_clip_duration', '60', 'number'], ['stripe_secret_key', 'sk_live_verysecret', 'string'],
++ ['star_streamer', 'alice', 'string'], ['arena_backfill_cursor_3', '100', 'string'], ['money_writes_frozen', 'false', 'boolean']]) {
++ raw.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(k, v, t);
++}
++
++const express = require('express');
++const app = express();
++app.use(express.json());
++app.use('/api/admin', auth.requireAuth, permissions.requireAdmin, require('../server/admin/routes'));
++const server = http.createServer(app).listen(0);
++const call = async (method, p, body, user = 1) => {
++ const r = await fetch(`http://127.0.0.1:${server.address().port}${p}`, { method, headers: { 'x-test-user': String(user), 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++};
++
++(async () => {
++ await new Promise((r) => server.once('listening', r));
++ const siteConfig = require('../server/admin/site-config');
++ const store = siteConfig.getStore();
++
++ // ── Revision 1: the configuration rows, nothing else ──
++ assert.strictEqual(store.revision(), 1);
++ const keys = Object.keys(store.get());
++ for (const k of ['max_clip_duration', 'motd', 'stripe_secret_key']) assert.ok(keys.includes(k), `${k} is configuration`);
++ for (const k of ['star_streamer', 'arena_backfill_cursor_3', 'money_writes_frozen']) assert.ok(!keys.includes(k), `${k} is not`);
++ assert.strictEqual(db.getSetting('max_clip_duration'), 60, 'readers read the rows, typed as before');
++
++ // ── An admin change: one revision, written to the rows ──
++ let r = await call('PUT', '/api/admin/settings', { settings: { motd: 'welcome', max_clip_duration: '90' }, reason: 'longer clips' }, 2);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.strictEqual(r.body.revision, 2);
++ assert.deepStrictEqual([db.getSetting('motd'), db.getSetting('max_clip_duration')], ['welcome', 90]);
++ assert.strictEqual(raw.prepare("SELECT type FROM site_settings WHERE key = 'max_clip_duration'").get().type, 'number', 'the type column is kept');
++
++ // ── A job writes around the journal; the next change records it first and never reverts it ──
++ db.setSetting('motd', 'set by a script');
++ db.setSetting('star_streamer', 'bob');
++ r = await call('PUT', '/api/admin/settings/max_clip_duration', { value: '120' }, 2);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([store.revision(), db.getSetting('motd'), db.getSetting('max_clip_duration'), db.getSetting('star_streamer')], [4, 'set by a script', 120, 'bob'], 'a sync revision (3), then the change (4)');
++
++ // ── History: who, why, secrets as fingerprints ──
++ r = await call('GET', '/api/admin/config/live.site_settings/history', undefined, 2);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual(r.body.snapshots.map((x) => x.revision), [4, 3, 2, 1]);
++ assert.deepStrictEqual(r.body.snapshots[2].created_by, { type: 'user', id: 'usr_01KRMBAEEGCF1Z34D5WXXAP3TA' });
++ assert.strictEqual(r.body.snapshots[2].reason, 'longer clips');
++ assert.match(r.body.snapshots[1].reason, /^sync: /);
++ assert.ok(!JSON.stringify(r.body).includes('sk_live_verysecret'), 'the secret never leaves');
++ assert.strictEqual(r.body.snapshots[0].values.stripe_secret_key.redacted, true);
++ r = await call('GET', '/api/admin/config', undefined, 2);
++ assert.strictEqual(r.body.namespaces[0].namespace, 'live.site_settings');
++
++ // ── Rollback: owner only; undoes the last change, not the script's row, not job state ──
++ assert.strictEqual((await call('POST', '/api/admin/config/live.site_settings/rollback', { reason: 'too long' }, 2)).status, 403);
++ r = await call('POST', '/api/admin/config/live.site_settings/rollback', { reason: 'too long' }, 1);
++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
++ assert.deepStrictEqual([db.getSetting('max_clip_duration'), db.getSetting('motd'), db.getSetting('star_streamer'), db.getSetting('stripe_secret_key')], [90, 'set by a script', 'bob', 'sk_live_verysecret']);
++
++ // ── Deleting a setting is a revision too; job state is written as before; the money freeze is never touched ──
++ r = await call('DELETE', '/api/admin/settings/motd', undefined, 2);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(db.getSetting('motd'), null);
++ assert.ok(!('motd' in store.get()));
++ r = await call('PUT', '/api/admin/settings', { settings: { arena_backfill_cursor_3: '200' } }, 2);
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(db.getSetting('arena_backfill_cursor_3'), '200');
++ assert.ok(!('arena_backfill_cursor_3' in store.get()), 'job state stays out of the journal');
++ assert.strictEqual(db.getSetting('money_writes_frozen'), false);
++ await assert.rejects(siteConfig.change({ set: { money_writes_frozen: 'true' } }), /not configuration/);
++
++ permissions.isOwner = realIsOwner;
++ server.close();
++ try { fs.unlinkSync(tmp); } catch { /* */ }
++ quiet('site config: all checks passed');
++ process.exit(0);
++})().catch((err) => { quiet(err); process.exit(1); });