openvibers/openvibe.network Commits

Network: provider secrets are environment-only (no database fallback, no admin path stores one); one-time scripts deleted (#118)

OP OpenVibers committed 10 Oct 2026 · 95312465cd · 14 files, +109 −612

Roadmap §18.2(12) ("secrets are environment references, never plaintext provider keys in a database") and plan T2's one-time scripts.

**Provider secrets are environment-only now.** Production check on 2026-10-10: Network's boot line is `resend_api_key=env resend_webhook_secret=unset discord_bot_token=unset discord_oauth_client_secret=unset vapid_private_key=env github_token=unset`. The copy-out script's dry run found no database copy of any secret. So nothing changes at runtime, and the fallback that could still read or store a database copy goes:
- `db.getSetting()` returns a secret from its variable only, never from `site_settings`. `secrets.source()` is `env` or `unset` for secrets. The VAPID public key companion keeps its environment-first database fallback.
- No admin path stores a secret:
  - `PUT /api/admin/settings` answers 400 `settings.secret_env_only` with the variable to set;
  - Discord's settings and the email key skip them;
  - GitHub's `PUT`/`DELETE` answer 409 `integration.secret_env_only`.
- The admin UI shows each secret as "set in the environment (VAR)" or "not set: add VAR to /etc/openvibe/network.env and restart Network". There's no input or save/remove button for them. The GitHub test button stays.
- `scripts/secrets-out-of-db.js` (the one-time copy-out) is deleted, and the README states the rule.

**Deleted:** `scripts/import-blocks.js`, the one-time import of Chat's `dm_blocks` (WS-E, done), and its test section.

**Tests:** `provider-secrets.test.js` and `github-integration.test.js` are rewritten for environment-only behaviour: a database row is never used or shown, nothing is stored, and Blog still reads the token from the environment. `site-config.test.js` now expects the Discord token to be skipped. `ov test`: green (78 files reached).

Changes

ddiff --git a/README.md b/README.md
iindex cdef939d..1fb2c097 100644
---- a/README.md
++++ b/README.md
@@@ -399,24 +399,9 @@ secret as "set in the environment" and never saves one into the database. `GET /
`net.ipinfo_token`, `net.globalping_token` and the old `ses_*` keys are never read by Network (the Tools
gateway reads `NET_IPINFO_TOKEN` / `NET_GLOBALPING_TOKEN` from its own env file).
--Moving them out of the database (operator):
--
--```bash
--cd /opt/openvibe.network
--sudo node scripts/secrets-out-of-db.js # dry run: names, env file and running service, per secret
--sudo node scripts/secrets-out-of-db.js --copy-to-env # dry run of the copy into /etc/openvibe/network.env
--sudo node scripts/secrets-out-of-db.js --copy-to-env --apply # appends VAR=value lines (backup: network.env.bak-<time>)
--sudo systemctl restart openvibe-network # the boot line shows <key>=env
--sudo node scripts/secrets-out-of-db.js --apply --backup data/network-pre-secrets-$(date +%F).db
--# rollback: sudo node scripts/secrets-out-of-db.js --restore-from data/network-pre-secrets-<date>.db --apply
--```
--
--`--apply` blanks a database copy only when the env file sets the variable to the same value (a
--different one only with `--allow-different`) and the running service already has it, plus the secrets
--Network never reads; everything else is kept and the dry run says why. The script reads the env file
--and the service's environment as root, then becomes the database owner. It never prints a value:
--`--copy-to-env` reads the database in a child process running as its owner and hands the values to the
--env file through a pipe (a value that would need quoting is left for the operator to add by hand).
++Provider secrets are read from the environment only (`/etc/openvibe/network.env`): a `site_settings` row of the same
++key is ignored and no admin form stores one. The database fallback and its copy-out script were retired on 2026-10-10,
++once production held no database copy of any secret.
---
@@@ -476,8 +461,6 @@ Contracts 0.49.0; `server/identity/blocks.js`). `user_blocks` is keyed by subjec
(DMs, mentions) and Community (replies) keep projections from their own Events subscriptions.
- Network's notifications: nothing is created from a person the recipient blocked (`sender_id` as a Network
id, or `actor_subject`); moderation, system and admin notices always are.
--- Import (once, from Chat's `dm_blocks` export): `node scripts/import-blocks.js --file <json>` (dry run),
-- then `--apply`. Pairs Network already knows are left alone.
---
ddiff --git a/public/js/admin/content.js b/public/js/admin/content.js
iindex b44ad80f..4b75fd11 100644
---- a/public/js/admin/content.js
++++ b/public/js/admin/content.js
@@@ -389,11 +389,8 @@ async function loadEmail() {
${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
<h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>
-- ${email.api_key_source === 'env'
-- ? `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})" disabled autocomplete="off"></div>
-- <p class="muted" style="margin:-4px 0 8px;font-size:11px">The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file and cannot be changed here.</p>`
-- : `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="${esc(email.api_key || '')}" placeholder="re_xxxxxxxxxxxx" autocomplete="off"></div>
-- <p class="muted" style="margin:-4px 0 8px;font-size:11px">Get your API key from <a href="https://resend.com/api-keys" target="_blank" style="color:var(--accent)">resend.com/api-keys</a>. Key is masked after saving.${email.api_key_source === 'database' ? ` Stored in the database; prefer <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file.` : ''}</p>`}
++ <div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="${email.api_key_source === 'env' ? `Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})` : 'Not set'}" disabled autocomplete="off"></div>
++ <p class="muted" style="margin:-4px 0 8px;font-size:11px">${email.api_key_source === 'env' ? `The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file; change it there.` : `Provider secrets live in the server's environment only: get a key from <a href="https://resend.com/api-keys" target="_blank" rel="noopener" style="color:var(--accent)">resend.com/api-keys</a>, set <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
<h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>
<div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>
ddiff --git a/public/js/admin/settings.js b/public/js/admin/settings.js
iindex c88d0d7d..a796e711 100644
---- a/public/js/admin/settings.js
++++ b/public/js/admin/settings.js
@@@ -363,10 +363,11 @@ async function saveStreamerSettings() {
// ═══════════════════════════════════════════════════════════════
// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
function envSecretInput(sources, key, value, placeholder, style) {
++ // Provider secrets are environment-only (server/secrets.js): the field shows where it comes from and never edits it.
const src = (sources || {})[key] || {};
-- if (src.source === 'env') return `<input type="password" data-key="${esc(key)}" value="" placeholder="Set in the environment (${esc(src.env || '')})" disabled style="${style}">`;
-- return `<input type="password" data-key="${esc(key)}" value="${esc(value || '')}" placeholder="${esc(placeholder)}" style="${style}">`
-- + (src.source === 'database' && src.env ? `<div class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(src.env)}</code> in the server's environment file.</div>` : '');
++ const env = esc(src.env || '');
++ const label = src.source === 'env' ? `Set in the environment (${env})` : `Not set: add ${env} to /etc/openvibe/network.env and restart Network`;
++ return `<input type="password" data-key="${esc(key)}" value="" placeholder="${label}" disabled style="${style}">`;
}
async function loadDiscordSettings() {
@@@ -441,36 +442,20 @@ async function loadGithubSettings() {
c.innerHTML = '<p class="muted">Loading…</p>';
try {
const g = (await api('/api/admin/integrations/github')).github || {};
-- const where = g.source === 'env' ? `set in the environment (<code>${esc(g.env)}</code>)` : g.source === 'database' ? 'saved here' : 'not set';
-- const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (${where})</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;
++ const env = esc(g.env || 'GITHUB_TOKEN');
++ const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (set in the environment)</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;
c.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">
<h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}
</div>
-- <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}. Create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions. The token is never shown again after saving.</p>
-- ${g.source === 'env' ? `<p class="muted">It comes from <code>${esc(g.env)}</code> in the server's environment file; change it there.</p>` : `
-- <div class="setting-row"><label for="github-token-input">Token</label>
-- <input type="password" id="github-token-input" autocomplete="off" placeholder="${g.set ? 'Replace the saved token' : 'github_pat_… or ghp_…'}" style="width:100%;max-width:520px"></div>`}
++ <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}.</p>
++ <p class="muted" style="max-width:720px">Provider secrets live in the server's environment only, never in the database. ${g.set ? `This one comes from <code>${env}</code>; change it there.` : `To add one, create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions, set <code>${env}</code> in <code>/etc/openvibe/network.env</code>, and restart Network.`}</p>
<div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
-- ${g.source === 'env' ? '' : '<button type="button" class="btn btn-primary" onclick="saveGithubToken()"><i class="fa-solid fa-save"></i> Save token</button>'}
<button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>
-- ${g.source === 'database' ? '<button type="button" class="btn btn-outline" onclick="clearGithubToken()"><i class="fa-solid fa-trash"></i> Remove</button>' : ''}
</div>
<div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
} catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
}
--async function saveGithubToken() {
-- const el = document.getElementById('github-token-input');
-- const token = (el && el.value || '').trim();
-- if (!token) return toast('Paste a token first', 'error');
-- try { await api('/api/admin/integrations/github', { method: 'PUT', body: { token } }); el.value = ''; toast('GitHub token saved'); await loadGithubSettings(); testGithubToken(); }
-- catch (e) { toast(e.message, 'error'); }
--}
--async function clearGithubToken() {
-- if (!confirm('Remove the saved GitHub token? GitHub calls go back to the shared anonymous limit.')) return;
-- try { await api('/api/admin/integrations/github', { method: 'DELETE' }); toast('GitHub token removed'); loadGithubSettings(); }
-- catch (e) { toast(e.message, 'error'); }
--}
async function testGithubToken() {
const out = document.getElementById('github-test-result');
if (out) out.textContent = 'Asking GitHub…';
ddiff --git a/scripts/import-blocks.js b/scripts/import-blocks.js
ddeleted file mode 100644
iindex b4dc861c..00000000
---- a/scripts/import-blocks.js
++++ /dev/null
@@@ -1,107 +0,0 @@
--#!/usr/bin/env node
--/**
-- * Import blocks made elsewhere into platform blocks (roadmap WS-E task 5). The input is the JSON file
-- * OpenVibe.Chat's scripts/migrate-dm-blocks-to-network.js writes from its dm_blocks table:
-- *
-- * { "pairs": [{ "blocker_subject": "usr_…", "blocked_subject": "usr_…" }, …] } (a bare array works too)
-- *
-- * node scripts/import-blocks.js --file <json> # dry run: what would be imported, nothing changes
-- * node scripts/import-blocks.js --file <json> --apply # insert the blocks and queue their events
-- *
-- * The database is the service's own PostgreSQL (DATABASE_URL, plan T2); run this where Network runs, or
-- * with DATABASE_DIRECT_URL/DATABASE_URL pointing at a copy. A pair is imported only when Network has never
-- * seen it: an existing row (blocked, or unblocked since) is the person's later decision and is left alone.
-- * Pairs naming an unknown account, a guest or the same person twice are skipped and counted. Each import is
-- * a normal block (server/identity/blocks.js setBlock): revision 1 and network.block.changed in
-- * network_event_outbox in the same transaction; the running server relays those rows within its next poll.
-- * Safe to run twice (the second run imports nothing). Undo a pair with DELETE /api/v1/me/blocks/:subject
-- * as the person.
-- */
--'use strict';
--const fs = require('fs');
--const path = require('path');
--const { initDb } = require('../server/db/database');
--const blocks = require('../server/identity/blocks');
--
--const USAGE = 'usage: node scripts/import-blocks.js --file <json> [--apply]';
--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--
--/** --flag / --key value parsing (this script stays standalone). */
--function parseArgs(argv, { flags = [], values = [] } = {}) {
-- const out = { _: [] };
-- for (let i = 0; i < argv.length; i++) {
-- const a = argv[i];
-- const name = a.replace(/^--/, '');
-- if (flags.includes(name)) out[name] = true;
-- else if (values.includes(name)) {
-- const v = argv[++i];
-- if (v === undefined || v.startsWith('--')) throw new Error(`${a} needs a value`);
-- out[name] = v;
-- } else if (a === '--help' || a === '-h') out.help = true;
-- else throw new Error(`unknown argument ${a}`);
-- }
-- return out;
--}
--
--function readPairs(file) {
-- const data = JSON.parse(fs.readFileSync(file, 'utf8'));
-- const list = Array.isArray(data) ? data : data && Array.isArray(data.pairs) ? data.pairs : null;
-- if (!list) throw new Error('the file must hold { pairs: [...] } or an array of { blocker_subject, blocked_subject }');
-- return list;
--}
--
--/** Sort each pair into import / skip. → { todo: [{ blocker, blocked }], skipped: { reason: n } } */
--async function plan(db, pairs) {
-- const person = db.prepare('SELECT is_anon FROM users WHERE subject_id = ?');
-- const seen = db.prepare('SELECT 1 FROM user_blocks WHERE blocker_subject = ? AND blocked_subject = ?');
-- const todo = [];
-- const skipped = {};
-- const skip = (why) => { skipped[why] = (skipped[why] || 0) + 1; };
-- const dup = new Set();
-- for (const p of pairs) {
-- const blocker = p && String(p.blocker_subject || '');
-- const blocked = p && String(p.blocked_subject || '');
-- if (!SUBJECT_RE.test(blocker) || !SUBJECT_RE.test(blocked)) { skip('not-a-subject'); continue; }
-- if (blocker === blocked) { skip('self'); continue; }
-- const key = `${blocker}>${blocked}`;
-- if (dup.has(key)) { skip('duplicate-in-file'); continue; }
-- dup.add(key);
-- const a = await person.get(blocker);
-- const b = await person.get(blocked);
-- if (!a || !b) { skip('unknown-account'); continue; }
-- if (a.is_anon || b.is_anon) { skip('guest'); continue; }
-- if (await seen.get(blocker, blocked)) { skip('already-on-network'); continue; }
-- todo.push({ blocker, blocked });
-- }
-- return { todo, skipped };
--}
--
--async function main(argv, log = console.log) {
-- let args;
-- try { args = parseArgs(argv, { flags: ['apply'], values: ['file'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
-- if (args.help) { log(USAGE); return 0; }
-- if (!args.file) { log(USAGE); return 2; }
-- let pairs;
-- try { pairs = readPairs(path.resolve(process.cwd(), args.file)); } catch (e) { log(`error: ${e.message}`); return 2; }
-- const db = await initDb();
-- await blocks.ensureSchema(db);
-- const { todo, skipped } = await plan(db, pairs);
-- const why = Object.entries(skipped).map(([k, n]) => `${k} ${n}`).join(', ') || 'none';
-- log(`database ${db.store}\npairs ${pairs.length}; to import ${todo.length}; skipped: ${why}`);
-- if (!args.apply) { log(todo.length ? 'dry run: nothing changed. Re-run with --apply to import.' : 'nothing to import.'); return 0; }
-- let imported = 0;
-- let refused = 0;
-- for (const p of todo) {
-- try { if ((await blocks.setBlock(db, p.blocker, p.blocked, true)).changed) imported++; } catch (e) {
-- if (!(e instanceof blocks.BlockError)) throw e;
-- refused++;
-- log(`refused ${p.blocker} -> ${p.blocked}: ${e.message}`);
-- }
-- }
-- log(`imported ${imported} block(s); ${imported} network.block.changed event(s) queued in network_event_outbox${refused ? `; refused ${refused}` : ''}`);
-- return refused ? 1 : 0;
--}
--
--if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
--
--module.exports = { main, plan, readPairs };
ddiff --git a/scripts/secrets-out-of-db.js b/scripts/secrets-out-of-db.js
ddeleted file mode 100644
iindex dde6ffa4..00000000
---- a/scripts/secrets-out-of-db.js
++++ /dev/null
@@@ -1,238 +0,0 @@
--#!/usr/bin/env node
--/**
-- * Provider secrets out of site_settings (roadmap §18.2(12); server/secrets.js). Network reads each secret
-- * from its environment variable first and from site_settings only as a fallback; this script is how the
-- * operator empties the database copies once the environment has them. It prints setting and variable
-- * NAMES, never a value (values are compared by SHA-256 only).
-- *
-- * node scripts/secrets-out-of-db.js
-- * dry run: every secret setting by name, whether the database holds a value, its variable, whether
-- * the env file sets it (and to the same value), and whether the running service has it
-- * node scripts/secrets-out-of-db.js --copy-to-env [--apply]
-- * for each secret the database holds and the env file does not set, append VAR=value to the env
-- * file (after copying it to <env file>.bak-<time>, same mode), so the values move without anyone
-- * seeing them: the values go to the file through this process, never to the terminal. Dry run
-- * without --apply. Restart the service after.
-- * node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
-- * writes the secret settings to a new owner-only (0600) JSON backup, then blanks the database copy of
-- * - each secret whose variable the env file sets to the same value (a different value only with
-- * --allow-different) AND the running service already has (restart it after editing the file)
-- * - each secret setting Network never reads (Net tools tokens, old SES keys)
-- * and leaves every other one as it is, saying why
-- * node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]
-- * rollback: puts the backed-up value back into each secret setting that is blank now
-- *
-- * On PostgreSQL (plan T2, ADR-035) the settings live in the service database: the script reads and writes
-- * them through DATABASE_URL, exactly as the service does. --backup writes a JSON file (0600)
-- * holding the values being blanked.
-- *
-- * --env-file <path> default /etc/openvibe/network.env
-- * --unit <name> systemd unit whose running process must already have the variables
-- * (default openvibe-network); --no-service-check skips that check
-- *
-- * Run with sudo: the env file and the service's environment are root-only.
-- */
--'use strict';
--const fs = require('fs');
--const path = require('path');
--const crypto = require('crypto');
--const { execFileSync } = require('child_process');
--const { parseArgs } = require('./lib/db-ops');
--const secrets = require('../server/secrets');
--const { isSensitiveSettingKey } = require('../server/auth/owner-guard');
--
--const USAGE = `usage: sudo node scripts/secrets-out-of-db.js [--env-file <path>] [--unit <name> | --no-service-check]
-- sudo node scripts/secrets-out-of-db.js --copy-to-env [--apply]
-- sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
-- sudo node scripts/secrets-out-of-db.js --restore-from <backup> [--apply]`;
--
--// Settings that look sensitive by name but are not secrets.
--const NOT_SECRET = new Set(['discord_oauth_client_id', 'vapid_public_key', 'vapid_contact_email']);
--
--const digest = (v) => crypto.createHash('sha256').update(String(v)).digest('hex');
--
--/** Open the service database: the injected handle (tests), else DATABASE_URL as the service uses it. */
--async function openDb(injected) {
-- if (injected) return injected;
-- if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set: the settings live in the service database');
-- const { createDb } = require('openvibe-sdk/db');
-- return createDb({ url: process.env.DATABASE_URL, service: 'network-secrets', max: 1 });
--}
--
--function parseEnvText(text) {
-- const util = require('util');
-- if (typeof util.parseEnv === 'function') return util.parseEnv(text);
-- const out = {};
-- for (const line of String(text).split(/\r?\n/)) {
-- const m = line.match(/^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)?\s*$/);
-- if (!m) continue;
-- let v = (m[2] || '').trim();
-- if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
-- out[m[1]] = v;
-- }
-- return out;
--}
--
--function readEnvFile(file) {
-- try { return { vars: parseEnvText(fs.readFileSync(file, 'utf8')) }; } catch (err) { return { error: err.code || err.message }; }
--}
--
--/** The running unit's environment (root only): { vars } or { error }. */
--function serviceEnv(unit) {
-- let pid;
-- try { pid = execFileSync('systemctl', ['show', '-p', 'MainPID', '--value', unit], { stdio: ['ignore', 'pipe', 'ignore'], timeout: 5000 }).toString().trim(); } catch { return { error: 'systemctl unavailable' }; }
-- if (!pid || pid === '0') return { error: `${unit} is not running` };
-- try {
-- const vars = {};
-- for (const kv of fs.readFileSync(`/proc/${pid}/environ`).toString('utf8').split('\0')) { const i = kv.indexOf('='); if (i > 0) vars[kv.slice(0, i)] = kv.slice(i + 1); }
-- return { vars, pid };
-- } catch (err) { return { error: `cannot read the environment of pid ${pid} (${err.code || err.message}); run with sudo` }; }
--}
--
--/** Every secret-looking setting, classified. */
--async function classify(db) {
-- const rows = await db.prepare('SELECT key, value, type FROM site_settings').all();
-- const byKey = new Map(rows.map(r => [r.key, r]));
-- const out = [];
-- for (const s of secrets.SECRETS) out.push({ key: s.key, kind: 'provider', env: s.env, value: (byKey.get(s.key) || {}).value || '' });
-- for (const [key, note] of secrets.UNUSED) if (byKey.has(key)) out.push({ key, kind: 'unused', note, value: byKey.get(key).value || '' });
-- for (const r of rows) {
-- if (out.some(o => o.key === r.key) || NOT_SECRET.has(r.key) || secrets.isManaged(r.key)) continue;
-- if (r.type === 'secret' || isSensitiveSettingKey(r.key)) out.push({ key: r.key, kind: 'unclassified', value: r.value || '' });
-- }
-- return out;
--}
--
--// A value systemd's EnvironmentFile and util.parseEnv read back unchanged without quoting.
--const PLAIN_VALUE = /^[A-Za-z0-9._\-+/=:~]+$/;
--
--/** The database's provider values: { ENV_NAME: value }. */
--async function readValues(db) {
-- const get = db.prepare('SELECT value FROM site_settings WHERE key = ?');
-- const out = {};
-- for (const s of secrets.SECRETS) { const r = await get.get(s.key); if (r && r.value) out[s.env] = String(r.value); }
-- return out;
--}
--
--async function copyToEnv(db, envFile, envf, apply, log, now = new Date()) {
-- if (envf.error) { log(`error: the env file ${envFile} is unreadable (${envf.error}); run with sudo`); return 2; }
-- const values = await readValues(db);
-- const add = [];
-- for (const s of secrets.SECRETS) {
-- const v = values[s.env];
-- if (!v) { log(` ${s.env}: the database holds no ${s.key}; nothing to copy`); continue; }
-- const have = envf.vars[s.env] && String(envf.vars[s.env]).trim();
-- if (have) { log(` ${s.env}: already in the env file (${digest(have) === digest(v) ? 'same value' : 'DIFFERENT value, left as it is'})`); continue; }
-- if (!PLAIN_VALUE.test(v)) { log(` ${s.env}: the value has characters that would need quoting; add it by hand`); continue; }
-- add.push([s.env, v]);
-- log(` ${s.env}: ${apply ? 'appended' : 'would be appended'} (from ${s.key})`);
-- }
-- if (!apply) { log(`dry run: ${add.length} variable(s) to append. Re-run with --copy-to-env --apply.`); return 0; }
-- if (!add.length) { log('nothing to append.'); return 0; }
-- const stamp = now.toISOString().replace(/[:.]/g, '-');
-- const bak = `${envFile}.bak-${stamp}`;
-- fs.copyFileSync(envFile, bak, fs.constants.COPYFILE_EXCL);
-- fs.chmodSync(bak, fs.statSync(envFile).mode & 0o777);
-- const text = fs.readFileSync(envFile, 'utf8');
-- fs.appendFileSync(envFile, `${text.endsWith('\n') || !text ? '' : '\n'}# Provider secrets moved from site_settings (scripts/secrets-out-of-db.js, ${now.toISOString()})\n${add.map(([k, v]) => `${k}=${v}`).join('\n')}\n`);
-- log(`env file ${envFile}: appended ${add.map(([k]) => k).join(', ')} (previous version: ${bak}). Restart openvibe-network, then run --apply --backup <new file>.`);
-- return 0;
--}
--
--/** Write the site_settings rows to a NEW owner-only JSON backup; refuses an existing target. */
--function writeBackup(target, rows, log) {
-- const file = path.resolve(target);
-- if (fs.existsSync(file)) throw new Error(`backup target ${file} already exists; choose a new path`);
-- const umask = process.umask(0o077);
-- try { fs.writeFileSync(file, JSON.stringify({ taken_at: new Date().toISOString(), settings: rows }, null, 2)); } finally { process.umask(umask); }
-- fs.chmodSync(file, 0o600);
-- log(`backup ${file} (0600). It holds the site_settings rows (owner-only): delete it once the rollback window is over.`);
-- return file;
--}
--
--async function main(argv, log = console.log, { readService = serviceEnv, db: injected } = {}) {
-- let args;
-- try { args = parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check', 'copy-to-env'], values: ['backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
-- if (args.help) { log(USAGE); return 0; }
-- const envFile = args['env-file'] || '/etc/openvibe/network.env';
--
-- let db;
-- try { db = await openDb(injected); } catch (e) { log(`error: ${e.message}`); return 2; }
-- try {
-- if (args['copy-to-env']) {
-- if (args.backup || args['restore-from']) { log('--copy-to-env takes only --apply and --env-file'); return 2; }
-- log(`env file ${envFile}`);
-- return await copyToEnv(db, envFile, readEnvFile(envFile), !!args.apply, log);
-- }
--
-- const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
-- const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');
--
-- log('database DATABASE_URL');
-- if (args['restore-from']) {
-- const backup = JSON.parse(fs.readFileSync(path.resolve(args['restore-from']), 'utf8'));
-- const saved = new Map((backup.settings || []).map(r => [r.key, r.value]));
-- const todo = (await classify(db)).filter(c => c.kind !== 'unclassified' && !c.value && saved.get(c.key));
-- for (const c of todo) log(` restore ${c.key}`);
-- log(`${todo.length} setting(s) to restore from ${args['restore-from']}`);
-- if (!args.apply) { log('dry run: nothing changed. Re-run with --apply.'); return 0; }
-- await db.tx(async (t) => {
-- let n = 0;
-- for (const c of todo) { n += (await t.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')").run(saved.get(c.key), c.key)).changes; }
-- log(`restored ${n} setting(s). Restart openvibe-network if it should read them again (the environment still wins where set).`);
-- });
-- return 0;
-- }
--
-- log(`env file ${envFile}${envf.error ? ` (unreadable: ${envf.error}; run with sudo)` : ''}`);
-- if (svc) log(`service ${svc.error ? `unknown (${svc.error})` : `pid ${svc.pid}`}`);
-- const plan = [];
-- for (const c of await classify(db)) {
-- const row = { key: c.key, kind: c.kind, db: c.value ? 'value' : 'empty' };
-- if (c.kind === 'provider') {
-- row.env = c.env;
-- const fv = envf.vars ? envf.vars[c.env] : undefined;
-- row.envFile = envf.error ? 'unknown' : fv ? (c.value ? (digest(fv) === digest(c.value) ? 'set, same value' : 'set, DIFFERENT value') : 'set') : 'not set';
-- const sv = svc && svc.vars ? svc.vars[c.env] : undefined;
-- row.service = !svc ? 'not checked' : svc.error ? 'unknown' : sv ? (fv && digest(sv) === digest(fv) ? 'has it' : 'has a different value (restart it)') : 'not set (restart it)';
-- if (!c.value) row.action = 'nothing (no database copy)';
-- else if (envf.error || !fv) row.action = `keep: put ${c.env} in the env file first`;
-- else if (row.envFile.includes('DIFFERENT') && !args['allow-different']) row.action = 'keep: the env file value differs (--allow-different blanks it anyway)';
-- else if (svc && row.service !== 'has it') row.action = `keep: the running service does not have this ${c.env} yet (restart openvibe-network)`;
-- else row.action = 'BLANK';
-- } else if (c.kind === 'unused') {
-- row.note = c.note;
-- row.action = c.value ? 'BLANK (Network never reads it; the backup keeps it)' : 'nothing (empty)';
-- } else {
-- row.action = 'keep: not classified by server/secrets.js; review it';
-- }
-- plan.push(row);
-- }
-- for (const p of plan) {
-- log(` ${p.key}`);
-- log(` database: ${p.db}${p.env ? ` · variable ${p.env} · env file: ${p.envFile} · service: ${p.service}` : ''}${p.note ? ` · ${p.note}` : ''}`);
-- log(` --apply: ${p.action}`);
-- }
-- const blank = plan.filter(p => p.action.startsWith('BLANK'));
-- log(`${blank.length} setting(s) would be blanked; ${plan.filter(p => p.action.startsWith('keep')).length} kept.`);
-- if (!args.apply) { log('dry run: nothing changed. Re-run with --apply --backup <new file>.'); return 0; }
-- if (!args.backup) { log('refusing --apply without --backup <new file> (owner-only JSON, 0600)'); return 2; }
-- if (envf.error) { log(`refusing --apply: the env file ${envFile} is unreadable`); return 2; }
-- if (!blank.length) { log('nothing to blank; no backup taken.'); return 0; }
-- const rows = await db.prepare('SELECT key, value FROM site_settings').all();
-- writeBackup(args.backup, rows, log);
-- await db.tx(async (t) => {
-- const up = t.prepare("UPDATE site_settings SET value = '' WHERE key = ?");
-- let n = 0;
-- for (const p of blank) n += (await up.run(p.key)).changes;
-- log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);
-- });
-- return 0;
-- } finally { if (!injected) await db.close().catch(() => {}); }
--}
--
--if (require.main === module) {
-- main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
--}
--
--module.exports = { main, classify, parseEnvText };
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 1fbdf82f..bd2cac66 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -250,12 +250,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const collect = async () => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
-- // Only update API key if it's not a masked placeholder, and never while RESEND_API_KEY
-- // provides it (server/secrets.js): the environment is the source then.
-- if (api_key && !/\u2022/.test(api_key)) {
-- if (await secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');
-- else setSetting.run('resend_api_key', api_key, 'string');
-- }
++ // The Resend API key is environment-only (RESEND_API_KEY, server/secrets.js): never stored here.
++ if (api_key && !/\u2022/.test(api_key)) skipped.push('resend_api_key');
if (from_email) setSetting.run('email_from_address', from_email, 'string');
if (from_name !== undefined) setSetting.run('email_from_name', from_name || 'OpenVibe', 'string');
// Per-service from addresses
@@@ -319,8 +315,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const owner = isOwner(req.user);
for (const r of rows) {
if (EMAIL_MANAGED_KEYS.has(r.key)) continue;
-- // Provider secrets set in the environment (server/secrets.js): the database copy is not
-- // used, and the value is never shown; the UI says which variable provides it.
++ // Provider secrets are environment-only (server/secrets.js): a database row is never used or
++ // shown; the UI says which variable provides it, or that it is not set.
++ if (secrets.isSecret(r.key)) {
++ settings[r.key] = { value: '', type: r.type, source: await secrets.source(db, r.key), env: secrets.envName(r.key), redacted: true, secret: true };
++ continue;
++ }
if (secrets.isManaged(r.key)) {
const src = await secrets.source(db, r.key);
if (src === 'env') { settings[r.key] = { value: '', type: r.type, source: 'env', env: secrets.envName(r.key), redacted: true }; continue; }
@@@ -353,7 +353,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (typeof value === 'string' && /^••••/.test(value)) {
return res.json({ ok: true, skipped: true });
}
-- // A provider secret the environment provides is never saved into the database.
++ // A provider secret is environment-only: never saved into the database.
++ if (secrets.isSecret(key)) {
++ return res.status(400).json({ ok: false, code: 'settings.secret_env_only', env: secrets.envName(key), error: `${key} is read from the environment only: set ${secrets.envName(key)} in /etc/openvibe/network.env and restart Network` });
++ }
++ // A companion the environment provides is not saved either.
if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') {
return res.json({ ok: true, skipped: true, source: 'env', env: secrets.envName(key), note: `set in the environment (${secrets.envName(key)}); not saved to the database` });
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex df4645c7..9d202056 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -318,10 +318,11 @@ function attachHelpers(db) {
// shape (a runner the caller invokes, possibly more than once, e.g. a dry-run path).
db.txFn = (fn) => async (...args) => await db.tx(() => fn(...args));
-- // Provider secrets come from their environment variable when it is set.
++ // Provider secrets come from their environment variable only; companions environment-first.
db.getSetting = async function (key) {
const fromEnv = secrets.fromEnv(key);
if (fromEnv !== null) return fromEnv;
++ if (secrets.isSecret(key)) return null;
const row = await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
if (!row) return null;
if (row.type === 'boolean') return row.value === 'true';
ddiff --git a/server/discord/routes.js b/server/discord/routes.js
iindex b7c5f5dd..9c0b80f9 100644
---- a/server/discord/routes.js
++++ b/server/discord/routes.js
@@@ -53,8 +53,8 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
const skipped = [];
for (const [key, value] of Object.entries(settings)) {
if (!allowedKeys.includes(key)) continue;
-- // Never save a secret into the database while its environment variable provides it.
-- if (secrets.isManaged(key) && await secrets.source(db, key) === 'env') { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }
++ // Provider secrets are environment-only (server/secrets.js): never saved into the database.
++ if (secrets.isSecret(key)) { if (String(value ?? '').trim() && String(value).trim() !== '••••••••') skipped.push(key); continue; }
// Bot token / OAuth secret are owner-only — silently skip for admins.
if (isSensitiveSettingKey(key) && !owner) continue;
const strVal = String(value ?? '').trim();
ddiff --git a/server/integrations/github.js b/server/integrations/github.js
iindex 273b24ad..acf602ee 100644
---- a/server/integrations/github.js
++++ b/server/integrations/github.js
@@@ -3,9 +3,9 @@
* The network's GitHub API token (read-only, public repositories). GitHub allows 60 anonymous
* requests an hour per address, shared by everything on the host; with a token it is 5,000.
*
-- * Where it comes from (server/secrets.js, key github_token): GITHUB_TOKEN in network.env first, else
-- * the value the owner saves in admin (Settings → GitHub). The value is never shown or logged: admin
-- * sees its source and last four characters, and a test that asks GitHub for the token's rate limit.
++ * Where it comes from (server/secrets.js, key github_token): GITHUB_TOKEN in network.env, nowhere else
++ * (provider secrets are environment-only). The value is never shown or logged: admin sees whether it is
++ * set, its last four characters, and a test that asks GitHub for the token's rate limit.
*
* Who reads it:
* Network itself the registry's library tags (server/registry/library-tags.js)
@@@ -48,20 +48,10 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
const r = express.Router();
r.use(requireOwner);
r.get('/', async (req, res) => res.json({ ok: true, github: await status(db) }));
-- r.put('/', express.json({ limit: '4kb' }), async (req, res) => {
-- if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
-- const token = String((req.body && req.body.token) || '').trim();
-- if (!TOKEN_RE.test(token)) return res.status(400).json({ ok: false, error: 'That does not look like a GitHub token (ghp_…, github_pat_…)' });
-- await db.prepare('INSERT INTO site_settings (key, value, type) VALUES (?, ?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value, type = excluded.type').run(KEY, token, 'secret');
-- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', last4: token.slice(-4) }));
-- res.json({ ok: true, github: await status(db) });
-- });
-- r.delete('/', async (req, res) => {
-- if (await secrets.source(db, KEY) === 'env') return res.status(409).json({ ok: false, error: `Set in the environment (${secrets.envName(KEY)}); change it there` });
-- await db.prepare("UPDATE site_settings SET value = '' WHERE key = ?").run(KEY);
-- await db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(req.user.id, 'integration_update', JSON.stringify({ integration: 'github', cleared: true }));
-- res.json({ ok: true, github: await status(db) });
-- });
++ // The token is environment-only: admin cannot store or clear it (set or remove GITHUB_TOKEN in network.env).
++ const envOnly = (req, res) => res.status(409).json({ ok: false, code: 'integration.secret_env_only', env: secrets.envName(KEY), error: `The GitHub token is read from the environment only: set ${secrets.envName(KEY)} in /etc/openvibe/network.env and restart Network` });
++ r.put('/', envOnly);
++ r.delete('/', envOnly);
r.post('/test', async (req, res) => {
try { res.json({ ok: true, test: await test(await tokenOf(db), fetchImpl) }); }
catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }
ddiff --git a/server/secrets.js b/server/secrets.js
iindex dba4e9d1..8c3f2f2a 100644
---- a/server/secrets.js
++++ b/server/secrets.js
@@@ -1,17 +1,16 @@
'use strict';
// ═══════════════════════════════════════════════════════════════
--// Provider secrets: environment first, the database only as a fallback (roadmap §18.2(12): secrets are
--// environment references, never plaintext reusable provider keys in a database).
++// Provider secrets: the environment only (roadmap §18.2(12): secrets are environment references, never
++// plaintext reusable provider keys in a database).
//
--// Each secret Network uses has a named environment variable (set in /etc/openvibe/network.env). When
--// it is set, that value is used and the site_settings row is ignored; the admin UI shows the secret as
--// "set in the environment" and never saves one into the database while the environment provides it.
--// When it is not set, the site_settings value is used as before, so nothing changes until the operator
--// moves a value. scripts/secrets-out-of-db.js lists the names (never values) and, once the environment
--// has them, blanks the database copies (with a backup).
++// Each secret Network uses has a named environment variable (set in /etc/openvibe/network.env). That is the
++// only place it is read from: a site_settings row of the same key is ignored, the admin UI shows the secret
++// as "set in the environment" or "not set" and never stores one. (The database fallback and its copy-out
++// script were retired on 2026-10-10, once production held no database copy of any secret.) The companions
++// below are not secret: they are read environment-first with the database as a fallback.
//
--// db.getSetting() (server/db/database.js) goes through fromEnv() for these keys, so every reader (email,
--// Resend webhook, Discord bot and account linking, web push) is environment-first without its own code.
++// db.getSetting() (server/db/database.js) goes through value() for these keys, so every reader (email,
++// Resend webhook, Discord bot and account linking, web push) gets this without its own code.
// ═══════════════════════════════════════════════════════════════
const SECRETS = [
@@@ -56,9 +55,10 @@ async function dbValue(db, key) {
try { const r = await db.prepare('SELECT value FROM site_settings WHERE key = ?').get(key); return r && r.value ? String(r.value) : ''; } catch { return ''; }
}
--/** 'env' | 'database' | 'unset' for a managed key. */
++/** 'env' | 'database' | 'unset' for a managed key; a secret is never 'database' (environment-only). */
async function source(db, key, env = process.env) {
if (fromEnv(key, env) !== null) return 'env';
++ if (isSecret(key)) return 'unset';
return (await dbValue(db, key)) ? 'database' : 'unset';
}
@@@ -70,7 +70,7 @@ async function report(db, env = process.env) {
})));
}
--/** One boot line: "resend_api_key=env discord_bot_token=database ..." (names and sources only). */
++/** One boot line: "resend_api_key=env discord_bot_token=unset ..." (names and sources only). */
async function summary(db, env = process.env) {
return (await report(db, env)).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
}
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 44e90a83..62abf56e 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -3,7 +3,7 @@
// /api/v1/me/blocks (never themselves, never a guest), each change writes network.block.changed into the
// outbox with a growing per-pair revision, GET /internal/blocks answers services holding network.blocks.read
// (service token only), Network creates no notification from a person the recipient blocked (staff and
--// system notices still arrive), and scripts/import-blocks.js imports Chat's exported dm_blocks once.
++// system notices still arrive). (Chat's dm_blocks were imported once, in WS-E; the importer is retired.)
// node test/blocks.test.js
const assert = require('assert');
const crypto = require('crypto');
@@@ -177,34 +177,6 @@ const valid = (e) => {
await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });
assert.ok(await notes.create({ user_id: 1, type: 'MENTION', sender_id: 2, message: 'bob mentioned you' }), 'unblocked: notifications again (a MENTION: a second FOLLOW from bob within the hour is deduped)');
-- // ── Import of Chat's exported dm_blocks ──
-- const importer = require('../scripts/import-blocks');
-- const file = path.join(dir, 'pairs.json');
-- fs.writeFileSync(file, JSON.stringify({ pairs: [
-- { blocker_subject: BOB, blocked_subject: CAT },
-- { blocker_subject: BOB, blocked_subject: CAT }, // duplicate
-- { blocker_subject: ANN, blocked_subject: BOB }, // ann unblocked bob on Network since: left alone
-- { blocker_subject: CAT, blocked_subject: CAT }, // self
-- { blocker_subject: CAT, blocked_subject: 'usr_01JAB2C3D4E5F6G7H8J9K0ZZZZ' }, // unknown account
-- { blocker_subject: '42', blocked_subject: BOB }, // not a subject
-- ] }));
-- const out = [];
-- let code = await importer.main(['--file', file], (l) => out.push(l));
-- assert.strictEqual(code, 0);
-- assert.match(out.join('\n'), /pairs {6}6; to import 1; skipped: duplicate-in-file 1, already-on-network 1, self 1, unknown-account 1, not-a-subject 1/);
-- assert.ok(!await blocks.isBlocked(db, BOB, CAT), 'dry run changed nothing');
-- const n = (await events()).length;
-- code = await importer.main(['--file', file, '--apply'], () => {});
-- assert.strictEqual(code, 0);
-- assert.ok(await blocks.isBlocked(db, BOB, CAT), 'imported');
-- assert.ok(!await blocks.isBlocked(db, ANN, BOB), 'an unblock on Network wins over the old Chat block');
-- assert.strictEqual((await events()).length, n + 1);
-- const imported = (await events()).at(-1); valid(imported);
-- assert.deepStrictEqual([imported.payload.blocker, imported.payload.blocked, imported.payload.active, imported.payload.revision], [BOB, CAT, true, 1]);
-- out.length = 0;
-- await importer.main(['--file', file], (l) => out.push(l));
-- assert.match(out.join('\n'), /to import 0/, 'a second run imports nothing');
--
// The account page lists blocks as text (DOM nodes), with unblock buttons.
const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
assert.ok(page.includes('id="blocks-list"'));
ddiff --git a/test/github-integration.test.js b/test/github-integration.test.js
iindex 982683ad..c9372443 100644
---- a/test/github-integration.test.js
++++ b/test/github-integration.test.js
@@@ -1,6 +1,6 @@
'use strict';
--// The network's GitHub token (server/integrations/github.js): env first, else what the owner saves in admin;
--// admin never sees the value; only the owner changes it; Blog (network.integration.github.read) reads it
++// The network's GitHub token (server/integrations/github.js): environment-only (GITHUB_TOKEN); admin cannot store
++// or clear it and never sees the value; a database row is ignored; Blog (network.integration.github.read) reads it
// over /internal with its service token and no other service can; the registry's library tags use it.
const assert = require('assert');
const crypto = require('crypto');
@@@ -42,7 +42,7 @@ app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
app.use(async (req, _res, next) => { const u = req.headers['x-user']; req.user = u ? await db.prepare('SELECT id, username FROM users WHERE username = ?').get(u) : null; next(); });
app.use('/api/admin/integrations/github', github.adminRouter(db, { fetchImpl: fakeGithub }));
--app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read', { legacy: false }), github.internalHandler(db));
++app.get('/internal/integrations/github-token', principals.guard('network.integration.github.read'), github.internalHandler(db));
const server = http.createServer(app);
(async () => {
@@@ -60,52 +60,44 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.env], [false, 'unset', 'GITHUB_TOKEN']);
-- // Validation, save, never echoed.
-- assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: 'hunter2' } })).status, 400);
++ // Environment-only: admin cannot store or clear it, and a database row of the same key is ignored.
r = await call('PUT', A, { user: 'goosely', body: { token: TOKEN } });
-- assert.strictEqual(r.status, 200, r.text);
-- assert.ok(!r.text.includes(TOKEN), 'the token is never sent back');
-- assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'database', TOKEN.slice(-4)]);
-- assert.strictEqual(await github.tokenOf(db), TOKEN);
-- const audit = await db.prepare("SELECT details FROM audit_log WHERE action = 'integration_update'").all();
-- assert.strictEqual(audit.length, 1);
-- assert.ok(!audit[0].details.includes(TOKEN), 'the audit row keeps the last four only');
-- assert.ok(!(await call('GET', A, { user: 'goosely' })).text.includes(TOKEN));
--
-- // Test asks GitHub with the token.
-- r = await call('POST', A + '/test', { user: 'goosely' });
-- assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit, r.body.test.remaining], [true, 5000, 4999]);
-- assert.strictEqual(seen.pop(), `Bearer ${TOKEN}`);
--
-- // Blog reads it with its service token; another service and the legacy key cannot.
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.body.code, 'integration.secret_env_only');
++ assert.strictEqual(r.body.env, 'GITHUB_TOKEN');
++ assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);
++ await db.prepare("INSERT INTO site_settings (key, value, type) VALUES ('github_token', ?, 'secret') ON CONFLICT (key) DO UPDATE SET value = excluded.value").run(TOKEN);
++ assert.strictEqual(await github.tokenOf(db), null, 'a database copy is never used');
++ r = await call('GET', A, { user: 'goosely' });
++ assert.deepStrictEqual([r.body.github.set, r.body.github.source], [false, 'unset']);
const blog = await svcToken('blog', 'blog-secret');
assert.ok(blog.access_token, JSON.stringify(blog));
assert.ok(blog.scope.split(' ').includes('network.integration.github.read'));
r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
++ assert.deepStrictEqual([r.status, r.body.error], [404, 'not_configured'], 'nothing configured: the database row does not count');
++ r = await call('POST', A + '/test', { user: 'goosely' });
++ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit], [false, 60]);
++ assert.strictEqual(seen.pop(), null);
++
++ // From the environment: admin sees its source and last four, the test uses it, Blog reads it.
++ const ENV_TOKEN = 'ghp_' + 'Z'.repeat(36);
++ process.env.GITHUB_TOKEN = ENV_TOKEN;
++ r = await call('GET', A, { user: 'goosely' });
++ assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.last4], [true, 'env', 'ZZZZ']);
++ assert.ok(!r.text.includes(ENV_TOKEN), 'the token is never sent to admin');
++ r = await call('POST', A + '/test', { user: 'goosely' });
++ assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit, r.body.test.remaining], [true, 5000, 4999]);
++ assert.strictEqual(seen.pop(), `Bearer ${ENV_TOKEN}`);
++ r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual(r.body, { token: TOKEN, source: 'database' });
++ assert.deepStrictEqual(r.body, { token: ENV_TOKEN, source: 'env' });
assert.strictEqual(r.cache, 'no-store');
const live = await svcToken('live', 'live-secret');
assert.strictEqual((await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${live.access_token}` } })).status, 403, 'only blog holds the capability');
assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token', { headers: { 'x-internal-key': 'legacy-key' } })).status), 'no legacy key');
assert.ok([401, 403].includes((await call('GET', '/internal/integrations/github-token')).status), 'no credential');
--
-- // Clear → 404 not_configured; the test falls back to anonymous.
-- r = await call('DELETE', A, { user: 'goosely' });
-- assert.deepStrictEqual([r.status, r.body.github.set], [200, false]);
-- r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
-- assert.deepStrictEqual([r.status, r.body.error], [404, 'not_configured']);
-- r = await call('POST', A + '/test', { user: 'goosely' });
-- assert.deepStrictEqual([r.body.test.authenticated, r.body.test.limit], [false, 60]);
-- assert.strictEqual(seen.pop(), null);
--
-- // The environment wins and cannot be changed from admin.
-- process.env.GITHUB_TOKEN = 'ghp_' + 'Z'.repeat(36);
-- r = await call('GET', A, { user: 'goosely' });
-- assert.deepStrictEqual([r.body.github.source, r.body.github.last4], ['env', 'ZZZZ']);
-- assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409);
-- assert.strictEqual((await call('DELETE', A, { user: 'goosely' })).status, 409);
-- assert.strictEqual(await github.tokenOf(db), process.env.GITHUB_TOKEN);
++ assert.strictEqual((await call('PUT', A, { user: 'goosely', body: { token: TOKEN } })).status, 409, 'still not changeable from admin');
++ assert.strictEqual(await github.tokenOf(db), ENV_TOKEN);
delete process.env.GITHUB_TOKEN;
// The registry's library tags read the token at call time.
ddiff --git a/test/provider-secrets.test.js b/test/provider-secrets.test.js
iindex c9301aee..b820ebb1 100644
---- a/test/provider-secrets.test.js
++++ b/test/provider-secrets.test.js
@@@ -1,9 +1,7 @@
'use strict';
--// Provider secrets (roadmap §18.2(12), server/secrets.js): read from the environment first, the database
--// only as a fallback, and the source is reported by name; the admin UI never saves a secret into the
--// database while the environment provides it; scripts/secrets-out-of-db.js lists names only and blanks
--// the database copies (with a backup, and a rollback) once the environment and the running service
--// have them.
++// Provider secrets (roadmap §18.2(12), server/secrets.js): read from the environment only. A site_settings row of
++// the same key is never used, the source is reported by name ('env' or 'unset'), and no admin path stores a secret
++// in the database. The companions (VAPID public key) stay environment-first with a database fallback.
// node test/provider-secrets.test.js
const assert = require('assert');
const fs = require('fs');
@@@ -34,21 +32,22 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
await set.run('ses_secret_access_key', 'ses-666', 'string');
await set.run('some_new_api_key', 'unknown-777', 'string');
-- // ── Environment first, database fallback ──
-- assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');
-- assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'database');
++ // ── Environment only ──
++ assert.strictEqual(await db.getSetting('resend_api_key'), null, 'a database copy is never used');
++ assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'unset');
process.env.RESEND_API_KEY = 're_env_key_999';
-- assert.strictEqual(await db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');
++ assert.strictEqual(await db.getSetting('resend_api_key'), 're_env_key_999', 'the variable is the source');
assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'env');
process.env.RESEND_API_KEY = ' ';
-- assert.strictEqual(await db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');
++ assert.strictEqual(await db.getSetting('resend_api_key'), null, 'a blank variable is unset, and the database copy still does not count');
process.env.RESEND_API_KEY = 're_env_key_999';
-- assert.strictEqual(await secrets.source(db, 'resend_webhook_secret'), 'database');
++ assert.strictEqual(await secrets.source(db, 'resend_webhook_secret'), 'unset');
assert.strictEqual(await db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
const rep = await secrets.report(db);
assert.deepStrictEqual(rep.filter(r => r.secret).map(r => r.key), ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'github_token']);
assert.ok(!JSON.stringify(rep).includes('re_env_key_999') && !JSON.stringify(rep).includes('re_db_key_111'), 'the report carries no value');
-- assert.match(await secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
++ assert.ok(rep.find(r => r.key === 'discord_bot_token').database_copy, 'the report still says a stray database copy exists (for clean-up)');
++ assert.match(await secrets.summary(db), /resend_api_key=env resend_webhook_secret=unset discord_bot_token=unset/);
// The email service reads it through db.getSetting, and says where it came from.
const { EmailService } = require('../server/notifications/email-service');
@@@ -89,123 +88,41 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
const byKey = Object.fromEntries(r.body.secrets.map(s => [s.key, s]));
assert.strictEqual(byKey.resend_api_key.source, 'env');
assert.strictEqual(byKey.resend_api_key.env, 'RESEND_API_KEY');
-- assert.strictEqual(byKey.discord_bot_token.source, 'database');
++ assert.strictEqual(byKey.discord_bot_token.source, 'unset');
assert.strictEqual(byKey.vapid_private_key.source, 'env');
assert.ok(!JSON.stringify(r.body).match(/re_env_key_999|re_db_key_111|db\.bot\.token|whsec_db/), 'names and sources only');
-- // Email: the key from the environment is not saved over.
++ // Email: a key typed in the UI is never stored.
r = await call('PUT', '/api/admin/email', { api_key: 're_typed_in_ui', from_name: 'OpenVibe' });
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(r.body.skipped, ['resend_api_key']);
-- assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');
++ assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the database row is left as it was, and unused');
-- // Generic settings: an env-provided secret shows no value and cannot be saved; a database one can.
++ // Generic settings: a secret shows no value (env or not) and can never be saved.
process.env.RESEND_WEBHOOK_SECRET = 'whsec_env_888';
r = await call('GET', '/api/admin/settings');
-- assert.deepStrictEqual(r.body.settings.resend_webhook_secret, { value: '', type: 'string', source: 'env', env: 'RESEND_WEBHOOK_SECRET', redacted: true });
-- assert.strictEqual(r.body.settings.discord_bot_token.source, 'database');
-- r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
-- assert.strictEqual(r.body.skipped, true);
-- assert.strictEqual(r.body.env, 'RESEND_WEBHOOK_SECRET');
-- assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');
++ assert.deepStrictEqual(r.body.settings.resend_webhook_secret, { value: '', type: 'string', source: 'env', env: 'RESEND_WEBHOOK_SECRET', redacted: true, secret: true });
++ assert.deepStrictEqual(r.body.settings.discord_bot_token, { value: '', type: 'secret', source: 'unset', env: 'DISCORD_BOT_TOKEN', redacted: true, secret: true }, 'a database value is never shown');
delete process.env.RESEND_WEBHOOK_SECRET;
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
-- assert.ok(!r.body.skipped);
-- assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');
-- await set.run('resend_webhook_secret', 'whsec_db_222', 'string');
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'settings.secret_env_only');
++ assert.strictEqual(r.body.env, 'RESEND_WEBHOOK_SECRET');
++ assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved, even with the variable unset');
// Discord: the same rule.
process.env.DISCORD_BOT_TOKEN = 'env.bot.token';
r = await call('GET', '/api/admin/discord');
assert.strictEqual(r.body.sources.discord_bot_token.source, 'env');
-- assert.strictEqual(r.body.sources.discord_oauth_client_secret.source, 'database');
-- r = await call('PUT', '/api/admin/discord', { settings: { discord_bot_token: 'typed.token', discord_guild_id: '42' } });
-- assert.deepStrictEqual(r.body.skipped, ['discord_bot_token']);
++ assert.strictEqual(r.body.sources.discord_oauth_client_secret.source, 'unset');
++ r = await call('PUT', '/api/admin/discord', { settings: { discord_bot_token: 'typed.token', discord_oauth_client_secret: 'typed-secret', discord_guild_id: '42' } });
++ assert.deepStrictEqual(r.body.skipped, ['discord_bot_token', 'discord_oauth_client_secret']);
assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');
++ assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444');
assert.strictEqual(await dbVal('discord_guild_id'), '42', 'other Discord settings still save');
++ delete process.env.DISCORD_BOT_TOKEN;
server.close();
-- // ── scripts/secrets-out-of-db.js ──
-- const script = require('../scripts/secrets-out-of-db');
-- const envFile = path.join(dir, 'network.env');
-- // The env file before the move: one rotated value already there, one variable present but empty.
-- fs.writeFileSync(envFile, ['NODE_ENV=production', 'DISCORD_OAUTH_CLIENT_SECRET=rotated-secret', 'RESEND_WEBHOOK_SECRET=', ''].join('\n'), { mode: 0o600 });
-- const out = [];
-- const running = { vars: { RESEND_API_KEY: 're_db_key_111', DISCORD_BOT_TOKEN: 'db.bot.token.333', DISCORD_OAUTH_CLIENT_SECRET: 'rotated-secret' }, pid: 4242 };
-- const run = (argv, service = running) => script.main(['--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service, db });
-- const values = ['re_db_key_111', 'whsec_db_222', 'db.bot.token.333', 'db-oauth-secret-444', 'ipinfo-555', 'ses-666', 'unknown-777', 'rotated-secret'];
-- const noValues = () => { const text = out.join('\n'); for (const v of values) assert.ok(!text.includes(v), `the script never prints a value (${v.slice(0, 3)}…)`); };
--
-- // --copy-to-env: the values move from the database into the env file without being shown.
-- assert.strictEqual(await run(['--copy-to-env']), 0);
-- let text = out.join('\n');
-- assert.match(text, /RESEND_API_KEY: would be appended/);
-- assert.match(text, /RESEND_WEBHOOK_SECRET: would be appended/, 'an empty variable counts as unset');
-- assert.match(text, /DISCORD_BOT_TOKEN: would be appended/);
-- assert.match(text, /DISCORD_OAUTH_CLIENT_SECRET: already in the env file \(DIFFERENT value, left as it is\)/);
-- assert.match(text, /VAPID_PRIVATE_KEY: the database holds no vapid_private_key/);
-- assert.ok(!fs.readFileSync(envFile, 'utf8').includes('RESEND_API_KEY'), 'the dry run writes nothing');
-- assert.strictEqual(await run(['--copy-to-env', '--apply']), 0);
-- const envText = fs.readFileSync(envFile, 'utf8');
-- assert.ok(envText.includes('\nRESEND_API_KEY=re_db_key_111\n') && envText.includes('\nDISCORD_BOT_TOKEN=db.bot.token.333\n') && envText.includes('\nRESEND_WEBHOOK_SECRET=whsec_db_222\n'), 'appended');
-- assert.ok(envText.startsWith('NODE_ENV=production\nDISCORD_OAUTH_CLIENT_SECRET=rotated-secret\n'), 'the existing lines are untouched');
-- assert.strictEqual(fs.statSync(envFile).mode & 0o777, 0o600, 'the env file keeps its mode');
-- const baks = fs.readdirSync(dir).filter(f => f.startsWith('network.env.bak-'));
-- assert.strictEqual(baks.length, 1, 'the previous env file is kept');
-- assert.ok(!fs.readFileSync(path.join(dir, baks[0]), 'utf8').includes('RESEND_API_KEY'));
-- assert.strictEqual(require('util').parseEnv(envText).RESEND_WEBHOOK_SECRET, 'whsec_db_222', 'the later assignment wins');
-- noValues();
--
-- out.length = 0;
-- assert.strictEqual(await run([]), 0);
-- noValues();
-- text = out.join('\n');
-- for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'vapid_private_key', 'net.ipinfo_token', 'ses_secret_access_key', 'some_new_api_key']) assert.ok(text.includes(` ${k}\n`), `lists ${k}`);
-- assert.ok(!text.includes(' discord_oauth_client_id\n') && !text.includes(' vapid_public_key\n'), 'non-secrets are not listed');
-- assert.match(text, /resend_api_key\n.*env file: set, same value · service: has it\n\s+--apply: BLANK/);
-- assert.match(text, /resend_webhook_secret\n.*env file: set, same value · service: not set \(restart it\)\n\s+--apply: keep: the running service does not have this RESEND_WEBHOOK_SECRET yet/);
-- assert.match(text, /discord_oauth_client_secret\n.*DIFFERENT value.*\n\s+--apply: keep: the env file value differs/);
-- assert.match(text, /net\.ipinfo_token\n.*\n\s+--apply: BLANK \(Network never reads it/);
-- assert.match(text, /some_new_api_key\n.*\n\s+--apply: keep: not classified/);
-- assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111', 'the dry run changes nothing');
--
-- out.length = 0;
-- assert.strictEqual(await run(['--apply']), 2, '--apply refuses without --backup');
-- // A service that was not restarted after the env file changed keeps its database copy.
-- out.length = 0;
-- const backup = path.join(dir, 'pre-secrets.json');
-- assert.strictEqual(await run(['--apply', '--backup', backup], { vars: { RESEND_API_KEY: 're_db_key_111' }, pid: 1 }), 0);
-- assert.strictEqual(await dbVal('resend_api_key'), '', 'blanked: env file and running service have it');
-- assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');
-- assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');
-- assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: the running service does not have it');
-- assert.strictEqual(await dbVal('net.ipinfo_token'), '', 'unused secret blanked');
-- assert.strictEqual(await dbVal('ses_secret_access_key'), '', 'unused secret blanked');
-- assert.strictEqual(await dbVal('some_new_api_key'), 'unknown-777', 'unclassified left alone');
-- assert.strictEqual(fs.statSync(backup).mode & 0o777, 0o600, 'the backup is owner-only');
-- noValues();
-- // After the restart, and with --allow-different for the rotated one.
-- out.length = 0;
-- assert.strictEqual(await run(['--apply', '--backup', path.join(dir, 'pre-secrets-2.json'), '--allow-different']), 0);
-- assert.strictEqual(await dbVal('discord_bot_token'), '');
-- assert.strictEqual(await dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
-- assert.strictEqual(await dbVal('resend_webhook_secret'), 'whsec_db_222', 'still kept until the service has it');
-- noValues();
-- // An unreadable env file refuses --apply.
-- out.length = 0;
-- assert.strictEqual(await script.main(['--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.json')], (m) => out.push(m), { readService: () => running, db }), 2);
--
-- // Rollback from the first backup: every blanked value comes back; nothing printed.
-- out.length = 0;
-- assert.strictEqual(await script.main(['--restore-from', backup], (m) => out.push(m), { db }), 0);
-- assert.strictEqual(await dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');
-- assert.strictEqual(await script.main(['--restore-from', backup, '--apply'], (m) => out.push(m), { db }), 0);
-- assert.strictEqual(await dbVal('resend_api_key'), 're_db_key_111');
-- assert.strictEqual(await dbVal('discord_bot_token'), 'db.bot.token.333');
-- assert.strictEqual(await dbVal('net.ipinfo_token'), 'ipinfo-555');
-- assert.strictEqual(await dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'restored from the backup taken before it was blanked');
-- noValues();
--
for (const n of ENV_NAMES) delete process.env[n];
console.log('provider secrets: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/site-config.test.js b/test/site-config.test.js
iindex f5d13538..d26a90e2 100644
---- a/test/site-config.test.js
++++ b/test/site-config.test.js
@@@ -68,7 +68,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
r = await quiet(() => call('PUT', '/api/admin/discord', { settings: { discord_dedupe_minutes: '15', discord_bot_token: 'example-new-bot-token' } }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.deepStrictEqual(await row('discord_dedupe_minutes'), { value: '15', type: 'number' });
-- assert.deepStrictEqual(await row('discord_bot_token'), { value: 'example-new-bot-token', type: 'secret' }, 'a secret row stays a secret row');
++ assert.deepStrictEqual(r.body.skipped, ['discord_bot_token'], 'a provider secret is environment-only: never stored');
++ assert.deepStrictEqual(await row('discord_bot_token'), { value: 'example-bot-token-not-real', type: 'secret' }, 'the row is left as it was');
// ── Written around the journal: recorded first, never reverted ──
await set.run('email_daily_cap', '777', 'number');
@@@ -90,7 +91,7 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
// ── Rollback undoes the last change only; the secret and the outside row stay ──
r = await quiet(() => call('POST', '/api/admin/config/network.site_settings/rollback', { reason: 'too many' }));
assert.strictEqual(r.status, 200, JSON.stringify(r.body));
-- assert.deepStrictEqual([(await row('stream_live_daily_cap')).value, (await row('email_daily_cap')).value, (await row('discord_bot_token')).value], ['9', '777', 'example-new-bot-token']);
++ assert.deepStrictEqual([(await row('stream_live_daily_cap')).value, (await row('email_daily_cap')).value, (await row('discord_bot_token')).value], ['9', '777', 'example-bot-token-not-real']);
assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);
// Only the owner reads or rolls back the configuration.