openvibers/openvibe.events Commits
Events on current pins (plan T7, A1 + A2): openvibe-contracts v0.80.0, openvibe-sdk v0.25.1, openvibe-shared v2.2.0 (no removed export was in use; behaviour unchanged); docs/replay-window-rollout.md and docs/capabilities-proposal/ deleted (superseded by Contracts' capability manifests; nothing linked them).
Changes
@@@ -37,7 +37,7 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service || `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |--These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.63.0 knows all seven).++These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.80.0 knows all seven).Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.@@@ -108,7 +108,7 @@ Topic patterns are dot-separated segments where `*` stands for one or more wholeEach delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.--**Replay window (signature v2).** v1 signs only the body, so a captured delivery verifies forever (the inbox's `event_id` dedupe is all that stops a replay). v2 signs the timestamp together with the body, and every attempt, retries included, is signed afresh with the time it is sent. Consumers check it with `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })` (here) or openvibe-sdk ≥ 0.4.0 `parseDelivery(raw, headers, secret, { requireV2: true })` and reject anything more than 300 s from their clock either way. A v2 header that is present but wrong or stale is a failure: never fall back to v1 then. Rollout: Events sends both headers first, then each consumer requires v2; v1 stays on the wire until every consumer does. See [docs/replay-window-rollout.md](docs/replay-window-rollout.md).++**Replay window (signature v2).** v1 signs only the body, so a captured delivery verifies forever (the inbox's `event_id` dedupe is all that stops a replay). v2 signs the timestamp together with the body, and every attempt, retries included, is signed afresh with the time it is sent. Consumers check it with `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })` (here) or openvibe-sdk ≥ 0.4.0 `parseDelivery(raw, headers, secret, { requireV2: true })` and reject anything more than 300 s from their clock either way. A v2 header that is present but wrong or stale is a failure: never fall back to v1 then. Rollout: Events sends both headers first, then each consumer requires v2; v1 stays on the wire until every consumer does.Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST /api/v1/deliveries/replay { subscription_id, event_ids: [...] }` or `{ subscription_id, from_seq }` requeues retained events (that is also how a new subscription catches up on history).@@@ -7,7 +7,7 @@"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4300,"domain": "events.openvibe.network",-- "contracts": "openvibe-contracts v0.76.0",++ "contracts": "openvibe-contracts v0.80.0","capabilities": ["events.event.publish","events.subscription.manage",ddeleted file mode 100644@@@ -1,20 +0,0 @@--{-- "id": "events.delivery.admin",-- "version": "1.0",-- "owner": "events",-- "status": "active",-- "visibility": "internal",-- "description": "Operator access: inspect deliveries (including the dead-letter queue) and replay events to any subscription.",-- "permissions": [-- "events:admin"-- ],-- "resourceConstraints": [-- "none"-- ],-- "quotaClass": "events-admin",-- "events": [],-- "implementedBy": [-- "GET /api/v1/deliveries",-- "POST /api/v1/deliveries/replay"-- ]--}ddeleted file mode 100644@@@ -1,20 +0,0 @@--{-- "id": "events.event.publish",-- "version": "1.0",-- "owner": "events",-- "status": "active",-- "visibility": "internal",-- "description": "Publish durable events (events.event-envelope@1) as the calling service: source must be the principal's service slug and event_type must use a prefix that source owns. Idempotent on event_id.",-- "inputSchema": "events.event-envelope@1",-- "permissions": [-- "events:write"-- ],-- "resourceConstraints": [-- "owner"-- ],-- "quotaClass": "events-publish",-- "events": [],-- "implementedBy": [-- "POST /api/v1/events"-- ]--}ddeleted file mode 100644@@@ -1,24 +0,0 @@--{-- "id": "events.event.read",-- "version": "1.0",-- "owner": "events",-- "status": "active",-- "visibility": "internal",-- "description": "Read retained events of every visibility with a seq cursor (pull consumers), keep per-consumer checkpoints, and open realtime streams as a service principal.",-- "outputSchema": "events.event-envelope@1",-- "permissions": [-- "events:read"-- ],-- "resourceConstraints": [-- "none"-- ],-- "quotaClass": "events-read",-- "events": [],-- "implementedBy": [-- "GET /api/v1/events",-- "GET /api/v1/events/:event_id",-- "GET /api/v1/checkpoints",-- "PUT /api/v1/checkpoints",-- "GET /realtime/stream"-- ]--}ddeleted file mode 100644@@@ -1,23 +0,0 @@--{-- "id": "events.subscription.manage",-- "version": "1.0",-- "owner": "events",-- "status": "active",-- "visibility": "internal",-- "description": "Create, list, disable and enable the calling service's own webhook subscriptions (topic pattern + allow-listed internal endpoint, HMAC-signed deliveries with retry and dead letters).",-- "permissions": [-- "events:subscribe"-- ],-- "resourceConstraints": [-- "owner"-- ],-- "quotaClass": "events-subscribe",-- "events": [],-- "implementedBy": [-- "POST /api/v1/subscriptions",-- "GET /api/v1/subscriptions",-- "GET /api/v1/subscriptions/:id",-- "POST /api/v1/subscriptions/:id/disable",-- "POST /api/v1/subscriptions/:id/enable"-- ]--}ddeleted file mode 100644@@@ -1,26 +0,0 @@--{-- "version": "0.1.0",-- "contractRanges": {-- "openvibe-contracts": ">=0.5.1 <1.0.0"-- },-- "id": "events",-- "name": "OpenVibe.Events",-- "status": "alpha",-- "repository": "OpenVibers/OpenVibe.Events",-- "domains": [-- "events.openvibe.network"-- ],-- "publicOrigin": "https://events.openvibe.network",-- "health": "/api/health",-- "ready": "/api/ready",-- "capabilities": [-- "events.event.publish",-- "events.subscription.manage",-- "events.event.read",-- "events.delivery.admin"-- ],-- "eventsProduced": [],-- "eventsConsumed": [],-- "namespacesOwned": [],-- "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream until ADR-005 decides where Realtime lives."--}ddeleted file mode 100644@@@ -1,537 +0,0 @@--# Replay window for webhook deliveries (signature v2): rollout----Status (2026-09-23): OpenVibe.Events signs v2 in code (not deployed). openvibe-sdk 0.4.0 is committed but **not tagged**. Contracts and ADR-004 describe v2. Consumers have **not** been changed: each change below has to wait for the `v0.4.0` SDK tag.----## What changes on the wire----Every delivery attempt now carries three signature headers, all keyed with the subscription secret:----| Header | Value |--|---|---|--| `X-OpenVibe-Signature` (v1, unchanged) | `sha256=<hex HMAC-SHA256 of the raw body>` |--| `X-OpenVibe-Timestamp` | `<unix seconds>`, the time this attempt was sent |--| `X-OpenVibe-Signature-V2` | `t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` |----- The worker signs each attempt when it sends it (`server/worker.js`). Retries and operator replays from the DLQ therefore always carry a fresh timestamp. A delivery that waited in the queue, or backed off for an hour, is still inside the window when it arrives.--- A consumer accepts v2 only when `t` is within **±300 s** of its own clock. It compares signatures in constant time, and it refuses a v2 header that is present but wrong or stale. It never falls back to v1 in that case. With `requireV2` it also refuses a delivery with no v2 header, which blocks replaying a captured delivery with the v2 header stripped off.--- The helpers:-- - Events `lib/client.js`: `signV2(raw, secret, ts)` and `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })`.-- - openvibe-sdk 0.4.0: `verifyDeliveryV2`, `parseDelivery(raw, headers, secret, { requireV2, toleranceSec, now })`, and `signDeliveryV2` / `signDeliveryHeaders` for tests.--- Every host must keep its clock synced with NTP. Events and all the consumers listed here run on the same host today, so they share one clock. Developer-app endpoints run on their own clocks.----## Deploy order----1. **Review and tag openvibe-sdk `v0.4.0`** (OpenVibe.SDK). Nothing depends on it yet.--2. **Deploy OpenVibe.Events.** It keeps sending v1 and adds the two v2 headers, which current consumers ignore. Then check that one real delivery carries `X-OpenVibe-Timestamp` and `X-OpenVibe-Signature-V2`: look at a consumer's access log, or use a throwaway subscription to a local endpoint. Also compare `date +%s` on the host with a trusted clock. Rollback is to redeploy the previous release. That is safe only while no consumer requires v2.--3. **Codes docs** (already committed) can go out any time after step 1. The page now points readers to `openvibe-sdk` 0.4.0.--4. **Consumers, one at a time**, each after step 2 is confirmed. Each one pins `openvibe-sdk` to `v0.4.0` (Search has no SDK), switches to v2 as shown below, updates its tests, runs `npm test` on Node 22, and deploys. After each deploy, watch that service for `401 *.bad_signature` and for its deliveries in `GET /api/v1/deliveries?status=failed&subscription_id=…`. The suggested order goes from least to most consequential:-- 1. **Live** (`/internal/openre-events`): inert until `OPENRE_URL` / `OPENRE_EVENTS_SECRET` are set.-- 2. **Search**: index updates, rebuildable.-- 3. **News**, then **Deals**, then **Trade**: source-driven imports, idempotent.-- 4. **Reviews**.-- 5. **Tips**, then **VIP**: settlement and entitlements from Billing events. Do these last, when the rest has run clean for a day.-- 6. **Examples** (`webhook-consumer`): a reference app, not deployed. Update it in the same week so developers copy the v2 form.--5. **Optional, after every consumer requires v2:** stop sending v1 from the Events worker. `verifyDelivery` stays exported in the SDK and in `lib/client.js`.----**If a consumer rejects deliveries** (bad secret, clock skew), Events keeps retrying for about 2 h 13 min (1 s, 5 s, 30 s, 2 min, 10 min, 1 h, 1 h) before the delivery goes to the dead-letter queue. Meanwhile, roll the consumer back to its previous release. Then requeue its dead deliveries with `POST /api/v1/deliveries/replay { subscription_id, event_ids }` or `{ subscription_id, from_seq }`. Replays are signed afresh, and the consumer's inbox dedupes on `event_id`.----## Consumers that verify Events deliveries----I searched `~/OpenVibers/*/{server,lib,scripts,examples}` and `~/orca/workspaces/OpenVibe.Live/seadragon` (excluding `node_modules`) for `verifyDelivery`, `parseDelivery`, `x-openvibe-signature`, `createHmac` and `timingSafeEqual`.----| Service | Route | Verifier today | SDK pin | Secret (env) | Change |--|---|---|---|---|---|---|--| Live (seadragon) | `POST /internal/openre-events` | SDK `parseDelivery` (`server/openre/mirror.js:141`) | v0.2.2 | `OPENRE_EVENTS_SECRET` (one) | add `{ requireV2: true }` |--| Search | `POST /internal/events` | hand-rolled HMAC (`server/api/webhook.js:27-38, 98`) | none | `SEARCH_EVENTS_SECRET` (list) | hand-rolled v2 check (no SDK dependency) |--| News | `POST /internal/events` | SDK `verifyDelivery` (`server/http/webhook.js:35-36`) | v0.2.2 | `NEWS_EVENTS_SECRET` (list) | `verifyDeliveryV2(raw, req.headers, s)` |--| Deals | `POST /internal/events` | SDK `verifyDelivery` (`server/http/internal.js:28-29`) | v0.2.2 | `DEALS_EVENTS_SECRET` (list) | `verifyDeliveryV2(raw, req.headers, s)` |--| Trade | `POST /internal/events` | SDK `parseDelivery` (`server/events/webhook.js:25`) | v0.2.2 | `TRADE_EVENTS_WEBHOOK_SECRET` (one) | add `{ requireV2: true }` |--| Reviews | `POST /internal/events` | SDK `parseDelivery` per secret (`server/http/consumer.js:48`) | v0.2.2 | `REVIEWS_EVENTS_SECRET` (list) | add `{ requireV2: true }` |--| Tips | `POST /internal/events` | SDK `parseDelivery` per secret (`server/events/consumer.js:51`) | v0.2.2 | `TIPS_EVENTS_SECRET` (list) | add `{ requireV2: true }` |--| VIP | `POST /internal/events` | SDK `parseDelivery` per secret (`server/events/consumer.js:80`) | v0.2.2 | `VIP_EVENTS_SECRET` (list) | add `{ requireV2: true }` |--| Examples `webhook-consumer` (reference) | `POST /webhooks/openvibe` | SDK `parseDelivery` per secret (`examples/webhook-consumer/server.js:71`) | v0.3.1 | `OV_WEBHOOK_SECRET`, `OV_WEBHOOK_SECRET_PREVIOUS` | add `{ requireV2: true }` and fix one test |----These do **not** receive Events deliveries, so they need no change:--- **Producers only:** Wiki, Blog, Chat, Billing, Media, Host, Coupons, Games, Network and OpenRe.Stream. Network's `resend-webhook.js` is Resend's Svix webhook, and Media's `X-OVMedia-Signature` is Media's own webhook to Live.--- **No Events code:** Community, AI and Tools.--- **Not code:** Publishing (a library) and Realtime (closed, part of Events).--- **Codes:** `server/domain/webhooks.js` is a signature *tester*, not a receiver. Its docs text is updated. The tester still checks and generates v1 only. A follow-up after the SDK tag can make it inspect `X-OpenVibe-Signature-V2` and include the v2 headers in the sample.----### How the changes below were checked----Each diff was applied to a scratch copy of the repo with the local openvibe-sdk 0.4.0 installed in its `node_modules`, then tested on Node 22.22.1:--- **Full suites:** Deals, News, Search, Tips, Reviews, Trade, VIP and Examples all pass.--- **Live:** only `test/openre-switch.test.js` was run. It passes.--- **SDK bump alone, code unchanged:** every suite still passes, except the Examples smoke test. That test blanks only `X-OpenVibe-Signature` and expects a 401. With 0.4.0 the fresh v2 header from the mock is accepted, which is correct: a valid v2 is never overridden by v1. The Examples diff fixes the test.----### Common to the SDK consumers----In `package.json`, change the pin and refresh the lockfile with `npm install`:----```diff--- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",--+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",--```----Examples pins v0.3.1 in two places: the root `package.json` and `examples/webhook-consumer/package.json`.----The 0.2.2 → 0.4.0 jump includes 0.3.0's behaviour changes. None of these services uses the APIs involved: they import only `openvibe-sdk/core`, `/auth` and `/events`, and none calls `iterate({ onPage })`, `responseType: 'response'` or the SDK's authorize-URL builders. Their suites pass on 0.4.0.----Tests that post signed deliveries must send v2. Replace `signDelivery(raw, secret)` with the SDK's `...signDeliveryHeaders(raw, secret)`, which returns all three headers. A negative test that only swaps `X-OpenVibe-Signature` for a bad value no longer fails when a valid v2 is present, so it must also break or drop the v2 header.----### Live (`~/orca/workspaces/OpenVibe.Live/seadragon`)----```diff--+++ b/server/openre/mirror.js--@@ -138,7 +138,7 @@-- const secret = process.env.OPENRE_EVENTS_SECRET || '';-- if (!secret) return res.status(503).json({ error: 'OPENRE_EVENTS_SECRET is not set' });-- const { parseDelivery } = require('openvibe-sdk/events');--- const delivery = parseDelivery(req.rawBody, req.headers, secret);--+ const delivery = parseDelivery(req.rawBody, req.headers, secret, { requireV2: true });-- if (!delivery) return res.status(401).json({ error: 'bad signature' });-- // Signed but unusable (no event_id): acknowledge so it is not redelivered forever.-- if (!delivery.event || !delivery.event.event_id) return res.status(204).end();--+++ b/test/openre-switch.test.js--@@ -51,14 +51,14 @@-- });---- function signed(event, secret) {--- const { signDelivery } = require('openvibe-sdk/events');--+ const { signDeliveryHeaders } = require('openvibe-sdk/events');-- const raw = Buffer.from(JSON.stringify({ event, seq: 1 }));--- return { raw, sig: signDelivery(raw, secret) };--+ return { raw, headers: signDeliveryHeaders(raw, secret) };-- }-----async function deliver(base, event, { secret = 'whsec_test', sig } = {}) {--+async function deliver(base, event, { secret = 'whsec_test', headers } = {}) {-- const s = signed(event, secret);--- const res = await fetch(`${base}/internal/openre-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': sig || s.sig }, body: s.raw });--+ const res = await fetch(`${base}/internal/openre-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(headers || s.headers) }, body: s.raw });-- return res.status;-- }----@@ -162,7 +162,7 @@-- assert.strictEqual(openreCalls.filter(c => c.url.endsWith('/keys/rotate')).pop().subject, SUBJECT);---- // ── Mirror ────────────────────────────────────────────────--- assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2), { sig: 'sha256=00' }), 401, 'bad signature');--+ assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2), { headers: { 'X-OpenVibe-Signature': 'sha256=00', 'X-OpenVibe-Timestamp': '1', 'X-OpenVibe-Signature-V2': 't=1,v2=00' } }), 401, 'bad signature');-- assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2, { mirror_to_live: false })), 204);-- assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM streams WHERE is_live = 1').get().n, 0, 'no consent, no mirror');----```----### Search (no SDK dependency: hand-rolled, same rules as `verifyDeliveryV2`)----Search deliberately has no `openvibe-sdk` dependency, so it gets its own v2 check. The alternative is adding `openvibe-sdk` v0.4.0 and calling `verifyDeliveryV2(raw, req.headers, s)` for each secret. Either choice gives the same behaviour.----```diff--+++ b/server/api/webhook.js--@@ -28,6 +28,41 @@-- return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');-- }----+/** X-OpenVibe-Signature-V2 value: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">. */--+function signV2(raw, secret, ts = Math.floor(Date.now() / 1000)) {--+ return `t=${ts},v2=` + crypto.createHmac('sha256', String(secret)).update(`${ts}.`).update(raw).digest('hex');--+}--+--+const V2_TOLERANCE_SEC = 300;--+--+/**--+ * Constant-time check of X-OpenVibe-Signature-V2 against the raw body with any of `secrets`, and--+ * of its timestamp (±300 s of now; X-OpenVibe-Timestamp, when sent, must be the same t). Same--+ * rules as openvibe-sdk 0.4.0 verifyDeliveryV2(). v1 is never consulted.--+ */--+function verifySignatureV2(raw, headerV2, headerTs, secrets, now = Date.now()) {--+ if (!raw || typeof headerV2 !== 'string') return false;--+ let t = null;--+ const given = [];--+ for (const part of headerV2.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(Buffer.from(v));--+ }--+ if (t === null || !given.length) return false;--+ if (headerTs !== undefined && String(headerTs).trim() !== String(t)) return false;--+ if (Math.abs(now / 1000 - t) > V2_TOLERANCE_SEC) return false;--+ return secrets.some((s) => {--+ const expected = Buffer.from(signV2(raw, s, t).split(',v2=')[1]);--+ return given.some((g) => g.length === expected.length && crypto.timingSafeEqual(g, expected));--+ });--+}--+-- function verifySignature(raw, header, secrets) {-- if (!raw || typeof header !== 'string') return false;-- const given = Buffer.from(header.trim());--@@ -95,8 +130,8 @@-- const secrets = config.events.webhookSecrets;-- if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);--- if (!verifySignature(raw, req.get('x-openvibe-signature'), secrets)) {--- return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });--+ if (!verifySignatureV2(raw, req.get('x-openvibe-signature-v2'), req.get('x-openvibe-timestamp'), secrets)) {--+ return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx });-- }-- let body;-- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }--@@ -131,4 +166,4 @@-- return router;-- }-----module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };--+module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, verifySignatureV2, signV2, CONSUMER };--+++ b/test/helpers.js--@@ -11,7 +11,7 @@-- const { serviceAuth, ids } = require('openvibe-contracts');-- const { load } = require('../server/config');-- const { start } = require('../server/index');---const { sign } = require('../server/api/webhook');--+const { sign, signV2 } = require('../server/api/webhook');---- const ISSUER = 'https://openvibe.network';-- const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);--@@ -116,10 +116,11 @@-- /** POST a signed delivery to /internal/events. */-- async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false } = {}) {-- const raw = JSON.stringify({ event, seq });--- const signature = badSignature ? sign(raw, 'wrong-secret-' + 'y'.repeat(32)) : sign(raw, secret);--+ const key = badSignature ? 'wrong-secret-' + 'y'.repeat(32) : secret;--+ const ts = Math.floor(Date.now() / 1000);-- return request(base, 'POST', '/internal/events', {-- raw,--- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signature, 'X-OpenVibe-Event-Id': event.event_id },--+ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': sign(raw, key), 'X-OpenVibe-Timestamp': String(ts), 'X-OpenVibe-Signature-V2': signV2(raw, key, ts), 'X-OpenVibe-Event-Id': event.event_id },-- });-- }----```----### News----The News test helper signs by hand, so it also computes v2 by hand.----```diff--+++ b/server/http/webhook.js--@@ -16,7 +16,7 @@-- */-- const express = require('express');-- const { http } = require('openvibe-contracts');---const { verifyDelivery, createInbox } = require('openvibe-sdk/events');--+const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');---- const CONSUMER = 'news-sources';-- const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;--@@ -32,8 +32,8 @@-- const secrets = config.events.webhookSecrets;-- if (!secrets.length) return http.sendProblem(res, 503, 'news.webhook_disabled', { detail: 'NEWS_EVENTS_SECRET is not set', ctx });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);--- const sig = req.get('x-openvibe-signature');--- if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });--+ // v2 only: signature over "<t>.<raw body>" and t within ±300 s (a replayed or v1-only delivery fails).--+ if (!secrets.some((s) => verifyDeliveryV2(raw, req.headers, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx });-- let body;-- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }-- const event = body && body.event;--+++ b/test/helpers/mocks.js--@@ -193,7 +193,9 @@-- const envelope = { event_id: `evt_${ids.ulid(Date.now())}`, version: 1, timestamp: new Date().toISOString(), visibility: 'internal', actor: { type: 'service', id: 'sources' }, source: 'sources', payload: {}, ...event };-- const body = JSON.stringify({ event: envelope, seq });-- const sig = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;--- return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };--+ const ts = Math.floor(Date.now() / 1000);--+ const v2 = `t=${ts},v2=${crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')}`;--+ return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };-- }---- module.exports = { startNetwork, startSources, startAi, delivery, listen };--```----### Deals----```diff--+++ b/server/http/internal.js--@@ -11,7 +11,7 @@-- */-- const express = require('express');-- const { http } = require('openvibe-contracts');---const { verifyDelivery, createInbox } = require('openvibe-sdk/events');--+const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');---- const CONSUMER = 'deals-sources';-- const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);--@@ -25,8 +25,8 @@-- const secrets = config.events.webhookSecrets;-- if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);--- const sig = req.get('x-openvibe-signature');--- if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'deals.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });--+ // v2 only: signature over "<t>.<raw body>" and t within ±300 s (a replayed or v1-only delivery fails).--+ if (!secrets.some((s) => verifyDeliveryV2(raw, req.headers, s))) return http.sendProblem(res, 401, 'deals.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx: req.ov });-- let body;-- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }-- const event = body && body.event;--+++ b/test/import.test.js--@@ -6,7 +6,7 @@-- const assert = require('assert');-- const { boot, check, done, robotsOf, HOUR } = require('./helpers/boot');-- const { sourceItem } = require('./helpers/mocks');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');---- (async () => {-- const t = await boot();--@@ -127,15 +127,17 @@---- await check('the signed event wake-up: bad signature 401, deals items scheduled once, others ignored', async () => {-- const env = (id, category) => ({ event: { event_id: id, event_type: 'sources.item.updated', source: 'sources', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'sources' }, subject: { type: 'item', id: 'itm_x' }, visibility: 'internal', payload: { category } }, seq: 1 });--- const post = (body, sig) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig } });--+ const post = (body, sig) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...(typeof sig === 'string' ? { 'x-openvibe-signature': sig } : sig) } });-- const good = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA1', 'deals'));-- assert.strictEqual((await post(good, 'sha256=00')).status, 401);--- const r1 = await post(good, signDelivery(good, 'whsec-test'));--+ assert.strictEqual((await post(good, signDelivery(good, 'whsec-test'))).status, 401, 'v1 only: refused');--+ assert.strictEqual((await post(good, signDeliveryHeaders(good, 'whsec-test', { now: Date.now() - 301000 }))).status, 401, 'stale v2: refused');--+ const r1 = await post(good, signDeliveryHeaders(good, 'whsec-test'));-- assert.deepStrictEqual([r1.status, r1.json().outcome, r1.json().duplicate], [200, 'import_scheduled', false]);--- const r2 = await post(good, signDelivery(good, 'whsec-test'));--+ const r2 = await post(good, signDeliveryHeaders(good, 'whsec-test'));-- assert.strictEqual(r2.json().duplicate, true);-- const news = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA2', 'news'));--- assert.strictEqual((await post(news, signDelivery(news, 'whsec-test'))).json().outcome, 'ignored');--+ assert.strictEqual((await post(news, signDeliveryHeaders(news, 'whsec-test'))).json().outcome, 'ignored');-- });---- await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {--```----### Trade----```diff--+++ b/server/events/webhook.js--@@ -22,7 +22,7 @@-- router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {-- if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');--- const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret);--+ const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret, { requireV2: true });-- if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });-- const e = delivery.event;-- if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });--+++ b/test/sync.test.js--@@ -6,7 +6,7 @@-- * wakes the sync and is idempotent per event id. Deterministic resolution.-- */-- const assert = require('assert');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');-- const { boot, check, done } = require('./helpers/boot');-- const { mapItem } = require('../server/domain/mapping');----@@ -119,12 +119,14 @@-- const body = JSON.stringify({ event: { event_id: 'evt_01JABCDEFGHJKMNPQRSTVWXYZ0', event_type: 'sources.item.created', payload: {} }, seq: 7 });-- const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': 'sha256=00' } });-- assert.strictEqual(bad.status, 401);--- const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });--+ const v1only = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });--+ assert.strictEqual(v1only.status, 401, 'v1 only: refused (requireV2)');--+ const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'hook-secret') } });-- assert.strictEqual(ok.status, 200);-- assert.strictEqual(ok.json().sync, true);-- await t.ctx.sync.run();-- assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 1);--- const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });--+ const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'hook-secret') } });-- assert.strictEqual(again.json().duplicate, true);-- assert.strictEqual(again.json().sync, false);-- });--```----### Reviews----```diff--+++ b/server/http/consumer.js--@@ -45,7 +45,7 @@-- if (!secrets.length) return http.sendProblem(res, 503, 'reviews.webhook_disabled', { detail: 'REVIEWS_EVENTS_SECRET is not set', ctx: req.ov });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);-- let delivery = null;--- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }--+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }-- if (!delivery) return http.sendProblem(res, 401, 'reviews.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });-- const event = delivery.event;-- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {--+++ b/test/helpers.js--@@ -10,7 +10,7 @@-- const os = require('os');-- const path = require('path');-- const { serviceAuth, ids } = require('openvibe-contracts');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDeliveryHeaders } = require('openvibe-sdk/events');-- const { load } = require('../server/config');-- const { start } = require('../server/index');----@@ -212,7 +212,7 @@-- /** A signed delivery from OpenVibe.Events to POST /internal/events. */-- async function deliver(h, envelope, { secret = WEBHOOK_SECRET } = {}) {-- const raw = JSON.stringify({ event: envelope, seq: 1 });--- return req(h, 'POST', '/internal/events', { body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw, secret) } });--+ return req(h, 'POST', '/internal/events', { body: raw, headers: signDeliveryHeaders(raw, secret) });-- }---- function sourcesEvent(type, item, extra = {}) {--```----### Tips----```diff--+++ b/server/events/consumer.js--@@ -48,7 +48,7 @@-- if (!secrets.length) return http.sendProblem(res, 503, 'tips.webhook_disabled', { detail: 'TIPS_EVENTS_SECRET is not set', ctx: req.ov });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);-- let delivery = null;--- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }--+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }-- if (!delivery) return http.sendProblem(res, 401, 'tips.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });-- const event = delivery.event;-- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {--+++ b/test/helpers/app.js--@@ -13,7 +13,7 @@-- const path = require('path');-- const http = require('http');-- const crypto = require('crypto');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDeliveryHeaders } = require('openvibe-sdk/events');-- const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');---- const EVENTS_SECRET = 'e'.repeat(48);--@@ -75,7 +75,7 @@-- const raw = JSON.stringify({ event, seq });-- const res = await fetch(`${base}/internal/events`, {-- method: 'POST', body: raw,--- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },--+ headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, secret), 'X-OpenVibe-Seq': String(seq) },-- });-- return { status: res.status, json: await res.json().catch(() => null) };-- }--+++ b/test/settlement.test.js--@@ -239,8 +239,8 @@-- const bad = await t.deliver(ev, { secret: 'x'.repeat(48) });-- assert.strictEqual(bad.status, 401);-- const raw = JSON.stringify({ event: ev, seq: 1 });--- const { signDelivery } = require('openvibe-sdk/events');--- const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });--+ const { signDeliveryHeaders } = require('openvibe-sdk/events');--+ const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });-- assert.strictEqual(proxied.status, 404);-- const forged = await t.deliver({ ...ev, source: 'live' });-- assert.strictEqual(forged.json.outcome, 'ignored:source');--```----### VIP----```diff--+++ b/server/events/consumer.js--@@ -77,7 +77,7 @@-- if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });-- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);-- let delivery = null;--- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }--+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }-- if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });-- const event = delivery.event;-- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {--+++ b/test/helpers/app.js--@@ -12,7 +12,7 @@-- const os = require('os');-- const path = require('path');-- const http = require('http');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDeliveryHeaders } = require('openvibe-sdk/events');-- const { startNetwork, startBilling } = require('./stubs');---- const EVENTS_SECRET = 'e'.repeat(48);--@@ -68,7 +68,7 @@-- const raw = JSON.stringify({ event, seq });-- const res = await fetch(`${base}/internal/events`, {-- method: 'POST', body: raw,--- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },--+ headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, secret), 'X-OpenVibe-Seq': String(seq) },-- });-- return { status: res.status, json: await res.json().catch(() => null) };-- }--+++ b/test/entitlements.test.js--@@ -5,7 +5,7 @@-- * valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.-- */-- const assert = require('assert');---const { signDelivery } = require('openvibe-sdk/events');--+const { signDeliveryHeaders } = require('openvibe-sdk/events');-- const { boot, harness } = require('./helpers/app');-- const { DAY } = require('./helpers/stubs');----@@ -56,7 +56,7 @@-- assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');-- assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);-- const raw = JSON.stringify({ event: ent, seq: 1 });--- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });--+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });-- assert.strictEqual(res.status, 401);-- });----```----### Examples (`examples/webhook-consumer`)----Also update `examples/webhook-consumer/README.md`. Its "Signature scheme note" (line 67) says the HMAC has no timestamp. Replace it with the v2 description: the signature covers `"<t>.<raw body>"`, deliveries are refused outside ±300 s, and `requireV2` is on. The inbox stays mandatory, because delivery is at least once. Line 4 and the root README's table row name only `X-OpenVibe-Signature`, so add v2 there too.----```diff--+++ b/examples/webhook-consumer/server.js--@@ -68,7 +68,7 @@---- function verify(raw, headers) {-- for (const secret of config.secrets) {--- const d = parseDelivery(raw, headers, secret);--+ const d = parseDelivery(raw, headers, secret, { requireV2: true });-- if (d) return d;-- }-- return null;--+++ b/examples/webhook-consumer/test/smoke.test.js--@@ -106,7 +106,9 @@-- const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);-- assert.equal((await replay(last, { 'X-OpenVibe-Signature': signDelivery(forgedBody, 'whsec_not_the_secret'), 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401);-- assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401, 'changed bytes, old signature');--- assert.equal((await replay(last, { 'X-OpenVibe-Signature': '' })).status, 401);--+ assert.equal((await replay(last, { 'X-OpenVibe-Signature': '', 'X-OpenVibe-Signature-V2': '' })).status, 401);--+ const { 'X-OpenVibe-Signature-V2': _v2, ...v1only } = last.headers;--+ assert.equal((await realFetch(local, { method: 'POST', headers: v1only, body: last.body })).status, 401, 'v1 only (v2 stripped): refused');-- // Header and signed body disagree on the event id: refused.-- assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);-- assert.deepEqual(handled, [e1.event_id, e2.event_id]);--```@@@ -13,9 +13,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},"devDependencies": {@@@ -1059,9 +1059,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.76.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",++ "version": "0.80.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",++ "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -1075,9 +1075,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.20.4",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",-- "integrity": "sha512-IMvvBrFhCVO4sm2Ii694BslcnKHSV7YABXviLqrDI/2lTr9NKIH80z3HXxwMUgj0fwZDWBWcNgqPs6OaWIB0GQ==",++ "version": "0.25.1",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==","license": "MIT","engines": {"node": ">=20"@@@ -1104,9 +1104,9 @@}},"node_modules/openvibe-shared": {-- "version": "1.28.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",-- "integrity": "sha512-WLd11lDG75veVi7mFwngquK2nqIQV86Z14Ad/xE2dYvyMT5mhKJxxxlKUoskpdyLeap0ucMvzzS8Tg7vPGwYoQ==",++ "version": "2.2.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",++ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -25,9 +25,9 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0","pg": "^8.23.0"},"engines": {
@@ -37,7 +37,7 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
@@ -37,7 +37,7 @@ Services call with an OpenVibe.Network client-credentials token (`POST /oauth/to
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.event.read` | `GET /api/v1/events`, `GET /api/v1/events/:id`, `/api/v1/checkpoints`, realtime as a service |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
| `events.delivery.admin` | `GET /api/v1/deliveries`, `POST /api/v1/deliveries/replay` |
-These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.63.0 knows all seven).
+These four are `internal` in `openvibe-contracts` (never granted to developer apps). Developer apps use the three `public` capabilities in [Developer apps](#developer-apps) instead, on the same routes. `server/auth.js` grants with the contracts rule (exact id or a `family.*` grant) and hands the decision to `capabilities.check()` for every id the installed contracts know (the pinned v0.80.0 knows all seven).
Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.
Sandbox tokens (`env: sandbox`, developer apps only) are accepted only on the developer-app routes; every other route answers `401 token.sandbox_refused`. App tokens are never judged on a first-party capability: an app token on an operator route is a `403`.
@@ -108,7 +108,7 @@ Topic patterns are dot-separated segments where `*` stands for one or more whole
@@ -108,7 +108,7 @@ Topic patterns are dot-separated segments where `*` stands for one or more whole
Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
Each delivery is `POST <endpoint>` with body `{ "event": <envelope>, "seq": n }` and headers `X-OpenVibe-Event-Id`, `X-OpenVibe-Event-Type`, `X-OpenVibe-Seq`, `X-OpenVibe-Subscription-Id`, `X-OpenVibe-Delivery-Attempt`, `X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>`, `X-OpenVibe-Timestamp: <unix seconds>`, `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>">` and `traceparent` (the event's trace). Any 2xx is delivered. Anything else is retried after 1 s, 5 s, 30 s, 2 min, 10 min, 1 h (then hourly) up to 8 attempts, after which the delivery is `dead`. Priority classes go first (`critical`, `important`, `low`, then seq); one delivery per subscription is in flight at a time and at most 20 overall. Delivery is at least once and not strictly ordered; consumers dedupe with an inbox and order with `subject.revision`.
-**Replay window (signature v2).** v1 signs only the body, so a captured delivery verifies forever (the inbox's `event_id` dedupe is all that stops a replay). v2 signs the timestamp together with the body, and every attempt, retries included, is signed afresh with the time it is sent. Consumers check it with `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })` (here) or openvibe-sdk ≥ 0.4.0 `parseDelivery(raw, headers, secret, { requireV2: true })` and reject anything more than 300 s from their clock either way. A v2 header that is present but wrong or stale is a failure: never fall back to v1 then. Rollout: Events sends both headers first, then each consumer requires v2; v1 stays on the wire until every consumer does. See [docs/replay-window-rollout.md](docs/replay-window-rollout.md).
+**Replay window (signature v2).** v1 signs only the body, so a captured delivery verifies forever (the inbox's `event_id` dedupe is all that stops a replay). v2 signs the timestamp together with the body, and every attempt, retries included, is signed afresh with the time it is sent. Consumers check it with `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })` (here) or openvibe-sdk ≥ 0.4.0 `parseDelivery(raw, headers, secret, { requireV2: true })` and reject anything more than 300 s from their clock either way. A v2 header that is present but wrong or stale is a failure: never fall back to v1 then. Rollout: Events sends both headers first, then each consumer requires v2; v1 stays on the wire until every consumer does.
Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST /api/v1/deliveries/replay { subscription_id, event_ids: [...] }` or `{ subscription_id, from_seq }` requeues retained events (that is also how a new subscription catches up on history).
Operators: `GET /api/v1/deliveries?status=dead` is the dead-letter queue; `POST /api/v1/deliveries/replay { subscription_id, event_ids: [...] }` or `{ subscription_id, from_seq }` requeues retained events (that is also how a new subscription catches up on history).
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4300,
"port": 4300,
"domain": "events.openvibe.network",
"domain": "events.openvibe.network",
- "contracts": "openvibe-contracts v0.76.0",
+ "contracts": "openvibe-contracts v0.80.0",
"capabilities": [
"capabilities": [
"events.event.publish",
"events.event.publish",
"events.subscription.manage",
"events.subscription.manage",
deleted file mode 100644
deleted file mode 100644
@@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
-{
- "id": "events.delivery.admin",
- "version": "1.0",
- "owner": "events",
- "status": "active",
- "visibility": "internal",
- "description": "Operator access: inspect deliveries (including the dead-letter queue) and replay events to any subscription.",
- "permissions": [
- "events:admin"
- ],
- "resourceConstraints": [
- "none"
- ],
- "quotaClass": "events-admin",
- "events": [],
- "implementedBy": [
- "GET /api/v1/deliveries",
- "POST /api/v1/deliveries/replay"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,20 +0,0 @@
@@ -1,20 +0,0 @@
-{
- "id": "events.event.publish",
- "version": "1.0",
- "owner": "events",
- "status": "active",
- "visibility": "internal",
- "description": "Publish durable events (events.event-envelope@1) as the calling service: source must be the principal's service slug and event_type must use a prefix that source owns. Idempotent on event_id.",
- "inputSchema": "events.event-envelope@1",
- "permissions": [
- "events:write"
- ],
- "resourceConstraints": [
- "owner"
- ],
- "quotaClass": "events-publish",
- "events": [],
- "implementedBy": [
- "POST /api/v1/events"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,24 +0,0 @@
@@ -1,24 +0,0 @@
-{
- "id": "events.event.read",
- "version": "1.0",
- "owner": "events",
- "status": "active",
- "visibility": "internal",
- "description": "Read retained events of every visibility with a seq cursor (pull consumers), keep per-consumer checkpoints, and open realtime streams as a service principal.",
- "outputSchema": "events.event-envelope@1",
- "permissions": [
- "events:read"
- ],
- "resourceConstraints": [
- "none"
- ],
- "quotaClass": "events-read",
- "events": [],
- "implementedBy": [
- "GET /api/v1/events",
- "GET /api/v1/events/:event_id",
- "GET /api/v1/checkpoints",
- "PUT /api/v1/checkpoints",
- "GET /realtime/stream"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,23 +0,0 @@
@@ -1,23 +0,0 @@
-{
- "id": "events.subscription.manage",
- "version": "1.0",
- "owner": "events",
- "status": "active",
- "visibility": "internal",
- "description": "Create, list, disable and enable the calling service's own webhook subscriptions (topic pattern + allow-listed internal endpoint, HMAC-signed deliveries with retry and dead letters).",
- "permissions": [
- "events:subscribe"
- ],
- "resourceConstraints": [
- "owner"
- ],
- "quotaClass": "events-subscribe",
- "events": [],
- "implementedBy": [
- "POST /api/v1/subscriptions",
- "GET /api/v1/subscriptions",
- "GET /api/v1/subscriptions/:id",
- "POST /api/v1/subscriptions/:id/disable",
- "POST /api/v1/subscriptions/:id/enable"
- ]
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
-{
- "version": "0.1.0",
- "contractRanges": {
- "openvibe-contracts": ">=0.5.1 <1.0.0"
- },
- "id": "events",
- "name": "OpenVibe.Events",
- "status": "alpha",
- "repository": "OpenVibers/OpenVibe.Events",
- "domains": [
- "events.openvibe.network"
- ],
- "publicOrigin": "https://events.openvibe.network",
- "health": "/api/health",
- "ready": "/api/ready",
- "capabilities": [
- "events.event.publish",
- "events.subscription.manage",
- "events.event.read",
- "events.delivery.admin"
- ],
- "eventsProduced": [],
- "eventsConsumed": [],
- "namespacesOwned": [],
- "notes": "Durable events, subscriptions, signed delivery with retry, dead letters and replay; SSE realtime gateway at /realtime/stream until ADR-005 decides where Realtime lives."
-}
deleted file mode 100644
deleted file mode 100644
@@ -1,537 +0,0 @@
@@ -1,537 +0,0 @@
-# Replay window for webhook deliveries (signature v2): rollout
-
-Status (2026-09-23): OpenVibe.Events signs v2 in code (not deployed). openvibe-sdk 0.4.0 is committed but **not tagged**. Contracts and ADR-004 describe v2. Consumers have **not** been changed: each change below has to wait for the `v0.4.0` SDK tag.
-
-## What changes on the wire
-
-Every delivery attempt now carries three signature headers, all keyed with the subscription secret:
-
-| Header | Value |
-|---|---|
-| `X-OpenVibe-Signature` (v1, unchanged) | `sha256=<hex HMAC-SHA256 of the raw body>` |
-| `X-OpenVibe-Timestamp` | `<unix seconds>`, the time this attempt was sent |
-| `X-OpenVibe-Signature-V2` | `t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` |
-
-- The worker signs each attempt when it sends it (`server/worker.js`). Retries and operator replays from the DLQ therefore always carry a fresh timestamp. A delivery that waited in the queue, or backed off for an hour, is still inside the window when it arrives.
-- A consumer accepts v2 only when `t` is within **±300 s** of its own clock. It compares signatures in constant time, and it refuses a v2 header that is present but wrong or stale. It never falls back to v1 in that case. With `requireV2` it also refuses a delivery with no v2 header, which blocks replaying a captured delivery with the v2 header stripped off.
-- The helpers:
- - Events `lib/client.js`: `signV2(raw, secret, ts)` and `verifyDeliveryV2(raw, headers, secret, { toleranceSec = 300, now })`.
- - openvibe-sdk 0.4.0: `verifyDeliveryV2`, `parseDelivery(raw, headers, secret, { requireV2, toleranceSec, now })`, and `signDeliveryV2` / `signDeliveryHeaders` for tests.
-- Every host must keep its clock synced with NTP. Events and all the consumers listed here run on the same host today, so they share one clock. Developer-app endpoints run on their own clocks.
-
-## Deploy order
-
-1. **Review and tag openvibe-sdk `v0.4.0`** (OpenVibe.SDK). Nothing depends on it yet.
-2. **Deploy OpenVibe.Events.** It keeps sending v1 and adds the two v2 headers, which current consumers ignore. Then check that one real delivery carries `X-OpenVibe-Timestamp` and `X-OpenVibe-Signature-V2`: look at a consumer's access log, or use a throwaway subscription to a local endpoint. Also compare `date +%s` on the host with a trusted clock. Rollback is to redeploy the previous release. That is safe only while no consumer requires v2.
-3. **Codes docs** (already committed) can go out any time after step 1. The page now points readers to `openvibe-sdk` 0.4.0.
-4. **Consumers, one at a time**, each after step 2 is confirmed. Each one pins `openvibe-sdk` to `v0.4.0` (Search has no SDK), switches to v2 as shown below, updates its tests, runs `npm test` on Node 22, and deploys. After each deploy, watch that service for `401 *.bad_signature` and for its deliveries in `GET /api/v1/deliveries?status=failed&subscription_id=…`. The suggested order goes from least to most consequential:
- 1. **Live** (`/internal/openre-events`): inert until `OPENRE_URL` / `OPENRE_EVENTS_SECRET` are set.
- 2. **Search**: index updates, rebuildable.
- 3. **News**, then **Deals**, then **Trade**: source-driven imports, idempotent.
- 4. **Reviews**.
- 5. **Tips**, then **VIP**: settlement and entitlements from Billing events. Do these last, when the rest has run clean for a day.
- 6. **Examples** (`webhook-consumer`): a reference app, not deployed. Update it in the same week so developers copy the v2 form.
-5. **Optional, after every consumer requires v2:** stop sending v1 from the Events worker. `verifyDelivery` stays exported in the SDK and in `lib/client.js`.
-
-**If a consumer rejects deliveries** (bad secret, clock skew), Events keeps retrying for about 2 h 13 min (1 s, 5 s, 30 s, 2 min, 10 min, 1 h, 1 h) before the delivery goes to the dead-letter queue. Meanwhile, roll the consumer back to its previous release. Then requeue its dead deliveries with `POST /api/v1/deliveries/replay { subscription_id, event_ids }` or `{ subscription_id, from_seq }`. Replays are signed afresh, and the consumer's inbox dedupes on `event_id`.
-
-## Consumers that verify Events deliveries
-
-I searched `~/OpenVibers/*/{server,lib,scripts,examples}` and `~/orca/workspaces/OpenVibe.Live/seadragon` (excluding `node_modules`) for `verifyDelivery`, `parseDelivery`, `x-openvibe-signature`, `createHmac` and `timingSafeEqual`.
-
-| Service | Route | Verifier today | SDK pin | Secret (env) | Change |
-|---|---|---|---|---|---|
-| Live (seadragon) | `POST /internal/openre-events` | SDK `parseDelivery` (`server/openre/mirror.js:141`) | v0.2.2 | `OPENRE_EVENTS_SECRET` (one) | add `{ requireV2: true }` |
-| Search | `POST /internal/events` | hand-rolled HMAC (`server/api/webhook.js:27-38, 98`) | none | `SEARCH_EVENTS_SECRET` (list) | hand-rolled v2 check (no SDK dependency) |
-| News | `POST /internal/events` | SDK `verifyDelivery` (`server/http/webhook.js:35-36`) | v0.2.2 | `NEWS_EVENTS_SECRET` (list) | `verifyDeliveryV2(raw, req.headers, s)` |
-| Deals | `POST /internal/events` | SDK `verifyDelivery` (`server/http/internal.js:28-29`) | v0.2.2 | `DEALS_EVENTS_SECRET` (list) | `verifyDeliveryV2(raw, req.headers, s)` |
-| Trade | `POST /internal/events` | SDK `parseDelivery` (`server/events/webhook.js:25`) | v0.2.2 | `TRADE_EVENTS_WEBHOOK_SECRET` (one) | add `{ requireV2: true }` |
-| Reviews | `POST /internal/events` | SDK `parseDelivery` per secret (`server/http/consumer.js:48`) | v0.2.2 | `REVIEWS_EVENTS_SECRET` (list) | add `{ requireV2: true }` |
-| Tips | `POST /internal/events` | SDK `parseDelivery` per secret (`server/events/consumer.js:51`) | v0.2.2 | `TIPS_EVENTS_SECRET` (list) | add `{ requireV2: true }` |
-| VIP | `POST /internal/events` | SDK `parseDelivery` per secret (`server/events/consumer.js:80`) | v0.2.2 | `VIP_EVENTS_SECRET` (list) | add `{ requireV2: true }` |
-| Examples `webhook-consumer` (reference) | `POST /webhooks/openvibe` | SDK `parseDelivery` per secret (`examples/webhook-consumer/server.js:71`) | v0.3.1 | `OV_WEBHOOK_SECRET`, `OV_WEBHOOK_SECRET_PREVIOUS` | add `{ requireV2: true }` and fix one test |
-
-These do **not** receive Events deliveries, so they need no change:
-- **Producers only:** Wiki, Blog, Chat, Billing, Media, Host, Coupons, Games, Network and OpenRe.Stream. Network's `resend-webhook.js` is Resend's Svix webhook, and Media's `X-OVMedia-Signature` is Media's own webhook to Live.
-- **No Events code:** Community, AI and Tools.
-- **Not code:** Publishing (a library) and Realtime (closed, part of Events).
-- **Codes:** `server/domain/webhooks.js` is a signature *tester*, not a receiver. Its docs text is updated. The tester still checks and generates v1 only. A follow-up after the SDK tag can make it inspect `X-OpenVibe-Signature-V2` and include the v2 headers in the sample.
-
-### How the changes below were checked
-
-Each diff was applied to a scratch copy of the repo with the local openvibe-sdk 0.4.0 installed in its `node_modules`, then tested on Node 22.22.1:
-- **Full suites:** Deals, News, Search, Tips, Reviews, Trade, VIP and Examples all pass.
-- **Live:** only `test/openre-switch.test.js` was run. It passes.
-- **SDK bump alone, code unchanged:** every suite still passes, except the Examples smoke test. That test blanks only `X-OpenVibe-Signature` and expects a 401. With 0.4.0 the fresh v2 header from the mock is accepted, which is correct: a valid v2 is never overridden by v1. The Examples diff fixes the test.
-
-### Common to the SDK consumers
-
-In `package.json`, change the pin and refresh the lockfile with `npm install`:
-
-```diff
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
-+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
-```
-
-Examples pins v0.3.1 in two places: the root `package.json` and `examples/webhook-consumer/package.json`.
-
-The 0.2.2 → 0.4.0 jump includes 0.3.0's behaviour changes. None of these services uses the APIs involved: they import only `openvibe-sdk/core`, `/auth` and `/events`, and none calls `iterate({ onPage })`, `responseType: 'response'` or the SDK's authorize-URL builders. Their suites pass on 0.4.0.
-
-Tests that post signed deliveries must send v2. Replace `signDelivery(raw, secret)` with the SDK's `...signDeliveryHeaders(raw, secret)`, which returns all three headers. A negative test that only swaps `X-OpenVibe-Signature` for a bad value no longer fails when a valid v2 is present, so it must also break or drop the v2 header.
-
-### Live (`~/orca/workspaces/OpenVibe.Live/seadragon`)
-
-```diff
-+++ b/server/openre/mirror.js
-@@ -138,7 +138,7 @@
- const secret = process.env.OPENRE_EVENTS_SECRET || '';
- if (!secret) return res.status(503).json({ error: 'OPENRE_EVENTS_SECRET is not set' });
- const { parseDelivery } = require('openvibe-sdk/events');
-- const delivery = parseDelivery(req.rawBody, req.headers, secret);
-+ const delivery = parseDelivery(req.rawBody, req.headers, secret, { requireV2: true });
- if (!delivery) return res.status(401).json({ error: 'bad signature' });
- // Signed but unusable (no event_id): acknowledge so it is not redelivered forever.
- if (!delivery.event || !delivery.event.event_id) return res.status(204).end();
-+++ b/test/openre-switch.test.js
-@@ -51,14 +51,14 @@
- });
-
- function signed(event, secret) {
-- const { signDelivery } = require('openvibe-sdk/events');
-+ const { signDeliveryHeaders } = require('openvibe-sdk/events');
- const raw = Buffer.from(JSON.stringify({ event, seq: 1 }));
-- return { raw, sig: signDelivery(raw, secret) };
-+ return { raw, headers: signDeliveryHeaders(raw, secret) };
- }
-
--async function deliver(base, event, { secret = 'whsec_test', sig } = {}) {
-+async function deliver(base, event, { secret = 'whsec_test', headers } = {}) {
- const s = signed(event, secret);
-- const res = await fetch(`${base}/internal/openre-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': sig || s.sig }, body: s.raw });
-+ const res = await fetch(`${base}/internal/openre-events`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(headers || s.headers) }, body: s.raw });
- return res.status;
- }
-
-@@ -162,7 +162,7 @@
- assert.strictEqual(openreCalls.filter(c => c.url.endsWith('/keys/rotate')).pop().subject, SUBJECT);
-
- // ── Mirror ────────────────────────────────────────────────
-- assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2), { sig: 'sha256=00' }), 401, 'bad signature');
-+ assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2), { headers: { 'X-OpenVibe-Signature': 'sha256=00', 'X-OpenVibe-Timestamp': '1', 'X-OpenVibe-Signature-V2': 't=1,v2=00' } }), 401, 'bad signature');
- assert.strictEqual(await deliver(base, sessionEvent('started', SES(3), 2, { mirror_to_live: false })), 204);
- assert.strictEqual(d.prepare('SELECT COUNT(*) AS n FROM streams WHERE is_live = 1').get().n, 0, 'no consent, no mirror');
-
-```
-
-### Search (no SDK dependency: hand-rolled, same rules as `verifyDeliveryV2`)
-
-Search deliberately has no `openvibe-sdk` dependency, so it gets its own v2 check. The alternative is adding `openvibe-sdk` v0.4.0 and calling `verifyDeliveryV2(raw, req.headers, s)` for each secret. Either choice gives the same behaviour.
-
-```diff
-+++ b/server/api/webhook.js
-@@ -28,6 +28,41 @@
- return 'sha256=' + crypto.createHmac('sha256', String(secret)).update(raw).digest('hex');
- }
-
-+/** X-OpenVibe-Signature-V2 value: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">. */
-+function signV2(raw, secret, ts = Math.floor(Date.now() / 1000)) {
-+ return `t=${ts},v2=` + crypto.createHmac('sha256', String(secret)).update(`${ts}.`).update(raw).digest('hex');
-+}
-+
-+const V2_TOLERANCE_SEC = 300;
-+
-+/**
-+ * Constant-time check of X-OpenVibe-Signature-V2 against the raw body with any of `secrets`, and
-+ * of its timestamp (±300 s of now; X-OpenVibe-Timestamp, when sent, must be the same t). Same
-+ * rules as openvibe-sdk 0.4.0 verifyDeliveryV2(). v1 is never consulted.
-+ */
-+function verifySignatureV2(raw, headerV2, headerTs, secrets, now = Date.now()) {
-+ if (!raw || typeof headerV2 !== 'string') return false;
-+ let t = null;
-+ const given = [];
-+ for (const part of headerV2.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(Buffer.from(v));
-+ }
-+ if (t === null || !given.length) return false;
-+ if (headerTs !== undefined && String(headerTs).trim() !== String(t)) return false;
-+ if (Math.abs(now / 1000 - t) > V2_TOLERANCE_SEC) return false;
-+ return secrets.some((s) => {
-+ const expected = Buffer.from(signV2(raw, s, t).split(',v2=')[1]);
-+ return given.some((g) => g.length === expected.length && crypto.timingSafeEqual(g, expected));
-+ });
-+}
-+
- function verifySignature(raw, header, secrets) {
- if (!raw || typeof header !== 'string') return false;
- const given = Buffer.from(header.trim());
-@@ -95,8 +130,8 @@
- const secrets = config.events.webhookSecrets;
- if (!secrets.length) return http.sendProblem(res, 503, 'search.webhook_disabled', { detail: 'SEARCH_EVENTS_SECRET is not set', ctx });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
-- if (!verifySignature(raw, req.get('x-openvibe-signature'), secrets)) {
-- return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });
-+ if (!verifySignatureV2(raw, req.get('x-openvibe-signature-v2'), req.get('x-openvibe-timestamp'), secrets)) {
-+ return http.sendProblem(res, 401, 'search.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx });
- }
- let body;
- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
-@@ -131,4 +166,4 @@
- return router;
- }
-
--module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, CONSUMER };
-+module.exports = { webhookRouter, createInbox, documentFromEvent, verifySignature, sign, verifySignatureV2, signV2, CONSUMER };
-+++ b/test/helpers.js
-@@ -11,7 +11,7 @@
- const { serviceAuth, ids } = require('openvibe-contracts');
- const { load } = require('../server/config');
- const { start } = require('../server/index');
--const { sign } = require('../server/api/webhook');
-+const { sign, signV2 } = require('../server/api/webhook');
-
- const ISSUER = 'https://openvibe.network';
- const WEBHOOK_SECRET = 'whsec_test_' + 'x'.repeat(40);
-@@ -116,10 +116,11 @@
- /** POST a signed delivery to /internal/events. */
- async function deliver(base, event, { secret = WEBHOOK_SECRET, seq = 1, badSignature = false } = {}) {
- const raw = JSON.stringify({ event, seq });
-- const signature = badSignature ? sign(raw, 'wrong-secret-' + 'y'.repeat(32)) : sign(raw, secret);
-+ const key = badSignature ? 'wrong-secret-' + 'y'.repeat(32) : secret;
-+ const ts = Math.floor(Date.now() / 1000);
- return request(base, 'POST', '/internal/events', {
- raw,
-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signature, 'X-OpenVibe-Event-Id': event.event_id },
-+ headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': sign(raw, key), 'X-OpenVibe-Timestamp': String(ts), 'X-OpenVibe-Signature-V2': signV2(raw, key, ts), 'X-OpenVibe-Event-Id': event.event_id },
- });
- }
-
-```
-
-### News
-
-The News test helper signs by hand, so it also computes v2 by hand.
-
-```diff
-+++ b/server/http/webhook.js
-@@ -16,7 +16,7 @@
- */
- const express = require('express');
- const { http } = require('openvibe-contracts');
--const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
-+const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');
-
- const CONSUMER = 'news-sources';
- const EVT_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
-@@ -32,8 +32,8 @@
- const secrets = config.events.webhookSecrets;
- if (!secrets.length) return http.sendProblem(res, 503, 'news.webhook_disabled', { detail: 'NEWS_EVENTS_SECRET is not set', ctx });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
-- const sig = req.get('x-openvibe-signature');
-- if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx });
-+ // v2 only: signature over "<t>.<raw body>" and t within ±300 s (a replayed or v1-only delivery fails).
-+ if (!secrets.some((s) => verifyDeliveryV2(raw, req.headers, s))) return http.sendProblem(res, 401, 'news.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx });
- let body;
- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
- const event = body && body.event;
-+++ b/test/helpers/mocks.js
-@@ -193,7 +193,9 @@
- const envelope = { event_id: `evt_${ids.ulid(Date.now())}`, version: 1, timestamp: new Date().toISOString(), visibility: 'internal', actor: { type: 'service', id: 'sources' }, source: 'sources', payload: {}, ...event };
- const body = JSON.stringify({ event: envelope, seq });
- const sig = `sha256=${crypto.createHmac('sha256', secret).update(body).digest('hex')}`;
-- return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
-+ const ts = Math.floor(Date.now() / 1000);
-+ const v2 = `t=${ts},v2=${crypto.createHmac('sha256', secret).update(`${ts}.${body}`).digest('hex')}`;
-+ return { body, envelope, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig, 'x-openvibe-timestamp': String(ts), 'x-openvibe-signature-v2': v2, 'x-openvibe-delivery-attempt': String(attempt), 'x-openvibe-seq': String(seq) } };
- }
-
- module.exports = { startNetwork, startSources, startAi, delivery, listen };
-```
-
-### Deals
-
-```diff
-+++ b/server/http/internal.js
-@@ -11,7 +11,7 @@
- */
- const express = require('express');
- const { http } = require('openvibe-contracts');
--const { verifyDelivery, createInbox } = require('openvibe-sdk/events');
-+const { verifyDeliveryV2, createInbox } = require('openvibe-sdk/events');
-
- const CONSUMER = 'deals-sources';
- const TYPES = new Set(['sources.item.created', 'sources.item.updated', 'sources.item.removed']);
-@@ -25,8 +25,8 @@
- const secrets = config.events.webhookSecrets;
- if (!secrets.length) return http.sendProblem(res, 503, 'deals.webhook_disabled', { detail: 'DEALS_EVENTS_SECRET is not set', ctx: req.ov });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
-- const sig = req.get('x-openvibe-signature');
-- if (!secrets.some((s) => verifyDelivery(raw, sig, s))) return http.sendProblem(res, 401, 'deals.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
-+ // v2 only: signature over "<t>.<raw body>" and t within ±300 s (a replayed or v1-only delivery fails).
-+ if (!secrets.some((s) => verifyDeliveryV2(raw, req.headers, s))) return http.sendProblem(res, 401, 'deals.bad_signature', { detail: 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window', ctx: req.ov });
- let body;
- try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
- const event = body && body.event;
-+++ b/test/import.test.js
-@@ -6,7 +6,7 @@
- const assert = require('assert');
- const { boot, check, done, robotsOf, HOUR } = require('./helpers/boot');
- const { sourceItem } = require('./helpers/mocks');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
-
- (async () => {
- const t = await boot();
-@@ -127,15 +127,17 @@
-
- await check('the signed event wake-up: bad signature 401, deals items scheduled once, others ignored', async () => {
- const env = (id, category) => ({ event: { event_id: id, event_type: 'sources.item.updated', source: 'sources', version: 1, timestamp: new Date().toISOString(), actor: { type: 'service', id: 'sources' }, subject: { type: 'item', id: 'itm_x' }, visibility: 'internal', payload: { category } }, seq: 1 });
-- const post = (body, sig) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': sig } });
-+ const post = (body, sig) => t.get('/internal/events', { body, headers: { 'content-type': 'application/json', ...(typeof sig === 'string' ? { 'x-openvibe-signature': sig } : sig) } });
- const good = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA1', 'deals'));
- assert.strictEqual((await post(good, 'sha256=00')).status, 401);
-- const r1 = await post(good, signDelivery(good, 'whsec-test'));
-+ assert.strictEqual((await post(good, signDelivery(good, 'whsec-test'))).status, 401, 'v1 only: refused');
-+ assert.strictEqual((await post(good, signDeliveryHeaders(good, 'whsec-test', { now: Date.now() - 301000 }))).status, 401, 'stale v2: refused');
-+ const r1 = await post(good, signDeliveryHeaders(good, 'whsec-test'));
- assert.deepStrictEqual([r1.status, r1.json().outcome, r1.json().duplicate], [200, 'import_scheduled', false]);
-- const r2 = await post(good, signDelivery(good, 'whsec-test'));
-+ const r2 = await post(good, signDeliveryHeaders(good, 'whsec-test'));
- assert.strictEqual(r2.json().duplicate, true);
- const news = JSON.stringify(env('evt_01K5AAAAAAAAAAAAAAAAAAAAA2', 'news'));
-- assert.strictEqual((await post(news, signDelivery(news, 'whsec-test'))).json().outcome, 'ignored');
-+ assert.strictEqual((await post(news, signDeliveryHeaders(news, 'whsec-test'))).json().outcome, 'ignored');
- });
-
- await check('Sources down: the pull fails honestly (readiness says so), keeps its cursor, invents nothing', async () => {
-```
-
-### Trade
-
-```diff
-+++ b/server/events/webhook.js
-@@ -22,7 +22,7 @@
- router.post('/internal/events', express.raw({ type: '*/*', limit: '1mb' }), function receiveEventsDelivery(req, res) {
- if (!config.events.webhookSecret) return http.sendProblem(res, 404, 'route.not_found', { detail: 'Not found', ctx: req.ov });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.from('');
-- const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret);
-+ const delivery = parseDelivery(raw, req.headers, config.events.webhookSecret, { requireV2: true });
- if (!delivery) return http.sendProblem(res, 401, 'webhook.signature_invalid', { detail: 'The delivery signature does not verify', ctx: req.ov });
- const e = delivery.event;
- if (typeof e.event_id !== 'string' || typeof e.event_type !== 'string') return http.sendProblem(res, 400, 'webhook.malformed', { detail: 'Not an event envelope', ctx: req.ov });
-+++ b/test/sync.test.js
-@@ -6,7 +6,7 @@
- * wakes the sync and is idempotent per event id. Deterministic resolution.
- */
- const assert = require('assert');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDelivery, signDeliveryHeaders } = require('openvibe-sdk/events');
- const { boot, check, done } = require('./helpers/boot');
- const { mapItem } = require('../server/domain/mapping');
-
-@@ -119,12 +119,14 @@
- const body = JSON.stringify({ event: { event_id: 'evt_01JABCDEFGHJKMNPQRSTVWXYZ0', event_type: 'sources.item.created', payload: {} }, seq: 7 });
- const bad = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': 'sha256=00' } });
- assert.strictEqual(bad.status, 401);
-- const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });
-+ const v1only = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });
-+ assert.strictEqual(v1only.status, 401, 'v1 only: refused (requireV2)');
-+ const ok = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'hook-secret') } });
- assert.strictEqual(ok.status, 200);
- assert.strictEqual(ok.json().sync, true);
- await t.ctx.sync.run();
- assert.strictEqual((await t.get('/i/AAPL.json')).json().documents.length, 1);
-- const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', 'x-openvibe-signature': signDelivery(body, 'hook-secret') } });
-+ const again = await t.get('/internal/events', { method: 'POST', body, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(body, 'hook-secret') } });
- assert.strictEqual(again.json().duplicate, true);
- assert.strictEqual(again.json().sync, false);
- });
-```
-
-### Reviews
-
-```diff
-+++ b/server/http/consumer.js
-@@ -45,7 +45,7 @@
- if (!secrets.length) return http.sendProblem(res, 503, 'reviews.webhook_disabled', { detail: 'REVIEWS_EVENTS_SECRET is not set', ctx: req.ov });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
- let delivery = null;
-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
-+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
- if (!delivery) return http.sendProblem(res, 401, 'reviews.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
- const event = delivery.event;
- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
-+++ b/test/helpers.js
-@@ -10,7 +10,7 @@
- const os = require('os');
- const path = require('path');
- const { serviceAuth, ids } = require('openvibe-contracts');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDeliveryHeaders } = require('openvibe-sdk/events');
- const { load } = require('../server/config');
- const { start } = require('../server/index');
-
-@@ -212,7 +212,7 @@
- /** A signed delivery from OpenVibe.Events to POST /internal/events. */
- async function deliver(h, envelope, { secret = WEBHOOK_SECRET } = {}) {
- const raw = JSON.stringify({ event: envelope, seq: 1 });
-- return req(h, 'POST', '/internal/events', { body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw, secret) } });
-+ return req(h, 'POST', '/internal/events', { body: raw, headers: signDeliveryHeaders(raw, secret) });
- }
-
- function sourcesEvent(type, item, extra = {}) {
-```
-
-### Tips
-
-```diff
-+++ b/server/events/consumer.js
-@@ -48,7 +48,7 @@
- if (!secrets.length) return http.sendProblem(res, 503, 'tips.webhook_disabled', { detail: 'TIPS_EVENTS_SECRET is not set', ctx: req.ov });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
- let delivery = null;
-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
-+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
- if (!delivery) return http.sendProblem(res, 401, 'tips.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
- const event = delivery.event;
- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
-+++ b/test/helpers/app.js
-@@ -13,7 +13,7 @@
- const path = require('path');
- const http = require('http');
- const crypto = require('crypto');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDeliveryHeaders } = require('openvibe-sdk/events');
- const { startNetwork, startBilling, startEvents, startLive } = require('./stubs');
-
- const EVENTS_SECRET = 'e'.repeat(48);
-@@ -75,7 +75,7 @@
- const raw = JSON.stringify({ event, seq });
- const res = await fetch(`${base}/internal/events`, {
- method: 'POST', body: raw,
-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
-+ headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, secret), 'X-OpenVibe-Seq': String(seq) },
- });
- return { status: res.status, json: await res.json().catch(() => null) };
- }
-+++ b/test/settlement.test.js
-@@ -239,8 +239,8 @@
- const bad = await t.deliver(ev, { secret: 'x'.repeat(48) });
- assert.strictEqual(bad.status, 401);
- const raw = JSON.stringify({ event: ev, seq: 1 });
-- const { signDelivery } = require('openvibe-sdk/events');
-- const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });
-+ const { signDeliveryHeaders } = require('openvibe-sdk/events');
-+ const proxied = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, 'e'.repeat(48)), 'X-Forwarded-For': '203.0.113.9' } });
- assert.strictEqual(proxied.status, 404);
- const forged = await t.deliver({ ...ev, source: 'live' });
- assert.strictEqual(forged.json.outcome, 'ignored:source');
-```
-
-### VIP
-
-```diff
-+++ b/server/events/consumer.js
-@@ -77,7 +77,7 @@
- if (!secrets.length) return http.sendProblem(res, 503, 'vip.webhook_disabled', { detail: 'VIP_EVENTS_SECRET is not set', ctx: req.ov });
- const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
- let delivery = null;
-- for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s); if (delivery) break; }
-+ for (const s of secrets) { delivery = parseDelivery(raw, req.headers, s, { requireV2: true }); if (delivery) break; }
- if (!delivery) return http.sendProblem(res, 401, 'vip.bad_signature', { detail: 'X-OpenVibe-Signature does not verify', ctx: req.ov });
- const event = delivery.event;
- if (!event || typeof event.event_id !== 'string' || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(event.event_id)) {
-+++ b/test/helpers/app.js
-@@ -12,7 +12,7 @@
- const os = require('os');
- const path = require('path');
- const http = require('http');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDeliveryHeaders } = require('openvibe-sdk/events');
- const { startNetwork, startBilling } = require('./stubs');
-
- const EVENTS_SECRET = 'e'.repeat(48);
-@@ -68,7 +68,7 @@
- const raw = JSON.stringify({ event, seq });
- const res = await fetch(`${base}/internal/events`, {
- method: 'POST', body: raw,
-- headers: { 'Content-Type': 'application/json', 'X-OpenVibe-Signature': signDelivery(raw, secret), 'X-OpenVibe-Seq': String(seq) },
-+ headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(raw, secret), 'X-OpenVibe-Seq': String(seq) },
- });
- return { status: res.status, json: await res.json().catch(() => null) };
- }
-+++ b/test/entitlements.test.js
-@@ -5,7 +5,7 @@
- * valid_until (+ grace); ordering and duplicates are safe. Injected clock throughout.
- */
- const assert = require('assert');
--const { signDelivery } = require('openvibe-sdk/events');
-+const { signDeliveryHeaders } = require('openvibe-sdk/events');
- const { boot, harness } = require('./helpers/app');
- const { DAY } = require('./helpers/stubs');
-
-@@ -56,7 +56,7 @@
- assert.strictEqual((await t.deliver(forged)).json.outcome, 'ignored:source');
- assert.strictEqual(t.domain.entitlements.getRow(m.subject, creator.subject), null);
- const raw = JSON.stringify({ event: ent, seq: 1 });
-- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: { 'X-OpenVibe-Signature': signDelivery(raw + ' ', t.EVENTS_SECRET) } });
-+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body: raw, headers: signDeliveryHeaders(raw + ' ', t.EVENTS_SECRET) });
- assert.strictEqual(res.status, 401);
- });
-
-```
-
-### Examples (`examples/webhook-consumer`)
-
-Also update `examples/webhook-consumer/README.md`. Its "Signature scheme note" (line 67) says the HMAC has no timestamp. Replace it with the v2 description: the signature covers `"<t>.<raw body>"`, deliveries are refused outside ±300 s, and `requireV2` is on. The inbox stays mandatory, because delivery is at least once. Line 4 and the root README's table row name only `X-OpenVibe-Signature`, so add v2 there too.
-
-```diff
-+++ b/examples/webhook-consumer/server.js
-@@ -68,7 +68,7 @@
-
- function verify(raw, headers) {
- for (const secret of config.secrets) {
-- const d = parseDelivery(raw, headers, secret);
-+ const d = parseDelivery(raw, headers, secret, { requireV2: true });
- if (d) return d;
- }
- return null;
-+++ b/examples/webhook-consumer/test/smoke.test.js
-@@ -106,7 +106,9 @@
- const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);
- assert.equal((await replay(last, { 'X-OpenVibe-Signature': signDelivery(forgedBody, 'whsec_not_the_secret'), 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401);
- assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401, 'changed bytes, old signature');
-- assert.equal((await replay(last, { 'X-OpenVibe-Signature': '' })).status, 401);
-+ assert.equal((await replay(last, { 'X-OpenVibe-Signature': '', 'X-OpenVibe-Signature-V2': '' })).status, 401);
-+ const { 'X-OpenVibe-Signature-V2': _v2, ...v1only } = last.headers;
-+ assert.equal((await realFetch(local, { method: 'POST', headers: v1only, body: last.body })).status, 401, 'v1 only (v2 stripped): refused');
- // Header and signed body disagree on the event id: refused.
- assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);
- assert.deepEqual(handled, [e1.event_id, e2.event_id]);
-```
@@ -13,9 +13,9 @@
@@ -13,9 +13,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -1059,9 +1059,9 @@
@@ -1059,9 +1059,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.76.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "integrity": "sha512-zTdx776KWrcJKm1JTnf+0+BnsvswdvXy1ldPyse1hSqhnW/SSr98sX696eRetFUhTl8qVM1xSRCykqIUbqItPw==",
+ "version": "0.80.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
+ "integrity": "sha512-7RCNFKxFRey3PquBwyQkSu8Bu2q2Ek0yb248SJWaymcg/g9PrlStFVUDTLMgh8i7BZSaZSngj0nojpwYTLGYDQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1075,9 +1075,9 @@
@@ -1075,9 +1075,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.20.4",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",
- "integrity": "sha512-IMvvBrFhCVO4sm2Ii694BslcnKHSV7YABXviLqrDI/2lTr9NKIH80z3HXxwMUgj0fwZDWBWcNgqPs6OaWIB0GQ==",
+ "version": "0.25.1",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "integrity": "sha512-c6K1riU7xT60qEd0YFn4QDPjWXTglo0LcE4cU8TSrmRTJL7TPAT7/iqQjS2UACt2zmQ1soNM8gfIZf7v9sVdZg==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -1104,9 +1104,9 @@
@@ -1104,9 +1104,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "1.28.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
- "integrity": "sha512-WLd11lDG75veVi7mFwngquK2nqIQV86Z14Ad/xE2dYvyMT5mhKJxxxlKUoskpdyLeap0ucMvzzS8Tg7vPGwYoQ==",
+ "version": "2.2.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
+ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -25,9 +25,9 @@
@@ -25,9 +25,9 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.76.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.20.4",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.28.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.80.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.1",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {