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.

OP OpenVibers committed 28 Sept 2026 · 116e07abd9 · 9 files, +1152 −14


Changes

ddiff --git a/.gitleaks.toml b/.gitleaks.toml
iindex 691866d..5df3bdf 100644
---- a/.gitleaks.toml
++++ b/.gitleaks.toml
@@@ -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)$''']
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dbd802b..cd237a4 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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.
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`.
ddiff --git a/README.md b/README.md
iindex 1c6abba..c51d434 100644
---- a/README.md
++++ b/README.md
@@@ -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
`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
const config = require('openvibe-shared/config');
const settings = config.createConfigStore({
ddiff --git a/STATUS.json b/STATUS.json
iindex a59ce38..9a4f610 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/config-pg.js b/config-pg.js
nnew file mode 100644
iindex 0000000..2add13d
---- /dev/null
++++ b/config-pg.js
@@@ -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 };
ddiff --git a/config.js b/config.js
iindex 2582130..7994256 100644
---- a/config.js
++++ b/config.js
@@@ -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 };
ddiff --git a/package-lock.json b/package-lock.json
iindex b5d1ba2..a03e1a4 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 8c0e66c..38fb791 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/test/config-pg.test.js b/test/config-pg.test.js
nnew file mode 100644
iindex 0000000..035fd8f
---- /dev/null
++++ b/test/config-pg.test.js
@@@ -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); });