openvibers/openvibe.shared Commits

openvibe-shared/config, the configuration model (WS-C task 7): createConfigStore keeps one namespace of a service's configuration as immutable revisions (common.config-snapshot@1) in the service's own better-sqlite3 database: get() from memory, JSON Schema subset and validate(), public/internal/secret classification, atomic activation with the last-known-good restored in memory and in the database when onActivate throws, rollback as a new revision, pruning that keeps the last-known-good, redacted history (a secret is an HMAC-SHA256 fingerprint under a random 32-byte key per namespace in config_keys, never served or logged; the checksum covers the values as shown), legacy import (fromRows) as revision 1; adminRoutes serves /api/admin/config (list, history, apply, rollback) with problem+json, on Express or plain http without depending on either; no new dependency (unreleased)

OP OpenVibers committed 26 Sept 2026 · b473e0ddff · 6 files, +1584 −2


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 27ab050..3b9b330 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).
++## Unreleased
++
++**`openvibe-shared/config`, the configuration model** (roadmap WS-C task 7): `createConfigStore({ db, service, namespace, schema, validate, classify, defaults, legacy, onActivate })` keeps one namespace of a service's configuration as immutable revisions (`common.config-snapshot@1`) in the service's own better-sqlite3 database (`config_snapshots`; several namespaces per database). `get()` serves the active values from memory; `propose`/`activate`/`apply` validate (a built-in JSON Schema subset and/or `validate()`), classify every key (public, internal by default, secret) and activate in one transaction, and a throwing `onActivate` puts the last-known-good back in memory and in the database, rejects the new revision with the error and throws it. `rollback()` activates a copy of the previous good revision (or `to`) as a new revision; `history()`, `lastKnownGood()` and `summary()` are redacted: a secret is `{ redacted: true, fingerprint }`, an HMAC-SHA256 under a random 32-byte key made per namespace on first use (`config_keys`; never served, never logged), a snapshot's checksum covers the values as shown, and secret values never leave or reach the log. A marker sent back keeps the secret it stands for only when its fingerprint matches. Revision 1 comes from `defaults` or a legacy source (`legacy`, `import()`, `fromRows()` for typed key-value rows). `adminRoutes(stores, { requireAdmin })` serves `GET /api/admin/config`, `GET /api/admin/config/:namespace/history`, `POST /api/admin/config/:namespace` and `POST /api/admin/config/:namespace/rollback` with problem+json errors, on Express or plain http, without depending on either. No new dependency. Additive.
++
## 1.13.0 — 2026-09-25
Accessibility (roadmap WS-T task 3), from an axe survey of every site. The navbar's **Sign In** button read at 3.67:1 (white on #3b82f6) on nine sites: every theme now derives **`--accent-strong` / `--on-accent-strong`**, the accent deepened (under white) or lightened (under dark ink) just enough for 4.5:1, and the button uses them (`test/login-contrast.test.js` checks all 35 themes). The two tokens are in `theme-sync` and the loader's map. The **footer** reads `{ label, href }` items as `{ name, url }`, so a site passing the navbar's shape no longer renders empty links (Media and Games did). Additive.
ddiff --git a/README.md b/README.md
iindex dea08dd..2fb35db 100644
---- a/README.md
++++ b/README.md
@@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
|---|---|
| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
--| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`; `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
++| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
@@@ -76,6 +76,82 @@ A check fails when it throws, times out, returns `false`, a string (the reason)
`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
without it.
++### Configuration (server, WS-C task 7)
++
++`openvibe-shared/config` keeps one namespace of a service's configuration as immutable revisions
++(`common.config-snapshot@1`) in the service's own SQLite database. No new dependency: pass the
++better-sqlite3 handle. Several namespaces share one database (tables `config_snapshots` and
++`config_keys`).
++
++```js
++const config = require('openvibe-shared/config');
++const settings = config.createConfigStore({
++ db, service: 'live', namespace: 'live.site_settings',
++ schema, // JSON Schema subset: type, enum, const, required, properties,
++ // additionalProperties, min/max*, pattern, items, uniqueItems, multipleOf
++ validate: (values) => true, // or false, a message, a list, { valid, errors }; synchronous
++ classify: (key) => (/api_key|secret|token/.test(key) ? 'secret' : undefined), // or { key: class }; unknown = internal
++ defaults, // revision 1 of a new namespace, and under every revision
++ legacy: () => config.fromRows(db.prepare('SELECT * FROM site_settings').all()), // revision 1 instead
++ onActivate: async (values, previous, { restoring }) => { /* apply it; throw to refuse */ },
++ keep: 50, log: console, now: () => new Date(),
++});
++settings.get(); // defaults overlaid with the active values: frozen, in memory
++settings.get('max_bitrate_kbps'); // one value; never a database read
++settings.revision(); // the active revision, or null
++settings.propose(values, { actor, reason, merge, unset }); // validated, stored as proposed (redacted snapshot)
++await settings.activate(revision, { actor });
++await settings.apply(values, { actor, reason, merge, unset }); // propose + activate
++await settings.rollback({ actor, reason, to }); // a new revision copying the previous good one (or `to`)
++settings.lastKnownGood(); settings.history({ limit, before }); settings.snapshot(revision); settings.summary();
++settings.import(legacy); // revision 1 from a legacy source when the namespace has none
++settings.reload(); // another process changed it: re-read the active revision
++```
++
++- **Values** are a complete snapshot; `merge: true` starts from the active values and `unset` removes
++ keys (a key with a default falls back to it). A redaction marker sent back (`{ redacted: true,
++ fingerprint }` as the routes show it) keeps the value it stands for, so a form can round-trip
++ secrets; a marker whose fingerprint is not the current value's is refused (422).
++- **Activation** is serialized per store. The switch (new revision active, old superseded) is one
++ transaction; then `onActivate(values, previous)` runs. If it throws, the previous revision is active
++ again in memory and in the database, the new one is `rejected` with the error (scrubbed of secrets),
++ `onActivate` runs once more with the restored values (`{ restoring: true }`), and a
++ `ConfigError` (`config.activation_failed`, 422) is thrown with the original as `cause`. A proposal
++ whose base is no longer active is `config.stale` (409). A process that stopped mid-activation boots
++ on the last-known-good.
++- **Last-known-good** is the newest revision that activated successfully. Pruning keeps the newest
++ `keep` revisions and never the active one or the last-known-good.
++- **Secrets** stay in the row so they can be activated again, but leave only redacted (history,
++ summary, routes, the return values) and never reach the log or a stored error. A redacted value is
++ `{ redacted: true, fingerprint }`: HMAC-SHA256 of its canonical JSON under a random 32-byte key made
++ for the namespace on first use and kept in `config_keys`. The key never leaves the database and is
++ never logged, so equal fingerprints mean an unchanged value while nothing can be guessed offline. A
++ snapshot's `checksum` is the sha256 of its values as shown. Losing the key row only means a new key:
++ older markers stop round-tripping. A key classified secret later is redacted in older revisions too.
++ Errors name the rule, never the value.
++- `config.fromRows(rows, { prefix, type })` reads typed key-value rows (`number`, `boolean`, `json`,
++ anything else a string), as Live's and Network's `site_settings` and Media's `media_settings` keep them.
++
++```js
++config.adminRoutes([settings, other], {
++ requireAdmin, // a middleware or a list; guards every handler
++ actor: (req) => ({ type: 'user', id: req.user.subject_id }), // default: req.actor, req.subject, req.user.subject(_id)
++ authorize: (req, { action, namespace, keys }) => action === 'read' || isOwner(req.user) || !keys.some(isSecret),
++ basePath: '/api/admin/config',
++}).mount(app); // or { router }, the handlers, or handle(req, res, next) on plain http
++```
++
++| Route | Answer |
++|---|---|
++| `GET /api/admin/config` | `{ namespaces: [{ service, namespace, revision, values, classification, active, last_known_good }] }`, values redacted |
++| `GET /api/admin/config/:namespace` | one of them |
++| `GET /api/admin/config/:namespace/history?limit&before` | `{ namespace, snapshots, next_before }`, newest first |
++| `POST /api/admin/config/:namespace` `{ values, reason, merge?, unset? }` | the new active snapshot; 422 `config.invalid` with `errors[]`, 422 `config.activation_failed` |
++| `POST /api/admin/config/:namespace/rollback` `{ to?, reason }` | the new active snapshot; 409 `config.no_previous`, `config.not_good` |
++
++Errors are `application/problem+json` (`errors.problem@1`). Mount it after a JSON body parser, or
++let it read the body itself.
++
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
```js
ddiff --git a/config.js b/config.js
nnew file mode 100644
iindex 0000000..2582130
---- /dev/null
++++ b/config.js
@@@ -0,0 +1,866 @@
++'use strict';
++// ═══════════════════════════════════════════════════════════════
++// openvibe-shared/config — the configuration model (roadmap WS-C task 7). One namespace of a
++// service's configuration kept as immutable revisions (common.config-snapshot@1) in the service's
++// own SQLite database: validated, classified, activated atomically, with the last-known-good
++// revision retained and restored when an activation fails.
++//
++// const config = require('openvibe-shared/config');
++// const tiering = config.createConfigStore({
++// db, // the service's better-sqlite3 handle
++// service: 'media', namespace: 'media.storage_tier',
++// schema: { type: 'object', additionalProperties: false, properties: { minAgeDays: { type: 'integer', minimum: 0 } } },
++// validate: (values) => values.minFreeGb < values.targetFreeGb || 'minFreeGb must be below targetFreeGb',
++// classify: { b2_secret: 'secret' }, // or (key, value) => 'public' | 'internal' | 'secret'
++// defaults: DEFAULTS, // revision 1 of a new namespace; also under every revision
++// legacy: () => config.fromRows(rows, { prefix: 'storage_tier.', type: 'json' }), // revision 1 instead
++// onActivate: async (values, previous) => { restartSweep(values); },
++// });
++// tiering.get('minAgeDays'); // in memory, never a DB read
++// await tiering.apply({ minAgeDays: 3 }, { merge: true, actor: { type: 'user', id: 'usr_…' }, reason: '…' });
++// await tiering.rollback({ actor, reason });
++// config.adminRoutes([tiering], { requireAdmin }).mount(app); // /api/admin/config…
++//
++// A revision's values never change. Its state moves proposed -> active -> superseded | rolled_back,
++// or proposed -> rejected. activate() switches the active revision in one transaction, then runs
++// onActivate(values, previous); when that throws, the previous revision is active again (in memory
++// and in the database), the new one is rejected with the error, onActivate runs once more with the
++// restored values ({ restoring: true }), and the error is thrown. get() is the defaults overlaid
++// with the active revision's values. A secret-class value stays in the row (so it can be activated
++// again) but leaves only as { redacted: true, fingerprint }: an HMAC-SHA256 under a random 32-byte
++// key kept per namespace in config_keys, which never leaves the database and is never logged. Equal
++// fingerprints in one namespace mean an unchanged value; without the key nothing can be guessed. A
++// snapshot's checksum is the sha256 of its values as shown, so it reveals nothing more either.
++// ═══════════════════════════════════════════════════════════════
++
++const crypto = require('crypto');
++
++const CLASSES = ['public', 'internal', 'secret'];
++const SERVICE_RE = /^[a-z][a-z0-9-]{1,39}$/;
++const NAMESPACE_RE = /^[a-z][a-z0-9-]{1,39}(\.[a-z0-9_-]+)+$/;
++const KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.:-]{0,127}$/;
++const RESERVED = new Set(['__proto__', 'constructor', 'prototype']);
++const validKey = (k) => KEY_RE.test(k) && !RESERVED.has(k);
++const SUBJECT_ID = {
++ user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/,
++ app: /^app_[0-9A-HJKMNP-TV-Z]{26}$/, mod: /^mod_[0-9A-HJKMNP-TV-Z]{26}$/,
++ service: SERVICE_RE, system: SERVICE_RE,
++};
++const PREFIXED = { usr_: 'user', gst_: 'guest', app_: 'app', mod_: 'mod' };
++const REASON_MAX = 500;
++const ERROR_MAX = 2000;
++
++class ConfigError extends Error {
++ constructor(code, message, { status = 400, errors = null, revision = null, cause } = {}) {
++ super(message, cause === undefined ? undefined : { cause });
++ this.name = 'ConfigError';
++ this.code = code;
++ this.status = status;
++ if (errors) this.errors = errors;
++ if (revision != null) this.revision = revision;
++ }
++}
++
++// ── JSON helpers ────────────────────────────────────────────────
++
++/** Canonical JSON: object keys sorted at every depth, no whitespace. */
++function canonical(v) {
++ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
++ if (v && typeof v === 'object') return `{${Object.keys(v).sort().map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
++ return JSON.stringify(v);
++}
++const sha256 = (s) => crypto.createHash('sha256').update(s).digest('hex');
++const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v) && [Object.prototype, null].includes(Object.getPrototypeOf(v));
++const pointer = (k) => `/${String(k).replace(/~/g, '~0').replace(/\//g, '~1')}`;
++const clone = (v) => JSON.parse(JSON.stringify(v));
++const same = (a, b) => canonical(a) === canonical(b);
++const has = (o, k) => o != null && Object.prototype.hasOwnProperty.call(o, k);
++
++function deepFreeze(v) {
++ if (v && typeof v === 'object' && !Object.isFrozen(v)) { Object.freeze(v); for (const x of Object.values(v)) deepFreeze(x); }
++ return v;
++}
++
++/** Only what JSON keeps as it is: no undefined, functions, NaN/Infinity, Dates or class instances. */
++function jsonErrors(v, path, errors, depth = 0) {
++ if (depth > 32) { errors.push({ path, message: 'is nested too deeply' }); return; }
++ if (v === null || typeof v === 'string' || typeof v === 'boolean') return;
++ if (typeof v === 'number') { if (!Number.isFinite(v)) errors.push({ path, message: 'must be a finite number' }); return; }
++ if (Array.isArray(v)) { v.forEach((x, i) => jsonErrors(x, `${path}/${i}`, errors, depth + 1)); return; }
++ if (isPlainObject(v)) { for (const [k, x] of Object.entries(v)) jsonErrors(x, `${path}${pointer(k)}`, errors, depth + 1); return; }
++ errors.push({ path, message: 'is not a JSON value' });
++}
++
++const isMarker = (v) => isPlainObject(v) && v.redacted === true && Object.keys(v).length === 2 && typeof v.fingerprint === 'string';
++/** An object with a redacted member is reserved for markers (common.config-snapshot@1). */
++const looksRedacted = (v) => isPlainObject(v) && has(v, 'redacted');
++const sameHex = (a, b) => typeof a === 'string' && typeof b === 'string' && a.length === b.length && crypto.timingSafeEqual(Buffer.from(a), Buffer.from(b));
++
++// ── A small JSON Schema subset ──────────────────────────────────
++// type, enum, const, properties, required, additionalProperties, min/maxProperties, items,
++// min/maxItems, uniqueItems, min/maxLength, pattern, minimum, maximum, exclusiveMinimum/Maximum,
++// multipleOf. Annotations (title, description, default, format, x-…) are ignored. Anything else
++// throws when the store is created, so a schema is never silently checked less than it says.
++
++const KEYWORDS = new Set(['type', 'enum', 'const', 'properties', 'required', 'additionalProperties', 'minProperties', 'maxProperties',
++ 'items', 'minItems', 'maxItems', 'uniqueItems', 'minLength', 'maxLength', 'pattern', 'minimum', 'maximum',
++ 'exclusiveMinimum', 'exclusiveMaximum', 'multipleOf']);
++const ANNOTATIONS = new Set(['$schema', '$id', '$comment', 'title', 'description', 'default', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'format']);
++const TYPES = ['object', 'array', 'string', 'number', 'integer', 'boolean', 'null'];
++const patterns = new WeakMap();
++
++function compileSchema(s, at = '#') {
++ if (typeof s === 'boolean') return;
++ if (!isPlainObject(s)) throw new TypeError(`config schema ${at}: must be an object or a boolean`);
++ for (const k of Object.keys(s)) {
++ if (!KEYWORDS.has(k) && !ANNOTATIONS.has(k) && !k.startsWith('x-')) throw new TypeError(`config schema ${at}: unsupported keyword "${k}"`);
++ }
++ if (s.type !== undefined && ![].concat(s.type).every((t) => TYPES.includes(t))) throw new TypeError(`config schema ${at}: unknown type`);
++ if (s.pattern !== undefined) patterns.set(s, new RegExp(s.pattern, 'u'));
++ if (s.properties) for (const [k, sub] of Object.entries(s.properties)) compileSchema(sub, `${at}/properties/${k}`);
++ if (s.additionalProperties !== undefined) compileSchema(s.additionalProperties, `${at}/additionalProperties`);
++ if (s.items !== undefined) compileSchema(s.items, `${at}/items`);
++}
++
++function typeOf(v) {
++ if (v === null) return 'null';
++ if (Array.isArray(v)) return 'array';
++ return typeof v;
++}
++function isType(v, t) {
++ if (t === 'integer') return Number.isInteger(v);
++ if (t === 'number') return typeof v === 'number' && Number.isFinite(v);
++ return typeOf(v) === t;
++}
++
++/** Messages name the rule, never the value (a value may be a secret). */
++function checkSchema(s, v, path, errors) {
++ if (s === true || s === undefined) return;
++ if (s === false) { errors.push({ path, message: 'is not allowed' }); return; }
++ if (s.type !== undefined) {
++ const types = [].concat(s.type);
++ if (!types.some((t) => isType(v, t))) { errors.push({ path, message: `must be ${types.join(' or ')}` }); return; }
++ }
++ if (s.enum !== undefined && !s.enum.some((e) => same(e, v))) errors.push({ path, message: `must be one of ${s.enum.map((e) => JSON.stringify(e)).join(', ')}` });
++ if (s.const !== undefined && !same(s.const, v)) errors.push({ path, message: 'must equal the constant' });
++ if (typeof v === 'number') {
++ if (s.minimum !== undefined && v < s.minimum) errors.push({ path, message: `must be >= ${s.minimum}` });
++ if (s.maximum !== undefined && v > s.maximum) errors.push({ path, message: `must be <= ${s.maximum}` });
++ if (s.exclusiveMinimum !== undefined && v <= s.exclusiveMinimum) errors.push({ path, message: `must be > ${s.exclusiveMinimum}` });
++ if (s.exclusiveMaximum !== undefined && v >= s.exclusiveMaximum) errors.push({ path, message: `must be < ${s.exclusiveMaximum}` });
++ if (s.multipleOf !== undefined && Math.abs(v / s.multipleOf - Math.round(v / s.multipleOf)) > 1e-9) errors.push({ path, message: `must be a multiple of ${s.multipleOf}` });
++ }
++ if (typeof v === 'string') {
++ const len = [...v].length;
++ if (s.minLength !== undefined && len < s.minLength) errors.push({ path, message: `must be at least ${s.minLength} characters` });
++ if (s.maxLength !== undefined && len > s.maxLength) errors.push({ path, message: `must be at most ${s.maxLength} characters` });
++ if (s.pattern !== undefined && !(patterns.get(s) || new RegExp(s.pattern, 'u')).test(v)) errors.push({ path, message: `must match ${s.pattern}` });
++ }
++ if (Array.isArray(v)) {
++ if (s.minItems !== undefined && v.length < s.minItems) errors.push({ path, message: `must have at least ${s.minItems} items` });
++ if (s.maxItems !== undefined && v.length > s.maxItems) errors.push({ path, message: `must have at most ${s.maxItems} items` });
++ if (s.uniqueItems && new Set(v.map(canonical)).size !== v.length) errors.push({ path, message: 'must not repeat items' });
++ if (s.items !== undefined) v.forEach((x, i) => checkSchema(s.items, x, `${path}/${i}`, errors));
++ }
++ if (isPlainObject(v)) {
++ const keys = Object.keys(v);
++ for (const r of s.required || []) if (!has(v, r)) errors.push({ path: `${path}${pointer(r)}`, message: 'is required' });
++ if (s.minProperties !== undefined && keys.length < s.minProperties) errors.push({ path, message: `must have at least ${s.minProperties} keys` });
++ if (s.maxProperties !== undefined && keys.length > s.maxProperties) errors.push({ path, message: `must have at most ${s.maxProperties} keys` });
++ for (const k of keys) {
++ if (s.properties && has(s.properties, k)) checkSchema(s.properties[k], v[k], `${path}${pointer(k)}`, errors);
++ else if (s.additionalProperties === false) errors.push({ path: `${path}${pointer(k)}`, message: 'is not a known key' });
++ else if (s.additionalProperties !== undefined) checkSchema(s.additionalProperties, v[k], `${path}${pointer(k)}`, errors);
++ }
++ }
++}
++
++/** validate(values) may return true/undefined, false, a message, a list, or { valid, errors } (Ajv/contracts style). */
++function validateResult(r) {
++ const norm = (e) => (typeof e === 'string' ? { path: '', message: e } : { path: String(e.path ?? e.instancePath ?? ''), message: String((e && e.message) || 'is invalid') });
++ if (r === true || r == null) return [];
++ if (r === false) return [{ path: '', message: 'rejected by validate()' }];
++ if (typeof r === 'string') return [{ path: '', message: r }];
++ if (Array.isArray(r)) return r.map(norm);
++ if (typeof r === 'object') {
++ if (r.valid === false || (Array.isArray(r.errors) && r.errors.length)) return (r.errors && r.errors.length ? r.errors : ['is invalid']).map(norm);
++ return [];
++ }
++ return [{ path: '', message: 'rejected by validate()' }];
++}
++
++// ── Subjects and text ───────────────────────────────────────────
++
++/** A common subject reference ({ type, id }), or a prefixed id string (usr_…, gst_…, app_…, mod_…). */
++function subjectRef(actor) {
++ if (typeof actor === 'string') {
++ const type = PREFIXED[actor.slice(0, 4)];
++ if (type && SUBJECT_ID[type].test(actor)) return { type, id: actor };
++ return null;
++ }
++ if (actor && typeof actor === 'object' && SUBJECT_ID[actor.type] && typeof actor.id === 'string' && SUBJECT_ID[actor.type].test(actor.id)) {
++ return { type: actor.type, id: actor.id };
++ }
++ return null;
++}
++
++/** One line, no credentials in URLs or key=value pairs, and never any of the given secret values. */
++function scrub(text, secrets = []) {
++ let s = String(text == null ? '' : text).split('\n')[0];
++ for (const x of secrets) if (x.length >= 4) s = s.split(x).join('[redacted]');
++ s = s.replace(/(\b[a-z][a-z0-9+.-]*:\/\/)[^/@\s]*@/gi, '$1')
++ .replace(/\b(key|token|secret|password|signature)=[^\s&]+/gi, '$1=…');
++ return s.slice(0, ERROR_MAX) || 'failed';
++}
++
++const errText = (err) => (err instanceof Error ? err.message : String(err));
++const summarize = (errors) => errors.map((e) => (e.path ? `${e.path} ${e.message}` : e.message)).join('; ');
++
++function makeLog(log) {
++ const noop = () => {};
++ if (log === false || log === null) return { info: noop, warn: noop, error: noop };
++ if (typeof log === 'function') return { info: (m) => log(m, 'info'), warn: (m) => log(m, 'warn'), error: (m) => log(m, 'error') };
++ const l = log || console;
++ return { info: (m) => (l.info || l.log || noop).call(l, m), warn: (m) => (l.warn || l.log || noop).call(l, m), error: (m) => (l.error || l.warn || noop).call(l, m) };
++}
++
++// ── Storage ─────────────────────────────────────────────────────
++
++function ensureTables(db) {
++ db.exec(`
++ CREATE TABLE IF NOT EXISTS config_snapshots (
++ namespace TEXT NOT NULL,
++ revision INTEGER NOT NULL,
++ service TEXT NOT NULL,
++ previous_revision INTEGER,
++ state TEXT NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
++ values_json TEXT NOT NULL, -- the full values, secrets included (never served)
++ classification_json TEXT NOT NULL,
++ values_checksum TEXT NOT NULL, -- sha256 of the full values: internal, never served
++ created_at TEXT NOT NULL,
++ created_by TEXT NOT NULL,
++ activated_at TEXT,
++ activated_by TEXT,
++ reason TEXT,
++ error TEXT,
++ copied_from INTEGER,
++ good INTEGER 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 PRIMARY KEY,
++ hmac_key BLOB NOT NULL,
++ created_at TEXT NOT NULL
++ );
++ `);
++}
++
++/**
++ * Typed key-value rows ({ key, value, type }, as site_settings/media_settings keep them) as a values
++ * object: number, boolean ('true'), json (parsed; the raw text if it does not parse), anything else
++ * a string. `prefix` keeps only the keys that start with it and strips it; `type` forces one type.
++ */
++function fromRows(rows, { prefix = '', type = null } = {}) {
++ const out = {};
++ for (const r of rows || []) {
++ if (!r || typeof r.key !== 'string' || !r.key.startsWith(prefix) || RESERVED.has(r.key.slice(prefix.length))) continue;
++ const raw = r.value == null ? '' : String(r.value);
++ const t = type || r.type || 'string';
++ let v = raw;
++ if (t === 'number') v = raw.trim() === '' ? null : Number(raw);
++ else if (t === 'boolean') v = raw === 'true';
++ else if (t === 'json') { try { v = JSON.parse(raw); } catch { v = raw; } }
++ if (typeof v === 'number' && !Number.isFinite(v)) v = raw;
++ out[r.key.slice(prefix.length)] = v;
++ }
++ return out;
++}
++
++// ── The store ───────────────────────────────────────────────────
++
++function createConfigStore(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, …)');
++ 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}`;
++
++ ensureTables(db);
++ 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 = (fn) => db.transaction(fn).immediate();
++
++ // ── the namespace's fingerprint key: made on first use, then read (a racing process keeps the first) ──
++ const hmacKey = (() => {
++ const read = db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?');
++ let row = read.get(namespace);
++ if (!row) {
++ db.prepare('INSERT OR IGNORE INTO config_keys (namespace, hmac_key, created_at) VALUES (?, ?, ?)').run(namespace, crypto.randomBytes(32), iso());
++ row = read.get(namespace);
++ }
++ const k = row && Buffer.isBuffer(row.hmac_key) ? Buffer.from(row.hmac_key) : null;
++ if (!k || k.length < 32) throw new Error(`createConfigStore: the fingerprint key of ${namespace} in config_keys is not a 32-byte key`);
++ return 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);
++ }
++
++ function insertRow(values, { state, actor, reason, previous, copiedFrom = null, activatedAt = null, good = 0 }) {
++ let row;
++ tx(() => {
++ const revision = q.max.get(namespace).n + 1;
++ 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 = q.row.get(namespace, revision);
++ });
++ return row;
++ }
++
++ function prune() {
++ const cutoff = q.max.get(namespace).n - keep;
++ if (cutoff < 1) return;
++ const good = q.lastGood.get(namespace);
++ q.prune.run(namespace, cutoff, good ? good.revision : -1);
++ }
++
++ // ── boot: load the active revision, recover an interrupted activation, or seed revision 1 ──
++ 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 = 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);
++ }
++
++ function importLegacy(legacy, { reason = 'imported from the legacy source' } = {}) {
++ if (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 seed(v, reasonOf(reason) || 'imported from the legacy source', { strict: false });
++ }
++
++ (function boot() {
++ let row = 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 = q.lastGood.get(namespace);
++ tx(() => {
++ q.reject.run('activation interrupted: the service stopped before onActivate finished', namespace, row.revision);
++ if (good) 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 ? 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 (q.any.get(namespace)) return;
++ if (opts.legacy !== undefined && importLegacy(opts.legacy)) return;
++ if (opts.defaults != null) 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 = 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) {
++ 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();
++ tx(() => {
++ const dbActive = 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) q.setState.run(rollingBack ? 'rolled_back' : 'superseded', namespace, dbActive.revision);
++ q.activate.run(at, JSON.stringify(by), onActivate ? 0 : 1, namespace, revision);
++ });
++ current = load(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));
++ tx(() => {
++ q.reject.run(message, namespace, revision);
++ if (prev) 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 });
++ }
++ q.good.run(namespace, revision);
++ }
++ prune();
++ log.info(`${tag}: revision ${revision} active${prev ? ` (was ${prev.revision})` : ''} by ${who(by)}; changed ${listKeys(changed)}`);
++ return toSnapshot(q.row.get(namespace, revision));
++ }
++
++ 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 = insertRow(next, { state: 'proposed', actor, reason, previous: current ? current.revision : null });
++ prune();
++ log.info(`${tag}: revision ${row.revision} proposed by ${who(actor)}; changes ${listKeys(changedKeys(effectiveNow(), { ...defaults, ...next }))}`);
++ return toSnapshot(row);
++ }
++
++ 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 = 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) ? 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(() => activateNow(Number(revision), { actor: o.actor })); },
++ /** propose + activate, in turn with every other activation of this store. */
++ apply(values, o = {}) { return serialized(() => activateNow(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(() => {
++ const actor = actorOf(o.actor);
++ const target = rollbackTarget(o.to);
++ const values = JSON.parse(q.row.get(namespace, target).values_json);
++ const row = 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 activateNow(row.revision, { actor, rollingBack: true });
++ });
++ },
++ rollbackTarget,
++ lastKnownGood() { const r = q.lastGood.get(namespace); return r ? toSnapshot(q.row.get(namespace, r.revision)) : null; },
++ snapshot(revision) { const row = q.row.get(namespace, Number(revision)); return row ? toSnapshot(row) : null; },
++ 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 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. */
++ changes(values, o = {}) {
++ const next = o.revision != null ? JSON.parse((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. */
++ 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 ? store.snapshot(current.revision) : null,
++ last_known_good: 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. */
++ reload() {
++ const row = q.active.get(namespace);
++ current = row ? load(row) : null;
++ return store.revision();
++ },
++ };
++ 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' };
++
++/** RFC 9457 body (errors.problem@1), with the legacy { error } field like openvibe-contracts' http.problem(). */
++function problem(status, code, { detail, errors } = {}) {
++ const body = { type: `https://openvibe.network/problems/${code}`, title: TITLES[status] || 'Error', status, code };
++ if (detail) body.detail = detail;
++ if (errors && errors.length) body.errors = errors;
++ body.error = detail || body.title;
++ return body;
++}
++
++function send(res, status, body, type = 'application/json; charset=utf-8') {
++ res.statusCode = status;
++ res.setHeader('Content-Type', type);
++ res.setHeader('Cache-Control', 'no-store');
++ res.end(JSON.stringify(body));
++}
++const sendProblem = (res, status, code, o) => send(res, status, problem(status, code, o), 'application/problem+json');
++
++/** The acting subject: req.actor, req.subject, req.user.subject or req.user.subject_id (a usr_… id). */
++function defaultActor(req) {
++ for (const c of [req.actor, req.subject, req.user && req.user.subject, req.user && req.user.subject_id]) {
++ const ref = c ? subjectRef(c) : null;
++ if (ref) return ref;
++ }
++ return null;
++}
++
++function readBody(req, limit = 256 * 1024) {
++ if (req.body !== undefined) return Promise.resolve(req.body);
++ if (typeof req.on !== 'function') return Promise.resolve({});
++ return new Promise((resolve, reject) => {
++ let size = 0; const chunks = [];
++ req.on('data', (c) => { size += c.length; if (size > limit) { reject(new ConfigError('config.too_large', 'request body too large', { status: 413 })); req.destroy(); } else chunks.push(c); });
++ req.on('end', () => {
++ const text = Buffer.concat(chunks).toString('utf8');
++ if (!text.trim()) return resolve({});
++ try { resolve(JSON.parse(text)); } catch { reject(new ConfigError('config.bad_request', 'the body is not JSON', { status: 400 })); }
++ });
++ req.on('error', reject);
++ });
++}
++
++/**
++ * adminRoutes(stores, { requireAdmin, basePath, actor, authorize, router, log })
++ * GET <base> every namespace: active revision, values (redacted), last-known-good
++ * GET <base>/:namespace one of them
++ * GET <base>/:namespace/history ?limit (≤200) &before=<revision>, newest first
++ * POST <base>/:namespace { values, reason, merge?, unset? } → apply; 422 with errors
++ * POST <base>/:namespace/rollback { to?, reason } → the new revision
++ * requireAdmin (a middleware or a list) guards every handler, mounted or called directly. actor(req)
++ * names who acts (default: req.user.subject_id and friends; none → 403). authorize(req, { action:
++ * 'read' | 'write', namespace, keys }) may refuse more (false → 403). No Express dependency: pass
++ * `router` (anything with get/post) or call mount(router), use the handlers, or handle(req, res, next)
++ * for a plain http server. The body is req.body when a JSON parser ran, else read from the request.
++ */
++function adminRoutes(stores, o = {}) {
++ const list = [].concat(stores || []);
++ const byNs = new Map();
++ for (const s of list) {
++ if (!s || typeof s.summary !== 'function') throw new TypeError('adminRoutes: every store must come from createConfigStore');
++ if (byNs.has(s.namespace)) throw new TypeError(`adminRoutes: ${s.namespace} is listed twice`);
++ byNs.set(s.namespace, s);
++ }
++ const guards = [].concat(o.requireAdmin || []);
++ if (!guards.length || !guards.every((g) => typeof g === 'function')) throw new TypeError('adminRoutes: requireAdmin middleware is required');
++ const basePath = (o.basePath || '/api/admin/config').replace(/\/+$/, '');
++ const actorOf = o.actor || defaultActor;
++ const authorize = o.authorize || null;
++ const log = makeLog(o.log);
++
++ const allowed = async (req, ctx) => !authorize || (await authorize(req, ctx)) !== false;
++ function storeFor(req) {
++ const ns = (req.params && req.params.namespace) || '';
++ const s = byNs.get(ns);
++ if (!s) throw new ConfigError('config.namespace_not_found', `no configuration namespace ${ns || '(none)'}`, { status: 404 });
++ return s;
++ }
++ const queryOf = (req) => req.query || Object.fromEntries(new URL(req.url || '/', 'http://x').searchParams);
++
++ 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());
++ 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());
++ },
++ 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 });
++ send(res, 200, { namespace: s.namespace, snapshots, next_before: snapshots.length === limit ? snapshots[snapshots.length - 1].revision : null });
++ },
++ async apply(req, res) {
++ const s = storeFor(req);
++ const body = await readBody(req);
++ if (!isPlainObject(body) || !isPlainObject(body.values)) throw new ConfigError('config.bad_request', 'the body must be { values, reason }', { status: 400 });
++ if (body.merge !== undefined && typeof body.merge !== 'boolean') throw new ConfigError('config.bad_request', 'merge must be true or false', { 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 opts = { merge: body.merge === true, unset: body.unset || [], actor, reason: body.reason };
++ const keys = 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));
++ },
++ async rollback(req, res) {
++ const s = storeFor(req);
++ const body = await readBody(req);
++ if (!isPlainObject(body)) throw new ConfigError('config.bad_request', 'the body must be { to?, reason? }', { 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);
++ 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) });
++ 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 }));
++ },
++ };
++
++ function fail(res, err) {
++ if (res.headersSent) return;
++ if (err instanceof ConfigError) return sendProblem(res, err.status, err.code, { detail: err.message, errors: err.errors });
++ log.error(`[config] admin route failed: ${scrub(errText(err))}`);
++ sendProblem(res, 500, 'config.internal', { detail: 'the configuration request failed' });
++ }
++ /** requireAdmin first, always; a guard that answers (401/403) ends the request there. */
++ function guarded(fn) {
++ return (req, res, next) => {
++ let i = 0;
++ const step = (err) => {
++ if (err) return typeof next === 'function' ? next(err) : fail(res, err);
++ if (i >= guards.length) return Promise.resolve().then(() => fn(req, res)).catch((e) => fail(res, e));
++ const g = guards[i++];
++ try { g(req, res, step); } catch (e) { step(e); }
++ };
++ step();
++ };
++ }
++ const out = Object.fromEntries(Object.entries(handlers).map(([k, fn]) => [k, guarded(fn)]));
++ const routes = [
++ { method: 'get', path: basePath, handler: out.list },
++ { method: 'get', path: `${basePath}/:namespace`, handler: out.get },
++ { method: 'get', path: `${basePath}/:namespace/history`, handler: out.history },
++ { method: 'post', path: `${basePath}/:namespace`, handler: out.apply },
++ { method: 'post', path: `${basePath}/:namespace/rollback`, handler: out.rollback },
++ ];
++ function mount(router) {
++ for (const r of routes) router[r.method](r.path, r.handler);
++ return router;
++ }
++ /** For a plain http server: dispatches the five routes, calls next() (or answers 404) for anything else. */
++ function handle(req, res, next) {
++ const url = new URL(req.url || '/', 'http://x');
++ const method = String(req.method || 'GET').toLowerCase();
++ const rest = url.pathname === basePath ? '' : url.pathname.startsWith(`${basePath}/`) ? url.pathname.slice(basePath.length + 1) : null;
++ let m = null;
++ if (rest === '') m = method === 'get' ? ['list'] : null;
++ else if (rest !== null) {
++ const parts = rest.split('/');
++ if (parts.length === 1 && method === 'get') m = ['get', parts[0]];
++ else if (parts.length === 1 && method === 'post') m = ['apply', parts[0]];
++ else if (parts.length === 2 && parts[1] === 'history' && method === 'get') m = ['history', parts[0]];
++ else if (parts.length === 2 && parts[1] === 'rollback' && method === 'post') m = ['rollback', parts[0]];
++ }
++ if (!m) return typeof next === 'function' ? next() : sendProblem(res, 404, 'config.route_not_found', { detail: 'no such configuration route' });
++ let ns = m[1] || '';
++ try { ns = decodeURIComponent(ns); } catch { /* keep it as it is */ }
++ req.params = { ...(req.params || {}), namespace: ns };
++ if (!req.query) req.query = Object.fromEntries(url.searchParams);
++ return out[m[0]](req, res, next);
++ }
++ if (o.router) mount(o.router);
++ return { ...out, routes, mount, handle };
++}
++
++module.exports = { createConfigStore, adminRoutes, fromRows, canonical, ConfigError, problem };
ddiff --git a/package.json b/package.json
iindex 3e868d1..ae6654d 100644
---- a/package.json
++++ b/package.json
@@@ -19,6 +19,7 @@
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
++ "./config": "./config.js",
"./frame": "./frame.js",
"./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
ddiff --git a/test/config.test.js b/test/config.test.js
nnew file mode 100644
iindex 0000000..764e664
---- /dev/null
++++ b/test/config.test.js
@@@ -0,0 +1,635 @@
++'use strict';
++// config.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');
++const Database = require('better-sqlite3');
++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 = (db, ns) => db.prepare('SELECT hmac_key FROM config_keys WHERE namespace = ?').get(ns).hmac_key;
++const fp = (db, ns, v) => crypto.createHmac('sha256', 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 = (db) => 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,
++ exec: (s) => db.exec(s),
++ transaction: (fn) => db.transaction(fn),
++ prepare(sql) {
++ const st = db.prepare(sql);
++ return { get: (...a) => (c.n++, st.get(...a)), all: (...a) => (c.n++, st.all(...a)), run: (...a) => (c.n++, st.run(...a)) };
++ },
++ };
++}
++const rowOf = (db, ns, rev) => db.prepare('SELECT * FROM config_snapshots WHERE namespace = ? AND revision = ?').get(ns, rev);
++
++(async () => {
++ const db = new Database(':memory:');
++
++ // ── Construction ──
++ assert.throws(() => createConfigStore({}), /better-sqlite3/);
++ assert.throws(() => createConfigStore({ db, service: 'Live', namespace: 'live.x' }), /service/);
++ assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'media.x' }), /namespace/);
++ assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live' }), /namespace/);
++ assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { oneOf: [] } }), /unsupported keyword "oneOf"/);
++ assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', schema: { type: 'object', properties: { a: { $ref: '#' } } } }), /unsupported keyword "\$ref"/);
++ assert.throws(() => createConfigStore({ db, service: 'live', namespace: 'live.x', classify: 'secret' }), /classify/);
++ assert.throws(() => 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 = 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(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 = 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);
++ assert.throws(() => live.propose(values, { actor: USER }), ok, `${path} is refused`);
++ await assert.rejects(live.apply(values, { actor: USER }), ok);
++ }
++ assert.throws(() => live.propose('nope'), (e) => e.code === 'config.invalid');
++ const required = 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(), {});
++ assert.throws(() => required.propose({ b: 1 }), (e) => e.errors[0].path === '/a' && e.errors[0].message === 'is required');
++ assert.strictEqual(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.
++ assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: 12345678 }), (e) => !JSON.stringify(e.errors).includes('12345678') && !e.message.includes('12345678'));
++ assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: `${SECRET} !` }), (e) => !e.message.includes(SECRET) && e.message.includes('[redacted]'));
++ assert.throws(() => 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(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: 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(live.snapshot(1).state, 'superseded');
++ assert.strictEqual(live.lastKnownGood().revision, 2);
++ assert.ok(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 = 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(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 = 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(rowOf(db, 'live.site_settings', goodRev).state, 'active', 'database: the previous revision is active again');
++ assert.strictEqual(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(live.lastKnownGood().revision, goodRev);
++ assertSnapshot(live.snapshot(failedRev));
++ const restarted = 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 = 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(live.snapshot(p2.revision).state, 'rejected');
++ assert.match(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 = 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(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({ ...live.snapshot(goodRev), state: null }, { ...goodSnap, state: null }, 'the copied revision is untouched (only its state moved)');
++ assert.strictEqual(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(live.snapshot(beforeFailedRollback).state, 'active', 'a failed rollback puts the rolled_back revision back');
++ const fresh = 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 = live.summary();
++ assert.deepStrictEqual(summary.values.youtube_api_key, { redacted: true, fingerprint: fp(db, 'live.site_settings', SECRET) });
++ assert.strictEqual(summary.values.youtube_api_key.fingerprint, crypto.createHmac('sha256', 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';
++ assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: fp(db, 'live.site_settings', 'other') } }), refusedMarker, 'a fingerprint of another value is refused');
++ assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, fingerprint: sha(SECRET) } }), refusedMarker, 'an unkeyed hash of the right value is refused');
++ assert.throws(() => live.propose({ ...DEFAULTS, youtube_api_key: { redacted: true, sha256: sha(SECRET) } }), refusedMarker, 'the old { redacted, sha256 } shape is refused');
++ assert.throws(() => 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 = live.history({ limit: 200 });
++ const keyFps = new Set(history.filter((h) => h.state !== 'rejected' && live.snapshot(h.revision)).map((h) => h.values.youtube_api_key.fingerprint));
++ assert.ok(keyFps.has(fp(db, 'live.site_settings', SECRET)) && keyFps.has(fp(db, 'live.site_settings', '')), 'fingerprints differ exactly when the value does');
++ for (const s of live.history({ limit: 200 })) assertSnapshot(s);
++ assert.ok(!JSON.stringify(live.history({ limit: 200 })).includes(SECRET));
++ assert.ok(!JSON.stringify(live.summary()).includes(SECRET));
++ // A key classified secret later is redacted in the revisions stored before.
++ const flags = createConfigStore({ db, service: 'live', namespace: 'live.flags', defaults: {}, log });
++ await flags.apply({ webhook_url: 'https://hooks.example/T0K3N' }, { actor: USER });
++ assert.strictEqual(flags.history()[0].values.webhook_url, 'https://hooks.example/T0K3N');
++ const flags2 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
++ assert.ok(flags2.history().every((s) => !JSON.stringify(s).includes('T0K3N')));
++ assert.strictEqual(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 = flags2.summary().values.webhook_url;
++ db.prepare("DELETE FROM config_keys WHERE namespace = 'live.flags'").run();
++ const flags3 = createConfigStore({ db, service: 'live', namespace: 'live.flags', classify: { webhook_url: 'secret' }, log });
++ assert.notDeepStrictEqual(flags3.summary().values.webhook_url, oldMarker);
++ assert.throws(() => flags3.propose({ webhook_url: oldMarker }), (e) => e.code === 'config.invalid');
++ assert.strictEqual(flags3.propose({ webhook_url: flags3.summary().values.webhook_url }).values.webhook_url.fingerprint, 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 = 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(media.history().every((s) => s.namespace === 'media.storage_tier' && s.service === 'media'));
++ assert.ok(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(live.summary().values.youtube_api_key.fingerprint, media.summary().values.b2_key.fingerprint);
++ assert.throws(() => live.propose({ youtube_api_key: media.summary().values.b2_key }, { merge: true }), (e) => e.code === 'config.invalid');
++ const keyRows = 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 = createConfigStore({ ...liveOpts, db });
++ assert.deepStrictEqual(live2.get(), live.get());
++ assert.strictEqual(live2.revision(), live.revision());
++ assert.strictEqual(live2.lastKnownGood().revision, live.lastKnownGood().revision);
++ assert.deepStrictEqual(live2.summary(), live.summary(), 'the key survives a restart: same fingerprints, same checksums');
++ assert.strictEqual(db.prepare("SELECT COUNT(*) AS n FROM config_keys WHERE namespace = 'live.site_settings'").get().n, 1);
++ const media2 = 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 = 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(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(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 = 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(rowOf(db, 'live.hang', 2).state, 'active');
++ assert.strictEqual(hanging.lastKnownGood().revision, 1, 'not good until onActivate finished');
++ for (let i = 0; i < 3; i++) hanging.propose({ n: 10 + i }, { actor: USER });
++ const hangRevs = 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 = createConfigStore({ db, service: 'live', namespace: 'live.hang', defaults: { n: 0 }, log });
++ assert.strictEqual(rebooted.revision(), 1);
++ assert.strictEqual(rebooted.get('n'), 0);
++ assert.strictEqual(rowOf(db, 'live.hang', 2).state, 'rejected');
++ assert.match(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 = 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(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 = createConfigStore({ db, service: 'live', namespace: 'live.concurrency', log });
++ const [c1, c2] = await Promise.all([conc.apply({ n: 6 }, { actor: USER }), Promise.resolve().then(() => 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 = 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(pr.history().map((s) => s.revision), [7, 6, 5]);
++ const pr2 = 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(pr2.history().map((s) => `${s.revision}:${s.state}`), ['6:rejected', '5:rejected', '1:active']);
++ assert.strictEqual(pr2.lastKnownGood().revision, 1);
++ assert.deepStrictEqual(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 = new Database(':memory:');
++ 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 = createConfigStore(importOpts);
++ assert.strictEqual(imp.revision(), 1);
++ assert.strictEqual(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(imp.snapshot(1).values.resend_api_key, { redacted: true, fingerprint: fp(legacyDb, 'network.site_settings', 're_abcdef123456') });
++ assertSnapshot(imp.snapshot(1));
++ createConfigStore(importOpts);
++ assert.strictEqual(legacyReads, 1, 'the legacy source is read once, when the namespace is new');
++ assert.strictEqual(imp.import(rows), null, 'import is a no-op once the namespace has revisions');
++ const imp2 = createConfigStore({ db: legacyDb, service: 'network', namespace: 'network.other', log });
++ assert.strictEqual(imp2.revision(), null);
++ const imported = 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 = 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'), leakForms(db).concat(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: fp(db, 'live.site_settings', SECRET) });
++ assert.strictEqual(liveEntry.last_known_good.revision, 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: 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 = 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 = leakForms(db);
++ texts.forEach((x, i) => assertNoLeak(x, forms, `route response ${i}`));
++ assertNoLeak(JSON.stringify([live.summary(), media.summary(), live.history({ limit: 200 }), media.history({ limit: 200 }), 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(), 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'), leakForms(db).concat(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); });
ddiff --git a/test/package.test.js b/test/package.test.js
iindex 3c0a50d..caedf13 100644
---- a/test/package.test.js
++++ b/test/package.test.js
@@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
assert.doesNotThrow(() => require.resolve(`openvibe-shared/${sub}`), `openvibe-shared/${sub}`);
}
// Server modules load in Node; the navbar module loads too (UMD, no DOM touched at require time).
--for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready', 'egress', 'trace']) require(`openvibe-shared/${sub}`);
++for (const sub of ['analytics', 'app-icon', 'brand', 'chrome-ssr', 'footer', 'icons', 'legal', 'notifications', 'seo', 'url-resolver', 'files', 'nav-icons', 'metrics', 'ready', 'egress', 'trace', 'config']) require(`openvibe-shared/${sub}`);
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');