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
Changes
@@@ -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"@@@ -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"}@@@ -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 });}});nnew file mode 100644@@@ -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 };@@@ -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, rif (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 changednnew file mode 100644@@@ -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); });
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
"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-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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -1490,9 +1490,9 @@
@@ -1490,9 +1490,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"
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development 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",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.54.0",
"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-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",
"uuid": "^11.1.0",
"web-push": "^3.6.7"
"web-push": "^3.6.7"
}
}
@@ -168,6 +168,7 @@ async function refreshServiceByKey(req, key) {
@@ -168,6 +168,7 @@ async function refreshServiceByKey(req, key) {
}
}
function createAdminRoutes(db, notificationService, emailService, requireAuth) {
function createAdminRoutes(db, notificationService, emailService, requireAuth) {
+ const siteConfig = require('./site-config');
function getEmailMetrics() {
function getEmailMetrics() {
const summary = {
const summary = {
@@ -250,14 +251,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -250,14 +251,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
});
});
// PUT /api/admin/email — update email config (owner-only: writes resend_api_key)
// 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 {
try {
const { enabled, api_key, from_email, from_name,
const { enabled, api_key, from_email, from_name,
from_email_openvibelive, from_email_openvibegames, from_email_openvibenetwork } = req.body;
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 skipped = [];
- const tx = db.transaction(() => {
+ const collect = () => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
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
// 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.
// provides it (server/secrets.js): the environment is the source then.
@@ -271,8 +274,9 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -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_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_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');
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();
emailService.reload();
@@ -283,6 +287,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -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' } : {}) });
res.json({ ok: true, email: emailService.getStatus(), ...(skipped.length ? { skipped, note: 'set in the environment; not saved to the database' } : {}) });
} catch (err) {
} 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 });
res.status(500).json({ ok: false, error: err.message });
}
}
});
});
@@ -347,7 +352,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -347,7 +352,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
}
}
});
});
- router.put('/settings', requireOwner, (req, res) => {
+ router.put('/settings', requireOwner, async (req, res) => {
try {
try {
const { key, value, type } = req.body;
const { key, value, type } = req.body;
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
if (!key) return res.status(400).json({ ok: false, error: 'Key required' });
@@ -363,12 +368,41 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -363,12 +368,41 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (secrets.isManaged(key) && secrets.source(db, key) === 'env') {
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` });
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(
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'setting_update', JSON.stringify({ key })
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) {
} 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 });
res.status(500).json({ ok: false, error: err.message });
}
}
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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 };
@@ -46,9 +46,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -46,9 +46,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
'discord_oauth_client_id', 'discord_oauth_client_secret',
'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;
let tokenChanged = false;
const owner = isOwner(req.user);
const owner = isOwner(req.user);
@@ -65,7 +63,17 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -65,7 +63,17 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
if (key === 'discord_oauth_client_secret' && (strVal === '••••••••' || strVal === '')) continue;
if (key === 'discord_oauth_client_secret' && (strVal === '••••••••' || strVal === '')) continue;
if (key === 'discord_bot_token') tokenChanged = true;
if (key === 'discord_bot_token') tokenChanged = true;
const type = key === 'discord_dedupe_minutes' ? 'number' : 'string';
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
// Reinit bot if token changed
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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); });