openvibers/openvibe.shared Commits
Shared 1.28.0: openvibe-shared/config on PostgreSQL. Given an openvibe-sdk/db handle, createConfigStore uses config-pg.js, the same store on the async handle (immutable revisions, atomic activation, last-known-good restored when onActivate fails, redacted secrets with a per-namespace fingerprint key, legacy import) and returns a promise of the store; get() and revision() stay in memory, the rest is async. configSchema() is the DDL for the service's migrations. adminRoutes awaits either kind of store; the better-sqlite3 path is unchanged. test/config-pg.test.js runs every scenario of config.test.js on PGlite (openvibe-sdk v0.20.4 as a dev dependency; its made-up secrets are allowlisted like the original's). npm test 41/41.
Changes
@@@ -5,7 +5,7 @@useDefault = true[[allowlists]]--description = "Made-up secrets in the configuration model's tests (test/config.test.js): they exist to prove secrets are redacted, never logged and never served"++description = "Made-up secrets in the configuration model's tests (test/config.test.js and its PostgreSQL twin test/config-pg.test.js): they exist to prove secrets are redacted, never logged and never served"condition = "AND"--paths = ['''^test/config\.test\.js$''']++paths = ['''^test/config(-pg)?\.test\.js$''']regexes = ['''^(sk_live_TOPSECRET_9f8e7d6c5b4a|re_abcdef123456)$''']@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 1.28.0 — 2026-09-28++++**`openvibe-shared/config` on PostgreSQL** (ADR-035: Media, Network and Live keep their configuration here). Given an openvibe-sdk/db handle, `createConfigStore` uses the new `config-pg.js`, which is the same store on the async handle. Revisions are immutable, activation is atomic, the last-known-good revision is restored when `onActivate` fails, secret values are redacted with a per-namespace fingerprint key, legacy values are imported, and so on. On this path `createConfigStore` returns a promise of the store: it has read, restored or seeded its active revision when the promise resolves. `get()` and `revision()` stay in memory. The other methods are async. The tables come from the service's migrations: `configSchema()` is their DDL, the SQLite schema converted. `adminRoutes` awaits either kind of store. The better-sqlite3 path is unchanged. `test/config-pg.test.js` runs every scenario of `test/config.test.js` on PGlite (openvibe-sdk v0.20.4, which returns `bytea` as a Buffer, is a dev dependency).++## 1.27.0 — 2026-09-28**`openvibe-shared/express-async`: an async handler's rejection no longer ends the process.** Express 4 ignores what a handler returns, so a promise that an `async (req, res) => …` handler rejects (a database error, a thrown problem) became an unhandled rejection, and Node 22 exits on one. The move to PostgreSQL (ADR-035) made most handlers async; nine services had between 5 and 41 such routes. `routeAsyncErrors(app)` patches the app's own Express 4 `Layer` once, so the rejection goes to `next(err)` and the service's error handler answers, exactly as a synchronous throw always did. Error handlers get the same treatment. On Express 5, which handles promises itself, it does nothing. **`instrument()` calls it**, and every service calls `instrument()`, so a pin bump is the whole change. `test/express-async.test.js`.@@@ -89,6 +89,8 @@ never `true`: it reports status `skipped` with the reason and is listed in `skipbetter-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and`config_keys`).++On PostgreSQL (an openvibe-sdk/db handle, ADR-035) `createConfigStore` returns a promise of the store (`config-pg.js`): `const tiering = await config.createConfigStore({ db, … })`. Its reading and writing methods are async, while `get()` and `revision()` stay in memory. The service's migrations create the tables from `config.configSchema()`.++```jsconst config = require('openvibe-shared/config');const settings = config.createConfigStore({@@@ -4,7 +4,7 @@"code": true,"wave": "W2","package": "openvibe-shared",-- "version": "1.27.0",++ "version": "1.28.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)","deployed": false,"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",nnew file mode 100644@@@ -0,0 +1,448 @@++'use strict';++// openvibe-shared/config on PostgreSQL (ADR-035): the same store as config.js, on an openvibe-sdk/db handle. Every++// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this++// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or++// seeded its active revision when it resolves). The tables come from++// the service's migrations: configSchema() is their DDL.++const crypto = require('crypto');++const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config')._internal;++++/** The DDL of config_snapshots and config_keys, for a service's migrations. */++function configSchema() {++ return `CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL, -- the full values, secrets included (never served)++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the full values: internal, never served++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++-- The fingerprint key of each namespace. Never served, never logged.++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);`;++}++++async function createPgConfigStore(opts = {}) {++ const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;++ if (!db || typeof db.prepare !== 'function' || typeof db.tx !== 'function') throw new TypeError('createConfigStore: db must be an openvibe-sdk/db handle');++ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');++ if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {++ throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);++ }++ if (schema !== null) compileSchema(schema);++ if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');++ if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');++ if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');++ if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');++ const keep = Math.max(1, Math.floor(opts.keep || 50));++ const maxBytes = opts.maxBytes || 1024 * 1024;++ const log = makeLog(opts.log);++ const now = opts.now || (() => new Date());++ const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); };++ const self = { type: 'service', id: service };++ const defaults = deepFreeze(clone(opts.defaults || {}));++ const tag = `[config] ${namespace}`;++++ // config_snapshots and config_keys come from the service's migrations (configSchema()).++ const q = {++ row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'),++ active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"),++ any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'),++ max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'),++ lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"),++ previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"),++ history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'),++ insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json,++ values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good)++ VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json,++ @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`),++ setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'),++ activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"),++ good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),++ reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),++ prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),++ };++ const tx = async (fn) => await db.tx(fn); // ambient: the store's statements inside join it++++ // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──++ let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first)++ async function loadKey() {++ const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');++ let row = await read.get(namespace);++ if (!row) {++ await db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(namespace, crypto.randomBytes(32), iso());++ row = await read.get(namespace);++ }++ const k = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite)++ if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);++ hmacKey = k;++ }++ const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');++ const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });++++ // ── classification and redaction ──++ const RANK = { public: 0, internal: 1, secret: 2 };++ function classOf(key, value) {++ let c;++ try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }++ if (c == null) return 'internal';++ if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave "${String(c).slice(0, 20)}" for ${key}; treating it as secret`); return 'secret'; }++ return c;++ }++ const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOf(k, values[k])]));++ /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */++ function classesFor(values, stored = {}) {++ const out = classifyAll(values);++ for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];++ return out;++ }++ const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerFor(v) : v]));++ function secretStrings(...valueSets) {++ const out = [];++ for (const values of valueSets) {++ if (!values) continue;++ for (const [k, v] of Object.entries(values)) {++ if (classOf(k, v) !== 'secret' || v == null) continue;++ out.push(typeof v === 'string' ? v : JSON.stringify(v));++ }++ }++ return out.sort((a, b) => b.length - a.length);++ }++++ function toSnapshot(row) {++ const values = JSON.parse(row.values_json);++ const classification = classesFor(values, JSON.parse(row.classification_json));++ const shown = redact(values, classification);++ return {++ service: row.service,++ namespace: row.namespace,++ revision: row.revision,++ previous_revision: row.previous_revision == null ? null : row.previous_revision,++ state: row.state,++ values: shown,++ classification,++ checksum: sha256(canonical(shown)),++ created_at: row.created_at,++ created_by: JSON.parse(row.created_by),++ activated_at: row.activated_at || null,++ activated_by: row.activated_by ? JSON.parse(row.activated_by) : null,++ reason: row.reason == null ? null : row.reason,++ error: row.error == null ? null : row.error,++ copied_from: row.copied_from == null ? null : row.copied_from,++ };++ }++++ // ── in-memory state ──++ let current = null; // { revision, values (stored), effective (frozen), classification }++ const emptyEffective = defaults;++ function load(row) {++ const values = JSON.parse(row.values_json);++ return { revision: row.revision, checksum: row.values_checksum, values, effective: deepFreeze({ ...clone(defaults), ...clone(values) }), classification: JSON.parse(row.classification_json) };++ }++ const effectiveNow = () => (current ? current.effective : emptyEffective);++++ // ── validation ──++ function problems(values) {++ const errors = [];++ if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }];++ for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' });++ for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' });++ jsonErrors(values, '', errors);++ if (errors.length) return errors;++ const size = Buffer.byteLength(canonical(values));++ if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];++ const effective = { ...defaults, ...values };++ if (schema !== null) checkSchema(schema, effective, '', errors);++ if (validate) {++ let r;++ try { r = validate(effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }++ if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');++ errors.push(...validateResult(r));++ }++ const secrets = secretStrings(values, effectiveNow());++ return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));++ }++ const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });++++ /** values (+ merge/unset) as the complete values a new revision would hold. A redaction marker keeps the value it stands for. */++ function resolve(input, { merge = false, unset = [] } = {}) {++ if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);++ if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);++ const base = merge && current ? clone(current.values) : {};++ const errors = [];++ for (const [k, v] of Object.entries(input)) {++ if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }++ if (!isMarker(v)) { base[k] = v; continue; }++ if (current && has(current.values, k) && sameHex(fingerprint(current.values[k]), v.fingerprint)) base[k] = clone(current.values[k]);++ else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];++ else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });++ }++ if (errors.length) throw invalid(errors);++ for (const k of unset) delete base[k];++ return base;++ }++++ function changedKeys(before, after) {++ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);++ return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();++ }++ const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing');++ const who = (a) => `${a.type}:${a.id}`;++++ function actorOf(actor) {++ if (actor == null) return self;++ const ref = subjectRef(actor);++ if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 });++ return ref;++ }++ function reasonOf(reason) {++ if (reason == null || reason === '') return null;++ if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]);++ return reason.slice(0, REASON_MAX);++ }++++ async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {++ let row;++ await tx(async () => {++ const revision = (await q.max.get(namespace)).n + 1;++ await q.insert.run({++ namespace, revision, service, previous_revision: previous, state,++ values_json: JSON.stringify(values), classification_json: JSON.stringify(classifyAll(values)),++ values_checksum: sha256(canonical(values)), created_at: iso(), created_by: JSON.stringify(actor),++ activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,++ reason, copied_from: copiedFrom, good,++ });++ row = await q.row.get(namespace, revision);++ });++ return row;++ }++++ async function prune() {++ const cutoff = (await q.max.get(namespace)).n - keep;++ if (cutoff < 1) return;++ const good = await q.lastGood.get(namespace);++ await q.prune.run(namespace, cutoff, good ? good.revision : -1);++ }++++ // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──++ async function seed(values, reason, { strict }) {++ if (!strict) {++ // A legacy key the contract cannot carry is left behind, by name, rather than imported.++ const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k]));++ if (bad.length) {++ log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`);++ values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k)));++ }++ }++ const errors = problems(values);++ if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors });++ if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`);++ const row = await insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 });++ current = load(row);++ log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`);++ return toSnapshot(row);++ }++++ async function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {++ if (await q.any.get(namespace)) return null;++ let v = typeof legacy === 'function' ? legacy() : legacy;++ if (Array.isArray(v)) v = fromRows(v);++ if (v == null) return null;++ if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows');++ return await seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });++ }++++ async function boot() {++ await loadKey();++ let row = await q.active.get(namespace);++ if (row && !row.good) {++ // The process stopped between the switch and the end of onActivate: that revision never++ // proved itself, so the last-known-good one is active again.++ const good = await q.lastGood.get(namespace);++ await tx(async () => {++ await q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);++ if (good) await q.setState.run('active', namespace, good.revision);++ });++ log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`);++ row = good ? await q.row.get(namespace, good.revision) : null;++ }++ if (row) {++ current = load(row);++ const errors = problems(current.values);++ if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`);++ return;++ }++ if (await q.any.get(namespace)) return;++ if (opts.legacy !== undefined && await importLegacy(opts.legacy)) return;++ if (opts.defaults != null) await seed(defaults, 'seeded from defaults', { strict: true });++ }++++ // ── activation (serialized per store) ──++ let queue = Promise.resolve();++ function serialized(fn) {++ const run = queue.then(fn, fn);++ queue = run.catch(() => {});++ return run;++ }++++ async function activateNow(revision, { actor, rollingBack = false } = {}) {++ const by = actorOf(actor);++ const row = await q.row.get(namespace, revision);++ if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision });++ if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision });++ const values = JSON.parse(row.values_json);++ const errors = problems(values);++ if (errors.length) {++ await q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision);++ log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`);++ throw invalid(errors, revision);++ }++ const prev = current;++ const at = iso();++ await tx(async () => {++ const dbActive = await q.active.get(namespace);++ const activeRev = dbActive ? dbActive.revision : null;++ if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) {++ throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision });++ }++ if (dbActive) await q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);++ await q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);++ });++ current = load(await q.row.get(namespace, revision));++ const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective);++ if (onActivate) {++ const next = current;++ try {++ await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });++ } catch (err) {++ const message = scrub(`onActivate: ${errText(err)}`, secretStrings(next.values, prev && prev.values));++ await tx(async () => {++ await q.reject.run(message, namespace, revision);++ if (prev) await q.setState.run('active', namespace, prev.revision);++ });++ current = prev;++ log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`);++ if (prev) {++ try {++ await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });++ } catch (again) {++ log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), secretStrings(next.values, prev.values))}`);++ }++ }++ throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });++ }++ await q.good.run(namespace, revision);++ }++ await prune();++ log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);++ return toSnapshot(await q.row.get(namespace, revision));++ }++++ async function propose(values, o = {}) {++ const actor = actorOf(o.actor);++ const reason = reasonOf(o.reason);++ const next = resolve(values, o);++ const errors = problems(next);++ if (errors.length) throw invalid(errors);++ const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });++ await prune();++ log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);++ return toSnapshot(row);++ }++++ async function rollbackTarget(to) {++ if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 });++ if (to == null) {++ const r = await q.previousGood.get(namespace, current.revision, current.checksum);++ if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 });++ return r.revision;++ }++ const rev = Number(to);++ const row = Number.isInteger(rev) ? await q.row.get(namespace, rev) : null;++ if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null });++ if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev });++ if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev });++ return rev;++ }++++ const store = {++ service,++ namespace,++ /** The active values over the defaults (frozen), or one of them. Never reads the database. */++ get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },++ /** The active revision number, or null. */++ revision() { return current ? current.revision : null; },++ propose,++ activate(revision, o = {}) { return serialized(async () => await activateNow(Number(revision), { actor: o.actor })); },++ /** propose + activate, in turn with every other activation of this store. */++ apply(values, o = {}) { return serialized(async () => await activateNow((await propose(values, o)).revision, { actor: o.actor })); },++ /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */++ rollback(o = {}) {++ return serialized(async () => {++ const actor = actorOf(o.actor);++ const target = await rollbackTarget(o.to);++ const values = JSON.parse((await q.row.get(namespace, target)).values_json);++ const row = await insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target });++ log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`);++ return await activateNow(row.revision, { actor, rollingBack: true });++ });++ },++ rollbackTarget,++ async lastKnownGood() { const r = await q.lastGood.get(namespace); return r ? toSnapshot(await q.row.get(namespace, r.revision)) : null; },++ async snapshot(revision) { const row = await q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },++ async history({ limit = 20, before = null } = {}) {++ const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20));++ const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before));++ return (await q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n)).map(toSnapshot);++ },++ /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */++ async changes(values, o = {}) {++ const next = o.revision != null ? JSON.parse((await q.row.get(namespace, Number(o.revision)) || { values_json: '{}' }).values_json) : resolve(values, o);++ return changedKeys(effectiveNow(), { ...defaults, ...next });++ },++ /** What /api/admin/config shows: the active revision, its effective values redacted, and the last-known-good. */++ async summary() {++ const effective = effectiveNow();++ const classification = classesFor(effective, current ? current.classification : {});++ return {++ service, namespace,++ revision: current ? current.revision : null,++ values: redact(effective, classification),++ classification,++ active: current ? await store.snapshot(current.revision) : null,++ last_known_good: await store.lastKnownGood(),++ };++ },++ /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */++ import: importLegacy,++ /** Re-read the active revision (another process changed it). onActivate is not run. */++ async reload() {++ const row = await q.active.get(namespace);++ current = row ? load(row) : null;++ return store.revision();++ },++ };++ await boot();++ return store;++}++++// ── /api/admin/config ───────────────────────────────────────────++++const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' };++++module.exports = { createPgConfigStore, configSchema };@@@ -281,6 +281,8 @@ function fromRows(rows, { prefix = '', type = null } = {}) {// ── The store ───────────────────────────────────────────────────function createConfigStore(opts = {}) {++ // On an openvibe-sdk/db handle (PostgreSQL, ADR-035): config-pg.js, which returns a promise of the store.++ if (opts.db && typeof opts.db.tx === 'function') return require('./config-pg').createPgConfigStore(opts);const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') throw new TypeError('createConfigStore: db must be a better-sqlite3 handle');if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');@@@ -766,20 +768,20 @@ function adminRoutes(stores, o = {}) {const handlers = {async list(req, res) {const namespaces = [];-- for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(s.summary());++ for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(await s.summary());send(res, 200, { namespaces });},async get(req, res) {const s = storeFor(req);if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });-- send(res, 200, s.summary());++ send(res, 200, await s.summary());},async history(req, res) {const s = storeFor(req);if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });const query = queryOf(req);const limit = Math.min(200, Math.max(1, parseInt(query.limit, 10) || 20));-- const snapshots = s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null });++ const snapshots = await s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null });send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null });},async apply(req, res) {@@@ -790,7 +792,7 @@ function adminRoutes(stores, o = {}) {const actor = await actorOf(req);if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };-- const keys = s.changes(body.values, opts);++ const keys = await s.changes(body.values, opts);if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });send(res, 200, await s.apply(body.values, opts));},@@@ -801,7 +803,7 @@ function adminRoutes(stores, o = {}) {if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 });const actor = await actorOf(req);if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });-- const keys = s.changes(null, { revision: s.rollbackTarget(body.to) });++ const keys = await s.changes(null, { revision: await s.rollbackTarget(body.to) });if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });send(res, 200, await s.rollback({ to: body.to, reason: body.reason, actor }));},@@@ -863,4 +865,6 @@ function adminRoutes(stores, o = {}) {return { ...out, routes, mount, handle };}--module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem };++module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };++// The helpers the PostgreSQL store (config-pg.js) shares with this one.++module.exports._internal = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };@@@ -1,23 +1,32 @@{"name": "openvibe-shared",-- "version": "1.27.0",++ "version": "1.28.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-shared",-- "version": "1.27.0",++ "version": "1.28.0","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"},"devDependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4"}},++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.8",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",++ "dev": true,++ "license": "Apache-2.0"++ },"node_modules/accepts": {"version": "1.3.8","resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",@@@ -1337,6 +1346,36 @@"node": ">=20"}},++ "node_modules/openvibe-sdk": {++ "version": "0.20.4",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",++ "integrity": "sha512-IMvvBrFhCVO4sm2Ii694BslcnKHSV7YABXviLqrDI/2lTr9NKIH80z3HXxwMUgj0fwZDWBWcNgqPs6OaWIB0GQ==",++ "dev": true,++ "license": "MIT",++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "better-sqlite3": ">=11",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "better-sqlite3": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },"node_modules/parseurl": {"version": "1.3.3","resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",@@@ -1,6 +1,6 @@{"name": "openvibe-shared",-- "version": "1.27.0",++ "version": "1.28.0","description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site","main": "index.js","exports": {@@@ -21,6 +21,7 @@"./builtin-themes": "./builtin-themes.js","./chrome-ssr": "./chrome-ssr.js","./config": "./config.js",++ "./config-pg": "./config-pg.js","./egress": "./egress.js","./files": "./files.js","./footer": "./footer.js",@@@ -85,9 +86,11 @@"jsonwebtoken": "^9.0.0"},"devDependencies": {++ "@electric-sql/pglite": "^0.5.8","better-sqlite3": "^11.10.0","express": "^4.22.3","linkedom": "^0.18.13",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0"++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4"}}nnew file mode 100644@@@ -0,0 +1,638 @@++'use strict';++// config-pg.js (PostgreSQL, through an embedded PGlite): the scenarios of config.test.js. Validation, classification and redaction, atomic activation with last-known-good++// restored when onActivate throws (memory and database), rollback as a new revision, legacy import,++// two namespaces in one database, restart and an interrupted activation, serialized applies,++// pruning that keeps the last-known-good, and /api/admin/config on Express and on plain http.++const assert = require('assert');++const crypto = require('crypto');++const http = require('http');++let createDb;++try { ({ createDb } = require('openvibe-sdk/db')); } catch { console.log('config-pg: skipped (openvibe-sdk not installed)'); process.exit(0); }++const { configSchema } = require('../config-pg');++const express = require('express');++const { createConfigStore, adminRoutes, fromRows, canonical, ConfigError } = require('../config');++++let contracts = null;++try { contracts = require('openvibe-contracts'); } catch { /* optional */ }++const hasSnapshotContract = (() => { try { return !!(contracts && contracts.resolve('common.config-snapshot@1')); } catch { return false; } })();++++const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };++const SECRET = 'sk_live_TOPSECRET_9f8e7d6c5b4a';++const sha = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');++/** The namespace's fingerprint key, read the way only the service itself can, and a fingerprint under it. */++const keyOf = async (db, ns) => (await db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns)).hmac_key;++const fp = async (db, ns, v) => crypto.createHmac('sha256', await keyOf(db, ns)).update(canonical(v)).digest('hex');++/** Every form a key could leak in, for every namespace in a database, and the unkeyed hash of SECRET. */++const leakForms = async (db) => (await db.prepare('SELECT hmac_key FROM config_keys').all())++ .flatMap((r) => [r.hmac_key.toString('hex'), r.hmac_key.toString('base64'), r.hmac_key.toString('base64url')])++ .concat([sha(SECRET)]);++const assertNoLeak = (text, forms, what) => { for (const f of forms) assert.ok(!String(text).includes(f), `${what} leaks a key or an unkeyed hash`); };++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++let t = Date.parse('2026-09-25T12:00:00.000Z');++const clock = () => new Date(t += 1000);++const logs = [];++const log = (m, level) => logs.push(`${level} ${m}`);++++/** The common.config-snapshot@1 shape, checked here and by the contract when the pinned copy has it. */++function assertSnapshot(s) {++ const keys = ['service', 'namespace', 'revision', 'previous_revision', 'state', 'values', 'classification', 'checksum', 'created_at', 'created_by', 'activated_at', 'activated_by', 'reason', 'error', 'copied_from'];++ assert.deepStrictEqual(Object.keys(s).sort(), [...keys].sort());++ assert.ok(['proposed', 'active', 'superseded', 'rejected', 'rolled_back'].includes(s.state), s.state);++ assert.ok(Number.isInteger(s.revision) && s.revision >= 1);++ assert.match(s.checksum, /^[0-9a-f]{64}$/);++ assert.deepStrictEqual(Object.keys(s.values).sort(), Object.keys(s.classification).sort(), 'every key classified');++ for (const [k, v] of Object.entries(s.values)) {++ const marker = !!(v && typeof v === 'object' && v.redacted === true);++ assert.strictEqual(marker, s.classification[k] === 'secret', `${k} redacted exactly when secret`);++ if (marker) { assert.deepStrictEqual(Object.keys(v).sort(), ['fingerprint', 'redacted']); assert.match(v.fingerprint, /^[0-9a-f]{64}$/); }++ }++ assert.strictEqual(s.checksum, sha(s.values), 'the checksum covers the values as shown');++ if (s.state === 'rejected') assert.ok(s.error, 'a rejected revision names its error');++ if (['active', 'superseded', 'rolled_back'].includes(s.state)) assert.ok(s.activated_at, `${s.state} has activated_at`);++ if (hasSnapshotContract) {++ const r = contracts.validate('common.config-snapshot@1', s);++ assert.ok(r.valid, JSON.stringify(r.errors));++ }++ return s;++}++const assertProblem = (body, status, code) => {++ assert.strictEqual(body.status, status);++ assert.strictEqual(body.code, code);++ assert.strictEqual(body.type, `https://openvibe.network/problems/${code}`);++ if (contracts) assert.ok(contracts.validate('errors.problem@1', body).valid, JSON.stringify(body));++};++++/** A handle that counts statement executions, to prove get() never touches the database. */++function counting(db) {++ const c = { n: 0 };++ return {++ c,++ tx: async (fn) => await db.tx(fn),++ prepare(sql) {++ const st = db.prepare(sql);++ return { get: async (...a) => (c.n++, await st.get(...a)), all: async (...a) => (c.n++, await st.all(...a)), run: async (...a) => (c.n++, await st.run(...a)) };++ },++ };++}const rowOf = async (db, ns, rev) => await db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?').get(ns, rev);++++(async () => {++ // PostgreSQL (ADR-035) on an embedded PGlite: the same scenarios as config.test.js, through config-pg.js.++ const db = createDb({ pglite: true, log: { log() {}, warn() {} } });++ await db.query(configSchema());++++ // ── Construction ──++ await assert.rejects(async () => await createConfigStore({}), /better-sqlite3/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { oneOf: [] } }), /unsupported keyword "oneOf"/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { type: 'object', properties: { a: { $ref: '#' } } } }), /unsupported keyword "\$ref"/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', classify: 'secret' }), /classify/);++ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.bad_defaults', schema: { type: 'object', properties: { a: { type: 'string' } } }, defaults: { a: 1 }, log }),++ (e) => e.code === 'config.invalid_defaults');++++ // ── Seeded from defaults ──++ const SCHEMA = {++ $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Live site settings',++ type: 'object', additionalProperties: false, required: ['site_name'],++ properties: {++ site_name: { type: 'string', minLength: 1, maxLength: 60, description: 'shown in the navbar' },++ max_bitrate_kbps: { type: 'integer', minimum: 500, maximum: 20000 },++ mode: { enum: ['open', 'invite', 'closed'] },++ support_email: { type: 'string', pattern: '^[^@\\s]+@[^@\\s]+$', format: 'email' },++ youtube_api_key: { type: 'string' },++ tags: { type: 'array', items: { type: 'string' }, maxItems: 3, uniqueItems: true },++ banner: { type: ['object', 'null'], additionalProperties: false, properties: { text: { type: 'string' } } },++ },++ };++ const DEFAULTS = { site_name: 'OpenVibe.Live', max_bitrate_kbps: 6000, mode: 'open', youtube_api_key: '' };++ const classify = (k) => (/api_key|secret|token/.test(k) ? 'secret' : ['site_name', 'mode'].includes(k) ? 'public' : undefined);++ let activations = [];++ let failNext = null;++ let tighten = false;++ const liveOpts = {++ db, service: 'live', namespace: 'live.site_settings', schema: SCHEMA, classify, defaults: DEFAULTS, log, now: clock,++ validate: (v) => {++ if (v.mode === 'closed' && v.max_bitrate_kbps > 10000) return [{ path: '/max_bitrate_kbps', message: 'must be at most 10000 while closed' }];++ if (v.youtube_api_key && !/^[A-Za-z0-9_-]+$/.test(v.youtube_api_key)) return `youtube_api_key ${v.youtube_api_key} has characters keys never have`;++ if (tighten && v.max_bitrate_kbps < 7500) return 'max_bitrate_kbps is below the new floor';++ return true;++ },++ onActivate: async (values, previous, info) => {++ activations.push({ values, previous, info });++ await sleep(1);++ if (failNext && !info.restoring) { const e = failNext; failNext = null; throw e; }++ },++ };++ const counted = counting(db);++ const live = await createConfigStore({ ...liveOpts, db: counted });++ assert.strictEqual(live.revision(), 1);++ assert.deepStrictEqual(live.get(), DEFAULTS);++ assert.strictEqual(live.get('mode'), 'open');++ assert.ok(Object.isFrozen(live.get()));++ assert.throws(() => { live.get().mode = 'closed'; }, TypeError);++ const seeded = assertSnapshot(await live.lastKnownGood());++ assert.strictEqual(seeded.revision, 1);++ assert.strictEqual(seeded.state, 'active');++ assert.strictEqual(seeded.previous_revision, null);++ assert.strictEqual(seeded.reason, 'seeded from defaults');++ assert.deepStrictEqual(seeded.created_by, { type: 'service', id: 'live' });++ assert.strictEqual(activations.length, 0, 'seeding does not run onActivate');++ const reads = counted.c.n;++ for (let i = 0; i < 1000; i++) { live.get(); live.get('mode'); live.revision(); }++ assert.strictEqual(counted.c.n, reads, 'get() and revision() never read the database');++++ // ── Validation rejects, and nothing is stored ──++ const before = (await live.history({ limit: 200 })).length;++ const cases = [++ [{ ...DEFAULTS, max_bitrate_kbps: 100 }, '/max_bitrate_kbps'],++ [{ ...DEFAULTS, max_bitrate_kbps: 6000.5 }, '/max_bitrate_kbps'],++ [{ ...DEFAULTS, max_bitrate_kbps: NaN }, '/max_bitrate_kbps'],++ [{ ...DEFAULTS, mode: 'party' }, '/mode'],++ [{ ...DEFAULTS, support_email: 'nope' }, '/support_email'],++ [{ ...DEFAULTS, unknown_key: 1 }, '/unknown_key'],++ [{ ...DEFAULTS, site_name: '' }, '/site_name'],++ [{ ...DEFAULTS, tags: ['a', 'a'] }, '/tags'],++ [{ ...DEFAULTS, tags: ['a', 1] }, '/tags/1'],++ [{ ...DEFAULTS, banner: { text: 'x', extra: true } }, '/banner/extra'],++ [{ ...DEFAULTS, mode: 'closed', max_bitrate_kbps: 15000 }, '/max_bitrate_kbps'],++ [{ ...DEFAULTS, 'bad key': 1 }, '/bad key'],++ [{ ...DEFAULTS, when: new Date() }, '/when'],++ ];++ for (const [values, path] of cases) {++ const ok = (e) => e instanceof ConfigError && e.code === 'config.invalid' && e.status === 422 && e.errors.some((x) => x.path === path);++ await assert.rejects(async () => await live.propose(values, { actor: USER }), ok, `${path} is refused`);++ await assert.rejects(live.apply(values, { actor: USER }), ok);++ }++ await assert.rejects(async () => await live.propose('nope'), (e) => e.code === 'config.invalid');++ const required = await createConfigStore({ db, service: 'live', namespace: 'live.required', schema: { type: 'object', required: ['a'] }, log });++ assert.strictEqual(required.revision(), null, 'no defaults and no legacy: nothing active');++ assert.deepStrictEqual(required.get(), {});++ await assert.rejects(async () => await required.propose({ b: 1 }), (e) => e.errors[0].path === '/a' && e.errors[0].message === 'is required');++ assert.strictEqual((await live.history({ limit: 200 })).length, before, 'a refused proposal is not stored');++ assert.strictEqual(live.revision(), 1);++ // Messages name the rule, never the value; a validate() that echoes a secret is scrubbed.++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: 12345678 }), (e) => !JSON.stringify(e.errors).includes('12345678') && !e.message.includes('12345678'));++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: `${SECRET} !` }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));++ await assert.rejects(async () => await live.propose(DEFAULTS, { actor: { type: 'user', id: '42' } }), (e) => e.code === 'config.bad_actor');++++ // ── Propose, then activate ──++ const v2 = { ...DEFAULTS, max_bitrate_kbps: 8000, youtube_api_key: SECRET };++ const p = assertSnapshot(await live.propose(v2, { actor: USER.id, reason: 'raise the cap' }));++ assert.strictEqual(p.state, 'proposed');++ assert.strictEqual(p.revision, 2);++ assert.strictEqual(p.previous_revision, 1);++ assert.strictEqual(p.activated_at, null);++ assert.deepStrictEqual(p.created_by, USER, 'a usr_ id string is a user subject');++ assert.deepStrictEqual(p.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });++ assert.strictEqual(p.classification.youtube_api_key, 'secret');++ assert.strictEqual(p.classification.site_name, 'public');++ assert.strictEqual(p.classification.max_bitrate_kbps, 'internal', 'an unclassified key is internal');++ assert.notStrictEqual(p.checksum, sha(v2), 'an unkeyed checksum of the full values is never served');++ assert.ok(!JSON.stringify(p).includes(sha(SECRET)), 'nor an unkeyed hash of the secret');++ assert.strictEqual(live.get('max_bitrate_kbps'), 6000, 'a proposal changes nothing');++ const a = assertSnapshot(await live.activate(2, { actor: USER }));++ assert.strictEqual(a.state, 'active');++ assert.deepStrictEqual(a.activated_by, USER);++ assert.strictEqual(live.revision(), 2);++ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the service itself gets the real value');++ assert.strictEqual(activations.length, 1);++ assert.strictEqual(activations[0].values.max_bitrate_kbps, 8000);++ assert.strictEqual(activations[0].previous.max_bitrate_kbps, 6000);++ assert.deepStrictEqual(activations[0].info, { namespace: 'live.site_settings', revision: 2, previous_revision: 1, restoring: false });++ assert.strictEqual((await live.snapshot(1)).state, 'superseded');++ assert.strictEqual((await live.lastKnownGood()).revision, 2);++ assert.ok((await rowOf(db, 'live.site_settings', 2)).values_json.includes(SECRET), 'the row keeps the secret so it can be activated again');++ await assert.rejects(live.activate(2), (e) => e.code === 'config.not_proposed' && e.status === 409);++ await assert.rejects(live.activate(99), (e) => e.code === 'config.revision_not_found' && e.status === 404);++ // A proposal made on an older active revision is stale.++ const stale = await live.propose({ mode: 'invite' }, { merge: true, actor: USER });++ await live.apply({ mode: 'closed' }, { merge: true, actor: USER, reason: 'maintenance' });++ await assert.rejects(live.activate(stale.revision, { actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);++ assert.strictEqual((await live.snapshot(stale.revision)).state, 'proposed', 'a stale proposal is left as it was');++ const goodRev = live.revision();++ const goodValues = live.get();++ assert.deepStrictEqual({ mode: goodValues.mode, max: goodValues.max_bitrate_kbps, key: goodValues.youtube_api_key }, { mode: 'closed', max: 8000, key: SECRET }, 'merge keeps the other keys');++++ // ── A throwing onActivate restores the last-known-good, in memory and in the database ──++ activations = [];++ failNext = new Error(`bucket refused key ${SECRET}`);++ let thrown;++ await assert.rejects(live.apply({ max_bitrate_kbps: 9000, youtube_api_key: `${SECRET}x` }, { merge: true, actor: USER }), (e) => { thrown = e; return true; });++ assert.ok(thrown instanceof ConfigError);++ assert.strictEqual(thrown.code, 'config.activation_failed');++ assert.strictEqual(thrown.status, 422);++ assert.ok(thrown.message.includes('bucket refused key [redacted]') && !thrown.message.includes(SECRET), thrown.message);++ assert.ok(thrown.cause.message.includes('bucket refused'), 'the original error is the cause');++ const failedRev = thrown.revision;++ assert.strictEqual(failedRev, goodRev + 1);++ assert.strictEqual(live.revision(), goodRev, 'memory: the previous revision is active again');++ assert.strictEqual(live.get(), goodValues, 'memory: the same values object as before');++ const failedRow = await rowOf(db, 'live.site_settings', failedRev);++ assert.strictEqual(failedRow.state, 'rejected', 'database: the new revision is rejected');++ assert.strictEqual(failedRow.activated_at, null);++ assert.ok(failedRow.error.startsWith('onActivate: bucket refused key [redacted]') && !failedRow.error.includes(SECRET));++ assert.strictEqual((await rowOf(db, 'live.site_settings', goodRev)).state, 'active', 'database: the previous revision is active again');++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'").get()).n, 1);++ assert.strictEqual(activations.length, 2, 'onActivate ran for the new values, then again to restore');++ assert.strictEqual(activations[1].info.restoring, true);++ assert.strictEqual(activations[1].values, goodValues);++ assert.strictEqual(activations[1].previous.max_bitrate_kbps, 9000);++ assert.strictEqual((await live.lastKnownGood()).revision, goodRev);++ assertSnapshot(await live.snapshot(failedRev));++ const restarted = await createConfigStore({ ...liveOpts, db });++ assert.strictEqual(restarted.revision(), goodRev, 'a restart after the failure serves the last-known-good');++++ // Validation that fails at activation time (the rules changed since the proposal) rejects it too.++ const p2 = await live.propose({ max_bitrate_kbps: 7000 }, { merge: true, actor: USER });++ tighten = true;++ await assert.rejects(live.activate(p2.revision, { actor: USER }), (e) => e.code === 'config.invalid' && e.revision === p2.revision);++ tighten = false;++ assert.strictEqual((await live.snapshot(p2.revision)).state, 'rejected');++ assert.match((await live.snapshot(p2.revision)).error, /below the new floor/);++ assert.strictEqual(live.revision(), goodRev);++++ // ── Rollback: a new revision copying an older good one; history is never rewritten ──++ const goodSnap = await live.snapshot(goodRev);++ const lowered = await live.apply({ max_bitrate_kbps: 5000 }, { merge: true, actor: USER, reason: 'lower' });++ const r = assertSnapshot(await live.rollback({ actor: USER, reason: 'too low' }));++ assert.strictEqual(r.copied_from, goodRev);++ assert.strictEqual(r.previous_revision, lowered.revision);++ assert.strictEqual(r.reason, 'too low');++ assert.strictEqual(r.checksum, goodSnap.checksum);++ assert.strictEqual((await live.snapshot(lowered.revision)).state, 'rolled_back');++ assert.strictEqual(live.get('max_bitrate_kbps'), 8000);++ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the secret comes back from the row');++ assert.deepStrictEqual({ ...await live.snapshot(goodRev), state: null }, { ...goodSnap, state: null }, 'the copied revision is untouched (only its state moved)');++ assert.strictEqual((await live.snapshot(goodRev)).state, 'superseded');++ const again = await live.rollback({ actor: USER });++ assert.strictEqual(again.copied_from, lowered.revision, 'rolling back a rollback undoes it');++ assert.strictEqual(again.reason, `rollback to revision ${lowered.revision}`);++ assert.strictEqual(live.get('max_bitrate_kbps'), 5000);++ await live.rollback({ actor: USER, to: 1 });++ assert.deepStrictEqual(live.get(), DEFAULTS);++ await assert.rejects(live.rollback({ to: live.revision() }), (e) => e.code === 'config.already_active' && e.status === 409);++ await assert.rejects(live.rollback({ to: failedRev }), (e) => e.code === 'config.not_good' && e.status === 409);++ await assert.rejects(live.rollback({ to: 999 }), (e) => e.code === 'config.revision_not_found' && e.status === 404);++ failNext = new Error('nope');++ const beforeFailedRollback = live.revision();++ await assert.rejects(live.rollback({ actor: USER, to: goodRev }), (e) => e.code === 'config.activation_failed');++ assert.strictEqual(live.revision(), beforeFailedRollback);++ assert.strictEqual((await live.snapshot(beforeFailedRollback)).state, 'active', 'a failed rollback puts the rolled_back revision back');++ const fresh = await createConfigStore({ db, service: 'live', namespace: 'live.fresh', defaults: { a: 1 }, log });++ await assert.rejects(fresh.rollback(), (e) => e.code === 'config.no_previous');++ await assert.rejects(required.rollback(), (e) => e.code === 'config.nothing_active');++++ // ── Secrets: redacted everywhere they leave, kept by a redacted round trip ──++ await live.rollback({ actor: USER, to: goodRev });++ const summary = await live.summary();++ assert.deepStrictEqual(summary.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });++ assert.strictEqual(summary.values.youtube_api_key.fingerprint, crypto.createHmac('sha256', await keyOf(db, 'live.site_settings')).update(JSON.stringify(SECRET)).digest('hex'), 'HMAC-SHA256 of the canonical JSON under the stored key');++ assertSnapshot(summary.active);++ assertSnapshot(summary.last_known_good);++ const roundTrip = await live.apply({ ...summary.values, max_bitrate_kbps: 7777 }, { actor: USER });++ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'sending the marker back keeps the secret');++ assert.strictEqual(roundTrip.values.youtube_api_key.fingerprint, summary.values.youtube_api_key.fingerprint, 'an unchanged value keeps its fingerprint');++ const refusedMarker = (e) => e.code === 'config.invalid' && e.errors[0].path === '/youtube_api_key';++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: await fp(db, 'live.site_settings', 'other') } }), refusedMarker, 'a fingerprint of another value is refused');++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: sha(SECRET) } }), refusedMarker, 'an unkeyed hash of the right value is refused');++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, sha256: sha(SECRET) } }), refusedMarker, 'the old { redacted, sha256 } shape is refused');++ await assert.rejects(async () => await live.propose({ ...DEFAULTS, banner: { redacted: false, text: 'x' } }), (e) => e.errors.some((x) => x.path === '/banner'), 'a redacted member is reserved for markers');++ const history = await live.history({ limit: 200 });++ const keyFps = new Set(history.filter((h) => h.state !== 'rejected').map((h) => h.values.youtube_api_key.fingerprint));++ assert.ok(keyFps.has(await fp(db, 'live.site_settings', SECRET)) && keyFps.has(await fp(db, 'live.site_settings', '')), 'fingerprints differ exactly when the value does');++ for (const s of await live.history({ limit: 200 })) assertSnapshot(s);++ assert.ok(!JSON.stringify(await live.history({ limit: 200 })).includes(SECRET));++ assert.ok(!JSON.stringify(await live.summary()).includes(SECRET));++ // A key classified secret later is redacted in the revisions stored before.++ const flags = await createConfigStore({ db, service: 'live', namespace: 'live.flags', defaults: {}, log });++ await flags.apply({ webhook_url: 'https://hooks.example/T0K3N' }, { actor: USER });++ assert.strictEqual((await flags.history())[0].values.webhook_url, 'https://hooks.example/T0K3N');++ const flags2 = await createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });++ assert.ok((await flags2.history()).every((s) => !JSON.stringify(s).includes('T0K3N')));++ assert.strictEqual((await flags2.history())[0].classification.webhook_url, 'secret');++ // A lost key is replaced on the next start: fingerprints change and older markers stop round-tripping.++ const oldMarker = (await flags2.summary()).values.webhook_url;++ await db.prepare("DELETE FROM config_keys WHERE namespace = 'live.flags'").run();++ const flags3 = await createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });++ assert.notDeepStrictEqual((await flags3.summary()).values.webhook_url, oldMarker);++ await assert.rejects(async () => await flags3.propose({ webhook_url: oldMarker }), (e) => e.code === 'config.invalid');++ assert.strictEqual((await flags3.propose({ webhook_url: (await flags3.summary()).values.webhook_url })).values.webhook_url.fingerprint, (await flags3.summary()).values.webhook_url.fingerprint);++++ // ── Two stores (and two services) in one database ──++ const TIER_SCHEMA = { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 }, r2Enabled: { type: 'boolean' }, b2_key: { type: 'string' } } };++ const media = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });++ const liveRev = live.revision();++ assert.strictEqual(media.revision(), 1, 'each namespace numbers its own revisions');++ await media.apply({ minAgeDays: 3, b2_key: SECRET }, { merge: true, actor: USER });++ assert.strictEqual(media.revision(), 2);++ assert.strictEqual(live.revision(), liveRev, 'the other namespace is untouched');++ assert.ok((await media.history()).every((s) => s.namespace === 'media.storage_tier' && s.service === 'media'));++ assert.ok((await live.history({ limit: 200 })).every((s) => s.namespace === 'live.site_settings'));++ // The same secret in two namespaces: different keys, different fingerprints, and one's marker is refused by the other.++ assert.strictEqual(live.get('youtube_api_key'), media.get('b2_key'));++ assert.notStrictEqual((await live.summary()).values.youtube_api_key.fingerprint, (await media.summary()).values.b2_key.fingerprint);++ await assert.rejects(async () => await live.propose({ youtube_api_key: (await media.summary()).values.b2_key }, { merge: true }), (e) => e.code === 'config.invalid');++ const keyRows = await db.prepare('SELECT namespace, hmac_key FROM config_keys').all();++ assert.ok(keyRows.length >= 5 && keyRows.every((k) => Buffer.isBuffer(k.hmac_key) && k.hmac_key.length === 32), 'a random 32-byte key per namespace');++ assert.strictEqual(new Set(keyRows.map((k) => k.hmac_key.toString('hex'))).size, keyRows.length, 'no two namespaces share a key');++++ // ── Restart: a new store on the same database serves the same active values ──++ const live2 = await createConfigStore({ ...liveOpts, db });++ assert.deepStrictEqual(live2.get(), live.get());++ assert.strictEqual(live2.revision(), live.revision());++ assert.strictEqual((await live2.lastKnownGood()).revision, (await live.lastKnownGood()).revision);++ assert.deepStrictEqual(await live2.summary(), await live.summary(), 'the key survives a restart: same fingerprints, same checksums');++ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM config_keys WHERE namespace = 'live.site_settings'").get()).n, 1);++ const media2 = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });++ assert.deepStrictEqual(media2.get(), { minAgeDays: 3, r2Enabled: true, b2_key: SECRET });++ // A release that adds a default serves it at once, without a new revision.++ const media3 = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true, r2MinViews: 20 }, log });++ assert.strictEqual(media3.get('r2MinViews'), 20);++ assert.strictEqual(media3.revision(), 2);++ assert.deepStrictEqual(await media3.changes({ minAgeDays: 4 }, { merge: true }), ['minAgeDays']);++ await media3.apply({}, { merge: true, unset: ['minAgeDays'], actor: USER });++ assert.strictEqual(media3.get('minAgeDays'), 7, 'an unset key falls back to its default');++ // Another store (another process) moved the namespace: this one's copy is stale until reload().++ await assert.rejects(media.apply({ minAgeDays: 6 }, { merge: true, actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);++ assert.strictEqual(media.get('minAgeDays'), 3);++ assert.strictEqual(await media.reload(), media3.revision());++ assert.strictEqual(media.get('minAgeDays'), 7);++++ // An activation interrupted by a stop (onActivate never finished) is rejected at the next boot,++ // and pruning while it hangs keeps the last-known-good even outside the window.++ const hanging = await createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, keep: 2, log, onActivate: () => new Promise(() => {}) });++ hanging.apply({ n: 1 }, { actor: USER }).catch(() => {});++ await sleep(5);++ assert.strictEqual((await rowOf(db, 'live.hang', 2)).state, 'active');++ assert.strictEqual((await hanging.lastKnownGood()).revision, 1, 'not good until onActivate finished');++ for (let i = 0; i < 3; i++) await hanging.propose({ n: 10 + i }, { actor: USER });++ const hangRevs = (await db.prepare("SELECT revision FROM config_snapshots WHERE namespace = 'live.hang' ORDER BY revision").all()).map((x) => x.revision);++ assert.deepStrictEqual(hangRevs, [1, 2, 4, 5], 'pruned to the newest two, plus the active and the last-known-good');++ const rebooted = await createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, log });++ assert.strictEqual(rebooted.revision(), 1);++ assert.strictEqual(rebooted.get('n'), 0);++ assert.strictEqual((await rowOf(db, 'live.hang', 2)).state, 'rejected');++ assert.match((await rowOf(db, 'live.hang', 2)).error, /interrupted/);++++ // ── Concurrency: applies run one at a time and get increasing revisions ──++ let running = 0; let overlapped = false;++ const conc = await createConfigStore({ db, service: 'live', namespace: 'live.concurrency', defaults: { n: 0 }, log, onActivate: async () => { running++; if (running > 1) overlapped = true; await sleep(5); running--; } });++ const [s1, s2, s3] = await Promise.all([conc.apply({ n: 1 }, { actor: USER }), conc.apply({ n: 2 }, { actor: USER }), conc.apply({ n: 3 }, { actor: USER })]);++ assert.deepStrictEqual([s1.revision, s2.revision, s3.revision], [2, 3, 4]);++ assert.deepStrictEqual([s1.previous_revision, s2.previous_revision, s3.previous_revision], [1, 2, 3]);++ assert.ok(!overlapped, 'onActivate never overlaps');++ assert.strictEqual(conc.get('n'), 3);++ assert.deepStrictEqual((await conc.history()).map((s) => s.state), ['active', 'superseded', 'superseded', 'superseded']);++ const b1 = await conc.apply({ n: 4 }, { actor: USER });++ const b2 = await conc.apply({ n: 5 }, { actor: USER });++ assert.strictEqual(b2.revision, b1.revision + 1);++ const conc2 = await createConfigStore({ db, service: 'live', namespace: 'live.concurrency', log });++ const [c1, c2] = await Promise.all([conc.apply({ n: 6 }, { actor: USER }), Promise.resolve().then(async () => await conc2.propose({ n: 7 }, { actor: USER }))]);++ assert.ok(c2.revision !== c1.revision, 'two stores on one namespace never reuse a revision');++++ // ── Pruning: the newest N, and never the active one ──++ const pr = await createConfigStore({ db, service: 'live', namespace: 'live.prune', defaults: { n: 0 }, keep: 3, log });++ for (let i = 1; i <= 6; i++) await pr.apply({ n: i }, { actor: USER });++ assert.deepStrictEqual((await pr.history()).map((s) => s.revision), [7, 6, 5]);++ const pr2 = await createConfigStore({ db, service: 'live', namespace: 'live.prune2', defaults: { n: 0 }, keep: 2, log, onActivate: (v) => { if (v.n > 0) throw new Error('refused'); } });++ for (let i = 1; i <= 5; i++) await assert.rejects(pr2.apply({ n: i }, { actor: USER }), (e) => e.code === 'config.activation_failed');++ assert.deepStrictEqual((await pr2.history()).map((s) => `${s.revision}:${s.state}`), ['6:rejected', '5:rejected', '1:active']);++ assert.strictEqual((await pr2.lastKnownGood()).revision, 1);++ assert.deepStrictEqual((await pr2.history({ limit: 1, before: 6 })).map((s) => s.revision), [5], 'paged with before');++++ // ── Legacy import ──++ const rows = [++ { key: 'site_name', value: 'Old Live', type: 'string' },++ { key: 'max_bitrate_kbps', value: '4500', type: 'number' },++ { key: 'registration_open', value: 'true', type: 'boolean' },++ { key: 'banner', value: '{"text":"hi"}', type: 'json' },++ { key: 'broken_json', value: '{', type: 'json' },++ { key: 'resend_api_key', value: 're_abcdef123456', type: 'secret' },++ { key: 'bad key', value: 'x' },++ { key: '__proto__', value: 'x' },++ ];++ assert.deepStrictEqual(fromRows(rows), { site_name: 'Old Live', max_bitrate_kbps: 4500, registration_open: true, banner: { text: 'hi' }, broken_json: '{', resend_api_key: 're_abcdef123456', 'bad key': 'x' });++ assert.deepStrictEqual(fromRows([{ key: 'storage_tier.minAgeDays', value: '3' }, { key: 'storage_tier.r2Enabled', value: 'false' }, { key: 'other', value: '1' }], { prefix: 'storage_tier.', type: 'json' }), { minAgeDays: 3, r2Enabled: false });++ const legacyDb = createDb({ pglite: true, log: { log() {}, warn() {} } });++ await legacyDb.query(configSchema());++ let legacyReads = 0;++ const importOpts = { db: legacyDb, service: 'network', namespace: 'network.site_settings', classify: (k) => (/api_key/.test(k) ? 'secret' : undefined), defaults: { site_name: 'OpenVibe' }, legacy: () => { legacyReads++; return rows; }, log };++ const imp = await createConfigStore(importOpts);++ assert.strictEqual(imp.revision(), 1);++ assert.strictEqual((await imp.snapshot(1)).reason, 'imported from the legacy source');++ assert.strictEqual(imp.get('site_name'), 'Old Live', 'legacy wins over defaults for revision 1');++ assert.strictEqual(imp.get('max_bitrate_kbps'), 4500);++ assert.ok(!('bad key' in imp.get()), 'a key the contract cannot carry is left behind');++ assert.ok(logs.some((l) => l.includes('network.site_settings') && l.includes('not imported') && l.includes('"bad key"')));++ assert.deepStrictEqual((await imp.snapshot(1)).values.resend_api_key, { redacted: true, fingerprint: await fp(legacyDb, 'network.site_settings', 're_abcdef123456') });++ assertSnapshot(await imp.snapshot(1));++ await createConfigStore(importOpts);++ assert.strictEqual(legacyReads, 1, 'the legacy source is read once, when the namespace is new');++ assert.strictEqual(await imp.import(rows), null, 'import is a no-op once the namespace has revisions');++ const imp2 = await createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.other', log });++ assert.strictEqual(imp2.revision(), null);++ const imported = await imp2.import({ a: 1 }, { reason: 'from site_settings' });++ assert.strictEqual(imported.revision, 1);++ assert.strictEqual(imported.reason, 'from site_settings');++ assert.strictEqual(imp2.get('a'), 1);++ const strictImport = await createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.strict', schema: { type: 'object', properties: { n: { type: 'integer' } } }, legacy: { n: 'not a number' }, log });++ assert.strictEqual(strictImport.get('n'), 'not a number', 'what runs today is imported even when it does not validate');++ assert.ok(logs.some((l) => l.startsWith('warn') && l.includes('network.strict') && l.includes('do not validate')));++++ // ── Secrets never reach the log ──++ assert.ok(logs.length > 10);++ assert.ok(!logs.join('\n').includes(SECRET), 'no secret in the log');++ assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the log');++ assert.ok(!logs.join('\n').includes('re_abcdef123456'));++ assert.ok(logs.some((l) => /live\.site_settings: revision \d+ active \(was \d+\) by user:usr_/.test(l)));++++ // ── /api/admin/config on Express ──++ const USER2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++ const app = express();++ app.use(express.json());++ app.use((req, _res, next) => {++ const who = req.headers['x-test-user'];++ if (who === 'admin') req.user = { id: 1, role: 'admin', subject_id: USER.id };++ else if (who === 'owner') req.user = { id: 2, role: 'admin', subject_id: USER2, owner: true };++ else if (who === 'nosubject') req.user = { id: 3, role: 'admin' };++ else if (who === 'viewer') req.user = { id: 4, role: 'user', subject_id: USER.id };++ next();++ });++ let handlerRan = 0;++ const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admin only' }));++ const authorize = (req, { action, keys }) => { handlerRan++; return action === 'read' || !!(req.user && req.user.owner) || !keys.some((k) => classify(k) === 'secret'); };++ assert.throws(() => adminRoutes([live]), /requireAdmin/);++ assert.throws(() => adminRoutes([live, live2], { requireAdmin }), /twice/);++ adminRoutes([live, media], { requireAdmin, authorize, router: app });++ const server = app.listen(0);++ await new Promise((r) => server.once('listening', r));++ const base = `http://127.0.0.1:${server.address().port}`;++ const texts = [];++ async function call(method, path, { user = 'admin', body } = {}) {++ const res = await fetch(base + path, { method, headers: { 'x-test-user': user, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });++ const text = await res.text();++ texts.push(text);++ return { status: res.status, type: res.headers.get('content-type') || '', cache: res.headers.get('cache-control'), body: text ? JSON.parse(text) : null };++ }++++ let res = await call('GET', '/api/admin/config', { user: 'viewer' });++ assert.strictEqual(res.status, 403);++ assert.deepStrictEqual(res.body, { error: 'Admin only' }, "requireAdmin's own answer");++ assert.strictEqual(handlerRan, 0, 'the handler never ran');++ res = await call('GET', '/api/admin/config');++ assert.strictEqual(res.status, 200);++ assert.match(res.type, /^application\/json/);++ assert.strictEqual(res.cache, 'no-store');++ assert.deepStrictEqual(res.body.namespaces.map((n) => n.namespace), ['live.site_settings', 'media.storage_tier']);++ const liveEntry = res.body.namespaces[0];++ assert.strictEqual(liveEntry.revision, live.revision());++ assert.deepStrictEqual(liveEntry.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });++ assert.strictEqual(liveEntry.last_known_good.revision, (await live.lastKnownGood()).revision);++ assertSnapshot(liveEntry.active);++ res = await call('GET', '/api/admin/config/media.storage_tier');++ assert.strictEqual(res.status, 200);++ assert.deepStrictEqual(res.body.values.b2_key, { redacted: true, fingerprint: await fp(db, 'media.storage_tier', SECRET) });++ // A form round trip: the marker it was shown keeps the secret, even for an admin who may not change it.++ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: liveEntry.values.youtube_api_key }, merge: true, reason: 'form round trip' } });++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));++ assert.strictEqual(live.get('youtube_api_key'), SECRET);++ assert.deepStrictEqual(res.body.values.youtube_api_key, liveEntry.values.youtube_api_key);++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: { redacted: true, fingerprint: '0'.repeat(64) } }, merge: true } });++ assertProblem(res.body, 422, 'config.invalid');++ assert.strictEqual(res.body.errors[0].path, '/youtube_api_key');++ res = await call('GET', '/api/admin/config/nope.settings');++ assert.strictEqual(res.status, 404);++ assert.strictEqual(res.type, 'application/problem+json');++ assertProblem(res.body, 404, 'config.namespace_not_found');++++ res = await call('GET', '/api/admin/config/live.site_settings/history?limit=2');++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.body.snapshots.length, 2);++ res.body.snapshots.forEach(assertSnapshot);++ assert.strictEqual(res.body.next_before, res.body.snapshots[1].revision);++ const older = await call('GET', `/api/admin/config/live.site_settings/history?limit=200&before=${res.body.next_before}`);++ assert.ok(older.body.snapshots.every((s) => s.revision < res.body.next_before));++ assert.strictEqual(older.body.next_before, null);++++ const liveBefore = live.revision();++ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { mode: 'invite' }, merge: true, reason: 'soft launch' } });++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));++ assertSnapshot(res.body);++ assert.strictEqual(res.body.revision, liveBefore + 1);++ assert.strictEqual(res.body.state, 'active');++ assert.deepStrictEqual(res.body.created_by, USER, 'the actor comes from req.user.subject_id');++ assert.strictEqual(res.body.reason, 'soft launch');++ assert.strictEqual(live.get('mode'), 'invite');++ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { max_bitrate_kbps: 1 }, merge: true } });++ assert.strictEqual(res.status, 422);++ assert.strictEqual(res.type, 'application/problem+json');++ assertProblem(res.body, 422, 'config.invalid');++ assert.deepStrictEqual(res.body.errors, [{ path: '/max_bitrate_kbps', message: 'must be >= 500' }]);++ res = await call('POST', '/api/admin/config/live.site_settings', { body: { reason: 'no values' } });++ assertProblem(res.body, 400, 'config.bad_request');++ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });++ assertProblem(res.body, 403, 'config.forbidden');++ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.body.created_by.id, USER2);++ assert.strictEqual(live.get('youtube_api_key'), 'AIzaNEW');++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'nosubject', body: { values: { mode: 'open' }, merge: true } });++ assertProblem(res.body, 403, 'config.actor_required');++ failNext = new Error(`Media said no to ${SECRET}`);++ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: SECRET, mode: 'open' }, merge: true } });++ assertProblem(res.body, 422, 'config.activation_failed');++ assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');++++ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });++ assertProblem(res.body, 403, 'config.forbidden');++ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { reason: 'undo the key' } });++ assert.strictEqual(res.status, 200, JSON.stringify(res.body));++ assertSnapshot(res.body);++ assert.ok(res.body.copied_from);++ assert.strictEqual(live.get('youtube_api_key'), SECRET);++ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 'one' } });++ assertProblem(res.body, 400, 'config.bad_request');++ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 99999 } });++ assertProblem(res.body, 404, 'config.revision_not_found');++ const emptyStore = await createConfigStore({ db, service: 'media', namespace: 'media.empty', defaults: { a: 1 }, log });++ const emptyApp = express();++ emptyApp.use(express.json());++ adminRoutes(emptyStore, { requireAdmin: (_req, _res, next) => next(), actor: () => USER }).mount(emptyApp);++ const emptyServer = emptyApp.listen(0);++ await new Promise((r) => emptyServer.once('listening', r));++ const er = await fetch(`http://127.0.0.1:${emptyServer.address().port}/api/admin/config/media.empty/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });++ const erBody = await er.json();++ assertProblem(erBody, 409, 'config.no_previous');++ emptyServer.close();++ assert.ok(texts.every((x) => !x.includes(SECRET)), 'no response ever carries the secret');++ assert.ok(!logs.join('\n').includes(SECRET));++ const forms = await leakForms(db);++ texts.forEach((x, i) => assertNoLeak(x, forms, `route response ${i}`));++ assertNoLeak(JSON.stringify([await live.summary(), await media.summary(), await live.history({ limit: 200 }), await media.history({ limit: 200 }), await live.lastKnownGood()]), forms, 'summary and history');++ server.closeAllConnections();++ server.close();++++ // ── Plain http, a basePath under an app-scoped router, and direct handlers ──++ const plain = adminRoutes([media], {++ requireAdmin: (req, res, next) => (req.headers.authorization === 'Bearer admin' ? next() : (res.statusCode = 401, res.end())),++ actor: () => ({ type: 'service', id: 'live' }),++ });++ const plainServer = http.createServer((req, res) => plain.handle(req, res));++ plainServer.listen(0);++ await new Promise((r) => plainServer.once('listening', r));++ const pbase = `http://127.0.0.1:${plainServer.address().port}`;++ const auth = { authorization: 'Bearer admin' };++ assert.strictEqual((await fetch(`${pbase}/api/admin/config`)).status, 401);++ let pr0 = await fetch(`${pbase}/api/admin/config`, { headers: auth });++ assert.strictEqual(pr0.status, 200);++ assert.strictEqual((await pr0.json()).namespaces[0].namespace, 'media.storage_tier');++ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ values: { minAgeDays: 5 }, merge: true, reason: 'plain http' }) });++ const plainSnap = await pr0.json();++ assert.strictEqual(pr0.status, 200, JSON.stringify(plainSnap));++ assert.deepStrictEqual(plainSnap.created_by, { type: 'service', id: 'live' });++ assert.strictEqual(media.get('minAgeDays'), 5);++ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier/history?limit=1`, { headers: auth });++ assert.strictEqual((await pr0.json()).snapshots.length, 1);++ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: auth, body: '{not json' });++ assertProblem(await pr0.json(), 400, 'config.bad_request');++ pr0 = await fetch(`${pbase}/elsewhere`, { headers: auth });++ assertProblem(await pr0.json(), 404, 'config.route_not_found');++ for (const path of ['/api/admin/config', '/api/admin/config/media.storage_tier', '/api/admin/config/media.storage_tier/history?limit=200']) {++ assertNoLeak(await (await fetch(pbase + path, { headers: auth })).text(), await leakForms(db), `plain ${path}`);++ }++ plainServer.close();++++ const scoped = express();++ scoped.use(express.json());++ const sub = express.Router({ mergeParams: true });++ adminRoutes([media], { requireAdmin: (_req, _res, next) => next(), basePath: '/config', actor: (req) => ({ type: 'service', id: req.params.app || 'live' }) }).mount(sub);++ scoped.use('/api/v1/:app/admin', sub);++ const scopedServer = scoped.listen(0);++ await new Promise((r) => scopedServer.once('listening', r));++ const sres = await fetch(`http://127.0.0.1:${scopedServer.address().port}/api/v1/live/admin/config/media.storage_tier`);++ assert.strictEqual(sres.status, 200);++ assert.strictEqual((await sres.json()).namespace, 'media.storage_tier');++ scopedServer.close();++++ const direct = adminRoutes([media], { requireAdmin: (req, res, next) => (req.admin ? next() : (res.statusCode = 403, res.end('no'))) });++ const fakeRes = () => ({ headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; this.done = true; } });++ const denied = fakeRes();++ await direct.list({ url: '/api/admin/config' }, denied);++ assert.strictEqual(denied.statusCode, 403, 'a handler used directly is still guarded');++ const allowedRes = fakeRes();++ direct.list({ url: '/api/admin/config', admin: true }, allowedRes);++ await sleep(5);++ assert.strictEqual(allowedRes.statusCode, 200);++ assert.strictEqual(direct.routes.length, 5);++ assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [++ 'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',++ 'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',++ ]);++++ assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');++ assert.ok(!logs.join('\n').includes(SECRET));++++ console.log(`config: store, validation, last-known-good, rollback, secrets, legacy, restart, concurrency and routes ok${hasSnapshotContract ? ' (snapshots checked against common.config-snapshot@1)' : ''}`);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
useDefault = true
useDefault = true
[[allowlists]]
[[allowlists]]
-description = "Made-up secrets in the configuration model's tests (test/config.test.js): they exist to prove secrets are redacted, never logged and never served"
+description = "Made-up secrets in the configuration model's tests (test/config.test.js and its PostgreSQL twin test/config-pg.test.js): they exist to prove secrets are redacted, never logged and never served"
condition = "AND"
condition = "AND"
-paths = ['''^test/config\.test\.js$''']
+paths = ['''^test/config(-pg)?\.test\.js$''']
regexes = ['''^(sk_live_TOPSECRET_9f8e7d6c5b4a|re_abcdef123456)$''']
regexes = ['''^(sk_live_TOPSECRET_9f8e7d6c5b4a|re_abcdef123456)$''']
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 1.28.0 — 2026-09-28
+
+**`openvibe-shared/config` on PostgreSQL** (ADR-035: Media, Network and Live keep their configuration here). Given an openvibe-sdk/db handle, `createConfigStore` uses the new `config-pg.js`, which is the same store on the async handle. Revisions are immutable, activation is atomic, the last-known-good revision is restored when `onActivate` fails, secret values are redacted with a per-namespace fingerprint key, legacy values are imported, and so on. On this path `createConfigStore` returns a promise of the store: it has read, restored or seeded its active revision when the promise resolves. `get()` and `revision()` stay in memory. The other methods are async. The tables come from the service's migrations: `configSchema()` is their DDL, the SQLite schema converted. `adminRoutes` awaits either kind of store. The better-sqlite3 path is unchanged. `test/config-pg.test.js` runs every scenario of `test/config.test.js` on PGlite (openvibe-sdk v0.20.4, which returns `bytea` as a Buffer, is a dev dependency).
+
## 1.27.0 — 2026-09-28
## 1.27.0 — 2026-09-28
**`openvibe-shared/express-async`: an async handler's rejection no longer ends the process.** Express 4 ignores what a handler returns, so a promise that an `async (req, res) => …` handler rejects (a database error, a thrown problem) became an unhandled rejection, and Node 22 exits on one. The move to PostgreSQL (ADR-035) made most handlers async; nine services had between 5 and 41 such routes. `routeAsyncErrors(app)` patches the app's own Express 4 `Layer` once, so the rejection goes to `next(err)` and the service's error handler answers, exactly as a synchronous throw always did. Error handlers get the same treatment. On Express 5, which handles promises itself, it does nothing. **`instrument()` calls it**, and every service calls `instrument()`, so a pin bump is the whole change. `test/express-async.test.js`.
**`openvibe-shared/express-async`: an async handler's rejection no longer ends the process.** Express 4 ignores what a handler returns, so a promise that an `async (req, res) => …` handler rejects (a database error, a thrown problem) became an unhandled rejection, and Node 22 exits on one. The move to PostgreSQL (ADR-035) made most handlers async; nine services had between 5 and 41 such routes. `routeAsyncErrors(app)` patches the app's own Express 4 `Layer` once, so the rejection goes to `next(err)` and the service's error handler answers, exactly as a synchronous throw always did. Error handlers get the same treatment. On Express 5, which handles promises itself, it does nothing. **`instrument()` calls it**, and every service calls `instrument()`, so a pin bump is the whole change. `test/express-async.test.js`.
@@ -89,6 +89,8 @@ never `true`: it reports status `skipped` with the reason and is listed in `skip
@@ -89,6 +89,8 @@ never `true`: it reports status `skipped` with the reason and is listed in `skip
better-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and
better-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and
`config_keys`).
`config_keys`).
+On PostgreSQL (an openvibe-sdk/db handle, ADR-035) `createConfigStore` returns a promise of the store (`config-pg.js`): `const tiering = await config.createConfigStore({ db, … })`. Its reading and writing methods are async, while `get()` and `revision()` stay in memory. The service's migrations create the tables from `config.configSchema()`.
+
```js
```js
const config = require('openvibe-shared/config');
const config = require('openvibe-shared/config');
const settings = config.createConfigStore({
const settings = config.createConfigStore({
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
"package": "openvibe-shared",
"package": "openvibe-shared",
- "version": "1.27.0",
+ "version": "1.28.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
new file mode 100644
new file mode 100644
@@ -0,0 +1,448 @@
@@ -0,0 +1,448 @@
+'use strict';
+// openvibe-shared/config on PostgreSQL (ADR-035): the same store as config.js, on an openvibe-sdk/db handle. Every
+// method that reads or writes the database is async; get() and revision() stay in memory. createConfigStore picks this
+// implementation when it is handed an openvibe-sdk/db handle, and then returns a promise of the store (it has read or
+// seeded its active revision when it resolves). The tables come from
+// the service's migrations: configSchema() is their DDL.
+const crypto = require('crypto');
+const { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows } = require('./config')._internal;
+
+/** The DDL of config_snapshots and config_keys, for a service's migrations. */
+function configSchema() {
+ return `CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL, -- the full values, secrets included (never served)
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL, -- sha256 of the full values: internal, never served
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0, -- 1 once it activated successfully (last-known-good)
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+-- The fingerprint key of each namespace. Never served, never logged.
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);`;
+}
+
+async function createPgConfigStore(opts = {}) {
+ const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;
+ if (!db || typeof db.prepare !== 'function' || typeof db.tx !== 'function') throw new TypeError('createConfigStore: db must be an openvibe-sdk/db handle');
+ if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
+ if (!NAMESPACE_RE.test(String(namespace || '')) || !namespace.startsWith(`${service}.`) || namespace.length > 120) {
+ throw new TypeError(`createConfigStore: namespace must be <service>.<name>, e.g. ${service}.settings`);
+ }
+ if (schema !== null) compileSchema(schema);
+ if (validate !== null && typeof validate !== 'function') throw new TypeError('createConfigStore: validate must be a function');
+ if (onActivate !== null && typeof onActivate !== 'function') throw new TypeError('createConfigStore: onActivate must be a function');
+ if (classify !== null && typeof classify !== 'function' && !isPlainObject(classify)) throw new TypeError('createConfigStore: classify must be a map or a function');
+ if (opts.defaults != null && !isPlainObject(opts.defaults)) throw new TypeError('createConfigStore: defaults must be an object');
+ const keep = Math.max(1, Math.floor(opts.keep || 50));
+ const maxBytes = opts.maxBytes || 1024 * 1024;
+ const log = makeLog(opts.log);
+ const now = opts.now || (() => new Date());
+ const iso = () => { const d = now(); return (d instanceof Date ? d : new Date(d)).toISOString(); };
+ const self = { type: 'service', id: service };
+ const defaults = deepFreeze(clone(opts.defaults || {}));
+ const tag = `[config] ${namespace}`;
+
+ // config_snapshots and config_keys come from the service's migrations (configSchema()).
+ const q = {
+ row: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?'),
+ active: db.prepare("SELECT * FROM config_snapshots WHERE namespace = ? AND state = 'active'"),
+ any: db.prepare('SELECT 1 AS x FROM config_snapshots WHERE namespace = ? LIMIT 1'),
+ max: db.prepare('SELECT COALESCE(MAX(revision), 0) AS n FROM config_snapshots WHERE namespace = ?'),
+ lastGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('active', 'superseded', 'rolled_back') ORDER BY revision DESC LIMIT 1"),
+ previousGood: db.prepare("SELECT revision FROM config_snapshots WHERE namespace = ? AND good = 1 AND state IN ('superseded', 'rolled_back') AND revision < ? AND values_checksum != ? ORDER BY revision DESC LIMIT 1"),
+ history: db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision < ? ORDER BY revision DESC LIMIT ?'),
+ insert: db.prepare(`INSERT INTO config_snapshots (namespace, revision, service, previous_revision, state, values_json, classification_json,
+ values_checksum, created_at, created_by, activated_at, activated_by, reason, copied_from, good)
+ VALUES (@namespace, @revision, @service, @previous_revision, @state, @values_json, @classification_json,
+ @values_checksum, @created_at, @created_by, @activated_at, @activated_by, @reason, @copied_from, @good)`),
+ setState: db.prepare('UPDATE config_snapshots SET state = ? WHERE namespace = ? AND revision = ?'),
+ activate: db.prepare("UPDATE config_snapshots SET state = 'active', activated_at = ?, activated_by = ?, good = ? WHERE namespace = ? AND revision = ?"),
+ good: db.prepare('UPDATE config_snapshots SET good = 1 WHERE namespace = ? AND revision = ?'),
+ reject: db.prepare("UPDATE config_snapshots SET state = 'rejected', error = ?, activated_at = NULL, activated_by = NULL, good = 0 WHERE namespace = ? AND revision = ?"),
+ prune: db.prepare("DELETE FROM config_snapshots WHERE namespace = ? AND revision <= ? AND state != 'active' AND revision != ?"),
+ };
+ const tx = async (fn) => await db.tx(fn); // ambient: the store's statements inside join it
+
+ // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──
+ let hmacKey = null; // loaded by init(): made on first use, then read (a racing process keeps the first)
+ async function loadKey() {
+ const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
+ let row = await read.get(namespace);
+ if (!row) {
+ await db.prepare('INSERT INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?) ON CONFLICT DO NOTHING').run(namespace, crypto.randomBytes(32), iso());
+ row = await read.get(namespace);
+ }
+ const k = row && row.hmac_key ? Buffer.from(row.hmac_key) : null; // Buffer (pg) or Uint8Array (PGlite)
+ if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);
+ hmacKey = k;
+ }
+ const fingerprint = (v) => crypto.createHmac('sha256', hmacKey).update(canonical(v)).digest('hex');
+ const markerFor = (v) => ({ redacted: true, fingerprint: fingerprint(v) });
+
+ // ── classification and redaction ──
+ const RANK = { public: 0, internal: 1, secret: 2 };
+ function classOf(key, value) {
+ let c;
+ try { c = typeof classify === 'function' ? classify(key, value) : (classify && has(classify, key) ? classify[key] : undefined); } catch { c = 'secret'; }
+ if (c == null) return 'internal';
+ if (!CLASSES.includes(c)) { log.warn(`${tag}: classify gave "${String(c).slice(0, 20)}" for ${key}; treating it as secret`); return 'secret'; }
+ return c;
+ }
+ const classifyAll = (values) => Object.fromEntries(Object.keys(values).map((k) => [k, classOf(k, values[k])]));
+ /** The stricter of what was stored and what the policy says now, so a key made secret later is redacted in old revisions too. */
+ function classesFor(values, stored = {}) {
+ const out = classifyAll(values);
+ for (const k of Object.keys(out)) if (stored[k] && RANK[stored[k]] > RANK[out[k]]) out[k] = stored[k];
+ return out;
+ }
+ const redact = (values, cls) => Object.fromEntries(Object.entries(values).map(([k, v]) => [k, cls[k] === 'secret' ? markerFor(v) : v]));
+ function secretStrings(...valueSets) {
+ const out = [];
+ for (const values of valueSets) {
+ if (!values) continue;
+ for (const [k, v] of Object.entries(values)) {
+ if (classOf(k, v) !== 'secret' || v == null) continue;
+ out.push(typeof v === 'string' ? v : JSON.stringify(v));
+ }
+ }
+ return out.sort((a, b) => b.length - a.length);
+ }
+
+ function toSnapshot(row) {
+ const values = JSON.parse(row.values_json);
+ const classification = classesFor(values, JSON.parse(row.classification_json));
+ const shown = redact(values, classification);
+ return {
+ service: row.service,
+ namespace: row.namespace,
+ revision: row.revision,
+ previous_revision: row.previous_revision == null ? null : row.previous_revision,
+ state: row.state,
+ values: shown,
+ classification,
+ checksum: sha256(canonical(shown)),
+ created_at: row.created_at,
+ created_by: JSON.parse(row.created_by),
+ activated_at: row.activated_at || null,
+ activated_by: row.activated_by ? JSON.parse(row.activated_by) : null,
+ reason: row.reason == null ? null : row.reason,
+ error: row.error == null ? null : row.error,
+ copied_from: row.copied_from == null ? null : row.copied_from,
+ };
+ }
+
+ // ── in-memory state ──
+ let current = null; // { revision, values (stored), effective (frozen), classification }
+ const emptyEffective = defaults;
+ function load(row) {
+ const values = JSON.parse(row.values_json);
+ return { revision: row.revision, checksum: row.values_checksum, values, effective: deepFreeze({ ...clone(defaults), ...clone(values) }), classification: JSON.parse(row.classification_json) };
+ }
+ const effectiveNow = () => (current ? current.effective : emptyEffective);
+
+ // ── validation ──
+ function problems(values) {
+ const errors = [];
+ if (!isPlainObject(values)) return [{ path: '', message: 'must be an object' }];
+ for (const k of Object.keys(values)) if (!validKey(k)) errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' });
+ for (const [k, v] of Object.entries(values)) if (looksRedacted(v)) errors.push({ path: pointer(k), message: 'is an object with a redacted member, which only a redaction marker may be' });
+ jsonErrors(values, '', errors);
+ if (errors.length) return errors;
+ const size = Buffer.byteLength(canonical(values));
+ if (size > maxBytes) return [{ path: '', message: `is ${size} bytes; at most ${maxBytes}` }];
+ const effective = { ...defaults, ...values };
+ if (schema !== null) checkSchema(schema, effective, '', errors);
+ if (validate) {
+ let r;
+ try { r = validate(effective); } catch (err) { r = [{ path: '', message: errText(err) }]; }
+ if (r && typeof r.then === 'function') throw new TypeError('createConfigStore: validate must be synchronous (check asynchronously in onActivate)');
+ errors.push(...validateResult(r));
+ }
+ const secrets = secretStrings(values, effectiveNow());
+ return errors.map((e) => ({ path: e.path, message: scrub(e.message, secrets) }));
+ }
+ const invalid = (errors, revision) => new ConfigError('config.invalid', `invalid configuration: ${summarize(errors)}`.slice(0, ERROR_MAX), { status: 422, errors, revision });
+
+ /** values (+ merge/unset) as the complete values a new revision would hold. A redaction marker keeps the value it stands for. */
+ function resolve(input, { merge = false, unset = [] } = {}) {
+ if (!isPlainObject(input)) throw invalid([{ path: '', message: 'must be an object' }]);
+ if (!Array.isArray(unset) || !unset.every((k) => typeof k === 'string')) throw invalid([{ path: '', message: 'unset must be a list of keys' }]);
+ const base = merge && current ? clone(current.values) : {};
+ const errors = [];
+ for (const [k, v] of Object.entries(input)) {
+ if (!validKey(k)) { errors.push({ path: pointer(k), message: 'is not a valid key (letters, digits, _ . : -; at most 128)' }); continue; }
+ if (!isMarker(v)) { base[k] = v; continue; }
+ if (current && has(current.values, k) && sameHex(fingerprint(current.values[k]), v.fingerprint)) base[k] = clone(current.values[k]);
+ else if (has(defaults, k) && sameHex(fingerprint(defaults[k]), v.fingerprint)) delete base[k];
+ else errors.push({ path: pointer(k), message: 'is a redacted value that is not the current one; send the value itself' });
+ }
+ if (errors.length) throw invalid(errors);
+ for (const k of unset) delete base[k];
+ return base;
+ }
+
+ function changedKeys(before, after) {
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
+ return [...keys].filter((k) => has(before, k) !== has(after, k) || !same(before[k], after[k])).sort();
+ }
+ const listKeys = (keys) => (keys.length ? keys.slice(0, 20).join(', ') + (keys.length > 20 ? ` (+${keys.length - 20})` : '') : 'nothing');
+ const who = (a) => `${a.type}:${a.id}`;
+
+ function actorOf(actor) {
+ if (actor == null) return self;
+ const ref = subjectRef(actor);
+ if (!ref) throw new ConfigError('config.bad_actor', 'actor must be a subject reference ({ type, id })', { status: 400 });
+ return ref;
+ }
+ function reasonOf(reason) {
+ if (reason == null || reason === '') return null;
+ if (typeof reason !== 'string') throw invalid([{ path: '/reason', message: 'must be a string' }]);
+ return reason.slice(0, REASON_MAX);
+ }
+
+ async function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
+ let row;
+ await tx(async () => {
+ const revision = (await q.max.get(namespace)).n + 1;
+ await q.insert.run({
+ namespace, revision, service, previous_revision: previous, state,
+ values_json: JSON.stringify(values), classification_json: JSON.stringify(classifyAll(values)),
+ values_checksum: sha256(canonical(values)), created_at: iso(), created_by: JSON.stringify(actor),
+ activated_at: activatedAt, activated_by: activatedAt ? JSON.stringify(actor) : null,
+ reason, copied_from: copiedFrom, good,
+ });
+ row = await q.row.get(namespace, revision);
+ });
+ return row;
+ }
+
+ async function prune() {
+ const cutoff = (await q.max.get(namespace)).n - keep;
+ if (cutoff < 1) return;
+ const good = await q.lastGood.get(namespace);
+ await q.prune.run(namespace, cutoff, good ? good.revision : -1);
+ }
+
+ // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──
+ async function seed(values, reason, { strict }) {
+ if (!strict) {
+ // A legacy key the contract cannot carry is left behind, by name, rather than imported.
+ const bad = Object.keys(values).filter((k) => !validKey(k) || looksRedacted(values[k]));
+ if (bad.length) {
+ log.warn(`${tag}: ${bad.length} legacy key(s) are not valid config keys and were not imported: ${bad.slice(0, 5).map((k) => JSON.stringify(k.slice(0, 64))).join(', ')}`);
+ values = Object.fromEntries(Object.entries(values).filter(([k]) => validKey(k)));
+ }
+ }
+ const errors = problems(values);
+ if (errors.length && strict) throw new ConfigError('config.invalid_defaults', `${namespace} defaults do not validate: ${summarize(errors)}`, { status: 500, errors });
+ if (errors.length) log.warn(`${tag}: the imported values do not validate (${summarize(errors).slice(0, 300)}); imported as they are`);
+ const row = await insertRow(clone(values), { state: 'active', actor: self, reason, previous: null, activatedAt: iso(), good: 1 });
+ current = load(row);
+ log.info(`${tag}: revision ${row.revision} ${reason} (${Object.keys(values).length} keys)`);
+ return toSnapshot(row);
+ }
+
+ async function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {
+ if (await q.any.get(namespace)) return null;
+ let v = typeof legacy === 'function' ? legacy() : legacy;
+ if (Array.isArray(v)) v = fromRows(v);
+ if (v == null) return null;
+ if (!isPlainObject(v)) throw new TypeError('import: legacy must give an object or key-value rows');
+ return await seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });
+ }
+
+ async function boot() {
+ await loadKey();
+ let row = await q.active.get(namespace);
+ if (row && !row.good) {
+ // The process stopped between the switch and the end of onActivate: that revision never
+ // proved itself, so the last-known-good one is active again.
+ const good = await q.lastGood.get(namespace);
+ await tx(async () => {
+ await q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);
+ if (good) await q.setState.run('active', namespace, good.revision);
+ });
+ log.warn(`${tag}: revision ${row.revision} was interrupted while activating; ${good ? `revision ${good.revision} restored` : 'no earlier revision to restore'}`);
+ row = good ? await q.row.get(namespace, good.revision) : null;
+ }
+ if (row) {
+ current = load(row);
+ const errors = problems(current.values);
+ if (errors.length) log.warn(`${tag}: active revision ${row.revision} does not validate under this release (${summarize(errors).slice(0, 300)}); serving it as it is`);
+ return;
+ }
+ if (await q.any.get(namespace)) return;
+ if (opts.legacy !== undefined && await importLegacy(opts.legacy)) return;
+ if (opts.defaults != null) await seed(defaults, 'seeded from defaults', { strict: true });
+ }
+
+ // ── activation (serialized per store) ──
+ let queue = Promise.resolve();
+ function serialized(fn) {
+ const run = queue.then(fn, fn);
+ queue = run.catch(() => {});
+ return run;
+ }
+
+ async function activateNow(revision, { actor, rollingBack = false } = {}) {
+ const by = actorOf(actor);
+ const row = await q.row.get(namespace, revision);
+ if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${revision}`, { status: 404, revision });
+ if (row.state !== 'proposed') throw new ConfigError('config.not_proposed', `revision ${revision} is ${row.state}, not proposed`, { status: 409, revision });
+ const values = JSON.parse(row.values_json);
+ const errors = problems(values);
+ if (errors.length) {
+ await q.reject.run(summarize(errors).slice(0, ERROR_MAX), namespace, revision);
+ log.warn(`${tag}: revision ${revision} rejected: ${summarize(errors).slice(0, 300)}`);
+ throw invalid(errors, revision);
+ }
+ const prev = current;
+ const at = iso();
+ await tx(async () => {
+ const dbActive = await q.active.get(namespace);
+ const activeRev = dbActive ? dbActive.revision : null;
+ if ((row.previous_revision == null ? null : row.previous_revision) !== activeRev) {
+ throw new ConfigError('config.stale', `revision ${revision} was proposed on ${row.previous_revision ? `revision ${row.previous_revision}` : 'an empty namespace'}, but ${activeRev ? `revision ${activeRev}` : 'nothing'} is active now; propose it again`, { status: 409, revision });
+ }
+ if (dbActive) await q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);
+ await q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);
+ });
+ current = load(await q.row.get(namespace, revision));
+ const changed = changedKeys(prev ? prev.effective : emptyEffective, current.effective);
+ if (onActivate) {
+ const next = current;
+ try {
+ await onActivate(next.effective, prev ? prev.effective : null, { namespace, revision, previous_revision: prev ? prev.revision : null, restoring: false });
+ } catch (err) {
+ const message = scrub(`onActivate: ${errText(err)}`, secretStrings(next.values, prev && prev.values));
+ await tx(async () => {
+ await q.reject.run(message, namespace, revision);
+ if (prev) await q.setState.run('active', namespace, prev.revision);
+ });
+ current = prev;
+ log.warn(`${tag}: revision ${revision} rejected (${message}); ${prev ? `revision ${prev.revision} restored` : 'nothing was active before'}`);
+ if (prev) {
+ try {
+ await onActivate(prev.effective, next.effective, { namespace, revision: prev.revision, previous_revision: revision, restoring: true });
+ } catch (again) {
+ log.error(`${tag}: re-applying revision ${prev.revision} failed too: ${scrub(errText(again), secretStrings(next.values, prev.values))}`);
+ }
+ }
+ throw new ConfigError('config.activation_failed', message, { status: 422, revision, cause: err });
+ }
+ await q.good.run(namespace, revision);
+ }
+ await prune();
+ log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
+ return toSnapshot(await q.row.get(namespace, revision));
+ }
+
+ async function propose(values, o = {}) {
+ const actor = actorOf(o.actor);
+ const reason = reasonOf(o.reason);
+ const next = resolve(values, o);
+ const errors = problems(next);
+ if (errors.length) throw invalid(errors);
+ const row = await insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
+ await prune();
+ log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);
+ return toSnapshot(row);
+ }
+
+ async function rollbackTarget(to) {
+ if (!current) throw new ConfigError('config.nothing_active', `${namespace} has no active revision to roll back`, { status: 409 });
+ if (to == null) {
+ const r = await q.previousGood.get(namespace, current.revision, current.checksum);
+ if (!r) throw new ConfigError('config.no_previous', `${namespace} has no earlier good revision to roll back to`, { status: 409 });
+ return r.revision;
+ }
+ const rev = Number(to);
+ const row = Number.isInteger(rev) ? await q.row.get(namespace, rev) : null;
+ if (!row) throw new ConfigError('config.revision_not_found', `${namespace} has no revision ${to}`, { status: 404, revision: Number.isInteger(rev) ? rev : null });
+ if (row.revision === current.revision) throw new ConfigError('config.already_active', `revision ${rev} is the active one`, { status: 409, revision: rev });
+ if (!row.good) throw new ConfigError('config.not_good', `revision ${rev} never activated successfully (${row.state})`, { status: 409, revision: rev });
+ return rev;
+ }
+
+ const store = {
+ service,
+ namespace,
+ /** The active values over the defaults (frozen), or one of them. Never reads the database. */
+ get(key) { const e = effectiveNow(); return key === undefined ? e : e[key]; },
+ /** The active revision number, or null. */
+ revision() { return current ? current.revision : null; },
+ propose,
+ activate(revision, o = {}) { return serialized(async () => await activateNow(Number(revision), { actor: o.actor })); },
+ /** propose + activate, in turn with every other activation of this store. */
+ apply(values, o = {}) { return serialized(async () => await activateNow((await propose(values, o)).revision, { actor: o.actor })); },
+ /** A new revision copying the previous good one (or `to`), activated; the one it replaces becomes rolled_back. */
+ rollback(o = {}) {
+ return serialized(async () => {
+ const actor = actorOf(o.actor);
+ const target = await rollbackTarget(o.to);
+ const values = JSON.parse((await q.row.get(namespace, target)).values_json);
+ const row = await insertRow(values, { state: 'proposed', actor, reason: reasonOf(o.reason) || `rollback to revision ${target}`, previous: current.revision, copiedFrom: target });
+ log.info(`${tag}: revision ${row.revision} rolls back to revision ${target} (by ${who(actor)})`);
+ return await activateNow(row.revision, { actor, rollingBack: true });
+ });
+ },
+ rollbackTarget,
+ async lastKnownGood() { const r = await q.lastGood.get(namespace); return r ? toSnapshot(await q.row.get(namespace, r.revision)) : null; },
+ async snapshot(revision) { const row = await q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },
+ async history({ limit = 20, before = null } = {}) {
+ const n = Math.min(200, Math.max(1, Math.floor(Number(limit)) || 20));
+ const b = before == null || before === '' ? Number.MAX_SAFE_INTEGER : Math.floor(Number(before));
+ return (await q.history.all(namespace, Number.isFinite(b) ? b : Number.MAX_SAFE_INTEGER, n)).map(toSnapshot);
+ },
+ /** Keys whose effective value would change: for new values (+ merge/unset), or for a revision's. */
+ async changes(values, o = {}) {
+ const next = o.revision != null ? JSON.parse((await q.row.get(namespace, Number(o.revision)) || { values_json: '{}' }).values_json) : resolve(values, o);
+ return changedKeys(effectiveNow(), { ...defaults, ...next });
+ },
+ /** What /api/admin/config shows: the active revision, its effective values redacted, and the last-known-good. */
+ async summary() {
+ const effective = effectiveNow();
+ const classification = classesFor(effective, current ? current.classification : {});
+ return {
+ service, namespace,
+ revision: current ? current.revision : null,
+ values: redact(effective, classification),
+ classification,
+ active: current ? await store.snapshot(current.revision) : null,
+ last_known_good: await store.lastKnownGood(),
+ };
+ },
+ /** Seed revision 1 from a legacy source when the namespace has no revisions yet (else null). */
+ import: importLegacy,
+ /** Re-read the active revision (another process changed it). onActivate is not run. */
+ async reload() {
+ const row = await q.active.get(namespace);
+ current = row ? load(row) : null;
+ return store.revision();
+ },
+ };
+ await boot();
+ return store;
+}
+
+// ── /api/admin/config ───────────────────────────────────────────
+
+const TITLES = { 400: 'Bad Request', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 413: 'Payload Too Large', 422: 'Unprocessable Content', 500: 'Internal Server Error' };
+
+module.exports = { createPgConfigStore, configSchema };
@@ -281,6 +281,8 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
@@ -281,6 +281,8 @@ function fromRows(rows, { prefix = '', type = null } = {}) {
// ── The store ───────────────────────────────────────────────────
// ── The store ───────────────────────────────────────────────────
function createConfigStore(opts = {}) {
function createConfigStore(opts = {}) {
+ // On an openvibe-sdk/db handle (PostgreSQL, ADR-035): config-pg.js, which returns a promise of the store.
+ if (opts.db && typeof opts.db.tx === 'function') return require('./config-pg').createPgConfigStore(opts);
const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;
const { db, service, namespace, schema = null, validate = null, classify = null, onActivate = null } = opts;
if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') throw new TypeError('createConfigStore: db must be a better-sqlite3 handle');
if (!db || typeof db.prepare !== 'function' || typeof db.transaction !== 'function') throw new TypeError('createConfigStore: db must be a better-sqlite3 handle');
if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
if (!SERVICE_RE.test(String(service || ''))) throw new TypeError('createConfigStore: service must be a service id (live, media, …)');
@@ -766,20 +768,20 @@ function adminRoutes(stores, o = {}) {
@@ -766,20 +768,20 @@ function adminRoutes(stores, o = {}) {
const handlers = {
const handlers = {
async list(req, res) {
async list(req, res) {
const namespaces = [];
const namespaces = [];
- for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(s.summary());
+ for (const s of list) if (await allowed(req, { action: 'read', namespace: s.namespace, keys: [] })) namespaces.push(await s.summary());
send(res, 200, { namespaces });
send(res, 200, { namespaces });
},
},
async get(req, res) {
async get(req, res) {
const s = storeFor(req);
const s = storeFor(req);
if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });
if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });
- send(res, 200, s.summary());
+ send(res, 200, await s.summary());
},
},
async history(req, res) {
async history(req, res) {
const s = storeFor(req);
const s = storeFor(req);
if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });
if (!(await allowed(req, { action: 'read', namespace: s.namespace, keys: [] }))) throw new ConfigError('config.forbidden', 'not allowed to read this namespace', { status: 403 });
const query = queryOf(req);
const query = queryOf(req);
const limit = Math.min(200, Math.max(1, parseInt(query.limit, 10) || 20));
const limit = Math.min(200, Math.max(1, parseInt(query.limit, 10) || 20));
- const snapshots = s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null });
+ const snapshots = await s.history({ limit, before: query.before != null && query.before !== '' ? parseInt(query.before, 10) : null });
send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null });
send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null });
},
},
async apply(req, res) {
async apply(req, res) {
@@ -790,7 +792,7 @@ function adminRoutes(stores, o = {}) {
@@ -790,7 +792,7 @@ function adminRoutes(stores, o = {}) {
const actor = await actorOf(req);
const actor = await actorOf(req);
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
const opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
- const keys = s.changes(body.values, opts);
+ const keys = await s.changes(body.values, opts);
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
send(res, 200, await s.apply(body.values, opts));
send(res, 200, await s.apply(body.values, opts));
},
},
@@ -801,7 +803,7 @@ function adminRoutes(stores, o = {}) {
@@ -801,7 +803,7 @@ function adminRoutes(stores, o = {}) {
if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 });
if (body.to != null && !Number.isInteger(body.to)) throw new ConfigError('config.bad_request', 'to must be a revision number', { status: 400 });
const actor = await actorOf(req);
const actor = await actorOf(req);
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
if (!subjectRef(actor)) throw new ConfigError('config.actor_required', 'the request does not name the acting subject', { status: 403 });
- const keys = s.changes(null, { revision: s.rollbackTarget(body.to) });
+ const keys = await s.changes(null, { revision: await s.rollbackTarget(body.to) });
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
if (!(await allowed(req, { action: 'write', namespace: s.namespace, keys }))) throw new ConfigError('config.forbidden', 'not allowed to change these keys', { status: 403 });
send(res, 200, await s.rollback({ to: body.to, reason: body.reason, actor }));
send(res, 200, await s.rollback({ to: body.to, reason: body.reason, actor }));
},
},
@@ -863,4 +865,6 @@ function adminRoutes(stores, o = {}) {
@@ -863,4 +865,6 @@ function adminRoutes(stores, o = {}) {
return { ...out, routes, mount, handle };
return { ...out, routes, mount, handle };
}
}
-module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem };
+module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem, configSchema: (...a) => require('./config-pg').configSchema(...a) };
+// The helpers the PostgreSQL store (config-pg.js) shares with this one.
+module.exports._internal = { CLASSES, SERVICE_RE, NAMESPACE_RE, validKey, REASON_MAX, ERROR_MAX, ConfigError, canonical, sha256, isPlainObject, pointer, clone, same, has, deepFreeze, jsonErrors, isMarker, looksRedacted, sameHex, compileSchema, checkSchema, validateResult, subjectRef, scrub, errText, summarize, makeLog, fromRows };
@@ -1,23 +1,32 @@
@@ -1,23 +1,32 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.27.0",
+ "version": "1.28.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.27.0",
+ "version": "1.28.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
},
},
"devDependencies": {
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4"
}
}
},
},
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.8",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
+ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/accepts": {
"node_modules/accepts": {
"version": "1.3.8",
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
@@ -1337,6 +1346,36 @@
@@ -1337,6 +1346,36 @@
"node": ">=20"
"node": ">=20"
}
}
},
},
+ "node_modules/openvibe-sdk": {
+ "version": "0.20.4",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",
+ "integrity": "sha512-IMvvBrFhCVO4sm2Ii694BslcnKHSV7YABXviLqrDI/2lTr9NKIH80z3HXxwMUgj0fwZDWBWcNgqPs6OaWIB0GQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "better-sqlite3": ">=11",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "better-sqlite3": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
"node_modules/parseurl": {
"node_modules/parseurl": {
"version": "1.3.3",
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.27.0",
+ "version": "1.28.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -21,6 +21,7 @@
@@ -21,6 +21,7 @@
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
"./config": "./config.js",
"./config": "./config.js",
+ "./config-pg": "./config-pg.js",
"./egress": "./egress.js",
"./egress": "./egress.js",
"./files": "./files.js",
"./files": "./files.js",
"./footer": "./footer.js",
"./footer": "./footer.js",
@@ -85,9 +86,11 @@
@@ -85,9 +86,11 @@
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
},
},
"devDependencies": {
"devDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
"express": "^4.22.3",
"express": "^4.22.3",
"linkedom": "^0.18.13",
"linkedom": "^0.18.13",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0"
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.61.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,638 @@
@@ -0,0 +1,638 @@
+'use strict';
+// config-pg.js (PostgreSQL, through an embedded PGlite): the scenarios of config.test.js. Validation, classification and redaction, atomic activation with last-known-good
+// restored when onActivate throws (memory and database), rollback as a new revision, legacy import,
+// two namespaces in one database, restart and an interrupted activation, serialized applies,
+// pruning that keeps the last-known-good, and /api/admin/config on Express and on plain http.
+const assert = require('assert');
+const crypto = require('crypto');
+const http = require('http');
+let createDb;
+try { ({ createDb } = require('openvibe-sdk/db')); } catch { console.log('config-pg: skipped (openvibe-sdk not installed)'); process.exit(0); }
+const { configSchema } = require('../config-pg');
+const express = require('express');
+const { createConfigStore, adminRoutes, fromRows, canonical, ConfigError } = require('../config');
+
+let contracts = null;
+try { contracts = require('openvibe-contracts'); } catch { /* optional */ }
+const hasSnapshotContract = (() => { try { return !!(contracts && contracts.resolve('common.config-snapshot@1')); } catch { return false; } })();
+
+const USER = { type: 'user', id: 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ' };
+const SECRET = 'sk_live_TOPSECRET_9f8e7d6c5b4a';
+const sha = (v) => crypto.createHash('sha256').update(canonical(v)).digest('hex');
+/** The namespace's fingerprint key, read the way only the service itself can, and a fingerprint under it. */
+const keyOf = async (db, ns) => (await db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns)).hmac_key;
+const fp = async (db, ns, v) => crypto.createHmac('sha256', await keyOf(db, ns)).update(canonical(v)).digest('hex');
+/** Every form a key could leak in, for every namespace in a database, and the unkeyed hash of SECRET. */
+const leakForms = async (db) => (await db.prepare('SELECT hmac_key FROM config_keys').all())
+ .flatMap((r) => [r.hmac_key.toString('hex'), r.hmac_key.toString('base64'), r.hmac_key.toString('base64url')])
+ .concat([sha(SECRET)]);
+const assertNoLeak = (text, forms, what) => { for (const f of forms) assert.ok(!String(text).includes(f), `${what} leaks a key or an unkeyed hash`); };
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+let t = Date.parse('2026-09-25T12:00:00.000Z');
+const clock = () => new Date(t += 1000);
+const logs = [];
+const log = (m, level) => logs.push(`${level} ${m}`);
+
+/** The common.config-snapshot@1 shape, checked here and by the contract when the pinned copy has it. */
+function assertSnapshot(s) {
+ const keys = ['service', 'namespace', 'revision', 'previous_revision', 'state', 'values', 'classification', 'checksum', 'created_at', 'created_by', 'activated_at', 'activated_by', 'reason', 'error', 'copied_from'];
+ assert.deepStrictEqual(Object.keys(s).sort(), [...keys].sort());
+ assert.ok(['proposed', 'active', 'superseded', 'rejected', 'rolled_back'].includes(s.state), s.state);
+ assert.ok(Number.isInteger(s.revision) && s.revision >= 1);
+ assert.match(s.checksum, /^[0-9a-f]{64}$/);
+ assert.deepStrictEqual(Object.keys(s.values).sort(), Object.keys(s.classification).sort(), 'every key classified');
+ for (const [k, v] of Object.entries(s.values)) {
+ const marker = !!(v && typeof v === 'object' && v.redacted === true);
+ assert.strictEqual(marker, s.classification[k] === 'secret', `${k} redacted exactly when secret`);
+ if (marker) { assert.deepStrictEqual(Object.keys(v).sort(), ['fingerprint', 'redacted']); assert.match(v.fingerprint, /^[0-9a-f]{64}$/); }
+ }
+ assert.strictEqual(s.checksum, sha(s.values), 'the checksum covers the values as shown');
+ if (s.state === 'rejected') assert.ok(s.error, 'a rejected revision names its error');
+ if (['active', 'superseded', 'rolled_back'].includes(s.state)) assert.ok(s.activated_at, `${s.state} has activated_at`);
+ if (hasSnapshotContract) {
+ const r = contracts.validate('common.config-snapshot@1', s);
+ assert.ok(r.valid, JSON.stringify(r.errors));
+ }
+ return s;
+}
+const assertProblem = (body, status, code) => {
+ assert.strictEqual(body.status, status);
+ assert.strictEqual(body.code, code);
+ assert.strictEqual(body.type, `https://openvibe.network/problems/${code}`);
+ if (contracts) assert.ok(contracts.validate('errors.problem@1', body).valid, JSON.stringify(body));
+};
+
+/** A handle that counts statement executions, to prove get() never touches the database. */
+function counting(db) {
+ const c = { n: 0 };
+ return {
+ c,
+ tx: async (fn) => await db.tx(fn),
+ prepare(sql) {
+ const st = db.prepare(sql);
+ return { get: async (...a) => (c.n++, await st.get(...a)), all: async (...a) => (c.n++, await st.all(...a)), run: async (...a) => (c.n++, await st.run(...a)) };
+ },
+ };
+}const rowOf = async (db, ns, rev) => await db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?').get(ns, rev);
+
+(async () => {
+ // PostgreSQL (ADR-035) on an embedded PGlite: the same scenarios as config.test.js, through config-pg.js.
+ const db = createDb({ pglite: true, log: { log() {}, warn() {} } });
+ await db.query(configSchema());
+
+ // ── Construction ──
+ await assert.rejects(async () => await createConfigStore({}), /better-sqlite3/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { oneOf: [] } }), /unsupported keyword "oneOf"/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { type: 'object', properties: { a: { $ref: '#' } } } }), /unsupported keyword "\$ref"/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.x', classify: 'secret' }), /classify/);
+ await assert.rejects(async () => await createConfigStore({ db, service: 'live', namespace: 'live.bad_defaults', schema: { type: 'object', properties: { a: { type: 'string' } } }, defaults: { a: 1 }, log }),
+ (e) => e.code === 'config.invalid_defaults');
+
+ // ── Seeded from defaults ──
+ const SCHEMA = {
+ $schema: 'https://json-schema.org/draft/2020-12/schema', title: 'Live site settings',
+ type: 'object', additionalProperties: false, required: ['site_name'],
+ properties: {
+ site_name: { type: 'string', minLength: 1, maxLength: 60, description: 'shown in the navbar' },
+ max_bitrate_kbps: { type: 'integer', minimum: 500, maximum: 20000 },
+ mode: { enum: ['open', 'invite', 'closed'] },
+ support_email: { type: 'string', pattern: '^[^@\\s]+@[^@\\s]+$', format: 'email' },
+ youtube_api_key: { type: 'string' },
+ tags: { type: 'array', items: { type: 'string' }, maxItems: 3, uniqueItems: true },
+ banner: { type: ['object', 'null'], additionalProperties: false, properties: { text: { type: 'string' } } },
+ },
+ };
+ const DEFAULTS = { site_name: 'OpenVibe.Live', max_bitrate_kbps: 6000, mode: 'open', youtube_api_key: '' };
+ const classify = (k) => (/api_key|secret|token/.test(k) ? 'secret' : ['site_name', 'mode'].includes(k) ? 'public' : undefined);
+ let activations = [];
+ let failNext = null;
+ let tighten = false;
+ const liveOpts = {
+ db, service: 'live', namespace: 'live.site_settings', schema: SCHEMA, classify, defaults: DEFAULTS, log, now: clock,
+ validate: (v) => {
+ if (v.mode === 'closed' && v.max_bitrate_kbps > 10000) return [{ path: '/max_bitrate_kbps', message: 'must be at most 10000 while closed' }];
+ if (v.youtube_api_key && !/^[A-Za-z0-9_-]+$/.test(v.youtube_api_key)) return `youtube_api_key ${v.youtube_api_key} has characters keys never have`;
+ if (tighten && v.max_bitrate_kbps < 7500) return 'max_bitrate_kbps is below the new floor';
+ return true;
+ },
+ onActivate: async (values, previous, info) => {
+ activations.push({ values, previous, info });
+ await sleep(1);
+ if (failNext && !info.restoring) { const e = failNext; failNext = null; throw e; }
+ },
+ };
+ const counted = counting(db);
+ const live = await createConfigStore({ ...liveOpts, db: counted });
+ assert.strictEqual(live.revision(), 1);
+ assert.deepStrictEqual(live.get(), DEFAULTS);
+ assert.strictEqual(live.get('mode'), 'open');
+ assert.ok(Object.isFrozen(live.get()));
+ assert.throws(() => { live.get().mode = 'closed'; }, TypeError);
+ const seeded = assertSnapshot(await live.lastKnownGood());
+ assert.strictEqual(seeded.revision, 1);
+ assert.strictEqual(seeded.state, 'active');
+ assert.strictEqual(seeded.previous_revision, null);
+ assert.strictEqual(seeded.reason, 'seeded from defaults');
+ assert.deepStrictEqual(seeded.created_by, { type: 'service', id: 'live' });
+ assert.strictEqual(activations.length, 0, 'seeding does not run onActivate');
+ const reads = counted.c.n;
+ for (let i = 0; i < 1000; i++) { live.get(); live.get('mode'); live.revision(); }
+ assert.strictEqual(counted.c.n, reads, 'get() and revision() never read the database');
+
+ // ── Validation rejects, and nothing is stored ──
+ const before = (await live.history({ limit: 200 })).length;
+ const cases = [
+ [{ ...DEFAULTS, max_bitrate_kbps: 100 }, '/max_bitrate_kbps'],
+ [{ ...DEFAULTS, max_bitrate_kbps: 6000.5 }, '/max_bitrate_kbps'],
+ [{ ...DEFAULTS, max_bitrate_kbps: NaN }, '/max_bitrate_kbps'],
+ [{ ...DEFAULTS, mode: 'party' }, '/mode'],
+ [{ ...DEFAULTS, support_email: 'nope' }, '/support_email'],
+ [{ ...DEFAULTS, unknown_key: 1 }, '/unknown_key'],
+ [{ ...DEFAULTS, site_name: '' }, '/site_name'],
+ [{ ...DEFAULTS, tags: ['a', 'a'] }, '/tags'],
+ [{ ...DEFAULTS, tags: ['a', 1] }, '/tags/1'],
+ [{ ...DEFAULTS, banner: { text: 'x', extra: true } }, '/banner/extra'],
+ [{ ...DEFAULTS, mode: 'closed', max_bitrate_kbps: 15000 }, '/max_bitrate_kbps'],
+ [{ ...DEFAULTS, 'bad key': 1 }, '/bad key'],
+ [{ ...DEFAULTS, when: new Date() }, '/when'],
+ ];
+ for (const [values, path] of cases) {
+ const ok = (e) => e instanceof ConfigError && e.code === 'config.invalid' && e.status === 422 && e.errors.some((x) => x.path === path);
+ await assert.rejects(async () => await live.propose(values, { actor: USER }), ok, `${path} is refused`);
+ await assert.rejects(live.apply(values, { actor: USER }), ok);
+ }
+ await assert.rejects(async () => await live.propose('nope'), (e) => e.code === 'config.invalid');
+ const required = await createConfigStore({ db, service: 'live', namespace: 'live.required', schema: { type: 'object', required: ['a'] }, log });
+ assert.strictEqual(required.revision(), null, 'no defaults and no legacy: nothing active');
+ assert.deepStrictEqual(required.get(), {});
+ await assert.rejects(async () => await required.propose({ b: 1 }), (e) => e.errors[0].path === '/a' && e.errors[0].message === 'is required');
+ assert.strictEqual((await live.history({ limit: 200 })).length, before, 'a refused proposal is not stored');
+ assert.strictEqual(live.revision(), 1);
+ // Messages name the rule, never the value; a validate() that echoes a secret is scrubbed.
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: 12345678 }), (e) => !JSON.stringify(e.errors).includes('12345678') && !e.message.includes('12345678'));
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: `${SECRET} !` }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));
+ await assert.rejects(async () => await live.propose(DEFAULTS, { actor: { type: 'user', id: '42' } }), (e) => e.code === 'config.bad_actor');
+
+ // ── Propose, then activate ──
+ const v2 = { ...DEFAULTS, max_bitrate_kbps: 8000, youtube_api_key: SECRET };
+ const p = assertSnapshot(await live.propose(v2, { actor: USER.id, reason: 'raise the cap' }));
+ assert.strictEqual(p.state, 'proposed');
+ assert.strictEqual(p.revision, 2);
+ assert.strictEqual(p.previous_revision, 1);
+ assert.strictEqual(p.activated_at, null);
+ assert.deepStrictEqual(p.created_by, USER, 'a usr_ id string is a user subject');
+ assert.deepStrictEqual(p.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });
+ assert.strictEqual(p.classification.youtube_api_key, 'secret');
+ assert.strictEqual(p.classification.site_name, 'public');
+ assert.strictEqual(p.classification.max_bitrate_kbps, 'internal', 'an unclassified key is internal');
+ assert.notStrictEqual(p.checksum, sha(v2), 'an unkeyed checksum of the full values is never served');
+ assert.ok(!JSON.stringify(p).includes(sha(SECRET)), 'nor an unkeyed hash of the secret');
+ assert.strictEqual(live.get('max_bitrate_kbps'), 6000, 'a proposal changes nothing');
+ const a = assertSnapshot(await live.activate(2, { actor: USER }));
+ assert.strictEqual(a.state, 'active');
+ assert.deepStrictEqual(a.activated_by, USER);
+ assert.strictEqual(live.revision(), 2);
+ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the service itself gets the real value');
+ assert.strictEqual(activations.length, 1);
+ assert.strictEqual(activations[0].values.max_bitrate_kbps, 8000);
+ assert.strictEqual(activations[0].previous.max_bitrate_kbps, 6000);
+ assert.deepStrictEqual(activations[0].info, { namespace: 'live.site_settings', revision: 2, previous_revision: 1, restoring: false });
+ assert.strictEqual((await live.snapshot(1)).state, 'superseded');
+ assert.strictEqual((await live.lastKnownGood()).revision, 2);
+ assert.ok((await rowOf(db, 'live.site_settings', 2)).values_json.includes(SECRET), 'the row keeps the secret so it can be activated again');
+ await assert.rejects(live.activate(2), (e) => e.code === 'config.not_proposed' && e.status === 409);
+ await assert.rejects(live.activate(99), (e) => e.code === 'config.revision_not_found' && e.status === 404);
+ // A proposal made on an older active revision is stale.
+ const stale = await live.propose({ mode: 'invite' }, { merge: true, actor: USER });
+ await live.apply({ mode: 'closed' }, { merge: true, actor: USER, reason: 'maintenance' });
+ await assert.rejects(live.activate(stale.revision, { actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);
+ assert.strictEqual((await live.snapshot(stale.revision)).state, 'proposed', 'a stale proposal is left as it was');
+ const goodRev = live.revision();
+ const goodValues = live.get();
+ assert.deepStrictEqual({ mode: goodValues.mode, max: goodValues.max_bitrate_kbps, key: goodValues.youtube_api_key }, { mode: 'closed', max: 8000, key: SECRET }, 'merge keeps the other keys');
+
+ // ── A throwing onActivate restores the last-known-good, in memory and in the database ──
+ activations = [];
+ failNext = new Error(`bucket refused key ${SECRET}`);
+ let thrown;
+ await assert.rejects(live.apply({ max_bitrate_kbps: 9000, youtube_api_key: `${SECRET}x` }, { merge: true, actor: USER }), (e) => { thrown = e; return true; });
+ assert.ok(thrown instanceof ConfigError);
+ assert.strictEqual(thrown.code, 'config.activation_failed');
+ assert.strictEqual(thrown.status, 422);
+ assert.ok(thrown.message.includes('bucket refused key [redacted]') && !thrown.message.includes(SECRET), thrown.message);
+ assert.ok(thrown.cause.message.includes('bucket refused'), 'the original error is the cause');
+ const failedRev = thrown.revision;
+ assert.strictEqual(failedRev, goodRev + 1);
+ assert.strictEqual(live.revision(), goodRev, 'memory: the previous revision is active again');
+ assert.strictEqual(live.get(), goodValues, 'memory: the same values object as before');
+ const failedRow = await rowOf(db, 'live.site_settings', failedRev);
+ assert.strictEqual(failedRow.state, 'rejected', 'database: the new revision is rejected');
+ assert.strictEqual(failedRow.activated_at, null);
+ assert.ok(failedRow.error.startsWith('onActivate: bucket refused key [redacted]') && !failedRow.error.includes(SECRET));
+ assert.strictEqual((await rowOf(db, 'live.site_settings', goodRev)).state, 'active', 'database: the previous revision is active again');
+ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'").get()).n, 1);
+ assert.strictEqual(activations.length, 2, 'onActivate ran for the new values, then again to restore');
+ assert.strictEqual(activations[1].info.restoring, true);
+ assert.strictEqual(activations[1].values, goodValues);
+ assert.strictEqual(activations[1].previous.max_bitrate_kbps, 9000);
+ assert.strictEqual((await live.lastKnownGood()).revision, goodRev);
+ assertSnapshot(await live.snapshot(failedRev));
+ const restarted = await createConfigStore({ ...liveOpts, db });
+ assert.strictEqual(restarted.revision(), goodRev, 'a restart after the failure serves the last-known-good');
+
+ // Validation that fails at activation time (the rules changed since the proposal) rejects it too.
+ const p2 = await live.propose({ max_bitrate_kbps: 7000 }, { merge: true, actor: USER });
+ tighten = true;
+ await assert.rejects(live.activate(p2.revision, { actor: USER }), (e) => e.code === 'config.invalid' && e.revision === p2.revision);
+ tighten = false;
+ assert.strictEqual((await live.snapshot(p2.revision)).state, 'rejected');
+ assert.match((await live.snapshot(p2.revision)).error, /below the new floor/);
+ assert.strictEqual(live.revision(), goodRev);
+
+ // ── Rollback: a new revision copying an older good one; history is never rewritten ──
+ const goodSnap = await live.snapshot(goodRev);
+ const lowered = await live.apply({ max_bitrate_kbps: 5000 }, { merge: true, actor: USER, reason: 'lower' });
+ const r = assertSnapshot(await live.rollback({ actor: USER, reason: 'too low' }));
+ assert.strictEqual(r.copied_from, goodRev);
+ assert.strictEqual(r.previous_revision, lowered.revision);
+ assert.strictEqual(r.reason, 'too low');
+ assert.strictEqual(r.checksum, goodSnap.checksum);
+ assert.strictEqual((await live.snapshot(lowered.revision)).state, 'rolled_back');
+ assert.strictEqual(live.get('max_bitrate_kbps'), 8000);
+ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'the secret comes back from the row');
+ assert.deepStrictEqual({ ...await live.snapshot(goodRev), state: null }, { ...goodSnap, state: null }, 'the copied revision is untouched (only its state moved)');
+ assert.strictEqual((await live.snapshot(goodRev)).state, 'superseded');
+ const again = await live.rollback({ actor: USER });
+ assert.strictEqual(again.copied_from, lowered.revision, 'rolling back a rollback undoes it');
+ assert.strictEqual(again.reason, `rollback to revision ${lowered.revision}`);
+ assert.strictEqual(live.get('max_bitrate_kbps'), 5000);
+ await live.rollback({ actor: USER, to: 1 });
+ assert.deepStrictEqual(live.get(), DEFAULTS);
+ await assert.rejects(live.rollback({ to: live.revision() }), (e) => e.code === 'config.already_active' && e.status === 409);
+ await assert.rejects(live.rollback({ to: failedRev }), (e) => e.code === 'config.not_good' && e.status === 409);
+ await assert.rejects(live.rollback({ to: 999 }), (e) => e.code === 'config.revision_not_found' && e.status === 404);
+ failNext = new Error('nope');
+ const beforeFailedRollback = live.revision();
+ await assert.rejects(live.rollback({ actor: USER, to: goodRev }), (e) => e.code === 'config.activation_failed');
+ assert.strictEqual(live.revision(), beforeFailedRollback);
+ assert.strictEqual((await live.snapshot(beforeFailedRollback)).state, 'active', 'a failed rollback puts the rolled_back revision back');
+ const fresh = await createConfigStore({ db, service: 'live', namespace: 'live.fresh', defaults: { a: 1 }, log });
+ await assert.rejects(fresh.rollback(), (e) => e.code === 'config.no_previous');
+ await assert.rejects(required.rollback(), (e) => e.code === 'config.nothing_active');
+
+ // ── Secrets: redacted everywhere they leave, kept by a redacted round trip ──
+ await live.rollback({ actor: USER, to: goodRev });
+ const summary = await live.summary();
+ assert.deepStrictEqual(summary.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });
+ assert.strictEqual(summary.values.youtube_api_key.fingerprint, crypto.createHmac('sha256', await keyOf(db, 'live.site_settings')).update(JSON.stringify(SECRET)).digest('hex'), 'HMAC-SHA256 of the canonical JSON under the stored key');
+ assertSnapshot(summary.active);
+ assertSnapshot(summary.last_known_good);
+ const roundTrip = await live.apply({ ...summary.values, max_bitrate_kbps: 7777 }, { actor: USER });
+ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'sending the marker back keeps the secret');
+ assert.strictEqual(roundTrip.values.youtube_api_key.fingerprint, summary.values.youtube_api_key.fingerprint, 'an unchanged value keeps its fingerprint');
+ const refusedMarker = (e) => e.code === 'config.invalid' && e.errors[0].path === '/youtube_api_key';
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: await fp(db, 'live.site_settings', 'other') } }), refusedMarker, 'a fingerprint of another value is refused');
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: sha(SECRET) } }), refusedMarker, 'an unkeyed hash of the right value is refused');
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, sha256: sha(SECRET) } }), refusedMarker, 'the old { redacted, sha256 } shape is refused');
+ await assert.rejects(async () => await live.propose({ ...DEFAULTS, banner: { redacted: false, text: 'x' } }), (e) => e.errors.some((x) => x.path === '/banner'), 'a redacted member is reserved for markers');
+ const history = await live.history({ limit: 200 });
+ const keyFps = new Set(history.filter((h) => h.state !== 'rejected').map((h) => h.values.youtube_api_key.fingerprint));
+ assert.ok(keyFps.has(await fp(db, 'live.site_settings', SECRET)) && keyFps.has(await fp(db, 'live.site_settings', '')), 'fingerprints differ exactly when the value does');
+ for (const s of await live.history({ limit: 200 })) assertSnapshot(s);
+ assert.ok(!JSON.stringify(await live.history({ limit: 200 })).includes(SECRET));
+ assert.ok(!JSON.stringify(await live.summary()).includes(SECRET));
+ // A key classified secret later is redacted in the revisions stored before.
+ const flags = await createConfigStore({ db, service: 'live', namespace: 'live.flags', defaults: {}, log });
+ await flags.apply({ webhook_url: 'https://hooks.example/T0K3N' }, { actor: USER });
+ assert.strictEqual((await flags.history())[0].values.webhook_url, 'https://hooks.example/T0K3N');
+ const flags2 = await createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
+ assert.ok((await flags2.history()).every((s) => !JSON.stringify(s).includes('T0K3N')));
+ assert.strictEqual((await flags2.history())[0].classification.webhook_url, 'secret');
+ // A lost key is replaced on the next start: fingerprints change and older markers stop round-tripping.
+ const oldMarker = (await flags2.summary()).values.webhook_url;
+ await db.prepare("DELETE FROM config_keys WHERE namespace = 'live.flags'").run();
+ const flags3 = await createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
+ assert.notDeepStrictEqual((await flags3.summary()).values.webhook_url, oldMarker);
+ await assert.rejects(async () => await flags3.propose({ webhook_url: oldMarker }), (e) => e.code === 'config.invalid');
+ assert.strictEqual((await flags3.propose({ webhook_url: (await flags3.summary()).values.webhook_url })).values.webhook_url.fingerprint, (await flags3.summary()).values.webhook_url.fingerprint);
+
+ // ── Two stores (and two services) in one database ──
+ const TIER_SCHEMA = { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 }, r2Enabled: { type: 'boolean' }, b2_key: { type: 'string' } } };
+ const media = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });
+ const liveRev = live.revision();
+ assert.strictEqual(media.revision(), 1, 'each namespace numbers its own revisions');
+ await media.apply({ minAgeDays: 3, b2_key: SECRET }, { merge: true, actor: USER });
+ assert.strictEqual(media.revision(), 2);
+ assert.strictEqual(live.revision(), liveRev, 'the other namespace is untouched');
+ assert.ok((await media.history()).every((s) => s.namespace === 'media.storage_tier' && s.service === 'media'));
+ assert.ok((await live.history({ limit: 200 })).every((s) => s.namespace === 'live.site_settings'));
+ // The same secret in two namespaces: different keys, different fingerprints, and one's marker is refused by the other.
+ assert.strictEqual(live.get('youtube_api_key'), media.get('b2_key'));
+ assert.notStrictEqual((await live.summary()).values.youtube_api_key.fingerprint, (await media.summary()).values.b2_key.fingerprint);
+ await assert.rejects(async () => await live.propose({ youtube_api_key: (await media.summary()).values.b2_key }, { merge: true }), (e) => e.code === 'config.invalid');
+ const keyRows = await db.prepare('SELECT namespace, hmac_key FROM config_keys').all();
+ assert.ok(keyRows.length >= 5 && keyRows.every((k) => Buffer.isBuffer(k.hmac_key) && k.hmac_key.length === 32), 'a random 32-byte key per namespace');
+ assert.strictEqual(new Set(keyRows.map((k) => k.hmac_key.toString('hex'))).size, keyRows.length, 'no two namespaces share a key');
+
+ // ── Restart: a new store on the same database serves the same active values ──
+ const live2 = await createConfigStore({ ...liveOpts, db });
+ assert.deepStrictEqual(live2.get(), live.get());
+ assert.strictEqual(live2.revision(), live.revision());
+ assert.strictEqual((await live2.lastKnownGood()).revision, (await live.lastKnownGood()).revision);
+ assert.deepStrictEqual(await live2.summary(), await live.summary(), 'the key survives a restart: same fingerprints, same checksums');
+ assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM config_keys WHERE namespace = 'live.site_settings'").get()).n, 1);
+ const media2 = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', schema: TIER_SCHEMA, classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true }, log });
+ assert.deepStrictEqual(media2.get(), { minAgeDays: 3, r2Enabled: true, b2_key: SECRET });
+ // A release that adds a default serves it at once, without a new revision.
+ const media3 = await createConfigStore({ db, service: 'media', namespace: 'media.storage_tier', classify: { b2_key: 'secret' }, defaults: { minAgeDays: 7, r2Enabled: true, r2MinViews: 20 }, log });
+ assert.strictEqual(media3.get('r2MinViews'), 20);
+ assert.strictEqual(media3.revision(), 2);
+ assert.deepStrictEqual(await media3.changes({ minAgeDays: 4 }, { merge: true }), ['minAgeDays']);
+ await media3.apply({}, { merge: true, unset: ['minAgeDays'], actor: USER });
+ assert.strictEqual(media3.get('minAgeDays'), 7, 'an unset key falls back to its default');
+ // Another store (another process) moved the namespace: this one's copy is stale until reload().
+ await assert.rejects(media.apply({ minAgeDays: 6 }, { merge: true, actor: USER }), (e) => e.code === 'config.stale' && e.status === 409);
+ assert.strictEqual(media.get('minAgeDays'), 3);
+ assert.strictEqual(await media.reload(), media3.revision());
+ assert.strictEqual(media.get('minAgeDays'), 7);
+
+ // An activation interrupted by a stop (onActivate never finished) is rejected at the next boot,
+ // and pruning while it hangs keeps the last-known-good even outside the window.
+ const hanging = await createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, keep: 2, log, onActivate: () => new Promise(() => {}) });
+ hanging.apply({ n: 1 }, { actor: USER }).catch(() => {});
+ await sleep(5);
+ assert.strictEqual((await rowOf(db, 'live.hang', 2)).state, 'active');
+ assert.strictEqual((await hanging.lastKnownGood()).revision, 1, 'not good until onActivate finished');
+ for (let i = 0; i < 3; i++) await hanging.propose({ n: 10 + i }, { actor: USER });
+ const hangRevs = (await db.prepare("SELECT revision FROM config_snapshots WHERE namespace = 'live.hang' ORDER BY revision").all()).map((x) => x.revision);
+ assert.deepStrictEqual(hangRevs, [1, 2, 4, 5], 'pruned to the newest two, plus the active and the last-known-good');
+ const rebooted = await createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, log });
+ assert.strictEqual(rebooted.revision(), 1);
+ assert.strictEqual(rebooted.get('n'), 0);
+ assert.strictEqual((await rowOf(db, 'live.hang', 2)).state, 'rejected');
+ assert.match((await rowOf(db, 'live.hang', 2)).error, /interrupted/);
+
+ // ── Concurrency: applies run one at a time and get increasing revisions ──
+ let running = 0; let overlapped = false;
+ const conc = await createConfigStore({ db, service: 'live', namespace: 'live.concurrency', defaults: { n: 0 }, log, onActivate: async () => { running++; if (running > 1) overlapped = true; await sleep(5); running--; } });
+ const [s1, s2, s3] = await Promise.all([conc.apply({ n: 1 }, { actor: USER }), conc.apply({ n: 2 }, { actor: USER }), conc.apply({ n: 3 }, { actor: USER })]);
+ assert.deepStrictEqual([s1.revision, s2.revision, s3.revision], [2, 3, 4]);
+ assert.deepStrictEqual([s1.previous_revision, s2.previous_revision, s3.previous_revision], [1, 2, 3]);
+ assert.ok(!overlapped, 'onActivate never overlaps');
+ assert.strictEqual(conc.get('n'), 3);
+ assert.deepStrictEqual((await conc.history()).map((s) => s.state), ['active', 'superseded', 'superseded', 'superseded']);
+ const b1 = await conc.apply({ n: 4 }, { actor: USER });
+ const b2 = await conc.apply({ n: 5 }, { actor: USER });
+ assert.strictEqual(b2.revision, b1.revision + 1);
+ const conc2 = await createConfigStore({ db, service: 'live', namespace: 'live.concurrency', log });
+ const [c1, c2] = await Promise.all([conc.apply({ n: 6 }, { actor: USER }), Promise.resolve().then(async () => await conc2.propose({ n: 7 }, { actor: USER }))]);
+ assert.ok(c2.revision !== c1.revision, 'two stores on one namespace never reuse a revision');
+
+ // ── Pruning: the newest N, and never the active one ──
+ const pr = await createConfigStore({ db, service: 'live', namespace: 'live.prune', defaults: { n: 0 }, keep: 3, log });
+ for (let i = 1; i <= 6; i++) await pr.apply({ n: i }, { actor: USER });
+ assert.deepStrictEqual((await pr.history()).map((s) => s.revision), [7, 6, 5]);
+ const pr2 = await createConfigStore({ db, service: 'live', namespace: 'live.prune2', defaults: { n: 0 }, keep: 2, log, onActivate: (v) => { if (v.n > 0) throw new Error('refused'); } });
+ for (let i = 1; i <= 5; i++) await assert.rejects(pr2.apply({ n: i }, { actor: USER }), (e) => e.code === 'config.activation_failed');
+ assert.deepStrictEqual((await pr2.history()).map((s) => `${s.revision}:${s.state}`), ['6:rejected', '5:rejected', '1:active']);
+ assert.strictEqual((await pr2.lastKnownGood()).revision, 1);
+ assert.deepStrictEqual((await pr2.history({ limit: 1, before: 6 })).map((s) => s.revision), [5], 'paged with before');
+
+ // ── Legacy import ──
+ const rows = [
+ { key: 'site_name', value: 'Old Live', type: 'string' },
+ { key: 'max_bitrate_kbps', value: '4500', type: 'number' },
+ { key: 'registration_open', value: 'true', type: 'boolean' },
+ { key: 'banner', value: '{"text":"hi"}', type: 'json' },
+ { key: 'broken_json', value: '{', type: 'json' },
+ { key: 'resend_api_key', value: 're_abcdef123456', type: 'secret' },
+ { key: 'bad key', value: 'x' },
+ { key: '__proto__', value: 'x' },
+ ];
+ assert.deepStrictEqual(fromRows(rows), { site_name: 'Old Live', max_bitrate_kbps: 4500, registration_open: true, banner: { text: 'hi' }, broken_json: '{', resend_api_key: 're_abcdef123456', 'bad key': 'x' });
+ assert.deepStrictEqual(fromRows([{ key: 'storage_tier.minAgeDays', value: '3' }, { key: 'storage_tier.r2Enabled', value: 'false' }, { key: 'other', value: '1' }], { prefix: 'storage_tier.', type: 'json' }), { minAgeDays: 3, r2Enabled: false });
+ const legacyDb = createDb({ pglite: true, log: { log() {}, warn() {} } });
+ await legacyDb.query(configSchema());
+ let legacyReads = 0;
+ const importOpts = { db: legacyDb, service: 'network', namespace: 'network.site_settings', classify: (k) => (/api_key/.test(k) ? 'secret' : undefined), defaults: { site_name: 'OpenVibe' }, legacy: () => { legacyReads++; return rows; }, log };
+ const imp = await createConfigStore(importOpts);
+ assert.strictEqual(imp.revision(), 1);
+ assert.strictEqual((await imp.snapshot(1)).reason, 'imported from the legacy source');
+ assert.strictEqual(imp.get('site_name'), 'Old Live', 'legacy wins over defaults for revision 1');
+ assert.strictEqual(imp.get('max_bitrate_kbps'), 4500);
+ assert.ok(!('bad key' in imp.get()), 'a key the contract cannot carry is left behind');
+ assert.ok(logs.some((l) => l.includes('network.site_settings') && l.includes('not imported') && l.includes('"bad key"')));
+ assert.deepStrictEqual((await imp.snapshot(1)).values.resend_api_key, { redacted: true, fingerprint: await fp(legacyDb, 'network.site_settings', 're_abcdef123456') });
+ assertSnapshot(await imp.snapshot(1));
+ await createConfigStore(importOpts);
+ assert.strictEqual(legacyReads, 1, 'the legacy source is read once, when the namespace is new');
+ assert.strictEqual(await imp.import(rows), null, 'import is a no-op once the namespace has revisions');
+ const imp2 = await createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.other', log });
+ assert.strictEqual(imp2.revision(), null);
+ const imported = await imp2.import({ a: 1 }, { reason: 'from site_settings' });
+ assert.strictEqual(imported.revision, 1);
+ assert.strictEqual(imported.reason, 'from site_settings');
+ assert.strictEqual(imp2.get('a'), 1);
+ const strictImport = await createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.strict', schema: { type: 'object', properties: { n: { type: 'integer' } } }, legacy: { n: 'not a number' }, log });
+ assert.strictEqual(strictImport.get('n'), 'not a number', 'what runs today is imported even when it does not validate');
+ assert.ok(logs.some((l) => l.startsWith('warn') && l.includes('network.strict') && l.includes('do not validate')));
+
+ // ── Secrets never reach the log ──
+ assert.ok(logs.length > 10);
+ assert.ok(!logs.join('\n').includes(SECRET), 'no secret in the log');
+ assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the log');
+ assert.ok(!logs.join('\n').includes('re_abcdef123456'));
+ assert.ok(logs.some((l) => /live\.site_settings: revision \d+ active \(was \d+\) by user:usr_/.test(l)));
+
+ // ── /api/admin/config on Express ──
+ const USER2 = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+ const app = express();
+ app.use(express.json());
+ app.use((req, _res, next) => {
+ const who = req.headers['x-test-user'];
+ if (who === 'admin') req.user = { id: 1, role: 'admin', subject_id: USER.id };
+ else if (who === 'owner') req.user = { id: 2, role: 'admin', subject_id: USER2, owner: true };
+ else if (who === 'nosubject') req.user = { id: 3, role: 'admin' };
+ else if (who === 'viewer') req.user = { id: 4, role: 'user', subject_id: USER.id };
+ next();
+ });
+ let handlerRan = 0;
+ const requireAdmin = (req, res, next) => (req.user && req.user.role === 'admin' ? next() : res.status(403).json({ error: 'Admin only' }));
+ const authorize = (req, { action, keys }) => { handlerRan++; return action === 'read' || !!(req.user && req.user.owner) || !keys.some((k) => classify(k) === 'secret'); };
+ assert.throws(() => adminRoutes([live]), /requireAdmin/);
+ assert.throws(() => adminRoutes([live, live2], { requireAdmin }), /twice/);
+ adminRoutes([live, media], { requireAdmin, authorize, router: app });
+ const server = app.listen(0);
+ await new Promise((r) => server.once('listening', r));
+ const base = `http://127.0.0.1:${server.address().port}`;
+ const texts = [];
+ async function call(method, path, { user = 'admin', body } = {}) {
+ const res = await fetch(base + path, { method, headers: { 'x-test-user': user, ...(body !== undefined ? { 'content-type': 'application/json' } : {}) }, body: body !== undefined ? JSON.stringify(body) : undefined });
+ const text = await res.text();
+ texts.push(text);
+ return { status: res.status, type: res.headers.get('content-type') || '', cache: res.headers.get('cache-control'), body: text ? JSON.parse(text) : null };
+ }
+
+ let res = await call('GET', '/api/admin/config', { user: 'viewer' });
+ assert.strictEqual(res.status, 403);
+ assert.deepStrictEqual(res.body, { error: 'Admin only' }, "requireAdmin's own answer");
+ assert.strictEqual(handlerRan, 0, 'the handler never ran');
+ res = await call('GET', '/api/admin/config');
+ assert.strictEqual(res.status, 200);
+ assert.match(res.type, /^application\/json/);
+ assert.strictEqual(res.cache, 'no-store');
+ assert.deepStrictEqual(res.body.namespaces.map((n) => n.namespace), ['live.site_settings', 'media.storage_tier']);
+ const liveEntry = res.body.namespaces[0];
+ assert.strictEqual(liveEntry.revision, live.revision());
+ assert.deepStrictEqual(liveEntry.values.youtube_api_key, { redacted: true, fingerprint: await fp(db, 'live.site_settings', SECRET) });
+ assert.strictEqual(liveEntry.last_known_good.revision, (await live.lastKnownGood()).revision);
+ assertSnapshot(liveEntry.active);
+ res = await call('GET', '/api/admin/config/media.storage_tier');
+ assert.strictEqual(res.status, 200);
+ assert.deepStrictEqual(res.body.values.b2_key, { redacted: true, fingerprint: await fp(db, 'media.storage_tier', SECRET) });
+ // A form round trip: the marker it was shown keeps the secret, even for an admin who may not change it.
+ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: liveEntry.values.youtube_api_key }, merge: true, reason: 'form round trip' } });
+ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
+ assert.strictEqual(live.get('youtube_api_key'), SECRET);
+ assert.deepStrictEqual(res.body.values.youtube_api_key, liveEntry.values.youtube_api_key);
+ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: { redacted: true, fingerprint: '0'.repeat(64) } }, merge: true } });
+ assertProblem(res.body, 422, 'config.invalid');
+ assert.strictEqual(res.body.errors[0].path, '/youtube_api_key');
+ res = await call('GET', '/api/admin/config/nope.settings');
+ assert.strictEqual(res.status, 404);
+ assert.strictEqual(res.type, 'application/problem+json');
+ assertProblem(res.body, 404, 'config.namespace_not_found');
+
+ res = await call('GET', '/api/admin/config/live.site_settings/history?limit=2');
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.snapshots.length, 2);
+ res.body.snapshots.forEach(assertSnapshot);
+ assert.strictEqual(res.body.next_before, res.body.snapshots[1].revision);
+ const older = await call('GET', `/api/admin/config/live.site_settings/history?limit=200&before=${res.body.next_before}`);
+ assert.ok(older.body.snapshots.every((s) => s.revision < res.body.next_before));
+ assert.strictEqual(older.body.next_before, null);
+
+ const liveBefore = live.revision();
+ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { mode: 'invite' }, merge: true, reason: 'soft launch' } });
+ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
+ assertSnapshot(res.body);
+ assert.strictEqual(res.body.revision, liveBefore + 1);
+ assert.strictEqual(res.body.state, 'active');
+ assert.deepStrictEqual(res.body.created_by, USER, 'the actor comes from req.user.subject_id');
+ assert.strictEqual(res.body.reason, 'soft launch');
+ assert.strictEqual(live.get('mode'), 'invite');
+ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { max_bitrate_kbps: 1 }, merge: true } });
+ assert.strictEqual(res.status, 422);
+ assert.strictEqual(res.type, 'application/problem+json');
+ assertProblem(res.body, 422, 'config.invalid');
+ assert.deepStrictEqual(res.body.errors, [{ path: '/max_bitrate_kbps', message: 'must be >= 500' }]);
+ res = await call('POST', '/api/admin/config/live.site_settings', { body: { reason: 'no values' } });
+ assertProblem(res.body, 400, 'config.bad_request');
+ res = await call('POST', '/api/admin/config/live.site_settings', { body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
+ assertProblem(res.body, 403, 'config.forbidden');
+ assert.strictEqual(live.get('youtube_api_key'), SECRET, 'authorize() refused a secret change');
+ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: 'AIzaNEW' }, merge: true } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.body.created_by.id, USER2);
+ assert.strictEqual(live.get('youtube_api_key'), 'AIzaNEW');
+ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'nosubject', body: { values: { mode: 'open' }, merge: true } });
+ assertProblem(res.body, 403, 'config.actor_required');
+ failNext = new Error(`Media said no to ${SECRET}`);
+ res = await call('POST', '/api/admin/config/live.site_settings', { user: 'owner', body: { values: { youtube_api_key: SECRET, mode: 'open' }, merge: true } });
+ assertProblem(res.body, 422, 'config.activation_failed');
+ assert.strictEqual(live.get('mode'), 'invite', 'the failure restored the previous values');
+
+ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { body: { reason: 'undo the key' } });
+ assertProblem(res.body, 403, 'config.forbidden');
+ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { reason: 'undo the key' } });
+ assert.strictEqual(res.status, 200, JSON.stringify(res.body));
+ assertSnapshot(res.body);
+ assert.ok(res.body.copied_from);
+ assert.strictEqual(live.get('youtube_api_key'), SECRET);
+ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 'one' } });
+ assertProblem(res.body, 400, 'config.bad_request');
+ res = await call('POST', '/api/admin/config/live.site_settings/rollback', { user: 'owner', body: { to: 99999 } });
+ assertProblem(res.body, 404, 'config.revision_not_found');
+ const emptyStore = await createConfigStore({ db, service: 'media', namespace: 'media.empty', defaults: { a: 1 }, log });
+ const emptyApp = express();
+ emptyApp.use(express.json());
+ adminRoutes(emptyStore, { requireAdmin: (_req, _res, next) => next(), actor: () => USER }).mount(emptyApp);
+ const emptyServer = emptyApp.listen(0);
+ await new Promise((r) => emptyServer.once('listening', r));
+ const er = await fetch(`http://127.0.0.1:${emptyServer.address().port}/api/admin/config/media.empty/rollback`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' });
+ const erBody = await er.json();
+ assertProblem(erBody, 409, 'config.no_previous');
+ emptyServer.close();
+ assert.ok(texts.every((x) => !x.includes(SECRET)), 'no response ever carries the secret');
+ assert.ok(!logs.join('\n').includes(SECRET));
+ const forms = await leakForms(db);
+ texts.forEach((x, i) => assertNoLeak(x, forms, `route response ${i}`));
+ assertNoLeak(JSON.stringify([await live.summary(), await media.summary(), await live.history({ limit: 200 }), await media.history({ limit: 200 }), await live.lastKnownGood()]), forms, 'summary and history');
+ server.closeAllConnections();
+ server.close();
+
+ // ── Plain http, a basePath under an app-scoped router, and direct handlers ──
+ const plain = adminRoutes([media], {
+ requireAdmin: (req, res, next) => (req.headers.authorization === 'Bearer admin' ? next() : (res.statusCode = 401, res.end())),
+ actor: () => ({ type: 'service', id: 'live' }),
+ });
+ const plainServer = http.createServer((req, res) => plain.handle(req, res));
+ plainServer.listen(0);
+ await new Promise((r) => plainServer.once('listening', r));
+ const pbase = `http://127.0.0.1:${plainServer.address().port}`;
+ const auth = { authorization: 'Bearer admin' };
+ assert.strictEqual((await fetch(`${pbase}/api/admin/config`)).status, 401);
+ let pr0 = await fetch(`${pbase}/api/admin/config`, { headers: auth });
+ assert.strictEqual(pr0.status, 200);
+ assert.strictEqual((await pr0.json()).namespaces[0].namespace, 'media.storage_tier');
+ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: { ...auth, 'content-type': 'application/json' }, body: JSON.stringify({ values: { minAgeDays: 5 }, merge: true, reason: 'plain http' }) });
+ const plainSnap = await pr0.json();
+ assert.strictEqual(pr0.status, 200, JSON.stringify(plainSnap));
+ assert.deepStrictEqual(plainSnap.created_by, { type: 'service', id: 'live' });
+ assert.strictEqual(media.get('minAgeDays'), 5);
+ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier/history?limit=1`, { headers: auth });
+ assert.strictEqual((await pr0.json()).snapshots.length, 1);
+ pr0 = await fetch(`${pbase}/api/admin/config/media.storage_tier`, { method: 'POST', headers: auth, body: '{not json' });
+ assertProblem(await pr0.json(), 400, 'config.bad_request');
+ pr0 = await fetch(`${pbase}/elsewhere`, { headers: auth });
+ assertProblem(await pr0.json(), 404, 'config.route_not_found');
+ for (const path of ['/api/admin/config', '/api/admin/config/media.storage_tier', '/api/admin/config/media.storage_tier/history?limit=200']) {
+ assertNoLeak(await (await fetch(pbase + path, { headers: auth })).text(), await leakForms(db), `plain ${path}`);
+ }
+ plainServer.close();
+
+ const scoped = express();
+ scoped.use(express.json());
+ const sub = express.Router({ mergeParams: true });
+ adminRoutes([media], { requireAdmin: (_req, _res, next) => next(), basePath: '/config', actor: (req) => ({ type: 'service', id: req.params.app || 'live' }) }).mount(sub);
+ scoped.use('/api/v1/:app/admin', sub);
+ const scopedServer = scoped.listen(0);
+ await new Promise((r) => scopedServer.once('listening', r));
+ const sres = await fetch(`http://127.0.0.1:${scopedServer.address().port}/api/v1/live/admin/config/media.storage_tier`);
+ assert.strictEqual(sres.status, 200);
+ assert.strictEqual((await sres.json()).namespace, 'media.storage_tier');
+ scopedServer.close();
+
+ const direct = adminRoutes([media], { requireAdmin: (req, res, next) => (req.admin ? next() : (res.statusCode = 403, res.end('no'))) });
+ const fakeRes = () => ({ headers: {}, setHeader(k, v) { this.headers[k] = v; }, end(b) { this.body = b; this.done = true; } });
+ const denied = fakeRes();
+ await direct.list({ url: '/api/admin/config' }, denied);
+ assert.strictEqual(denied.statusCode, 403, 'a handler used directly is still guarded');
+ const allowedRes = fakeRes();
+ direct.list({ url: '/api/admin/config', admin: true }, allowedRes);
+ await sleep(5);
+ assert.strictEqual(allowedRes.statusCode, 200);
+ assert.strictEqual(direct.routes.length, 5);
+ assert.deepStrictEqual(direct.routes.map((x) => `${x.method.toUpperCase()} ${x.path}`), [
+ 'GET /api/admin/config', 'GET /api/admin/config/:namespace', 'GET /api/admin/config/:namespace/history',
+ 'POST /api/admin/config/:namespace', 'POST /api/admin/config/:namespace/rollback',
+ ]);
+
+ assertNoLeak(logs.join('\n'), (await leakForms(db)).concat(await leakForms(legacyDb)), 'the whole log');
+ assert.ok(!logs.join('\n').includes(SECRET));
+
+ console.log(`config: store, validation, last-known-good, rollback, secrets, legacy, restart, concurrency and routes ok${hasSnapshotContract ? ' (snapshots checked against common.config-snapshot@1)' : ''}`);
+})().catch((err) => { console.error(err); process.exit(1); });