openvibers/openvibe.sdk Commits
0.4.0: replay protection for Events webhook deliveries (signature v2)
Changes
@@@ -3,6 +3,35 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.4.0 (2026-09-23)++++Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:++existing callers of `parseDelivery(raw, headers, secret)` and `verifyDelivery()` keep working, and++v1-only deliveries are still accepted until you opt in to `requireV2`.++++- **Events sends v2.** Besides the unchanged `X-OpenVibe-Signature: sha256=<HMAC of the raw body>`,++ every delivery attempt (retries included, each with a fresh time) carries++ `X-OpenVibe-Timestamp: <unix seconds>` and++ `X-OpenVibe-Signature-V2: t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`.++- New `verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = 300, now = Date.now() })`:++ constant-time check of the v2 signature, false when the timestamp is more than `toleranceSec`++ from `now` either way or when `X-OpenVibe-Timestamp` disagrees with `t`. Several `v2=` values++ are allowed (any one may match); a bad `toleranceSec` throws.++- `parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now })`: when a v2++ header is present it must verify and be fresh, and a bad or stale v2 returns `null` (it never++ falls back to v1); without a v2 header, v1 is accepted only while `requireV2` is false. Header++ lookup on a plain object is now case-insensitive. **Behaviour change**: a delivery that carries++ v2 is now refused when your clock is more than 300 s off Events' (keep NTP on), or when the v2++ signature does not verify even if v1 does. Conversely, once v2 verifies, v1 is not consulted:++ a test that blanks only `X-OpenVibe-Signature` on an Events delivery and expects a refusal must++ drop or break `X-OpenVibe-Signature-V2` too.++- New `signDeliveryV2(rawBody, secret, timestamp?)` and `signDeliveryHeaders(rawBody, secret,++ { now })` (the three signature headers, for tests that post deliveries to a consumer).++ `verifyDelivery` and `signDelivery` (v1) are unchanged.++- **Mock Events** `deliverEvents()` / `startDeliveries()` send v1 and v2 headers, signed afresh on++ every attempt, as production does.++- Types and ESM entry points include the new functions.++## 0.3.1 (2026-09-23)The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or@@@ -2,14 +2,14 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.3.1 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.++**Status:** alpha, v0.4.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0```It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.28.0, checked in CI).@@@ -155,7 +155,7 @@ const res = await jobs.file(job.id, 0);| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` || `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` || `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |--| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |++| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` || `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` || `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers || `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |@@@ -203,6 +203,33 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.++### Receiving event webhooks++++OpenVibe.Events POSTs each delivery as `{ event, seq }` with two signatures:++++- `X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (v1). It covers only the body, so a captured delivery verifies forever.++- `X-OpenVibe-Timestamp: <unix seconds>` and `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later.++++The key is the subscription secret. Verify the raw bytes, not re-serialized JSON:++++```js++const { parseDelivery } = require('openvibe-sdk/events');++++app.post('/internal/events', express.raw({ type: 'application/json' }), (req, res) => {++ const d = parseDelivery(req.body, req.headers, process.env.EVENTS_WEBHOOK_SECRET, { requireV2: true });++ if (!d) return res.sendStatus(401);++ inbox.once('my-service', d.event.event_id, () => { /* synchronous db writes */ }); // still dedupe: delivery is at least once++ res.sendStatus(204);++});++```++++`parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec = 300, now })`:++++- If `X-OpenVibe-Signature-V2` is present, it must verify and its timestamp must be within ±`toleranceSec` of `now` (ms, default `Date.now()`). A bad or stale v2 returns `null`. It never falls back to v1.++- If there is no v2 header, v1 is accepted only while `requireV2` is false (the default, so 0.4.0 changes nothing for existing callers).++++Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them.++### Testing your app```js@@@ -222,7 +249,7 @@ The mock answers at the real public origins with real RS256 tokens and checks au- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.--- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.++- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries (v1 and v2 headers, fresh timestamp per attempt) to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.3.1",++ "version": "0.4.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","updated": "2026-09-23"}@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.1, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.4.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.3.1';++const SDK_VERSION = '0.4.0';module.exports = {createClient,@@@ -3,6 +3,6 @@ import cjs from '../src/events.js';export const {createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,-- projectKey, appSource, createAppEvents,++ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.3.1",++ "version": "0.4.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.3.1",++ "version": "0.4.0","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.3.1",++ "version": "0.4.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.3.1';++const SDK_VERSION = '0.4.0';module.exports = {createClient,@@@ -33,13 +33,100 @@ function verifyDelivery(rawBody, signatureHeader, secret) {return given.length === expected.length && crypto.timingSafeEqual(given, expected);}++// v2 (replay window). Events also sends, on every attempt with the time it is sent:++// X-OpenVibe-Timestamp: <unix seconds>++// X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">++// A v1 signature covers only the body, so a captured delivery verifies forever; v2 stops++// verifying once the timestamp is more than `toleranceSec` (300) from the consumer's clock.++++const V2_TOLERANCE_SEC = 300;++++function v2Hex(rawBody, secret, timestamp) {++ return crypto.createHmac('sha256', String(secret)).update(`${timestamp}.`).update(toBuffer(rawBody)).digest('hex');++}++++/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2. */++function signDeliveryV2(rawBody, secret, timestamp = Math.floor(Date.now() / 1000)) {++ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new TypeError('timestamp must be unix seconds');++ return `t=${timestamp},v2=${v2Hex(rawBody, secret, timestamp)}`;++}++++/**++ * The three signature headers Events puts on a delivery (X-OpenVibe-Signature,++ * X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2), for tests that post deliveries to a consumer.++ */++function signDeliveryHeaders(rawBody, secret, { now = Date.now() } = {}) {++ const timestamp = Math.floor(Number(now) / 1000);++ return {++ 'X-OpenVibe-Signature': signDelivery(rawBody, secret),++ 'X-OpenVibe-Timestamp': String(timestamp),++ 'X-OpenVibe-Signature-V2': signDeliveryV2(rawBody, secret, timestamp),++ };++}++++/** One header from req.headers (any key case; arrays joined) or a Fetch Headers; undefined if absent. */++function headerValue(headers, name) {++ if (!headers) return undefined;++ if (typeof headers.get === 'function') return headers.get(name) ?? undefined;++ let v = headers[name];++ if (v === undefined) {++ const key = Object.keys(headers).find((k) => k.toLowerCase() === name);++ v = key === undefined ? undefined : headers[key];++ }++ return Array.isArray(v) ? v.join(',') : v;++}++++/**++ * Constant-time check of a delivery's X-OpenVibe-Signature-V2 against the RAW request body, and++ * of its timestamp: false when it is more than `toleranceSec` from `now` (ms) either way, or when++ * X-OpenVibe-Timestamp is present and names another time. `headers` is req.headers (or a Fetch Headers).++ */++function verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = V2_TOLERANCE_SEC, now = Date.now() } = {}) {++ if (!Number.isFinite(toleranceSec) || toleranceSec < 0) throw new TypeError('toleranceSec must be a number of seconds >= 0');++ const header = headerValue(headers, 'x-openvibe-signature-v2');++ if (rawBody == null || typeof header !== 'string' || !secret) return false;++ let t = null;++ const given = [];++ for (const part of header.split(',')) {++ const i = part.indexOf('=');++ if (i < 0) return false;++ const k = part.slice(0, i).trim();++ const v = part.slice(i + 1).trim();++ if (k === 't') {++ if (t !== null || !/^\d{1,12}$/.test(v)) return false;++ t = Number(v);++ } else if (k === 'v2') given.push(v);++ }++ if (t === null || !given.length) return false;++ const stated = headerValue(headers, 'x-openvibe-timestamp');++ if (stated !== undefined && String(stated).trim() !== String(t)) return false;++ if (Math.abs(Number(now) / 1000 - t) > toleranceSec) return false;++ const expected = Buffer.from(v2Hex(rawBody, secret, t));++ let ok = false;++ for (const v of given) {++ const g = Buffer.from(v);++ if (g.length === expected.length && crypto.timingSafeEqual(g, expected)) ok = true;++ }++ return ok;++}++/**-- * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when the-- * signature does not verify. `headers` is req.headers (or a Fetch Headers).++ * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when it does not++ * verify. `headers` is req.headers (or a Fetch Headers).++ *++ * X-OpenVibe-Signature-V2 present it must verify and be within ±toleranceSec (default 300) of++ * `now`; a bad or stale v2 is null, never a fallback to v1++ * no v2 header v1 (X-OpenVibe-Signature) is accepted unless requireV2++ *++ * Set requireV2: true once OpenVibe.Events sends v2 to you: then a replayed v1-only delivery fails.*/--function parseDelivery(rawBody, headers, secret) {-- const get = (k) => (headers && typeof headers.get === 'function' ? headers.get(k) : headers && headers[k.toLowerCase()]);-- if (!verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) return null;++function parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now } = {}) {++ const get = (k) => headerValue(headers, k);++ if (get('x-openvibe-signature-v2') !== undefined) {++ if (!verifyDeliveryV2(rawBody, headers, secret, { toleranceSec, now })) return null;++ } else if (requireV2 || !verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) {++ return null;++ }let body;try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }if (!body || !body.event) return null;@@@ -272,5 +359,5 @@ const { createOutbox, createInbox } = require('./outbox');module.exports = {createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,-- projectKey, appSource, createAppEvents,++ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,};@@@ -35,6 +35,7 @@ const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = requirconst { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');const { createJobsService } = require('./jobs');const appRules = require('./apps');++const { signDeliveryHeaders } = require('../events');const DEFAULT_ORIGINS = {network: 'https://openvibe.network',@@@ -593,8 +594,9 @@ function createMockPlatform(opts = {}) {* deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:* for each enabled subscription, POSTs every event published after it was created (in seq* order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries-- * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>, plus-- * X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,++ * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>;++ * X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">,++ * signed afresh on every attempt; plus X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,* traceparent). A 2xx moves on; anything else stops that subscription until the next call,* and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.* `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.@@@ -625,7 +627,7 @@ function createMockPlatform(opts = {}) {'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock','X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',-- 'X-OpenVibe-Signature': `sha256=${crypto.createHmac('sha256', String(sub.secret)).update(body).digest('hex')}`,++ ...signDeliveryHeaders(body, sub.secret),traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,},});@@@ -5,7 +5,7 @@ const crypto = require('node:crypto');const { stubServer, send, run, waitFor } = require('./helpers');const { createClient, parseTraceparent } = require('../src/core');const { createServiceTokenClient } = require('../src/auth');--const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox } = require('../src/events');++const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders } = require('../src/events');const { subscribe } = require('../src/realtime');const { createMockPlatform } = require('../src/testing');@@@ -132,6 +132,64 @@ run([assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);}],++ ['verifyDeliveryV2 checks X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") and the ±300 s window', async () => {++ const secret = `whsec_${'ab'.repeat(32)}`;++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));++ const now = 1790000000000;++ const t = now / 1000;++ const v2 = signDeliveryV2(raw, secret, t);++ assert.equal(v2, `t=${t},v2=${crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex')}`);++ const headers = { 'x-openvibe-timestamp': String(t), 'x-openvibe-signature-v2': v2 };++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now }), true);++ assert.equal(verifyDeliveryV2(raw.toString(), new Headers(headers), secret, { now }), true, 'Fetch Headers, string body');++ assert.equal(verifyDeliveryV2(new Uint8Array(raw), { 'X-OpenVibe-Signature-V2': v2 }, secret, { now }), true, 'any header case; X-OpenVibe-Timestamp optional');++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300000 }), true, 'edge of the window');++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300000 }), true);++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300001 }), false, 'too old: a replay');++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300001 }), false, 'too far in the future');++ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 600000, toleranceSec: 600 }), true, 'toleranceSec');++ assert.equal(verifyDeliveryV2(raw, headers, secret), false, 'defaults to Date.now()');++ assert.equal(verifyDeliveryV2(raw, headers, 'other-secret', { now }), false);++ assert.equal(verifyDeliveryV2(Buffer.from(raw.toString().replace('7', '8')), headers, secret, { now }), false, 'body changed');++ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-signature-v2': v2.replace(`t=${t}`, `t=${t + 1}`) }, secret, { now }), false, 'timestamp changed');++ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-timestamp': String(t - 1) }, secret, { now }), false, 'X-OpenVibe-Timestamp disagrees with t');++ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': `${v2},v2=${'0'.repeat(64)}` }, secret, { now }), true, 'one of several v2 values');++ const mac = v2.split(',')[1];++ for (const bad of [undefined, '', mac, `t=${t}`, `t=${t},t=${t},${mac}`, `t=-${t},${mac}`, `t=${t}.5,${mac}`, `t=${t},v2=00`, `t=${t},${mac}x`, signDelivery(raw, secret)]) {++ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': bad }, secret, { now }), false, String(bad));++ }++ assert.equal(verifyDeliveryV2(raw, headers, '', { now }), false);++ assert.equal(verifyDeliveryV2(null, headers, secret, { now }), false);++ assert.throws(() => verifyDeliveryV2(raw, headers, secret, { toleranceSec: -1 }), TypeError);++ assert.throws(() => signDeliveryV2(raw, secret, 1.5), TypeError);++ assert.match(signDeliveryV2(raw, secret), /^t=\d{10},v2=[0-9a-f]{64}$/, 'defaults to now');++ const all = signDeliveryHeaders(raw, secret, { now: now + 999 });++ assert.deepEqual(all, { 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': v2 });++ }],++++ ['parseDelivery: a present v2 must verify and be fresh (no v1 fallback); requireV2 refuses v1-only', async () => {++ const secret = `whsec_${'cd'.repeat(32)}`;++ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9 }));++ const now = 1790000000000;++ const signed = signDeliveryHeaders(raw, secret, { now });++ const want = { event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9, subscriptionId: null, attempt: 1 };++ const v1only = { 'x-openvibe-signature': signed['X-OpenVibe-Signature'] };++ // Both headers (what Events sends): v2 decides.++ assert.deepEqual(parseDelivery(raw, signed, secret, { now }), want);++ assert.deepEqual(parseDelivery(raw, signed, secret, { now, requireV2: true }), want);++ assert.deepEqual(parseDelivery(raw, new Headers(signed), secret, { now, requireV2: true }), want);++ assert.equal(parseDelivery(raw, signed, secret, { now: now + 301000 }), null, 'stale v2 with a valid v1: refused, never a v1 fallback');++ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': `t=${now / 1000},v2=${'0'.repeat(64)}` }, secret, { now }), null, 'bad v2 with a valid v1: refused');++ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': '' }, secret, { now }), null, 'an empty v2 header is present and bad');++ assert.equal(parseDelivery(raw, { ...v1only, 'x-openvibe-signature-v2': signed['X-OpenVibe-Signature-V2'] }, secret, { now: now + 200000, toleranceSec: 100 }), null, 'toleranceSec is passed through');++ // No v2 header: v1 only while requireV2 is off.++ assert.deepEqual(parseDelivery(raw, v1only, secret), want, 'v1-only still parses by default (backward compatible)');++ assert.equal(parseDelivery(raw, v1only, secret, { requireV2: true }), null, 'requireV2 refuses a v1-only (replayable) delivery');++ assert.equal(parseDelivery(raw, { 'x-openvibe-signature': 'sha256=bad' }, secret), null);++ // A v2 delivery with a bad v1 still parses: v1 is not consulted once v2 is there.++ assert.deepEqual(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature': 'sha256=bad' }, secret, { now }), want);++ }],++['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {const { platform, events } = setup();for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });@@@ -190,7 +248,7 @@ run([let secret;const handled = [];const srv = await stubServer((req, res, body) => {-- const d = parseDelivery(body, req.headers, secret);++ const d = parseDelivery(body, req.headers, secret, { requireV2: true });if (!d) return send(res, 401, { error: 'bad signature' });if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));@@@ -209,6 +267,9 @@ run([const req = srv.requests.at(-1);assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);++ assert.match(req.headers['x-openvibe-timestamp'], /^\d{10}$/);++ assert.equal(req.headers['x-openvibe-signature-v2'].split(',')[0], `t=${req.headers['x-openvibe-timestamp']}`);++ assert.equal(verifyDeliveryV2(req.body, req.headers, secret), true, 'the mock sends v2 too');assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');@@@ -91,7 +91,27 @@ export declare function createAppEvents(client: OpenVibeClient, opts: { projectItype RawBody = string | Uint8Array | ArrayBufferView;export declare function signDelivery(rawBody: RawBody, secret: string): string;export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;--export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;++/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2 (timestamp in unix seconds, default now). */++export declare function signDeliveryV2(rawBody: RawBody, secret: string, timestamp?: number): string;++/** X-OpenVibe-Signature, X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 for a delivery body, as Events sends them (now in ms). */++export declare function signDeliveryHeaders(rawBody: RawBody, secret: string, opts?: { now?: number }): {++ 'X-OpenVibe-Signature': string;++ 'X-OpenVibe-Timestamp': string;++ 'X-OpenVibe-Signature-V2': string;++};++export interface DeliveryV2Options {++ /** Seconds the timestamp may be away from `now`, either way. Default 300. */++ toleranceSec?: number;++ /** The current time in ms. Default Date.now(). */++ now?: number;++}++/** Constant-time check of X-OpenVibe-Signature-V2 over the raw body, and of its timestamp (±toleranceSec). */++export declare function verifyDeliveryV2(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: DeliveryV2Options): boolean;++export interface ParseDeliveryOptions extends DeliveryV2Options {++ /** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only). Default false. A present v2 header must always verify. */++ requireV2?: boolean;++}++export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;/** The subset of a better-sqlite3 Database the outbox and inbox use. */export interface SqliteDatabase {
@@ -3,6 +3,35 @@
@@ -3,6 +3,35 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.4.0 (2026-09-23)
+
+Replay protection for OpenVibe.Events webhook deliveries (signature v2). Backward compatible:
+existing callers of `parseDelivery(raw, headers, secret)` and `verifyDelivery()` keep working, and
+v1-only deliveries are still accepted until you opt in to `requireV2`.
+
+- **Events sends v2.** Besides the unchanged `X-OpenVibe-Signature: sha256=<HMAC of the raw body>`,
+ every delivery attempt (retries included, each with a fresh time) carries
+ `X-OpenVibe-Timestamp: <unix seconds>` and
+ `X-OpenVibe-Signature-V2: t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`.
+- New `verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = 300, now = Date.now() })`:
+ constant-time check of the v2 signature, false when the timestamp is more than `toleranceSec`
+ from `now` either way or when `X-OpenVibe-Timestamp` disagrees with `t`. Several `v2=` values
+ are allowed (any one may match); a bad `toleranceSec` throws.
+- `parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now })`: when a v2
+ header is present it must verify and be fresh, and a bad or stale v2 returns `null` (it never
+ falls back to v1); without a v2 header, v1 is accepted only while `requireV2` is false. Header
+ lookup on a plain object is now case-insensitive. **Behaviour change**: a delivery that carries
+ v2 is now refused when your clock is more than 300 s off Events' (keep NTP on), or when the v2
+ signature does not verify even if v1 does. Conversely, once v2 verifies, v1 is not consulted:
+ a test that blanks only `X-OpenVibe-Signature` on an Events delivery and expects a refusal must
+ drop or break `X-OpenVibe-Signature-V2` too.
+- New `signDeliveryV2(rawBody, secret, timestamp?)` and `signDeliveryHeaders(rawBody, secret,
+ { now })` (the three signature headers, for tests that post deliveries to a consumer).
+ `verifyDelivery` and `signDelivery` (v1) are unchanged.
+- **Mock Events** `deliverEvents()` / `startDeliveries()` send v1 and v2 headers, signed afresh on
+ every attempt, as production does.
+- Types and ESM entry points include the new functions.
+
## 0.3.1 (2026-09-23)
## 0.3.1 (2026-09-23)
The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or
The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.3.1 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.
+**Status:** alpha, v0.4.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; OpenVibe.Examples uses 0.3.0.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0
```
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.28.0, checked in CI).
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.28.0, checked in CI).
@@ -155,7 +155,7 @@ const res = await jobs.file(job.id, 0);
@@ -155,7 +155,7 @@ const res = await jobs.file(job.id, 0);
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
-| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
+| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
@@ -203,6 +203,33 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -203,6 +203,33 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
+### Receiving event webhooks
+
+OpenVibe.Events POSTs each delivery as `{ event, seq }` with two signatures:
+
+- `X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (v1). It covers only the body, so a captured delivery verifies forever.
+- `X-OpenVibe-Timestamp: <unix seconds>` and `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later.
+
+The key is the subscription secret. Verify the raw bytes, not re-serialized JSON:
+
+```js
+const { parseDelivery } = require('openvibe-sdk/events');
+
+app.post('/internal/events', express.raw({ type: 'application/json' }), (req, res) => {
+ const d = parseDelivery(req.body, req.headers, process.env.EVENTS_WEBHOOK_SECRET, { requireV2: true });
+ if (!d) return res.sendStatus(401);
+ inbox.once('my-service', d.event.event_id, () => { /* synchronous db writes */ }); // still dedupe: delivery is at least once
+ res.sendStatus(204);
+});
+```
+
+`parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec = 300, now })`:
+
+- If `X-OpenVibe-Signature-V2` is present, it must verify and its timestamp must be within ±`toleranceSec` of `now` (ms, default `Date.now()`). A bad or stale v2 returns `null`. It never falls back to v1.
+- If there is no v2 header, v1 is accepted only while `requireV2` is false (the default, so 0.4.0 changes nothing for existing callers).
+
+Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them.
+
### Testing your app
### Testing your app
```js
```js
@@ -222,7 +249,7 @@ The mock answers at the real public origins with real RS256 tokens and checks au
@@ -222,7 +249,7 @@ The mock answers at the real public origins with real RS256 tokens and checks au
- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.
- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.
- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).
- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).
- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.
- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.
-- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.
+- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries (v1 and v2 headers, fresh timestamp per attempt) to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.
- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.
- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.
- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.
- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.3.1",
+ "version": "0.4.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"updated": "2026-09-23"
"updated": "2026-09-23"
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.1, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.4.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.3.1';
+const SDK_VERSION = '0.4.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -3,6 +3,6 @@ import cjs from '../src/events.js';
@@ -3,6 +3,6 @@ import cjs from '../src/events.js';
export const {
export const {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
- projectKey, appSource, createAppEvents,
+ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.1",
+ "version": "0.4.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.1",
+ "version": "0.4.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.1",
+ "version": "0.4.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.3.1';
+const SDK_VERSION = '0.4.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -33,13 +33,100 @@ function verifyDelivery(rawBody, signatureHeader, secret) {
@@ -33,13 +33,100 @@ function verifyDelivery(rawBody, signatureHeader, secret) {
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
}
+// v2 (replay window). Events also sends, on every attempt with the time it is sent:
+// X-OpenVibe-Timestamp: <unix seconds>
+// X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">
+// A v1 signature covers only the body, so a captured delivery verifies forever; v2 stops
+// verifying once the timestamp is more than `toleranceSec` (300) from the consumer's clock.
+
+const V2_TOLERANCE_SEC = 300;
+
+function v2Hex(rawBody, secret, timestamp) {
+ return crypto.createHmac('sha256', String(secret)).update(`${timestamp}.`).update(toBuffer(rawBody)).digest('hex');
+}
+
+/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2. */
+function signDeliveryV2(rawBody, secret, timestamp = Math.floor(Date.now() / 1000)) {
+ if (!Number.isSafeInteger(timestamp) || timestamp < 0) throw new TypeError('timestamp must be unix seconds');
+ return `t=${timestamp},v2=${v2Hex(rawBody, secret, timestamp)}`;
+}
+
+/**
+ * The three signature headers Events puts on a delivery (X-OpenVibe-Signature,
+ * X-OpenVibe-Timestamp, X-OpenVibe-Signature-V2), for tests that post deliveries to a consumer.
+ */
+function signDeliveryHeaders(rawBody, secret, { now = Date.now() } = {}) {
+ const timestamp = Math.floor(Number(now) / 1000);
+ return {
+ 'X-OpenVibe-Signature': signDelivery(rawBody, secret),
+ 'X-OpenVibe-Timestamp': String(timestamp),
+ 'X-OpenVibe-Signature-V2': signDeliveryV2(rawBody, secret, timestamp),
+ };
+}
+
+/** One header from req.headers (any key case; arrays joined) or a Fetch Headers; undefined if absent. */
+function headerValue(headers, name) {
+ if (!headers) return undefined;
+ if (typeof headers.get === 'function') return headers.get(name) ?? undefined;
+ let v = headers[name];
+ if (v === undefined) {
+ const key = Object.keys(headers).find((k) => k.toLowerCase() === name);
+ v = key === undefined ? undefined : headers[key];
+ }
+ return Array.isArray(v) ? v.join(',') : v;
+}
+
+/**
+ * Constant-time check of a delivery's X-OpenVibe-Signature-V2 against the RAW request body, and
+ * of its timestamp: false when it is more than `toleranceSec` from `now` (ms) either way, or when
+ * X-OpenVibe-Timestamp is present and names another time. `headers` is req.headers (or a Fetch Headers).
+ */
+function verifyDeliveryV2(rawBody, headers, secret, { toleranceSec = V2_TOLERANCE_SEC, now = Date.now() } = {}) {
+ if (!Number.isFinite(toleranceSec) || toleranceSec < 0) throw new TypeError('toleranceSec must be a number of seconds >= 0');
+ const header = headerValue(headers, 'x-openvibe-signature-v2');
+ if (rawBody == null || typeof header !== 'string' || !secret) return false;
+ let t = null;
+ const given = [];
+ for (const part of header.split(',')) {
+ const i = part.indexOf('=');
+ if (i < 0) return false;
+ const k = part.slice(0, i).trim();
+ const v = part.slice(i + 1).trim();
+ if (k === 't') {
+ if (t !== null || !/^\d{1,12}$/.test(v)) return false;
+ t = Number(v);
+ } else if (k === 'v2') given.push(v);
+ }
+ if (t === null || !given.length) return false;
+ const stated = headerValue(headers, 'x-openvibe-timestamp');
+ if (stated !== undefined && String(stated).trim() !== String(t)) return false;
+ if (Math.abs(Number(now) / 1000 - t) > toleranceSec) return false;
+ const expected = Buffer.from(v2Hex(rawBody, secret, t));
+ let ok = false;
+ for (const v of given) {
+ const g = Buffer.from(v);
+ if (g.length === expected.length && crypto.timingSafeEqual(g, expected)) ok = true;
+ }
+ return ok;
+}
+
/**
/**
- * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when the
- * signature does not verify. `headers` is req.headers (or a Fetch Headers).
+ * Verify and parse one delivery: { event, seq, subscriptionId, attempt } or null when it does not
+ * verify. `headers` is req.headers (or a Fetch Headers).
+ *
+ * X-OpenVibe-Signature-V2 present it must verify and be within ±toleranceSec (default 300) of
+ * `now`; a bad or stale v2 is null, never a fallback to v1
+ * no v2 header v1 (X-OpenVibe-Signature) is accepted unless requireV2
+ *
+ * Set requireV2: true once OpenVibe.Events sends v2 to you: then a replayed v1-only delivery fails.
*/
*/
-function parseDelivery(rawBody, headers, secret) {
- const get = (k) => (headers && typeof headers.get === 'function' ? headers.get(k) : headers && headers[k.toLowerCase()]);
- if (!verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) return null;
+function parseDelivery(rawBody, headers, secret, { requireV2 = false, toleranceSec, now } = {}) {
+ const get = (k) => headerValue(headers, k);
+ if (get('x-openvibe-signature-v2') !== undefined) {
+ if (!verifyDeliveryV2(rawBody, headers, secret, { toleranceSec, now })) return null;
+ } else if (requireV2 || !verifyDelivery(rawBody, get('x-openvibe-signature'), secret)) {
+ return null;
+ }
let body;
let body;
try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }
try { body = JSON.parse(toBuffer(rawBody).toString('utf8')); } catch { return null; }
if (!body || !body.event) return null;
if (!body || !body.event) return null;
@@ -272,5 +359,5 @@ const { createOutbox, createInbox } = require('./outbox');
@@ -272,5 +359,5 @@ const { createOutbox, createInbox } = require('./outbox');
module.exports = {
module.exports = {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
- projectKey, appSource, createAppEvents,
+ projectKey, appSource, createAppEvents, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders,
};
};
@@ -35,6 +35,7 @@ const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = requir
@@ -35,6 +35,7 @@ const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = requir
const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');
const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');
const { createJobsService } = require('./jobs');
const { createJobsService } = require('./jobs');
const appRules = require('./apps');
const appRules = require('./apps');
+const { signDeliveryHeaders } = require('../events');
const DEFAULT_ORIGINS = {
const DEFAULT_ORIGINS = {
network: 'https://openvibe.network',
network: 'https://openvibe.network',
@@ -593,8 +594,9 @@ function createMockPlatform(opts = {}) {
@@ -593,8 +594,9 @@ function createMockPlatform(opts = {}) {
* deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:
* deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:
* for each enabled subscription, POSTs every event published after it was created (in seq
* for each enabled subscription, POSTs every event published after it was created (in seq
* order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries
* order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries
- * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>, plus
- * X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
+ * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>;
+ * X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2: t=<ts>,v2=<HMAC of "<ts>.<raw body>">,
+ * signed afresh on every attempt; plus X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
* traceparent). A 2xx moves on; anything else stops that subscription until the next call,
* traceparent). A 2xx moves on; anything else stops that subscription until the next call,
* and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.
* and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.
* `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.
* `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.
@@ -625,7 +627,7 @@ function createMockPlatform(opts = {}) {
@@ -625,7 +627,7 @@ function createMockPlatform(opts = {}) {
'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',
'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',
'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),
'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),
'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',
'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',
- 'X-OpenVibe-Signature': `sha256=${crypto.createHmac('sha256', String(sub.secret)).update(body).digest('hex')}`,
+ ...signDeliveryHeaders(body, sub.secret),
traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,
traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,
},
},
});
});
@@ -5,7 +5,7 @@ const crypto = require('node:crypto');
@@ -5,7 +5,7 @@ const crypto = require('node:crypto');
const { stubServer, send, run, waitFor } = require('./helpers');
const { stubServer, send, run, waitFor } = require('./helpers');
const { createClient, parseTraceparent } = require('../src/core');
const { createClient, parseTraceparent } = require('../src/core');
const { createServiceTokenClient } = require('../src/auth');
const { createServiceTokenClient } = require('../src/auth');
-const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox } = require('../src/events');
+const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox, verifyDeliveryV2, signDeliveryV2, signDeliveryHeaders } = require('../src/events');
const { subscribe } = require('../src/realtime');
const { subscribe } = require('../src/realtime');
const { createMockPlatform } = require('../src/testing');
const { createMockPlatform } = require('../src/testing');
@@ -132,6 +132,64 @@ run([
@@ -132,6 +132,64 @@ run([
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
}],
}],
+ ['verifyDeliveryV2 checks X-OpenVibe-Signature-V2 (HMAC of "<t>.<raw body>") and the ±300 s window', async () => {
+ const secret = `whsec_${'ab'.repeat(32)}`;
+ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7 }));
+ const now = 1790000000000;
+ const t = now / 1000;
+ const v2 = signDeliveryV2(raw, secret, t);
+ assert.equal(v2, `t=${t},v2=${crypto.createHmac('sha256', secret).update(`${t}.${raw}`).digest('hex')}`);
+ const headers = { 'x-openvibe-timestamp': String(t), 'x-openvibe-signature-v2': v2 };
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now }), true);
+ assert.equal(verifyDeliveryV2(raw.toString(), new Headers(headers), secret, { now }), true, 'Fetch Headers, string body');
+ assert.equal(verifyDeliveryV2(new Uint8Array(raw), { 'X-OpenVibe-Signature-V2': v2 }, secret, { now }), true, 'any header case; X-OpenVibe-Timestamp optional');
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300000 }), true, 'edge of the window');
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300000 }), true);
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 300001 }), false, 'too old: a replay');
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now - 300001 }), false, 'too far in the future');
+ assert.equal(verifyDeliveryV2(raw, headers, secret, { now: now + 600000, toleranceSec: 600 }), true, 'toleranceSec');
+ assert.equal(verifyDeliveryV2(raw, headers, secret), false, 'defaults to Date.now()');
+ assert.equal(verifyDeliveryV2(raw, headers, 'other-secret', { now }), false);
+ assert.equal(verifyDeliveryV2(Buffer.from(raw.toString().replace('7', '8')), headers, secret, { now }), false, 'body changed');
+ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-signature-v2': v2.replace(`t=${t}`, `t=${t + 1}`) }, secret, { now }), false, 'timestamp changed');
+ assert.equal(verifyDeliveryV2(raw, { ...headers, 'x-openvibe-timestamp': String(t - 1) }, secret, { now }), false, 'X-OpenVibe-Timestamp disagrees with t');
+ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': `${v2},v2=${'0'.repeat(64)}` }, secret, { now }), true, 'one of several v2 values');
+ const mac = v2.split(',')[1];
+ for (const bad of [undefined, '', mac, `t=${t}`, `t=${t},t=${t},${mac}`, `t=-${t},${mac}`, `t=${t}.5,${mac}`, `t=${t},v2=00`, `t=${t},${mac}x`, signDelivery(raw, secret)]) {
+ assert.equal(verifyDeliveryV2(raw, { 'x-openvibe-signature-v2': bad }, secret, { now }), false, String(bad));
+ }
+ assert.equal(verifyDeliveryV2(raw, headers, '', { now }), false);
+ assert.equal(verifyDeliveryV2(null, headers, secret, { now }), false);
+ assert.throws(() => verifyDeliveryV2(raw, headers, secret, { toleranceSec: -1 }), TypeError);
+ assert.throws(() => signDeliveryV2(raw, secret, 1.5), TypeError);
+ assert.match(signDeliveryV2(raw, secret), /^t=\d{10},v2=[0-9a-f]{64}$/, 'defaults to now');
+ const all = signDeliveryHeaders(raw, secret, { now: now + 999 });
+ assert.deepEqual(all, { 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Timestamp': String(t), 'X-OpenVibe-Signature-V2': v2 });
+ }],
+
+ ['parseDelivery: a present v2 must verify and be fresh (no v1 fallback); requireV2 refuses v1-only', async () => {
+ const secret = `whsec_${'cd'.repeat(32)}`;
+ const raw = Buffer.from(JSON.stringify({ event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9 }));
+ const now = 1790000000000;
+ const signed = signDeliveryHeaders(raw, secret, { now });
+ const want = { event: { event_id: 'evt_2', event_type: 'media.vod.ready' }, seq: 9, subscriptionId: null, attempt: 1 };
+ const v1only = { 'x-openvibe-signature': signed['X-OpenVibe-Signature'] };
+ // Both headers (what Events sends): v2 decides.
+ assert.deepEqual(parseDelivery(raw, signed, secret, { now }), want);
+ assert.deepEqual(parseDelivery(raw, signed, secret, { now, requireV2: true }), want);
+ assert.deepEqual(parseDelivery(raw, new Headers(signed), secret, { now, requireV2: true }), want);
+ assert.equal(parseDelivery(raw, signed, secret, { now: now + 301000 }), null, 'stale v2 with a valid v1: refused, never a v1 fallback');
+ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': `t=${now / 1000},v2=${'0'.repeat(64)}` }, secret, { now }), null, 'bad v2 with a valid v1: refused');
+ assert.equal(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature-V2': '' }, secret, { now }), null, 'an empty v2 header is present and bad');
+ assert.equal(parseDelivery(raw, { ...v1only, 'x-openvibe-signature-v2': signed['X-OpenVibe-Signature-V2'] }, secret, { now: now + 200000, toleranceSec: 100 }), null, 'toleranceSec is passed through');
+ // No v2 header: v1 only while requireV2 is off.
+ assert.deepEqual(parseDelivery(raw, v1only, secret), want, 'v1-only still parses by default (backward compatible)');
+ assert.equal(parseDelivery(raw, v1only, secret, { requireV2: true }), null, 'requireV2 refuses a v1-only (replayable) delivery');
+ assert.equal(parseDelivery(raw, { 'x-openvibe-signature': 'sha256=bad' }, secret), null);
+ // A v2 delivery with a bad v1 still parses: v1 is not consulted once v2 is there.
+ assert.deepEqual(parseDelivery(raw, { ...signed, 'X-OpenVibe-Signature': 'sha256=bad' }, secret, { now }), want);
+ }],
+
['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {
['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {
const { platform, events } = setup();
const { platform, events } = setup();
for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
@@ -190,7 +248,7 @@ run([
@@ -190,7 +248,7 @@ run([
let secret;
let secret;
const handled = [];
const handled = [];
const srv = await stubServer((req, res, body) => {
const srv = await stubServer((req, res, body) => {
- const d = parseDelivery(body, req.headers, secret);
+ const d = parseDelivery(body, req.headers, secret, { requireV2: true });
if (!d) return send(res, 401, { error: 'bad signature' });
if (!d) return send(res, 401, { error: 'bad signature' });
if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }
if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }
const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));
const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));
@@ -209,6 +267,9 @@ run([
@@ -209,6 +267,9 @@ run([
const req = srv.requests.at(-1);
const req = srv.requests.at(-1);
assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);
assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);
assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);
assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);
+ assert.match(req.headers['x-openvibe-timestamp'], /^\d{10}$/);
+ assert.equal(req.headers['x-openvibe-signature-v2'].split(',')[0], `t=${req.headers['x-openvibe-timestamp']}`);
+ assert.equal(verifyDeliveryV2(req.body, req.headers, secret), true, 'the mock sends v2 too');
assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');
assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');
@@ -91,7 +91,27 @@ export declare function createAppEvents(client: OpenVibeClient, opts: { projectI
@@ -91,7 +91,27 @@ export declare function createAppEvents(client: OpenVibeClient, opts: { projectI
type RawBody = string | Uint8Array | ArrayBufferView;
type RawBody = string | Uint8Array | ArrayBufferView;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
-export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
+/** `t=<ts>,v2=<hex HMAC-SHA256 of "<ts>.<raw body>">`, the value of X-OpenVibe-Signature-V2 (timestamp in unix seconds, default now). */
+export declare function signDeliveryV2(rawBody: RawBody, secret: string, timestamp?: number): string;
+/** X-OpenVibe-Signature, X-OpenVibe-Timestamp and X-OpenVibe-Signature-V2 for a delivery body, as Events sends them (now in ms). */
+export declare function signDeliveryHeaders(rawBody: RawBody, secret: string, opts?: { now?: number }): {
+ 'X-OpenVibe-Signature': string;
+ 'X-OpenVibe-Timestamp': string;
+ 'X-OpenVibe-Signature-V2': string;
+};
+export interface DeliveryV2Options {
+ /** Seconds the timestamp may be away from `now`, either way. Default 300. */
+ toleranceSec?: number;
+ /** The current time in ms. Default Date.now(). */
+ now?: number;
+}
+/** Constant-time check of X-OpenVibe-Signature-V2 over the raw body, and of its timestamp (±toleranceSec). */
+export declare function verifyDeliveryV2(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: DeliveryV2Options): boolean;
+export interface ParseDeliveryOptions extends DeliveryV2Options {
+ /** Refuse deliveries without X-OpenVibe-Signature-V2 (v1-only). Default false. A present v2 header must always verify. */
+ requireV2?: boolean;
+}
+export declare function parseDelivery(rawBody: RawBody, headers: Record<string, any> | Headers, secret: string, opts?: ParseDeliveryOptions): { event: EventEnvelope; seq: number; subscriptionId: string | null; attempt: number } | null;
/** The subset of a better-sqlite3 Database the outbox and inbox use. */
/** The subset of a better-sqlite3 Database the outbox and inbox use. */
export interface SqliteDatabase {
export interface SqliteDatabase {