openvibers/openvibe.network Commits

Split security crawls into module-scoped test files (#33)

OP OpenVibers committed 3 Oct 2026 · 92383ba70d · 10 files, +410 −366

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness (QUALITY=high)

https://github.com/OpenVibers/OpenVibe.Network/pull/33

Changes

ddiff --git a/test/security-private-personal.test.js b/test/security-private-personal.test.js
nnew file mode 100644
iindex 00000000..372d938e
---- /dev/null
++++ b/test/security-private-personal.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++// Personal data, private modules, follows and blocks stay private across the route crawl.
++const assert = require('assert');
++const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');
++
++withWorld('private-personal', async (w) => {
++ const { users } = w;
++ const { res } = await privateCrawl(w, 'personal');
++ checkPrivateStatuses(w, res);
++
++ // Positive controls: the owners of the data do see it, so the needles are real.
++ await privateMine(w, 'alice', '/api/auth/me', users.alice.email);
++ await privateMine(w, 'alice', '/api/modules/ai.preferences', 'alice-private-perspective');
++ await privateMine(w, 'alice', '/api/notifications', 'alice-private-notification');
++ await privateMine(w, 'alice', '/api/auth/sessions', 'alice-laptop');
++ await privateMine(w, 'alice', '/api/history', 'alice-private-history');
++ // The linked-account positive control was deleted with the retired internal key.
++ const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
++ assert.strictEqual(pub.status, 200);
++ assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
++
++ // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.
++ await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});
++ let r = await w.call('bob', 'GET', '/api/v1/me/blocks');
++ assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');
++ r = await w.call('bob', 'GET', '/api/v1/me/follows');
++ assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');
++ r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
++ assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);
++ assert.ok(!r.text.includes(users.alice.subject));
++ r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
++ assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');
++ r = await w.call('alice', 'GET', '/api/v1/me/blocks');
++ assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');
++ // Public module reads carry only a namespace's public fields.
++ r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);
++ assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-private-platform.test.js b/test/security-private-platform.test.js
nnew file mode 100644
iindex 00000000..31580afa
---- /dev/null
++++ b/test/security-private-platform.test.js
@@@ -0,0 +1,17 @@
++'use strict';
++// Other GET routes, internal capabilities and retired internal endpoints.
++const assert = require('assert');
++const { withWorld, privateCrawl, checkPrivateStatuses } = require('./security-suite');
++
++withWorld('private-platform', async (w) => {
++ const { users } = w;
++ const { res } = await privateCrawl(w, 'platform');
++ checkPrivateStatuses(w, res);
++ const st = res.byPath;
++ // A service principal reaches only the /internal routes its capabilities name.
++ assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');
++ // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.
++ for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {
++ assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);
++ }
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-private-projects.test.js b/test/security-private-projects.test.js
nnew file mode 100644
iindex 00000000..110d14f0
---- /dev/null
++++ b/test/security-private-projects.test.js
@@@ -0,0 +1,24 @@
++'use strict';
++// Project details, lists and app tokens respect membership and session boundaries.
++const assert = require('assert');
++const { withWorld, privateCrawl, privateMine, checkPrivateStatuses } = require('./security-suite');
++
++withWorld('private-projects', async (w) => {
++ const { dev } = w;
++ const { res, people } = await privateCrawl(w, 'projects');
++ checkPrivateStatuses(w, res);
++ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
++ await privateMine(w, 'alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
++
++ // Lists and search: bob's view of projects never includes alice's, even asking for all.
++ for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {
++ const r = await w.call('bob', 'GET', p);
++ assert.strictEqual(r.status, 200);
++ assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);
++ }
++ // A developer app token opens no person's API and no project API.
++ for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {
++ const r = await w.call(people.app, 'GET', p);
++ assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);
++ }
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
ddeleted file mode 100644
iindex 585d03fa..00000000
---- a/test/security-private.test.js
++++ /dev/null
@@@ -1,161 +0,0 @@
--'use strict';
--// Private bypass (roadmap WS-R task 5). With the seeded world of test/security-world.js, alice's
--// private data (her email, module data, notification, session device, linked account, history, push
--// endpoint, the people she blocks and follows) and the developer projects (names, apps, members,
--// credentials, usage, audit) are private; so are /internal/* and the staff pages. Every GET route
--// Express knows after boot is crawled as anonymous, bob (another user), a developer app and a service
--// principal: no answer may carry alice's private data, a project's private data to a non-member, or
--// an incident closed long ago. Then, by status: /internal/* never answers 2xx without a valid key or
--// capability token, staff pages never answer non-staff, a non-member gets 404 (existence not
--// disclosed) on every read of a project, its apps, members, credentials, grants, quotas, usage and
--// audit, and anonymous gets 401.
--// node test/security-private.test.js
--const assert = require('assert');
--const { buildWorld } = require('./security-world');
--const crawler = require('./security-crawl');
--
--const out = (...a) => process.stdout.write(a.join(' ') + '\n');
--
--(async () => {
-- const t0 = Date.now();
-- const w = await buildWorld({ label: 'private' });
-- try {
-- const { users, dev, db } = w;
-- // An incident closed 40 days ago is gone from the public page (recent = the last 30 days).
-- const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });
-- assert.strictEqual(inc.status, 201, inc.text);
-- const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
-- assert.strictEqual(closed.status, 200, closed.text);
-- const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
-- await db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
-- await db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
--
-- const alice = {
-- 'alice email': users.alice.email,
-- 'alice module data': 'alice-private-perspective',
-- 'alice notification': 'alice-private-notification',
-- 'alice session device': 'alice-laptop',
-- 'alice linked account': 'alice-games-771',
-- 'alice history': 'alice-private-history',
-- 'alice push endpoint': w.pushSub.endpoint,
-- 'alice project': 'alice-private-project',
-- 'alice app': 'alice-private-app',
-- 'bob project': 'bob-private-project',
-- 'bob app': 'bob-private-app',
-- 'long-closed incident': 'long-closed-incident-title',
-- 'carol email': users.carol.email,
-- 'staff email': users.staff.email,
-- };
-- const values = (name) => ({
-- project: [dev.PA.id, 'prj_00000000000000000000000000'],
-- app: [dev.PA.app, 'app_00000000000000000000000000'],
-- credential: [dev.PA.credential, 'crd_x'],
-- capability: ['media.object.upload', 'x'],
-- subject: [users.alice.subject, 'usr_x'],
-- target: [users.alice.subject, users.carol.subject, 'x'],
-- type: ['user', 'channel'],
-- creator: [users.alice.subject, 'alice'],
-- id: [String(users.alice.id), String(users.carol.id), '1', 'x'],
-- userId: [String(users.alice.id), String(users.carol.id)],
-- username: ['alice', 'carol'],
-- name: ['alice', 'carol'],
-- ns: ['ai.preferences', 'chat.tts_defaults', 'x'],
-- namespace: ['general', 'x'],
-- token: ['x'],
-- idOrSlug: ['default', 'x'],
-- serviceId: ['live', 'x'],
-- domain: ['openvibe.live'],
-- topic: ['live.stream.started'],
-- category: ['system'],
-- key: ['OV_LIVE_URL'],
-- }[name] || ['1']);
-- const paths = crawler.pathsFor(w.routes, values, { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });
-- // bob is not a member of alice's project and bob's own project is his: his own names are not needles for him.
-- const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };
-- assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');
-- const needlesFor = (who) => {
-- const n = { ...alice };
-- if (who === 'user') { delete n['bob project']; delete n['bob app']; }
-- return n;
-- };
-- const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);
-- out(`private crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);
-- assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);
-- assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > 200);
--
-- // Positive controls: the owners of the data do see it, so the needles are real.
-- const mine = async (who, p, needle) => { const r = await w.call(who, 'GET', p); assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`); };
-- await mine('alice', '/api/auth/me', users.alice.email);
-- await mine('alice', '/api/modules/ai.preferences', 'alice-private-perspective');
-- await mine('alice', '/api/notifications', 'alice-private-notification');
-- await mine('alice', '/api/auth/sessions', 'alice-laptop');
-- await mine('alice', '/api/history', 'alice-private-history');
-- await mine('alice', `/api/v1/projects/${dev.PA.id}`, 'alice-private-project');
-- await mine('alice', `/api/v1/projects/${dev.PA.id}/apps`, 'alice-private-app');
-- // (The linked-account needle's positive control was GET /internal/users/:id/linked-accounts, deleted with the key
-- // in plan T2; the needle stays in the crawl so any route that exposes it to the wrong caller still fails.)
-- const pub = await w.call(null, 'GET', '/api/v1/status/incidents');
-- assert.strictEqual(pub.status, 200);
-- assert.ok(!pub.text.includes('long-closed-incident-title'), 'an incident closed 40 days ago is not listed');
--
-- // ── By status ─────────────────────────────────────────────────
-- const st = res.byPath;
-- const bad = [];
-- for (const [k, s] of Object.entries(st)) {
-- const [who, , p] = k.split(' ');
-- const path = p.split('?')[0];
-- if (who === 'service') continue;
-- // /internal/*: a key or a capability token, else never 2xx (and never a redirect into it).
-- if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);
-- // Staff and admin pages: never 2xx for these callers.
-- // (/api/v1/staff/capabilities is the caller's own: empty for non-staff.)
-- if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);
-- // Alice's project: anonymous 401, others 404 (existence not disclosed).
-- if (path.startsWith(`/api/v1/projects/${dev.PA.id}`)) {
-- const want = who === 'anonymous' || who === 'app' ? [401] : [404];
-- if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);
-- }
-- // Personal lists: anonymous and apps are refused.
-- // (/api/modules/:ns/public/:subject is public by design: only a namespace's public fields.)
-- if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);
-- }
-- assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
-- // A service principal reaches only the /internal routes its capabilities name.
-- assert.strictEqual(st['service GET /internal/integrations/github-token'], 403, 'github-token needs its own capability');
-- // Deleted with the X-Internal-Key retirement (plan T2, no caller left): no longer routes at all.
-- for (const p of ['/internal/stats', `/internal/users/${users.alice.id}`, `/internal/users/${users.alice.id}/linked-accounts`, '/internal/anon-list', `/internal/notifications/unread/${users.alice.id}`]) {
-- assert.strictEqual(st[`service GET ${p}`], undefined, `${p} is gone`);
-- }
--
-- // Lists and search: bob's view of projects never includes alice's, even asking for all.
-- for (const p of ['/api/v1/projects', '/api/v1/projects?all=1']) {
-- const r = await w.call('bob', 'GET', p);
-- assert.strictEqual(r.status, 200);
-- assert.ok(r.body.projects.every((x) => x.id !== dev.PA.id), `${p}: alice's project is not listed to bob`);
-- }
-- // Blocks and follows are the person's own: bob cannot list alice's, only count public follows.
-- await w.call('alice', 'PUT', `/api/v1/me/follows/channel/${users.owner.subject}`, {});
-- let r = await w.call('bob', 'GET', '/api/v1/me/blocks');
-- assert.ok(!r.text.includes(users.carol.subject), 'bob does not see who alice blocks');
-- r = await w.call('bob', 'GET', '/api/v1/me/follows');
-- assert.ok(!r.text.includes(users.owner.subject), 'bob does not see whom alice follows');
-- r = await w.call('bob', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
-- assert.ok(r.status === 403 || r.status === 404, `followers of someone else are not bob's to list (${r.status})`);
-- assert.ok(!r.text.includes(users.alice.subject));
-- r = await w.call('owner', 'GET', `/api/v1/follows/channel/${users.owner.subject}/followers`);
-- assert.ok(r.status === 200 && r.text.includes(users.alice.subject), 'the target lists its own followers (positive control)');
-- r = await w.call('alice', 'GET', '/api/v1/me/blocks');
-- assert.ok(r.text.includes(users.carol.subject), 'alice lists her own blocks (positive control)');
-- // Public module reads carry only a namespace's public fields.
-- r = await w.call('bob', 'GET', `/api/modules/ai.preferences/public/${users.alice.subject}`);
-- assert.ok(!r.text.includes('alice-private-perspective') && !r.text.includes('casual'), `public module read carries no private field (${r.status})`);
-- // A developer app token opens no person's API and no project API.
-- for (const p of ['/api/auth/me', '/api/notifications', '/api/v1/projects', `/api/v1/projects/${dev.PA.id}`, '/api/modules/ai.preferences']) {
-- r = await w.call(people.app, 'GET', p);
-- assert.strictEqual(r.status, 401, `${p}: an app token is not a person's session`);
-- }
-- out(`security private: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
-- } finally {
-- await w.stop();
-- }
--})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets-keys.test.js b/test/security-secrets-keys.test.js
nnew file mode 100644
iindex 00000000..cb76ca54
---- /dev/null
++++ b/test/security-secrets-keys.test.js
@@@ -0,0 +1,26 @@
++'use strict';
++// Without key files, a keyless child never publishes its ephemeral HS256 secret.
++const assert = require('assert');
++const jwt = require('jsonwebtoken');
++const { bootServer } = require('./helpers/boot-server');
++const { out } = require('./security-suite');
++
++(async () => {
++ const t0 = Date.now();
++ const bare = await bootServer({ child: true });
++ 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')) {
++ // A published HS256 secret would let a caller forge an admin session.
++ 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-keys: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
++})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets-reads.test.js b/test/security-secrets-reads.test.js
nnew file mode 100644
iindex 00000000..53a2b8a6
---- /dev/null
++++ b/test/security-secrets-reads.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++// Crawl every GET route for secrets and verify owner-only reads and JWKS.
++const assert = require('assert');
++const crypto = require('crypto');
++const crawler = require('./security-crawl');
++const { withWorld, out, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');
++
++withWorld('secrets-reads', async (w) => {
++ const paths = secretsPaths(w);
++ const people = secretsPeople(w);
++ const needlesFor = secretsNeedlesFor(w, people);
++ 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 owner, and only the owner, reads provider secrets and secret-typed registry values in clear.
++ 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');
++ 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('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');
++ assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');
++
++ 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}`);
++ const jwk = jwks.body.keys[0];
++ assert.strictEqual(jwk.kty, 'RSA');
++ assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
++ assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
++ assert.ok(!/PRIVATE/.test(jwks.text));
++ await checkSecretsStored(w, needlesFor);
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets-storage.test.js b/test/security-secrets-storage.test.js
nnew file mode 100644
iindex 00000000..6dc8b7d6
---- /dev/null
++++ b/test/security-secrets-storage.test.js
@@@ -0,0 +1,16 @@
++'use strict';
++// Shown-once responses, logs and persisted events keep secrets private.
++const assert = require('assert');
++const { withWorld, secretsPeople, secretsNeedlesFor, checkSecretsStored } = require('./security-suite');
++
++withWorld('secrets-storage', async (w) => {
++ const needlesFor = secretsNeedlesFor(w, secretsPeople(w));
++ 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');
++ 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`);
++ }
++
++ await checkSecretsStored(w, needlesFor);
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets-writes.test.js b/test/security-secrets-writes.test.js
nnew file mode 100644
iindex 00000000..3a77627a
---- /dev/null
++++ b/test/security-secrets-writes.test.js
@@@ -0,0 +1,49 @@
++'use strict';
++// Refused and malformed write routes must never reveal secrets.
++const assert = require('assert');
++const jwt = require('jsonwebtoken');
++const crawler = require('./security-crawl');
++const { withWorld, out, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored } = require('./security-suite');
++
++withWorld('secrets-writes', async (w) => {
++ const { users, dev } = w;
++ const paths = secretsPaths(w);
++ const people = secretsPeople(w);
++ const needlesFor = secretsNeedlesFor(w, people);
++ const writes = {};
++ for (const m of ['post', 'put', 'patch', 'delete']) writes[m] = crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { 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],
++ ['{}', { anonymous: {}, app: people.app || {} }],
++ ]) {
++ 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);
++ }
++ }
++ const wrong = {
++ 'retired internal key': { 'x-internal-key': 'retired-key' },
++ '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')}`);
++ await checkSecretsStored(w, needlesFor);
++}).then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
ddeleted file mode 100644
iindex 8e5669f2..00000000
---- a/test/security-secrets.test.js
++++ /dev/null
@@@ -1,205 +0,0 @@
--'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 a caller still sending the
--// retired X-Internal-Key (which the server must keep refusing and must never answer).
--// 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 crypto = require('crypto');
--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, key: { 'x-internal-key': 'retired-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('key', '/internal/url-registry/resolved'), 401, 'the retired X-Internal-Key opens nothing');
-- assert.strictEqual(st('anonymous', '/internal/url-registry/resolved'), 401, 'and nobody without a service token gets in');
--
-- // 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}`);
-- // The configured key (decision 2, plan T2): the kid and modulus JWKS serves are the signing key's.
-- const jwk = jwks.body.keys[0];
-- assert.strictEqual(jwk.kty, 'RSA');
-- assert.strictEqual(jwk.kid, 'ov-network-1', 'the kid /api/.well-known/jwks publishes');
-- assert.strictEqual(jwk.n, crypto.createPublicKey(w.keys.publicKey).export({ format: 'jwk' }).n, 'the modulus is the configured key\'s');
-- 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 = {
-- 'retired internal key': { 'x-internal-key': 'retired-key' },
-- '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 = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_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(await 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((await 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 (a child of its own, so its
-- // database and process are not the seeded world's) ───
-- const bare = await bootServer({ child: true });
-- 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)`);
--})().then(() => process.exit(0)).catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-suite.js b/test/security-suite.js
nnew file mode 100644
iindex 00000000..b003a91d
---- /dev/null
++++ b/test/security-suite.js
@@@ -0,0 +1,188 @@
++'use strict';
++// Shared fixtures and route expansion for the module-scoped security checks.
++const assert = require('assert');
++const { buildWorld } = require('./security-world');
++const crawler = require('./security-crawl');
++
++const out = (...a) => process.stdout.write(a.join(' ') + '\n');
++
++async function withWorld(label, check) {
++ const t0 = Date.now();
++ const w = await buildWorld({ label });
++ try {
++ await check(w);
++ out(`security ${label}: all checks passed (${((Date.now() - t0) / 1000).toFixed(1)} s)`);
++ } finally {
++ await w.stop();
++ }
++}
++
++async function prepareClosedIncident(w) {
++ const inc = await w.call('staff', 'POST', '/api/v1/status/incidents', { kind: 'incident', title: 'long-closed-incident-title', severity: 'minor', state: 'investigating', services: ['network'], message: 'x' });
++ assert.strictEqual(inc.status, 201, inc.text);
++ const closed = await w.call('staff', 'POST', `/api/v1/status/incidents/${inc.body.id}/updates`, { state: 'resolved', message: 'fixed' });
++ assert.strictEqual(closed.status, 200, closed.text);
++ const longAgo = new Date(Date.now() - 40 * 864e5).toISOString();
++ await w.db.prepare('UPDATE status_incidents SET starts_at = ?, updated_at = ?, ends_at = ? WHERE id = ?').run(longAgo, longAgo, longAgo, inc.body.id);
++ await w.db.prepare('UPDATE status_incident_updates SET at = ? WHERE incident_id = ?').run(longAgo, inc.body.id);
++}
++
++function privateValues(w, name) {
++ const { users, dev } = w;
++ return ({
++ project: [dev.PA.id, 'prj_00000000000000000000000000'],
++ app: [dev.PA.app, 'app_00000000000000000000000000'],
++ credential: [dev.PA.credential, 'crd_x'],
++ capability: ['media.object.upload', 'x'],
++ subject: [users.alice.subject, 'usr_x'],
++ target: [users.alice.subject, users.carol.subject, 'x'],
++ type: ['user', 'channel'],
++ creator: [users.alice.subject, 'alice'],
++ id: [String(users.alice.id), String(users.carol.id), '1', 'x'],
++ userId: [String(users.alice.id), String(users.carol.id)],
++ username: ['alice', 'carol'],
++ name: ['alice', 'carol'],
++ ns: ['ai.preferences', 'chat.tts_defaults', 'x'],
++ namespace: ['general', 'x'],
++ token: ['x'],
++ idOrSlug: ['default', 'x'],
++ serviceId: ['live', 'x'],
++ domain: ['openvibe.live'],
++ topic: ['live.stream.started'],
++ category: ['system'],
++ key: ['OV_LIVE_URL'],
++ })[name] || ['1'];
++}
++
++function privateArea(path) {
++ if (path.startsWith('/api/v1/projects')) return 'projects';
++ if (path.match(/^\/api\/(auth|notifications|history|modules|coins|push|v1\/me|v1\/follows|v1\/status)(\/|\?|$)/)) return 'personal';
++ return 'platform';
++}
++
++async function privateCrawl(w, area) {
++ const { users, dev } = w;
++ await prepareClosedIncident(w);
++ const alice = {
++ 'alice email': users.alice.email,
++ 'alice module data': 'alice-private-perspective',
++ 'alice notification': 'alice-private-notification',
++ 'alice session device': 'alice-laptop',
++ 'alice linked account': 'alice-games-771',
++ 'alice history': 'alice-private-history',
++ 'alice push endpoint': w.pushSub.endpoint,
++ 'alice project': 'alice-private-project',
++ 'alice app': 'alice-private-app',
++ 'bob project': 'bob-private-project',
++ 'bob app': 'bob-private-app',
++ 'long-closed incident': 'long-closed-incident-title',
++ 'carol email': users.carol.email,
++ 'staff email': users.staff.email,
++ };
++ const allPaths = crawler.pathsFor(w.routes, (name) => privateValues(w, name), { method: 'get', query: 'all=1&limit=100&user_id=' + users.alice.id + '&subject=' + users.alice.subject, extra: ['/api/v1/projects?all=1', '/api/v1/status/incidents'] });
++ const paths = allPaths.filter((p) => privateArea(p) === area);
++ const people = { anonymous: w.callers.anonymous, user: w.callers.user, app: w.callers.app, service: w.callers.service };
++ assert.ok(people.app && people.app.authorization, 'a developer app token to crawl with');
++ const needlesFor = (who) => {
++ const n = { ...alice };
++ if (who === 'user') { delete n['bob project']; delete n['bob app']; }
++ return n;
++ };
++ const res = await crawler.crawl(w.srv.base, paths, people, needlesFor);
++ out(`private ${area} crawl: ${paths.length} paths x ${Object.keys(people).length} callers, ${JSON.stringify(res.statuses)}`);
++ assert.deepStrictEqual(res.found, [], `private data reached the wrong caller:\n${res.found.join('\n')}`);
++ assert.ok(res.answered === paths.length * Object.keys(people).length && (res.statuses['2xx'] || 0) > (area === 'platform' ? 200 : 0));
++ return { res, people };
++}
++
++async function privateMine(w, who, p, needle) {
++ const r = await w.call(who, 'GET', p);
++ assert.ok(r.text.includes(needle), `${who} sees ${needle} at ${p} (${r.status})`);
++}
++
++function checkPrivateStatuses(w, res) {
++ const st = res.byPath;
++ const bad = [];
++ for (const [k, s] of Object.entries(st)) {
++ const [who, , p] = k.split(' ');
++ const path = p.split('?')[0];
++ if (who === 'service') continue;
++ if (/^\/internal\//.test(path) && s < 400) bad.push(`${k} → ${s}`);
++ if (/^\/api\/(admin|v1\/staff)\//.test(path) && path !== '/api/v1/staff/capabilities' && s < 400) bad.push(`${k} → ${s}`);
++ if (path.startsWith(`/api/v1/projects/${w.dev.PA.id}`)) {
++ const want = who === 'anonymous' || who === 'app' ? [401] : [404];
++ if (!want.includes(s)) bad.push(`${k} → ${s} (want ${want})`);
++ }
++ if (/^\/api\/(notifications|history|modules|coins\/me|push\/status|auth\/sessions|auth\/me|v1\/me\/)/.test(path) && !/^\/api\/modules\/[^/]+\/public\//.test(path) && who !== 'user' && s < 400) bad.push(`${k} → ${s}`);
++ }
++ assert.deepStrictEqual(bad, [], `private reads answered:\n${bad.join('\n')}`);
++}
++
++function secretsValues(w, name) {
++ const { users, dev } = w;
++ return ({
++ 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'];
++}
++
++function secretsPeople(w) { return { ...w.callers, key: { 'x-internal-key': 'retired-key' } }; }
++
++function secretsNeedlesFor(w, people) {
++ const tokenOf = (h) => String((h && h.authorization) || '').replace(/^Bearer /, '');
++ return (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;
++ };
++}
++
++function secretsPaths(w) {
++ 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=${w.dev.PA.app}`, '/api/auth/anon-identities', '/sso/check', '/fedcm/accounts'];
++ return crawler.pathsFor(w.routes, (name) => secretsValues(w, name), { method: 'get', query: 'limit=5&all=1&debug=1&include=secret', extra });
++}
++
++async function checkSecretsStored(w, needlesFor) {
++ 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 = (await w.db.prepare("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() AND (table_name LIKE '%outbox%' OR table_name LIKE '%audit%' OR table_name LIKE '%log%' OR table_name LIKE '%usage%' OR table_name LIKE '%changes%' OR table_name LIKE '%alerts%' OR table_name LIKE '%revisions%' OR table_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(await 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((await w.db.prepare('SELECT COUNT(*) AS n FROM network_event_outbox').get()).n > 0, 'the outbox was written meanwhile');
++}
++
++module.exports = { withWorld, out, privateCrawl, privateMine, checkPrivateStatuses, secretsValues, secretsPeople, secretsNeedlesFor, secretsPaths, checkSecretsStored };