openvibers/openvibe.network Commits

Provider secrets from the environment first (roadmap §18.2(12)): the Resend API key and webhook secret, the Discord bot token and OAuth client secret, and the VAPID private key were read only from site_settings in plaintext. Each now has a variable (RESEND_API_KEY, RESEND_WEBHOOK_SECRET, DISCORD_BOT_TOKEN, DISCORD_OAUTH_CLIENT_SECRET, VAPID_PRIVATE_KEY; server/secrets.js) that db.getSetting reads first, so every reader is environment-first; the database row stays the fallback, so nothing changes until the operator moves a value. Which source each uses is reported by name only: the boot line [Secrets] key=env|database|unset, GET /api/admin/secrets (owner), api_key_source in the email status and sources in the Discord settings. The admin panel shows an environment-provided secret as set in the environment (input disabled), and the email, settings and Discord endpoints never save one into the database while its variable is set. Web push derives the VAPID public key from the private key, so a private key in the environment (or a stale public key beside it) still gives a matching pair and no new pair is generated over it. scripts/secrets-out-of-db.js lists the secret setting names with their variable, whether the env file sets it to the same value and whether the running service has it (never a value); --apply --backup <new file> blanks only the copies the environment and the restarted service already provide (a different value only with --allow-different) plus the settings Network never reads (net.* tokens, old ses_* keys); --restore-from <backup> [--apply] rolls back. Run with sudo, it reads the env file and /proc/<pid>/environ, then becomes the database owner. test/provider-secrets.test.js

OP OpenVibers committed 24 Sept 2026 · 70050bca65 · 13 files, +589 −16


Changes

