openvibers/openvibe.live Commits

D11: secrets leave Live's site_settings for the sealed config store (#607)

OP OpenVibers committed 8 days ago · d1a9a0cc23 · 14 files, +289 −34

* D11: secrets leave Live's site_settings for the sealed config store

* D11: blank a legacy secret only when the store holds that exact value

Changes

ddiff --git a/README.md b/README.md
iindex ecfd68c2..866ac7a8 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex b59da601..9f6eaa2c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0025_config.sql b/migrations/0025_config.sql
nnew file mode 100644
iindex 00000000..cdfa947d
---- /dev/null
++++ b/migrations/0025_config.sql
@@@ -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
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex e33ab21f..07b83936 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 65bc023c..94a84c12 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/server/admin/site-config.js b/server/admin/site-config.js
iindex 2a3a6b55..a23d6898 100644
---- a/server/admin/site-config.js
++++ b/server/admin/site-config.js
@@@ -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; } };
ddiff --git a/server/chat/live-context-routes.js b/server/chat/live-context-routes.js
iindex e6727c21..b9d6fd69 100644
---- a/server/chat/live-context-routes.js
++++ b/server/chat/live-context-routes.js
@@@ -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.
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 4edbb4ae..990a28cf 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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) {
ddiff --git a/server/index.js b/server/index.js
iindex a59f2200..f0cb429c 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 64d7a030..60366261 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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 });
ddiff --git a/test/chat-context.test.js b/test/chat-context.test.js
iindex 1a7b43b7..8e44dd45 100644
---- a/test/chat-context.test.js
++++ b/test/chat-context.test.js
@@@ -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.
ddiff --git a/test/legacy-secrets-kek.test.js b/test/legacy-secrets-kek.test.js
nnew file mode 100644
iindex 00000000..73fb3dc2
---- /dev/null
++++ b/test/legacy-secrets-kek.test.js
@@@ -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); });
ddiff --git a/test/legacy-secrets-nokek.test.js b/test/legacy-secrets-nokek.test.js
nnew file mode 100644
iindex 00000000..96911809
---- /dev/null
++++ b/test/legacy-secrets-nokek.test.js
@@@ -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); });
ddiff --git a/test/site-config.test.js b/test/site-config.test.js
iindex e77cf3b9..597f8771 100644
---- a/test/site-config.test.js
++++ b/test/site-config.test.js
@@@ -32,10 +32,8 @@ permissions.requireAdmin = (req, res, next) => (req.user && ['admin'].includes(r
async 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');