openvibers/openvibe.chat Commits

Secrets and private rooms, by crawl (roadmap WS-R task 5: internal-secret leak, private room bypass). test/security-crawl.js lists every route of the booted app from Express's router stack (a route added later is crawled without anyone listing it), fills parameters with seeded values and GETs each path as several people, with the session cookie and the bearer token; third-party fetches are kept off the network.

OP OpenVibers committed 26 Sept 2026 · 655135db62 · 3 files, +422 −0

test/security-secrets.test.js: a sentinel in the Network OAuth client secret, the Events subscription secret and the provider keys Chat reads from Live's settings (GIF, TTS, soundboard); 484 paths as anonymous, a viewer, a streamer, a global mod, an admin and the site owner, every write route with a broken body, the internal routes with a missing, wrong or other-audience service token and a forged webhook signature: no environment secret reaches anyone, stored keys only the owner (the TTS settings page), and neither the outbox nor the log lines carry them.

test/security-private.test.js: a private room, a private call room and a DM with words found nowhere else; 244 paths (pages, sitemap, search, lists, history, every parameter) as anonymous, a stranger, someone the room blocked and chat staff (who may look into rooms, never DMs): none of it appears. Also the WebSocket (joining the private room, a message posted while a stranger listens), the events outbox, and the private rooms' message ids swapped into the stranger's own room. All held.

