openvibers/openvibe.events Commits
Events service: durable store, publish API, signed delivery with retry/DLQ/replay, SSE realtime gateway, client library
Changes
nnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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"++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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();nnew file mode 100644@@@ -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);++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,313 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,1360 @@
@@ -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"
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,216 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,418 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,46 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,214 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,170 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,165 @@
@@ -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();
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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);
+})();