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)
Changes
@@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking change to any exported module, browser global or served file name is a new major.A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).++## 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-25Accessibility (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.@@@ -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 servewithout 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)```jsnnew file mode 100644@@@ -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 };@@@ -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",nnew file mode 100644@@@ -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); });@@@ -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, '');
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -4,6 +4,10 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
+## 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
## 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.
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.
@@ -24,7 +24,7 @@ were reconciled first.
@@ -24,7 +24,7 @@ were reconciled first.
| Kind | Files |
| 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` |
| 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`) |
| 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` |
| 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)
@@ -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
`{ ok: false, error }`. Name a dependency required only when the service really cannot serve
without it.
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)
### Releases: `/release.json`, open tabs and the mixed-version test (ADR-016, Track R)
```js
```js
new file mode 100644
new file mode 100644
@@ -0,0 +1,866 @@
@@ -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 };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"./auth-client": "./auth-client.js",
"./auth-client": "./auth-client.js",
"./brand": "./brand.js",
"./brand": "./brand.js",
"./builtin-themes": "./builtin-themes.js",
"./builtin-themes": "./builtin-themes.js",
+ "./config": "./config.js",
"./frame": "./frame.js",
"./frame": "./frame.js",
"./serve": "./serve.js",
"./serve": "./serve.js",
"./chrome-ssr": "./chrome-ssr.js",
"./chrome-ssr": "./chrome-ssr.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,635 @@
@@ -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); });
@@ -22,7 +22,7 @@ for (const sub of ['analytics', 'app-icon', 'auth-client', 'brand', 'chrome-ssr'
@@ -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}`);
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).
// 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.
// analytics is dependency-free: its parts require only Node built-ins and each other.
for (const f of fs.readdirSync(path.join(ROOT, 'analytics'))) {
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, '');
const src = fs.readFileSync(path.join(ROOT, 'analytics', f), 'utf8').replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '');