openvibers/openvibe.network Commits

Security crawl for secrets (roadmap WS-R task 5: internal-secret leak). test/security-secrets.test.js boots the real server with a throwaway RS256 and VAPID pair and a fake low-entropy sentinel in every secret it reads from the environment, seeds the database-held ones (OAuth client secrets, developer credentials overlapping after a rotation and their hashes, an export token, a refresh token and its hash, session and anonymous tokens, push keys, provider-secret copies, password hashes, a verification key), then requests every GET route Express knows after boot (test/security-crawl.js walks the router stack from a preload, so a new route is crawled without anyone listing it) as anonymous, a user, a staff admin, the owner, a service principal, a developer app and the legacy key, plus file probes, malformed and empty writes to every write route and wrong credentials. No body or header may carry a secret (also URL-encoded or base64); the log, the event outbox and the audit tables neither. The owner's clear-text reads of provider secrets (owner-guard.js) are the documented exception. test/security-world.js is the shared fixture for the security suites.

OP OpenVibers committed 26 Sept 2026 · 655e8ad5d8 · 9 files, +629 −3

Fixes: a Network started without its key files published its ephemeral HS256 secret as public_key at /api/.well-known/jwks, so anyone could sign a session for any account, admins included (high: every keyless start, and production if the key files go missing); it now serves no key then. GET /internal/url-registry/resolved gave the owner's DEPLOY_CLOUDFLARE_TOKEN to every service holding identity.subject.resolve and to the legacy key (medium); secret-typed entries are left out. Token answers were cacheable: /oauth/token's code and refresh grants and /api/auth's login, refresh, session and anonymous-identity answers had no Cache-Control, nor the owner's settings and registry reads (low); they are no-store now.

Changes

