openvibers/openvibe.live Commits
D11: secrets leave Live's site_settings for the sealed config store (#607)
Changes
@@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.<!-- versions:start -->--- openvibe-contracts: v0.222.0++- openvibe-contracts: v0.288.0- openvibe-sdk: v0.82.0--- openvibe-shared: v4.109.2++- openvibe-shared: v4.110.0<!-- versions:end -->@@@ -5,9 +5,9 @@"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step","port": 3000,"domain": "openvibe.live (www, ingest, whip)",-- "contracts": "openvibe-contracts v0.222.0",++ "contracts": "openvibe-contracts v0.288.0","sdk": "openvibe-sdk v0.82.0",-- "shared": "openvibe-shared v4.109.2",++ "shared": "openvibe-shared v4.110.0","capabilities": ["live.chat_context.read","live.chat_effects.write",nnew file mode 100644@@@ -0,0 +1,33 @@++-- phase: expand++-- D11. The revisioned-configuration journal (openvibe-shared/config on PostgreSQL): server/admin/site-config.js++-- keeps site_settings for non-secret configuration and records changes as revisions of the namespace++-- live.site_settings; a secret-class value lives only in the store (sealed when OV_CONFIG_KEKS is set).++-- config_snapshots and config_keys are openvibe-shared/config's configSchema() (config-pg.js); a service's++-- migrations own its tables and the runtime role creates none.++++CREATE TABLE IF NOT EXISTS config_snapshots (++ namespace text COLLATE "C" NOT NULL,++ revision bigint NOT NULL,++ service text COLLATE "C" NOT NULL,++ previous_revision bigint,++ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),++ values_json text COLLATE "C" NOT NULL,++ classification_json text COLLATE "C" NOT NULL,++ values_checksum text COLLATE "C" NOT NULL,++ created_at text COLLATE "C" NOT NULL,++ created_by text COLLATE "C" NOT NULL,++ activated_at text COLLATE "C",++ activated_by text COLLATE "C",++ reason text COLLATE "C",++ error text COLLATE "C",++ copied_from bigint,++ good bigint NOT NULL DEFAULT 0,++ PRIMARY KEY (namespace, revision)++);++CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';++++CREATE TABLE IF NOT EXISTS config_keys (++ namespace text COLLATE "C" PRIMARY KEY,++ hmac_key bytea NOT NULL,++ created_at text COLLATE "C" NOT NULL++);@@@ -23,9 +23,9 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -2840,9 +2840,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.288.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",++ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -2884,9 +2884,9 @@}},"node_modules/openvibe-shared": {-- "version": "4.109.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",-- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",++ "version": "4.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",++ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0",@@@ -42,9 +42,9 @@"multer": "^1.4.5-lts.1","node-media-server": "^2.7.1","onnxruntime-node": "^1.20.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0","pg": "^8.23.1","sanitize-html": "^2.17.7","sdp-transform": "^3.0.0",@@@ -29,19 +29,34 @@ const SYSTEM = { type: 'service', id: 'live' };const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);const classify = (key) => (SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');--/** The configuration rows as they are now: key → the stored string. */++const isSecret = (key) => classify(key) === 'secret';++const nonSecret = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !isSecret(k)));++++/** The configuration rows as they are now: key → the stored string. Never the merged view readers see. */async function rowsNow() {const out = {};-- for (const r of await db.getAllSettings()) if (isConfigKey(r.key)) out[r.key] = r.value == null ? '' : String(r.value);++ for (const r of await db.all('SELECT key, value FROM site_settings ORDER BY key')) if (isConfigKey(r.key)) out[r.key] = r.value == null ? '' : String(r.value);return out;}--/** Write the revision's values to the rows: set what differs, delete what the revision no longer has. */++/**++ * Write the revision's non-secret values to the rows: set what differs, delete what the revision no longer has.++ * A secret-class value never goes into site_settings (D11): the store keeps the only copy, sealed when it has a++ * KEK. Its row may stay as a blank, typed slot so the settings page still lists the key; a stored value is left++ * alone here (the boot migration blanks it once the store holds a sealed copy).++ */async function writeRows(target) {const now = await rowsNow();await db.getDb().tx(async () => {-- for (const [k, v] of Object.entries(target)) if (isConfigKey(k) && now[k] !== String(v)) await db.setSetting(k, String(v));-- for (const k of Object.keys(now)) if (!(k in target)) await db.deleteSetting(k);++ for (const [k, v] of Object.entries(target)) {++ if (!isConfigKey(k)) continue;++ if (isSecret(k)) {++ if (!(k in now)) await db.setSetting(k, '');++ continue;++ }++ if (now[k] !== String(v)) await db.setSetting(k, String(v));++ }++ for (const k of Object.keys(now)) if (!(k in target) && !isSecret(k)) await db.deleteSetting(k);});}@@@ -64,14 +79,25 @@ function getStore() {return store;}++/** The store once it exists, without creating it. Readers use it when the boot has loaded it. */++function hasStore() { return store !== null; }++async function secretValue(key) {++ if (!store) return undefined;++ return (await store).get(key);++}++const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));--/** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */++/** Record non-secret rows written around the journal as a revision of their own. → the sync snapshot, or null */async function sync() {const s = await getStore();const now = await rowsNow();-- if (canonical({ ...s.get() }) === canonical(now)) return null;-- return s.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });++ const current = { ...s.get() };++ const next = {};++ for (const [k, v] of Object.entries(current)) if (isSecret(k)) next[k] = v; // secrets stay in the store++ for (const [k, v] of Object.entries(now)) if (!isSecret(k)) next[k] = v;++ if (canonical(nonSecret(current)) === canonical(nonSecret(next))) return null;++ return s.apply(next, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });}/**@@@ -84,7 +110,10 @@ async function change({ set = {}, unset = [] } = {}, { actor = SYSTEM, reason =await sync();const values = {};for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');-- return (await getStore()).apply(values, { merge: true, unset, actor, reason });++ const snap = await (await getStore()).apply(values, { merge: true, unset, actor, reason });++ // An explicit unset drops the store's secret; blank the legacy slot too so no plaintext copy survives.++ for (const k of unset) if (isSecret(k)) await db.setSetting(k, '');++ return snap;}/** Roll back to the previous good revision (or `to`) after recording any outside changes. */@@@ -93,10 +122,55 @@ async function rollback({ actor = SYSTEM, reason = null, to } = {}) {return (await getStore()).rollback({ actor, reason, to });}++/**++ * D11: the legacy table keeps non-secret rows only. With a KEK, every secret-class row the store holds — and for++ * which the sealed store's read-back and marker agree — is blanked. Without a KEK nothing is blanked (the store is++ * not sealed); without a value in the store, or with a locked envelope, the row is left for a later run. Counts++ * only, never a key's value.++ */++async function migrateLegacySecrets(s) {++ const rows = await rowsNow();++ const candidates = Object.entries(rows).filter(([k, v]) => isSecret(k) && String(v) !== '');++ if (!candidates.length) return { checked: 0, blanked: 0, differs: 0, kept: 0, sealed: !!s.sealed };++ if (!s.sealed) {++ console.log(`[Config] legacy site_settings: ${candidates.length} secret value(s) left (no KEK; the store is not sealed)`);++ return { checked: candidates.length, blanked: 0, differs: 0, kept: candidates.length, sealed: false };++ }++ const summary = await s.summary();++ const snap = summary.active;++ let blanked = 0, differs = 0;++ for (const [k, v] of candidates) {++ const stored = s.get(k);++ const marker = summary.secrets[k] || (typeof s.secretMarker === 'function' ? await s.secretMarker(k) : null);++ const sealedRow = snap && snap.values ? snap.values[k] : null;++ const proven = stored !== undefined && stored !== null && String(stored) !== ''++ && marker && sealedRow && sealedRow.redacted === true++ && marker.fingerprint && marker.fingerprint === sealedRow.fingerprint;++ // Never drop a value the store does not hold: the sealed copy must be exactly this legacy value.++ if (!proven || String(stored) !== String(v)) { differs++; continue; }++ await db.setSetting(k, '');++ blanked++;++ }++ console.log(`[Config] legacy site_settings: ${blanked} secret value(s) blanked (the store holds the same value); ${differs} kept (value differs or no sealed copy)${s.kek ? `; KEK ${s.kek}` : ''}`);++ return { checked: candidates.length, blanked, differs, kept: differs, sealed: true };++}++++let initPromise = null;++/** Boot: load the store, seal old plaintext rows, then blank the legacy copies the sealed store holds. */++function init() {++ if (initPromise) return initPromise;++ initPromise = (async () => {++ const s = await getStore();++ if (s.sealed) await s.reseal();++ return await migrateLegacySecrets(s);++ })().catch((err) => { initPromise = null; throw err; });++ return initPromise;++}++/** A person as the journal records them. */function actorOf(user) {if (user && user.subject_id) return { type: 'user', id: String(user.subject_id) };return SYSTEM;}--module.exports = { getStore, change, rollback, sync, isConfigKey, classify, actorOf, NOT_CONFIG, _reset: () => { store = null; } };++module.exports = { getStore, init, hasStore, secretValue, migrateLegacySecrets, change, rollback, sync, isConfigKey, classify, isSecret, actorOf, NOT_CONFIG, _reset: () => { store = null; initPromise = null; } };@@@ -34,6 +34,7 @@ const express = require('express');const fs = require('fs');const path = require('path');const db = require('../db/database');++const siteConfig = require('../admin/site-config');const config = require('../config');const permissions = require('../auth/permissions');const { guard } = require('../net/service-guard');@@@ -371,16 +372,18 @@ effectsRouter.post('/site-settings', async (req, res) => {const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);const owner = permissions.isOwner(actor);-- let count = 0;++ const set = {};for (const [key, value] of Object.entries(req.body.settings || {})) {if (!allowed.includes(key)) continue;if (secret.has(key) && !owner) continue;if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;-- await db.setSetting(key, value);-- count++;++ set[key] = value;}++ // D11: these are configuration (the credentials are secret-class): one revision in the store, which seals a++ // secret and never writes its value to site_settings.++ if (Object.keys(set).length) await siteConfig.change({ set }, { actor: siteConfig.actorOf(actor), reason: 'Chat TTS settings' });try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }-- res.json({ ok: true, updated: count });++ res.json({ ok: true, updated: Object.keys(set).length });});// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.@@@ -2481,7 +2481,24 @@ async function close() {// ── Site Settings helpers ────────────────────────────────────++/** The config journal, when the boot has loaded it: a secret-class value's only copy (D11). */++function configJournal() {++ try {++ const sc = require('../admin/site-config');++ return typeof sc.hasStore === 'function' && sc.hasStore() ? sc : null;++ } catch { return null; }++}++++/** The store's value for a secret-class key; the row's value is only a fallback before the store exists. */++async function storeSecret(key) {++ const sc = configJournal();++ if (!sc || sc.classify(key) !== 'secret') return undefined;++ return await sc.secretValue(key);++}++async function getSetting(key) {++ const secret = await storeSecret(key);++ if (secret !== undefined) return secret;const row = await get('SELECT * FROM site_settings WHERE key = ?', [key]);if (!row) return null;switch (row.type) {@@@ -2493,11 +2510,26 @@ async function getSetting(key) {}async function getSettingRow(key) {-- return await get('SELECT * FROM site_settings WHERE key = ?', [key]);++ const row = await get('SELECT * FROM site_settings WHERE key = ?', [key]);++ if (row) {++ const secret = await storeSecret(key);++ if (secret !== undefined) row.value = typeof secret === 'string' ? secret : JSON.stringify(secret);++ }++ return row;}++/** The rows as the admin settings page expects them: secret-class values from the store, never the legacy copy. */async function getAllSettings() {-- return await all('SELECT * FROM site_settings ORDER BY key');++ const rows = await all('SELECT * FROM site_settings ORDER BY key');++ const sc = configJournal();++ if (sc) {++ for (const r of rows) {++ if (sc.classify(r.key) !== 'secret') continue;++ const secret = await sc.secretValue(r.key);++ if (secret !== undefined) r.value = typeof secret === 'string' ? secret : JSON.stringify(secret);++ }++ }++ return rows;}async function setSetting(key, value) {@@@ -1283,6 +1283,9 @@ async function start() {// 1. Initialize database: migrations as the owner, then the pool (server/db/database.js; the schema is migrations/).await db.initDb();++ // D11: load the configuration journal, reseal old plaintext under the KEK, then blank the legacy secret++ // copies it holds. Without a KEK the store reports sealed:false and nothing is blanked.++ await require('./admin/site-config').init();await analyticsStore.ready();console.log('[Server] Database ready');await restoreRum(); // the real-user reservoirs, now that the database is open@@@ -70,6 +70,7 @@ const ulid = () => ids.ulid();const chatTables = ['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];for (const t of chatTables) exempt[t] = 'Chat owns and erases it';exempt.hidden_relay_users = 'a moderation record of a relay account hidden from a channel, kept for accountability';++ exempt.config_snapshots = 'the configuration journal: created_by/activated_by are actor refs, not an account row';// Every other table with a person column is handled by the schema-driven export and erase below.const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));const missing = await uncovered(d, { taskKeyed: handled, exempt });@@@ -399,6 +399,7 @@ const waitFor = async (pred, what, ms = 3000) => {assert.notStrictEqual(await db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);assert.strictEqual(await db.getSetting('tts_google_api_key'), 'owner-can');++ assert.strictEqual((await db.get("SELECT value FROM site_settings WHERE key = 'tts_google_api_key'")).value, '', 'the credential is in the journal, not site_settings');// 8. Slow mode and sub-only persistence moved to Chat with channel_moderation_settings (roadmap// T3); Live now only writes channels.emote_sources for Chat's PUT /api/emotes/sources.nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++/**++ * D11: a secret-class value copied into site_settings is blanked at boot once the sealed config store holds it.++ * With a KEK the store's copy is an envelope; the row is blank, no summary/log carries the value, and every++ * reader (db.getSetting, the TTS settings) still gets it.++ *++ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-kek.test.js++ */++const assert = require('assert');++process.env.NODE_ENV = 'test';++process.env.OV_CONFIG_KEKS = `k1:${Buffer.alloc(32, 7).toString('base64')}`;++process.env.OV_CONFIG_KEK_CURRENT = 'k1';++++const SECRET = 'sentinel-not-a-secret-tts-key';++const logs = [];++const capture = (level) => (...a) => { logs.push(a.map(String).join(' ')); };++const real = { log: console.log, warn: console.warn, error: console.error };++console.log = capture('log'); console.warn = capture('warn'); console.error = capture('error');++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ await db.setSetting('tts_google_api_key', SECRET);++ await db.setSetting('tts_enabled', '1');++ await db.setSetting('motd', 'hello');++++ const siteConfig = require('../server/admin/site-config');++ const report = await siteConfig.init();++ const store = await siteConfig.getStore();++ assert.strictEqual(store.sealed, true, 'the store is sealed under the KEK');++ assert.strictEqual(report.blanked, 1, 'the legacy secret value was blanked');++ assert.strictEqual(report.differs, 0, 'an equal value is not counted as differing');++++ const row = await db.get('SELECT value, type FROM site_settings WHERE key = ?', ['tts_google_api_key']);++ assert.ok(row, 'the key keeps its typed slot');++ assert.strictEqual(row.value, '', 'no secret-class value remains in site_settings');++ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['motd'])).value, 'hello', 'a non-secret row is untouched');++++ const summary = JSON.stringify(await store.summary());++ const snapshot = JSON.stringify(await store.snapshot(store.revision()));++ assert.ok(!summary.includes(SECRET) && !snapshot.includes(SECRET), 'no summary or snapshot carries the value');++ const stored = await db.get("SELECT values_json FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'");++ assert.ok(!stored.values_json.includes(SECRET), 'values_json holds no plaintext');++ assert.ok(stored.values_json.includes('"v1:'), 'the secret is stored as an envelope');++ assert.strictEqual((await store.summary()).values.tts_google_api_key.redacted, true);++++ assert.strictEqual(await db.getSetting('tts_google_api_key'), SECRET, 'db.getSetting reads the store');++ const tts = await require('../server/chat/tts-engine').getTTSSettings();++ assert.strictEqual(tts.googleApiKey, SECRET, 'the TTS reader still gets its value');++ assert.ok(!logs.join('\n').includes(SECRET), 'no log line carries the value');++++ // A change through the journal writes the non-secret rows and keeps the secret write-only in the store.++ await siteConfig.change({ set: { tts_google_api_key: 'sentinel-rotated', motd: 'welcome' } }, { reason: 'rotate' });++ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_google_api_key'])).value, '', 'a new secret is not written to the legacy row');++ assert.strictEqual(await db.getSetting('tts_google_api_key'), 'sentinel-rotated', 'the rotated value is read from the store');++ assert.ok(!JSON.stringify(await store.summary()).includes('sentinel-rotated'));++++ // A legacy row newer than the store is kept, not blanked: the store does not hold this value.++ await siteConfig.change({ set: { tts_aws_secret_access_key: 'store-side-secret' } }, { reason: 'store side' });++ await db.setSetting('tts_aws_secret_access_key', 'legacy-side-secret');++ // A secret row the store does not hold at all is kept too.++ await db.setSetting('tts_aws_access_key_id', 'legacy-only-secret');++ const kept = await siteConfig.migrateLegacySecrets(store);++ assert.strictEqual(kept.blanked, 0, 'a differing value is never blanked');++ assert.strictEqual(kept.differs, 2, 'one unequal value and one the store does not hold');++ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_aws_secret_access_key'])).value, 'legacy-side-secret', 'the newer legacy value is kept');++ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_aws_access_key_id'])).value, 'legacy-only-secret', 'the only copy is kept');++ assert.strictEqual(await db.getSetting('tts_aws_secret_access_key'), 'store-side-secret', 'the reader keeps the store value');++ assert.ok(!logs.join('\n').includes('store-side-secret') && !logs.join('\n').includes('legacy-side-secret') && !logs.join('\n').includes('legacy-only-secret'), 'no log line carries either side');++++ real.log('legacy secrets (KEK): all checks passed');++ process.exit(0);++})().catch((err) => { real.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,37 @@++'use strict';++/**++ * D11: without a KEK the config store is not sealed and NOTHING in the legacy table is blanked. The reader still++ * gets the value (the store holds it, as today), so a service with no KEK keeps working unchanged.++ *++ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-nokek.test.js++ */++const assert = require('assert');++process.env.NODE_ENV = 'test';++delete process.env.OV_CONFIG_KEKS;++delete process.env.OV_CONFIG_KEK_CURRENT;++++const SECRET = 'sentinel-not-a-secret-no-kek';++const real = { log: console.log, error: console.error };++console.log = () => {}; console.warn = () => {}; console.error = () => {};++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ await db.setSetting('tts_google_api_key', SECRET);++++ const siteConfig = require('../server/admin/site-config');++ const report = await siteConfig.init();++ const store = await siteConfig.getStore();++ assert.strictEqual(store.sealed, false, 'the store is not sealed without a KEK');++ assert.strictEqual(report.blanked, 0, 'nothing is blanked without a sealed store');++++ const row = await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_google_api_key']);++ assert.strictEqual(row.value, SECRET, 'the only copy is still the legacy row');++ assert.strictEqual(await db.getSetting('tts_google_api_key'), SECRET, 'the reader still gets its value');++ const summary = JSON.stringify(await store.summary());++ assert.ok(!summary.includes(SECRET), 'the summary redacts it even unsealed');++ await assert.rejects(store.reseal(), /KEK/, 'reseal needs a KEK');++++ real.log('legacy secrets (no KEK): all checks passed');++ process.exit(0);++})().catch((err) => { real.error(err); process.exit(1); });@@@ -32,10 +32,8 @@ permissions.requireAdmin = (req, res, next) => (req.user && ['admin'].includes(rasync function main() {await db.initDb();--// migrations/0002_live.sql does not create openvibe-shared/config's tables (config_snapshots/config_keys,--// their configSchema()); the service migrations are meant to. Owner-only DDL stands them up here until the--// migration includes configSchema() (Network's migrations/0006_config.sql is the pattern).--await globalThis.__ovLiveDdl(require('openvibe-shared/config-pg').configSchema());++// migrations/0025_config.sql creates openvibe-shared/config's tables (config_snapshots/config_keys, its++// configSchema()): the service's migrations own its tables (Network's migrations/0006_config.sql is the pattern).const raw = db.getDb();const add = async (id, username, role) => await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);await add(1, 'owner', 'admin');
@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
@@ -175,7 +175,7 @@ Minimum `.env`: `BASE_URL`, `JWT_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
- [docs/restream-branding.md](docs/restream-branding.md) — branding guide for restream channels.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.222.0
+- openvibe-contracts: v0.288.0
- openvibe-sdk: v0.82.0
- openvibe-sdk: v0.82.0
-- openvibe-shared: v4.109.2
+- openvibe-shared: v4.110.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,9 +5,9 @@
@@ -5,9 +5,9 @@
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"runtime": "node 22, express, PostgreSQL, node-media-server, mediasoup; no build step",
"port": 3000,
"port": 3000,
"domain": "openvibe.live (www, ingest, whip)",
"domain": "openvibe.live (www, ingest, whip)",
- "contracts": "openvibe-contracts v0.222.0",
+ "contracts": "openvibe-contracts v0.288.0",
"sdk": "openvibe-sdk v0.82.0",
"sdk": "openvibe-sdk v0.82.0",
- "shared": "openvibe-shared v4.109.2",
+ "shared": "openvibe-shared v4.110.0",
"capabilities": [
"capabilities": [
"live.chat_context.read",
"live.chat_context.read",
"live.chat_effects.write",
"live.chat_effects.write",
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+-- phase: expand
+-- D11. The revisioned-configuration journal (openvibe-shared/config on PostgreSQL): server/admin/site-config.js
+-- keeps site_settings for non-secret configuration and records changes as revisions of the namespace
+-- live.site_settings; a secret-class value lives only in the store (sealed when OV_CONFIG_KEKS is set).
+-- config_snapshots and config_keys are openvibe-shared/config's configSchema() (config-pg.js); a service's
+-- migrations own its tables and the runtime role creates none.
+
+CREATE TABLE IF NOT EXISTS config_snapshots (
+ namespace text COLLATE "C" NOT NULL,
+ revision bigint NOT NULL,
+ service text COLLATE "C" NOT NULL,
+ previous_revision bigint,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('proposed', 'active', 'superseded', 'rejected', 'rolled_back')),
+ values_json text COLLATE "C" NOT NULL,
+ classification_json text COLLATE "C" NOT NULL,
+ values_checksum text COLLATE "C" NOT NULL,
+ created_at text COLLATE "C" NOT NULL,
+ created_by text COLLATE "C" NOT NULL,
+ activated_at text COLLATE "C",
+ activated_by text COLLATE "C",
+ reason text COLLATE "C",
+ error text COLLATE "C",
+ copied_from bigint,
+ good bigint NOT NULL DEFAULT 0,
+ PRIMARY KEY (namespace, revision)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS config_snapshots_one_active ON config_snapshots (namespace) WHERE state = 'active';
+
+CREATE TABLE IF NOT EXISTS config_keys (
+ namespace text COLLATE "C" PRIMARY KEY,
+ hmac_key bytea NOT NULL,
+ created_at text COLLATE "C" NOT NULL
+);
@@ -23,9 +23,9 @@
@@ -23,9 +23,9 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -2840,9 +2840,9 @@
@@ -2840,9 +2840,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.288.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
+ "integrity": "sha512-AKRP1yXACYNu7okpf4kCDq4ds0PhsoNvD92VlvBWBCjcSlNimSTQhLWIvSa3D31/kQYz91fodo06ZnwuTanOqg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -2884,9 +2884,9 @@
@@ -2884,9 +2884,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "4.109.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
- "integrity": "sha512-t9K1yHr6GOJsfamqmgERRTTtiWYWAVmjWBEm4ykTcYjEenbTiqQTwX6CJsZW4lYnqS1IlqEvn+0jG4vXmHSJ3Q==",
+ "version": "4.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
+ "integrity": "sha512-ZNOSraSgE89jhLTI//lFJALvKCiBJIBwf+V0aCkiIk0VCrPPAytsfsgOgCINU8kXDVdCEnuWWfcA9hZUc54QVA==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -42,9 +42,9 @@
@@ -42,9 +42,9 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"node-media-server": "^2.7.1",
"node-media-server": "^2.7.1",
"onnxruntime-node": "^1.20.0",
"onnxruntime-node": "^1.20.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.288.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.109.2",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.110.0",
"pg": "^8.23.1",
"pg": "^8.23.1",
"sanitize-html": "^2.17.7",
"sanitize-html": "^2.17.7",
"sdp-transform": "^3.0.0",
"sdp-transform": "^3.0.0",
@@ -29,19 +29,34 @@ const SYSTEM = { type: 'service', id: 'live' };
@@ -29,19 +29,34 @@ const SYSTEM = { type: 'service', id: 'live' };
const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
const isConfigKey = (key) => typeof key === 'string' && key.length > 0 && key.length <= 100 && !NOT_CONFIG.test(key);
const classify = (key) => (SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
const classify = (key) => (SECRET.test(key) ? 'secret' : PUBLIC_KEYS.has(key) ? 'public' : 'internal');
-/** The configuration rows as they are now: key → the stored string. */
+const isSecret = (key) => classify(key) === 'secret';
+const nonSecret = (o) => Object.fromEntries(Object.entries(o).filter(([k]) => !isSecret(k)));
+
+/** The configuration rows as they are now: key → the stored string. Never the merged view readers see. */
async function rowsNow() {
async function rowsNow() {
const out = {};
const out = {};
- for (const r of await db.getAllSettings()) if (isConfigKey(r.key)) out[r.key] = r.value == null ? '' : String(r.value);
+ for (const r of await db.all('SELECT key, value FROM site_settings ORDER BY key')) if (isConfigKey(r.key)) out[r.key] = r.value == null ? '' : String(r.value);
return out;
return out;
}
}
-/** Write the revision's values to the rows: set what differs, delete what the revision no longer has. */
+/**
+ * Write the revision's non-secret values to the rows: set what differs, delete what the revision no longer has.
+ * A secret-class value never goes into site_settings (D11): the store keeps the only copy, sealed when it has a
+ * KEK. Its row may stay as a blank, typed slot so the settings page still lists the key; a stored value is left
+ * alone here (the boot migration blanks it once the store holds a sealed copy).
+ */
async function writeRows(target) {
async function writeRows(target) {
const now = await rowsNow();
const now = await rowsNow();
await db.getDb().tx(async () => {
await db.getDb().tx(async () => {
- for (const [k, v] of Object.entries(target)) if (isConfigKey(k) && now[k] !== String(v)) await db.setSetting(k, String(v));
- for (const k of Object.keys(now)) if (!(k in target)) await db.deleteSetting(k);
+ for (const [k, v] of Object.entries(target)) {
+ if (!isConfigKey(k)) continue;
+ if (isSecret(k)) {
+ if (!(k in now)) await db.setSetting(k, '');
+ continue;
+ }
+ if (now[k] !== String(v)) await db.setSetting(k, String(v));
+ }
+ for (const k of Object.keys(now)) if (!(k in target) && !isSecret(k)) await db.deleteSetting(k);
});
});
}
}
@@ -64,14 +79,25 @@ function getStore() {
@@ -64,14 +79,25 @@ function getStore() {
return store;
return store;
}
}
+/** The store once it exists, without creating it. Readers use it when the boot has loaded it. */
+function hasStore() { return store !== null; }
+async function secretValue(key) {
+ if (!store) return undefined;
+ return (await store).get(key);
+}
+
const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
const canonical = (o) => JSON.stringify(Object.keys(o).sort().map((k) => [k, o[k]]));
-/** Record rows written around the journal as a revision of their own. → the sync snapshot, or null */
+/** Record non-secret rows written around the journal as a revision of their own. → the sync snapshot, or null */
async function sync() {
async function sync() {
const s = await getStore();
const s = await getStore();
const now = await rowsNow();
const now = await rowsNow();
- if (canonical({ ...s.get() }) === canonical(now)) return null;
- return s.apply(now, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
+ const current = { ...s.get() };
+ const next = {};
+ for (const [k, v] of Object.entries(current)) if (isSecret(k)) next[k] = v; // secrets stay in the store
+ for (const [k, v] of Object.entries(now)) if (!isSecret(k)) next[k] = v;
+ if (canonical(nonSecret(current)) === canonical(nonSecret(next))) return null;
+ return s.apply(next, { actor: SYSTEM, reason: 'sync: site_settings changed outside the configuration journal' });
}
}
/**
/**
@@ -84,7 +110,10 @@ async function change({ set = {}, unset = [] } = {}, { actor = SYSTEM, reason =
@@ -84,7 +110,10 @@ async function change({ set = {}, unset = [] } = {}, { actor = SYSTEM, reason =
await sync();
await sync();
const values = {};
const values = {};
for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');
for (const [k, v] of Object.entries(set)) values[k] = v !== null && typeof v === 'object' ? JSON.stringify(v) : String(v ?? '');
- return (await getStore()).apply(values, { merge: true, unset, actor, reason });
+ const snap = await (await getStore()).apply(values, { merge: true, unset, actor, reason });
+ // An explicit unset drops the store's secret; blank the legacy slot too so no plaintext copy survives.
+ for (const k of unset) if (isSecret(k)) await db.setSetting(k, '');
+ return snap;
}
}
/** Roll back to the previous good revision (or `to`) after recording any outside changes. */
/** Roll back to the previous good revision (or `to`) after recording any outside changes. */
@@ -93,10 +122,55 @@ async function rollback({ actor = SYSTEM, reason = null, to } = {}) {
@@ -93,10 +122,55 @@ async function rollback({ actor = SYSTEM, reason = null, to } = {}) {
return (await getStore()).rollback({ actor, reason, to });
return (await getStore()).rollback({ actor, reason, to });
}
}
+/**
+ * D11: the legacy table keeps non-secret rows only. With a KEK, every secret-class row the store holds — and for
+ * which the sealed store's read-back and marker agree — is blanked. Without a KEK nothing is blanked (the store is
+ * not sealed); without a value in the store, or with a locked envelope, the row is left for a later run. Counts
+ * only, never a key's value.
+ */
+async function migrateLegacySecrets(s) {
+ const rows = await rowsNow();
+ const candidates = Object.entries(rows).filter(([k, v]) => isSecret(k) && String(v) !== '');
+ if (!candidates.length) return { checked: 0, blanked: 0, differs: 0, kept: 0, sealed: !!s.sealed };
+ if (!s.sealed) {
+ console.log(`[Config] legacy site_settings: ${candidates.length} secret value(s) left (no KEK; the store is not sealed)`);
+ return { checked: candidates.length, blanked: 0, differs: 0, kept: candidates.length, sealed: false };
+ }
+ const summary = await s.summary();
+ const snap = summary.active;
+ let blanked = 0, differs = 0;
+ for (const [k, v] of candidates) {
+ const stored = s.get(k);
+ const marker = summary.secrets[k] || (typeof s.secretMarker === 'function' ? await s.secretMarker(k) : null);
+ const sealedRow = snap && snap.values ? snap.values[k] : null;
+ const proven = stored !== undefined && stored !== null && String(stored) !== ''
+ && marker && sealedRow && sealedRow.redacted === true
+ && marker.fingerprint && marker.fingerprint === sealedRow.fingerprint;
+ // Never drop a value the store does not hold: the sealed copy must be exactly this legacy value.
+ if (!proven || String(stored) !== String(v)) { differs++; continue; }
+ await db.setSetting(k, '');
+ blanked++;
+ }
+ console.log(`[Config] legacy site_settings: ${blanked} secret value(s) blanked (the store holds the same value); ${differs} kept (value differs or no sealed copy)${s.kek ? `; KEK ${s.kek}` : ''}`);
+ return { checked: candidates.length, blanked, differs, kept: differs, sealed: true };
+}
+
+let initPromise = null;
+/** Boot: load the store, seal old plaintext rows, then blank the legacy copies the sealed store holds. */
+function init() {
+ if (initPromise) return initPromise;
+ initPromise = (async () => {
+ const s = await getStore();
+ if (s.sealed) await s.reseal();
+ return await migrateLegacySecrets(s);
+ })().catch((err) => { initPromise = null; throw err; });
+ return initPromise;
+}
+
/** A person as the journal records them. */
/** A person as the journal records them. */
function actorOf(user) {
function actorOf(user) {
if (user && user.subject_id) return { type: 'user', id: String(user.subject_id) };
if (user && user.subject_id) return { type: 'user', id: String(user.subject_id) };
return SYSTEM;
return SYSTEM;
}
}
-module.exports = { getStore, change, rollback, sync, isConfigKey, classify, actorOf, NOT_CONFIG, _reset: () => { store = null; } };
+module.exports = { getStore, init, hasStore, secretValue, migrateLegacySecrets, change, rollback, sync, isConfigKey, classify, isSecret, actorOf, NOT_CONFIG, _reset: () => { store = null; initPromise = null; } };
@@ -34,6 +34,7 @@ const express = require('express');
@@ -34,6 +34,7 @@ const express = require('express');
const fs = require('fs');
const fs = require('fs');
const path = require('path');
const path = require('path');
const db = require('../db/database');
const db = require('../db/database');
+const siteConfig = require('../admin/site-config');
const config = require('../config');
const config = require('../config');
const permissions = require('../auth/permissions');
const permissions = require('../auth/permissions');
const { guard } = require('../net/service-guard');
const { guard } = require('../net/service-guard');
@@ -371,16 +372,18 @@ effectsRouter.post('/site-settings', async (req, res) => {
@@ -371,16 +372,18 @@ effectsRouter.post('/site-settings', async (req, res) => {
const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];
const allowed = ['tts_enabled', 'tts_provider', 'tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key', 'tts_aws_region', 'tts_max_length', 'tts_max_queue_per_user', 'tts_max_queue_global', 'tts_default_voice'];
const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);
const secret = new Set(['tts_google_api_key', 'tts_google_service_account', 'tts_aws_access_key_id', 'tts_aws_secret_access_key']);
const owner = permissions.isOwner(actor);
const owner = permissions.isOwner(actor);
- let count = 0;
+ const set = {};
for (const [key, value] of Object.entries(req.body.settings || {})) {
for (const [key, value] of Object.entries(req.body.settings || {})) {
if (!allowed.includes(key)) continue;
if (!allowed.includes(key)) continue;
if (secret.has(key) && !owner) continue;
if (secret.has(key) && !owner) continue;
if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
if (secret.has(key) && typeof value === 'string' && /^••••/.test(value)) continue;
- await db.setSetting(key, value);
- count++;
+ set[key] = value;
}
}
+ // D11: these are configuration (the credentials are secret-class): one revision in the store, which seals a
+ // secret and never writes its value to site_settings.
+ if (Object.keys(set).length) await siteConfig.change({ set }, { actor: siteConfig.actorOf(actor), reason: 'Chat TTS settings' });
try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }
try { require('./tts-engine').invalidateSettingsCache(); } catch { /* */ }
- res.json({ ok: true, updated: count });
+ res.json({ ok: true, updated: Object.keys(set).length });
});
});
// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.
// One real chat line: OpenCoins chat bonus, AI chat viewers, the PowerChat overlay relay.
@@ -2481,7 +2481,24 @@ async function close() {
@@ -2481,7 +2481,24 @@ async function close() {
// ── Site Settings helpers ────────────────────────────────────
// ── Site Settings helpers ────────────────────────────────────
+/** The config journal, when the boot has loaded it: a secret-class value's only copy (D11). */
+function configJournal() {
+ try {
+ const sc = require('../admin/site-config');
+ return typeof sc.hasStore === 'function' && sc.hasStore() ? sc : null;
+ } catch { return null; }
+}
+
+/** The store's value for a secret-class key; the row's value is only a fallback before the store exists. */
+async function storeSecret(key) {
+ const sc = configJournal();
+ if (!sc || sc.classify(key) !== 'secret') return undefined;
+ return await sc.secretValue(key);
+}
+
async function getSetting(key) {
async function getSetting(key) {
+ const secret = await storeSecret(key);
+ if (secret !== undefined) return secret;
const row = await get('SELECT * FROM site_settings WHERE key = ?', [key]);
const row = await get('SELECT * FROM site_settings WHERE key = ?', [key]);
if (!row) return null;
if (!row) return null;
switch (row.type) {
switch (row.type) {
@@ -2493,11 +2510,26 @@ async function getSetting(key) {
@@ -2493,11 +2510,26 @@ async function getSetting(key) {
}
}
async function getSettingRow(key) {
async function getSettingRow(key) {
- return await get('SELECT * FROM site_settings WHERE key = ?', [key]);
+ const row = await get('SELECT * FROM site_settings WHERE key = ?', [key]);
+ if (row) {
+ const secret = await storeSecret(key);
+ if (secret !== undefined) row.value = typeof secret === 'string' ? secret : JSON.stringify(secret);
+ }
+ return row;
}
}
+/** The rows as the admin settings page expects them: secret-class values from the store, never the legacy copy. */
async function getAllSettings() {
async function getAllSettings() {
- return await all('SELECT * FROM site_settings ORDER BY key');
+ const rows = await all('SELECT * FROM site_settings ORDER BY key');
+ const sc = configJournal();
+ if (sc) {
+ for (const r of rows) {
+ if (sc.classify(r.key) !== 'secret') continue;
+ const secret = await sc.secretValue(r.key);
+ if (secret !== undefined) r.value = typeof secret === 'string' ? secret : JSON.stringify(secret);
+ }
+ }
+ return rows;
}
}
async function setSetting(key, value) {
async function setSetting(key, value) {
@@ -1283,6 +1283,9 @@ async function start() {
@@ -1283,6 +1283,9 @@ async function start() {
// 1. Initialize database: migrations as the owner, then the pool (server/db/database.js; the schema is migrations/).
// 1. Initialize database: migrations as the owner, then the pool (server/db/database.js; the schema is migrations/).
await db.initDb();
await db.initDb();
+ // D11: load the configuration journal, reseal old plaintext under the KEK, then blank the legacy secret
+ // copies it holds. Without a KEK the store reports sealed:false and nothing is blanked.
+ await require('./admin/site-config').init();
await analyticsStore.ready();
await analyticsStore.ready();
console.log('[Server] Database ready');
console.log('[Server] Database ready');
await restoreRum(); // the real-user reservoirs, now that the database is open
await restoreRum(); // the real-user reservoirs, now that the database is open
@@ -70,6 +70,7 @@ const ulid = () => ids.ulid();
@@ -70,6 +70,7 @@ const ulid = () => ids.ulid();
const chatTables = ['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];
const chatTables = ['channel_moderators', 'channel_moderation_settings', 'user_tags', 'chat_ai_summaries', 'chat_timeline_events'];
for (const t of chatTables) exempt[t] = 'Chat owns and erases it';
for (const t of chatTables) exempt[t] = 'Chat owns and erases it';
exempt.hidden_relay_users = 'a moderation record of a relay account hidden from a channel, kept for accountability';
exempt.hidden_relay_users = 'a moderation record of a relay account hidden from a channel, kept for accountability';
+ exempt.config_snapshots = 'the configuration journal: created_by/activated_by are actor refs, not an account row';
// Every other table with a person column is handled by the schema-driven export and erase below.
// Every other table with a person column is handled by the schema-driven export and erase below.
const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const handled = [...cols.keys()].filter((t) => !chatTables.includes(t));
const missing = await uncovered(d, { taskKeyed: handled, exempt });
const missing = await uncovered(d, { taskKeyed: handled, exempt });
@@ -399,6 +399,7 @@ const waitFor = async (pred, what, ms = 3000) => {
@@ -399,6 +399,7 @@ const waitFor = async (pred, what, ms = 3000) => {
assert.notStrictEqual(await db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');
assert.notStrictEqual(await db.getSetting('tts_google_api_key'), 'admin-cannot', 'an admin cannot set a credential');
assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
assert.strictEqual((await put(owner, { tts_google_api_key: 'owner-can' })).body.updated, 1);
assert.strictEqual(await db.getSetting('tts_google_api_key'), 'owner-can');
assert.strictEqual(await db.getSetting('tts_google_api_key'), 'owner-can');
+ assert.strictEqual((await db.get("SELECT value FROM site_settings WHERE key = 'tts_google_api_key'")).value, '', 'the credential is in the journal, not site_settings');
// 8. Slow mode and sub-only persistence moved to Chat with channel_moderation_settings (roadmap
// 8. Slow mode and sub-only persistence moved to Chat with channel_moderation_settings (roadmap
// T3); Live now only writes channels.emote_sources for Chat's PUT /api/emotes/sources.
// T3); Live now only writes channels.emote_sources for Chat's PUT /api/emotes/sources.
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+/**
+ * D11: a secret-class value copied into site_settings is blanked at boot once the sealed config store holds it.
+ * With a KEK the store's copy is an envelope; the row is blank, no summary/log carries the value, and every
+ * reader (db.getSetting, the TTS settings) still gets it.
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-kek.test.js
+ */
+const assert = require('assert');
+process.env.NODE_ENV = 'test';
+process.env.OV_CONFIG_KEKS = `k1:${Buffer.alloc(32, 7).toString('base64')}`;
+process.env.OV_CONFIG_KEK_CURRENT = 'k1';
+
+const SECRET = 'sentinel-not-a-secret-tts-key';
+const logs = [];
+const capture = (level) => (...a) => { logs.push(a.map(String).join(' ')); };
+const real = { log: console.log, warn: console.warn, error: console.error };
+console.log = capture('log'); console.warn = capture('warn'); console.error = capture('error');
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ await db.setSetting('tts_google_api_key', SECRET);
+ await db.setSetting('tts_enabled', '1');
+ await db.setSetting('motd', 'hello');
+
+ const siteConfig = require('../server/admin/site-config');
+ const report = await siteConfig.init();
+ const store = await siteConfig.getStore();
+ assert.strictEqual(store.sealed, true, 'the store is sealed under the KEK');
+ assert.strictEqual(report.blanked, 1, 'the legacy secret value was blanked');
+ assert.strictEqual(report.differs, 0, 'an equal value is not counted as differing');
+
+ const row = await db.get('SELECT value, type FROM site_settings WHERE key = ?', ['tts_google_api_key']);
+ assert.ok(row, 'the key keeps its typed slot');
+ assert.strictEqual(row.value, '', 'no secret-class value remains in site_settings');
+ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['motd'])).value, 'hello', 'a non-secret row is untouched');
+
+ const summary = JSON.stringify(await store.summary());
+ const snapshot = JSON.stringify(await store.snapshot(store.revision()));
+ assert.ok(!summary.includes(SECRET) && !snapshot.includes(SECRET), 'no summary or snapshot carries the value');
+ const stored = await db.get("SELECT values_json FROM config_snapshots WHERE namespace = 'live.site_settings' AND state = 'active'");
+ assert.ok(!stored.values_json.includes(SECRET), 'values_json holds no plaintext');
+ assert.ok(stored.values_json.includes('"v1:'), 'the secret is stored as an envelope');
+ assert.strictEqual((await store.summary()).values.tts_google_api_key.redacted, true);
+
+ assert.strictEqual(await db.getSetting('tts_google_api_key'), SECRET, 'db.getSetting reads the store');
+ const tts = await require('../server/chat/tts-engine').getTTSSettings();
+ assert.strictEqual(tts.googleApiKey, SECRET, 'the TTS reader still gets its value');
+ assert.ok(!logs.join('\n').includes(SECRET), 'no log line carries the value');
+
+ // A change through the journal writes the non-secret rows and keeps the secret write-only in the store.
+ await siteConfig.change({ set: { tts_google_api_key: 'sentinel-rotated', motd: 'welcome' } }, { reason: 'rotate' });
+ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_google_api_key'])).value, '', 'a new secret is not written to the legacy row');
+ assert.strictEqual(await db.getSetting('tts_google_api_key'), 'sentinel-rotated', 'the rotated value is read from the store');
+ assert.ok(!JSON.stringify(await store.summary()).includes('sentinel-rotated'));
+
+ // A legacy row newer than the store is kept, not blanked: the store does not hold this value.
+ await siteConfig.change({ set: { tts_aws_secret_access_key: 'store-side-secret' } }, { reason: 'store side' });
+ await db.setSetting('tts_aws_secret_access_key', 'legacy-side-secret');
+ // A secret row the store does not hold at all is kept too.
+ await db.setSetting('tts_aws_access_key_id', 'legacy-only-secret');
+ const kept = await siteConfig.migrateLegacySecrets(store);
+ assert.strictEqual(kept.blanked, 0, 'a differing value is never blanked');
+ assert.strictEqual(kept.differs, 2, 'one unequal value and one the store does not hold');
+ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_aws_secret_access_key'])).value, 'legacy-side-secret', 'the newer legacy value is kept');
+ assert.strictEqual((await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_aws_access_key_id'])).value, 'legacy-only-secret', 'the only copy is kept');
+ assert.strictEqual(await db.getSetting('tts_aws_secret_access_key'), 'store-side-secret', 'the reader keeps the store value');
+ assert.ok(!logs.join('\n').includes('store-side-secret') && !logs.join('\n').includes('legacy-side-secret') && !logs.join('\n').includes('legacy-only-secret'), 'no log line carries either side');
+
+ real.log('legacy secrets (KEK): all checks passed');
+ process.exit(0);
+})().catch((err) => { real.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+'use strict';
+/**
+ * D11: without a KEK the config store is not sealed and NOTHING in the legacy table is blanked. The reader still
+ * gets the value (the store holds it, as today), so a service with no KEK keeps working unchanged.
+ *
+ * node --import ./test/helpers/pg-preload.mjs test/legacy-secrets-nokek.test.js
+ */
+const assert = require('assert');
+process.env.NODE_ENV = 'test';
+delete process.env.OV_CONFIG_KEKS;
+delete process.env.OV_CONFIG_KEK_CURRENT;
+
+const SECRET = 'sentinel-not-a-secret-no-kek';
+const real = { log: console.log, error: console.error };
+console.log = () => {}; console.warn = () => {}; console.error = () => {};
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ await db.setSetting('tts_google_api_key', SECRET);
+
+ const siteConfig = require('../server/admin/site-config');
+ const report = await siteConfig.init();
+ const store = await siteConfig.getStore();
+ assert.strictEqual(store.sealed, false, 'the store is not sealed without a KEK');
+ assert.strictEqual(report.blanked, 0, 'nothing is blanked without a sealed store');
+
+ const row = await db.get('SELECT value FROM site_settings WHERE key = ?', ['tts_google_api_key']);
+ assert.strictEqual(row.value, SECRET, 'the only copy is still the legacy row');
+ assert.strictEqual(await db.getSetting('tts_google_api_key'), SECRET, 'the reader still gets its value');
+ const summary = JSON.stringify(await store.summary());
+ assert.ok(!summary.includes(SECRET), 'the summary redacts it even unsealed');
+ await assert.rejects(store.reseal(), /KEK/, 'reseal needs a KEK');
+
+ real.log('legacy secrets (no KEK): all checks passed');
+ process.exit(0);
+})().catch((err) => { real.error(err); process.exit(1); });
@@ -32,10 +32,8 @@ permissions.requireAdmin = (req, res, next) => (req.user && ['admin'].includes(r
@@ -32,10 +32,8 @@ permissions.requireAdmin = (req, res, next) => (req.user && ['admin'].includes(r
async function main() {
async function main() {
await db.initDb();
await db.initDb();
-// migrations/0002_live.sql does not create openvibe-shared/config's tables (config_snapshots/config_keys,
-// their configSchema()); the service migrations are meant to. Owner-only DDL stands them up here until the
-// migration includes configSchema() (Network's migrations/0006_config.sql is the pattern).
-await globalThis.__ovLiveDdl(require('openvibe-shared/config-pg').configSchema());
+// migrations/0025_config.sql creates openvibe-shared/config's tables (config_snapshots/config_keys, its
+// configSchema()): the service's migrations own its tables (Network's migrations/0006_config.sql is the pattern).
const raw = db.getDb();
const raw = db.getDb();
const add = async (id, username, role) => await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
const add = async (id, username, role) => await raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at) OVERRIDING SYSTEM VALUE VALUES (?, ?, ?, ?, 'x', ?, '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`, role);
await add(1, 'owner', 'admin');
await add(1, 'owner', 'admin');