openvibers/openvibe.examples Commits
webhook-consumer requires signature v2 (replay window); openvibe-sdk v0.4.0
Changes
@@@ -7,7 +7,7 @@Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs threeof them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; itis not part of CI or `npm test`, and it was not run against production as part of this release.--**Built on:** [openvibe-sdk v0.3.1](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.1), and++**Built on:** [openvibe-sdk v0.4.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.4.0), andopenvibe-contracts v0.28.0 for the mod manifest.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).@@@ -22,7 +22,7 @@ OpenVibe services themselves are AGPL-3.0.| [node-server-app](examples/node-server-app) | Client-credentials app token per audience and what it carries, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth` (`getTokenInfo`), `/registry`, `/core` || [media-uploader](examples/media-uploader) | Upload into, read from and delete in your project's Media tenant; sandbox files come back with signed URLs | `openvibe-sdk/media` || [event-subscriber](examples/event-subscriber) | Publish to your project's topic `app.<project_key>.*`, pull it with a durable cursor, gaps; anonymous realtime for public events | `openvibe-sdk/events` (`createAppEvents`), `/realtime` |--| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) |++| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature-V2` (±300 s replay window) verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) || [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, its SSE stream resumed with `Last-Event-ID` | `openvibe-sdk/jobs` || [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` || [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |@@@ -163,7 +163,7 @@ Repository checks (`test/`):value in a `.env.example`.- `structure.test.js` checks the nine examples are complete and copyable: README with "What itproves" and "Run it against the real platform", a `.env.example` that names every variable the-- code reads, MIT license, `openvibe-sdk` pinned to the v0.3.1 tag, no `file:` links; and that++ code reads, MIT license, `openvibe-sdk` pinned to the v0.4.0 tag, no `file:` links; and that`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.## Layout@@@ -190,7 +190,7 @@ test/public-surface.test.js, test/structure.test.js## Depends on--- OpenVibe.SDK (v0.3.1), OpenVibe.Contracts (v0.28.0, mod manifest only)++- OpenVibe.SDK (v0.4.0), OpenVibe.Contracts (v0.28.0, mod manifest only)- at run time against the real platform: Network (tokens, registry, developer projects), Events,Media, Tools, Chat (on openvibe.live)@@@ -4,7 +4,7 @@"code": true,"wave": "W20","version": "0.2.0",-- "sdk": "openvibe-sdk v0.3.1",++ "sdk": "openvibe-sdk v0.4.0","contracts": "openvibe-contracts v0.28.0 (mod-manifest example)","ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network","e2e": "npm run e2e runs media-uploader, event-subscriber (pull) and tool-job against the real platform with a sandbox app's OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID; on demand only, not in CI or npm test; not run against production as part of this release",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -1,7 +1,8 @@# Webhook consumerReceive OpenVibe.Events deliveries for your project's events over HTTPS, verify--`X-OpenVibe-Signature`, and handle every event **exactly once** even though delivery is at least once.++`X-OpenVibe-Signature-V2` (signature v2, with a replay window), and handle every event **exactly once**++even though delivery is at least once.```bashnode --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*@@@ -64,6 +65,12 @@ endpoints: a private address is `422 events.endpoint_not_allowed`).Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox eventsonly. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.--Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery--can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the--inbox is not optional.++Signature scheme note: every delivery carries `X-OpenVibe-Timestamp` and++`X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed with the++subscription secret, besides the older body-only `X-OpenVibe-Signature` (v1). The consumer calls++`parseDelivery(raw, headers, secret, { requireV2: true })`: it refuses a delivery whose `t` is more++than ±300 s from its own clock, and it refuses a delivery with no v2 header, so a captured delivery++cannot be replayed later with the v2 header stripped off. Keep the server's clock synced (NTP).++Events signs every attempt afresh, so retries and replays from its dead-letter queue arrive inside++the window. The inbox is still not optional: delivery is at least once, so a retry after a lost 2xx++is a duplicate the inbox acknowledges without handling it again.@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0","better-sqlite3": "^11.10.0"}}@@@ -7,11 +7,14 @@* node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe** A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with-- * X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>.++ * X-OpenVibe-Timestamp: <unix seconds> and++ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>++ * (plus the older body-only X-OpenVibe-Signature, which this consumer does not accept on its own).** 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).-- * 2. openvibe-sdk/events parseDelivery() checks the signature in constant time and parses it.-- * A bad signature is 401 and nothing else is said.++ * 2. openvibe-sdk/events parseDelivery(..., { requireV2: true }) checks the v2 signature in++ * constant time, refuses a timestamp more than 300 s from this clock or a missing v2 header++ * (a replayed capture), and parses it. A bad signature is 401 and nothing else is said.* 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs* the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,* and a handler that throws leaves no receipt: the 500 makes Events retry later.@@@ -68,7 +71,7 @@ function createConsumer(config, { db = openDatabase(config.dbPath), handle = ()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;@@@ -11,7 +11,7 @@ const fs = require('node:fs');const os = require('node:os');const path = require('node:path');const { createMockPlatform } = require('openvibe-sdk/testing');--const { signDelivery, projectKey, appSource } = require('openvibe-sdk/events');++const { signDelivery, signDeliveryHeaders, projectKey, appSource } = require('openvibe-sdk/events');const { createConsumer, loadConfig, openDatabase } = require('../server');const { createSubscription, loadConfig: loadSubscribeConfig } = require('../subscribe');@@@ -100,13 +100,16 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);assert.deepEqual(handled, [e1.event_id, e2.event_id]);-- // Attacks: wrong secret, tampered body, missing signature: 401 and no side effect.++ // Attacks: wrong secret, tampered body, missing signature, v1 only, stale v2: 401 and no side effect.const last = sent.at(-1);const e3 = appEvent('order.refunded');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');++ assert.equal((await replay(last, signDeliveryHeaders(last.body, created.secret, { now: Date.now() - 301000 }))).status, 401, 'stale v2 (outside the 300 s window): 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]);@@@ -12,7 +12,7 @@"examples/*"],"devDependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -23,7 +23,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -45,7 +45,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -56,7 +56,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -68,7 +68,7 @@"license": "MIT","dependencies": {"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -79,7 +79,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -90,7 +90,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -101,7 +101,7 @@"version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -113,7 +113,7 @@"license": "MIT","dependencies": {"better-sqlite3": "^11.10.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"},"engines": {"node": ">=22"@@@ -469,9 +469,9 @@"link": true},"node_modules/openvibe-sdk": {-- "version": "0.3.1",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",-- "integrity": "sha512-6fMz/kuIucCOYJW5CAUusHgK6rm5PhIC1/0IKmV87a4nAnEsjWk1h2JTX7HKw0JSO0O+9f/Fydj1Iwq08VnxGw==",++ "version": "0.4.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",++ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==","license": "MIT","engines": {"node": ">=20"@@@ -17,6 +17,6 @@"node": ">=22"},"devDependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"}}@@@ -12,7 +12,7 @@ const path = require('node:path');const ROOT = path.join(__dirname, '..');const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];--const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1';++const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0';const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');@@@ -38,7 +38,7 @@ for (const name of EXPECTED) {assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);-- if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.1`);++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.4.0`);}const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
is not part of CI or `npm test`, and it was not run against production as part of this release.
is not part of CI or `npm test`, and it was not run against production as part of this release.
-**Built on:** [openvibe-sdk v0.3.1](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.1), and
+**Built on:** [openvibe-sdk v0.4.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.4.0), and
openvibe-contracts v0.28.0 for the mod manifest.
openvibe-contracts v0.28.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
@@ -22,7 +22,7 @@ OpenVibe services themselves are AGPL-3.0.
@@ -22,7 +22,7 @@ OpenVibe services themselves are AGPL-3.0.
| [node-server-app](examples/node-server-app) | Client-credentials app token per audience and what it carries, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth` (`getTokenInfo`), `/registry`, `/core` |
| [node-server-app](examples/node-server-app) | Client-credentials app token per audience and what it carries, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth` (`getTokenInfo`), `/registry`, `/core` |
| [media-uploader](examples/media-uploader) | Upload into, read from and delete in your project's Media tenant; sandbox files come back with signed URLs | `openvibe-sdk/media` |
| [media-uploader](examples/media-uploader) | Upload into, read from and delete in your project's Media tenant; sandbox files come back with signed URLs | `openvibe-sdk/media` |
| [event-subscriber](examples/event-subscriber) | Publish to your project's topic `app.<project_key>.*`, pull it with a durable cursor, gaps; anonymous realtime for public events | `openvibe-sdk/events` (`createAppEvents`), `/realtime` |
| [event-subscriber](examples/event-subscriber) | Publish to your project's topic `app.<project_key>.*`, pull it with a durable cursor, gaps; anonymous realtime for public events | `openvibe-sdk/events` (`createAppEvents`), `/realtime` |
-| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) |
+| [webhook-consumer](examples/webhook-consumer) | An app subscription on your own topic; `X-OpenVibe-Signature-V2` (±300 s replay window) verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`createAppEvents`, `parseDelivery`, `createInbox`) |
| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, its SSE stream resumed with `Last-Event-ID` | `openvibe-sdk/jobs` |
| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, its SSE stream resumed with `Last-Event-ID` | `openvibe-sdk/jobs` |
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [oauth-app](examples/oauth-app) | Authorization code + PKCE for a confidential app, exchange on the server, offline app-token verification, session hygiene | `openvibe-sdk/auth` |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
| [chat-bot](examples/chat-bot) | A bot on Chat's WebSocket protocol that stays in its one configured room and always identifies as a bot | Chat `/ws/chat` protocol (no SDK client: Chat is first-party) |
@@ -163,7 +163,7 @@ Repository checks (`test/`):
@@ -163,7 +163,7 @@ Repository checks (`test/`):
value in a `.env.example`.
value in a `.env.example`.
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
- `structure.test.js` checks the nine examples are complete and copyable: README with "What it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
proves" and "Run it against the real platform", a `.env.example` that names every variable the
- code reads, MIT license, `openvibe-sdk` pinned to the v0.3.1 tag, no `file:` links; and that
+ code reads, MIT license, `openvibe-sdk` pinned to the v0.4.0 tag, no `file:` links; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
## Layout
## Layout
@@ -190,7 +190,7 @@ test/public-surface.test.js, test/structure.test.js
@@ -190,7 +190,7 @@ test/public-surface.test.js, test/structure.test.js
## Depends on
## Depends on
-- OpenVibe.SDK (v0.3.1), OpenVibe.Contracts (v0.28.0, mod manifest only)
+- OpenVibe.SDK (v0.4.0), OpenVibe.Contracts (v0.28.0, mod manifest only)
- at run time against the real platform: Network (tokens, registry, developer projects), Events,
- at run time against the real platform: Network (tokens, registry, developer projects), Events,
Media, Tools, Chat (on openvibe.live)
Media, Tools, Chat (on openvibe.live)
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
"version": "0.2.0",
"version": "0.2.0",
- "sdk": "openvibe-sdk v0.3.1",
+ "sdk": "openvibe-sdk v0.4.0",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
"contracts": "openvibe-contracts v0.28.0 (mod-manifest example)",
"ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network",
"ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform (developer-app rules for Events, Media and Tools as in production) plus a local Chat WebSocket mock; none reaches the network",
"e2e": "npm run e2e runs media-uploader, event-subscriber (pull) and tool-job against the real platform with a sandbox app's OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID; on demand only, not in CI or npm test; not run against production as part of this release",
"e2e": "npm run e2e runs media-uploader, event-subscriber (pull) and tool-job against the real platform with a sandbox app's OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID; on demand only, not in CI or npm test; not run against production as part of this release",
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0"
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -13,6 +13,6 @@
@@ -13,6 +13,6 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
# Webhook consumer
# Webhook consumer
Receive OpenVibe.Events deliveries for your project's events over HTTPS, verify
Receive OpenVibe.Events deliveries for your project's events over HTTPS, verify
-`X-OpenVibe-Signature`, and handle every event **exactly once** even though delivery is at least once.
+`X-OpenVibe-Signature-V2` (signature v2, with a replay window), and handle every event **exactly once**
+even though delivery is at least once.
```bash
```bash
node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*
node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*
@@ -64,6 +65,12 @@ endpoints: a private address is `422 events.endpoint_not_allowed`).
@@ -64,6 +65,12 @@ endpoints: a private address is `422 events.endpoint_not_allowed`).
Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox events
Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox events
only. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.
only. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.
-Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery
-can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
-inbox is not optional.
+Signature scheme note: every delivery carries `X-OpenVibe-Timestamp` and
+`X-OpenVibe-Signature-V2: t=<unix seconds>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, keyed with the
+subscription secret, besides the older body-only `X-OpenVibe-Signature` (v1). The consumer calls
+`parseDelivery(raw, headers, secret, { requireV2: true })`: it refuses a delivery whose `t` is more
+than ±300 s from its own clock, and it refuses a delivery with no v2 header, so a captured delivery
+cannot be replayed later with the v2 header stripped off. Keep the server's clock synced (NTP).
+Events signs every attempt afresh, so retries and replays from its dead-letter queue arrive inside
+the window. The inbox is still not optional: delivery is at least once, so a retry after a lost 2xx
+is a duplicate the inbox acknowledges without handling it again.
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"node": ">=22"
"node": ">=22"
},
},
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
}
}
}
}
@@ -7,11 +7,14 @@
@@ -7,11 +7,14 @@
* node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe
* node --env-file=.env server.js # POST http://localhost:3003/webhooks/openvibe
*
*
* A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with
* A delivery is POSTed as { "event": <events.event-envelope@1>, "seq": <n> } with
- * X-OpenVibe-Signature: sha256=<HMAC-SHA256 of the raw body with the subscription secret>.
+ * X-OpenVibe-Timestamp: <unix seconds> and
+ * X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<HMAC-SHA256 of "<t>.<raw body>" with the subscription secret>
+ * (plus the older body-only X-OpenVibe-Signature, which this consumer does not accept on its own).
*
*
* 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).
* 1. Read the RAW body (the signature covers the exact bytes; never re-serialize JSON first).
- * 2. openvibe-sdk/events parseDelivery() checks the signature in constant time and parses it.
- * A bad signature is 401 and nothing else is said.
+ * 2. openvibe-sdk/events parseDelivery(..., { requireV2: true }) checks the v2 signature in
+ * constant time, refuses a timestamp more than 300 s from this clock or a missing v2 header
+ * (a replayed capture), and parses it. A bad signature is 401 and nothing else is said.
* 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs
* 3. Delivery is at least once (retries, replays). inbox.once() records the event id and runs
* the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,
* the handler in ONE SQLite transaction, so a repeat is answered 200 without running it again,
* and a handler that throws leaves no receipt: the 500 makes Events retry later.
* and a handler that throws leaves no receipt: the 500 makes Events retry later.
@@ -68,7 +71,7 @@ function createConsumer(config, { db = openDatabase(config.dbPath), handle = ()
@@ -68,7 +71,7 @@ function createConsumer(config, { db = openDatabase(config.dbPath), handle = ()
function verify(raw, headers) {
function verify(raw, headers) {
for (const secret of config.secrets) {
for (const secret of config.secrets) {
- const d = parseDelivery(raw, headers, secret);
+ const d = parseDelivery(raw, headers, secret, { requireV2: true });
if (d) return d;
if (d) return d;
}
}
return null;
return null;
@@ -11,7 +11,7 @@ const fs = require('node:fs');
@@ -11,7 +11,7 @@ const fs = require('node:fs');
const os = require('node:os');
const os = require('node:os');
const path = require('node:path');
const path = require('node:path');
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createMockPlatform } = require('openvibe-sdk/testing');
-const { signDelivery, projectKey, appSource } = require('openvibe-sdk/events');
+const { signDelivery, signDeliveryHeaders, projectKey, appSource } = require('openvibe-sdk/events');
const { createConsumer, loadConfig, openDatabase } = require('../server');
const { createConsumer, loadConfig, openDatabase } = require('../server');
const { createSubscription, loadConfig: loadSubscribeConfig } = require('../subscribe');
const { createSubscription, loadConfig: loadSubscribeConfig } = require('../subscribe');
@@ -100,13 +100,16 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
@@ -100,13 +100,16 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);
assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
- // Attacks: wrong secret, tampered body, missing signature: 401 and no side effect.
+ // Attacks: wrong secret, tampered body, missing signature, v1 only, stale v2: 401 and no side effect.
const last = sent.at(-1);
const last = sent.at(-1);
const e3 = appEvent('order.refunded');
const e3 = appEvent('order.refunded');
const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);
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-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-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');
+ assert.equal((await replay(last, signDeliveryHeaders(last.body, created.secret, { now: Date.now() - 301000 }))).status, 401, 'stale v2 (outside the 300 s window): refused');
// Header and signed body disagree on the event id: 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.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
"examples/*"
"examples/*"
],
],
"devDependencies": {
"devDependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -23,7 +23,7 @@
@@ -23,7 +23,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -45,7 +45,7 @@
@@ -45,7 +45,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -56,7 +56,7 @@
@@ -56,7 +56,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -68,7 +68,7 @@
@@ -68,7 +68,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -79,7 +79,7 @@
@@ -79,7 +79,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -90,7 +90,7 @@
@@ -90,7 +90,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -101,7 +101,7 @@
@@ -101,7 +101,7 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -113,7 +113,7 @@
@@ -113,7 +113,7 @@
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"better-sqlite3": "^11.10.0",
"better-sqlite3": "^11.10.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -469,9 +469,9 @@
@@ -469,9 +469,9 @@
"link": true
"link": true
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.3.1",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
- "integrity": "sha512-6fMz/kuIucCOYJW5CAUusHgK6rm5PhIC1/0IKmV87a4nAnEsjWk1h2JTX7HKw0JSO0O+9f/Fydj1Iwq08VnxGw==",
+ "version": "0.4.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0",
+ "integrity": "sha512-Hyz55YOMIs0xwCQ1NXsN0sUYhlbKXAX3uWY2fPCdEEL18FXUYVhK8wlG/5JMMypFHnjDiSd9VHzBNG12hWsmkQ==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -17,6 +17,6 @@
@@ -17,6 +17,6 @@
"node": ">=22"
"node": ">=22"
},
},
"devDependencies": {
"devDependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0"
}
}
}
}
@@ -12,7 +12,7 @@ const path = require('node:path');
@@ -12,7 +12,7 @@ const path = require('node:path');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
const EXPECTED = ['browser-app', 'chat-bot', 'event-subscriber', 'media-uploader', 'mod-manifest', 'node-server-app', 'oauth-app', 'tool-job', 'webhook-consumer'];
-const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1';
+const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.4.0';
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
@@ -38,7 +38,7 @@ for (const name of EXPECTED) {
@@ -38,7 +38,7 @@ for (const name of EXPECTED) {
assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
assert.equal(pkg.scripts.test, 'node test/smoke.test.js', `${name}: npm test runs the smoke test`);
for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
for (const [dep, spec] of Object.entries({ ...pkg.dependencies, ...pkg.devDependencies })) {
assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
assert.ok(!/^(file|link):/.test(spec), `${name}: ${dep} is not a local link`);
- if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.1`);
+ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.4.0`);
}
}
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');