openvibers/openvibe.events Commits

Events service: durable store, publish API, signed delivery with retry/DLQ/replay, SSE realtime gateway, client library

OP OpenVibers committed 23 Sept 2026 · f8c2e24189 · 23 files, +4473 −0

- SQLite store (events with global seq, subscriptions, deliveries, consumer_checkpoints,
  idempotency_receipts); an event and its delivery rows commit before anything is sent
- POST /api/v1/events: events.event-envelope@1, source = calling service, per-source type
  prefixes, idempotent on event_id, batches of up to 100, loop guard at 8 hops
- worker: HMAC-signed POSTs, backoff 1s..1h, dead after 8 attempts, priority classes,
  one in flight per subscription and 20 overall; admin DLQ listing and replay
- subscriptions with an SSRF allow-list (127.0.0.1, *.openvibe.*); pull API with cursor,
  gap reporting and checkpoints; 30-day retention
- /realtime/stream SSE: public/subject/internal visibility, Last-Event-ID resume, gap events,
  heartbeat, topic and connection caps, credentialed CORS for https://*.openvibe.* only
- lib/client.js (package main): publisher, transactional outbox, verifyDelivery, inbox
- tests: publish validation, delivery/backoff/DLQ/replay, crash + replay exactly-once,
  outbox rollback/commit, SSE visibility/resume/gap, loop guard, SSRF

