openvibers/openvibe.network Commits
Network: provider secrets are environment-only (no database fallback, no admin path stores one); one-time scripts deleted (#118)
Changes
@@@ -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 Toolsgateway 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 Networkid, 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.---@@@ -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>@@@ -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…';ddeleted file mode 100644@@@ -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 };ddeleted file mode 100644@@@ -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 };@@@ -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` });}@@@ -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';@@@ -53,8 +53,8 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, rconst 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();@@@ -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}` }); }@@@ -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(' ');}@@@ -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.jsconst 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"'));@@@ -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.@@@ -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.jsconst assert = require('assert');const fs = require('fs');@@@ -34,21 +32,22 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lawait 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.lconst 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); });@@@ -68,7 +68,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.lr = 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.
@@ -399,24 +399,9 @@ secret as "set in the environment" and never saves one into the database. `GET /
@@ -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
`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).
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
@@ -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.
(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
- 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.
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.
---
---
@@ -389,11 +389,8 @@ async function loadEmail() {
@@ -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>` : ''}
${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>
<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>
<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>
<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>
@@ -363,10 +363,11 @@ async function saveStreamerSettings() {
@@ -363,10 +363,11 @@ async function saveStreamerSettings() {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
function envSecretInput(sources, key, value, placeholder, style) {
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] || {};
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() {
async function loadDiscordSettings() {
@@ -441,36 +442,20 @@ async function loadGithubSettings() {
@@ -441,36 +442,20 @@ async function loadGithubSettings() {
c.innerHTML = '<p class="muted">Loading…</p>';
c.innerHTML = '<p class="muted">Loading…</p>';
try {
try {
const g = (await api('/api/admin/integrations/github')).github || {};
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 = `
c.innerHTML = `
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">
<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}
<h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}
</div>
</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">
<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>
<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>
<div id="github-test-result" class="muted" style="margin-top:10px"></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>`; }
} 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() {
async function testGithubToken() {
const out = document.getElementById('github-test-result');
const out = document.getElementById('github-test-result');
if (out) out.textContent = 'Asking GitHub…';
if (out) out.textContent = 'Asking GitHub…';
deleted file mode 100644
deleted file mode 100644
@@ -1,107 +0,0 @@
@@ -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 };
deleted file mode 100644
deleted file mode 100644
@@ -1,238 +0,0 @@
@@ -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 };
@@ -250,12 +250,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -250,12 +250,8 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const collect = async () => {
const collect = async () => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
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_email) setSetting.run('email_from_address', from_email, 'string');
if (from_name !== undefined) setSetting.run('email_from_name', from_name || 'OpenVibe', 'string');
if (from_name !== undefined) setSetting.run('email_from_name', from_name || 'OpenVibe', 'string');
// Per-service from addresses
// Per-service from addresses
@@ -319,8 +315,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
@@ -319,8 +315,12 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const owner = isOwner(req.user);
const owner = isOwner(req.user);
for (const r of rows) {
for (const r of rows) {
if (EMAIL_MANAGED_KEYS.has(r.key)) continue;
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)) {
if (secrets.isManaged(r.key)) {
const src = await secrets.source(db, 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; }
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) {
@@ -353,7 +353,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
if (typeof value === 'string' && /^••••/.test(value)) {
if (typeof value === 'string' && /^••••/.test(value)) {
return res.json({ ok: true, skipped: true });
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') {
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` });
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` });
}
}
@@ -318,10 +318,11 @@ function attachHelpers(db) {
@@ -318,10 +318,11 @@ function attachHelpers(db) {
// shape (a runner the caller invokes, possibly more than once, e.g. a dry-run path).
// 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));
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) {
db.getSetting = async function (key) {
const fromEnv = secrets.fromEnv(key);
const fromEnv = secrets.fromEnv(key);
if (fromEnv !== null) return fromEnv;
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);
const row = await db.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
if (!row) return null;
if (!row) return null;
if (row.type === 'boolean') return row.value === 'true';
if (row.type === 'boolean') return row.value === 'true';
@@ -53,8 +53,8 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
@@ -53,8 +53,8 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
const skipped = [];
const skipped = [];
for (const [key, value] of Object.entries(settings)) {
for (const [key, value] of Object.entries(settings)) {
if (!allowedKeys.includes(key)) continue;
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.
// Bot token / OAuth secret are owner-only — silently skip for admins.
if (isSensitiveSettingKey(key) && !owner) continue;
if (isSensitiveSettingKey(key) && !owner) continue;
const strVal = String(value ?? '').trim();
const strVal = String(value ?? '').trim();
@@ -3,9 +3,9 @@
@@ -3,9 +3,9 @@
* The network's GitHub API token (read-only, public repositories). GitHub allows 60 anonymous
* 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.
* 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:
* Who reads it:
* Network itself the registry's library tags (server/registry/library-tags.js)
* Network itself the registry's library tags (server/registry/library-tags.js)
@@ -48,20 +48,10 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
@@ -48,20 +48,10 @@ function adminRouter(db, { fetchImpl = globalThis.fetch } = {}) {
const r = express.Router();
const r = express.Router();
r.use(requireOwner);
r.use(requireOwner);
r.get('/', async (req, res) => res.json({ ok: true, github: await status(db) }));
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) => {
r.post('/test', async (req, res) => {
try { res.json({ ok: true, test: await test(await tokenOf(db), fetchImpl) }); }
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}` }); }
catch (err) { res.status(502).json({ ok: false, error: `GitHub could not be reached: ${err.message}` }); }
@@ -1,17 +1,16 @@
@@ -1,17 +1,16 @@
'use strict';
'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 = [
const SECRETS = [
@@ -56,9 +55,10 @@ async function dbValue(db, key) {
@@ -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 ''; }
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) {
async function source(db, key, env = process.env) {
if (fromEnv(key, env) !== null) return 'env';
if (fromEnv(key, env) !== null) return 'env';
+ if (isSecret(key)) return 'unset';
return (await dbValue(db, key)) ? 'database' : 'unset';
return (await dbValue(db, key)) ? 'database' : 'unset';
}
}
@@ -70,7 +70,7 @@ async function report(db, env = process.env) {
@@ -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) {
async function summary(db, env = process.env) {
return (await report(db, env)).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
return (await report(db, env)).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
}
}
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
// /api/v1/me/blocks (never themselves, never a guest), each change writes network.block.changed into the
// /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
// 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
// (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
// node test/blocks.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -177,34 +177,6 @@ const valid = (e) => {
@@ -177,34 +177,6 @@ const valid = (e) => {
await call('DELETE', '/api/v1/me/blocks/bob', { headers: await as(1) });
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)');
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.
// The account page lists blocks as text (DOM nodes), with unblock buttons.
const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
assert.ok(page.includes('id="blocks-list"'));
assert.ok(page.includes('id="blocks-list"'));
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'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.
// over /internal with its service token and no other service can; the registry's library tags use it.
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -42,7 +42,7 @@ app.locals.publicKey = keys.publicKey;
@@ -42,7 +42,7 @@ app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
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(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.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);
const server = http.createServer(app);
(async () => {
(async () => {
@@ -60,52 +60,44 @@ const server = http.createServer(app);
@@ -60,52 +60,44 @@ const server = http.createServer(app);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual([r.body.github.set, r.body.github.source, r.body.github.env], [false, 'unset', 'GITHUB_TOKEN']);
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 } });
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');
const blog = await svcToken('blog', 'blog-secret');
assert.ok(blog.access_token, JSON.stringify(blog));
assert.ok(blog.access_token, JSON.stringify(blog));
assert.ok(blog.scope.split(' ').includes('network.integration.github.read'));
assert.ok(blog.scope.split(' ').includes('network.integration.github.read'));
r = await call('GET', '/internal/integrations/github-token', { headers: { authorization: `Bearer ${blog.access_token}` } });
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.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');
assert.strictEqual(r.cache, 'no-store');
const live = await svcToken('live', 'live-secret');
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.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', { 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');
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;
delete process.env.GITHUB_TOKEN;
// The registry's library tags read the token at call time.
// The registry's library tags read the token at call time.
@@ -1,9 +1,7 @@
@@ -1,9 +1,7 @@
'use strict';
'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
// node test/provider-secrets.test.js
const assert = require('assert');
const assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -34,21 +32,22 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -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('ses_secret_access_key', 'ses-666', 'string');
await set.run('some_new_api_key', 'unknown-777', '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';
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');
assert.strictEqual(await secrets.source(db, 'resend_api_key'), 'env');
process.env.RESEND_API_KEY = ' ';
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';
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');
assert.strictEqual(await db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
const rep = await secrets.report(db);
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.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.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.
// The email service reads it through db.getSetting, and says where it came from.
const { EmailService } = require('../server/notifications/email-service');
const { EmailService } = require('../server/notifications/email-service');
@@ -89,123 +88,41 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -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]));
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.source, 'env');
assert.strictEqual(byKey.resend_api_key.env, 'RESEND_API_KEY');
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.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');
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' });
r = await call('PUT', '/api/admin/email', { api_key: 're_typed_in_ui', from_name: 'OpenVibe' });
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.deepStrictEqual(r.body.skipped, ['resend_api_key']);
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';
process.env.RESEND_WEBHOOK_SECRET = 'whsec_env_888';
r = await call('GET', '/api/admin/settings');
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;
delete process.env.RESEND_WEBHOOK_SECRET;
r = await call('PUT', '/api/admin/settings', { key: 'resend_webhook_secret', value: 'whsec_from_ui', type: 'string' });
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.
// Discord: the same rule.
process.env.DISCORD_BOT_TOKEN = 'env.bot.token';
process.env.DISCORD_BOT_TOKEN = 'env.bot.token';
r = await call('GET', '/api/admin/discord');
r = await call('GET', '/api/admin/discord');
assert.strictEqual(r.body.sources.discord_bot_token.source, 'env');
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_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');
assert.strictEqual(await dbVal('discord_guild_id'), '42', 'other Discord settings still save');
+ delete process.env.DISCORD_BOT_TOKEN;
server.close();
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];
for (const n of ENV_NAMES) delete process.env[n];
console.log('provider secrets: all checks passed');
console.log('provider secrets: all checks passed');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -68,7 +68,8 @@ const quiet = async (fn) => { const l = console.log, w = console.warn; console.l
@@ -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' } }));
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.strictEqual(r.status, 200, JSON.stringify(r.body));
assert.deepStrictEqual(await row('discord_dedupe_minutes'), { value: '15', type: 'number' });
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 ──
// ── Written around the journal: recorded first, never reverted ──
await set.run('email_daily_cap', '777', 'number');
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
@@ -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 ──
// ── 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' }));
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.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);
assert.strictEqual((await call('POST', '/api/admin/config/other.namespace/rollback', {})).status, 404);
// Only the owner reads or rolls back the configuration.
// Only the owner reads or rolls back the configuration.