ddiff --git a/package.json b/package.json
iindex a06afd33..1993d7cd 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/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.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-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.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/frame.test.js && node test/github-integration.test.js && node test/revocation.test.js && node test/usernames.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/operator-alerts.test.js && node test/follows.test.js && node test/follows-authority.test.js && node test/creator-analytics.test.js && node test/status-incidents.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-manifest-fixture.test.js && node test/staff-claims.test.js && node test/profile-events.test.js && node test/admin-role-events.test.js && node test/grants-admin.test.js && node test/events-ops.test.js && node test/deploy-drift.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/developer-export-tokens.test.js && node test/project-usage.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/notification-events.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 && node test/first-party-origins.test.js && node test/updates.test.js && node test/static-headers.test.js && node test/library-tags.test.js && node test/moderation-audit.test.js && node test/blocks.test.js && node test/site-config.test.js && node test/perf-budget.test.js && node test/shutdown.test.js && node test/loyalty-policy.test.js && node test/themes-review.test.js && node test/page-checks.test.js && node test/operator-checklist.test.js && node test/release-health.test.js && node test/security-secrets.test.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
},
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex e2f84dbb..875b0514 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -301,6 +301,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// Core site settings are owner-only (off-limits to admins).
router.get('/settings', requireOwner, (req, res) => {
++ res.set('Cache-Control', 'private, no-store'); // the owner sees secrets in clear here
try {
const rows = db.prepare('SELECT * FROM site_settings').all();
const settings = {};
@@@ -396,6 +397,7 @@ function createAdminRoutes(db, notificationService, emailService, requireAuth) {
// URL Registry
router.get('/url-registry', (req, res) => {
++ res.set('Cache-Control', 'private, no-store'); // the owner sees secret-typed values in clear here
try {
let entries = urlRegistry.getAllRegistryEntries(db);
// Mask secret-typed registry values (e.g. DEPLOY_CLOUDFLARE_TOKEN) for non-owners.
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex e43ef9db..699b0e2c 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -216,6 +216,7 @@ router.post('/token', (req, res) => {
const db = getDb(req);
const config = getConfig(req);
const { grant_type, client_id, client_secret, code, redirect_uri, refresh_token } = req.body;
++ res.set('Cache-Control', 'no-store'); // every grant answers with tokens (RFC 6749 §5.1)
// Developer apps (app_<ULID>): client_credentials or authorization_code + PKCE, own credential store.
if (devTokens.isAppClient(client_id)) {
ddiff --git a/server/auth/routes.js b/server/auth/routes.js
iindex a12581b7..185d0e1d 100644
---- a/server/auth/routes.js
++++ b/server/auth/routes.js
@@@ -14,6 +14,9 @@ const subjects = require('../identity/subjects');
const { staffClaims } = require('./staff-claims');
const revocation = require('./revocation');
const router = express.Router();
++// Sign-in, refresh, password change, sessions and anonymous identities answer with tokens: none of
++// these answers may be kept by a browser or proxy cache (a route may still set its own policy).
++router.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
// ── Helpers ──────────────────────────────────────────────────
ddiff --git a/server/index.js b/server/index.js
iindex ec633f4e..fb90de36 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -457,7 +457,10 @@ app.use(require('./status/routes').createStatusRoutes({ ecosystem }));
app.use(require('./updates/routes').createUpdatesRoutes({ blogUrl: process.env.OV_BLOG_INTERNAL_URL || 'http://127.0.0.1:4810' }).router);
app.get('/api/.well-known/jwks', (_req, res) => {
-- const out = { public_key: publicKey, algorithm: privateKey === publicKey ? 'HS256' : 'RS256' };
++ // Without key files the ephemeral HS256 "key pair" is one shared secret: publishing it would let
++ // anyone sign sessions. Only a real public key is ever served.
++ if (privateKey === publicKey || !String(publicKey).includes('BEGIN')) return res.json({ algorithm: 'HS256', keys: [] });
++ const out = { public_key: publicKey, algorithm: 'RS256' };
if (publicKey.includes('BEGIN')) {
try {
const jwk = require('crypto').createPublicKey(publicKey).export({ format: 'jwk' });
ddiff --git a/server/internal/routes.js b/server/internal/routes.js
iindex 3e12cc73..7301c97b 100644
---- a/server/internal/routes.js
++++ b/server/internal/routes.js
@@@ -242,11 +242,15 @@ router.get('/stats', (req, res) => {
});
// Service URLs only (no secrets); any first-party service that resolves identities may read them.
++// Secret-typed entries (DEPLOY_CLOUDFLARE_TOKEN) are left out: they are the owner's, not the services'.
// TODO(contracts): a network.registry.read capability would say it more exactly.
router.get('/url-registry/resolved', principals.guard('identity.subject.resolve'), (req, res) => {
try {
const db = getDb(req);
-- const resolved = urlRegistry.getResolvedRegistry(db, process.env);
++ const { URL_DEFINITIONS } = require('openvibe-shared/url-resolver');
++ const { isSensitiveSettingKey } = require('../auth/owner-guard');
++ const resolved = Object.fromEntries(Object.entries(urlRegistry.getResolvedRegistry(db, process.env))
++ .filter(([key]) => !((URL_DEFINITIONS[key] || {}).type === 'secret' || isSensitiveSettingKey(key))));
res.json({ ok: true, registry: resolved });
} catch (err) {
res.status(500).json({ ok: false, error: err.message });
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 00000000..1bde33fb
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,193 @@
++'use strict';
++/**
++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
++ * Network (walking Express's router stack inside the server process, so a route added later is
++ * crawled without anyone listing it), fills route parameters with seeded values, and requests each
++ * path as several callers, reporting any response whose body or headers carry a value that caller
++ * must never see. Not a test itself (no .test.js); used with test/helpers/boot-server.js.
++ *
++ * The server runs in a child process, so the route list comes from a preload (routeDumpPreload())
++ * that wraps express's app.listen and writes the router stack to a file once the server listens; the
++ * preload also refuses every TCP connection off loopback.
++ */
++const fs = require('fs');
++const path = require('path');
++
++const ROOT = path.join(__dirname, '..');
++
++/** The path an Express 4 layer is mounted at ('' for app-level middleware), or null when it is a pattern. */
++function mountPath(layer) {
++ if (!layer.regexp || layer.regexp.fast_slash) return '';
++ let src = layer.regexp.source.replace(/^\^/, '').replace(/\\\/\?\(\?=\\\/\|\$\)$/i, '').replace(/\/\?\(\?=\/\|\$\)$/i, '');
++ let i = 0;
++ src = src.replace(/\(\?:\(\[\^\\\/\]\+\?\)\)/g, () => `:${(layer.keys[i++] || {}).name || 'param'}`);
++ src = src.replace(/\\\//g, '/').replace(/\\\./g, '.').replace(/\\-/g, '-');
++ return /[\\^$()|[\]*+?]/.test(src) ? null : src;
++}
++
++/** Every route of an Express app: [{ path, methods }] ('_all' for mounted middleware with a path). */
++function listAppRoutes(app) {
++ const out = [];
++ const walk = (stack, prefix) => {
++ for (const layer of stack) {
++ if (layer.route) {
++ const methods = Object.keys(layer.route.methods).filter((m) => layer.route.methods[m]);
++ for (const p of [].concat(layer.route.path)) if (typeof p === 'string') out.push({ path: prefix + p, methods });
++ } else if (layer.handle && Array.isArray(layer.handle.stack)) {
++ const mp = mountPath(layer);
++ if (mp !== null) walk(layer.handle.stack, prefix + mp);
++ } else {
++ const mp = mountPath(layer);
++ if (mp) out.push({ path: prefix + mp, methods: ['_all'] });
++ }
++ }
++ };
++ walk(app._router.stack, '');
++ const seen = new Set();
++ return out.filter((r) => { const k = `${r.methods.join(',')} ${r.path}`; if (seen.has(k)) return false; seen.add(k); return true; });
++}
++
++/**
++ * Source of a preload for the child server: once app.listen() is listening, writes the route list
++ * (listAppRoutes) as JSON to process.env.OV_ROUTE_DUMP. Also keeps the process from reaching anything
++ * but loopback (as boot-server's own guard does), whatever client library connects.
++ */
++function routeDumpPreload() {
++ return `'use strict';
++const path = require('path');
++const express = require(${JSON.stringify(path.join(ROOT, 'node_modules', 'express'))});
++const { listAppRoutes } = require(${JSON.stringify(__filename)});
++const listen = express.application.listen;
++express.application.listen = function (...args) {
++ const app = this;
++ const server = listen.apply(app, args);
++ server.once('listening', () => {
++ try { require('fs').writeFileSync(process.env.OV_ROUTE_DUMP, JSON.stringify(listAppRoutes(app))); } catch (e) { console.error('route dump failed', e.message); }
++ });
++ return server;
++};
++// No traffic off the machine by any client (fetch, http(s), undici, discord.js, ws): every TCP
++// connect to something other than loopback fails the way an unreachable host does.
++const net = require('net');
++const realConnect = net.Socket.prototype.connect;
++net.Socket.prototype.connect = function (...args) {
++ let o = args[0];
++ if (Array.isArray(o)) o = o[0];
++ if (o && typeof o === 'object' && o.path) return realConnect.apply(this, args);
++ const host = String((o && typeof o === 'object') ? (o.host || o.hostname || 'localhost') : (typeof args[1] === 'string' ? args[1] : 'localhost')).replace(/^\\[|\\]$/g, '');
++ const port = (o && typeof o === 'object') ? o.port : args[0];
++ if (process.env.OV_CONNECT_LOG) { try { require('fs').appendFileSync(process.env.OV_CONNECT_LOG, host + ' ' + port + '\\n'); } catch { /* */ } }
++ if (host === '127.0.0.1' || host === 'localhost' || host === '::1') return realConnect.apply(this, args);
++ process.nextTick(() => this.destroy(Object.assign(new Error('test: no egress to ' + host), { code: 'ECONNREFUSED' })));
++ return this;
++};
++`;
++}
++
++/** Writes the preload into `dir` and returns the env that loads it: { NODE_OPTIONS, OV_ROUTE_DUMP, OV_CONNECT_LOG } (every TCP connect is logged as 'host port'). */
++function routeDumpEnv(dir) {
++ const file = path.join(dir, 'route-dump-preload.js');
++ fs.writeFileSync(file, routeDumpPreload());
++ return { NODE_OPTIONS: `--require ${file}`, OV_ROUTE_DUMP: path.join(dir, 'routes.json'), OV_CONNECT_LOG: path.join(dir, 'connects.log') };
++}
++
++/** The route list the child wrote (waits briefly for it). */
++async function readRoutes(dumpFile) {
++ for (let i = 0; i < 50; i++) {
++ try { return JSON.parse(fs.readFileSync(dumpFile, 'utf8')); } catch { await new Promise((r) => setTimeout(r, 100)); }
++ }
++ throw new Error('the server never wrote its route list');
++}
++
++/** Concrete paths for a template: candidate i of every parameter, for each i (no cross product). */
++function expand(template, values) {
++ const names = [];
++ const t = `/${template.replace(/^\/+/, '')}`.replace(/\*/g, 'x').replace(/:([A-Za-z0-9_]+)\??(\([^)]*\))?/g, (m, n) => { names.push(n); return `:${n}`; });
++ if (!names.length) return [t];
++ const lists = names.map((n) => values(n));
++ const width = Math.max(...lists.map((l) => l.length));
++ const paths = new Set();
++ for (let i = 0; i < width; i++) {
++ let j = 0;
++ paths.add(t.replace(/:([A-Za-z0-9_]+)/g, () => { const l = lists[j++]; return encodeURIComponent(String(l[Math.min(i, l.length - 1)])); }));
++ }
++ return [...paths];
++}
++
++/** Every path for `method` ('get', or a write method): each route expanded, also with `query` appended, plus `extra`. */
++function pathsFor(routes, values, { method = 'get', query = '', extra = [] } = {}) {
++ const paths = new Set();
++ for (const r of routes) {
++ if (!r.methods.includes(method) && !r.methods.includes('_all')) continue;
++ for (const p of expand(r.path, values)) {
++ paths.add(p);
++ if (query && !p.includes('?')) paths.add(`${p}?${query}`);
++ }
++ }
++ for (const p of extra) paths.add(p);
++ return [...paths];
++}
++
++/** The forms of a secret worth looking for: as is, URL-encoded, base64 and base64url. */
++function forms(value) {
++ const v = String(value);
++ const out = new Set([v, encodeURIComponent(v)]);
++ if (v.length >= 12) {
++ out.add(Buffer.from(v).toString('base64').replace(/=+$/, ''));
++ out.add(Buffer.from(v).toString('base64url'));
++ }
++ return [...out];
++}
++
++/** Which of `needles` ({ label: value }) `text` + `headers` carry: [{ label, where }]. */
++function leaks(res, needles) {
++ const found = [];
++ const h = res.headers;
++ const headerText = [...(h && typeof h.entries === 'function' ? h.entries() : Object.entries(h || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
++ for (const [label, value] of Object.entries(needles)) {
++ if (!value) continue;
++ for (const f of forms(value)) {
++ if (res.text && res.text.includes(f)) { found.push({ label, where: 'body' }); break; }
++ if (headerText.includes(f)) { found.push({ label, where: 'headers' }); break; }
++ }
++ }
++ return found;
++}
++
++/**
++ * Requests every path as every caller ({ who: headers }) with `method` (and `body` for writes).
++ * `needlesFor(who)` names what that caller must never see. Resolves { found, answered, statuses, byPath }.
++ */
++async function crawl(base, paths, people, needlesFor, { method = 'GET', body, concurrency = 8 } = {}) {
++ const found = [];
++ const statuses = {};
++ const byPath = {};
++ let answered = 0;
++ let n = 0;
++ const nextIp = () => { n++; return `198.19.${(n >> 8) & 255}.${n & 255}`; }; // the API limiter counts per address
++ for (const [who, headers] of Object.entries(people)) {
++ const needles = needlesFor(who);
++ const queue = [...paths];
++ await Promise.all(Array.from({ length: concurrency }, async () => {
++ while (queue.length) {
++ const p = queue.shift();
++ let r;
++ try {
++ const h = { 'x-forwarded-for': nextIp(), ...headers };
++ let b;
++ if (body !== undefined) { if (typeof body === 'string') b = body; else { b = JSON.stringify(body); h['content-type'] = 'application/json'; } }
++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual' });
++ r = { status: res.status, headers: res.headers, text: await res.text() };
++ } catch { r = { status: 0, text: '', headers: {} }; }
++ if (r.status) answered++;
++ const cls = r.status ? `${String(r.status)[0]}xx` : 'none';
++ statuses[cls] = (statuses[cls] || 0) + 1;
++ (byPath[`${who} ${method} ${p}`] = r.status);
++ for (const l of leaks(r, needles)) found.push(`${who}: ${method} ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
++ }
++ }));
++ }
++ return { found, answered, statuses, byPath };
++}
++
++module.exports = { mountPath, listAppRoutes, routeDumpPreload, routeDumpEnv, readRoutes, expand, pathsFor, forms, leaks, crawl };
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 00000000..3adf6135
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,197 @@
++'use strict';
++// Internal-secret leak (roadmap WS-R task 5). The real server boots with a throwaway RS256 pair, a
++// generated VAPID pair and a fake low-entropy sentinel in every other secret it reads from the
++// environment (test/security-world.js), plus database-held secrets: first-party OAuth client
++// secrets, developer app credentials (two overlapping after a rotation) and their hashes, an export
++// token, a refresh token and its hash, a session token, an anonymous identity's token, push keys,
++// provider-secret copies in site_settings, password hashes and a verification key.
++// Every GET route Express knows after boot (test/security-crawl.js walks the router, so a route added
++// later is crawled without anyone listing it) is requested with seeded and nonsense ids, with and
++// without a query string, plus probes for files that must never be served, as: anonymous, a user,
++// a staff admin, the owner, a service principal (live), a developer app and the legacy internal key.
++// Then every write route is sent malformed JSON (and anonymous an empty body), and wrong credentials.
++// No body or header may carry any secret (as is, URL-encoded or base64); the responses that show a
++// secret once (token, credential and session creation) must be Cache-Control: no-store. The log, the
++// event outbox and the audit tables written meanwhile must not carry one either. Also: a Network booted
++// without its key files never publishes its ephemeral HS256 secret as a "public key".
++// node test/security-secrets.test.js
++const assert = require('assert');
++const jwt = require('jsonwebtoken');
++const { buildWorld } = require('./security-world');
++const { bootServer } = require('./helpers/boot-server');
++const crawler = require('./security-crawl');
++
++const out = (...a) => process.stdout.write(a.join(' ') + '\n');
++
++(async () => {
++ const t0 = Date.now();
++ const w = await buildWorld({ label: 'secrets' });
++ try {
++ const { users, dev } = w;
++ const values = (name) => ({
++ project: [dev.PA.id, dev.PB.id, 'prj_00000000000000000000000000', '..%2f'],
++ app: [dev.PA.app, dev.PB.app, 'app_00000000000000000000000000', 'x'],
++ credential: [dev.PA.credential, dev.PB.credential, 'crd_x'],
++ capability: ['media.object.upload', 'identity.subject.resolve', 'x'],
++ subject: [users.alice.subject, users.bob.subject, 'usr_x'],
++ target: [users.alice.subject, users.bob.subject, 'x'],
++ type: ['user', 'channel', 'x'],
++ creator: [users.alice.subject, 'alice', 'x'],
++ id: [String(users.alice.id), String(users.owner.id), '1', '999999', 'x', '-1'],
++ userId: [String(users.alice.id), String(users.bob.id), '999999'],
++ username: ['alice', 'rootowner', 'nobody'],
++ name: ['alice', 'live', 'network', 'nobody'],
++ ns: ['ai.preferences', 'chat.tts_defaults', 'live.profile', 'x'],
++ namespace: ['general', 'network', 'x'],
++ key: ['DEPLOY_CLOUDFLARE_TOKEN', 'OV_LIVE_URL', 'x'],
++ token: ['not-a-token', 'x'],
++ idOrSlug: ['default', 'x'],
++ serviceId: ['live', 'network', 'x'],
++ domain: ['openvibe.live', 'x'],
++ topic: ['live.stream.started', 'x'],
++ category: ['system', 'x'],
++ }[name] || ['1', 'x']);
++ const extra = ['/api/nope', '/internal/nope', '/INTERNAL/users/1', '/.env', '/.git/config', '/package.json', '/server/config.js',
++ '/data/network.db', '/data/keys/private.pem', '/keys/private.pem', '/private.pem', '/network.db', '/data/avatars/..%2fnetwork.db',
++ '/data/avatars/%2e%2e/network.db', '/shared/..%2f..%2fpackage.json', '/node_modules/.package-lock.json', '/api/.well-known/jwks',
++ '/.well-known/openid-configuration', '/oauth/.well-known/openid-configuration', '/api/ready', '/metrics', '/release.json', '/status',
++ '/api/v1/status', '/api/admin/secrets', '/api/admin/settings', '/api/admin/config', '/api/admin/url-registry', '/api/admin/email',
++ '/api/admin/discord/', '/api/admin/integrations/github/', '/api/admin/deploy/config', '/internal/url-registry/resolved',
++ '/internal/integrations/github-token', '/api/setup/status', '/oauth/authorize?client_id=live&redirect_uri=https%3A%2F%2Fevil.test%2F&response_type=code',
++ '/oauth/client-info?client_id=live', `/oauth/client-info?client_id=${dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
++ const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
++ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
++ const people = { ...w.callers, legacy: { 'x-internal-key': w.sentinels.INTERNAL_API_KEY } };
++ // What each caller must never see: every secret, plus every other caller's credential. Staff
++ // admins and the owner list verification keys (they make them to hand out); nobody else sees one.
++ const needlesFor = (who) => {
++ const n = { ...w.secrets };
++ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s bearer token`] = tokenOf(h);
++ if (who !== 'admin' && who !== 'owner') n['verification key'] = w.verificationKey;
++ return n;
++ };
++
++ // ── Every GET, as everyone ─────────────────────────────────────
++ const gets = await crawler.crawl(w.srv.base, paths, people, needlesFor);
++ out(`GET crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(gets.statuses)}`);
++ // The one documented exception (server/auth/owner-guard.js): the owner, and only the owner,
++ // reads provider secrets in the admin settings and the secret-typed registry values in clear,
++ // to change them; staff admins get them masked. Those two reads must not be cached.
++ const OWNER_READS = /^owner: GET \/api\/admin\/(settings|url-registry)(\?[^ ]*)? → 200 carries (db setting [a-z_.]+|env DEPLOY_CLOUDFLARE_TOKEN) in its body$/;
++ const ownerReads = gets.found.filter((f) => OWNER_READS.test(f));
++ assert.ok(ownerReads.length > 0, 'the owner exception is still exercised');
++ for (const p of ['/api/admin/settings', '/api/admin/url-registry']) {
++ const r = await w.call(people.owner, 'GET', p);
++ assert.strictEqual(r.status, 200);
++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${p}: the owner's clear-text read is never cached`);
++ const staff = await w.call(people.admin, 'GET', p);
++ assert.ok(staff.status === 403 || (staff.status === 200 && crawler.leaks(staff, needlesFor('admin')).length === 0), `${p}: staff admins get it masked or not at all`);
++ }
++ const leaked = gets.found.filter((f) => !OWNER_READS.test(f));
++ assert.deepStrictEqual(leaked, [], `secrets in GET responses:\n${leaked.join('\n')}`);
++ assert.ok(paths.length >= 300 && gets.answered === paths.length * Object.keys(people).length, 'every request was answered');
++ assert.ok((gets.statuses['2xx'] || 0) > 500, 'the crawl reached real pages, not only refusals');
++ assert.strictEqual(Object.values(gets.byPath).filter((s) => s === 429).length, 0, 'no request was rate-limited away');
++ // The crawl really was signed in: each caller reaches something only it can.
++ const st = (who, p) => gets.byPath[`${who} GET ${p}`];
++ assert.strictEqual(st('user', '/api/auth/me'), 200);
++ assert.strictEqual(st('anonymous', '/api/auth/me'), 401);
++ assert.strictEqual(st('admin', '/api/admin/url-registry'), 200);
++ assert.strictEqual(st('admin', '/api/admin/settings'), 403, 'settings are the owner\'s');
++ assert.strictEqual(st('user', '/api/admin/url-registry'), 403);
++ assert.strictEqual(st('owner', '/api/admin/secrets'), 200);
++ assert.strictEqual(st('owner', '/api/admin/settings'), 200);
++ assert.strictEqual(st('service', '/internal/url-registry/resolved'), 200, 'live may read the resolved registry');
++ assert.strictEqual(st('legacy', '/internal/url-registry/resolved'), 200);
++ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 403);
++
++ // JWKS and OIDC discovery: the public key, never a private part.
++ const jwks = await w.call(null, 'GET', '/api/.well-known/jwks');
++ assert.strictEqual(jwks.status, 200);
++ assert.ok(jwks.body.public_key.includes('BEGIN PUBLIC KEY') && jwks.body.keys.length === 1);
++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) assert.ok(!(f in jwks.body.keys[0]), `JWKS has no private member ${f}`);
++ assert.ok(!/PRIVATE/.test(jwks.text));
++
++ // ── Error paths: every write route, refused or malformed ─────────
++ const writes = {};
++ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, values, { method: m });
++ const found = [];
++ let sent = 0;
++ for (const [m, list] of Object.entries(writes)) {
++ const M = m.toUpperCase();
++ for (const [body, callers] of [
++ ['{"broken": ', people], // malformed JSON, everyone
++ ['{}', { anonymous: {}, app: people.app || {} }], // empty body without the right credential
++ ]) {
++ const r = await crawler.crawl(w.srv.base, list, callers, needlesFor, { method: M, body });
++ sent += list.length * Object.keys(callers).length;
++ found.push(...r.found);
++ }
++ }
++ // Wrong credentials of every kind on the paths that take them.
++ const wrong = {
++ 'wrong internal key': { 'x-internal-key': w.sentinels.INTERNAL_API_KEY.slice(0, -1) + 'x' },
++ 'forged bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, 'guess', { issuer: w.issuer })}` },
++ 'none alg bearer': { authorization: `Bearer ${jwt.sign({ sub: users.owner.id, id: users.owner.id }, null, { algorithm: 'none' })}` },
++ 'setup token guess': { 'x-setup-token': 'x', authorization: 'Bearer x' },
++ };
++ const wr = await crawler.crawl(w.srv.base, [...writes.post.filter((p) => /^\/(internal|oauth|api\/setup|api\/admin)/.test(p)), ...paths.filter((p) => /^\/(internal|api\/admin)/.test(p))], wrong, needlesFor, { method: 'GET' });
++ found.push(...wr.found);
++ for (const [grant, form] of [
++ ['client_credentials', { client_id: 'live', client_secret: 'wrong' }],
++ ['client_credentials', { client_id: dev.PA.clientId, client_secret: 'ovsec_wrong' }],
++ ['authorization_code', { client_id: 'live', client_secret: w.clientSecrets.live, code: 'nope', redirect_uri: 'https://evil.test/' }],
++ ['refresh_token', { client_id: 'live', client_secret: w.clientSecrets.live, refresh_token: 'f'.repeat(96) }],
++ ]) {
++ const r = await w.call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: grant, ...form }));
++ assert.ok(r.status >= 400, `${grant} with wrong material is refused`);
++ for (const l of crawler.leaks(r, needlesFor('anonymous'))) found.push(`oauth ${grant} refusal carries ${l.label}`);
++ }
++ out(`write/error crawl: ${sent} requests`);
++ assert.deepStrictEqual(found, [], `secrets in error responses:\n${found.join('\n')}`);
++
++ // ── Shown once, never cached ─────────────────────────────────────
++ for (const s of w.shownOnce.filter((x) => x.status >= 200 && x.status < 300)) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
++ assert.ok(w.shownOnce.filter((x) => x.status >= 200 && x.status < 300).length >= 6, 'the shown-once responses were made');
++ // The other answers that carry a token: refresh, an anonymous session, the anonymous identities of this address.
++ for (const [who, m, p, body] of [['alice', 'POST', '/api/auth/refresh', {}], [null, 'POST', '/api/auth/anon-session', {}], [null, 'GET', '/api/auth/anon-identities'], [null, 'POST', '/api/auth/login', { username: 'alice', password: 'wrong' }]]) {
++ const r = await w.call(who, m, p, body);
++ assert.match(r.headers.get('cache-control') || '', /no-store/, `${m} ${p} (${r.status}): Cache-Control no-store`);
++ }
++
++ // ── Logs, outbox and audit tables ────────────────────────────────
++ const all = { ...needlesFor('nobody'), 'verification key': w.verificationKey };
++ delete all['verification key']; // stored in its own table by design; only its readers are checked above
++ const logHits = crawler.leaks({ text: w.srv.logs(), headers: {} }, all).map((l) => l.label);
++ assert.deepStrictEqual(logHits, [], 'nothing secret was logged');
++ const tables = w.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND (name LIKE '%outbox%' OR name LIKE '%audit%' OR name LIKE '%log%' OR name LIKE '%usage%' OR name LIKE '%changes%' OR name LIKE '%alerts%' OR name LIKE '%revisions%' OR name = 'notifications')").all().map((r) => r.name);
++ assert.ok(tables.includes('network_event_outbox') && tables.includes('dev_audit') && tables.includes('audit_log'), tables.join(','));
++ const rowHits = [];
++ for (const t of tables) {
++ const text = JSON.stringify(w.db.prepare(`SELECT * FROM "${t}"`).all());
++ for (const l of crawler.leaks({ text, headers: {} }, all)) rowHits.push(`${t}: ${l.label}`);
++ }
++ assert.deepStrictEqual(rowHits, [], 'no secret in the outbox, audit or log tables');
++ assert.ok(w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get().n > 0, 'the outbox was written meanwhile');
++ } finally {
++ await w.stop();
++ }
++
++ // ── Without key files: the ephemeral HS256 secret is never published ───
++ const bare = await bootServer();
++ try {
++ const r = await fetch(`${bare.base}/api/.well-known/jwks`);
++ const body = await r.json();
++ const k = body.public_key;
++ if (k && !String(k).includes('BEGIN')) {
++ // What the published "key" would let anyone do: sign a session for the first admin.
++ const forged = jwt.sign({ sub: 1, id: 1, username: 'admin', role: 'admin' }, k, { algorithm: 'HS256', issuer: 'https://openvibe.network', expiresIn: '1h' });
++ const me = await fetch(`${bare.base}/api/auth/me`, { headers: { authorization: `Bearer ${forged}` } });
++ assert.notStrictEqual(me.status, 200, 'a session signed with the published HS256 secret is refused');
++ }
++ assert.ok(!k || String(k).includes('BEGIN PUBLIC KEY'), 'a keyless Network publishes no HS256 secret as its public key');
++ } finally {
++ await bare.stop();
++ }
++ out(`security secrets: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-world.js b/test/security-world.js
nnew file mode 100644
iindex 00000000..bfff2223
---- /dev/null
++++ b/test/security-world.js
@@@ -0,0 +1,223 @@
++'use strict';
++/**
++ * The world the security suites run against (roadmap WS-R task 5): the real server/index.js booted in
++ * a child process (test/helpers/boot-server.js) on a scratch database, with a throwaway RS256 key
++ * pair and VAPID pair generated here, an obviously fake low-entropy sentinel in every other secret
++ * the server reads from its environment, and a seeded population:
++ * people alice, bob (users), carol (a third user), staff (role admin, not the owner),
++ * owner (ADMIN_USERNAME, created by the server itself from ADMIN_PASSWORD; OWNER_USERNAME)
++ * developer projects: alice's PA (app AA, confidential sandbox, its credential rotated so two
++ * secrets overlap) and bob's PB (app AB); an export token minted for PA
++ * per-person data: alice's modules, blocks, follows, notifications, sessions, linked account,
++ * history, push subscription, refresh token (OAuth code flow); an anonymous identity;
++ * provider-secret copies in site_settings; a verification key; a closed old incident
++ * `secrets` collects every secret value with a label, for the suites to look for in responses.
++ * Not a test itself (no .test.js).
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const jwt = require('jsonwebtoken');
++const Database = require('better-sqlite3');
++const { bootServer } = require('./helpers/boot-server');
++const crawler = require('./security-crawl');
++
++const S = (name) => `sentinel-not-a-secret-${name}`;
++
++/** Every secret-typed environment variable Network (or a module it loads) reads, with its sentinel. */
++function sentinelEnv() {
++ return {
++ INTERNAL_API_KEY: S('internal-api-key'),
++ SETUP_TOKEN: S('setup-token'),
++ NETWORK_EVENTS_SECRET: S('network-events-secret'),
++ OV_LIVE_WEBHOOK_SECRET: S('live-webhook-secret'),
++ OV_GAMES_WEBHOOK_SECRET: S('games-webhook-secret'),
++ ADMIN_PASSWORD: S('admin-password'),
++ RESEND_API_KEY: S('resend-api-key'),
++ RESEND_WEBHOOK_SECRET: S('resend-webhook-secret'),
++ DISCORD_BOT_TOKEN: S('discord-bot-token'),
++ DISCORD_OAUTH_CLIENT_SECRET: S('discord-oauth-client-secret'),
++ GITHUB_TOKEN: S('github-token'),
++ DEPLOY_CLOUDFLARE_TOKEN: S('deploy-cloudflare-token'),
++ OV_OAUTH_CLIENT_SECRET: S('ov-oauth-client-secret'),
++ NET_IPINFO_TOKEN: S('net-ipinfo-token'),
++ NET_GLOBALPING_TOKEN: S('net-globalping-token'),
++ };
++}
++
++/** Base64 runs of a PEM body worth looking for (each 64-char line, and the joined body). */
++function pemChunks(pem) {
++ const lines = String(pem).split('\n').filter((l) => l && !l.startsWith('-----'));
++ return { lines, body: lines.join('') };
++}
++
++async function buildWorld({ label = 'sec', env: extraEnv = {} } = {}) {
++ const os = require('os');
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), `ov-${label}-`));
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ fs.mkdirSync(path.join(dir, 'keys'));
++ fs.writeFileSync(path.join(dir, 'keys', 'private.pem'), keys.privateKey);
++ fs.writeFileSync(path.join(dir, 'keys', 'public.pem'), keys.publicKey);
++ const vapid = require('web-push').generateVAPIDKeys();
++ const sentinels = sentinelEnv();
++ const dump = crawler.routeDumpEnv(dir);
++ const ISSUER = 'https://openvibe.network';
++ const srv = await bootServer({
++ env: {
++ ...sentinels,
++ VAPID_PRIVATE_KEY: vapid.privateKey, VAPID_PUBLIC_KEY: vapid.publicKey,
++ JWT_PRIVATE_KEY: path.join(dir, 'keys', 'private.pem'), JWT_PUBLIC_KEY: path.join(dir, 'keys', 'public.pem'),
++ ADMIN_USERNAME: 'rootowner', OWNER_USERNAME: 'rootowner', BASE_URL: ISSUER, OV_NETWORK_URL: ISSUER,
++ DEV_SANDBOX_AUDIENCES: 'openvibe.media,openvibe.network', DEV_CREDENTIAL_OVERLAP_S: '3600',
++ ...dump, ...extraEnv,
++ },
++ });
++ const routes = await crawler.readRoutes(dump.OV_ROUTE_DUMP);
++ const db = new Database(path.join(srv.dir, 'network.db'));
++ db.pragma('busy_timeout = 5000');
++
++ // ── Secrets, by label ────────────────────────────────────────────
++ const secrets = {};
++ for (const [k, v] of Object.entries(sentinels)) secrets[`env ${k}`] = v;
++ secrets['env VAPID_PRIVATE_KEY'] = vapid.privateKey;
++ const priv = pemChunks(keys.privateKey);
++ const pub = pemChunks(keys.publicKey);
++ priv.lines.forEach((l, i) => { if (!pub.body.includes(l)) secrets[`signing key PEM line ${i}`] = l; });
++ const jwk = crypto.createPrivateKey(keys.privateKey).export({ format: 'jwk' });
++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) secrets[`signing key JWK ${f}`] = jwk[f];
++
++ // ── People ───────────────────────────────────────────────────────
++ const subjects = require('../server/identity/subjects');
++ const hash = '$2a$10$' + 'x'.repeat(53);
++ const addUser = (username, role = 'user', email = null) => {
++ db.prepare('INSERT INTO users (username, email, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)').run(username, email, hash, username, role);
++ const row = db.prepare('SELECT * FROM users WHERE username = ?').get(username);
++ subjects.ensureUserSubject(db, row);
++ return db.prepare('SELECT * FROM users WHERE id = ?').get(row.id);
++ };
++ const rows = {
++ alice: addUser('alice', 'user', '[email protected]'),
++ bob: addUser('bob', 'user', '[email protected]'),
++ carol: addUser('carol', 'user', '[email protected]'),
++ staff: addUser('staffer', 'admin', '[email protected]'),
++ };
++ const ownerRow = db.prepare("SELECT * FROM users WHERE username = 'rootowner'").get();
++ if (!ownerRow || ownerRow.role !== 'admin') throw new Error('the server did not create the ADMIN_USERNAME account');
++ subjects.ensureUserSubject(db, ownerRow);
++ rows.owner = db.prepare('SELECT * FROM users WHERE id = ?').get(ownerRow.id);
++ secrets['owner password hash'] = rows.owner.password_hash;
++ const sign = (u) => jwt.sign({ sub: u.id, id: u.id, username: u.username, role: u.role }, keys.privateKey, { algorithm: 'RS256', issuer: ISSUER, expiresIn: '2h' });
++ const users = {};
++ for (const [k, u] of Object.entries(rows)) users[k] = { id: u.id, username: u.username, subject: u.subject_id, email: u.email, token: sign(u) };
++
++ // First-party OAuth clients get unique secrets (stored in clear in oauth_clients).
++ const clientSecrets = {};
++ for (const c of db.prepare('SELECT client_id FROM oauth_clients').all()) {
++ clientSecrets[c.client_id] = `${S('client')}-${c.client_id}-${crypto.randomBytes(6).toString('hex')}`;
++ db.prepare('UPDATE oauth_clients SET client_secret = ? WHERE client_id = ?').run(clientSecrets[c.client_id], c.client_id);
++ secrets[`oauth client secret ${c.client_id}`] = clientSecrets[c.client_id];
++ }
++
++ // ── HTTP ─────────────────────────────────────────────────────────
++ let ipn = 0;
++ const nextIp = () => { ipn++; return `198.18.${(ipn >> 8) & 255}.${ipn & 255}`; };
++ const who = (w) => (typeof w === 'string' ? (users[w] ? { authorization: `Bearer ${users[w].token}` } : {}) : (w || {}));
++ async function call(w, method, p, body, headers = {}) {
++ const h = { 'x-forwarded-for': nextIp(), ...who(w), ...headers };
++ let b;
++ if (body !== undefined && body !== null) {
++ if (typeof body === 'string') b = body;
++ else if (body instanceof URLSearchParams) { b = body.toString(); h['content-type'] = 'application/x-www-form-urlencoded'; } else { b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }
++ }
++ const r = await fetch(srv.base + p, { method, headers: h, body: b, redirect: 'manual' });
++ const text = await r.text();
++ let json = null; try { json = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
++ return { status: r.status, headers: r.headers, text, body: json };
++ }
++ const shownOnce = []; // responses that legitimately carry a secret once (checked for no-store by the suites)
++ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };
++
++ // Service principal tokens (client_credentials) and the legacy key.
++ async function serviceToken(clientId, audience = 'openvibe.network') {
++ const r = once(`service token ${clientId}`, await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: clientId, client_secret: clientSecrets[clientId], audience })));
++ if (r.status !== 200) throw new Error(`service token for ${clientId}: ${r.status} ${r.text}`);
++ return r.body.access_token;
++ }
++ const liveToken = await serviceToken('live');
++
++ // ── Developer projects ───────────────────────────────────────────
++ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
++ const dev = {};
++ for (const [owner, name] of [['alice', 'PA'], ['bob', 'PB']]) {
++ const p = must(await call(owner, 'POST', '/api/v1/projects', { name: `${owner} project` }), 201, `${owner} project`).body;
++ const a = once(`app creation ${name}`, must(await call(owner, 'POST', `/api/v1/projects/${p.id}/apps`, { name: `${owner} app`, environment: 'sandbox', type: 'confidential' }), 201, `${owner} app`));
++ dev[name] = { id: p.id, app: a.body.id, clientId: a.body.oauth_client_id || a.body.client_id || a.body.id, secret: a.body.credential.client_secret, credential: a.body.credential.id, owner };
++ secrets[`dev credential ${name}`] = dev[name].secret;
++ }
++ // Rotation overlap: alice's old secret keeps working while the new one exists.
++ const rot = once('credential rotation', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/apps/${dev.PA.app}/credentials/rotate`, {}), 201, 'rotate'));
++ dev.PA.secret2 = rot.body.credential.client_secret; dev.PA.credential2 = rot.body.credential.id;
++ secrets['dev credential PA rotated'] = dev.PA.secret2;
++ for (const c of db.prepare('SELECT id, secret_hash FROM dev_credentials').all()) secrets[`dev credential hash ${c.id}`] = c.secret_hash;
++ // A grant request (pending) to have a grant row on each app.
++ for (const P of ['PA', 'PB']) await call(dev[P].owner, 'POST', `/api/v1/projects/${dev[P].id}/apps/${dev[P].app}/grants`, { capability: 'media.object.upload' });
++ const exp = once('export token', must(await call('alice', 'POST', `/api/v1/projects/${dev.PA.id}/export-tokens`, { audience: 'openvibe.media', env: 'sandbox' }), 201, 'export token'));
++ secrets['export token PA'] = exp.body.access_token;
++ // A developer app token (its project has no Network grant, so it is minted for Media; Network must refuse it everywhere).
++ const appTok = once('app token', await call(null, 'POST', '/oauth/token', new URLSearchParams({ grant_type: 'client_credentials', client_id: dev.PA.clientId, client_secret: dev.PA.secret2, audience: 'openvibe.media' })));
++ const appToken = appTok.status === 200 ? appTok.body.access_token : null;
++
++ // ── Per-person data ──────────────────────────────────────────────
++ await call('alice', 'PUT', '/api/modules/ai.preferences', { data: { style: 'casual', history: false } }, { 'if-match': '0' });
++ await call('alice', 'PUT', '/api/modules/chat.tts_defaults', { data: { send: true, volume: 37 } }, { 'if-match': '0' });
++ await call('alice', 'PUT', `/api/v1/me/blocks/${users.carol.subject}`, {});
++ await call('alice', 'PUT', `/api/v1/me/follows/user/${users.bob.subject}`, {});
++ await call({ authorization: `Bearer ${liveToken}` }, 'POST', '/internal/notifications/push', { user_id: users.alice.id, type: 'system', category: 'system', title: 'alice-private-notification', message: 'for alice only', service: 'live' });
++ const sess = once('session creation', await call('alice', 'POST', '/api/auth/sessions', { device_name: 'alice-laptop' }));
++ if (sess.body && sess.body.session_token) secrets['alice session token'] = sess.body.session_token;
++ await call({ 'x-internal-key': sentinels.INTERNAL_API_KEY }, 'POST', '/internal/link-account', { user_id: users.alice.id, service: 'games', service_user_id: 'alice-games-771' });
++ await call('alice', 'POST', '/api/history', { service: 'live', url: 'https://openvibe.live/@alice-private-history', title: 'alice-private-history' });
++ const pushSub = { endpoint: 'https://push.services.mozilla.com/wpush/v2/alice-endpoint-capability-0001', keys: { p256dh: vapid.publicKey, auth: 'alice-push-auth-secret' } };
++ await call('alice', 'POST', '/api/push/subscribe', { subscription: pushSub });
++ secrets['alice push endpoint'] = pushSub.endpoint;
++ secrets['alice push auth'] = pushSub.keys.auth;
++ // A refresh token through the OAuth code flow (client 'live').
++ const REDIRECT = (JSON.parse(db.prepare("SELECT redirect_uris FROM oauth_clients WHERE client_id = 'live'").get().redirect_uris) || [])[0];
++ const conf = await call(null, 'POST', '/oauth/confirm', { token: users.alice.token, client_id: 'live', redirect_uri: REDIRECT });
++ if (conf.status === 200 && conf.body && conf.body.redirect) {
++ const code = new URL(conf.body.redirect).searchParams.get('code');
++ const t = once('oauth code exchange', await call(null, 'POST', '/oauth/token', { grant_type: 'authorization_code', client_id: 'live', client_secret: clientSecrets.live, code, redirect_uri: REDIRECT }));
++ if (t.body && t.body.refresh_token) secrets['alice refresh token'] = t.body.refresh_token;
++ }
++ for (const r of db.prepare('SELECT token FROM oauth_tokens').all()) secrets[`refresh token hash ${r.token.slice(0, 6)}`] = r.token;
++ // An anonymous identity seen from another address.
++ const anonToken = `anon-${crypto.randomBytes(12).toString('hex')}`;
++ db.prepare('INSERT INTO anon_users (anon_number, session_token, ip) VALUES (?, ?, ?)').run(900001, anonToken, '192.0.2.77');
++ secrets['anon session token'] = anonToken;
++ // Provider-secret copies in the database (the environment wins, but the copies are secrets too).
++ for (const k of ['resend_api_key', 'resend_webhook_secret', 'discord_bot_token', 'discord_oauth_client_secret', 'github_token', 'net.ipinfo_token', 'ses_secret_access_key']) {
++ const v = S(`db-${k.replace(/[._]/g, '-')}`);
++ db.prepare("INSERT OR REPLACE INTO site_settings (key, value, type) VALUES (?, ?, 'secret')").run(k, v);
++ secrets[`db setting ${k}`] = v;
++ }
++ const vk = `VK-${crypto.randomBytes(8).toString('hex')}`;
++ db.prepare('INSERT INTO verification_keys (key, target_username, created_by) VALUES (?, ?, ?)').run(vk, 'carol', rows.staff.id);
++ // A per-user password hash is never shown to anyone.
++ for (const u of ['alice', 'bob', 'staff']) { db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(`$2a$10$${u}${crypto.randomBytes(20).toString('hex')}`, users[u].id); secrets[`${u} password hash`] = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(users[u].id).password_hash; }
++
++ const callers = {
++ anonymous: {},
++ user: { authorization: `Bearer ${users.bob.token}`, cookie: `ov_token=${users.bob.token}` },
++ admin: { authorization: `Bearer ${users.staff.token}`, cookie: `ov_token=${users.staff.token}` },
++ owner: { authorization: `Bearer ${users.owner.token}`, cookie: `ov_token=${users.owner.token}` },
++ service: { authorization: `Bearer ${liveToken}` },
++ ...(appToken ? { app: { authorization: `Bearer ${appToken}` } } : {}),
++ };
++ return {
++ dir: srv.dir, srv, db, keys, vapid, issuer: ISSUER, routes, sentinels, secrets, users, dev, callers, clientSecrets,
++ liveToken, appToken, anonToken, verificationKey: vk, pushSub, shownOnce, call, serviceToken, sign, S,
++ stop: async () => { try { db.close(); } catch { /* */ } await srv.stop(); try { fs.rmSync(dir, { recursive: true, force: true }); fs.rmSync(srv.dir, { recursive: true, force: true }); } catch { /* */ } },
++ };
++}
++
++module.exports = { buildWorld, sentinelEnv, pemChunks, S };