openvibers/openvibe.events Commits

Security crawl for secrets (roadmap WS-R task 5: internal-secret leak). Events holds one kind of secret, each subscription's signing secret, and reads none from its environment (test/security-secrets.test.js asserts that: a new secret-looking variable fails until it gets a sentinel). With service and app subscriptions whose secrets were rotated (both sign during the overlap), every GET route Express knows after boot (test/security-crawl.js walks the router stack, so a new route is crawled without anyone listing it) is requested with seeded and nonsense ids and several query strings, the realtime stream read for a moment, as anonymous, two people, three services, a reader and three developer apps; every write route gets malformed JSON and empty bodies, every route forged, alg-none, expired and wrong-audience tokens. No answer may carry a subscription secret (also URL-encoded or base64), another caller's token or any part of Network's private key; the deliveries sent, the log and every table but subscriptions neither. test/security-world.js is the shared fixture for the security suites.

OP OpenVibers committed 26 Sept 2026 · 7efb7580e6 · 4 files, +430 −0

Fix (low): creating a subscription and rotating its secret answered with the secret and no Cache-Control; everything under /api/v1/subscriptions is no-store now.

Changes

ddiff --git a/server/api/subscriptions.js b/server/api/subscriptions.js
iindex 385432b..921f651 100644
---- a/server/api/subscriptions.js
++++ b/server/api/subscriptions.js
@@@ -47,6 +47,8 @@ function checkRetryPolicy(p) {
function subscriptionsRouter({ config, store, auth, dnsLookup }) {
const router = express.Router();
++ // Creation and rotation answer with the signing secret (shown once): nothing here may be cached.
++ router.use('/api/v1/subscriptions', (req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
const guard = auth.appOrService(CAPS.subscribe, CAPS.appSubscribe, { requireService: true });
const consumerOf = (req) => (req.principal.kind === 'app' ? req.principal.sub : req.principal.service);
ddiff --git a/test/security-crawl.js b/test/security-crawl.js
nnew file mode 100644
iindex 0000000..d164967
---- /dev/null
++++ b/test/security-crawl.js
@@@ -0,0 +1,147 @@
++'use strict';
++/**
++ * The crawler behind the security suites (roadmap WS-R task 5): lists every route of the booted
++ * Events app (walking Express's router stack, 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.
++ * The realtime stream is read for a moment and then closed. Not a test itself (no .test.js).
++ */
++
++/** 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 Express 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 path for `method`: each route expanded, also with each of `queries` appended, plus `extra`. */
++function pathsFor(routes, values, { method = 'get', queries = [], 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);
++ for (const q of queries) if (!p.includes('?')) paths.add(`${p}?${q}`);
++ }
++ }
++ 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 }) a response ({ text, headers }) carries: [{ 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;
++}
++
++/** One request; a streaming answer (SSE) is read for `streamMs` and then cut. */
++async function hit(base, method, p, { headers = {}, body, streamMs = 250 } = {}) {
++ const ac = new AbortController();
++ const h = { ...headers };
++ let b;
++ if (body !== undefined) { if (typeof body === 'string') b = body; else b = JSON.stringify(body); h['content-type'] = h['content-type'] || 'application/json'; }
++ const res = await fetch(base + p, { method, headers: h, body: b, redirect: 'manual', signal: ac.signal });
++ let text = '';
++ if (String(res.headers.get('content-type') || '').includes('text/event-stream')) {
++ const reader = res.body.getReader();
++ const dec = new TextDecoder();
++ const stop = setTimeout(() => ac.abort(), streamMs);
++ try { for (;;) { const { done, value } = await reader.read(); if (done) break; text += dec.decode(value, { stream: true }); } } catch { /* cut */ }
++ clearTimeout(stop);
++ } else {
++ text = await res.text();
++ }
++ return { status: res.status, headers: res.headers, text };
++}
++
++/**
++ * Requests every path as every caller ({ who: headers }). `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;
++ 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 { r = await hit(base, method, p, { headers, body }); } 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, listRoutes, expand, pathsFor, forms, leaks, hit, crawl };
ddiff --git a/test/security-secrets.test.js b/test/security-secrets.test.js
nnew file mode 100644
iindex 0000000..262da33
---- /dev/null
++++ b/test/security-secrets.test.js
@@@ -0,0 +1,141 @@
++'use strict';
++// Internal-secret leak (roadmap WS-R task 5). Events holds one kind of secret: each subscription's
++// signing secret (service and app subscriptions; after a rotation the previous one keeps signing
++// during the overlap). It reads no secret from its environment (asserted below: a new secret-looking
++// variable fails this test until it gets a sentinel here); the Network key it verifies with is public,
++// and the matching private key (generated by test/helpers.js) must never appear either.
++// With the seeded world of test/security-world.js, every GET route Express knows after boot (walked
++// from the router stack, so a new route is crawled without anyone listing it) is requested with
++// seeded and nonsense ids and several query strings, the realtime stream read for a moment, as
++// anonymous, two users (Bearer and cookie), three services (one an operator), a reader and three
++// developer apps; then every write route gets malformed JSON from everyone and an empty body from
++// anonymous and an app, and every route wrong tokens (forged, alg none, expired, wrong audience).
++// No body or header may carry a subscription secret (as is, URL-encoded or base64), another caller's
++// token or any part of the private key; the shown-once answers (create, rotate) must be no-store; the
++// deliveries sent, the log, and every table but subscriptions must not carry a secret either.
++// node test/security-secrets.test.js
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const path = require('path');
++const { suite, serviceToken, appToken, userToken, ISSUER } = require('./helpers');
++const { buildWorld } = require('./security-world');
++const crawler = require('./security-crawl');
++
++const t = suite('security-secrets');
++let w;
++const needles = {};
++
++t('Events reads no secret from its environment (so there is none to plant a sentinel in)', async () => {
++ const names = new Set();
++ const walk = (d) => {
++ for (const f of fs.readdirSync(d)) {
++ const p = path.join(d, f);
++ if (fs.statSync(p).isDirectory()) walk(p);
++ else if (p.endsWith('.js')) for (const m of fs.readFileSync(p, 'utf8').matchAll(/\b(?:process\.)?env\.([A-Z][A-Z0-9_]+)|env\[['"]([A-Z][A-Z0-9_]+)['"]\]/g)) names.add(m[1] || m[2]);
++ }
++ };
++ walk(path.join(__dirname, '..', 'server'));
++ assert.ok(names.has('EVENTS_DB_PATH') && names.has('OV_NETWORK_PUBLIC_KEY'), 'the scan sees the config');
++ const secretish = [...names].filter((n) => /SECRET|PASSWORD|TOKEN|PRIVATE|CREDENTIAL|_KEY$/.test(n) && n !== 'OV_NETWORK_PUBLIC_KEY');
++ assert.deepStrictEqual(secretish, [], 'a new secret-looking variable: give it a sentinel in this test');
++});
++
++t('world', async () => {
++ w = await buildWorld();
++ Object.assign(needles, w.secrets);
++ // The Network private key (Events only ever has the public half).
++ const pem = (k) => k.split('\n').filter((l) => l && !l.startsWith('-----'));
++ const pub = pem(crypto.createPublicKey(w.privateKey).export({ type: 'spki', format: 'pem' })).join('');
++ pem(w.privateKey).forEach((l, i) => { if (!pub.includes(l)) needles[`network private key PEM line ${i}`] = l; });
++ const jwk = crypto.createPrivateKey(w.privateKey).export({ format: 'jwk' });
++ for (const f of ['d', 'p', 'q', 'dp', 'dq', 'qi']) needles[`network private key JWK ${f}`] = jwk[f];
++ assert.strictEqual(Object.keys(w.secrets).length, 7);
++});
++
++t('shown once: creation and rotation answer with the secret, never cacheable', async () => {
++ assert.strictEqual(w.shownOnce.length, 4);
++ for (const s of w.shownOnce) assert.match(s.cache, /no-store/, `${s.what}: Cache-Control no-store`);
++});
++
++t('crawl: no GET, refusal or error carries a secret, a token or the key', async () => {
++ const { subs, ev, ids } = w;
++ const people = {
++ anonymous: {},
++ userBearer: { authorization: `Bearer ${w.tok.userX}` },
++ userCookie: { cookie: `ov_token=${w.tok.userY}` },
++ live: { authorization: `Bearer ${w.tok.live}` },
++ media: { authorization: `Bearer ${w.tok.media}` },
++ ops: { authorization: `Bearer ${w.tok.ops}` },
++ reader: { authorization: `Bearer ${w.tok.reader}` },
++ appA: { authorization: `Bearer ${w.tok.appA}` },
++ appA2: { authorization: `Bearer ${w.tok.appA2}` },
++ appB: { authorization: `Bearer ${w.tok.appB}` },
++ };
++ const tokenOf = (h) => String(h.authorization || h.cookie || '').replace(/^Bearer |^ov_token=/, '');
++ const needlesFor = (who) => {
++ const n = { ...needles };
++ for (const [other, h] of Object.entries(people)) if (other !== who && tokenOf(h)) n[`${other}'s token`] = tokenOf(h);
++ return n;
++ };
++ const values = (name) => ({ id: [subs.live.id, subs.media.id, subs.appA.id, subs.appB.id, ev.public, ev.internal, ev.appA, 'sub_00000000000000000000000000', 'x', '..%2f'] }[name] || ['x']);
++ const queries = ['topic=*', 'topic=live.*', `topic=app.${ids.keyA}.*`, `topic=app.${ids.keyB}.*,live.*`, 'status=dead', `subscription_id=${subs.appB.id}`, 'after_seq=0&limit=1000',
++ 'topics=live.*,network.notification.*&last_event_id=0', 'debug=1&include=secret'];
++ const paths = crawler.pathsFor(w.routes, values, { method: 'get', queries, extra: ['/nope', '/api/nope', '/.env', '/data/events.db', '/package.json', '/api/v1/subscriptions/../../package.json'] });
++ const gets = await crawler.crawl(w.base, paths, people, needlesFor);
++ const found = [...gets.found];
++ assert.ok(gets.answered === paths.length * Object.keys(people).length, 'every request answered');
++ assert.ok((gets.statuses['2xx'] || 0) > 150, `real answers, not only refusals (${JSON.stringify(gets.statuses)})`);
++ const st = (who, p) => gets.byPath[`${who} GET ${p}`];
++ assert.strictEqual(st('live', '/api/v1/subscriptions'), 200, 'the crawl was signed in');
++ assert.strictEqual(st('appA', '/api/v1/subscriptions'), 200);
++ assert.strictEqual(st('ops', '/api/v1/deliveries'), 200);
++ assert.strictEqual(st('anonymous', '/api/v1/subscriptions'), 401);
++
++ // Error paths: every write route, malformed from everyone, empty from anonymous and an app.
++ for (const m of ['post', 'put', 'patch', 'delete']) {
++ const list = crawler.pathsFor(w.routes, values, { method: m });
++ if (!list.length) continue;
++ found.push(...(await crawler.crawl(w.base, list, people, needlesFor, { method: m.toUpperCase(), body: '{"broken": ' })).found);
++ found.push(...(await crawler.crawl(w.base, list, { anonymous: {}, appB: people.appB }, needlesFor, { method: m.toUpperCase(), body: '{}' })).found);
++ }
++ // Wrong tokens of every kind.
++ const now = Math.floor(Date.now() / 1000);
++ const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, privateKeyEncoding: { type: 'pkcs8', format: 'pem' }, publicKeyEncoding: { type: 'spki', format: 'pem' } });
++ const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++ const wrong = {
++ forged: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { key: other.privateKey })}` },
++ algNone: { authorization: `Bearer ${b64({ alg: 'none', typ: 'JWT' })}.${b64({ iss: ISSUER, sub: 'svc:live', actor_type: 'service', aud: ['openvibe.events'], cap: ['events.delivery.admin'], iat: now, exp: now + 300 })}.` },
++ expired: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { exp: now - 60 })}` },
++ wrongAudience: { authorization: `Bearer ${serviceToken('live', ['events.delivery.admin'], { aud: 'openvibe.media' })}` },
++ appWithAdmin: { authorization: `Bearer ${appToken({ appId: ids.A, projectId: ids.PA, env: 'production', cap: ['events.delivery.admin', 'events.subscription.manage'] })}` },
++ userAsService: { authorization: `Bearer ${userToken({ aud: ['openvibe.events'] })}` },
++ garbage: { authorization: 'Bearer x.y.z' },
++ };
++ const wr = await crawler.crawl(w.base, paths.filter((p) => p.startsWith('/api/v1/')), wrong, () => needles);
++ found.push(...wr.found);
++ const ok2xx = Object.entries(wr.byPath).filter(([, s]) => s < 400).map(([k, s]) => `${k} → ${s}`);
++ assert.deepStrictEqual(ok2xx, [], 'wrong tokens open nothing');
++ assert.deepStrictEqual(found, [], `secrets in answers:\n${found.join('\n')}`);
++});
++
++t('deliveries, the log and the tables carry no secret', async () => {
++ const all = { ...needles };
++ const sent = [...w.fp.calls.map((c) => ({ text: c.rawBody.toString('utf8'), headers: c.headers })), ...w.appSeen.map((c) => ({ text: c.rawBody, headers: c.headers }))];
++ assert.ok(sent.length >= 5, 'deliveries were made');
++ for (const s of sent) assert.ok(s.headers['x-openvibe-signature'] || s.headers['x-openvibe-signature-v2'] || Object.keys(s.headers).some((k) => /signature/.test(k)), 'each delivery is signed');
++ const inSent = sent.flatMap((s) => crawler.leaks(s, all).map((l) => l.label));
++ assert.deepStrictEqual(inSent, [], 'a delivery carries signatures, never the secret');
++ const inLog = crawler.leaks({ text: w.logged.join('\n'), headers: {} }, all).map((l) => l.label);
++ assert.deepStrictEqual(inLog, [], 'nothing secret was logged');
++ const db = w.h.db;
++ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'").all().map((r) => r.name).filter((n) => n !== 'subscriptions');
++ assert.ok(tables.includes('events') && tables.includes('deliveries'));
++ const hits = [];
++ for (const tb of tables) for (const l of crawler.leaks({ text: JSON.stringify(db.prepare(`SELECT * FROM "${tb}"`).all()), headers: {} }, all)) hits.push(`${tb}: ${l.label}`);
++ assert.deepStrictEqual(hits, [], 'no secret outside the subscriptions table (events, deliveries, usage outbox)');
++});
++
++t('stop', async () => { await w.stop(); });
++
++t.run();
ddiff --git a/test/security-world.js b/test/security-world.js
nnew file mode 100644
iindex 0000000..f5c669e
---- /dev/null
++++ b/test/security-world.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * The world the security suites run against (roadmap WS-R task 5): Events started in-process
++ * (server/index.js start()) on a temp database with a manual clock and a log that is kept, the
++ * Network key pair generated by test/helpers.js at load (throwaway, never committed), and:
++ * services live (publish, read, subscribe), media (the same), network (publish), ops
++ * (events.delivery.admin), and a reader without admin
++ * apps A (project PA, sandbox), A2 (project PA, production), B (project PB, sandbox)
++ * people x and y (user tokens), plus realtime tickets for x
++ * subscriptions, each with its signing secret: live's (explicit secret, then rotated: both
++ * secrets sign during the overlap), media's, A's (rotated too), A2's and B's; first-party
++ * deliveries go to a stub on 127.0.0.1, app deliveries through the real guarded poster
++ * with a fake DNS whose public answers the local receiver stands in for
++ * events public, internal and subject (for x, for y) first-party events, and app events in
++ * each project and environment, each with a distinct marker in its payload
++ * `secrets` collects every secret value with a label. Not a test itself (no .test.js).
++ */
++const fs = require('fs');
++const path = require('path');
++const nodeHttp = require('http');
++const { ids } = require('openvibe-contracts');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++const apps = require('../server/apps');
++const { createGuardedPost, isPublicAddress } = require('../server/egress');
++const { privateKey, publicKey, serviceToken, appToken, userToken, realtimeTicket, manualClock, tmpDir, request, envelope, subscriber } = require('./helpers');
++const crawler = require('./security-crawl');
++
++const S = (name) => `sentinel-not-a-secret-${name}`;
++const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
++
++async function buildWorld({ env = {} } = {}) {
++ const dir = tmpDir();
++ const logged = [];
++ const keep = (...a) => logged.push(a.map((x) => (x && x.stack) || String(x)).join(' '));
++ const log = { log: keep, info: keep, warn: keep, error: keep };
++
++ // App endpoints: a fake DNS, and a local receiver standing in for every public address.
++ const dnsTable = new Map([['hooks-a.example.com', ['93.184.216.34']], ['hooks-a2.example.com', ['93.184.216.35']], ['hooks-b.example.com', ['93.184.216.36']]]);
++ const dnsLookup = (host, opts, cb) => {
++ if (typeof opts === 'function') { cb = opts; opts = {}; }
++ const list = (dnsTable.get(String(host).toLowerCase()) || []).map((a) => ({ address: a, family: a.includes(':') ? 6 : 4 }));
++ if (!list.length) return cb(Object.assign(new Error('not found'), { code: 'ENOTFOUND' }));
++ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
++ };
++ const appSeen = [];
++ const receiver = nodeHttp.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', (c) => chunks.push(c));
++ req.on('end', () => { appSeen.push({ url: req.url, headers: req.headers, rawBody: Buffer.concat(chunks).toString('utf8') }); res.statusCode = 204; res.end(); });
++ });
++ await new Promise((r) => receiver.listen(0, '127.0.0.1', r));
++ const receiverPort = receiver.address().port;
++ const appPost = createGuardedPost({
++ lookup: (host, opts, cb) => dnsLookup(host, opts, (err, a, f) => {
++ if (err) return cb(err);
++ const list = (Array.isArray(a) ? a : [{ address: a, family: f }]).map((x) => ({ ...x, address: isPublicAddress(x.address) ? '127.0.0.1' : x.address }));
++ return opts.all ? cb(null, list) : cb(null, list[0].address, list[0].family);
++ }),
++ requestImpl: (options, cb) => nodeHttp.request({ ...options, port: receiverPort }, cb),
++ isAllowed: (ip) => ip === '127.0.0.1' || isPublicAddress(ip),
++ });
++
++ const clock = manualClock();
++ const config = load({ NODE_ENV: 'test', PORT: '0', EVENTS_DB_PATH: path.join(dir, 'events.db'), OV_NETWORK_PUBLIC_KEY: publicKey, EVENTS_WORKER: 'off', ...env });
++ const h = await start({ config, clock, log, appPost, dnsLookup });
++ const base = `http://127.0.0.1:${h.server.address().port}`;
++ const routes = crawler.listRoutes(h.server);
++
++ // ── Principals ───────────────────────────────────────────────────
++ const FP = ['events.event.publish', 'events.event.read', 'events.subscription.manage'];
++ const PA = `prj_${ids.ulid()}`; const PB = `prj_${ids.ulid()}`;
++ const A = ids.newId('app'); const A2 = ids.newId('app'); const B = ids.newId('app');
++ const x = ids.newId('user'); const y = ids.newId('user');
++ const tok = {
++ live: serviceToken('live', FP),
++ media: serviceToken('media', FP),
++ network: serviceToken('network', ['events.event.publish']),
++ ops: serviceToken('ops', ['events.delivery.admin']),
++ reader: serviceToken('search', ['events.event.read']),
++ appA: appToken({ appId: A, projectId: PA, env: 'sandbox', cap: ALL }),
++ appA2: appToken({ appId: A2, projectId: PA, env: 'production', cap: ALL }),
++ appB: appToken({ appId: B, projectId: PB, env: 'sandbox', cap: ALL }),
++ userX: userToken({ subjectId: x }),
++ userY: userToken({ subjectId: y }),
++ };
++ const call = (token, method, p, body, headers = {}) => request(base, method, p, { token, body, headers });
++ const must = (r, status, what) => { if (r.status !== status) throw new Error(`${what}: ${r.status} ${r.text.slice(0, 300)}`); return r; };
++ const shownOnce = [];
++ const once = (what, r) => { shownOnce.push({ what, status: r.status, cache: r.headers.get('cache-control') || '' }); return r; };
++
++ // ── Subscriptions and their secrets ──────────────────────────────
++ const fp = await subscriber();
++ const secrets = {};
++ const subs = {};
++ const liveSecret1 = S('live-subscription-secret-1');
++ const liveSecret2 = S('live-subscription-secret-2');
++ subs.live = once('subscription create (service)', must(await call(tok.live, 'POST', '/api/v1/subscriptions', { topic_pattern: 'live.*', endpoint: fp.url, secret: liveSecret1 }), 201, 'live subscription')).body;
++ once('rotate (service)', must(await call(tok.live, 'POST', `/api/v1/subscriptions/${subs.live.id}/rotate-secret`, { secret: liveSecret2, overlap_s: 3600 }), 200, 'live rotate'));
++ secrets['live subscription secret (previous, in overlap)'] = liveSecret1;
++ secrets['live subscription secret (current)'] = liveSecret2;
++ subs.media = must(await call(tok.media, 'POST', '/api/v1/subscriptions', { topic_pattern: 'media.*', endpoint: fp.url.replace('/hook', '/media') }), 201, 'media subscription').body;
++ secrets['media subscription secret'] = subs.media.secret;
++ const keyA = apps.projectKey(PA); const keyB = apps.projectKey(PB);
++ subs.appA = once('subscription create (app)', must(await call(tok.appA, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a.example.com/a' }), 201, 'app A subscription')).body;
++ secrets['app A subscription secret (previous, in overlap)'] = subs.appA.secret;
++ const rotA = once('rotate (app)', must(await call(tok.appA, 'POST', `/api/v1/subscriptions/${subs.appA.id}/rotate-secret`, {}), 200, 'app A rotate')).body;
++ secrets['app A subscription secret (current)'] = rotA.secret;
++ subs.appA2 = must(await call(tok.appA2, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyA}.*`, endpoint: 'https://hooks-a2.example.com/a2' }), 201, 'app A2 subscription').body;
++ secrets['app A2 subscription secret'] = subs.appA2.secret;
++ subs.appB = must(await call(tok.appB, 'POST', '/api/v1/subscriptions', { topic_pattern: `app.${keyB}.*`, endpoint: 'https://hooks-b.example.com/b' }), 201, 'app B subscription').body;
++ secrets['app B subscription secret'] = subs.appB.secret;
++ for (const s of Object.values(subs)) delete s.secret;
++
++ // ── Events, one marker each ──────────────────────────────────────
++ const ev = {};
++ const pub = async (name, token, body) => { ev[name] = must(await call(token, 'POST', '/api/v1/events', body), 201, `publish ${name}`).body.event_id; };
++ await pub('public', tok.live, envelope('live', { visibility: 'public', payload: { title: 'marker-public-live' } }));
++ await pub('internal', tok.live, envelope('live', { visibility: 'internal', payload: { title: 'marker-internal-live' } }));
++ const note = (to, m) => envelope('network', { event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: to }, actor: { type: 'service', id: 'network' }, payload: { title: m } });
++ await pub('subjectX', tok.network, note(x, 'marker-subject-x'));
++ await pub('subjectY', tok.network, note(y, 'marker-subject-y'));
++ const appEvent = (appId, projectId, marker) => envelope('x', { source: apps.appSource(appId), event_type: `app.${apps.projectKey(projectId)}.order.created`, actor: { type: 'app', id: appId }, subject: { type: 'order', id: '42' }, visibility: 'internal', payload: { title: marker } });
++ await pub('appA', tok.appA, appEvent(A, PA, 'marker-app-a-sandbox'));
++ await pub('appA2', tok.appA2, appEvent(A2, PA, 'marker-app-a-production'));
++ await pub('appB', tok.appB, appEvent(B, PB, 'marker-app-b-sandbox'));
++ await h.worker.drain();
++
++ const markers = {
++ public: 'marker-public-live', internal: 'marker-internal-live', subjectX: 'marker-subject-x', subjectY: 'marker-subject-y',
++ appA: 'marker-app-a-sandbox', appA2: 'marker-app-a-production', appB: 'marker-app-b-sandbox',
++ };
++ return {
++ h, base, dir, config, clock, routes, logged, secrets, subs, ev, markers, tok, shownOnce, call, fp, appSeen, dnsTable, dnsLookup,
++ ids: { PA, PB, A, A2, B, x, y, keyA, keyB }, privateKey, realtimeTicket,
++ stop: async () => { await fp.close(); await new Promise((r) => receiver.close(r)); await h.close(); fs.rmSync(dir, { recursive: true, force: true }); },
++ };
++}
++
++module.exports = { buildWorld, S, ALL };