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
Changes
@@@ -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"@@@ -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",@@@ -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// ═══════════════════════════════════════════════════════════════nnew file mode 100644@@@ -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; } };nnew file mode 100644@@@ -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); });
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
@@ -2814,9 +2814,9 @@
@@ -2814,9 +2814,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2839,9 +2839,9 @@
@@ -2839,9 +2839,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -43,9 +43,9 @@
@@ -43,9 +43,9 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"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-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",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
"sharp": "^0.33.0",
"sharp": "^0.33.0",
@@ -638,7 +638,11 @@ router.post('/ai/streamer/:userId/overview', async (req, res) => {
@@ -638,7 +638,11 @@ router.post('/ai/streamer/:userId/overview', async (req, res) => {
});
});
// ── Update Settings (bulk) ───────────────────────────────────
// ── 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 {
try {
const { settings } = req.body;
const { settings } = req.body;
if (!settings || typeof settings !== 'object') {
if (!settings || typeof settings !== 'object') {
@@ -676,8 +680,15 @@ router.put('/settings', (req, res) => {
@@ -676,8 +680,15 @@ router.put('/settings', (req, res) => {
if (Object.keys(rejected).length) {
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: [] });
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`];
const parts = [`${writes.length} updated`];
if (unchanged) parts.push(`${unchanged} unchanged`);
if (unchanged) parts.push(`${unchanged} unchanged`);
if (blocked) parts.push(`${blocked} owner-only setting${blocked === 1 ? '' : 's'} skipped`);
if (blocked) parts.push(`${blocked} owner-only setting${blocked === 1 ? '' : 's'} skipped`);
@@ -685,16 +696,18 @@ router.put('/settings', (req, res) => {
@@ -685,16 +696,18 @@ router.put('/settings', (req, res) => {
message: `Settings updated (${parts.join(', ')})`,
message: `Settings updated (${parts.join(', ')})`,
saved: writes.map(w => w[0]),
saved: writes.map(w => w[0]),
rejected,
rejected,
+ revision,
settings: permissions.redactSettingsForUser(db.getAllSettings(), req.user),
settings: permissions.redactSettingsForUser(db.getAllSettings(), req.user),
});
});
} catch (err) {
} catch (err) {
+ if (configError(res, err)) return;
console.error('[Admin] Settings update error:', err.message);
console.error('[Admin] Settings update error:', err.message);
res.status(500).json({ error: 'Failed to update settings' });
res.status(500).json({ error: 'Failed to update settings' });
}
}
});
});
// ── Update Single Setting ────────────────────────────────────
// ── Update Single Setting ────────────────────────────────────
-router.put('/settings/:key', (req, res) => {
+router.put('/settings/:key', async (req, res) => {
try {
try {
const { value } = req.body;
const { value } = req.body;
if (value === undefined) {
if (value === undefined) {
@@ -703,26 +716,56 @@ router.put('/settings/:key', (req, res) => {
@@ -703,26 +716,56 @@ router.put('/settings/:key', (req, res) => {
if (permissions.isSensitiveSettingKey(req.params.key) && !permissions.isOwner(req.user)) {
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' });
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) });
res.json({ message: 'Setting updated', setting: db.getSettingRow(req.params.key) });
} catch (err) {
} catch (err) {
+ if (configError(res, err)) return;
res.status(500).json({ error: 'Failed to update setting' });
res.status(500).json({ error: 'Failed to update setting' });
}
}
});
});
// ── Delete Setting ───────────────────────────────────────────
// ── Delete Setting ───────────────────────────────────────────
-router.delete('/settings/:key', (req, res) => {
+router.delete('/settings/:key', async (req, res) => {
try {
try {
if (permissions.isSensitiveSettingKey(req.params.key) && !permissions.isOwner(req.user)) {
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' });
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' });
res.json({ message: 'Setting deleted' });
} catch (err) {
} catch (err) {
+ if (configError(res, err)) return;
res.status(500).json({ error: 'Failed to delete setting' });
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
// Global Moderators
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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; } };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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); });