Changes

ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..9a1e7f3
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,127 @@
++'use strict';
++/**
++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
++ * Chat app (walking Express's router stack, so a route added later is crawled without anyone
++ * listing it), fills route parameters with seeded values, and GETs each path as several people,
++ * reporting any response whose body or headers carry a value that person must never see.
++ * Not a test itself (no .test.js); used with test/helpers.js's boot().
++ */
++
++/** 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 the app behind `server` (an http.Server): [{ path, methods }]. */
++function listRoutes(server) {
++ const app = server.listeners('request').find((fn) => fn && fn._router);
++ if (!app) throw new Error('no Express app on this server');
++ 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; });
++}
++
++/** 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 GET path: each GET route expanded, also with `query` appended, plus `extra`. */
++function getPaths(server, values, { query = '', extra = [] } = {}) {
++ const paths = new Set();
++ for (const r of listRoutes(server)) {
++ if (!r.methods.includes('get') && !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];
++}
++
++/** Which of `needles` ({ label: value }) a response carries: [{ label, where }]. */
++function leaks(res, needles) {
++ const found = [];
++ const headerText = [...(res.headers && typeof res.headers.entries === 'function' ? res.headers.entries() : Object.entries(res.headers || {}))].map(([k, v]) => `${k}: ${v}`).join('\n');
++ for (const [label, value] of Object.entries(needles)) {
++ if (!value) continue;
++ if (res.text && res.text.includes(value)) found.push({ label, where: 'body' });
++ if (headerText.includes(value)) found.push({ label, where: 'headers' });
++ }
++ return found;
++}
++
++/**
++ * GET every path as every person ({ who: { token } }) through h.http. `needlesFor(who)` names what
++ * that person must never see. Resolves { found, answered, statuses }.
++ */
++async function crawl(h, paths, people, needlesFor, { concurrency = 6 } = {}) {
++ const found = [];
++ const statuses = {};
++ let answered = 0;
++ let n = 0;
++ for (const [who, auth] of Object.entries(people)) {
++ const needles = needlesFor(who);
++ const ip = `203.0.113.${100 + (n++)}`; // one address per person: the API limiter counts per address
++ const queue = [...paths];
++ await Promise.all(Array.from({ length: concurrency }, async () => {
++ while (queue.length) {
++ const p = queue.shift();
++ let r;
++ try { r = await h.http('GET', p, { token: auth && auth.token, headers: { 'cf-connecting-ip': ip, 'x-forwarded-for': ip, ...(auth && auth.token ? { cookie: `ov_token=${auth.token}` } : {}) } }); } catch (e) { 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;
++ for (const l of leaks(r, needles)) found.push(`${who}: GET ${p} → ${r.status} carries ${l.label} in its ${l.where}`);
++ }
++ }));
++ }
++ return { found, answered, statuses };
++}
++
++/**
++ * The crawl reaches routes that call third parties (GIF search, TTS): keep those off the network. Any
++ * fetch to a host other than loopback fails the way an unreachable provider does.
++ */
++function noNetwork() {
++ const realFetch = globalThis.fetch;
++ globalThis.fetch = (url, opts) => {
++ let host = '';
++ try { host = new URL(String((url && url.url) || url)).hostname; } catch { /* */ }
++ if (host !== '127.0.0.1' && host !== 'localhost') return Promise.reject(new TypeError('fetch failed (no network in tests)'));
++ return realFetch(url, opts);
++ };
++}
++
++module.exports = { listRoutes, expand, getPaths, leaks, crawl, noNetwork };
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
nnew file mode 100644
iindex 0000000..bf45d71
---- /dev/null
++++ b/test/security-private.test.js
@@@ -0,0 +1,171 @@
++'use strict';
++/**
++ * Private rooms and direct messages reach nobody outside them, on any path (roadmap WS-R task 5, the
++ * private-room class). rooms.test.js, room-kinds.test.js and dm-inbox.test.js pin the rules route
++ * by route; this suite covers the class: it seeds a private community room, a private call room and
++ * a DM, each with words found nowhere else, then GETs EVERY route the booted app has (listed from
++ * Express's router stack, test/security-crawl.js), with the private rooms' slugs and message ids,
++ * the DM's id and its participants' names in every parameter, plus the pages, as anonymous, a
++ * signed-in stranger and someone the room blocked. None of those words may appear. Chat staff may
++ * look into private rooms (the moderation rule) but are strangers to DMs.
++ *
++ * Then the paths the crawl cannot reach: the WebSocket (joining the private room, and a message
++ * posted while a stranger listens on every room they can), the events outbox (nothing about a
++ * private room or a DM leaves Chat), and message ids swapped into a room the stranger owns.
++ *
++ * node test/security-private.test.js
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++const { ids } = require('openvibe-contracts');
++const { getPaths, crawl, noNetwork } = require('./security-crawl');
++
++noNetwork();
++
++const t = suite('security: private rooms and DMs');
++let h, ann, bob, cat, dan, mod;
++const rooms = (method, path, u, body) => h.http(method, `/api/chat/rooms${path}`, { token: u && u.token, body });
++const dms = (method, path, u, body) => h.http(method, `/api/dm${path}`, { token: u && u.token, body });
++const SECRET = { roomName: 'Hidden Den', roomWords: 'whisper-in-the-den', callWords: 'backstage-secret-line', dmWords: 'dm-secret-words', topic: 'den-topic-words' };
++const seeded = {};
++
++t('boot and seed: a private room, a private call room, a DM', async () => {
++ h = await boot({ env: { CHAT_WEB_URL: 'https://openvibe.chat' } });
++ ann = h.addUser('ann', { subject: ids.newId('user') });
++ bob = h.addUser('bob', { subject: ids.newId('user') });
++ cat = h.addUser('cat', { subject: ids.newId('user') });
++ dan = h.addUser('dan', { subject: ids.newId('user') });
++ mod = h.addUser('staffmod', { subject: ids.newId('user'), role: 'global_mod' });
++ for (const u of [ann, bob, cat, dan, mod]) { h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); }
++
++ let r = await rooms('POST', '/', ann, { name: SECRET.roomName, visibility: 'private', topic: SECRET.topic });
++ assert.strictEqual(r.status, 201, r.text);
++ seeded.slug = r.body.room.slug;
++ assert.strictEqual((await rooms('POST', `/${seeded.slug}/members`, ann, { username: 'bob', role: 'member' })).status, 200);
++ assert.strictEqual((await rooms('POST', `/${seeded.slug}/members`, ann, { username: 'dan', role: 'blocked' })).status, 200);
++ r = await rooms('POST', `/${seeded.slug}/messages`, bob, { message: SECRET.roomWords });
++ assert.strictEqual(r.status, 201, r.text);
++ seeded.msg = r.body.message.id;
++
++ r = await rooms('POST', '/', ann, { name: 'Back Stage Call', kind: 'call', visibility: 'private' });
++ assert.strictEqual(r.status, 201, r.text);
++ seeded.call = r.body.room.slug;
++ r = await rooms('POST', `/${seeded.call}/messages`, ann, { message: SECRET.callWords });
++ assert.strictEqual(r.status, 201, r.text);
++ seeded.callMsg = r.body.message.id;
++
++ // Cat owns a public room of her own (for the id-swap checks).
++ r = await rooms('POST', '/', cat, { name: 'Open Lounge', visibility: 'public' });
++ seeded.open = r.body.room.slug;
++ r = await rooms('POST', `/${seeded.open}/messages`, cat, { message: 'hello lounge' });
++ seeded.openMsg = r.body.message.id;
++
++ r = await dms('POST', '/conversations', ann, { user_ids: [bob.id] });
++ assert.ok([200, 201].includes(r.status), r.text);
++ seeded.conv = r.body.conversation ? r.body.conversation.id : (r.body.id || r.body.conversation_id);
++ assert.ok(seeded.conv, r.text);
++ r = await dms('POST', `/conversations/${seeded.conv}/messages`, bob, { message: SECRET.dmWords });
++ assert.ok([200, 201].includes(r.status), r.text);
++ seeded.dmMsg = (r.body.message && r.body.message.id) || r.body.id;
++});
++
++t('control: members see all of it (the seeded words are live)', async () => {
++ assert.ok((await rooms('GET', `/${seeded.slug}/messages`, bob)).text.includes(SECRET.roomWords));
++ assert.ok((await rooms('GET', `/${seeded.call}/messages`, ann)).text.includes(SECRET.callWords));
++ assert.ok((await dms('GET', `/conversations/${seeded.conv}/messages`, ann)).text.includes(SECRET.dmWords));
++ assert.ok((await rooms('GET', `/${seeded.slug}/messages`, mod)).text.includes(SECRET.roomWords), 'chat staff may look into a private room');
++});
++
++t('every GET route and page: nothing of the private rooms or the DM reaches anonymous, a stranger, a blocked person; staff get no DM', async () => {
++ const values = (name) => {
++ if (/slug|room/i.test(name)) return [seeded.slug, seeded.call, seeded.open];
++ if (/user(name)?$|^name$|handle/i.test(name)) return ['ann', 'bob', ann.id, bob.id];
++ if (/userId|user_id/.test(name)) return [ann.id, bob.id];
++ return [seeded.msg, seeded.conv, seeded.callMsg, seeded.dmMsg, ann.id, 1];
++ };
++ const paths = getPaths(h.server, values, {
++ query: `q=${SECRET.roomWords}&room=${seeded.slug}&conversation=${seeded.conv}&user=ann&before=999999&after=0`,
++ extra: ['/', '/rooms', `/r/${seeded.slug}`, `/r/${seeded.call}`, `/r/${seeded.slug}/settings`, `/messages/${seeded.conv}`, '/messages',
++ '/sitemap.xml', '/robots.txt', '/updates', `/api/chat/search?q=${SECRET.roomWords}`, `/api/chat/search?q=${SECRET.dmWords}`,
++ '/api/chat/rooms?q=Hidden', '/api/chat/rooms?visibility=private', '/api/chat/global/history', '/api/chat/online'],
++ });
++ const everything = { roomName: SECRET.roomName, roomWords: SECRET.roomWords, callWords: SECRET.callWords, dmWords: SECRET.dmWords, topic: SECRET.topic };
++ const r = await crawl(h, paths, { anonymous: null, stranger: cat, blocked: dan, staff: mod }, (who) => (who === 'staff' ? { dmWords: SECRET.dmWords } : everything));
++ process.stdout.write(` (${paths.length} paths × 4 people; answers ${JSON.stringify(r.statuses)})\n`);
++ assert.ok(r.answered >= paths.length * 3, `${r.answered} answers`);
++ assert.deepStrictEqual(r.found, []);
++});
++
++t('WebSocket: a stranger cannot join the private room, and hears nothing posted in it', async () => {
++ const strangerWs = await h.ws({ token: cat.token });
++ strangerWs.sendJson = (o) => strangerWs.send(JSON.stringify(o));
++ strangerWs.sendJson({ type: 'join', token: cat.token });
++ await strangerWs.next((m) => m.type === 'auth');
++ strangerWs.sendJson({ type: 'join_room', room: seeded.slug });
++ strangerWs.sendJson({ type: 'join_room', room: seeded.call });
++ strangerWs.sendJson({ type: 'join_room', room: seeded.open });
++ await strangerWs.next((m) => m.type === 'room_joined' && m.room === seeded.open);
++ const bobWs = await h.ws({ token: bob.token });
++ bobWs.send(JSON.stringify({ type: 'join_room', room: seeded.slug }));
++ await bobWs.next((m) => m.type === 'room_joined');
++ bobWs.send(JSON.stringify({ type: 'room_message', message: 'live-private-words' }));
++ await bobWs.next((m) => m.type === 'room_message' && m.message && m.message.message === 'live-private-words');
++ await dms('POST', `/conversations/${seeded.conv}/messages`, ann, { message: 'live-dm-words' });
++ await h.sleep(300);
++ const heard = JSON.stringify(strangerWs.all);
++ assert.ok(!strangerWs.all.some((m) => m.type === 'room_joined' && (m.room === seeded.slug || m.room === seeded.call)), 'joined a private room');
++ for (const w of ['live-private-words', 'live-dm-words', SECRET.roomWords, SECRET.callWords, SECRET.dmWords, SECRET.roomName]) assert.ok(!heard.includes(w), `the stranger's socket heard "${w}"`);
++ strangerWs.close(); bobWs.close();
++});
++
++t('events outbox: nothing about a private room or a DM leaves Chat', async () => {
++ // (The staff member's own log searches above are audited with the words they typed: their query, not the room's content.)
++ const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox').filter((r) => !/"action_type":"chat_log_search"/.test(r.event)));
++ for (const w of ['live-private-words', 'live-dm-words', SECRET.roomWords, SECRET.callWords, SECRET.dmWords, SECRET.roomName, SECRET.topic]) assert.ok(!rows.includes(w), `outbox carries "${w}"`);
++});
++
++t('ids swapped into the stranger\'s own room reach nothing of the private room', async () => {
++ const before = h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id');
++ let r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.msg}`, cat);
++ assert.strictEqual(r.status, 404, r.text);
++ r = await rooms('DELETE', `/${seeded.open}/messages/${seeded.callMsg}`, cat);
++ assert.strictEqual(r.status, 404, r.text);
++ r = await rooms('GET', `/${seeded.open}/messages?after=${seeded.msg - 1}&limit=100`, cat);
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes(SECRET.roomWords) && !r.text.includes(SECRET.callWords));
++ r = await dms('DELETE', `/conversations/${seeded.conv}/messages/${seeded.dmMsg}`, cat);
++ assert.ok([403, 404].includes(r.status), r.text);
++ assert.deepStrictEqual(h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), before);
++ assert.ok((await dms('GET', `/conversations/${seeded.conv}/messages`, ann)).text.includes(SECRET.dmWords), 'the DM is intact');
++});
++
++t('a stranger\'s every write to the private room or the DM is refused and changes nothing', async () => {
++ const snap = () => JSON.stringify([h.db.all('SELECT * FROM rooms ORDER BY id'), h.db.all('SELECT room_id, user_id, role FROM room_members ORDER BY room_id, user_id'),
++ h.db.all('SELECT id, is_deleted FROM room_messages ORDER BY id'), h.db.all('SELECT * FROM dm_participants ORDER BY conversation_id, user_id')]);
++ const before = snap();
++ for (const [m, p, body] of [
++ ['PATCH', `/${seeded.slug}`, { name: 'pwned', visibility: 'public' }], ['POST', `/${seeded.slug}/join`], ['POST', `/${seeded.slug}/messages`, { message: 'x' }],
++ ['DELETE', `/${seeded.slug}/messages/${seeded.msg}`], ['POST', `/${seeded.slug}/members`, { username: 'cat', role: 'mod' }], ['POST', `/${seeded.slug}/read`],
++ ['GET', `/${seeded.slug}/members`], ['GET', `/${seeded.slug}/attachments`], ['POST', `/${seeded.slug}/attachments`, { service: 'community', resource: 'x' }],
++ ]) {
++ for (const who of [cat, dan]) {
++ const r = await rooms(m, p, who, body);
++ assert.ok([401, 403, 404].includes(r.status), `${who.username}: ${m} ${p} → ${r.status} ${r.text.slice(0, 120)}`);
++ assert.ok(!r.text.includes(SECRET.roomWords) && !r.text.includes(SECRET.roomName), `${m} ${p} carries the room`);
++ }
++ }
++ for (const [m, p, body] of [
++ ['GET', `/conversations/${seeded.conv}`], ['GET', `/conversations/${seeded.conv}/messages`], ['POST', `/conversations/${seeded.conv}/messages`, { message: 'x' }],
++ ['POST', `/conversations/${seeded.conv}/read`], ['PATCH', `/conversations/${seeded.conv}`, { name: 'x' }],
++ ['POST', `/conversations/${seeded.conv}/participants`, { user_id: cat.id }], ['DELETE', `/conversations/${seeded.conv}/participants/${bob.id}`],
++ ]) {
++ for (const who of [cat, mod]) {
++ const r = await dms(m, p, who, body);
++ assert.ok([401, 403, 404].includes(r.status), `${who.username}: ${m} /api/dm${p} → ${r.status} ${r.text.slice(0, 120)}`);
++ assert.ok(!r.text.includes(SECRET.dmWords), `${m} ${p} carries the DM`);
++ }
++ }
++ assert.strictEqual(snap(), before);
++});
++
++t.run(() => h && h.close());
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..589544c
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,124 @@
++'use strict';
++/**
++ * Chat's secrets never leave in a response, an event or a log line (roadmap WS-R task 5, the
++ * internal-secret class). Chat boots with a sentinel in every secret it holds: its Network OAuth
++ * client secret, its OpenVibe.Events subscription secret, and the provider keys it reads from Live's
++ * site settings (GIF, TTS, soundboard). Then every route the booted app has (listed from Express's
++ * router stack, test/security-crawl.js) is requested as anonymous, a viewer, a streamer, a global
++ * mod, an admin and the site owner, along with the probes (/health, /ready), unknown paths, every
++ * write route with a broken body, the internal routes with a wrong or missing service token and the
++ * events webhook with a forged signature. No body or header may contain an environment secret;
++ * the stored provider keys are shown to the site owner only (the TTS settings page, where they are
++ * edited). The events outbox and the log lines written meanwhile may not contain them either.
++ *
++ * node test/security-secrets.test.js
++ */
++const assert = require('assert');
++const { boot, suite } = require('./helpers');
++const { ids } = require('openvibe-contracts');
++const { getPaths, crawl, listRoutes, expand, leaks, noNetwork } = require('./security-crawl');
++
++noNetwork();
++
++const t = suite('security: secrets');
++const ENV = {
++ OV_OAUTH_CLIENT_SECRET: 'sentinel-not-a-secret-chat-oauth-client',
++ CHAT_EVENTS_SECRET: 'sentinel-not-a-secret-chat-events-subscription',
++};
++const SETTINGS = {
++ gif_tenor_api_key: 'sentinel-not-a-secret-tenor',
++ gif_giphy_api_key: 'sentinel-not-a-secret-giphy',
++ tts_google_api_key: 'sentinel-not-a-secret-google-tts',
++ tts_aws_access_key_id: 'sentinel-not-a-secret-aws-id',
++ tts_aws_secret_access_key: 'sentinel-not-a-secret-aws-key',
++ soundboard_101_api_key: 'sentinel-not-a-secret-soundboard',
++};
++let h, people;
++const logs = [];
++
++t('boot with a sentinel in every secret', async () => {
++ h = await boot({ env: { ...ENV, CHAT_WEB_URL: 'https://openvibe.chat' } });
++ Object.assign(h.live.settings, SETTINGS, { tts_enabled: true, tts_provider: 'google' });
++ await h.ctx.ensureSettings(true);
++ const mk = (name, opts = {}) => { const u = h.addUser(name, { subject: ids.newId('user'), ...opts }); h.ctx.upsertUser(h.live.users.get(u.id)); h.netModules.subjects.add(u.subject_id); return u; };
++ people = {
++ anonymous: null, viewer: mk('viewer'), streamer: mk('streamer', { role: 'streamer' }), 'global mod': mk('staffmod', { role: 'global_mod' }),
++ admin: mk('admin', { role: 'admin' }), 'site owner': mk('owner', { role: 'admin', is_owner: 1 }),
++ };
++ people.stream = h.addStream(people.streamer.id, h.addChannel(people.streamer.id), { title: 'Live now', is_live: 1 });
++});
++
++t('control: the site owner\'s TTS settings show the stored key (so a crawl that finds none means something)', async () => {
++ const r = await h.http('GET', '/api/tts/admin/settings', { token: people['site owner'].token });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.ok(r.text.includes(SETTINGS.tts_google_api_key), 'the owner edits them here');
++ const a = await h.http('GET', '/api/tts/admin/settings', { token: people.admin.token });
++ assert.strictEqual(a.status, 200);
++ assert.ok(!a.text.includes(SETTINGS.tts_google_api_key), 'an admin who is not the owner gets them masked');
++});
++
++t('every GET route, page and probe, as six people: no environment secret to anyone, no stored key to anyone but the owner', async () => {
++ const orig = {};
++ for (const m of ['log', 'info', 'warn', 'error']) { orig[m] = console[m]; console[m] = (...a) => { logs.push(a.map(String).join(' ')); }; }
++ let r;
++ try {
++ const nonsense = ['-1', '99999999999', "'\"<x>", 'x'.repeat(300)];
++ const values = () => [people.stream, people.streamer.id, 'streamer', 1, ...nonsense];
++ const paths = getPaths(h.server, values, {
++ query: 'q=x&provider=tenor&limit=-1&before=%27',
++ extra: ['/health', '/ready', '/api/nope', '/nope/nope', '/.env', '/api/%', '/api/chat/gif/trending?provider=tenor', '/api/chat/gif/search?provider=giphy&q=cats',
++ '/api/tts/settings', '/api/tts/voices', '/sitemap.xml', '/robots.txt'],
++ });
++ const all = { ...ENV, ...SETTINGS };
++ r = await crawl(h, paths, people, (who) => (who === 'site owner' ? ENV : all));
++ process.stdout.write(` (${paths.length} paths × 6 people; answers ${JSON.stringify(r.statuses)})\n`);
++ } finally { Object.assign(console, orig); }
++ assert.ok(r.answered > 0);
++ assert.deepStrictEqual(r.found, []);
++});
++
++t('every write route with a broken body, anonymous and as an admin: the error names no secret', async () => {
++ const found = [];
++ const values = () => [people.streamer.id, 'streamer', 1];
++ for (const route of listRoutes(h.server)) {
++ const method = route.methods.find((m) => ['post', 'put', 'patch', 'delete'].includes(m));
++ if (!method) continue;
++ for (const p of expand(route.path, values).slice(0, 1)) {
++ for (const who of [null, people.admin]) {
++ const res = await h.http(method.toUpperCase(), p, { token: who && who.token, headers: { 'content-type': 'application/json', 'x-forwarded-for': '203.0.113.200' }, raw: '{"broken": ' });
++ for (const l of leaks(res, { ...ENV, ...SETTINGS })) found.push(`${who ? 'admin' : 'anonymous'}: ${method} ${p} → ${res.status} carries ${l.label}`);
++ }
++ }
++ }
++ assert.deepStrictEqual(found, []);
++});
++
++t('internal routes with a wrong, missing or other-audience service token, and the events webhook with a forged signature, echo nothing', async () => {
++ const found = [];
++ const internal = listRoutes(h.server).filter((r) => r.path.startsWith('/internal'));
++ assert.ok(internal.length > 0, 'internal routes listed');
++ const tokens = [null, 'not-a-token', h.serviceToken(['chat.context.read'], { aud: 'openvibe.somewhere-else' })];
++ for (const route of internal) {
++ for (const p of expand(route.path, () => [1])) {
++ for (const method of route.methods.filter((m) => m !== '_all').concat(route.methods.includes('_all') ? ['post', 'get'] : [])) {
++ for (const tok of tokens) {
++ const res = await h.http(method.toUpperCase(), p, { headers: { ...(tok ? { authorization: `Bearer ${tok}` } : {}), 'content-type': 'application/json', 'openvibe-signature': 't=1,v1=00', 'x-openvibe-signature': 'sha256=00' }, raw: method === 'get' ? undefined : '{"event_id":"evt_x"}' });
++ for (const l of leaks(res, { ...ENV, ...SETTINGS })) found.push(`${method} ${p} → ${res.status} carries ${l.label}`);
++ if (!tok) assert.ok(res.status >= 400, `${method} ${p} answered ${res.status} without a token`);
++ }
++ }
++ }
++ }
++ assert.deepStrictEqual(found, []);
++});
++
++t('the events outbox and the log lines carry no secret', async () => {
++ const rows = JSON.stringify(h.db.all('SELECT * FROM events_outbox'));
++ for (const [k, v] of Object.entries({ ...ENV, ...SETTINGS })) {
++ assert.ok(!rows.includes(v), `outbox carries ${k}`);
++ const line = logs.find((l) => l.includes(v));
++ assert.ok(!line, `a log line carries ${k}: ${line && line.slice(0, 160)}`);
++ }
++});
++
++t.run(() => h && h.close());