Changes

ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/lib/client.js b/lib/client.js
nnew file mode 100644
iindex 0000000..cdb636c
---- /dev/null
++++ b/lib/client.js
@@@ -0,0 +1,313 @@
++'use strict';
++/**
++ * openvibe-events client: what producing and consuming services use.
++ *
++ * const events = require('openvibe-events');
++ *
++ * // Producer: transactional outbox (better-sqlite3), relayed to OpenVibe.Events
++ * const publisher = events.createPublisher({ eventsUrl: 'http://127.0.0.1:4300', tokenClient });
++ * const outbox = events.createOutbox(db, { publisher });
++ * outbox.ensureSchema();
++ * db.transaction(() => {
++ * db.prepare('UPDATE vods SET status = ? WHERE id = ?').run('ready', id);
++ * outbox.enqueue({ event_type: 'media.vod.ready', source: 'media', actor, subject, payload });
++ * })();
++ * outbox.start();
++ *
++ * // Consumer: signed webhook + exactly-once effects
++ * app.post('/internal/events', express.json({ verify: (req, _res, buf) => { req.rawBody = buf; } }), (req, res) => {
++ * if (!events.verifyDelivery(req.rawBody, req.get('X-OpenVibe-Signature'), SECRET)) return res.sendStatus(401);
++ * inbox.once('live', req.body.event.event_id, () => { ...db writes... });
++ * res.sendStatus(204);
++ * });
++ *
++ * `tokenClient` is anything with `authHeaders()` (openvibe-contracts serviceAuth.createTokenClient
++ * with audience 'openvibe.events').
++ */
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++
++const TRACE_RE = /^[0-9a-f]{32}$/;
++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-[0-9a-f]{16}-[0-9a-f]{2}$/;
++
++// ── Signatures ─────────────────────────────────────────────
++
++/** `sha256=<hex HMAC of the raw body>`, the value of X-OpenVibe-Signature. */
++function sign(rawBody, secret) {
++ return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(rawBody).digest('hex');
++}
++
++/** Constant-time check of a delivery's X-OpenVibe-Signature against the raw request body. */
++function verifyDelivery(rawBody, signature, secret) {
++ if (rawBody == null || typeof signature !== 'string' || !secret) return false;
++ const expected = Buffer.from(sign(Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody)), secret));
++ const given = Buffer.from(signature.trim());
++ return given.length === expected.length && crypto.timingSafeEqual(given, expected);
++}
++
++// ── Envelopes ──────────────────────────────────────────────
++
++function traceIdFrom(traceparent) {
++ const m = typeof traceparent === 'string' && TRACEPARENT_RE.exec(traceparent.trim().toLowerCase());
++ return m ? m[1] : null;
++}
++
++/**
++ * Fill the fields a producer should not have to think about: event_id (evt_<ULID>), timestamp,
++ * trace_id (from `traceparent` when given, so a consumer that republishes stays in the trace),
++ * version 1, priority 'important', visibility 'internal'. Returns a new object.
++ */
++function prepareEnvelope(envelope, { traceparent, now = Date.now() } = {}) {
++ if (!envelope || typeof envelope !== 'object') throw new TypeError('envelope must be an object');
++ const out = { ...envelope };
++ if (!out.event_id) out.event_id = ids.newId('event', now);
++ if (!out.timestamp) out.timestamp = new Date(now).toISOString();
++ if (!out.trace_id) out.trace_id = traceIdFrom(traceparent) || crypto.randomBytes(16).toString('hex');
++ if (!TRACE_RE.test(out.trace_id)) throw new TypeError('trace_id must be 32 lowercase hex characters');
++ if (out.version == null) out.version = 1;
++ if (!out.priority) out.priority = 'important';
++ if (!out.visibility) out.visibility = 'internal';
++ if (out.payload == null) out.payload = {};
++ return out;
++}
++
++// ── Publisher ──────────────────────────────────────────────
++
++class PublishError extends Error {
++ constructor(status, problem) {
++ super(`events publish failed: ${status} ${(problem && (problem.code || problem.detail || problem.error)) || ''}`.trim());
++ this.status = status;
++ this.problem = problem || null;
++ this.code = problem && problem.code;
++ // 4xx other than 401/408/429 will fail the same way again.
++ this.permanent = status >= 400 && status < 500 && ![401, 408, 429].includes(status);
++ }
++}
++
++/**
++ * createPublisher({ eventsUrl, tokenClient, fetchImpl?, timeoutMs? })
++ * publish(envelope | [envelopes], { traceparent }) -> { event_id, seq, duplicate } | { results: [...] }
++ * A 401 invalidates the cached token and retries once.
++ */
++function createPublisher({ eventsUrl, tokenClient, fetchImpl = globalThis.fetch, timeoutMs = 10000 }) {
++ if (!eventsUrl) throw new TypeError('eventsUrl is required');
++ if (!tokenClient || typeof tokenClient.authHeaders !== 'function') throw new TypeError('tokenClient with authHeaders() is required');
++ const url = `${String(eventsUrl).replace(/\/$/, '')}/api/v1/events`;
++
++ async function post(body, traceparent) {
++ for (let attempt = 0; ; attempt++) {
++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokenClient.authHeaders()) };
++ if (traceparent) headers.traceparent = traceparent;
++ const res = await fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(body), signal: AbortSignal.timeout(timeoutMs) });
++ const json = await res.json().catch(() => null);
++ if (res.ok) return json;
++ if (res.status === 401 && attempt === 0 && typeof tokenClient.invalidate === 'function') {
++ tokenClient.invalidate();
++ continue;
++ }
++ throw new PublishError(res.status, json);
++ }
++ }
++
++ async function publish(input, { traceparent } = {}) {
++ if (Array.isArray(input)) {
++ const events = input.map(e => prepareEnvelope(e, { traceparent }));
++ return post({ events }, traceparent);
++ }
++ return post(prepareEnvelope(input, { traceparent }), traceparent);
++ }
++
++ return { publish, prepare: prepareEnvelope };
++}
++
++// ── Transactional outbox ───────────────────────────────────
++
++/**
++ * createOutbox(db, { publisher, table = 'event_outbox', batchSize = 50, intervalMs = 1000, now })
++ *
++ * ensureSchema() create the outbox table (idempotent)
++ * enqueue(envelope) INSIDE the caller's db transaction: the event exists iff the change commits
++ * flush() publish due rows once (batch; a rejected batch is retried row by row so one
++ * bad row cannot block the rest); resolves { sent, failed, rejected }
++ * start() / stop() relay loop (setTimeout chain, never overlapping)
++ * pending() count of rows not yet accepted by Events
++ *
++ * At-least-once: a crash between publish and mark-sent republishes the same event_id, which
++ * OpenVibe.Events answers as a duplicate.
++ */
++function createOutbox(db, { publisher, table = 'event_outbox', batchSize = 50, intervalMs = 1000, backoffMs = [1000, 5000, 30000, 120000, 600000], now = () => Date.now(), onError = null, allowOutsideTransaction = false } = {}) {
++ if (!db || typeof db.prepare !== 'function') throw new TypeError('a better-sqlite3 database is required');
++ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new TypeError('bad outbox table name');
++ let stmts = null;
++ let timer = null;
++ let running = false;
++ let flushing = null;
++
++ function ensureSchema() {
++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ event_id TEXT NOT NULL UNIQUE,
++ envelope TEXT NOT NULL,
++ traceparent TEXT,
++ created_at INTEGER NOT NULL,
++ attempts INTEGER NOT NULL DEFAULT 0,
++ next_attempt_at INTEGER NOT NULL DEFAULT 0,
++ sent_at INTEGER,
++ seq INTEGER,
++ rejected_at INTEGER,
++ last_error TEXT
++ );
++ CREATE INDEX IF NOT EXISTS idx_${table}_due ON ${table}(sent_at, rejected_at, next_attempt_at);`);
++ stmts = null;
++ }
++
++ function q() {
++ if (!stmts) {
++ stmts = {
++ insert: db.prepare(`INSERT INTO ${table} (event_id, envelope, traceparent, created_at, next_attempt_at) VALUES (?, ?, ?, ?, 0)`),
++ due: db.prepare(`SELECT * FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL AND next_attempt_at <= ? ORDER BY id LIMIT ?`),
++ sent: db.prepare(`UPDATE ${table} SET sent_at = ?, seq = ?, attempts = attempts + 1, last_error = NULL WHERE id = ?`),
++ failed: db.prepare(`UPDATE ${table} SET attempts = attempts + 1, next_attempt_at = ?, last_error = ? WHERE id = ?`),
++ rejected: db.prepare(`UPDATE ${table} SET rejected_at = ?, attempts = attempts + 1, last_error = ? WHERE id = ?`),
++ pending: db.prepare(`SELECT COUNT(*) AS n FROM ${table} WHERE sent_at IS NULL AND rejected_at IS NULL`),
++ prune: db.prepare(`DELETE FROM ${table} WHERE sent_at IS NOT NULL AND sent_at < ?`),
++ };
++ }
++ return stmts;
++ }
++
++ function enqueue(envelope, { traceparent } = {}) {
++ if (!allowOutsideTransaction && !db.inTransaction) {
++ throw new Error('outbox.enqueue() must run inside the transaction that makes the change');
++ }
++ const env = prepareEnvelope(envelope, { traceparent, now: now() });
++ q().insert.run(env.event_id, JSON.stringify(env), traceparent || null, now());
++ return env;
++ }
++
++ function backoff(attempts) {
++ return backoffMs[Math.min(attempts, backoffMs.length - 1)];
++ }
++
++ function markFailure(row, err) {
++ const message = String((err && err.message) || err).slice(0, 500);
++ if (err && err.permanent) q().rejected.run(now(), message, row.id);
++ else q().failed.run(now() + backoff(row.attempts), message, row.id);
++ if (onError) { try { onError(err, row); } catch { /* reporting must not break the relay */ } }
++ }
++
++ async function publishRows(rows) {
++ const stats = { sent: 0, failed: 0, rejected: 0 };
++ const envelopes = rows.map(r => JSON.parse(r.envelope));
++ try {
++ const out = rows.length === 1
++ ? { results: [await publisher.publish(envelopes[0], { traceparent: rows[0].traceparent || undefined })] }
++ : await publisher.publish(envelopes, { traceparent: rows[0].traceparent || undefined });
++ const byId = new Map((out.results || []).map(r => [r.event_id, r]));
++ db.transaction(() => { for (const row of rows) q().sent.run(now(), byId.get(row.event_id)?.seq ?? null, row.id); })();
++ stats.sent += rows.length;
++ } catch (err) {
++ if (rows.length > 1 && err.permanent) {
++ // One bad envelope rejects the whole batch: isolate it.
++ for (const row of rows) {
++ const s = await publishRows([row]);
++ stats.sent += s.sent; stats.failed += s.failed; stats.rejected += s.rejected;
++ }
++ } else {
++ for (const row of rows) markFailure(row, err);
++ if (err.permanent) stats.rejected += rows.length; else stats.failed += rows.length;
++ }
++ }
++ return stats;
++ }
++
++ async function doFlush() {
++ const total = { sent: 0, failed: 0, rejected: 0 };
++ for (;;) {
++ const rows = q().due.all(now(), batchSize);
++ if (!rows.length) break;
++ const s = await publishRows(rows);
++ total.sent += s.sent; total.failed += s.failed; total.rejected += s.rejected;
++ if (s.failed || rows.length < batchSize) break;
++ }
++ return total;
++ }
++
++ /** Publish due rows. Concurrent callers share the flush in progress. */
++ function flush() {
++ if (!flushing) flushing = doFlush().finally(() => { flushing = null; });
++ return flushing;
++ }
++
++ function schedule(ms) {
++ if (!running) return;
++ clearTimeout(timer);
++ timer = setTimeout(async () => {
++ timer = null;
++ try { await flush(); } catch (err) { if (onError) { try { onError(err); } catch { /* ignore */ } } }
++ schedule(intervalMs);
++ }, ms);
++ timer.unref?.();
++ }
++
++ return {
++ ensureSchema,
++ enqueue,
++ flush,
++ start() { if (!running) { running = true; schedule(0); } },
++ stop() { running = false; clearTimeout(timer); timer = null; return flushing || Promise.resolve(); },
++ /** Wake the relay now (e.g. right after the transaction commits). */
++ kick() { schedule(0); },
++ pending: () => q().pending.get().n,
++ /** Delete sent rows older than `olderThanMs`. */
++ prune(olderThanMs = 7 * 24 * 60 * 60 * 1000) { return q().prune.run(now() - olderThanMs).changes; },
++ };
++}
++
++// ── Inbox (exactly-once effects) ───────────────────────────
++
++/**
++ * createInbox(db, { table = 'idempotency_receipts' })
++ * ensureSchema()
++ * once(consumer, eventId, fn) -> { duplicate: true } | { duplicate: false, result }
++ *
++ * fn runs inside a better-sqlite3 transaction together with the receipt insert: either both commit
++ * (the effect happened exactly once) or neither does (fn threw; the redelivery will try again).
++ * fn must be synchronous and make its effects through this same database.
++ */
++function createInbox(db, { table = 'idempotency_receipts', now = () => Date.now() } = {}) {
++ if (!db || typeof db.prepare !== 'function') throw new TypeError('a better-sqlite3 database is required');
++ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(table)) throw new TypeError('bad inbox table name');
++ let claim = null;
++
++ function ensureSchema() {
++ db.exec(`CREATE TABLE IF NOT EXISTS ${table} (
++ consumer TEXT NOT NULL,
++ event_id TEXT NOT NULL,
++ processed_at INTEGER NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++ )`);
++ claim = null;
++ }
++
++ function once(consumer, eventId, fn) {
++ if (!consumer || !eventId) throw new TypeError('consumer and eventId are required');
++ if (!claim) claim = db.prepare(`INSERT OR IGNORE INTO ${table} (consumer, event_id, processed_at) VALUES (?, ?, ?)`);
++ return db.transaction(() => {
++ if (claim.run(String(consumer), String(eventId), now()).changes === 0) return { duplicate: true };
++ const result = fn();
++ if (result && typeof result.then === 'function') {
++ throw new TypeError('inbox.once(): fn must be synchronous (it runs inside a SQLite transaction)');
++ }
++ return { duplicate: false, result };
++ })();
++ }
++
++ function seen(consumer, eventId) {
++ return Boolean(db.prepare(`SELECT 1 FROM ${table} WHERE consumer = ? AND event_id = ?`).get(String(consumer), String(eventId)));
++ }
++
++ return { ensureSchema, once, seen };
++}
++
++module.exports = { sign, verifyDelivery, prepareEnvelope, createPublisher, createOutbox, createInbox, PublishError };
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..b96a408
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1360 @@
++{
++ "name": "openvibe-events",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-events",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.5.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1",
++ "integrity": "sha512-czQpWKEo8cY56Crj5l5Mv8r5vlIIj0vn/HMxv0OlIu5YTuSSlpvXyayYEfn3GQ5orDhaMIhOUlzmQMxUtWXaLA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..bd3fa28
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,25 @@
++{
++ "name": "openvibe-events",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Events: durable events, subscriptions, signed delivery with retry, dead letters and replay, plus the browser realtime (SSE) gateway. lib/client.js is the producer/consumer helper other services depend on.",
++ "main": "lib/client.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js"
++ },
++ "keywords": ["openvibe", "events", "outbox", "webhooks", "sse"],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Events",
++ "dependencies": {
++ "better-sqlite3": "^11.10.0",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.1"
++ },
++ "engines": {
++ "node": ">=20.0.0"
++ }
++}
ddiff --git a/server/api/publish.js b/server/api/publish.js
nnew file mode 100644
iindex 0000000..dc59317
---- /dev/null
++++ b/server/api/publish.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * POST /api/v1/events (service token, audience openvibe.events, capability events.publish)
++ *
++ * body: <envelope> -> 201 { event_id, seq, duplicate: false } | 200 on a repeat
++ * body: { events: [...] } -> 201 { results: [{ event_id, seq, duplicate }] } (<= 100, atomic)
++ *
++ * Every envelope must validate against events.event-envelope@1; `source` must be the calling
++ * service (svc:live -> 'live'); `event_type` must start with a prefix that source owns. A
++ * re-published event_id is answered with the stored seq and never stored twice.
++ */
++const express = require('express');
++const { validate, http } = require('openvibe-contracts');
++const { StoreError } = require('../store');
++const { CAPS } = require('../auth');
++
++function normalize(input, ctx) {
++ const env = { ...input };
++ if (env.trace_id === undefined) env.trace_id = ctx.traceId;
++ if (env.priority === undefined) env.priority = 'important';
++ if (env.visibility === undefined) env.visibility = 'internal';
++ return env;
++}
++
++function publishRouter({ config, store, auth, worker, realtime }) {
++ const router = express.Router();
++
++ function checkOne(input, principal, ctx) {
++ if (!input || typeof input !== 'object' || Array.isArray(input)) {
++ return { status: 422, code: 'events.invalid_envelope', detail: 'an event envelope must be a JSON object' };
++ }
++ const env = normalize(input, ctx);
++ const v = validate('events.event-envelope@1', env);
++ if (!v.valid) {
++ return { status: 422, code: 'events.invalid_envelope', detail: 'envelope does not match events.event-envelope@1', errors: v.errors };
++ }
++ if (Buffer.byteLength(JSON.stringify(env.payload)) > config.maxPayloadBytes) {
++ return { status: 413, code: 'events.payload_too_large', detail: `payload is larger than ${config.maxPayloadBytes} bytes` };
++ }
++ if (env.source !== principal.service) {
++ return { status: 403, code: 'events.source_mismatch', detail: `source "${env.source}" is not the calling service "${principal.service}"` };
++ }
++ const prefixes = config.sourcePrefixes[env.source];
++ if (!prefixes) return { status: 403, code: 'events.unknown_source', detail: `source "${env.source}" may not publish events` };
++ if (!prefixes.some(p => env.event_type.startsWith(p))) {
++ return { status: 403, code: 'events.type_not_allowed', detail: `${env.source} may publish ${prefixes.join(', ')}* only, not ${env.event_type}` };
++ }
++ return { env };
++ }
++
++ router.post('/api/v1/events', auth.requireCap(CAPS.publish, { requireService: true }), (req, res) => {
++ const ctx = req.ov;
++ const body = req.body;
++ const isBatch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;
++ const items = isBatch ? body.events : [body];
++ if (isBatch && !items.length) return http.sendProblem(res, 400, 'events.bad_request', { detail: 'events must not be empty', ctx });
++ if (items.length > config.maxBatch) {
++ return http.sendProblem(res, 413, 'events.batch_too_large', { detail: `at most ${config.maxBatch} events per request`, ctx });
++ }
++
++ const envelopes = [];
++ const seen = new Set();
++ for (let i = 0; i < items.length; i++) {
++ const r = checkOne(items[i], req.principal, ctx);
++ if (r.env && seen.has(r.env.event_id)) {
++ return http.sendProblem(res, 422, 'events.invalid_envelope', { detail: `events[${i}]: event_id repeated within the batch`, ctx, extra: { index: i } });
++ }
++ if (!r.env) {
++ return http.sendProblem(res, r.status, r.code, {
++ detail: isBatch ? `events[${i}]: ${r.detail}` : r.detail, errors: r.errors, ctx, extra: isBatch ? { index: i } : undefined,
++ });
++ }
++ seen.add(r.env.event_id);
++ envelopes.push(r.env);
++ }
++
++ let out;
++ try {
++ out = store.insertBatch(envelopes, { publisher: req.principal.sub, requestId: ctx.requestId });
++ } catch (err) {
++ if (err instanceof StoreError) return http.sendProblem(res, err.status, err.code, { detail: err.message, ctx, extra: err.extra });
++ throw err;
++ }
++ if (out.inserted.length) {
++ realtime.publish(out.inserted);
++ worker.kick();
++ }
++ const status = out.inserted.length ? 201 : 200;
++ return res.status(status).json(isBatch ? { results: out.results } : out.results[0]);
++ });
++
++ return router;
++}
++
++module.exports = { publishRouter };
ddiff --git a/server/api/read.js b/server/api/read.js
nnew file mode 100644
iindex 0000000..59723df
---- /dev/null
++++ b/server/api/read.js
@@@ -0,0 +1,128 @@
++'use strict';
++/**
++ * Pull consumers (service token with events.read):
++ *
++ * GET /api/v1/events?topic=media.vod.*[,…]&after_seq=0&limit=100
++ * -> { events: [{ seq, event }], next_after_seq, latest_seq, gap? }
++ * `gap` ({ from_seq, to_seq }) means events after after_seq were already pruned by retention.
++ * Keep next_after_seq as the cursor; it moves past events that did not match.
++ * GET /api/v1/events/:event_id -> { seq, event }
++ * GET /api/v1/checkpoints?topic=… / PUT /api/v1/checkpoints { topic, cursor }
++ * a consumer's own stored cursor per topic pattern (consumer = calling principal)
++ *
++ * Operators (events.admin):
++ *
++ * GET /api/v1/deliveries?status=dead&subscription_id=&after_seq=&limit=
++ * POST /api/v1/deliveries/replay { subscription_id, from_seq | event_ids: [...] }
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const topics = require('../topics');
++const { rowToEnvelope } = require('../store');
++const { CAPS } = require('../auth');
++
++const intParam = (v, d, min, max) => {
++ if (v === undefined || v === '') return d;
++ const n = Number(v);
++ return Number.isInteger(n) && n >= min && n <= max ? n : NaN;
++};
++
++function readRouter({ store, auth, worker }) {
++ const router = express.Router();
++ const canRead = auth.requireCap(CAPS.read);
++ const isAdmin = auth.requireCap(CAPS.admin);
++
++ router.get('/api/v1/events', canRead, (req, res) => {
++ const ctx = req.ov;
++ const patterns = String(req.query.topic || '*').split(',').map(s => s.trim()).filter(Boolean);
++ if (!patterns.length || patterns.length > 20 || !patterns.every(topics.isValidPattern)) {
++ return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic must be 1..20 comma-separated patterns', ctx });
++ }
++ const after = intParam(req.query.after_seq, 0, 0, Number.MAX_SAFE_INTEGER);
++ const limit = intParam(req.query.limit, 100, 1, 1000);
++ if (Number.isNaN(after) || Number.isNaN(limit)) {
++ return http.sendProblem(res, 400, 'events.bad_request', { detail: 'after_seq must be >= 0 and limit 1..1000', ctx });
++ }
++ const oldest = store.oldestSeq();
++ const out = {};
++ let from = after;
++ if (after < oldest - 1) {
++ out.gap = { from_seq: after + 1, to_seq: oldest - 1 };
++ from = oldest - 1;
++ }
++ const { rows, cursor } = store.scan(from, { patterns, limit });
++ out.events = rows.map(r => ({ seq: r.seq, event: rowToEnvelope(r) }));
++ out.next_after_seq = cursor;
++ out.latest_seq = store.lastSeq();
++ res.json(out);
++ });
++
++ router.get('/api/v1/events/:id', canRead, (req, res) => {
++ const row = store.getEvent(String(req.params.id));
++ if (!row) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such event (or pruned by retention)', ctx: req.ov });
++ return res.json({ seq: row.seq, event: rowToEnvelope(row) });
++ });
++
++ const consumerOf = (req) => req.principal.service || req.principal.sub;
++
++ router.get('/api/v1/checkpoints', canRead, (req, res) => {
++ const topic = String(req.query.topic || '');
++ if (!topics.isValidPattern(topic)) return http.sendProblem(res, 400, 'events.bad_topic', { detail: 'topic is required', ctx: req.ov });
++ const cp = store.getCheckpoint(consumerOf(req), topic);
++ res.json({ consumer: consumerOf(req), topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });
++ });
++
++ router.put('/api/v1/checkpoints', canRead, (req, res) => {
++ const b = req.body || {};
++ if (!topics.isValidPattern(b.topic) || !Number.isInteger(b.cursor) || b.cursor < 0) {
++ return http.sendProblem(res, 400, 'events.bad_request', { detail: 'topic (pattern) and cursor (integer >= 0) are required', ctx: req.ov });
++ }
++ const cp = store.setCheckpoint(consumerOf(req), b.topic, b.cursor);
++ res.json({ consumer: consumerOf(req), topic: b.topic, ...cp });
++ });
++
++ router.get('/api/v1/deliveries', isAdmin, (req, res) => {
++ const status = req.query.status ? String(req.query.status) : undefined;
++ if (status && !['pending', 'delivered', 'failed', 'dead'].includes(status)) {
++ return http.sendProblem(res, 400, 'events.bad_request', { detail: 'status is pending|delivered|failed|dead', ctx: req.ov });
++ }
++ const limit = intParam(req.query.limit, 100, 1, 1000);
++ const afterSeq = intParam(req.query.after_seq, 0, 0, Number.MAX_SAFE_INTEGER);
++ if (Number.isNaN(limit) || Number.isNaN(afterSeq)) return http.sendProblem(res, 400, 'events.bad_request', { detail: 'bad limit or after_seq', ctx: req.ov });
++ const rows = store.listDeliveries({ status, subscriptionId: req.query.subscription_id ? String(req.query.subscription_id) : undefined, limit, afterSeq });
++ return res.json({
++ deliveries: rows.map(d => ({
++ event_id: d.event_id,
++ subscription_id: d.subscription_id,
++ seq: d.seq,
++ status: d.status,
++ attempt: d.attempt,
++ next_attempt_at: d.next_attempt_at ? new Date(d.next_attempt_at).toISOString() : null,
++ last_status: d.last_status,
++ last_error: d.last_error,
++ delivered_at: d.delivered_at ? new Date(d.delivered_at).toISOString() : null,
++ })),
++ counts: store.deliveryCounts(),
++ });
++ });
++
++ router.post('/api/v1/deliveries/replay', isAdmin, (req, res) => {
++ const ctx = req.ov;
++ const b = req.body || {};
++ const sub = typeof b.subscription_id === 'string' ? store.getSubscription(b.subscription_id) : null;
++ if (!sub) return http.sendProblem(res, 404, 'events.not_found', { detail: 'no such subscription', ctx });
++ const hasIds = Array.isArray(b.event_ids);
++ const hasFrom = Number.isInteger(b.from_seq) && b.from_seq >= 0;
++ if (hasIds === hasFrom) return http.sendProblem(res, 400, 'events.bad_request', { detail: 'pass exactly one of from_seq or event_ids', ctx });
++ if (hasIds && (b.event_ids.length > 1000 || !b.event_ids.every(id => typeof id === 'string'))) {
++ return http.sendProblem(res, 400, 'events.bad_request', { detail: 'event_ids: at most 1000 ids', ctx });
++ }
++ const queued = store.requeue(sub, hasIds ? { eventIds: b.event_ids } : { fromSeq: b.from_seq });
++ worker.kick();
++ return res.json({ subscription_id: sub.id, queued });
++ });
++
++ return router;
++}
++
++module.exports = { readRouter };
ddiff --git a/server/api/subscriptions.js b/server/api/subscriptions.js
nnew file mode 100644
iindex 0000000..8afaa18
---- /dev/null
++++ b/server/api/subscriptions.js
@@@ -0,0 +1,107 @@
++'use strict';
++/**
++ * Subscriptions (service token with events.subscribe; the consumer is always the calling service).
++ *
++ * POST /api/v1/subscriptions { topic_pattern, endpoint, secret?, retry_policy? }
++ * -> 201 subscription + secret (shown once)
++ * GET /api/v1/subscriptions -> { subscriptions: [...] } (own only, no secrets)
++ * GET /api/v1/subscriptions/:id
++ * POST /api/v1/subscriptions/:id/disable | /enable
++ *
++ * The endpoint must be http(s) on the allow-list (127.0.0.1, *.openvibe.*): server/endpoints.js.
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { ids, http } = require('openvibe-contracts');
++const topics = require('../topics');
++const { checkEndpoint } = require('../endpoints');
++const { subscriptionView } = require('../store');
++const { CAPS } = require('../auth');
++
++function checkRetryPolicy(p) {
++ if (p == null) return { ok: true, value: null };
++ if (typeof p !== 'object' || Array.isArray(p)) return { ok: false, reason: 'retry_policy must be an object' };
++ const out = {};
++ for (const k of Object.keys(p)) if (!['max_attempts', 'backoff_ms'].includes(k)) return { ok: false, reason: `unknown retry_policy field ${k}` };
++ if (p.max_attempts !== undefined) {
++ if (!Number.isInteger(p.max_attempts) || p.max_attempts < 1 || p.max_attempts > 20) return { ok: false, reason: 'max_attempts must be 1..20' };
++ out.max_attempts = p.max_attempts;
++ }
++ if (p.backoff_ms !== undefined) {
++ if (!Array.isArray(p.backoff_ms) || !p.backoff_ms.length || p.backoff_ms.length > 20
++ || !p.backoff_ms.every(n => Number.isInteger(n) && n >= 0 && n <= 86400000)) {
++ return { ok: false, reason: 'backoff_ms must be 1..20 integers between 0 and 86400000' };
++ }
++ out.backoff_ms = p.backoff_ms;
++ }
++ return { ok: true, value: out };
++}
++
++function subscriptionsRouter({ config, store, auth }) {
++ const router = express.Router();
++ const guard = auth.requireCap(CAPS.subscribe, { requireService: true });
++
++ function own(req, res) {
++ const sub = store.getSubscription(String(req.params.id));
++ if (!sub || sub.consumer !== req.principal.service) {
++ http.sendProblem(res, 404, 'events.not_found', { detail: 'no such subscription', ctx: req.ov });
++ return null;
++ }
++ return sub;
++ }
++
++ router.post('/api/v1/subscriptions', guard, (req, res) => {
++ const ctx = req.ov;
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const pattern = b.topic_pattern ?? b.topic;
++ if (!topics.isValidPattern(pattern)) {
++ return http.sendProblem(res, 422, 'events.bad_topic', { detail: 'topic_pattern must be dot-separated segments of [a-z0-9_] or *', ctx });
++ }
++ const ep = checkEndpoint(b.endpoint, config.endpointHosts);
++ if (!ep.ok) return http.sendProblem(res, 422, 'events.endpoint_not_allowed', { detail: ep.reason, ctx });
++ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) {
++ return http.sendProblem(res, 422, 'events.bad_request', { detail: 'secret must be a string of 32..256 characters', ctx });
++ }
++ const rp = checkRetryPolicy(b.retry_policy);
++ if (!rp.ok) return http.sendProblem(res, 422, 'events.bad_request', { detail: rp.reason, ctx });
++
++ const consumer = req.principal.service;
++ const endpoint = ep.url.toString();
++ const dup = store.listSubscriptions(consumer).find(s => s.topic_pattern === pattern && s.endpoint === endpoint);
++ if (dup) {
++ return http.sendProblem(res, 409, 'events.subscription_exists', { detail: 'this consumer already subscribes that endpoint to that topic', ctx, extra: { subscription_id: dup.id } });
++ }
++ if (store.countSubscriptions(consumer) >= config.maxSubscriptionsPerConsumer) {
++ return http.sendProblem(res, 409, 'events.subscription_limit', { detail: `at most ${config.maxSubscriptionsPerConsumer} subscriptions per consumer`, ctx });
++ }
++ const row = store.createSubscription({
++ id: `sub_${ids.ulid()}`,
++ consumer,
++ topicPattern: pattern,
++ endpoint,
++ secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,
++ retryPolicy: rp.value && Object.keys(rp.value).length ? rp.value : null,
++ });
++ return res.status(201).json(subscriptionView(row, { withSecret: true }));
++ });
++
++ router.get('/api/v1/subscriptions', guard, (req, res) => {
++ res.json({ subscriptions: store.listSubscriptions(req.principal.service).map(s => subscriptionView(s)) });
++ });
++
++ router.get('/api/v1/subscriptions/:id', guard, (req, res) => {
++ const sub = own(req, res);
++ if (sub) res.json(subscriptionView(sub));
++ });
++
++ for (const [action, enabled] of [['disable', false], ['enable', true]]) {
++ router.post(`/api/v1/subscriptions/:id/${action}`, guard, (req, res) => {
++ const sub = own(req, res);
++ if (sub) res.json(subscriptionView(store.setSubscriptionEnabled(sub.id, enabled)));
++ });
++ }
++
++ return router;
++}
++
++module.exports = { subscriptionsRouter, checkRetryPolicy };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..8971e55
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,79 @@
++'use strict';
++/** Express app: request context, the v1 API, the realtime gateway, health/readiness. */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { publishRouter } = require('./api/publish');
++const { subscriptionsRouter } = require('./api/subscriptions');
++const { readRouter } = require('./api/read');
++const pkg = require('../package.json');
++
++function createApp({ config, store, auth, keys, worker, realtime, log = console }) {
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', 'loopback');
++ app.use(http.middleware());
++ app.use((req, res, next) => {
++ res.setHeader('X-Content-Type-Options', 'nosniff');
++ next();
++ });
++
++ // Realtime first: it answers CORS preflights and must not go through the JSON body parser.
++ app.options('/realtime/stream', realtime.cors);
++ app.get('/realtime/stream', realtime.cors, realtime.handler);
++
++ app.use('/api', express.json({ limit: '2mb', type: ['application/json', 'application/*+json'] }));
++
++ app.get('/api/health', (_req, res) => {
++ res.json({ status: 'ok', service: 'openvibe-events', version: pkg.version });
++ });
++
++ app.get('/api/ready', (_req, res) => {
++ let dbOk = false;
++ try { dbOk = store.ping(); } catch { dbOk = false; }
++ const checks = { db: dbOk, worker: !config.worker.enabled || worker.running(), key: keys.loaded() };
++ const ready = Object.values(checks).every(Boolean);
++ res.status(ready ? 200 : 503).json({
++ status: ready ? 'ready' : 'not_ready',
++ checks,
++ latest_seq: dbOk ? store.lastSeq() : null,
++ deliveries: dbOk ? store.deliveryCounts() : null,
++ worker: worker.stats(),
++ realtime_connections: realtime.count(),
++ });
++ });
++
++ app.use(publishRouter({ config, store, auth, worker, realtime }));
++ app.use(subscriptionsRouter({ config, store, auth }));
++ app.use(readRouter({ store, auth, worker }));
++
++ app.get('/', (_req, res) => {
++ res.type('text/plain').send([
++ 'OpenVibe.Events: durable events, subscriptions, signed delivery, dead letters and replay.',
++ '',
++ 'POST /api/v1/events publish (service token, events.publish)',
++ 'GET /api/v1/events pull with a cursor (events.read)',
++ ' /api/v1/subscriptions webhook subscriptions (events.subscribe)',
++ ' /api/v1/deliveries DLQ inspect and replay (events.admin)',
++ 'GET /realtime/stream?topics=... server-sent events for browsers',
++ 'GET /api/health, /api/ready',
++ '',
++ 'Source: https://github.com/OpenVibers/OpenVibe.Events',
++ '',
++ ].join('\n'));
++ });
++
++ app.use((req, res) => http.sendProblem(res, 404, 'events.not_found', { detail: `no route ${req.method} ${req.path}`, ctx: req.ov }));
++
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, _next) => {
++ if (err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'events.bad_json', { detail: 'request body is not valid JSON', ctx: req.ov });
++ if (err.type === 'entity.too.large') return http.sendProblem(res, 413, 'events.batch_too_large', { detail: 'request body too large', ctx: req.ov });
++ log.error(`[app] ${req.method} ${req.path}: ${err.stack || err}`);
++ if (res.headersSent) return res.end();
++ return http.sendProblem(res, 500, 'events.internal', { detail: 'internal error', ctx: req.ov });
++ });
++
++ return app;
++}
++
++module.exports = { createApp };
ddiff --git a/server/auth.js b/server/auth.js
nnew file mode 100644
iindex 0000000..4c56e29
---- /dev/null
++++ b/server/auth.js
@@@ -0,0 +1,216 @@
++'use strict';
++/**
++ * Authentication and capability checks.
++ *
++ * - Service principals: RS256 client-credentials tokens from OpenVibe.Network (audience
++ * openvibe.events), verified with openvibe-contracts serviceAuth.verifyServiceToken.
++ * - Browsers: Network user JWTs (RS256, same key), cookie `ov_token` or Bearer.
++ *
++ * Capability ids events.publish / events.subscribe / events.read / events.admin are proposed in
++ * docs/capabilities-proposal/ and are not in openvibe-contracts yet. capabilities.check() answers
++ * capability.unknown for an id it does not know; until the release that defines them, hasCap()
++ * decides with the same grant rule (exact id, or a `family.*` grant).
++ */
++const crypto = require('crypto');
++const { serviceAuth, capabilities, http } = require('openvibe-contracts');
++
++/** The capability ids this service enforces (manifests: docs/capabilities-proposal/). */
++const CAPS = Object.freeze({
++ publish: 'events.publish',
++ subscribe: 'events.subscribe',
++ read: 'events.read',
++ admin: 'events.admin',
++});
++
++// ── Network public key ─────────────────────────────────────
++
++/**
++ * Loads the Network signing key from GET /api/.well-known/jwks ({ keys: [jwk] } or the older
++ * { public_key: PEM }), retrying every 30 s until it loads and refreshing every 6 h after that.
++ * A PEM given in config (OV_NETWORK_PUBLIC_KEY) is used as is and never fetched.
++ */
++function createKeyStore({ urls = [], pem = null, fetchImpl = globalThis.fetch, log = console } = {}) {
++ let key = pem ? toPem(pem) : null;
++ let retryTimer = null;
++ let refreshTimer = null;
++
++ function toPem(value) {
++ return crypto.createPublicKey(value).export({ type: 'spki', format: 'pem' });
++ }
++
++ async function fetchOnce() {
++ for (const base of urls) {
++ if (!base) continue;
++ const url = `${base}/api/.well-known/jwks`;
++ try {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`HTTP ${res.status}`);
++ const body = await res.json();
++ const jwk = (body.keys || []).find(k => k.kty === 'RSA');
++ if (jwk) key = toPem({ key: jwk, format: 'jwk' });
++ else if (typeof body.public_key === 'string' && body.public_key.includes('BEGIN')) key = toPem(body.public_key);
++ else throw new Error('no RSA key in response');
++ log.log(`[auth] Network public key loaded from ${base}`);
++ return key;
++ } catch (err) {
++ log.warn(`[auth] key fetch from ${url} failed: ${err.message}`);
++ }
++ }
++ return null;
++ }
++
++ function start() {
++ if (pem) return Promise.resolve(key);
++ const attempt = async () => {
++ const k = await fetchOnce();
++ if (!k && !key) {
++ retryTimer = setTimeout(attempt, 30 * 1000);
++ retryTimer.unref?.();
++ }
++ return k;
++ };
++ refreshTimer = setInterval(() => { fetchOnce().catch(() => {}); }, 6 * 60 * 60 * 1000);
++ refreshTimer.unref?.();
++ return attempt();
++ }
++
++ function stop() {
++ clearTimeout(retryTimer);
++ clearInterval(refreshTimer);
++ }
++
++ return { get: () => key, loaded: () => Boolean(key), start, stop, fetchOnce };
++}
++
++// ── Capabilities ───────────────────────────────────────────
++
++/** Exact id or `family.*` grant (the rule openvibe-contracts applies to its known capabilities). */
++function hasCap(claims, id) {
++ const granted = claims && Array.isArray(claims.cap) ? claims.cap : [];
++ return granted.some(g => g === id || (g.endsWith('.*') && id.startsWith(g.slice(0, -1))));
++}
++
++/** Contracts decide once they know the id; until then the local rule does. */
++function allows(claims, id) {
++ if (!hasCap(claims, id)) return { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
++ const c = capabilities.check(claims, id);
++ if (c.code === 'capability.unknown' && !capabilities.get(id)) return { allowed: true, code: null, reason: null };
++ return c;
++}
++
++// ── Tokens ─────────────────────────────────────────────────
++
++const b64json = (s) => JSON.parse(Buffer.from(String(s), 'base64url').toString('utf8'));
++
++/** Network user JWT (RS256). Returns claims or null. Service tokens are never accepted as users. */
++function verifyUserJwt(token, { publicKey, issuer, audiences, now = Date.now() }) {
++ if (!publicKey || typeof token !== 'string') return null;
++ const parts = token.split('.');
++ if (parts.length !== 3) return null;
++ try {
++ const header = b64json(parts[0]);
++ if (header.alg !== 'RS256') return null;
++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'));
++ if (!ok) return null;
++ const claims = b64json(parts[1]);
++ const t = Math.floor(now / 1000);
++ if (typeof claims.exp !== 'number' || claims.exp + 30 < t) return null;
++ if (issuer && claims.iss !== issuer) return null;
++ const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
++ if (!aud.some(a => audiences.includes(a))) return null;
++ if (claims.actor_type || /^(svc|app|mod):/.test(String(claims.sub))) return null;
++ return claims;
++ } catch {
++ return null;
++ }
++}
++
++function bearer(req) {
++ const h = String(req.headers.authorization || '');
++ return h.startsWith('Bearer ') ? h.slice(7).trim() : null;
++}
++
++function cookie(req, name) {
++ const raw = String(req.headers.cookie || '');
++ for (const part of raw.split(';')) {
++ const i = part.indexOf('=');
++ if (i > 0 && part.slice(0, i).trim() === name) {
++ try { return decodeURIComponent(part.slice(i + 1).trim()); } catch { return null; }
++ }
++ }
++ return null;
++}
++
++/** Service slug for a principal sub: svc:live -> 'live'; app:/mod: principals have none. */
++function serviceSlug(sub) {
++ const m = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String(sub || ''));
++ return m ? m[1] : null;
++}
++
++function createAuth({ config, keys }) {
++ // Token lifetimes are judged on wall-clock time, never on the worker's (injectable) clock.
++ function verifyService(token) {
++ const publicKey = keys.get();
++ if (!publicKey) return { ok: false, code: 'token.unavailable', reason: 'signing key not loaded yet' };
++ return serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.issuer, audience: config.audience });
++ }
++
++ function verifyUser(token) {
++ return verifyUserJwt(token, { publicKey: keys.get(), issuer: config.issuer, audiences: config.userAudiences });
++ }
++
++ /**
++ * Express guard for service routes. Sets req.principal = { sub, service, cap, ns, jti }.
++ * `requireService` refuses app/mod principals (publish and subscribe act as a service).
++ */
++ function requireCap(id, { requireService = false } = {}) {
++ return function capGuard(req, res, next) {
++ const ctx = req.ov;
++ const token = bearer(req);
++ if (!token) return http.sendProblem(res, 401, 'token.missing', { detail: 'a service token is required', ctx });
++ const r = verifyService(token);
++ if (!r.ok) return http.sendProblem(res, r.code === 'token.unavailable' ? 503 : 401, r.code, { detail: r.reason, ctx });
++ const c = allows(r.claims, id);
++ if (!c.allowed) return http.sendProblem(res, 403, c.code, { detail: c.reason, ctx });
++ const service = serviceSlug(r.claims.sub);
++ if (requireService && !service) return http.sendProblem(res, 403, 'capability.denied', { detail: 'only service principals may do this', ctx });
++ req.principal = { sub: r.claims.sub, service, cap: r.claims.cap, ns: r.claims.ns || [], jti: r.claims.jti };
++ return next();
++ };
++ }
++
++ /**
++ * Who is on the other end of a realtime connection:
++ * { kind: 'service', sub } | { kind: 'user', subjectId, sub } | { kind: 'anonymous' }
++ * or { error: { status, code, detail } }. A Bearer token must verify; an unverifiable cookie
++ * (expired session) degrades to anonymous so the browser still gets public events.
++ */
++ function realtimeViewer(req) {
++ const token = bearer(req);
++ if (token) {
++ const svc = verifyService(token);
++ if (svc.ok) {
++ const c = allows(svc.claims, CAPS.read);
++ if (!c.allowed) return { error: { status: 403, code: c.code, detail: c.reason } };
++ return { kind: 'service', sub: svc.claims.sub };
++ }
++ if (svc.code === 'token.unavailable') return { error: { status: 503, code: svc.code, detail: svc.reason } };
++ const user = verifyUser(token);
++ if (user) return userViewer(user);
++ return { error: { status: 401, code: 'token.invalid', detail: 'token does not verify' } };
++ }
++ const fromCookie = cookie(req, 'ov_token');
++ const user = fromCookie ? verifyUser(fromCookie) : null;
++ if (user) return userViewer(user);
++ return { kind: 'anonymous' };
++ }
++
++ function userViewer(claims) {
++ const subjectId = typeof claims.subject_id === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(claims.subject_id) ? claims.subject_id : null;
++ return { kind: 'user', subjectId, sub: String(claims.sub) };
++ }
++
++ return { verifyService, verifyUser, requireCap, realtimeViewer };
++}
++
++module.exports = { CAPS, createKeyStore, createAuth, hasCap, allows, verifyUserJwt, serviceSlug, bearer, cookie };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..4073118
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,99 @@
++'use strict';
++/**
++ * OpenVibe.Events configuration. Everything comes from the environment (.env in development,
++ * /etc/openvibe/events.env in production); see .env.example for the documented list.
++ *
++ * load(env) is pure so tests can build a config without touching process.env.
++ */
++
++const int = (v, d) => {
++ const n = parseInt(v, 10);
++ return Number.isFinite(n) ? n : d;
++};
++const list = (v, d) => (v == null || v === '' ? d : String(v).split(',').map(s => s.trim()).filter(Boolean));
++
++/**
++ * Which sources may publish which event types. The first segment of every prefix must equal the
++ * source, so a service can only ever publish into its own namespace (svc:live -> live.*).
++ */
++const DEFAULT_SOURCES = ['live', 'media', 'network', 'community', 'chat', 'openre', 'billing', 'tips',
++ 'vip', 'ai', 'games', 'tools', 'codes', 'host'];
++
++function sourcePrefixes(env) {
++ let map = Object.fromEntries(DEFAULT_SOURCES.map(s => [s, [`${s}.`]]));
++ if (env.EVENTS_SOURCE_PREFIXES) {
++ // JSON { "<source>": ["<source>.x.", ...] } replaces the default map.
++ map = JSON.parse(env.EVENTS_SOURCE_PREFIXES);
++ }
++ for (const [source, prefixes] of Object.entries(map)) {
++ if (!/^[a-z][a-z0-9-]{1,39}$/.test(source)) throw new Error(`EVENTS_SOURCE_PREFIXES: bad source "${source}"`);
++ if (!Array.isArray(prefixes) || !prefixes.length) throw new Error(`EVENTS_SOURCE_PREFIXES: ${source} needs at least one prefix`);
++ for (const p of prefixes) {
++ if (typeof p !== 'string' || p.split('.')[0] !== source) {
++ throw new Error(`EVENTS_SOURCE_PREFIXES: prefix "${p}" of ${source} must start with "${source}."`);
++ }
++ }
++ }
++ return map;
++}
++
++function load(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4300);
++ return {
++ port,
++ host: env.HOST || '127.0.0.1',
++ nodeEnv,
++ isProduction,
++ baseUrl: (env.BASE_URL || (isProduction ? 'https://events.openvibe.network' : `http://localhost:${port}`)).replace(/\/$/, ''),
++
++ // Identity: OpenVibe.Network issues service tokens and user JWTs (RS256).
++ networkUrl: (env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
++ networkInternalUrl: (env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
++ issuer: (env.OV_NETWORK_ISSUER || env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/$/, ''),
++ // PEM of the Network public key; normally fetched from the JWKS endpoint instead.
++ networkPublicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
++ audience: 'openvibe.events',
++ // A user JWT is accepted when its aud contains one of these (Network does not mint
++ // openvibe.events user tokens yet; the network-wide audience is the stable one).
++ userAudiences: list(env.EVENTS_USER_AUDIENCES, ['openvibe.events', 'openvibe.network']),
++
++ dbPath: env.EVENTS_DB_PATH || './data/events.db',
++ retentionDays: int(env.EVENTS_RETENTION_DAYS, 30),
++ receiptRetentionDays: int(env.EVENTS_RECEIPT_RETENTION_DAYS, 90),
++ pruneIntervalMs: int(env.EVENTS_PRUNE_INTERVAL_MS, 60 * 60 * 1000),
++
++ sourcePrefixes: sourcePrefixes(env),
++ maxBatch: 100,
++ maxPayloadBytes: int(env.EVENTS_MAX_PAYLOAD_BYTES, 64 * 1024),
++ maxHops: int(env.EVENTS_MAX_HOPS, 8),
++
++ // Delivery worker
++ worker: {
++ enabled: env.EVENTS_WORKER !== 'off',
++ intervalMs: int(env.EVENTS_WORKER_INTERVAL_MS, 500),
++ maxInflight: int(env.EVENTS_MAX_INFLIGHT, 20),
++ timeoutMs: int(env.EVENTS_DELIVERY_TIMEOUT_MS, 10000),
++ maxAttempts: int(env.EVENTS_MAX_ATTEMPTS, 8),
++ // Wait after attempt n fails = backoffMs[n-1] (the last value repeats).
++ backoffMs: list(env.EVENTS_BACKOFF_MS, ['1000', '5000', '30000', '120000', '600000', '3600000']).map(Number),
++ },
++ maxSubscriptionsPerConsumer: int(env.EVENTS_MAX_SUBSCRIPTIONS, 100),
++ // Hostname patterns a subscription endpoint may point at (SSRF guard).
++ endpointHosts: list(env.EVENTS_ENDPOINT_HOSTS, ['127.0.0.1', '*.openvibe.*']),
++
++ // Realtime (SSE) gateway
++ realtime: {
++ maxConnections: int(env.REALTIME_MAX_CONNECTIONS, 2000),
++ maxTopics: int(env.REALTIME_MAX_TOPICS, 20),
++ heartbeatMs: int(env.REALTIME_HEARTBEAT_MS, 25000),
++ replayMax: int(env.REALTIME_REPLAY_MAX, 1000),
++ allowAnonymous: env.REALTIME_ALLOW_ANONYMOUS !== 'false',
++ // Extra exact origins allowed besides https://*.openvibe.* (dev: http://localhost:3000).
++ extraOrigins: list(env.REALTIME_CORS_ORIGINS, []),
++ },
++ };
++}
++
++module.exports = { load, DEFAULT_SOURCES };
ddiff --git a/server/endpoints.js b/server/endpoints.js
nnew file mode 100644
iindex 0000000..8596e36
---- /dev/null
++++ b/server/endpoints.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * Subscription endpoint allow-list (SSRF guard). An endpoint is an http(s) URL without credentials
++ * whose hostname matches one of the configured patterns:
++ *
++ * 127.0.0.1 exactly that host (services on the same machine)
++ * *.openvibe.* openvibe.<tld> and any subdomain of it (live.openvibe.network, openvibe.live)
++ * exact.host that hostname only
++ *
++ * IP literals other than an exact allowed entry never pass, whatever the pattern.
++ */
++const net = require('net');
++
++const LABEL = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
++
++function hostPatternToRegex(pattern) {
++ const p = String(pattern).toLowerCase();
++ if (p === '*.openvibe.*') return new RegExp(`^(?:${LABEL}\\.)*openvibe\\.[a-z]{2,63}$`);
++ if (p.startsWith('*.')) {
++ const rest = p.slice(2).replace(/[.]/g, '\\.');
++ return new RegExp(`^(?:${LABEL}\\.)*${rest}$`);
++ }
++ return new RegExp(`^${p.replace(/[.]/g, '\\.')}$`);
++}
++
++function checkEndpoint(endpoint, allowed) {
++ let url;
++ try { url = new URL(String(endpoint)); } catch { return { ok: false, reason: 'not a URL' }; }
++ if (url.protocol !== 'http:' && url.protocol !== 'https:') return { ok: false, reason: 'only http and https endpoints' };
++ if (url.username || url.password) return { ok: false, reason: 'credentials in the URL are not allowed' };
++ if (String(endpoint).length > 2048) return { ok: false, reason: 'URL too long' };
++ let host = url.hostname.toLowerCase();
++ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
++ if (host.endsWith('.')) return { ok: false, reason: 'trailing-dot hostnames are not allowed' };
++ const isIp = net.isIP(host) !== 0;
++ for (const pattern of allowed) {
++ const p = String(pattern).toLowerCase();
++ if (isIp) {
++ if (p === host) return { ok: true, url };
++ continue;
++ }
++ if (net.isIP(p)) continue;
++ if (hostPatternToRegex(p).test(host)) return { ok: true, url };
++ }
++ return { ok: false, reason: `host ${host} is not on the endpoint allow-list` };
++}
++
++module.exports = { checkEndpoint, hostPatternToRegex };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..bf233b2
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,87 @@
++'use strict';
++/**
++ * OpenVibe.Events entry point.
++ *
++ * node server/index.js (systemd: openvibe-events.service)
++ *
++ * start() is also what the tests use: it takes a config (server/config.js load()) plus injectable
++ * clock/fetch/log, and returns handles to every part so they can be driven directly.
++ */
++const { load } = require('./config');
++const { openDb, createStore } = require('./store');
++const { createKeyStore, createAuth } = require('./auth');
++const { createWorker } = require('./worker');
++const { createRealtime } = require('./realtime');
++const { createApp } = require('./app');
++
++async function start({ config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, deliveryFetch = fetchImpl, log = console, listen = true } = {}) {
++ config = config || load();
++ const db = openDb(config.dbPath);
++ const store = createStore(db, { clock, maxHops: config.maxHops });
++ const keys = createKeyStore({ urls: [config.networkInternalUrl, config.networkUrl], pem: config.networkPublicKey, fetchImpl, log });
++ const auth = createAuth({ config, keys });
++ const worker = createWorker({ store, config, clock, fetchImpl: deliveryFetch, log });
++ const realtime = createRealtime({ store, auth, config, log });
++ const app = createApp({ config, store, auth, keys, worker, realtime, log });
++
++ // The key loads in the background (retrying while Network boots); /api/ready says when it has.
++ const keyLoaded = keys.start().catch(() => null);
++ if (config.worker.enabled) worker.start();
++ realtime.start();
++
++ const prune = () => {
++ try {
++ const r = store.prune({ retentionDays: config.retentionDays, receiptRetentionDays: config.receiptRetentionDays });
++ if (r.events || r.receipts) log.log(`[retention] pruned ${r.events} events, ${r.receipts} receipts`);
++ } catch (err) {
++ log.error(`[retention] prune failed: ${err.message}`);
++ }
++ };
++ const pruneTimer = setInterval(prune, config.pruneIntervalMs);
++ pruneTimer.unref?.();
++ if (config.worker.enabled) prune();
++
++ let server = null;
++ if (listen) {
++ server = await new Promise((resolve, reject) => {
++ const s = app.listen(config.port, config.host, () => resolve(s));
++ s.on('error', reject);
++ });
++ // SSE connections stay open; Node's default keep-alive/header timers only affect idle sockets.
++ server.keepAliveTimeout = 65000;
++ server.headersTimeout = 66000;
++ log.log(`[events] listening on http://${config.host}:${server.address().port}`);
++ }
++
++ async function close() {
++ clearInterval(pruneTimer);
++ realtime.stop();
++ keys.stop();
++ await worker.stop();
++ if (server) {
++ server.closeAllConnections?.();
++ await new Promise(resolve => server.close(() => resolve()));
++ }
++ db.close();
++ }
++
++ return { config, db, store, keys, keyLoaded, auth, worker, realtime, app, server, close, prune };
++}
++
++if (require.main === module) {
++ require('dotenv').config();
++ start().then((handles) => {
++ const shutdown = (sig) => {
++ console.log(`[events] ${sig}: shutting down`);
++ handles.close().then(() => process.exit(0), () => process.exit(1));
++ setTimeout(() => process.exit(1), 10000).unref();
++ };
++ process.on('SIGTERM', () => shutdown('SIGTERM'));
++ process.on('SIGINT', () => shutdown('SIGINT'));
++ }).catch((err) => {
++ console.error(`[events] failed to start: ${err.stack || err}`);
++ process.exit(1);
++ });
++}
++
++module.exports = { start };
ddiff --git a/server/realtime.js b/server/realtime.js
nnew file mode 100644
iindex 0000000..e333cc9
---- /dev/null
++++ b/server/realtime.js
@@@ -0,0 +1,187 @@
++'use strict';
++/**
++ * Realtime gateway (SSE), inside this service until ADR-005 decides where Realtime lives.
++ *
++ * GET /realtime/stream?topics=live.stream.*,network.notification.*
++ *
++ * Every message is `id: <seq>` + `data: {"seq":n,"event":<envelope>}`. Visibility decides who sees
++ * an event:
++ * public anyone subscribed to a matching topic (signed-out visitors too, unless disabled)
++ * subject only the user whose subject id is the event's actor.id or its subject.id (subject
++ * type user); a guessed topic yields nothing for anyone else
++ * internal service principals (token with events.read) only, never a browser
++ *
++ * Resume: `Last-Event-ID` (or ?last_event_id=) is a seq. Missed events are replayed first; when the
++ * cursor is older than retention an `event: gap` message comes first, so the client knows to
++ * refetch state. Heartbeat comment every 25 s.
++ */
++const { http } = require('openvibe-contracts');
++const topics = require('./topics');
++const { rowToEnvelope } = require('./store');
++
++const ORIGIN_RE = /^https:\/\/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*openvibe\.[a-z]{2,63}$/;
++const MAX_BUFFERED = 1024 * 1024; // a client this far behind is dropped; it resumes with Last-Event-ID
++
++function createRealtime({ store, auth, config, log = console }) {
++ const opts = config.realtime;
++ const conns = new Set();
++ let heartbeat = null;
++
++ function originAllowed(origin) {
++ return typeof origin === 'string' && (ORIGIN_RE.test(origin) || opts.extraOrigins.includes(origin));
++ }
++
++ /** CORS for browser EventSource({ withCredentials: true }) from https://*.openvibe.* only. */
++ function cors(req, res, next) {
++ const origin = req.headers.origin;
++ res.setHeader('Vary', 'Origin');
++ if (origin && originAllowed(origin)) {
++ res.setHeader('Access-Control-Allow-Origin', origin);
++ res.setHeader('Access-Control-Allow-Credentials', 'true');
++ if (req.method === 'OPTIONS') {
++ res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
++ res.setHeader('Access-Control-Allow-Headers', 'Authorization, Last-Event-ID, Cache-Control');
++ res.setHeader('Access-Control-Max-Age', '600');
++ return res.status(204).end();
++ }
++ } else if (req.method === 'OPTIONS') {
++ return res.status(204).end();
++ }
++ return next();
++ }
++
++ function visibleTo(viewer, row) {
++ if (row.visibility === 'public') return true;
++ if (viewer.kind === 'service') return true;
++ if (row.visibility !== 'subject' || viewer.kind !== 'user' || !viewer.subjectId) return false;
++ let actor = null;
++ try { actor = JSON.parse(row.actor); } catch { actor = null; }
++ if (actor && actor.type === 'user' && actor.id === viewer.subjectId) return true;
++ return row.subject_type === 'user' && row.subject_id === viewer.subjectId;
++ }
++
++ function write(conn, chunk) {
++ if (conn.closed) return;
++ conn.res.write(chunk);
++ if (conn.res.writableLength > MAX_BUFFERED) {
++ log.warn('[realtime] dropping a slow client');
++ close(conn);
++ }
++ }
++
++ function sendEvent(conn, row) {
++ conn.lastSeq = row.seq;
++ write(conn, `id: ${row.seq}\ndata: ${JSON.stringify({ seq: row.seq, event: rowToEnvelope(row) })}\n\n`);
++ }
++
++ function sendGap(conn, gap) {
++ write(conn, `event: gap\ndata: ${JSON.stringify(gap)}\n\n`);
++ }
++
++ function close(conn) {
++ if (conn.closed) return;
++ conn.closed = true;
++ conns.delete(conn);
++ try { conn.res.end(); } catch { /* already gone */ }
++ }
++
++ function parseTopics(raw) {
++ const list = String(raw || '').split(',').map(s => s.trim()).filter(Boolean);
++ return [...new Set(list)];
++ }
++
++ function handler(req, res) {
++ const ctx = req.ov;
++ const wanted = parseTopics(req.query.topics);
++ if (!wanted.length) return http.sendProblem(res, 400, 'realtime.bad_request', { detail: 'topics=<pattern>[,<pattern>] is required', ctx });
++ if (wanted.length > opts.maxTopics) {
++ return http.sendProblem(res, 400, 'realtime.too_many_topics', { detail: `at most ${opts.maxTopics} topics per connection`, ctx });
++ }
++ const bad = wanted.find(t => !topics.isValidPattern(t));
++ if (bad) return http.sendProblem(res, 400, 'realtime.bad_topic', { detail: `invalid topic pattern ${bad}`, ctx });
++
++ const viewer = auth.realtimeViewer(req);
++ if (viewer.error) return http.sendProblem(res, viewer.error.status, viewer.error.code, { detail: viewer.error.detail, ctx });
++ if (viewer.kind === 'anonymous' && !opts.allowAnonymous) {
++ return http.sendProblem(res, 401, 'token.missing', { detail: 'sign in to open a realtime stream', ctx });
++ }
++ if (conns.size >= opts.maxConnections) {
++ res.setHeader('Retry-After', '10');
++ return http.sendProblem(res, 503, 'realtime.over_capacity', { detail: 'too many realtime connections, retry shortly', ctx });
++ }
++
++ const rawLast = req.headers['last-event-id'] ?? req.query.last_event_id;
++ const lastId = rawLast != null && /^\d{1,15}$/.test(String(rawLast).trim()) ? Number(String(rawLast).trim()) : null;
++
++ res.status(200);
++ res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
++ res.setHeader('Cache-Control', 'no-cache, no-transform');
++ res.setHeader('Connection', 'keep-alive');
++ res.setHeader('X-Accel-Buffering', 'no');
++ res.flushHeaders();
++ req.socket.setTimeout(0);
++ req.socket.setNoDelay(true);
++
++ const conn = { res, viewer, patterns: wanted, lastSeq: lastId ?? store.lastSeq(), closed: false };
++ write(conn, `retry: 3000\n: connected ${viewer.kind}\n\n`);
++
++ // Catch up synchronously (better-sqlite3 is synchronous, so no event can be published in
++ // between), then join the live fan-out.
++ if (lastId != null) {
++ const latest = store.lastSeq();
++ const oldest = store.oldestSeq();
++ if (lastId > latest) {
++ sendGap(conn, { reason: 'cursor_ahead', from_seq: latest + 1, to_seq: lastId, latest_seq: latest });
++ conn.lastSeq = latest;
++ } else {
++ let cursor = lastId;
++ if (lastId < oldest - 1) {
++ sendGap(conn, { reason: 'retention', from_seq: lastId + 1, to_seq: oldest - 1, latest_seq: latest });
++ cursor = oldest - 1;
++ }
++ const { rows, cursor: scanned } = store.scan(cursor, {
++ patterns: wanted, limit: opts.replayMax, scanMax: opts.replayMax * 50, accept: row => visibleTo(viewer, row),
++ });
++ for (const row of rows) sendEvent(conn, row);
++ if (scanned < latest) {
++ // Replay stopped at its limit: everything after `scanned` is reported as a gap.
++ sendGap(conn, { reason: 'replay_limit', from_seq: scanned + 1, to_seq: latest, latest_seq: latest });
++ }
++ conn.lastSeq = latest;
++ }
++ }
++
++ conns.add(conn);
++ res.on('close', () => close(conn));
++ return undefined;
++ }
++
++ /** Called after a publish commits, with the newly stored rows in seq order. */
++ function publish(rows) {
++ if (!conns.size) return;
++ for (const row of rows) {
++ for (const conn of conns) {
++ if (conn.closed || row.seq <= conn.lastSeq) continue;
++ if (!conn.patterns.some(p => topics.matches(p, row.event_type))) continue;
++ if (!visibleTo(conn.viewer, row)) continue;
++ sendEvent(conn, row);
++ }
++ }
++ }
++
++ function start() {
++ heartbeat = setInterval(() => {
++ for (const conn of conns) write(conn, ': hb\n\n');
++ }, opts.heartbeatMs);
++ heartbeat.unref?.();
++ }
++
++ function stop() {
++ clearInterval(heartbeat);
++ for (const conn of [...conns]) close(conn);
++ }
++
++ return { cors, handler, publish, start, stop, count: () => conns.size, visibleTo, originAllowed };
++}
++
++module.exports = { createRealtime };
ddiff --git a/server/store.js b/server/store.js
nnew file mode 100644
iindex 0000000..3f1dab6
---- /dev/null
++++ b/server/store.js
@@@ -0,0 +1,418 @@
++'use strict';
++/**
++ * The durable store (SQLite, better-sqlite3). An event is committed, together with one delivery row
++ * per matching subscription, before anything is sent anywhere: "event persists before consumer
++ * delivery". Global order is `seq`, handed out from a counter that never goes backwards, even after
++ * retention has pruned the newest rows.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++const topics = require('./topics');
++
++const PRIORITY_RANK = { critical: 0, important: 1, low: 2 };
++const RANK_PRIORITY = ['critical', 'important', 'low'];
++const DAY_MS = 24 * 60 * 60 * 1000;
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS sequences (
++ name TEXT PRIMARY KEY,
++ value INTEGER NOT NULL
++);
++CREATE TABLE IF NOT EXISTS events (
++ id TEXT PRIMARY KEY,
++ seq INTEGER NOT NULL UNIQUE,
++ event_type TEXT NOT NULL,
++ version INTEGER NOT NULL,
++ source TEXT NOT NULL,
++ actor TEXT NOT NULL,
++ subject_type TEXT NOT NULL,
++ subject_id TEXT NOT NULL,
++ subject_revision INTEGER,
++ trace_id TEXT NOT NULL,
++ priority TEXT NOT NULL,
++ visibility TEXT NOT NULL,
++ occurred_at TEXT NOT NULL,
++ received_at INTEGER NOT NULL,
++ payload TEXT NOT NULL,
++ hops INTEGER NOT NULL DEFAULT 1,
++ publisher TEXT NOT NULL,
++ request_id TEXT
++);
++CREATE INDEX IF NOT EXISTS idx_events_trace ON events(trace_id);
++CREATE INDEX IF NOT EXISTS idx_events_received ON events(received_at);
++CREATE INDEX IF NOT EXISTS idx_events_type_seq ON events(event_type, seq);
++
++CREATE TABLE IF NOT EXISTS subscriptions (
++ id TEXT PRIMARY KEY,
++ consumer TEXT NOT NULL,
++ topic_pattern TEXT NOT NULL,
++ endpoint TEXT NOT NULL,
++ secret TEXT NOT NULL,
++ enabled INTEGER NOT NULL DEFAULT 1,
++ retry_policy TEXT,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL
++);
++CREATE INDEX IF NOT EXISTS idx_subscriptions_consumer ON subscriptions(consumer);
++
++CREATE TABLE IF NOT EXISTS deliveries (
++ event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE,
++ subscription_id TEXT NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
++ seq INTEGER NOT NULL,
++ priority INTEGER NOT NULL,
++ attempt INTEGER NOT NULL DEFAULT 0,
++ status TEXT NOT NULL CHECK (status IN ('pending', 'delivered', 'failed', 'dead')),
++ next_attempt_at INTEGER,
++ last_error TEXT,
++ last_status INTEGER,
++ delivered_at INTEGER,
++ created_at INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ PRIMARY KEY (event_id, subscription_id)
++);
++CREATE INDEX IF NOT EXISTS idx_deliveries_due ON deliveries(status, next_attempt_at);
++CREATE INDEX IF NOT EXISTS idx_deliveries_sub ON deliveries(subscription_id, status, seq);
++
++CREATE TABLE IF NOT EXISTS consumer_checkpoints (
++ consumer TEXT NOT NULL,
++ topic_pattern TEXT NOT NULL,
++ cursor INTEGER NOT NULL,
++ updated_at INTEGER NOT NULL,
++ PRIMARY KEY (consumer, topic_pattern)
++);
++
++-- Publish receipts: remember accepted event ids for longer than the events themselves, so a
++-- producer that re-publishes an old id after retention is still answered "duplicate".
++CREATE TABLE IF NOT EXISTS idempotency_receipts (
++ consumer TEXT NOT NULL,
++ event_id TEXT NOT NULL,
++ processed_at INTEGER NOT NULL,
++ PRIMARY KEY (consumer, event_id)
++);
++`;
++
++const PUBLISH_RECEIPT = 'events:publish'; // receipts' consumer column for accepted publishes
++
++function openDb(dbPath) {
++ if (dbPath !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(dbPath)), { recursive: true });
++ const db = new Database(dbPath);
++ db.pragma('journal_mode = WAL');
++ db.pragma('synchronous = NORMAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ db.prepare("INSERT OR IGNORE INTO sequences (name, value) VALUES ('events', 0)").run();
++ return db;
++}
++
++class StoreError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail);
++ this.status = status;
++ this.code = code;
++ this.extra = extra;
++ }
++}
++
++/** Stored row -> the envelope exactly as consumers see it. */
++function rowToEnvelope(row) {
++ const subject = { type: row.subject_type, id: row.subject_id };
++ if (row.subject_revision != null) subject.revision = row.subject_revision;
++ return {
++ event_id: row.id,
++ trace_id: row.trace_id,
++ event_type: row.event_type,
++ version: row.version,
++ source: row.source,
++ actor: JSON.parse(row.actor),
++ timestamp: row.occurred_at,
++ priority: row.priority,
++ visibility: row.visibility,
++ subject,
++ payload: JSON.parse(row.payload),
++ };
++}
++
++function subscriptionView(row, { withSecret = false } = {}) {
++ const out = {
++ id: row.id,
++ consumer: row.consumer,
++ topic_pattern: row.topic_pattern,
++ endpoint: row.endpoint,
++ enabled: Boolean(row.enabled),
++ retry_policy: row.retry_policy ? JSON.parse(row.retry_policy) : null,
++ created_at: new Date(row.created_at).toISOString(),
++ updated_at: new Date(row.updated_at).toISOString(),
++ };
++ if (withSecret) out.secret = row.secret;
++ return out;
++}
++
++function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8 } = {}) {
++ const q = {
++ getEvent: db.prepare('SELECT * FROM events WHERE id = ?'),
++ getReceipt: db.prepare('SELECT processed_at FROM idempotency_receipts WHERE consumer = ? AND event_id = ?'),
++ addReceipt: db.prepare('INSERT OR IGNORE INTO idempotency_receipts (consumer, event_id, processed_at) VALUES (?, ?, ?)'),
++ nextSeq: db.prepare("UPDATE sequences SET value = value + 1 WHERE name = 'events' RETURNING value"),
++ lastSeq: db.prepare("SELECT value FROM sequences WHERE name = 'events'"),
++ minSeq: db.prepare('SELECT MIN(seq) AS s FROM events'),
++ chainHops: db.prepare('SELECT MAX(hops) AS h FROM events WHERE trace_id = ? AND source != ?'),
++ selfRepeats: db.prepare(`SELECT COUNT(*) AS n FROM events WHERE trace_id = ? AND source = ? AND event_type = ?
++ AND subject_type = ? AND subject_id = ? AND (request_id IS NULL OR request_id != ?)`),
++ insertEvent: db.prepare(`INSERT INTO events (id, seq, event_type, version, source, actor, subject_type, subject_id,
++ subject_revision, trace_id, priority, visibility, occurred_at, received_at, payload, hops, publisher, request_id)
++ VALUES (@id, @seq, @event_type, @version, @source, @actor, @subject_type, @subject_id, @subject_revision,
++ @trace_id, @priority, @visibility, @occurred_at, @received_at, @payload, @hops, @publisher, @request_id)`),
++ enabledSubs: db.prepare('SELECT id, topic_pattern FROM subscriptions WHERE enabled = 1'),
++ insertDelivery: db.prepare(`INSERT OR IGNORE INTO deliveries (event_id, subscription_id, seq, priority, attempt, status,
++ next_attempt_at, created_at, updated_at) VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)`),
++ afterSeq: db.prepare('SELECT * FROM events WHERE seq > ? ORDER BY seq LIMIT ?'),
++ afterSeqGlob: db.prepare('SELECT * FROM events WHERE seq > ? AND event_type GLOB ? ORDER BY seq LIMIT ?'),
++ };
++
++ // ── Events ───────────────────────────────────────────────
++
++ /**
++ * Store a batch atomically. `items` are validated envelopes with defaults filled. Returns
++ * { results: [{ event_id, seq, duplicate }], inserted: [row] }. Throws StoreError for an id
++ * conflict or a detected loop; nothing of the batch is stored then.
++ */
++ const insertBatch = db.transaction((items, { publisher, requestId }) => {
++ const now = clock.now();
++ const results = [];
++ const inserted = [];
++ const subs = q.enabledSubs.all();
++ for (const env of items) {
++ const existing = q.getEvent.get(env.event_id);
++ if (existing) {
++ if (existing.source !== env.source || existing.event_type !== env.event_type) {
++ throw new StoreError(409, 'events.id_conflict', `event ${env.event_id} already exists with different content`);
++ }
++ results.push({ event_id: env.event_id, seq: existing.seq, duplicate: true });
++ continue;
++ }
++ if (q.getReceipt.get(PUBLISH_RECEIPT, env.event_id)) {
++ results.push({ event_id: env.event_id, seq: null, duplicate: true, pruned: true });
++ continue;
++ }
++ // Loop guard: depth of the cross-service chain in this trace, and how often this exact
++ // (source, type, subject) already happened in the trace from other publish calls.
++ const chain = (q.chainHops.get(env.trace_id, env.source).h || 0) + 1;
++ const repeats = q.selfRepeats.get(env.trace_id, env.source, env.event_type, env.subject.type, env.subject.id, requestId || '').n + 1;
++ const hops = Math.max(chain, repeats);
++ if (hops > maxHops) {
++ throw new StoreError(409, 'events.loop_detected',
++ `trace ${env.trace_id} already carries ${hops - 1} hops for ${env.event_type}; refusing to extend a probable loop`,
++ { event_id: env.event_id, hops: hops - 1, max_hops: maxHops });
++ }
++ const seq = q.nextSeq.get().value;
++ const row = {
++ id: env.event_id,
++ seq,
++ event_type: env.event_type,
++ version: env.version,
++ source: env.source,
++ actor: JSON.stringify(env.actor),
++ subject_type: env.subject.type,
++ subject_id: env.subject.id,
++ subject_revision: env.subject.revision ?? null,
++ trace_id: env.trace_id,
++ priority: env.priority,
++ visibility: env.visibility,
++ occurred_at: env.timestamp,
++ received_at: now,
++ payload: JSON.stringify(env.payload),
++ hops,
++ publisher,
++ request_id: requestId || null,
++ };
++ q.insertEvent.run(row);
++ q.addReceipt.run(PUBLISH_RECEIPT, env.event_id, now);
++ for (const s of subs) {
++ if (topics.matches(s.topic_pattern, env.event_type)) {
++ q.insertDelivery.run(env.event_id, s.id, seq, PRIORITY_RANK[env.priority], now, now, now);
++ }
++ }
++ results.push({ event_id: env.event_id, seq, duplicate: false });
++ inserted.push(row);
++ }
++ return { results, inserted };
++ });
++
++ function getEvent(id) {
++ return q.getEvent.get(id) || null;
++ }
++
++ function lastSeq() {
++ return q.lastSeq.get().value;
++ }
++
++ /** Oldest seq still stored; when the table is empty, the next seq to be handed out. */
++ function oldestSeq() {
++ const m = q.minSeq.get().s;
++ return m == null ? lastSeq() + 1 : m;
++ }
++
++ /**
++ * Events with seq > afterSeq that match any of `patterns`, in order, filtered by `accept(row)`.
++ * Scans at most `scanMax` rows; returns { rows, cursor } where cursor is the last seq examined
++ * (so a pull consumer's cursor moves past rows that did not match).
++ */
++ function scan(afterSeq, { patterns = ['*'], limit = 100, scanMax = 5000, accept = () => true } = {}) {
++ const rows = [];
++ let cursor = afterSeq;
++ let scanned = 0;
++ const single = patterns.length === 1 && patterns[0] !== '*' ? topics.toGlob(patterns[0]) : null;
++ for (;;) {
++ const page = single ? q.afterSeqGlob.all(cursor, single, 500) : q.afterSeq.all(cursor, 500);
++ for (const row of page) {
++ scanned++;
++ cursor = row.seq;
++ if (patterns.some(p => topics.matches(p, row.event_type)) && accept(row)) rows.push(row);
++ if (rows.length >= limit || scanned >= scanMax) break;
++ }
++ if (rows.length >= limit || scanned >= scanMax) break;
++ if (page.length < 500) {
++ // Reached the end. With the GLOB narrowing, rows that were never fetched cannot match,
++ // so the cursor may move up to the latest seq handed out.
++ if (single) cursor = Math.max(cursor, lastSeq());
++ break;
++ }
++ }
++ return { rows, cursor };
++ }
++
++ // ── Subscriptions ────────────────────────────────────────
++
++ function createSubscription({ id, consumer, topicPattern, endpoint, secret, retryPolicy }) {
++ const now = clock.now();
++ db.prepare(`INSERT INTO subscriptions (id, consumer, topic_pattern, endpoint, secret, enabled, retry_policy, created_at, updated_at)
++ VALUES (?, ?, ?, ?, ?, 1, ?, ?, ?)`).run(id, consumer, topicPattern, endpoint, secret, retryPolicy ? JSON.stringify(retryPolicy) : null, now, now);
++ return getSubscription(id);
++ }
++ function getSubscription(id) {
++ return db.prepare('SELECT * FROM subscriptions WHERE id = ?').get(id) || null;
++ }
++ function listSubscriptions(consumer) {
++ return consumer
++ ? db.prepare('SELECT * FROM subscriptions WHERE consumer = ? ORDER BY created_at, id').all(consumer)
++ : db.prepare('SELECT * FROM subscriptions ORDER BY created_at, id').all();
++ }
++ function countSubscriptions(consumer) {
++ return db.prepare('SELECT COUNT(*) AS n FROM subscriptions WHERE consumer = ?').get(consumer).n;
++ }
++ function setSubscriptionEnabled(id, enabled) {
++ db.prepare('UPDATE subscriptions SET enabled = ?, updated_at = ? WHERE id = ?').run(enabled ? 1 : 0, clock.now(), id);
++ return getSubscription(id);
++ }
++
++ // ── Deliveries ───────────────────────────────────────────
++
++ /**
++ * Next due delivery per subscription (at most one each, so per-subscription concurrency stays 1),
++ * ordered by priority class, then seq. `busy` are subscription ids with a delivery in flight.
++ */
++ function dueDeliveries(now, limit, busy = []) {
++ const rows = db.prepare(`
++ SELECT * FROM (
++ SELECT d.*, ROW_NUMBER() OVER (PARTITION BY d.subscription_id ORDER BY d.priority, d.seq) AS rn
++ FROM deliveries d JOIN subscriptions s ON s.id = d.subscription_id
++ WHERE d.status IN ('pending', 'failed') AND d.next_attempt_at <= ? AND s.enabled = 1
++ ) WHERE rn = 1 ORDER BY priority, seq LIMIT ?`).all(now, limit + busy.length);
++ const skip = new Set(busy);
++ return rows.filter(r => !skip.has(r.subscription_id)).slice(0, limit);
++ }
++
++ function recordAttempt(eventId, subscriptionId, outcome) {
++ const now = clock.now();
++ if (outcome.ok) {
++ db.prepare(`UPDATE deliveries SET status = 'delivered', attempt = ?, delivered_at = ?, last_status = ?, last_error = NULL,
++ next_attempt_at = NULL, updated_at = ? WHERE event_id = ? AND subscription_id = ?`)
++ .run(outcome.attempt, now, outcome.status ?? null, now, eventId, subscriptionId);
++ } else {
++ db.prepare(`UPDATE deliveries SET status = ?, attempt = ?, last_status = ?, last_error = ?, next_attempt_at = ?, updated_at = ?
++ WHERE event_id = ? AND subscription_id = ?`)
++ .run(outcome.dead ? 'dead' : 'failed', outcome.attempt, outcome.status ?? null, String(outcome.error || '').slice(0, 500),
++ outcome.dead ? null : outcome.nextAttemptAt, now, eventId, subscriptionId);
++ }
++ }
++
++ function getDelivery(eventId, subscriptionId) {
++ return db.prepare('SELECT * FROM deliveries WHERE event_id = ? AND subscription_id = ?').get(eventId, subscriptionId) || null;
++ }
++
++ function listDeliveries({ status, subscriptionId, limit = 100, afterSeq = 0 } = {}) {
++ const where = ['seq > ?'];
++ const args = [afterSeq];
++ if (status) { where.push('status = ?'); args.push(status); }
++ if (subscriptionId) { where.push('subscription_id = ?'); args.push(subscriptionId); }
++ args.push(limit);
++ return db.prepare(`SELECT * FROM deliveries WHERE ${where.join(' AND ')} ORDER BY seq, subscription_id LIMIT ?`).all(...args);
++ }
++
++ /**
++ * Queue (again) the given events for one subscription: reset to pending, attempt 0, due now.
++ * Only retained events that match the subscription's topic are queued. Returns the count.
++ */
++ const requeue = db.transaction((sub, { fromSeq, eventIds, max = 10000 }) => {
++ const now = clock.now();
++ let rows;
++ if (Array.isArray(eventIds)) {
++ const get = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE id = ?');
++ rows = eventIds.map(id => get.get(id)).filter(Boolean);
++ } else {
++ rows = db.prepare('SELECT id, seq, priority, event_type FROM events WHERE seq >= ? AND event_type GLOB ? ORDER BY seq LIMIT ?')
++ .all(fromSeq, topics.toGlob(sub.topic_pattern), max * 2);
++ }
++ rows = rows.filter(r => topics.matches(sub.topic_pattern, r.event_type)).slice(0, max);
++ const up = db.prepare(`INSERT INTO deliveries (event_id, subscription_id, seq, priority, attempt, status, next_attempt_at, created_at, updated_at)
++ VALUES (?, ?, ?, ?, 0, 'pending', ?, ?, ?)
++ ON CONFLICT(event_id, subscription_id) DO UPDATE SET status = 'pending', attempt = 0, next_attempt_at = excluded.next_attempt_at,
++ last_error = NULL, last_status = NULL, delivered_at = NULL, updated_at = excluded.updated_at`);
++ for (const r of rows) up.run(r.id, sub.id, r.seq, PRIORITY_RANK[r.priority], now, now, now);
++ return rows.length;
++ });
++
++ function deliveryCounts() {
++ const out = { pending: 0, delivered: 0, failed: 0, dead: 0 };
++ for (const r of db.prepare('SELECT status, COUNT(*) AS n FROM deliveries GROUP BY status').all()) out[r.status] = r.n;
++ return out;
++ }
++
++ // ── Checkpoints ──────────────────────────────────────────
++
++ function getCheckpoint(consumer, topicPattern) {
++ const r = db.prepare('SELECT cursor, updated_at FROM consumer_checkpoints WHERE consumer = ? AND topic_pattern = ?').get(consumer, topicPattern);
++ return r ? { cursor: r.cursor, updated_at: new Date(r.updated_at).toISOString() } : null;
++ }
++ function setCheckpoint(consumer, topicPattern, cursor) {
++ db.prepare(`INSERT INTO consumer_checkpoints (consumer, topic_pattern, cursor, updated_at) VALUES (?, ?, ?, ?)
++ ON CONFLICT(consumer, topic_pattern) DO UPDATE SET cursor = excluded.cursor, updated_at = excluded.updated_at`)
++ .run(consumer, topicPattern, cursor, clock.now());
++ return getCheckpoint(consumer, topicPattern);
++ }
++
++ // ── Retention ────────────────────────────────────────────
++
++ /** Drop events (and their deliveries) older than retentionDays, and old publish receipts. */
++ function prune({ retentionDays = 30, receiptRetentionDays = 90, now = clock.now() } = {}) {
++ const events = db.prepare('DELETE FROM events WHERE received_at < ?').run(now - retentionDays * DAY_MS).changes;
++ const receipts = db.prepare('DELETE FROM idempotency_receipts WHERE processed_at < ?')
++ .run(now - Math.max(receiptRetentionDays, retentionDays) * DAY_MS).changes;
++ return { events, receipts };
++ }
++
++ function ping() {
++ return db.prepare('SELECT 1 AS ok').get().ok === 1;
++ }
++
++ return {
++ db, insertBatch, getEvent, lastSeq, oldestSeq, scan,
++ createSubscription, getSubscription, listSubscriptions, countSubscriptions, setSubscriptionEnabled,
++ dueDeliveries, recordAttempt, getDelivery, listDeliveries, requeue, deliveryCounts,
++ getCheckpoint, setCheckpoint, prune, ping,
++ };
++}
++
++module.exports = { openDb, createStore, rowToEnvelope, subscriptionView, StoreError, PRIORITY_RANK, RANK_PRIORITY };
ddiff --git a/server/topics.js b/server/topics.js
nnew file mode 100644
iindex 0000000..fa9ebed
---- /dev/null
++++ b/server/topics.js
@@@ -0,0 +1,46 @@
++'use strict';
++/**
++ * Topic patterns. A topic is an event type (`media.vod.ready`); a pattern is a dot-separated list of
++ * literal segments and `*`, where `*` stands for ONE OR MORE whole segments:
++ *
++ * media.vod.ready exactly that type
++ * media.vod.* media.vod.ready, media.vod.clip.cut, ...
++ * *.created every type that ends in .created
++ * media.*.ready media.vod.ready, media.a.b.ready
++ * * everything
++ */
++
++const PATTERN_RE = /^(\*|[a-z0-9_]+)(\.(\*|[a-z0-9_]+))*$/;
++const MAX_LEN = 200;
++const cache = new Map();
++
++function isValidPattern(p) {
++ return typeof p === 'string' && p.length > 0 && p.length <= MAX_LEN && PATTERN_RE.test(p) && !p.includes('*.*');
++}
++
++function compile(pattern) {
++ let re = cache.get(pattern);
++ if (re) return re;
++ if (!isValidPattern(pattern)) throw new TypeError(`invalid topic pattern "${pattern}"`);
++ const body = pattern.split('.').map(seg => (seg === '*' ? '[a-z0-9_]+(?:\\.[a-z0-9_]+)*' : seg)).join('\\.');
++ re = new RegExp(`^${body}$`);
++ if (cache.size > 5000) cache.clear();
++ cache.set(pattern, re);
++ return re;
++}
++
++function matches(pattern, eventType) {
++ return compile(pattern).test(eventType);
++}
++
++/**
++ * A SQLite GLOB that selects a superset of what the pattern matches (GLOB `*` also spans dots),
++ * so the SQL narrows the scan and matches() makes the exact decision. Valid patterns only contain
++ * [a-z0-9_.*], none of which GLOB treats specially except `*`, so the pattern is its own GLOB.
++ */
++function toGlob(pattern) {
++ if (!isValidPattern(pattern)) throw new TypeError(`invalid topic pattern "${pattern}"`);
++ return pattern;
++}
++
++module.exports = { isValidPattern, compile, matches, toGlob };
ddiff --git a/server/worker.js b/server/worker.js
nnew file mode 100644
iindex 0000000..29e7ce6
---- /dev/null
++++ b/server/worker.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * Delivery worker. Pending deliveries are POSTed to their subscription endpoints as
++ *
++ * { "event": <envelope>, "seq": <n> }
++ *
++ * with X-OpenVibe-Event-Id, X-OpenVibe-Signature (sha256=<HMAC of the raw body with the
++ * subscription secret>), traceparent (the event's trace). A 2xx is delivered; anything else
++ * (including redirects, which are never followed) is retried with backoff 1s, 5s, 30s, 2m, 10m, 1h
++ * up to maxAttempts, then the delivery is `dead` (the DLQ; replay requeues it).
++ *
++ * Scheduling: a setTimeout chain (never overlapping ticks); priority classes critical > important >
++ * low, then seq; one delivery in flight per subscription; at most maxInflight overall.
++ */
++const crypto = require('crypto');
++const { sign } = require('../lib/client');
++const { rowToEnvelope } = require('./store');
++const { checkEndpoint } = require('./endpoints');
++
++function createWorker({ store, config, clock = { now: () => Date.now() }, fetchImpl = globalThis.fetch, log = console }) {
++ const opts = config.worker;
++ const busy = new Set(); // subscription ids with a delivery in flight
++ const inflight = new Set(); // promises
++ let timer = null;
++ let running = false;
++ let lastTickAt = null;
++
++ function policy(sub) {
++ let p = null;
++ try { p = sub.retry_policy ? JSON.parse(sub.retry_policy) : null; } catch { p = null; }
++ const maxAttempts = Number.isInteger(p && p.max_attempts) && p.max_attempts >= 1 && p.max_attempts <= 20 ? p.max_attempts : opts.maxAttempts;
++ const backoffMs = Array.isArray(p && p.backoff_ms) && p.backoff_ms.length && p.backoff_ms.every(n => Number.isInteger(n) && n >= 0 && n <= 86400000)
++ ? p.backoff_ms : opts.backoffMs;
++ return { maxAttempts, backoffMs };
++ }
++
++ async function send(delivery) {
++ const sub = store.getSubscription(delivery.subscription_id);
++ const row = store.getEvent(delivery.event_id);
++ const attempt = delivery.attempt + 1;
++ if (!sub || !row) return; // pruned or removed while queued; cascade deletes the delivery
++ const { maxAttempts, backoffMs } = policy(sub);
++ let outcome;
++ try {
++ // Re-check the endpoint at send time too (the allow-list may have been tightened).
++ const check = checkEndpoint(sub.endpoint, config.endpointHosts);
++ if (!check.ok) throw Object.assign(new Error(`endpoint not allowed: ${check.reason}`), { permanent: true });
++ const body = JSON.stringify({ event: rowToEnvelope(row), seq: row.seq });
++ const res = await fetchImpl(sub.endpoint, {
++ method: 'POST',
++ redirect: 'manual',
++ signal: AbortSignal.timeout(opts.timeoutMs),
++ headers: {
++ 'Content-Type': 'application/json',
++ 'User-Agent': 'OpenVibe.Events/0.1',
++ 'X-OpenVibe-Event-Id': row.id,
++ 'X-OpenVibe-Event-Type': row.event_type,
++ 'X-OpenVibe-Seq': String(row.seq),
++ 'X-OpenVibe-Subscription-Id': sub.id,
++ 'X-OpenVibe-Delivery-Attempt': String(attempt),
++ 'X-OpenVibe-Hops': String(row.hops),
++ 'X-OpenVibe-Signature': sign(body, sub.secret),
++ traceparent: `00-${row.trace_id}-${crypto.randomBytes(8).toString('hex')}-01`,
++ },
++ body,
++ });
++ try { await res.body?.cancel(); } catch { /* body is not needed */ }
++ if (res.status >= 200 && res.status < 300) outcome = { ok: true, attempt, status: res.status };
++ else outcome = { ok: false, attempt, status: res.status, error: `HTTP ${res.status}` };
++ } catch (err) {
++ outcome = { ok: false, attempt, status: null, error: err.name === 'TimeoutError' ? 'timeout' : (err.message || String(err)), permanent: Boolean(err.permanent) };
++ }
++ if (!outcome.ok) {
++ outcome.dead = outcome.permanent || attempt >= maxAttempts;
++ if (!outcome.dead) outcome.nextAttemptAt = clock.now() + backoffMs[Math.min(attempt - 1, backoffMs.length - 1)];
++ if (outcome.dead) log.warn(`[worker] ${row.id} -> ${sub.id} dead after ${attempt} attempts: ${outcome.error}`);
++ }
++ store.recordAttempt(delivery.event_id, delivery.subscription_id, outcome);
++ }
++
++ /** Start every due delivery there is room for. Returns how many were started. */
++ function dispatch() {
++ lastTickAt = Date.now();
++ const room = opts.maxInflight - inflight.size;
++ if (room <= 0) return 0;
++ const due = store.dueDeliveries(clock.now(), room, [...busy]);
++ for (const d of due) {
++ busy.add(d.subscription_id);
++ const p = send(d)
++ .catch(err => log.error(`[worker] delivery ${d.event_id} -> ${d.subscription_id} crashed: ${err.stack || err}`))
++ .finally(() => {
++ busy.delete(d.subscription_id);
++ inflight.delete(p);
++ if (running) schedule(0);
++ });
++ inflight.add(p);
++ }
++ return due.length;
++ }
++
++ function schedule(ms) {
++ if (!running) return;
++ if (timer && ms > 0) return; // a tick is already coming
++ clearTimeout(timer);
++ timer = setTimeout(() => {
++ timer = null;
++ try { dispatch(); } catch (err) { log.error(`[worker] tick failed: ${err.stack || err}`); }
++ schedule(opts.intervalMs);
++ }, ms);
++ timer.unref?.();
++ }
++
++ /** Deliver everything due now (and whatever becomes due as a result), then resolve. Tests. */
++ async function drain() {
++ for (;;) {
++ const started = dispatch();
++ if (!started && !inflight.size) return;
++ if (inflight.size) await Promise.race([...inflight]);
++ }
++ }
++
++ return {
++ start() { if (!running) { running = true; schedule(0); } },
++ async stop() { running = false; clearTimeout(timer); timer = null; await Promise.allSettled([...inflight]); },
++ kick() { schedule(0); },
++ drain,
++ dispatch,
++ running: () => running,
++ stats: () => ({ running, inflight: inflight.size, busy_subscriptions: busy.size, last_tick_at: lastTickAt ? new Date(lastTickAt).toISOString() : null }),
++ };
++}
++
++module.exports = { createWorker };
ddiff --git a/test/delivery.test.js b/test/delivery.test.js
nnew file mode 100644
iindex 0000000..268a911
---- /dev/null
++++ b/test/delivery.test.js
@@@ -0,0 +1,214 @@
++'use strict';
++const assert = require('assert');
++const { verifyDelivery } = require('../lib/client');
++const { boot, request, serviceToken, envelope, subscriber, suite, sleep } = require('./helpers');
++
++const t = suite('delivery');
++let h;
++const live = serviceToken('live', ['events.publish']);
++const media = serviceToken('media', ['events.subscribe']);
++const admin = serviceToken('ops', ['events.admin']);
++
++async function subscribe(token, body) {
++ return request(h.base, 'POST', '/api/v1/subscriptions', { token, body });
++}
++async function publish(env) {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ assert.ok(r.status === 201 || r.status === 200, r.text);
++ return r.body;
++}
++
++t('boot', async () => { h = await boot(); });
++
++t('subscription API: create, list own, secret shown once', async () => {
++ const stub = await subscriber();
++ const r = await subscribe(media, { topic_pattern: 'live.demo.*', endpoint: stub.url });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.body.id, /^sub_/);
++ assert.strictEqual(r.body.consumer, 'media');
++ assert.match(r.body.secret, /^whsec_[0-9a-f]{64}$/);
++ const list = await request(h.base, 'GET', '/api/v1/subscriptions', { token: media });
++ assert.strictEqual(list.body.subscriptions.length, 1);
++ assert.strictEqual(list.body.subscriptions[0].secret, undefined);
++ const other = await request(h.base, 'GET', '/api/v1/subscriptions', { token: serviceToken('games', ['events.subscribe']) });
++ assert.strictEqual(other.body.subscriptions.length, 0, 'consumers only see their own');
++ const dup = await subscribe(media, { topic_pattern: 'live.demo.*', endpoint: stub.url });
++ assert.strictEqual(dup.status, 409);
++ const foreign = await request(h.base, 'POST', `/api/v1/subscriptions/${r.body.id}/disable`, { token: serviceToken('games', ['events.subscribe']) });
++ assert.strictEqual(foreign.status, 404, 'cannot disable another consumer\'s subscription');
++ const noCap = await subscribe(serviceToken('media', ['events.read']), { topic_pattern: 'x.y.z', endpoint: stub.url });
++ assert.strictEqual(noCap.status, 403);
++ await request(h.base, 'POST', `/api/v1/subscriptions/${r.body.id}/disable`, { token: media });
++ await stub.close();
++});
++
++t('SSRF: only http(s) endpoints on 127.0.0.1 or *.openvibe.* are accepted', async () => {
++ const bad = [
++ 'http://10.0.0.5/hook', 'http://169.254.169.254/latest/meta-data', 'http://localhost:4000/x', 'http://[::1]:4000/x',
++ 'https://evil.example/hook', 'https://openvibe.live.evil.com/x', 'https://xopenvibe.live/x', 'file:///etc/passwd',
++ 'gopher://127.0.0.1:25/', 'http://user:[email protected]/x', 'http://0.0.0.0:4000/x', 'https://openvibe.network./x', 'not a url',
++ ];
++ for (const endpoint of bad) {
++ const r = await subscribe(media, { topic_pattern: 'live.*', endpoint });
++ assert.strictEqual(r.status, 422, endpoint);
++ assert.strictEqual(r.body.code, 'events.endpoint_not_allowed', endpoint);
++ }
++ for (const endpoint of ['http://127.0.0.1:3000/internal/events', 'https://openvibe.live/internal/events', 'https://live.openvibe.network/hook']) {
++ const r = await subscribe(serviceToken('ssrfok', ['events.subscribe']), { topic_pattern: 'nothing.matches.this', endpoint });
++ assert.strictEqual(r.status, 201, endpoint);
++ }
++ // http://2130706433/ is 127.0.0.1 once parsed; it is stored normalised.
++ const r = await subscribe(serviceToken('ssrfok', ['events.subscribe']), { topic_pattern: 'nothing.matches.that', endpoint: 'http://2130706433:9/x' });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.body.endpoint, 'http://127.0.0.1:9/x');
++});
++
++t('success: signed POST, verified by verifyDelivery; trace propagated', async () => {
++ const stub = await subscriber();
++ const sub = (await subscribe(media, { topic_pattern: 'live.stream.*', endpoint: stub.url })).body;
++ const env = envelope('live', { trace_id: '4bf92f3577b34da6a3ce929d0e0e4736' });
++ const { seq } = await publish(env);
++ assert.strictEqual(h.store.getDelivery(env.event_id, sub.id).status, 'pending', 'persisted before delivery');
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, 1);
++ const call = stub.calls[0];
++ assert.ok(verifyDelivery(call.rawBody, call.headers['x-openvibe-signature'], sub.secret));
++ assert.ok(!verifyDelivery(call.rawBody, call.headers['x-openvibe-signature'], 'whsec_wrong_secret_wrong_secret_wrong'));
++ assert.ok(!verifyDelivery(Buffer.concat([call.rawBody, Buffer.from(' ')]), call.headers['x-openvibe-signature'], sub.secret));
++ assert.strictEqual(call.headers['x-openvibe-event-id'], env.event_id);
++ assert.match(call.headers.traceparent, /^00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01$/);
++ assert.deepStrictEqual(call.body.event.payload, env.payload);
++ assert.strictEqual(call.body.seq, seq);
++ const d = h.store.getDelivery(env.event_id, sub.id);
++ assert.strictEqual(d.status, 'delivered');
++ assert.strictEqual(d.attempt, 1);
++ await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/disable`, { token: media });
++ await stub.close();
++});
++
++t('retries with backoff (injectable clock), then DLQ after 8 attempts, then replay', async () => {
++ let healthy = false;
++ const stub = await subscriber(() => (healthy ? 200 : 503));
++ const sub = (await subscribe(media, { topic_pattern: 'live.retry.*', endpoint: stub.url })).body;
++ const env = envelope('live', { event_type: 'live.retry.me' });
++ await publish(env);
++ const expectedWaits = [1000, 5000, 30000, 120000, 600000, 3600000, 3600000];
++ for (let attempt = 1; attempt <= 8; attempt++) {
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, attempt, `attempt ${attempt} made`);
++ const d = h.store.getDelivery(env.event_id, sub.id);
++ assert.strictEqual(d.attempt, attempt);
++ if (attempt < 8) {
++ assert.strictEqual(d.status, 'failed');
++ assert.strictEqual(d.next_attempt_at - h.clock.now(), expectedWaits[attempt - 1]);
++ h.clock.advance(expectedWaits[attempt - 1] - 1);
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, attempt, 'not retried before the backoff elapses');
++ h.clock.advance(1);
++ } else {
++ assert.strictEqual(d.status, 'dead');
++ assert.strictEqual(d.last_error, 'HTTP 503');
++ }
++ }
++ h.clock.advance(86400000);
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, 8, 'a dead delivery is never retried on its own');
++
++ const dlq = await request(h.base, 'GET', `/api/v1/deliveries?status=dead&subscription_id=${sub.id}`, { token: admin });
++ assert.strictEqual(dlq.status, 200);
++ assert.strictEqual(dlq.body.deliveries.length, 1);
++ assert.strictEqual(dlq.body.deliveries[0].event_id, env.event_id);
++ const denied = await request(h.base, 'GET', '/api/v1/deliveries?status=dead', { token: media });
++ assert.strictEqual(denied.status, 403);
++
++ healthy = true;
++ const rp = await request(h.base, 'POST', '/api/v1/deliveries/replay', { token: admin, body: { subscription_id: sub.id, event_ids: [env.event_id] } });
++ assert.strictEqual(rp.status, 200, rp.text);
++ assert.strictEqual(rp.body.queued, 1);
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, 9);
++ assert.strictEqual(h.store.getDelivery(env.event_id, sub.id).status, 'delivered');
++ await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/disable`, { token: media });
++ await stub.close();
++});
++
++t('replay from_seq re-sends retained matching events (new subscription catches up)', async () => {
++ const e1 = envelope('live', { event_type: 'live.history.a' });
++ const e2 = envelope('live', { event_type: 'live.history.b' });
++ const e3 = envelope('live', { event_type: 'live.other.c' });
++ const { seq } = await publish(e1);
++ await publish(e2);
++ await publish(e3);
++ const stub = await subscriber();
++ const sub = (await subscribe(media, { topic_pattern: 'live.history.*', endpoint: stub.url })).body;
++ await h.worker.drain();
++ assert.strictEqual(stub.calls.length, 0, 'subscriptions only get events published after they exist');
++ const rp = await request(h.base, 'POST', '/api/v1/deliveries/replay', { token: admin, body: { subscription_id: sub.id, from_seq: seq } });
++ assert.strictEqual(rp.body.queued, 2);
++ await h.worker.drain();
++ assert.deepStrictEqual(stub.calls.map(c => c.body.event.event_id), [e1.event_id, e2.event_id]);
++ await request(h.base, 'POST', `/api/v1/subscriptions/${sub.id}/disable`, { token: media });
++ await stub.close();
++});
++
++t('priority classes: critical before important before low', async () => {
++ const orders = [[], [], []];
++ const stubs = [];
++ for (let i = 0; i < 3; i++) stubs.push(await subscriber((c) => { orders[i].push(c.body.event.priority); return 204; }));
++ for (let i = 0; i < 3; i++) await subscribe(serviceToken(`prio${i}`, ['events.subscribe']), { topic_pattern: 'live.prio.*', endpoint: stubs[i].url });
++ // Low first, critical last: the queue must still send critical first.
++ await publish(envelope('live', { event_type: 'live.prio.x', priority: 'low' }));
++ await publish(envelope('live', { event_type: 'live.prio.x', priority: 'important' }));
++ await publish(envelope('live', { event_type: 'live.prio.x', priority: 'critical' }));
++ const due = h.store.dueDeliveries(h.clock.now(), 100);
++ assert.strictEqual(due.length, 3, 'one per subscription');
++ assert.ok(due.every(d => d.priority === 0), 'each subscription\'s head is its critical event');
++ await h.worker.drain();
++ for (const o of orders) assert.deepStrictEqual(o, ['critical', 'important', 'low']);
++ for (const s of stubs) await s.close();
++ for (const sub of h.store.listSubscriptions()) h.store.setSubscriptionEnabled(sub.id, false);
++});
++
++t('backpressure: one in flight per subscription, <= maxInflight (20) overall', async () => {
++ let concurrent = 0;
++ let peak = 0;
++ const perSub = new Map();
++ let perSubPeak = 0;
++ const fetchImpl = async (url, init) => {
++ const sub = init.headers['X-OpenVibe-Subscription-Id'];
++ concurrent++; peak = Math.max(peak, concurrent);
++ perSub.set(sub, (perSub.get(sub) || 0) + 1); perSubPeak = Math.max(perSubPeak, perSub.get(sub));
++ await sleep(15);
++ concurrent--; perSub.set(sub, perSub.get(sub) - 1);
++ return new Response(null, { status: 204 });
++ };
++ const bp = await boot({ deliveryFetch: fetchImpl });
++ for (let i = 0; i < 25; i++) {
++ const r = await request(bp.base, 'POST', '/api/v1/subscriptions', { token: serviceToken(`bp${i}`, ['events.subscribe']), body: { topic_pattern: 'live.bp.*', endpoint: 'http://127.0.0.1:9/hook' } });
++ assert.strictEqual(r.status, 201);
++ }
++ for (let i = 0; i < 3; i++) await request(bp.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.bp.x' }) });
++ await bp.worker.drain();
++ assert.strictEqual(peak, 20, `peak in flight ${peak}`);
++ assert.strictEqual(perSubPeak, 1);
++ assert.strictEqual(bp.store.deliveryCounts().delivered, 75);
++ await bp.stop();
++});
++
++t('the real loop delivers without drain()', async () => {
++ const stub = await subscriber();
++ const loop = await boot({ worker: 'on', env: { EVENTS_WORKER_INTERVAL_MS: '50' } });
++ await request(loop.base, 'POST', '/api/v1/subscriptions', { token: media, body: { topic_pattern: 'live.loop.*', endpoint: stub.url } });
++ await request(loop.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.loop.go' }) });
++ for (let i = 0; i < 50 && !stub.calls.length; i++) await sleep(20);
++ assert.strictEqual(stub.calls.length, 1);
++ const ready = await request(loop.base, 'GET', '/api/ready');
++ assert.strictEqual(ready.status, 200);
++ assert.strictEqual(ready.body.checks.worker, true);
++ await loop.stop();
++ await stub.close();
++});
++
++t('stop', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..dfabfb0
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,194 @@
++'use strict';
++/**
++ * Shared test fixtures: a generated Network signing key, token minting, a booted Events service on a
++ * random port with a temp database and a manual clock, a stub subscriber, and an SSE reader.
++ */
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const nodeHttp = require('http');
++const { serviceAuth, ids } = require('openvibe-contracts');
++const { load } = require('../server/config');
++const { start } = require('../server/index');
++
++const ISSUER = 'https://openvibe.network';
++const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
++ modulusLength: 2048,
++ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
++ publicKeyEncoding: { type: 'spki', format: 'pem' },
++});
++
++const silent = { log() {}, warn() {}, error(...a) { if (process.env.DEBUG) console.error(...a); } };
++
++function serviceToken(slug, cap, { aud = 'openvibe.events', exp = Math.floor(Date.now() / 1000) + 300, iss = ISSUER, key = privateKey, sub } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({
++ iss, sub: sub || `svc:${slug}`, actor_type: 'service', aud: [aud], cap, ns: [], iat: now, exp,
++ jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
++ }, key);
++}
++
++function userToken({ subjectId = ids.newId('user'), aud = ['openvibe.live', 'openvibe.network'], exp = Math.floor(Date.now() / 1000) + 3600, key = privateKey } = {}) {
++ return serviceAuth.signServiceToken({
++ sub: 57, id: 57, subject_id: subjectId, username: 'viewer', role: 'user', iss: ISSUER, aud, iat: Math.floor(Date.now() / 1000), exp,
++ }, key);
++}
++
++function manualClock(t = Date.now()) {
++ return { t, now() { return this.t; }, advance(ms) { this.t += ms; return this.t; } };
++}
++
++const made = [];
++process.on('exit', () => { for (const d of made) fs.rmSync(d, { recursive: true, force: true }); });
++
++/** A temp directory removed when the test process exits. */
++function tmpDir() {
++ const d = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-events-test-'));
++ made.push(d);
++ return d;
++}
++
++/** Boot the service. env overrides go through config.load(); `worker: 'manual'` keeps the loop off. */
++async function boot({ env = {}, clock = manualClock(), worker = 'manual', fetchImpl, deliveryFetch } = {}) {
++ const dir = tmpDir();
++ const config = load({
++ NODE_ENV: 'test',
++ PORT: '0',
++ EVENTS_DB_PATH: path.join(dir, 'events.db'),
++ OV_NETWORK_PUBLIC_KEY: publicKey,
++ EVENTS_WORKER: worker === 'manual' ? 'off' : 'on',
++ ...env,
++ });
++ const h = await start({ config, clock, log: silent, fetchImpl, deliveryFetch });
++ const base = `http://127.0.0.1:${h.server.address().port}`;
++ return {
++ ...h, base, clock, dir,
++ async stop() { await h.close(); fs.rmSync(dir, { recursive: true, force: true }); },
++ };
++}
++
++async function request(base, method, p, { token, body, headers = {}, raw } = {}) {
++ const h = { ...headers };
++ if (token) h.Authorization = `Bearer ${token}`;
++ if (body !== undefined) h['Content-Type'] = 'application/json';
++ const res = await fetch(base + p, { method, headers: h, body: raw !== undefined ? raw : body !== undefined ? JSON.stringify(body) : undefined });
++ const text = await res.text();
++ let json = null;
++ try { json = text ? JSON.parse(text) : null; } catch { json = null; }
++ return { status: res.status, body: json, text, headers: res.headers };
++}
++
++/** A valid envelope for `source`; override anything. */
++function envelope(source = 'live', overrides = {}) {
++ return {
++ event_id: ids.newId('event'),
++ event_type: `${source}.stream.started`,
++ version: 1,
++ source,
++ actor: { type: 'service', id: source },
++ timestamp: new Date().toISOString(),
++ subject: { type: 'stream', id: 'str_1', revision: 1 },
++ payload: { title: 'hello' },
++ ...overrides,
++ };
++}
++
++/** Stub webhook receiver. `respond(req, rawBody, n)` returns a status (default 204). */
++async function subscriber(respond = () => 204) {
++ const calls = [];
++ const server = nodeHttp.createServer((req, res) => {
++ const chunks = [];
++ req.on('data', c => chunks.push(c));
++ req.on('end', () => {
++ const rawBody = Buffer.concat(chunks);
++ const call = { headers: req.headers, rawBody, body: JSON.parse(rawBody.toString('utf8') || 'null') };
++ calls.push(call);
++ let status;
++ try { status = respond(call, calls.length); } catch (err) { status = 500; call.error = err; }
++ if (status === 'destroy') { req.socket.destroy(); return; }
++ res.statusCode = status;
++ res.end();
++ });
++ });
++ await new Promise(r => server.listen(0, '127.0.0.1', r));
++ return {
++ calls,
++ url: `http://127.0.0.1:${server.address().port}/hook`,
++ close: () => new Promise(r => { server.closeAllConnections?.(); server.close(() => r()); }),
++ };
++}
++
++/** Open an SSE stream and parse it. */
++function sse(base, pathAndQuery, { headers = {} } = {}) {
++ return new Promise((resolve, reject) => {
++ const messages = [];
++ const waiters = [];
++ const url = new URL(base + pathAndQuery);
++ const req = nodeHttp.get({ hostname: url.hostname, port: url.port, path: url.pathname + url.search, headers }, (res) => {
++ let buf = '';
++ const client = {
++ status: res.statusCode,
++ headers: res.headers,
++ messages,
++ body: '',
++ events: () => messages.filter(m => !m.event || m.event === 'message').map(m => JSON.parse(m.data)),
++ gaps: () => messages.filter(m => m.event === 'gap').map(m => JSON.parse(m.data)),
++ comments: [],
++ waitFor(pred, ms = 2000) {
++ if (pred(client)) return Promise.resolve(client);
++ return new Promise((ok, fail) => {
++ const w = { pred, ok };
++ waiters.push(w);
++ setTimeout(() => { const i = waiters.indexOf(w); if (i >= 0) { waiters.splice(i, 1); fail(new Error('sse waitFor timed out: ' + JSON.stringify(messages))); } }, ms);
++ });
++ },
++ close() { req.destroy(); },
++ };
++ res.setEncoding('utf8');
++ res.on('data', (chunk) => {
++ client.body += chunk;
++ buf += chunk;
++ let i;
++ while ((i = buf.indexOf('\n\n')) >= 0) {
++ const block = buf.slice(0, i);
++ buf = buf.slice(i + 2);
++ const msg = {};
++ for (const line of block.split('\n')) {
++ if (line.startsWith(':')) { client.comments.push(line.slice(1).trim()); continue; }
++ const c = line.indexOf(':');
++ const k = c < 0 ? line : line.slice(0, c);
++ const v = c < 0 ? '' : line.slice(c + 1).replace(/^ /, '');
++ msg[k] = msg[k] ? msg[k] + '\n' + v : v;
++ }
++ if (msg.data !== undefined) messages.push(msg);
++ }
++ for (const w of [...waiters]) if (w.pred(client)) { waiters.splice(waiters.indexOf(w), 1); w.ok(client); }
++ });
++ resolve(client);
++ });
++ req.on('error', reject);
++ });
++}
++
++const sleep = (ms) => new Promise(r => setTimeout(r, ms));
++
++/** Tiny sequential test runner: t('name', async () => {...}); await t.run(). */
++function suite(name) {
++ const tests = [];
++ const t = (n, fn) => tests.push([n, fn]);
++ t.run = async () => {
++ let failed = 0;
++ for (const [n, fn] of tests) {
++ try { await fn(); console.log(` ok ${n}`); } catch (err) { failed++; console.log(` FAIL ${n}\n${err.stack}`); }
++ }
++ console.log(`${name}: ${tests.length - failed}/${tests.length} passed`);
++ if (failed) process.exit(1);
++ };
++ return t;
++}
++
++module.exports = {
++ ISSUER, privateKey, publicKey, silent, serviceToken, userToken, manualClock, tmpDir, boot, request,
++ envelope, subscriber, sse, sleep, suite,
++};
ddiff --git a/test/outbox-inbox.test.js b/test/outbox-inbox.test.js
nnew file mode 100644
iindex 0000000..3def504
---- /dev/null
++++ b/test/outbox-inbox.test.js
@@@ -0,0 +1,185 @@
++'use strict';
++const assert = require('assert');
++const path = require('path');
++const nodeHttp = require('http');
++const Database = require('better-sqlite3');
++const { serviceAuth } = require('openvibe-contracts');
++const events = require('..');
++const { boot, request, serviceToken, envelope, subscriber, suite, tmpDir } = require('./helpers');
++
++const t = suite('outbox-inbox');
++let h;
++let network;
++let tokenClient;
++const admin = serviceToken('ops', ['events.admin']);
++
++function countIn(id) {
++ return h.db.prepare('SELECT COUNT(*) AS n FROM events WHERE id = ?').get(id).n;
++}
++
++t('boot Events + a stub Network token endpoint', async () => {
++ h = await boot();
++ let issued = 0;
++ network = nodeHttp.createServer((req, res) => {
++ let body = '';
++ req.on('data', c => { body += c; });
++ req.on('end', () => {
++ const p = new URLSearchParams(body);
++ assert.strictEqual(p.get('grant_type'), 'client_credentials');
++ assert.strictEqual(p.get('audience'), 'openvibe.events');
++ issued++;
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify({ access_token: serviceToken(p.get('client_id'), ['events.publish']), token_type: 'Bearer', expires_in: 300 }));
++ });
++ });
++ await new Promise(r => network.listen(0, '127.0.0.1', r));
++ network.issued = () => issued;
++ tokenClient = serviceAuth.createTokenClient({
++ tokenUrl: `http://127.0.0.1:${network.address().port}/oauth/token`, clientId: 'live', clientSecret: 's', audience: 'openvibe.events',
++ });
++});
++
++t('publisher fills event_id, timestamp and trace (from traceparent)', async () => {
++ const publisher = events.createPublisher({ eventsUrl: h.base, tokenClient });
++ const out = await publisher.publish({
++ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' },
++ subject: { type: 'stream', id: '7' }, payload: {},
++ }, { traceparent: '00-11111111111111111111111111111111-2222222222222222-01' });
++ assert.match(out.event_id, /^evt_/);
++ assert.strictEqual(h.store.getEvent(out.event_id).trace_id, '11111111111111111111111111111111');
++ assert.strictEqual(network.issued(), 1);
++ const batch = await publisher.publish([envelope(), envelope()]);
++ assert.strictEqual(batch.results.length, 2);
++ assert.strictEqual(network.issued(), 1, 'token cached');
++ await assert.rejects(publisher.publish(envelope('media')), (err) => err.status === 403 && err.code === 'events.source_mismatch' && err.permanent);
++});
++
++t('outbox: enqueue inside a rolled-back transaction publishes nothing; committed publishes exactly once', async () => {
++ const db = new Database(path.join(tmpDir(), 'producer.db'));
++ db.exec('CREATE TABLE vods (id INTEGER PRIMARY KEY, status TEXT)');
++ const outbox = events.createOutbox(db, { publisher: events.createPublisher({ eventsUrl: h.base, tokenClient }) });
++ outbox.ensureSchema();
++
++ assert.throws(() => outbox.enqueue(envelope()), /inside the transaction/);
++
++ let rolledBack;
++ assert.throws(() => db.transaction(() => {
++ db.prepare('INSERT INTO vods (id, status) VALUES (1, ?)').run('ready');
++ rolledBack = outbox.enqueue(envelope('live', { event_type: 'live.vod.ready' }));
++ throw new Error('domain write failed');
++ })(), /domain write failed/);
++ assert.strictEqual(outbox.pending(), 0);
++ let r = await outbox.flush();
++ assert.deepStrictEqual(r, { sent: 0, failed: 0, rejected: 0 });
++ assert.strictEqual(countIn(rolledBack.event_id), 0, 'rolled back: never published');
++
++ let committed;
++ db.transaction(() => {
++ db.prepare('INSERT INTO vods (id, status) VALUES (2, ?)').run('ready');
++ committed = outbox.enqueue(envelope('live', { event_type: 'live.vod.ready' }));
++ })();
++ assert.strictEqual(outbox.pending(), 1);
++ r = await outbox.flush();
++ assert.strictEqual(r.sent, 1);
++ r = await outbox.flush();
++ assert.strictEqual(r.sent, 0, 'sent rows are not sent again');
++ assert.strictEqual(countIn(committed.event_id), 1);
++
++ // Relay crashed after Events accepted but before the row was marked: at-least-once resend is a duplicate.
++ db.prepare('UPDATE event_outbox SET sent_at = NULL WHERE event_id = ?').run(committed.event_id);
++ r = await outbox.flush();
++ assert.strictEqual(r.sent, 1);
++ assert.strictEqual(countIn(committed.event_id), 1, 'still exactly one event');
++ assert.ok(db.prepare('SELECT seq FROM event_outbox WHERE event_id = ?').get(committed.event_id).seq > 0);
++ db.close();
++});
++
++t('outbox: a poisoned row is isolated and rejected; a down Events is retried later', async () => {
++ const db = new Database(path.join(tmpDir(), 'producer2.db'));
++ let clock = Date.now();
++ const outbox = events.createOutbox(db, { publisher: events.createPublisher({ eventsUrl: h.base, tokenClient }), now: () => clock });
++ outbox.ensureSchema();
++ let good; let bad;
++ db.transaction(() => {
++ good = outbox.enqueue(envelope());
++ bad = outbox.enqueue(envelope('media')); // live may not publish as media
++ })();
++ const r = await outbox.flush();
++ assert.deepStrictEqual(r, { sent: 1, failed: 0, rejected: 1 });
++ assert.strictEqual(countIn(good.event_id), 1);
++ assert.ok(db.prepare('SELECT rejected_at FROM event_outbox WHERE event_id = ?').get(bad.event_id).rejected_at);
++
++ const down = events.createOutbox(db, { publisher: events.createPublisher({ eventsUrl: 'http://127.0.0.1:9', tokenClient }), now: () => clock });
++ let later;
++ db.transaction(() => { later = down.enqueue(envelope()); })();
++ const d = await down.flush();
++ assert.deepStrictEqual(d, { sent: 0, failed: 1, rejected: 0 });
++ assert.strictEqual(down.pending(), 1);
++ const row = db.prepare('SELECT * FROM event_outbox WHERE event_id = ?').get(later.event_id);
++ assert.strictEqual(row.next_attempt_at, clock + 1000, 'backoff after the first failure');
++ // Events is back: the regular relay picks it up once due.
++ clock += 1000;
++ assert.strictEqual((await outbox.flush()).sent, 1);
++ assert.strictEqual(countIn(later.event_id), 1);
++ db.close();
++});
++
++t('consumer crash mid-processing + redelivery + replay -> exactly one effect (createInbox)', async () => {
++ const consumerDb = new Database(path.join(tmpDir(), 'consumer.db'));
++ consumerDb.exec('CREATE TABLE credits (id INTEGER PRIMARY KEY AUTOINCREMENT, event_id TEXT, amount INTEGER)');
++ const inbox = events.createInbox(consumerDb);
++ inbox.ensureSchema();
++ let secret = null;
++ let mode = 'crash-after-commit';
++ const stub = await subscriber((call) => {
++ if (!events.verifyDelivery(call.rawBody, call.headers['x-openvibe-signature'], secret)) return 401;
++ const ev = call.body.event;
++ if (mode === 'crash-before-commit') {
++ mode = 'ok';
++ try {
++ inbox.once('media', ev.event_id, () => {
++ consumerDb.prepare('INSERT INTO credits (event_id, amount) VALUES (?, ?)').run(ev.event_id, ev.payload.amount);
++ throw new Error('process died mid-transaction');
++ });
++ } catch { /* the transaction rolled back */ }
++ return 'destroy';
++ }
++ inbox.once('media', ev.event_id, () => {
++ consumerDb.prepare('INSERT INTO credits (event_id, amount) VALUES (?, ?)').run(ev.event_id, ev.payload.amount);
++ });
++ if (mode === 'crash-after-commit') { mode = 'ok'; return 'destroy'; } // effect committed, no response sent
++ return 204;
++ });
++ const media = serviceToken('media', ['events.subscribe']);
++ const sub = (await request(h.base, 'POST', '/api/v1/subscriptions', { token: media, body: { topic_pattern: 'live.tip.*', endpoint: stub.url } })).body;
++ secret = sub.secret;
++ const live = serviceToken('live', ['events.publish']);
++ const effects = (id) => consumerDb.prepare('SELECT COUNT(*) AS n FROM credits WHERE event_id = ?').get(id).n;
++
++ for (const first of ['crash-after-commit', 'crash-before-commit']) {
++ mode = first;
++ const env = envelope('live', { event_type: 'live.tip.sent', payload: { amount: 5 } });
++ await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ await h.worker.drain();
++ const d = h.store.getDelivery(env.event_id, sub.id);
++ assert.strictEqual(d.status, 'failed', `${first}: the crashed attempt is retried`);
++ assert.strictEqual(effects(env.event_id), first === 'crash-after-commit' ? 1 : 0);
++ h.clock.advance(1000);
++ await h.worker.drain();
++ assert.strictEqual(h.store.getDelivery(env.event_id, sub.id).status, 'delivered');
++ assert.strictEqual(effects(env.event_id), 1, `${first}: exactly one effect after redelivery`);
++
++ const rp = await request(h.base, 'POST', '/api/v1/deliveries/replay', { token: admin, body: { subscription_id: sub.id, event_ids: [env.event_id] } });
++ assert.strictEqual(rp.body.queued, 1);
++ await h.worker.drain();
++ assert.strictEqual(effects(env.event_id), 1, `${first}: replay does not repeat the effect`);
++ }
++ assert.throws(() => inbox.once('media', 'evt_x', () => Promise.resolve()), /synchronous/);
++ assert.strictEqual(inbox.seen('media', 'evt_x'), false, 'an async fn is rolled back, not recorded');
++ await stub.close();
++ consumerDb.close();
++});
++
++t('stop', async () => { await h.stop(); network.close(); });
++
++t.run();
ddiff --git a/test/publish.test.js b/test/publish.test.js
nnew file mode 100644
iindex 0000000..1eb875d
---- /dev/null
++++ b/test/publish.test.js
@@@ -0,0 +1,170 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, request, serviceToken, envelope, suite } = require('./helpers');
++
++const t = suite('publish');
++let h;
++const live = serviceToken('live', ['events.publish']);
++
++t('boot', async () => { h = await boot(); });
++
++t('no token -> 401, wrong audience -> 401, missing capability -> 403', async () => {
++ let r = await request(h.base, 'POST', '/api/v1/events', { body: envelope() });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.body.code, 'token.missing');
++ assert.match(r.headers.get('content-type'), /problem\+json/);
++ r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('live', ['events.publish'], { aud: 'openvibe.media' }), body: envelope() });
++ assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.body.code, 'token.wrong_audience');
++ r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('live', ['events.read']), body: envelope() });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'capability.denied');
++});
++
++t('a family grant (events.*) covers events.publish', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('live', ['events.*']), body: envelope() });
++ assert.strictEqual(r.status, 201);
++});
++
++t('valid envelope -> 201 with a seq; defaults filled', async () => {
++ const env = envelope('live');
++ delete env.priority; delete env.visibility;
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: env, headers: { traceparent: '00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.body.event_id, env.event_id);
++ assert.ok(Number.isInteger(r.body.seq) && r.body.seq > 0);
++ assert.strictEqual(r.body.duplicate, false);
++ const row = h.store.getEvent(env.event_id);
++ assert.strictEqual(row.priority, 'important');
++ assert.strictEqual(row.visibility, 'internal');
++ assert.strictEqual(row.trace_id, '0af7651916cd43dd8448eb211c80319c', 'trace taken from traceparent');
++ assert.strictEqual(row.publisher, 'svc:live');
++});
++
++t('bad envelope -> 422 events.invalid_envelope with errors', async () => {
++ for (const bad of [
++ { ...envelope(), event_id: 'nope' },
++ { ...envelope(), event_type: 'live.started' }, // needs 3+ segments
++ { ...envelope(), actor: { type: 'user', id: 57 } },
++ { ...envelope(), extra: true },
++ (() => { const e = envelope(); delete e.subject; return e; })(),
++ { ...envelope(), visibility: 'everyone' },
++ ]) {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: bad });
++ assert.strictEqual(r.status, 422, JSON.stringify(bad));
++ assert.strictEqual(r.body.code, 'events.invalid_envelope');
++ }
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, raw: '{not json', headers: { 'Content-Type': 'application/json' } });
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'events.bad_json');
++});
++
++t('source must be the calling service', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('media') });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'events.source_mismatch');
++});
++
++t('event_type must use a prefix the source owns', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'media.vod.ready' }) });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'events.type_not_allowed');
++});
++
++t('unknown source (not in the manifest map) -> 403', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: serviceToken('stranger', ['events.publish']), body: envelope('stranger') });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.code, 'events.unknown_source');
++});
++
++t('app principals cannot publish', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', {
++ token: serviceToken('x', ['events.publish'], { sub: `app:${ids.newId('app')}` }), body: envelope('live'),
++ });
++ assert.strictEqual(r.status, 403);
++});
++
++t('idempotent repeat: same event_id -> 200, same seq, stored once', async () => {
++ const env = envelope();
++ const a = await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ const b = await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ assert.strictEqual(a.status, 201);
++ assert.strictEqual(b.status, 200);
++ assert.strictEqual(b.body.duplicate, true);
++ assert.strictEqual(b.body.seq, a.body.seq);
++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM events WHERE id = ?').get(env.event_id).n, 1);
++ const c = await request(h.base, 'POST', '/api/v1/events', { token: live, body: { ...env, event_type: 'live.stream.ended' } });
++ assert.strictEqual(c.status, 409);
++ assert.strictEqual(c.body.code, 'events.id_conflict');
++});
++
++t('batch: atomic, <= 100, results per event', async () => {
++ const events = [envelope(), envelope(), envelope()];
++ let r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: { events } });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(r.body.results.length, 3);
++ assert.ok(r.body.results[0].seq < r.body.results[1].seq && r.body.results[1].seq < r.body.results[2].seq);
++
++ const before = h.store.lastSeq();
++ r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: { events: [envelope(), envelope('media')] } });
++ assert.strictEqual(r.status, 403);
++ assert.strictEqual(r.body.index, 1);
++ assert.strictEqual(h.store.lastSeq(), before, 'nothing of a rejected batch is stored');
++
++ r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: { events: Array.from({ length: 101 }, () => envelope()) } });
++ assert.strictEqual(r.status, 413);
++ assert.strictEqual(r.body.code, 'events.batch_too_large');
++});
++
++t('payload size limit', async () => {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { payload: { big: 'x'.repeat(70 * 1024) } }) });
++ assert.strictEqual(r.status, 413);
++ assert.strictEqual(r.body.code, 'events.payload_too_large');
++});
++
++t('loop guard: a trace bouncing between services is refused at 8 hops', async () => {
++ const trace = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa';
++ const media = serviceToken('media', ['events.publish']);
++ let last;
++ for (let i = 0; i < 9; i++) {
++ const src = i % 2 ? 'media' : 'live';
++ last = await request(h.base, 'POST', '/api/v1/events', {
++ token: src === 'live' ? live : media,
++ body: envelope(src, { event_type: `${src}.ping.pong`, trace_id: trace }),
++ });
++ if (i < 8) assert.strictEqual(last.status, 201, `hop ${i + 1}: ${last.text}`);
++ }
++ assert.strictEqual(last.status, 409);
++ assert.strictEqual(last.body.code, 'events.loop_detected');
++ assert.strictEqual(last.body.max_hops, 8);
++});
++
++t('loop guard: one service re-triggering itself on the same subject', async () => {
++ const trace = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb';
++ let last;
++ for (let i = 0; i < 9; i++) {
++ last = await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.chat.echoed', trace_id: trace }) });
++ }
++ assert.strictEqual(last.status, 409);
++ assert.strictEqual(last.body.code, 'events.loop_detected');
++ // …but a batch of siblings in one request is not a loop.
++ const siblings = Array.from({ length: 20 }, () => envelope('live', { event_type: 'live.chat.imported', trace_id: 'cccccccccccccccccccccccccccccccc' }));
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: { events: siblings } });
++ assert.strictEqual(r.status, 201, r.text);
++});
++
++t('publish receipts outlive retention: a pruned id is still a duplicate', async () => {
++ const env = envelope();
++ await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ h.store.prune({ retentionDays: 30, receiptRetentionDays: 90, now: Date.now() + 31 * 86400000 });
++ assert.strictEqual(h.store.getEvent(env.event_id), null);
++ const r = await request(h.base, 'POST', '/api/v1/events', { token: live, body: env });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.body.duplicate, true);
++ assert.strictEqual(r.body.pruned, true);
++});
++
++t('stop', async () => { await h.stop(); });
++
++t.run();
ddiff --git a/test/pull-health.test.js b/test/pull-health.test.js
nnew file mode 100644
iindex 0000000..6477925
---- /dev/null
++++ b/test/pull-health.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++const assert = require('assert');
++const crypto = require('crypto');
++const nodeHttp = require('http');
++const topics = require('../server/topics');
++const { hasCap, allows } = require('../server/auth');
++const { load } = require('../server/config');
++const { boot, request, serviceToken, envelope, publicKey, suite, sleep } = require('./helpers');
++
++const t = suite('pull-health');
++let h;
++const live = serviceToken('live', ['events.publish']);
++const reader = serviceToken('games', ['events.read']);
++
++t('topic patterns: * spans one or more segments', () => {
++ const yes = [['media.vod.*', 'media.vod.ready'], ['media.vod.*', 'media.vod.clip.cut'], ['*.created', 'community.post.created'],
++ ['media.*.ready', 'media.vod.ready'], ['media.*.ready', 'media.a.b.ready'], ['*', 'live.stream.started'], ['live.stream.started', 'live.stream.started']];
++ const no = [['media.vod.*', 'media.vod'], ['media.vod.*', 'media.vodx.ready'], ['*.created', 'community.post.created_at'],
++ ['live.stream.started', 'live.stream.started.x'], ['media.*.ready', 'media.ready']];
++ for (const [p, e] of yes) assert.ok(topics.matches(p, e), `${p} ~ ${e}`);
++ for (const [p, e] of no) assert.ok(!topics.matches(p, e), `${p} !~ ${e}`);
++ for (const bad of ['', 'Media.x', 'a..b', 'a.*.*', 'a.b*', 'a/b', 'x'.repeat(201)]) assert.ok(!topics.isValidPattern(bad), bad);
++});
++
++t('capability grants: exact or family wildcard; contracts decide ids they know', () => {
++ assert.ok(hasCap({ cap: ['events.publish'] }, 'events.publish'));
++ assert.ok(hasCap({ cap: ['events.*'] }, 'events.admin'));
++ assert.ok(!hasCap({ cap: ['events.pub*'] }, 'events.publish'));
++ assert.ok(!hasCap({ cap: ['event.*'] }, 'events.publish'));
++ assert.ok(!hasCap({}, 'events.read'));
++ assert.strictEqual(allows({ cap: ['events.read'] }, 'events.read').allowed, true);
++ assert.strictEqual(allows({ cap: ['media.object.upload'] }, 'media.object.upload').allowed, true, 'known ids go through contracts');
++});
++
++t('config: every source prefix must start with the source', () => {
++ assert.throws(() => load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ live: ['media.'] }) }), /must start with "live\."/);
++ const c = load({ EVENTS_SOURCE_PREFIXES: JSON.stringify({ live: ['live.stream.'] }) });
++ assert.deepStrictEqual(c.sourcePrefixes, { live: ['live.stream.'] });
++ assert.strictEqual(load({}).port, 4300);
++ assert.strictEqual(load({}).retentionDays, 30);
++});
++
++t('boot', async () => { h = await boot(); });
++
++t('pull: cursor, topic filter, internal events included for services', async () => {
++ const s1 = (await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.vod.ready' }) })).body.seq;
++ await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.chat.sent' }) });
++ const s3 = (await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope('live', { event_type: 'live.vod.deleted' }) })).body.seq;
++
++ let r = await request(h.base, 'GET', '/api/v1/events?topic=live.vod.*&after_seq=0&limit=1', { token: reader });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual(r.body.events.map(e => e.seq), [s1]);
++ assert.strictEqual(r.body.events[0].event.visibility, 'internal');
++ r = await request(h.base, 'GET', `/api/v1/events?topic=live.vod.*&after_seq=${r.body.next_after_seq}`, { token: reader });
++ assert.deepStrictEqual(r.body.events.map(e => e.seq), [s3]);
++ assert.strictEqual(r.body.next_after_seq, s3);
++ r = await request(h.base, 'GET', `/api/v1/events?topic=live.vod.*&after_seq=${r.body.next_after_seq}`, { token: reader });
++ assert.deepStrictEqual(r.body.events, []);
++ assert.strictEqual(r.body.latest_seq, s3);
++
++ const id3 = h.db.prepare('SELECT id FROM events WHERE seq = ?').get(s3).id;
++ r = await request(h.base, 'GET', `/api/v1/events/${id3}`, { token: reader });
++ assert.strictEqual(r.body.seq, s3);
++ r = await request(h.base, 'GET', '/api/v1/events?topic=live.*', { token: live });
++ assert.strictEqual(r.status, 403, 'events.read is required');
++ r = await request(h.base, 'GET', '/api/v1/events?limit=5000', { token: reader });
++ assert.strictEqual(r.status, 400);
++});
++
++t('pull: a cursor older than retention reports the gap', async () => {
++ const before = h.store.lastSeq();
++ h.store.prune({ retentionDays: 30, now: Date.now() + 31 * 86400000 });
++ const s = (await request(h.base, 'POST', '/api/v1/events', { token: live, body: envelope() })).body.seq;
++ const r = await request(h.base, 'GET', '/api/v1/events?after_seq=1', { token: reader });
++ assert.deepStrictEqual(r.body.gap, { from_seq: 2, to_seq: before });
++ assert.deepStrictEqual(r.body.events.map(e => e.seq), [s]);
++});
++
++t('checkpoints: per consumer and topic', async () => {
++ let r = await request(h.base, 'GET', '/api/v1/checkpoints?topic=live.vod.*', { token: reader });
++ assert.strictEqual(r.body.cursor, 0);
++ r = await request(h.base, 'PUT', '/api/v1/checkpoints', { token: reader, body: { topic: 'live.vod.*', cursor: 42 } });
++ assert.strictEqual(r.body.cursor, 42);
++ r = await request(h.base, 'GET', '/api/v1/checkpoints?topic=live.vod.*', { token: reader });
++ assert.strictEqual(r.body.cursor, 42);
++ assert.strictEqual(r.body.consumer, 'games');
++ r = await request(h.base, 'GET', '/api/v1/checkpoints?topic=live.vod.*', { token: serviceToken('tools', ['events.read']) });
++ assert.strictEqual(r.body.cursor, 0, 'another consumer has its own');
++});
++
++t('health and ready', async () => {
++ let r = await request(h.base, 'GET', '/api/health');
++ assert.deepStrictEqual([r.status, r.body.status, r.body.service], [200, 'ok', 'openvibe-events']);
++ r = await request(h.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(r.body.checks, { db: true, worker: true, key: true });
++ r = await request(h.base, 'GET', '/nope');
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(r.body.code, 'events.not_found');
++ assert.ok(r.headers.get('x-openvibe-request-id'));
++});
++
++t('stop', async () => { await h.stop(); });
++
++t('key loader: JWKS {keys:[jwk]} and legacy {public_key}; ready is 503 until it loads', async () => {
++ const jwk = crypto.createPublicKey(publicKey).export({ format: 'jwk' });
++ let shape = 'none';
++ const net = nodeHttp.createServer((req, res) => {
++ if (req.url !== '/api/.well-known/jwks' || shape === 'none') { res.statusCode = 503; return res.end(); }
++ res.setHeader('Content-Type', 'application/json');
++ res.end(JSON.stringify(shape === 'jwks' ? { keys: [{ ...jwk, alg: 'RS256', kid: 'k1' }] } : { public_key: publicKey, algorithm: 'RS256' }));
++ });
++ await new Promise(r => net.listen(0, '127.0.0.1', r));
++ const url = `http://127.0.0.1:${net.address().port}`;
++ for (const s of ['jwks', 'pem']) {
++ shape = 'none';
++ const x = await boot({ env: { OV_NETWORK_PUBLIC_KEY: '', OV_NETWORK_INTERNAL_URL: url, OV_NETWORK_URL: url, OV_NETWORK_ISSUER: 'https://openvibe.network' }, worker: 'on' });
++ await x.keyLoaded;
++ let r = await request(x.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.body.checks.key, false);
++ r = await request(x.base, 'POST', '/api/v1/events', { token: live, body: envelope() });
++ assert.strictEqual(r.status, 503, 'no key yet: service unavailable, not unauthorized');
++ shape = s;
++ await x.keys.fetchOnce();
++ r = await request(x.base, 'GET', '/api/ready');
++ assert.strictEqual(r.status, 200, s);
++ r = await request(x.base, 'POST', '/api/v1/events', { token: live, body: envelope() });
++ assert.strictEqual(r.status, 201, s);
++ await x.stop();
++ }
++ net.close();
++ await sleep(10);
++});
++
++t.run();
ddiff --git a/test/realtime.test.js b/test/realtime.test.js
nnew file mode 100644
iindex 0000000..c95acd8
---- /dev/null
++++ b/test/realtime.test.js
@@@ -0,0 +1,165 @@
++'use strict';
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { boot, request, serviceToken, userToken, envelope, sse, sleep, suite } = require('./helpers');
++
++const t = suite('realtime');
++let h;
++const live = serviceToken('live', ['events.publish']);
++const network = serviceToken('network', ['events.publish']);
++const alice = ids.newId('user');
++const bob = ids.newId('user');
++const open = [];
++
++async function stream(q, headers) {
++ const c = await sse(h.base, `/realtime/stream?${q}`, { headers });
++ open.push(c);
++ return c;
++}
++async function publish(token, env) {
++ const r = await request(h.base, 'POST', '/api/v1/events', { token, body: env });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.body.seq;
++}
++const quiet = () => sleep(150);
++
++t('boot', async () => { h = await boot({ env: { REALTIME_HEARTBEAT_MS: '100' } }); });
++
++t('visibility: public to everyone, subject only to that user, internal only to services', async () => {
++ const anon = await stream('topics=live.stream.*,network.notification.*');
++ const a = await stream('topics=live.stream.*,network.notification.*', { Cookie: `theme=x; ov_token=${userToken({ subjectId: alice })}` });
++ const b = await stream('topics=live.stream.*,network.notification.*', { Authorization: `Bearer ${userToken({ subjectId: bob })}` });
++ const svc = await stream('topics=live.stream.*,network.notification.*', { Authorization: `Bearer ${serviceToken('live', ['events.read'])}` });
++ assert.strictEqual(anon.status, 200);
++ assert.match(anon.headers['content-type'], /text\/event-stream/);
++ await sleep(50);
++ assert.ok(a.comments.includes('connected user'));
++ assert.ok(svc.comments.includes('connected service'));
++
++ const pub = await publish(live, envelope('live', { visibility: 'public' }));
++ const internal = await publish(live, envelope('live', { visibility: 'internal' }));
++ const toAliceAsSubject = await publish(network, envelope('network', {
++ event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: alice }, payload: { text: 'hi alice' },
++ }));
++ const byAliceAsActor = await publish(live, envelope('live', { visibility: 'subject', actor: { type: 'user', id: alice }, event_type: 'live.stream.followed' }));
++ await svc.waitFor(c => c.events().length === 4);
++ await quiet();
++
++ const seqs = (c) => c.events().map(e => e.seq);
++ assert.deepStrictEqual(seqs(anon), [pub]);
++ assert.deepStrictEqual(seqs(a), [pub, toAliceAsSubject, byAliceAsActor]);
++ assert.deepStrictEqual(seqs(b), [pub], 'bob sees nothing addressed to alice');
++ assert.deepStrictEqual(seqs(svc), [pub, internal, toAliceAsSubject, byAliceAsActor]);
++ assert.strictEqual(a.messages.find(m => m.data.includes('hi alice')).id, String(toAliceAsSubject), 'SSE id is the seq');
++ for (const c of [anon, a, b, svc]) c.close();
++});
++
++t('a guessed private topic yields no data', async () => {
++ const guess = await stream('topics=network.notification.*,*', { Authorization: `Bearer ${userToken({ subjectId: bob })}` });
++ const anon = await stream('topics=*');
++ await publish(network, envelope('network', { event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: alice } }));
++ await publish(network, envelope('network', { event_type: 'network.wallet.credited', visibility: 'internal' }));
++ await quiet();
++ assert.deepStrictEqual(guess.events(), []);
++ assert.deepStrictEqual(anon.events(), []);
++ guess.close(); anon.close();
++});
++
++t('Last-Event-ID resume replays what was missed, in order, then goes live', async () => {
++ const first = await publish(live, envelope('live', { visibility: 'public', event_type: 'live.stream.a' }));
++ const missed1 = await publish(live, envelope('live', { visibility: 'public', event_type: 'live.stream.b' }));
++ await publish(live, envelope('live', { visibility: 'public', event_type: 'live.chat.not_subscribed' }));
++ await publish(live, envelope('live', { visibility: 'internal', event_type: 'live.stream.c' }));
++ const missed2 = await publish(live, envelope('live', { visibility: 'public', event_type: 'live.stream.d' }));
++ const c = await stream('topics=live.stream.*', { 'Last-Event-ID': String(first) });
++ await c.waitFor(x => x.events().length === 2);
++ assert.deepStrictEqual(c.events().map(e => e.seq), [missed1, missed2]);
++ assert.deepStrictEqual(c.gaps(), []);
++ const liveSeq = await publish(live, envelope('live', { visibility: 'public', event_type: 'live.stream.e' }));
++ await c.waitFor(x => x.events().length === 3);
++ assert.strictEqual(c.events()[2].seq, liveSeq);
++ c.close();
++ // Query-string form for clients that cannot set headers.
++ const q = await stream(`topics=live.stream.*&last_event_id=${missed2}`);
++ await q.waitFor(x => x.events().length === 1);
++ assert.strictEqual(q.events()[0].seq, liveSeq);
++ q.close();
++});
++
++t('a cursor older than retention gets `event: gap` first', async () => {
++ const old = await publish(live, envelope('live', { visibility: 'public' }));
++ h.store.prune({ retentionDays: 30, now: Date.now() + 31 * 86400000 });
++ const kept = await publish(live, envelope('live', { visibility: 'public' }));
++ const c = await stream('topics=live.stream.*', { 'Last-Event-ID': String(old - 1) });
++ await c.waitFor(x => x.events().length === 1);
++ const gaps = c.gaps();
++ assert.strictEqual(gaps.length, 1);
++ assert.strictEqual(gaps[0].reason, 'retention');
++ assert.strictEqual(gaps[0].from_seq, old);
++ assert.strictEqual(gaps[0].to_seq, kept - 1);
++ assert.ok(c.messages.findIndex(m => m.event === 'gap') < c.messages.findIndex(m => !m.event), 'gap comes before events');
++ assert.strictEqual(c.events()[0].seq, kept);
++ c.close();
++ const ahead = await stream('topics=live.stream.*', { 'Last-Event-ID': String(kept + 1000) });
++ await ahead.waitFor(x => x.gaps().length === 1);
++ assert.strictEqual(ahead.gaps()[0].reason, 'cursor_ahead');
++ ahead.close();
++});
++
++t('heartbeat comments keep the stream alive', async () => {
++ const c = await stream('topics=live.stream.*');
++ await c.waitFor(x => x.comments.includes('hb'), 1000);
++ c.close();
++});
++
++t('limits and auth errors', async () => {
++ const topics = Array.from({ length: 21 }, (_, i) => `live.t${i}.*`).join(',');
++ let r = await request(h.base, 'GET', `/realtime/stream?topics=${topics}`);
++ assert.strictEqual(r.status, 400);
++ assert.strictEqual(r.body.code, 'realtime.too_many_topics');
++ r = await request(h.base, 'GET', '/realtime/stream?topics=Live..x');
++ assert.strictEqual(r.status, 400);
++ r = await request(h.base, 'GET', '/realtime/stream');
++ assert.strictEqual(r.status, 400);
++ r = await request(h.base, 'GET', '/realtime/stream?topics=live.*', { headers: { Authorization: 'Bearer not.a.token' } });
++ assert.strictEqual(r.status, 401, 'a bad Bearer is refused, not downgraded');
++ r = await request(h.base, 'GET', '/realtime/stream?topics=live.*', { token: serviceToken('live', ['events.publish']) });
++ assert.strictEqual(r.status, 403, 'service tokens need events.read');
++ const expired = await stream('topics=live.*', { Cookie: `ov_token=${userToken({ exp: Math.floor(Date.now() / 1000) - 3600 })}` });
++ await sleep(30);
++ assert.ok(expired.comments.includes('connected anonymous'), 'an expired cookie degrades to anonymous');
++ expired.close();
++});
++
++t('CORS: credentials only for https://*.openvibe.* origins', async () => {
++ let r = await fetch(`${h.base}/realtime/stream?topics=live.*`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.live', 'Access-Control-Request-Method': 'GET' } });
++ assert.strictEqual(r.status, 204);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), 'https://openvibe.live');
++ assert.strictEqual(r.headers.get('access-control-allow-credentials'), 'true');
++ for (const origin of ['https://evil.example', 'http://openvibe.live', 'https://openvibe.live.evil.example']) {
++ r = await fetch(`${h.base}/realtime/stream?topics=live.*`, { method: 'OPTIONS', headers: { Origin: origin } });
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), null, origin);
++ }
++ const c = await stream('topics=live.*', { Origin: 'https://live.openvibe.network' });
++ assert.strictEqual(c.headers['access-control-allow-origin'], 'https://live.openvibe.network');
++ c.close();
++});
++
++t('global connection cap', async () => {
++ const capped = await boot({ env: { REALTIME_MAX_CONNECTIONS: '2' } });
++ const c1 = await sse(capped.base, '/realtime/stream?topics=live.*');
++ const c2 = await sse(capped.base, '/realtime/stream?topics=live.*');
++ const r = await request(capped.base, 'GET', '/realtime/stream?topics=live.*');
++ assert.strictEqual(r.status, 503);
++ assert.strictEqual(r.body.code, 'realtime.over_capacity');
++ c1.close();
++ await sleep(50);
++ const c3 = await sse(capped.base, '/realtime/stream?topics=live.*');
++ assert.strictEqual(c3.status, 200, 'a closed connection frees its slot');
++ c2.close(); c3.close();
++ await capped.stop();
++});
++
++t('stop', async () => { for (const c of open) c.close(); await h.stop(); });
++
++t.run();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..1e30d26
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,56 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails
++ * if any of them fails. They use temp SQLite databases, generated RSA keys and stub subscribers
++ * on random ports; none of them needs the network or a running OpenVibe.Network.
++ *
++ * npm test # everything
++ * npm test -- publish sse # only files whose name contains one of the words
++ */
++'use strict';
++const { spawn } = require('child_process');
++const fs = require('fs');
++const path = require('path');
++
++const filters = process.argv.slice(2).filter((a) => !a.startsWith('-'));
++const files = fs.readdirSync(__dirname)
++ .filter((f) => f.endsWith('.test.js'))
++ .filter((f) => !filters.length || filters.some((w) => f.includes(w)))
++ .sort();
++const TIMEOUT_MS = 60000;
++
++function runOne(file) {
++ return new Promise((resolve) => {
++ const started = Date.now();
++ const child = spawn(process.execPath, [path.join(__dirname, file)], {
++ cwd: path.join(__dirname, '..'),
++ env: { ...process.env, NODE_ENV: 'test' },
++ stdio: ['ignore', 'pipe', 'pipe'],
++ });
++ let output = '';
++ child.stdout.on('data', (c) => { output += c; });
++ child.stderr.on('data', (c) => { output += c; });
++ const timer = setTimeout(() => { output += `\n[run] timed out after ${TIMEOUT_MS}ms`; child.kill('SIGKILL'); }, TIMEOUT_MS);
++ child.on('close', (code, signal) => {
++ clearTimeout(timer);
++ resolve({ file, ok: code === 0, code: code ?? signal, ms: Date.now() - started, output });
++ });
++ });
++}
++
++(async () => {
++ if (!files.length) { console.error('no test files matched'); process.exit(1); }
++ const results = [];
++ for (const f of files) {
++ const r = await runOne(f);
++ results.push(r);
++ console.log(`${r.ok ? '✓' : '✗'} ${r.file.padEnd(32)} ${String(r.ms).padStart(6)}ms`);
++ }
++ const failed = results.filter((r) => !r.ok);
++ for (const r of failed) {
++ console.log(`\n── ${r.file} (exit ${r.code}) ──`);
++ console.log(r.output.split('\n').slice(-60).join('\n'));
++ }
++ console.log(`\n${results.length - failed.length}/${results.length} test files passed`);
++ process.exit(failed.length ? 1 : 0);
++})();