ddiff --git a/.env.example b/.env.example
iindex 9706796e..9a475b84 100644
---- a/.env.example
++++ b/.env.example
@@@ -68,11 +68,24 @@ OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
#SSH_SERVER_HOST=
#SSH_PROJECTS_ROOT=/opt/openvibe.network /opt/openvibe.live
++# ═══════════════════════════════════════════════════════════════
++# Provider secrets (server/secrets.js). Each one is read from its variable first; the admin
++# panel's database copy is only a fallback, and the panel cannot change one set here. In
++# production put them in /etc/openvibe/network.env; scripts/secrets-out-of-db.js then blanks
++# the database copies (README "Provider secrets").
++# ═══════════════════════════════════════════════════════════════
++#RESEND_API_KEY=
++#RESEND_WEBHOOK_SECRET=
++#DISCORD_BOT_TOKEN=
++#DISCORD_OAUTH_CLIENT_SECRET=
++#VAPID_PRIVATE_KEY=
++#VAPID_PUBLIC_KEY= (optional: derived from VAPID_PRIVATE_KEY)
++
# ═══════════════════════════════════════════════════════════════
# Email Notifications (optional)
#
--# Email is configured and managed through the admin panel.
--# For Resend integration, enter your API key and from-addresses in the Admin UI.
++# From-addresses are managed through the admin panel; the Resend API key is RESEND_API_KEY
++# above (or, as a fallback, the admin panel).
# ═══════════════════════════════════════════════════════════════
# ═══════════════════════════════════════════════════════════════
ddiff --git a/README.md b/README.md
iindex f04b87fb..6f9b1ed7 100644
---- a/README.md
++++ b/README.md
@@@ -265,11 +265,45 @@ Email is optional — the notification system works without it. Email is only se
### Admin panel configuration
--Instead of `.env`, configure email at runtime via the admin panel:
--- `GET /api/admin/email` — view current email config
++Configure email at runtime via the admin panel (the API key only when `RESEND_API_KEY` is not set; see below):
++- `GET /api/admin/email` — view current email config (`api_key_source`: `env`, `database` or `unset`)
- `PUT /api/admin/email` — update API key, default from address, and per-service from addresses
- `POST /api/admin/email/test` — send a test email
++## Provider secrets
++
++Each provider secret has an environment variable, read first; the `site_settings` row is only a
++fallback (`server/secrets.js`, roadmap §18.2(12)). While a variable is set, the admin panel shows the
++secret as "set in the environment" and never saves one into the database. `GET /api/admin/secrets`
++(owner) and the boot line `[Secrets] resend_api_key=env …` say which source each one uses, never a value.
++
++| Setting | Variable | Used for |
++| --- | --- | --- |
++| `resend_api_key` | `RESEND_API_KEY` | email delivery |
++| `resend_webhook_secret` | `RESEND_WEBHOOK_SECRET` | Resend delivery webhooks (Svix `whsec_…`) |
++| `discord_bot_token` | `DISCORD_BOT_TOKEN` | Discord bot alerts |
++| `discord_oauth_client_secret` | `DISCORD_OAUTH_CLIENT_SECRET` | Discord account linking |
++| `vapid_private_key` | `VAPID_PRIVATE_KEY` | web push (the public key is derived from it; `VAPID_PUBLIC_KEY` optional) |
++
++`net.ipinfo_token`, `net.globalping_token` and the old `ses_*` keys are never read by Network (the Tools
++gateway reads `NET_IPINFO_TOKEN` / `NET_GLOBALPING_TOKEN` from its own env file).
++
++Moving them out of the database (operator):
++
++```bash
++cd /opt/openvibe.network
++sudo node scripts/secrets-out-of-db.js # dry run: names, env file and running service, per secret
++# copy each value the dry run lists into /etc/openvibe/network.env (VAR=value), then
++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.
++
---
## Anonymous Users
ddiff --git a/package.json b/package.json
iindex 405e955c..3d4408ae 100644
---- a/package.json
++++ b/package.json
@@@ -6,7 +6,7 @@
"scripts": {
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
-- "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js",
++ "test": "node test/registry-bootstrap.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node test/chrome.test.js && node test/avatar.test.js && node test/roadmap-baseline.test.js && node test/identity-subjects.test.js && node test/principals.test.js && node test/modules.test.js && node test/module-events.test.js && node test/registry-ecosystem.test.js && node test/registry-exposure.test.js && node test/registry-topics.test.js && node test/service-principal.test.js && node test/oauth-pkce.test.js && node test/developer-projects.test.js && node test/developer-defaults.test.js && node test/public-cors.test.js && node test/observability.test.js && node test/status.test.js && node test/events-consumer.test.js && node test/security-session.test.js && node test/security-redirects.test.js && node test/analytics-privacy.test.js && node test/data-ownership.test.js && node test/unknown-paths.test.js && node test/oidc-discovery.test.js && node test/refresh-tokens.test.js && node test/provider-secrets.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/public/admin.html b/public/admin.html
iindex 06230a62..0274ab8d 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -1374,6 +1374,12 @@ async function loadToolsSettings() {
${keys.map(key => {
const s = settings[key];
const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
++ if (s.source === 'env') return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ </div>`;
if (s.type === 'boolean') return `
<div class="setting-row">
<label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
@@@ -1384,6 +1390,7 @@ async function loadToolsSettings() {
<label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
<input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ ${s.source === 'database' && s.env ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}
</div>`;
}).join('')}
<div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
@@@ -1396,6 +1403,7 @@ async function saveToolsSettings() {
const inputs = document.querySelectorAll('#tools-settings-form [data-key]');
const changed = [];
for (const input of inputs) {
++ if (input.disabled) continue; // provided by the environment
const key = input.dataset.key;
const value = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
if (value.startsWith('••••')) continue; // masked secret, untouched
@@@ -2673,8 +2681,11 @@ 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>
-- <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.</p>
++ ${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>`}
<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>
@@@ -2738,7 +2749,7 @@ async function saveEmail() {
try {
await api('/api/admin/email', { method: 'PUT', body: {
enabled: document.getElementById('email-enabled').checked,
-- api_key: document.getElementById('email-api-key').value,
++ api_key: document.getElementById('email-api-key').disabled ? undefined : document.getElementById('email-api-key').value,
from_email: document.getElementById('email-from-email').value,
from_name: document.getElementById('email-from-name').value,
from_email_openvibelive: document.getElementById('email-from-openvibelive').value,
@@@ -3538,6 +3549,14 @@ async function loadAudit() {
// ═══════════════════════════════════════════════════════════════
// Discord Bot Settings
// ═══════════════════════════════════════════════════════════════
++// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
++function envSecretInput(sources, key, value, placeholder, style) {
++ 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>` : '');
++}
++
async function loadDiscordSettings() {
const c = document.getElementById('settings-discord');
c.innerHTML = '<p class="muted">Loading Discord settings...</p>';
@@@ -3557,7 +3576,7 @@ async function loadDiscordSettings() {
<form id="discord-settings-form">
<div class="setting-row">
<label>Bot Token</label>
-- <input type="password" data-key="discord_bot_token" value="${esc(s.discord_bot_token || '')}" placeholder="Bot token from Discord Developer Portal" style="width:100%;max-width:500px">
++ ${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}
</div>
<div class="setting-row">
<label>Guild / Server ID</label>
@@@ -3588,7 +3607,7 @@ async function loadDiscordSettings() {
</div>
<div class="setting-row">
<label>OAuth2 Client Secret</label>
-- <input type="password" data-key="discord_oauth_client_secret" value="${esc(s.discord_oauth_client_secret || '')}" placeholder="Discord application client secret" style="width:300px">
++ ${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}
</div>
<div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
<button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>
@@@ -3607,6 +3626,7 @@ async function saveDiscordSettings() {
const form = document.getElementById('discord-settings-form');
const settings = {};
form.querySelectorAll('[data-key]').forEach(el => {
++ if (el.disabled) return; // provided by the environment
settings[el.dataset.key] = el.value;
});
await api('/api/admin/discord', { method: 'PUT', body: JSON.stringify({ settings }) });
ddiff --git a/scripts/secrets-out-of-db.js b/scripts/secrets-out-of-db.js
nnew file mode 100644
iindex 00000000..4383a818
---- /dev/null
++++ b/scripts/secrets-out-of-db.js
@@@ -0,0 +1,171 @@
++#!/usr/bin/env node
++/**
++ * Provider secrets out of network.db (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).
++ *
++ * sudo node scripts/secrets-out-of-db.js
++ * dry run: every secret setting by name, whether network.db holds a value, its variable, whether
++ * the env file sets it (and to the same value), and whether the running service has it
++ * sudo node scripts/secrets-out-of-db.js --apply --backup <new file> [--allow-different]
++ * takes an online backup (0600, verified), 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
++ * sudo 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
++ *
++ * --env-file <path> default /etc/openvibe/network.env
++ * --db <path> default $DB_PATH, else data/network.db (relative to the repo root)
++ * --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. The script reads them, then
++ * becomes the database's owner before opening network.db, so no file changes owner.
++ */
++'use strict';
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const { execFileSync } = require('child_process');
++const Database = require('better-sqlite3');
++const ops = 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>] [--db <path>] [--unit <name> | --no-service-check]
++ 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');
++
++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. */
++function classify(db) {
++ const rows = 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;
++}
++
++async function main(argv, log = console.log, { readService = serviceEnv } = {}) {
++ let args;
++ try { args = ops.parseArgs(argv, { flags: ['apply', 'allow-different', 'no-service-check'], values: ['db', 'backup', 'env-file', 'unit', 'restore-from'] }); } catch (e) { log(`${e.message}\n${USAGE}`); return 2; }
++ if (args.help) { log(USAGE); return 0; }
++ const file = ops.dbPath(args);
++ if (!fs.existsSync(file)) { log(`error: no database at ${file}`); return 2; }
++ const envFile = args['env-file'] || '/etc/openvibe/network.env';
++
++ // Root-only reads first, then drop to the database owner.
++ const envf = args['restore-from'] ? { vars: {} } : readEnvFile(envFile);
++ const svc = args['restore-from'] || args['no-service-check'] ? null : readService(args.unit || 'openvibe-network');
++ ops.dropToOwnerOf(file, log);
++
++ const db = new Database(file);
++ db.pragma('busy_timeout = 5000');
++ try {
++ log(`database ${file}`);
++ if (args['restore-from']) {
++ const backup = new Database(path.resolve(ops.ROOT, args['restore-from']), { readonly: true, fileMustExist: true });
++ const saved = new Map(backup.prepare('SELECT key, value FROM site_settings').all().map(r => [r.key, r.value]));
++ backup.close();
++ const todo = 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; }
++ const up = db.prepare("UPDATE site_settings SET value = ? WHERE key = ? AND (value IS NULL OR value = '')");
++ let n = 0;
++ db.transaction(() => { for (const c of todo) n += up.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 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> (sqlite online backup, 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 total = db.prepare('SELECT COUNT(*) AS n FROM site_settings').get().n;
++ const target = await ops.backupTo(db, args.backup, { expect: { table: 'site_settings', rows: total } });
++ log(`backup ${target} (0600, quick_check ok). It holds the secrets (owner-only, like network.db): delete it once the rollback window is over.`);
++ const up = db.prepare("UPDATE site_settings SET value = '' WHERE key = ?");
++ let n = 0;
++ db.transaction(() => { for (const p of blank) n += up.run(p.key).changes; })();
++ log(`blanked ${n} setting(s): ${blank.map(p => p.key).join(', ')}`);
++ return 0;
++ } finally { db.close(); }
++}
++
++if (require.main === module) main(process.argv.slice(2)).then((code) => process.exit(code), (err) => { console.error(`error: ${err.message}`); process.exit(1); });
++
++module.exports = { main, classify, parseEnvText };
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 5d84d0ef..385764a0 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -15,6 +15,7 @@ const childProcess = require('child_process');
const urlRegistry = require('../url-registry');
const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
const { isOwner, requireOwner, isSensitiveSettingKey, maskSecret } = require('../auth/owner-guard');
++const secrets = require('../secrets');
const { issueResetToken } = require('../auth/reset-tokens');
const { checkAdminLimit, recordAdminAction } = require('../auth/admin-limits');
@@@ -252,11 +253,16 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
const { enabled, api_key, from_email, from_name,
from_email_openvibelive, from_email_openvibegames, from_email_openvibenetwork } = req.body;
const setSetting = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ const skipped = [];
const tx = db.transaction(() => {
if (enabled !== undefined) setSetting.run('email_enabled', String(enabled), 'boolean');
-- // Only update API key if it's not a masked placeholder
-- if (api_key && !/\u2022/.test(api_key)) setSetting.run('resend_api_key', api_key, 'string');
++ // 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 (secrets.source(db, 'resend_api_key') === 'env') skipped.push('resend_api_key');
++ else setSetting.run('resend_api_key', api_key, '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');
// Per-service from addresses
@@@ -273,7 +279,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
req.user.id, 'email_config_update', JSON.stringify({ from_email })
);
-- res.json({ ok: true, email: emailService.getStatus() });
++ res.json({ ok: true, email: emailService.getStatus(), ...(skipped.length ? { skipped, note: 'set in the environment; not saved to the database' } : {}) });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
}
@@@ -296,6 +302,11 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Site Settings
// ═══════════════════════════════════════════════════════
++ // Where each provider secret comes from (server/secrets.js): names and sources, never values.
++ router.get('/secrets', requireOwner, (req, res) => {
++ res.json({ ok: true, secrets: secrets.report(db) });
++ });
++
// Core site settings are owner-only (off-limits to admins).
router.get('/settings', requireOwner, (req, res) => {
try {
@@@ -312,6 +323,15 @@ 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.
++ if (secrets.isManaged(r.key)) {
++ const src = 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 (!owner) { settings[r.key] = { value: maskSecret(r.value), type: r.type, source: src, env: secrets.envName(r.key), redacted: true }; continue; }
++ settings[r.key] = { value: r.value, type: r.type, source: src, env: secrets.envName(r.key) };
++ continue;
++ }
// Secrets (API keys / tokens / credentials) are owner-only — mask for admins.
if (!owner && (r.type === 'secret' || isSensitiveSettingKey(r.key))) {
settings[r.key] = { value: maskSecret(r.value), type: r.type, redacted: true };
@@@ -337,6 +357,10 @@ 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.
++ if (secrets.isManaged(key) && 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` });
++ }
db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)').run(key, String(value), type || 'string');
db.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
req.user.id, 'setting_update', JSON.stringify({ key })
ddiff --git a/server/db/database.js b/server/db/database.js
iindex 77f381a5..e3593d62 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -680,7 +680,11 @@ function initDb(dbPath) {
// used to live here was removed (compatibility register C-58).
// ── Helper: getSetting ───────────────────────────────────
++ // Provider secrets (server/secrets.js) come from their environment variable when it is set.
++ const secrets = require('../secrets');
db.getSetting = function (key) {
++ const fromEnv = secrets.fromEnv(key);
++ if (fromEnv !== null) return fromEnv;
const row = this.prepare('SELECT value, type FROM site_settings WHERE key = ?').get(key);
if (!row) return null;
if (row.type === 'boolean') return row.value === 'true';
ddiff --git a/server/discord/routes.js b/server/discord/routes.js
iindex b29f8c45..8bc849b7 100644
---- a/server/discord/routes.js
++++ b/server/discord/routes.js
@@@ -7,6 +7,7 @@
const express = require('express');
const { isOwner, isSensitiveSettingKey } = require('../auth/owner-guard');
++const secrets = require('../secrets');
module.exports = function createDiscordRoutes(db, discordService, requireAuth, requireAdmin) {
const router = express.Router();
@@@ -26,7 +27,10 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
discord_oauth_client_id: db.getSetting('discord_oauth_client_id') || '',
discord_oauth_client_secret: db.getSetting('discord_oauth_client_secret') ? '••••••••' : '',
};
-- res.json({ ok: true, status, settings });
++ // env: the variable provides it and the form cannot change it (server/secrets.js).
++ const sources = {};
++ for (const k of ['discord_bot_token', 'discord_oauth_client_secret']) sources[k] = { source: secrets.source(db, k), env: secrets.envName(k) };
++ res.json({ ok: true, status, settings, sources });
});
/** PUT /api/admin/discord — update Discord configuration */
@@@ -48,8 +52,11 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
let tokenChanged = false;
const owner = isOwner(req.user);
++ const skipped = [];
for (const [key, value] of Object.entries(settings)) {
if (!allowedKeys.includes(key)) continue;
++ // Never save a secret into the database while its environment variable provides it.
++ if (secrets.isManaged(key) && secrets.source(db, key) === 'env') { 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();
@@@ -70,7 +77,7 @@ module.exports = function createDiscordRoutes(db, discordService, requireAuth, r
}
}
-- res.json({ ok: true, status: discordService.getStatus() });
++ res.json({ ok: true, status: discordService.getStatus(), ...(skipped.length ? { skipped, note: 'set in the environment; not saved to the database' } : {}) });
});
/** POST /api/admin/discord/test — send a test alert to the configured channel */
ddiff --git a/server/index.js b/server/index.js
iindex 4ea531ee..b1d06b2a 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -348,6 +348,9 @@ app.locals.privateKey = privateKey;
app.locals.publicKey = publicKey;
app.locals.config = config;
++// Provider secrets: environment first, database fallback (server/secrets.js). Names and sources only.
++console.log(`[Secrets] ${require('./secrets').summary(db)}`);
++
// ── Initialize Services ──────────────────────────────────────
const notificationService = new NotificationService(db);
const emailService = new EmailService(db);
ddiff --git a/server/notifications/email-service.js b/server/notifications/email-service.js
iindex 489a453c..bf6d5c1b 100644
---- a/server/notifications/email-service.js
++++ b/server/notifications/email-service.js
@@@ -427,6 +427,7 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
getStatus() {
const apiKey = this.db.getSetting('resend_api_key') || '';
const issue = this.diagnose();
++ const secrets = require('../secrets');
return {
enabled: this._enabled,
ready: this.isEnabled,
@@@ -440,6 +441,9 @@ body { margin: 0; padding: 0; background: #1a1a24; font-family: -apple-system, B
hasApiKey: !!apiKey,
// Masked API key for display (show first 6 chars + last 4)
api_key: apiKey ? apiKey.slice(0, 6) + '••••' + apiKey.slice(-4) : '',
++ // Where the key comes from: env (RESEND_API_KEY, the admin UI cannot change it), database, unset.
++ api_key_source: secrets.source(this.db, 'resend_api_key'),
++ api_key_env: secrets.envName('resend_api_key'),
};
}
ddiff --git a/server/push/push-service.js b/server/push/push-service.js
iindex 21e7b598..8b968274 100644
---- a/server/push/push-service.js
++++ b/server/push/push-service.js
@@@ -1,6 +1,7 @@
'use strict';
let _db = null;
++let _publicKey = null;
let webpush;
try {
@@@ -13,19 +14,38 @@ try {
* Initialize VAPID keys. Generates a new keypair on first run and stores in site_settings.
* @param {object} db - better-sqlite3 Database instance
*/
++function publicFromPrivate(privateKey) {
++ const ecdh = require('crypto').createECDH('prime256v1');
++ ecdh.setPrivateKey(Buffer.from(String(privateKey), 'base64url'));
++ return ecdh.getPublicKey().toString('base64url');
++}
++
function initVapid(db) {
_db = db;
if (!webpush) return;
const upsert = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ // Both are environment-first (VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY, server/secrets.js), else site_settings.
let publicKey = db.getSetting('vapid_public_key');
let privateKey = db.getSetting('vapid_private_key');
++ // The public key is derived from the private one, so a private key alone (or a stale public key
++ // beside it) still gives a matching pair; a new pair is made only when there is no private key.
++ if (privateKey) {
++ try {
++ const derived = publicFromPrivate(privateKey);
++ if (publicKey && publicKey !== derived) console.warn('[push] vapid_public_key does not match the private key; using the key derived from it');
++ publicKey = derived;
++ } catch (err) { console.warn('[push] VAPID private key unreadable:', err.message); }
++ }
++ _publicKey = publicKey || null;
++
if (!publicKey || !privateKey) {
const keys = webpush.generateVAPIDKeys();
publicKey = keys.publicKey;
privateKey = keys.privateKey;
upsert.run('vapid_public_key', publicKey, 'secret');
upsert.run('vapid_private_key', privateKey, 'secret');
++ _publicKey = publicKey;
console.log('[push] Generated new VAPID keypair');
}
@@@ -38,7 +58,7 @@ function initVapid(db) {
* Get the public VAPID key for client subscription.
*/
function getPublicKey() {
-- return _db?.getSetting('vapid_public_key') || null;
++ return _publicKey || _db?.getSetting('vapid_public_key') || null;
}
/**
ddiff --git a/server/secrets.js b/server/secrets.js
nnew file mode 100644
iindex 00000000..086dc4ca
---- /dev/null
++++ b/server/secrets.js
@@@ -0,0 +1,77 @@
++'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).
++//
++// 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).
++//
++// 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.
++// ═══════════════════════════════════════════════════════════════
++
++const SECRETS = [
++ { key: 'resend_api_key', env: 'RESEND_API_KEY', use: 'email delivery (Resend API key)' },
++ { key: 'resend_webhook_secret', env: 'RESEND_WEBHOOK_SECRET', use: 'Resend delivery webhooks (Svix signing secret, whsec_...)' },
++ { key: 'discord_bot_token', env: 'DISCORD_BOT_TOKEN', use: 'Discord bot (go-live and system alerts)' },
++ { key: 'discord_oauth_client_secret', env: 'DISCORD_OAUTH_CLIENT_SECRET', use: 'Discord account linking (OAuth client secret)' },
++ { key: 'vapid_private_key', env: 'VAPID_PRIVATE_KEY', use: 'web push (VAPID private key; must stay the pair of vapid_public_key)' },
++];
++
++// Read environment-first too, but not secret: never blanked, only listed.
++const COMPANIONS = [
++ { key: 'vapid_public_key', env: 'VAPID_PUBLIC_KEY', use: 'web push (VAPID public key, served to browsers; the pair of VAPID_PRIVATE_KEY)' },
++];
++
++// Secret-looking settings Network stores but never reads. The Net tools tokens are read by the Tools
++// gateway from its own environment (NET_IPINFO_TOKEN, NET_GLOBALPING_TOKEN in tools.env); the SES keys
++// are from the email provider before Resend.
++const UNUSED = new Map([
++ ['net.ipinfo_token', 'not read by Network (the Tools gateway reads NET_IPINFO_TOKEN from tools.env)'],
++ ['net.globalping_token', 'not read by Network (the Tools gateway reads NET_GLOBALPING_TOKEN from tools.env)'],
++ ['ses_access_key_id', 'not read by Network (Amazon SES, replaced by Resend)'],
++ ['ses_secret_access_key', 'not read by Network (Amazon SES, replaced by Resend)'],
++]);
++
++const BY_KEY = new Map([...SECRETS, ...COMPANIONS].map(s => [s.key, s]));
++
++/** The environment value for a managed key, or null (unset or blank). */
++function fromEnv(key, env = process.env) {
++ const s = BY_KEY.get(key);
++ if (!s) return null;
++ const v = env[s.env];
++ return typeof v === 'string' && v.trim() ? v.trim() : null;
++}
++
++const isManaged = (key) => BY_KEY.has(key);
++const isSecret = (key) => SECRETS.some(s => s.key === key);
++const envName = (key) => (BY_KEY.get(key) || {}).env || null;
++
++function dbValue(db, key) {
++ try { const r = 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. */
++function source(db, key, env = process.env) {
++ if (fromEnv(key, env) !== null) return 'env';
++ return dbValue(db, key) ? 'database' : 'unset';
++}
++
++/** Where each secret comes from, by name only: [{ key, env, source, database_copy, use }]. Never values. */
++function report(db, env = process.env) {
++ return [...SECRETS, ...COMPANIONS].map(s => ({
++ key: s.key, env: s.env, secret: isSecret(s.key), source: source(db, s.key, env),
++ database_copy: !!dbValue(db, s.key), use: s.use,
++ }));
++}
++
++/** One boot line: "resend_api_key=env discord_bot_token=database ..." (names and sources only). */
++function summary(db, env = process.env) {
++ return report(db, env).filter(r => r.secret).map(r => `${r.key}=${r.source}`).join(' ');
++}
++
++module.exports = { SECRETS, COMPANIONS, UNUSED, fromEnv, isManaged, isSecret, envName, source, report, summary };
ddiff --git a/test/provider-secrets.test.js b/test/provider-secrets.test.js
nnew file mode 100644
iindex 00000000..50dcd9f8
---- /dev/null
++++ b/test/provider-secrets.test.js
@@@ -0,0 +1,196 @@
++'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.
++// node test/provider-secrets.test.js
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const express = require('express');
++const { initDb } = require('../server/db/database');
++const secrets = require('../server/secrets');
++
++const ENV_NAMES = ['RESEND_API_KEY', 'RESEND_WEBHOOK_SECRET', 'DISCORD_BOT_TOKEN', 'DISCORD_OAUTH_CLIENT_SECRET', 'VAPID_PRIVATE_KEY', 'VAPID_PUBLIC_KEY'];
++for (const n of ENV_NAMES) delete process.env[n];
++process.env.OWNER_USERNAME = 'owner';
++
++const quiet = async (fn) => { const l = console.log, w = console.warn; console.log = () => {}; console.warn = () => {}; try { return await fn(); } finally { console.log = l; console.warn = w; } };
++
++(async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-secrets-'));
++ const dbPath = path.join(dir, 'network.db');
++ const db = await quiet(() => initDb(dbPath));
++ const set = db.prepare('INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, ?)');
++ const dbVal = (k) => (db.prepare('SELECT value FROM site_settings WHERE key = ?').get(k) || {}).value;
++ set.run('resend_api_key', 're_db_key_111', 'string');
++ set.run('resend_webhook_secret', 'whsec_db_222', 'string');
++ set.run('discord_bot_token', 'db.bot.token.333', 'secret');
++ set.run('discord_oauth_client_secret', 'db-oauth-secret-444', 'secret');
++ set.run('net.ipinfo_token', 'ipinfo-555', 'secret');
++ set.run('ses_secret_access_key', 'ses-666', 'string');
++ set.run('some_new_api_key', 'unknown-777', 'string');
++
++ // ── Environment first, database fallback ──
++ assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'no variable: the database value');
++ assert.strictEqual(secrets.source(db, 'resend_api_key'), 'database');
++ process.env.RESEND_API_KEY = 're_env_key_999';
++ assert.strictEqual(db.getSetting('resend_api_key'), 're_env_key_999', 'the variable wins');
++ assert.strictEqual(secrets.source(db, 'resend_api_key'), 'env');
++ process.env.RESEND_API_KEY = ' ';
++ assert.strictEqual(db.getSetting('resend_api_key'), 're_db_key_111', 'a blank variable is unset');
++ process.env.RESEND_API_KEY = 're_env_key_999';
++ assert.strictEqual(secrets.source(db, 'resend_webhook_secret'), 'database');
++ assert.strictEqual(db.getSetting('email_user_daily_cap'), 30, 'other settings are untouched');
++ const rep = 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']);
++ 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(secrets.summary(db), /resend_api_key=env resend_webhook_secret=database discord_bot_token=database/);
++
++ // The email service reads it through db.getSetting, and says where it came from.
++ const { EmailService } = require('../server/notifications/email-service');
++ const email = await quiet(() => new EmailService(db));
++ assert.strictEqual(email.getStatus().api_key_source, 'env');
++ assert.strictEqual(email.getStatus().api_key_env, 'RESEND_API_KEY');
++ assert.ok(email.getStatus().api_key.startsWith('re_env'), 'the masked key shown is the one in use');
++
++ // ── Web push: the public key follows the private key from the environment ──
++ const webpush = require('web-push');
++ const pair = webpush.generateVAPIDKeys();
++ process.env.VAPID_PRIVATE_KEY = pair.privateKey;
++ const push = require('../server/push/push-service');
++ await quiet(() => push.initVapid(db));
++ assert.strictEqual(push.getPublicKey(), pair.publicKey, 'the public key is derived from VAPID_PRIVATE_KEY');
++ assert.notStrictEqual(dbVal('vapid_private_key'), pair.privateKey, 'the environment key is not copied into the database');
++
++ // ── Admin API: sources shown, env-provided secrets never saved ──
++ const createAdminRoutes = require('../server/admin/routes');
++ const createDiscordRoutes = require('../server/discord/routes');
++ const app = express();
++ app.use(express.json());
++ app.locals.db = db;
++ const who = { id: 1, username: 'owner', role: 'admin' };
++ const requireAuth = (req, _res, next) => { req.user = who; next(); };
++ const requireAdmin = (req, res, next) => (req.user.role === 'admin' ? next() : res.status(403).end());
++ const discordService = { getStatus: () => ({ connected: false }), reinit: async () => {} };
++ db.prepare("INSERT INTO users (id, username, password_hash, role) VALUES (1, 'owner', 'x', 'admin')").run();
++ app.use('/api/admin/discord', createDiscordRoutes(db, discordService, requireAuth, requireAdmin));
++ app.use('/api/admin', createAdminRoutes(db, { create() {} }, email, requireAuth));
++ const server = http.createServer(app);
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = (method, p, body) => fetch(base + p, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }).then(async r => ({ status: r.status, body: await r.json() }));
++
++ let r = await call('GET', '/api/admin/secrets');
++ assert.strictEqual(r.status, 200);
++ const byKey = Object.fromEntries(r.body.secrets.map(s => [s.key, s]));
++ assert.strictEqual(byKey.resend_api_key.source, 'env');
++ assert.strictEqual(byKey.resend_api_key.env, 'RESEND_API_KEY');
++ assert.strictEqual(byKey.discord_bot_token.source, 'database');
++ assert.strictEqual(byKey.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.
++ 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(dbVal('resend_api_key'), 're_db_key_111', 'the database copy is not overwritten');
++
++ // Generic settings: an env-provided secret shows no value and cannot be saved; a database one can.
++ 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(dbVal('resend_webhook_secret'), 'whsec_db_222', 'not saved while the environment provides it');
++ 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(dbVal('resend_webhook_secret'), 'whsec_from_ui', 'saved when the environment does not provide it');
++ set.run('resend_webhook_secret', 'whsec_db_222', 'string');
++
++ // 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(dbVal('discord_bot_token'), 'db.bot.token.333');
++ assert.strictEqual(dbVal('discord_guild_id'), '42', 'other Discord settings still save');
++ server.close();
++
++ // ── scripts/secrets-out-of-db.js ──
++ const script = require('../scripts/secrets-out-of-db');
++ const envFile = path.join(dir, 'network.env');
++ // The lead copied three values into the env file: two equal to the database, one different.
++ fs.writeFileSync(envFile, [
++ 'NODE_ENV=production',
++ 'RESEND_API_KEY=re_db_key_111',
++ 'DISCORD_BOT_TOKEN=db.bot.token.333',
++ 'DISCORD_OAUTH_CLIENT_SECRET=rotated-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(['--db', dbPath, '--env-file', envFile, ...argv], (m) => out.push(m), { readService: () => service });
++ 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)}…)`); };
++
++ assert.strictEqual(await run([]), 0);
++ noValues();
++ let 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: not set.*\n\s+--apply: keep: put RESEND_WEBHOOK_SECRET in the env file first/);
++ 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(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.db');
++ assert.strictEqual(await run(['--apply', '--backup', backup], { vars: { RESEND_API_KEY: 're_db_key_111' }, pid: 1 }), 0);
++ assert.strictEqual(dbVal('resend_api_key'), '', 'blanked: env file and running service have it');
++ assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333', 'kept: the running service does not have DISCORD_BOT_TOKEN yet');
++ assert.strictEqual(dbVal('discord_oauth_client_secret'), 'db-oauth-secret-444', 'kept: different value');
++ assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222', 'kept: not in the env file');
++ assert.strictEqual(dbVal('net.ipinfo_token'), '', 'unused secret blanked');
++ assert.strictEqual(dbVal('ses_secret_access_key'), '', 'unused secret blanked');
++ assert.strictEqual(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.db'), '--allow-different']), 0);
++ assert.strictEqual(dbVal('discord_bot_token'), '');
++ assert.strictEqual(dbVal('discord_oauth_client_secret'), '', '--allow-different blanks a differing copy');
++ assert.strictEqual(dbVal('resend_webhook_secret'), 'whsec_db_222');
++ noValues();
++ // An unreadable env file refuses --apply.
++ out.length = 0;
++ assert.strictEqual(await script.main(['--db', dbPath, '--env-file', path.join(dir, 'missing.env'), '--apply', '--backup', path.join(dir, 'x.db')], (m) => out.push(m), { readService: () => running }), 2);
++
++ // Rollback from the first backup: every blanked value comes back; nothing printed.
++ out.length = 0;
++ assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup], (m) => out.push(m)), 0);
++ assert.strictEqual(dbVal('discord_bot_token'), '', 'the rollback dry run changes nothing');
++ assert.strictEqual(await script.main(['--db', dbPath, '--restore-from', backup, '--apply'], (m) => out.push(m)), 0);
++ assert.strictEqual(dbVal('resend_api_key'), 're_db_key_111');
++ assert.strictEqual(dbVal('discord_bot_token'), 'db.bot.token.333');
++ assert.strictEqual(dbVal('net.ipinfo_token'), 'ipinfo-555');
++ assert.strictEqual(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); });