openvibers/openvibe.examples Commits

openvibe-sdk v0.3.1: no local stand-ins for Events or Media; openvibe-contracts v0.28.0 for mod-manifest

OP OpenVibers committed 23 Sept 2026 · c09aeceb03 · 30 files, +200 −438

- Pin openvibe-sdk v0.3.1 everywhere; mod-manifest on openvibe-contracts v0.28.0.
- event-subscriber, webhook-consumer: createAppEvents() / projectKey() / appSource() from
  openvibe-sdk/events replace the local copies; topics and subscription patterns are
  project-relative (default *), OV_PLATFORM_TOPICS adds public first-party topics to a pull.
  Both test/mock-app-events.js shims are deleted: the SDK mock plays Events' developer-app rules.
- media-uploader: the smoke test runs on the production-like mock Media (tenant-tagged keys,
  prj_…-sandbox tenant, signed url with sandbox: true served only with its signature, read back
  and delete); the hand-made sandbox response is gone. url is public_url or the SDK's signed_url.
- tool-job: jobs go to the default https://img.openvibe.tools, which the mock now answers;
  acceptSandbox stays for openvibe.tools.
- README, STATUS, CI comment follow.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex e71673b..3456069 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -12,7 +12,6 @@ jobs:
with:
node-version: 22.22.1 # the Node version the platform's services run
- run: npm ci # one install for every example (npm workspaces)
-- # Every example's smoke test runs against openvibe-sdk/testing's mock platform and two small
-- # documented stand-ins (Events' app rules, Chat WebSocket). No test reaches the network or a
-- # real service. `npm run e2e` (the real platform, needs credentials) is never run here.
++ # Every example's smoke test runs against openvibe-sdk/testing's mock platform (and a small
++ # Chat WebSocket mock for chat-bot). No test reaches the network or a real service. `npm run e2e` (the real platform, needs credentials) is never run here.
- run: npm test
ddiff --git a/README.md b/README.md
iindex 029545c..8c0ccda 100644
---- a/README.md
++++ b/README.md
@@@ -7,8 +7,8 @@
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
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.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.0), and
--openvibe-contracts v0.26.0 for the mod manifest.
++**Built on:** [openvibe-sdk v0.3.1](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.1), and
++openvibe-contracts v0.28.0 for the mod manifest.
**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).
**License:** MIT ([LICENSE](LICENSE)), so you can copy any example into your own project. The
@@@ -21,8 +21,8 @@ OpenVibe services themselves are AGPL-3.0.
| [browser-app](examples/browser-app) | A plain page (no build step) imports the SDK's browser bundle, reads the registry cross-origin and signs in with PKCE; a public app needs no secret | `openvibe-sdk/browser/openvibe-sdk.mjs`, `/auth` (`exchangeCode`, `verifyAppToken`) |
| [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`, `/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` (`subscriptions.create`, `parseDelivery`, `createInbox`) |
++| [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`) |
| [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) |
@@@ -86,7 +86,7 @@ at the root one: `node --env-file=../../.env upload.js ./logo.png`.
|---|---|---|---|
| [node-server-app](examples/node-server-app) | confidential | any | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCES` |
| [media-uploader](examples/media-uploader) | confidential | `media.object.upload`, `media.object.read` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_PROJECT_ID` |
--| [event-subscriber](examples/event-subscriber) | confidential | `events.app.publish`, `events.app.read` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_PROJECT_ID`, `OV_TOPICS`; realtime: `OV_EVENTS_MODE=realtime`, `OV_TOPICS` only |
++| [event-subscriber](examples/event-subscriber) | confidential | `events.app.publish`, `events.app.read` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_PROJECT_ID`, `OV_TOPICS`, `OV_PLATFORM_TOPICS`; realtime: `OV_EVENTS_MODE=realtime`, `OV_TOPICS` only |
| [webhook-consumer](examples/webhook-consumer) | confidential | `events.app.subscribe` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`; the consumer: `OV_WEBHOOK_SECRET` (written by `subscribe.js`); a public https host |
| [tool-job](examples/tool-job) | confidential | `tools.job.create`, `tools.job.read` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, optional `OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT` |
| [oauth-app](examples/oauth-app) | confidential, redirect `http://localhost:3009/callback` | every capability in `OV_SCOPE` | `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE` |
@@@ -147,11 +147,12 @@ How the tests stay offline:
- Network, Events, Media and Tools jobs are `createMockPlatform()` from `openvibe-sdk/testing`: an
in-process fake at the real public origins, with real RS256 tokens, developer apps and projects,
-- sandbox refusal unless a service opts in, `/oauth/authorize` with auto-consent, retention gaps
++ sandbox handling per service, `/oauth/authorize` with auto-consent, retention gaps
(`pruneEvents()`), a delivery worker (`deliverEvents()`) and Tools jobs with SSE.
--- Where the mock falls short, a small documented stand-in in the example's `test/` folder fills
-- in: `mock-app-events.js` (Events' `events.app.*` rules, in event-subscriber and
-- webhook-consumer) and `mock-chat-server.js` (Chat's WebSocket, which the SDK does not mock).
++- The mock plays the developer-app rules the way production does: Events' `events.app.*`
++ (own-project topics, `app-<ULID>` sources, env separation), Media's project tenants (`prj_…` and
++ `prj_…-sandbox`, signed URLs for sandbox files) and Tools at its satellites' origins.
++- Chat is the only service the SDK does not mock: chat-bot carries `test/mock-chat-server.js`.
- Every smoke test replaces the global `fetch` with one that throws, so an accidental real network
call fails the test.
@@@ -162,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 it
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.0 tag, no `file:` links; and that
++ code reads, MIT license, `openvibe-sdk` pinned to the v0.3.1 tag, no `file:` links; and that
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
## Layout
@@@ -189,14 +190,14 @@ test/public-surface.test.js, test/structure.test.js
## Depends on
--- OpenVibe.SDK (v0.3.0), OpenVibe.Contracts (v0.26.0, mod manifest only)
++- OpenVibe.SDK (v0.3.1), 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)
## Acceptance
--- CI runs every example: **yes**, against the SDK's mock platform and two small documented
-- stand-ins (no network). Against the real platform on demand: `npm run e2e` with a sandbox app's
++- CI runs every example: **yes**, against the SDK's mock platform (and a small Chat WebSocket
++ mock for chat-bot; no network). Against the real platform on demand: `npm run e2e` with a sandbox app's
credentials; there is no CI project with credentials.
- A new developer goes from account creation to a working Media, event and capability
integration using only public documentation, the SDK and scoped credentials: the platform path
ddiff --git a/STATUS.json b/STATUS.json
iindex 8a05cda..136609c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,9 +4,9 @@
"code": true,
"wave": "W20",
"version": "0.2.0",
-- "sdk": "openvibe-sdk v0.3.0",
-- "contracts": "openvibe-contracts v0.26.0 (mod-manifest example)",
-- "ci": "every example's smoke test runs on Node 22.22.1 against openvibe-sdk/testing's mock platform plus two documented stand-ins (Events app rules, Chat WebSocket); none reaches the network",
++ "sdk": "openvibe-sdk v0.3.1",
++ "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",
"platform": "sandbox developer path (projects, sandbox grants, app tokens for openvibe.media/events/tools, Media project tenant, Events app.<project_key>.* events, Tools jobs, CORS-open discovery) verified in production on 2026-09-23 by the platform's W20 exit check",
"not_working": [
ddiff --git a/examples/browser-app/package.json b/examples/browser-app/package.json
iindex 6649c2e..c4face4 100644
---- a/examples/browser-app/package.json
++++ b/examples/browser-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/event-subscriber/.env.example b/examples/event-subscriber/.env.example
iindex 8b9864e..d89d8f5 100644
---- a/examples/event-subscriber/.env.example
++++ b/examples/event-subscriber/.env.example
@@@ -6,8 +6,11 @@ OV_CLIENT_ID=
OV_CLIENT_SECRET=
# Optional: your project id (prj_...); read from the app token when empty.
OV_PROJECT_ID=
--# Pull: empty means app.<project_key>.* (your project). Realtime: required, e.g. chat.message.created
++# Pull: patterns relative to your project (order.*); empty means * = app.<project_key>.*.
++# Realtime: required, public first-party topics, e.g. chat.message.created
OV_TOPICS=
++# Pull only: public first-party topics to read as well (e.g. live.stream.*)
++OV_PLATFORM_TOPICS=
OV_CURSOR_PATH=./data/cursor.json
OV_POLL_MS=5000
# Optional: an explicit Events origin instead of the one the registry advertises.
ddiff --git a/examples/event-subscriber/README.md b/examples/event-subscriber/README.md
iindex 92653f7..5df5599 100644
---- a/examples/event-subscriber/README.md
++++ b/examples/event-subscriber/README.md
@@@ -15,9 +15,12 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
- **Your own topic.** A developer app publishes and reads `app.<project_key>.<name…>`, where
`project_key` is `p` followed by the project's ULID in lowercase (`prj_01JAB…` → `p01jab…`), and
its events carry `source: app-<app ULID in lowercase>` and `actor: { type: 'app', id: <app id> }`.
-- `publish.js` builds exactly that with `openvibe-sdk/events` `publish()` (which fills `event_id`,
-- `timestamp`, `version` and `trace_id`; a repeated `event_id` is answered as a duplicate, so
-- retries are safe). Events refuses another project's `app.*` topic (`403 events.topic_not_allowed`).
++ `openvibe-sdk/events` `createAppEvents({ projectId, appId })` fills all of that in: event types
++ and topic patterns are written relative to the project (`order.created`, `order.*`, `*`), and
++ `publish()` adds `event_id`, `timestamp`, `version` and `trace_id` (a repeated `event_id` is
++ answered as a duplicate, so retries are safe). Another project's `app.*` topic is refused by the
++ SDK before anything is sent (and by Events with `403 events.topic_not_allowed`).
++ `OV_PLATFORM_TOPICS` adds public first-party topics (`live.stream.*`) to a pull.
- **Pull** (`openvibe-sdk/events` `iterate()`): pages from the saved cursor to the head. The
cursor is saved after each handled event, and moved past non-matching events (the page's
`next_after_seq`) in `onPage`, which the SDK calls only after every item of that page was
@@@ -35,10 +38,9 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
| File | What |
|---|---|
--| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`, `resolveTopics()`), `createCursorStore()`, `projectKey()`, `appSource()` |
++| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`, `resolveTopics()`), `createCursorStore()` |
| `publish.js` | `publishAppEvent()`: one event on your project's topic |
--| `test/mock-app-events.js` | Events' developer-app rules in front of the SDK mock (see below) |
--| `test/smoke.test.js` | publish, pull with cursor and failure, pull gap after pruning, topic scope, anonymous realtime, reconnect, restart, cursor-ahead gap |
++| `test/smoke.test.js` | publish, pull with cursor and failure, pull gap after pruning, platform topics, topic scope, anonymous realtime, reconnect, restart, cursor-ahead gap |
## Run the smoke test
@@@ -46,11 +48,10 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
npm test
```
--Network and Events are `openvibe-sdk/testing`'s mock platform; retention gaps come from its
--`pruneEvents()`. The SDK 0.3.0 mock's Events only knows the first-party capabilities
--(`events.event.read`, …), so `test/mock-app-events.js` checks an app token's `events.app.*`
--capability, the `app.<project_key>.` prefix, the `app-<ULID>` source and the topic scope the way
--Events does, then hands the call to the mock. It does not model sandbox/production separation.
++Network and Events are `openvibe-sdk/testing`'s mock platform, which plays Events'
++developer-app rules (`events.app.*` capabilities, the `app.<project_key>.` prefix, the
++`app-<ULID>` source, own-project and own-environment reads, no app events on realtime);
++retention gaps come from its `pruneEvents()`.
## Run it against the real platform
@@@ -65,8 +66,8 @@ Things to know:
- Pull goes to `https://events.openvibe.network/api/v1/events` (the origin comes from the registry).
You see your project's events in your app's environment only: a sandbox app never sees production
-- events and the other way round. Adding a first-party pattern such as `live.stream.*` to
-- `OV_TOPICS` returns only that namespace's `public` events.
++ events and the other way round. A first-party pattern in `OV_PLATFORM_TOPICS`, such as
++ `live.stream.*`, returns only that namespace's `public` events.
- Realtime never streams `app.*` events, to anyone; use pull (or a webhook, see
[webhook-consumer](../webhook-consumer)) for your own events. Anonymous realtime shows public
first-party events only; which ones arrive depends on which producers publish public events.
ddiff --git a/examples/event-subscriber/package.json b/examples/event-subscriber/package.json
iindex 5fbc2d5..52edb0d 100644
---- a/examples/event-subscriber/package.json
++++ b/examples/event-subscriber/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/event-subscriber/publish.js b/examples/event-subscriber/publish.js
iindex 54f5b53..7f1b00c 100644
---- a/examples/event-subscriber/publish.js
++++ b/examples/event-subscriber/publish.js
@@@ -7,17 +7,17 @@
* node --env-file=.env publish.js # app.<project_key>.example.ping
* node --env-file=.env publish.js order.created '{"total":3}'
*
-- * An app publishes with events.app.publish (audience openvibe.events). Events enforces the names:
++ * An app publishes with events.app.publish (audience openvibe.events). Events enforces the names,
++ * and openvibe-sdk/events createAppEvents() fills them in from the project and app ids:
* event_type app.<project_key>.<name> project_key = p + your project's ULID in lowercase
* source app-<your app's ULID in lowercase>
* actor { type: 'app', id: <your app id> }
-- * openvibe-sdk/events fills event_id (evt_<ULID>), timestamp, version and trace_id. Publishing the
-- * same event_id again is answered with the stored seq (duplicate: true), so retries are safe.
++ * plus event_id (evt_<ULID>), timestamp, version and trace_id. Publishing the same event_id again
++ * is answered with the stored seq (duplicate: true), so retries are safe.
*/
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient } = require('openvibe-sdk/events');
--const { projectKey, appSource } = require('./subscriber');
++const { createAppEvents } = require('openvibe-sdk/events');
const NAME_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
@@@ -42,16 +42,9 @@ async function publishAppEvent(config, { name = 'example.ping', subject = { type
});
const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined });
const projectId = config.projectId || (await tokens.getTokenInfo({ audience: 'openvibe.events' })).unverifiedClaims.project_id;
-- const eventType = `app.${projectKey(projectId)}.${name}`;
-- const events = createEventsClient(client, { source: appSource(config.clientId) });
-- const out = await events.publish({
-- ...(eventId ? { event_id: eventId } : {}),
-- event_type: eventType,
-- actor: { type: 'app', id: config.clientId },
-- subject,
-- payload,
-- });
-- return { ...out, event_type: eventType };
++ const events = createAppEvents(client, { projectId, appId: config.clientId });
++ const out = await events.publish({ ...(eventId ? { event_id: eventId } : {}), event_type: name, subject, payload });
++ return { ...out, event_type: events.topic(name) };
}
async function main([name, payload] = process.argv.slice(2)) {
ddiff --git a/examples/event-subscriber/subscriber.js b/examples/event-subscriber/subscriber.js
iindex 42deb24..5424c98 100644
---- a/examples/event-subscriber/subscriber.js
++++ b/examples/event-subscriber/subscriber.js
@@@ -7,10 +7,11 @@
*
* Two ways in, one cursor discipline:
*
-- * pull GET /api/v1/events through openvibe-sdk/events iterate(), from the saved cursor, as
-- * your developer app (events.app.read, audience openvibe.events). The default topic is
-- * your project's own events, app.<project_key>.*; add public first-party topics
-- * (live.stream.*) if you want those too. Another project's app.* topics are refused.
++ * pull GET /api/v1/events through openvibe-sdk/events createAppEvents().iterate(), from the
++ * saved cursor, as your developer app (events.app.read, audience openvibe.events).
++ * OV_TOPICS are relative to your project (`order.*`; default `*`, i.e. all of
++ * app.<project_key>.*); OV_PLATFORM_TOPICS adds public first-party topics
++ * (live.stream.*). Another project's app.* topics are refused.
* realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(), anonymously:
* public first-party events only. Events never streams app.* events over realtime.
* Resumes with Last-Event-ID from the saved cursor after a restart or a drop.
@@@ -28,23 +29,9 @@ const fs = require('node:fs');
const path = require('node:path');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient } = require('openvibe-sdk/events');
++const { createAppEvents, projectKey, appSource } = require('openvibe-sdk/events');
const { subscribe } = require('openvibe-sdk/realtime');
--/** `p` + the project's ULID in lowercase: the second segment of your app event types. */
--function projectKey(projectId) {
-- const m = /^prj_([0-9A-HJKMNP-TV-Z]{26})$/i.exec(String(projectId || ''));
-- if (!m) throw Object.assign(new TypeError(`not a project id: ${projectId}`), { code: 'config.invalid' });
-- return `p${m[1].toLowerCase()}`;
--}
--
--/** `app-` + the app's ULID in lowercase: the `source` of the events your app publishes. */
--function appSource(appId) {
-- const m = /^(?:app:)?app_([0-9A-HJKMNP-TV-Z]{26})$/i.exec(String(appId || ''));
-- if (!m) throw Object.assign(new TypeError(`not an app id: ${appId}`), { code: 'config.invalid' });
-- return `app-${m[1].toLowerCase()}`;
--}
--
function loadConfig(env = process.env) {
const mode = env.OV_EVENTS_MODE || 'pull';
if (!['realtime', 'pull'].includes(mode)) throw Object.assign(new Error('OV_EVENTS_MODE must be pull or realtime'), { code: 'config.invalid' });
@@@ -52,7 +39,8 @@ function loadConfig(env = process.env) {
if (mode === 'pull' && !hasCreds) {
throw Object.assign(new Error('pull mode needs OV_CLIENT_ID and OV_CLIENT_SECRET (see .env.example)'), { code: 'config.missing' });
}
-- const topics = String(env.OV_TOPICS || '').split(',').map((t) => t.trim()).filter(Boolean);
++ const list = (v) => String(v || '').split(',').map((t) => t.trim()).filter(Boolean);
++ const topics = list(env.OV_TOPICS);
if (mode === 'realtime' && !topics.length) {
throw Object.assign(new Error('realtime mode needs OV_TOPICS (public first-party topics, e.g. chat.message.created)'), { code: 'config.missing' });
}
@@@ -63,7 +51,8 @@ function loadConfig(env = process.env) {
clientSecret: env.OV_CLIENT_SECRET || null,
projectId: env.OV_PROJECT_ID || null,
eventsUrl: env.OV_EVENTS_URL || null,
-- topics, // empty in pull mode: your project's app.<project_key>.*
++ topics, // pull: project-relative (empty = '*'); realtime: first-party
++ platformTopics: list(env.OV_PLATFORM_TOPICS), // pull only: public first-party topics as well
cursorPath: env.OV_CURSOR_PATH || path.join(__dirname, 'data', 'cursor.json'),
pollMs: Number(env.OV_POLL_MS || 5000),
};
@@@ -108,26 +97,32 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
await onResync(g, { via });
}
-- /** OV_TOPICS, or your project's own topic app.<project_key>.* (project id from OV_PROJECT_ID or the token). */
-- let topics = config.topics.length ? config.topics : null;
-- async function resolveTopics() {
-- if (topics) return topics;
++ /** The app-scoped events client: the project id comes from OV_PROJECT_ID or the app token. */
++ let appEvents = null;
++ async function eventsForApp() {
++ if (appEvents) return appEvents;
let projectId = config.projectId;
if (!projectId) {
const info = await tokens.getTokenInfo({ audience: 'openvibe.events' }); // decoded, not verified: fine for naming
projectId = info.unverifiedClaims && info.unverifiedClaims.project_id;
}
-- topics = [`app.${projectKey(projectId)}.*`];
-- return topics;
++ appEvents = createAppEvents(client, { projectId, appId: config.clientId });
++ return appEvents;
++ }
++ /** The full topic patterns a pull reads: app.<project_key>.<OV_TOPICS or *>, plus OV_PLATFORM_TOPICS. */
++ async function resolveTopics() {
++ const ev = await eventsForApp();
++ return [...(config.topics.length ? config.topics : ['*']).map(ev.topic), ...config.platformTopics];
}
// ── pull ────────────────────────────────────────────────
-- const events = createEventsClient(client);
/** Read from the saved cursor to the head once. Returns the number of events handled. */
async function pullOnce({ limit = 100 } = {}) {
++ const ev = await eventsForApp();
let handled = 0;
-- const iterator = events.iterate({
-- topic: await resolveTopics(),
++ const iterator = ev.iterate({
++ topic: config.topics.length ? config.topics : '*',
++ platformTopics: config.platformTopics,
afterSeq: cursor.get('pull') || 0,
limit,
onGap: (g) => gap(g, 'pull'),
@@@ -219,4 +214,4 @@ if (require.main === module) {
process.on('SIGTERM', stop);
}
--module.exports = { loadConfig, createCursorStore, createSubscriber, projectKey, appSource };
++module.exports = { loadConfig, createCursorStore, createSubscriber };
ddiff --git a/examples/event-subscriber/test/mock-app-events.js b/examples/event-subscriber/test/mock-app-events.js
ddeleted file mode 100644
iindex d4ef81d..0000000
---- a/examples/event-subscriber/test/mock-app-events.js
++++ /dev/null
@@@ -1,122 +0,0 @@
--'use strict';
--/**
-- * For tests only. openvibe-sdk 0.3.0's mock Events knows the first-party capabilities
-- * (events.event.publish|read, events.subscription.manage) but not the developer-app ones, so an
-- * app token holding events.app.* gets 403 there. This fetch plays OpenVibe.Events' developer-app
-- * rules (its README, "Developer apps") in front of the mock, then hands the call to it:
-- *
-- * - app tokens are verified here: RS256 with the mock's key, audience openvibe.events, the
-- * events.app.* capability of the route; env sandbox is accepted (Events accepts it on these
-- * routes only);
-- * - publish (events.app.publish): event_type app.<project_key>.<name…>, source
-- * app-<lowercased app ULID>, actor the app itself;
-- * - read and subscribe (events.app.read / events.app.subscribe): every pattern starts with a
-- * literal segment, an app.* pattern names the app's own project_key, subscriptions go to https
-- * endpoints only; reads only return the app's own project's events (and public first-party ones).
-- *
-- * Not modelled: sandbox/production separation, quotas, revocation, the SSRF address checks.
-- * Everything else (service tokens, /realtime/stream, other origins) goes to the mock unchanged.
-- */
--const crypto = require('node:crypto');
--
--const CAPS = { publish: 'events.app.publish', read: 'events.app.read', subscribe: 'events.app.subscribe' };
--const b64 = (s) => Buffer.from(String(s), 'base64url');
--const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
--const appSource = (appId) => `app-${String(appId).replace(/^app:/, '').replace(/^app_/, '').toLowerCase()}`;
--
--function problem(status, code, detail) {
-- return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail }), {
-- status, headers: { 'Content-Type': 'application/problem+json' },
-- });
--}
--
--function patternError(pattern, key) {
-- const [first, second] = String(pattern).split('.');
-- if (!first || first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';
-- if (first === 'app' && second !== key) return `app.* patterns must name your project: app.${key}.*`;
-- return null;
--}
--
--function createAppEventsFetch(platform) {
-- const owners = new Map(); // event_id -> project_id of the app that published it
-- const eventsOrigin = new URL(platform.origins.events).origin;
--
-- function appClaims(req) {
-- const auth = req.headers.get('authorization') || '';
-- const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];
-- if (parts.length !== 3) return null;
-- try {
-- const header = JSON.parse(b64(parts[0]));
-- if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), platform.keys.publicKey, b64(parts[2]))) return null;
-- const claims = JSON.parse(b64(parts[1]));
-- return claims.actor_type === 'app' && claims.exp * 1000 > Date.now() && (claims.aud || []).includes('openvibe.events') ? claims : null;
-- } catch { return null; }
-- }
--
-- /** The same call, as a first-party principal the mock understands (consumer = app:app_…). */
-- function forward(req, claims, capability, body) {
-- const token = platform.signServiceToken(claims.sub, { audience: 'openvibe.events', capabilities: [capability] });
-- const headers = new Headers(req.headers);
-- headers.set('authorization', `Bearer ${token}`);
-- return platform.fetch(new Request(req.url, { method: req.method, headers, body: body === undefined ? undefined : body }));
-- }
--
-- return async function fetch(input, init) {
-- const req = input instanceof Request && !init ? input : new Request(input, init);
-- const url = new URL(req.url);
-- if (url.origin !== eventsOrigin || !url.pathname.startsWith('/api/v1/')) return platform.fetch(req);
-- const claims = appClaims(req);
-- if (!claims) return platform.fetch(req);
-- const key = projectKey(claims.project_id);
-- const has = (cap) => (claims.cap || []).includes(cap);
-- const path = url.pathname;
--
-- if (path === '/api/v1/events' && req.method === 'POST') {
-- if (!has(CAPS.publish)) return problem(403, 'capability.denied', `${CAPS.publish} not granted`);
-- const env = await req.json().catch(() => null);
-- if (!env || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.event_id || '') || !env.event_type || !env.actor || !env.subject || !env.timestamp) {
-- return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');
-- }
-- if (env.source !== appSource(claims.sub)) return problem(403, 'events.source_mismatch', `source must be your app's "${appSource(claims.sub)}", not "${env.source}"`);
-- if (!env.event_type.startsWith(`app.${key}.`)) return problem(403, 'events.type_not_allowed', `an app may publish app.${key}.<name> only, not ${env.event_type}`);
-- if (env.actor.type !== 'app' || `app:${env.actor.id}` !== claims.sub) return problem(403, 'events.actor_mismatch', `actor must be { type: 'app', id: '${claims.sub.slice(4)}' }`);
-- const out = platform.publishEvent(env, claims.sub);
-- owners.set(out.event_id, claims.project_id);
-- return new Response(JSON.stringify(out), { status: out.duplicate ? 200 : 201, headers: { 'Content-Type': 'application/json' } });
-- }
--
-- if (path === '/api/v1/events' && req.method === 'GET') {
-- if (!has(CAPS.read)) return problem(403, 'capability.denied', `${CAPS.read} not granted`);
-- for (const p of (url.searchParams.get('topic') || '*').split(',')) {
-- const err = patternError(p, key);
-- if (err) return problem(403, 'events.topic_not_allowed', err);
-- }
-- const res = await forward(req, claims, 'events.event.read');
-- if (!res.ok) return res;
-- const page = await res.json();
-- const mine = (e) => (e.event.event_type.startsWith('app.') ? owners.get(e.event.event_id) === claims.project_id : e.event.visibility === 'public');
-- return new Response(JSON.stringify({ ...page, events: page.events.filter(mine) }), { status: 200, headers: { 'Content-Type': 'application/json' } });
-- }
--
-- if (path.startsWith('/api/v1/subscriptions')) {
-- if (!has(CAPS.subscribe)) return problem(403, 'capability.denied', `${CAPS.subscribe} not granted`);
-- let body;
-- if (req.method === 'POST' && path === '/api/v1/subscriptions') {
-- body = await req.text();
-- const b = JSON.parse(body || '{}');
-- const err = patternError(b.topic_pattern, key);
-- if (err) return problem(403, 'events.topic_not_allowed', err);
-- if (!/^https:\/\/[^/@]+\//.test(String(b.endpoint || ''))) return problem(422, 'events.endpoint_not_allowed', 'an app endpoint must be a public https URL');
-- }
-- return forward(req, claims, 'events.subscription.manage', body);
-- }
--
-- if (path === '/api/v1/checkpoints') {
-- if (!has(CAPS.read)) return problem(403, 'capability.denied', `${CAPS.read} not granted`);
-- return forward(req, claims, 'events.event.read', req.method === 'GET' ? undefined : await req.text());
-- }
-- return problem(403, 'capability.denied', 'app tokens are not accepted on this route');
-- };
--}
--
--module.exports = { createAppEventsFetch, projectKey, appSource };
ddiff --git a/examples/event-subscriber/test/smoke.test.js b/examples/event-subscriber/test/smoke.test.js
iindex e77822c..f144764 100644
---- a/examples/event-subscriber/test/smoke.test.js
++++ b/examples/event-subscriber/test/smoke.test.js
@@@ -1,18 +1,18 @@
'use strict';
/**
* Smoke test: publish to the project's own topic, pull it with a durable cursor as the app, gap
-- * handling after retention, and anonymous realtime with resume. Network, Events (incl.
-- * /realtime/stream) are openvibe-sdk/testing's mock platform; mock-app-events.js adds Events'
-- * developer-app rules in front of it (the SDK mock has no events.app.*). No network.
++ * handling after retention, and anonymous realtime with resume. Network and Events (incl.
++ * /realtime/stream and Events' developer-app rules) are openvibe-sdk/testing's mock platform.
++ * No network.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createMockPlatform } = require('openvibe-sdk/testing');
--const { createSubscriber, loadConfig, createCursorStore, projectKey, appSource } = require('../subscriber');
++const { projectKey, appSource } = require('openvibe-sdk/events');
++const { createSubscriber, loadConfig, createCursorStore } = require('../subscriber');
const { publishAppEvent, loadConfig: loadPublishConfig } = require('../publish');
--const { createAppEventsFetch } = require('./mock-app-events');
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
@@@ -35,7 +35,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));
const platform = createMockPlatform();
-- const fetch = createAppEventsFetch(platform);
++ const { fetch } = platform;
// A sandbox app (the default for a new project) with the events grants, and one of another project.
const app = platform.addApp({ env: 'sandbox', grants: ['events.app.publish', 'events.app.read'] });
const other = platform.addApp({ env: 'sandbox', grants: ['events.app.publish', 'events.app.read'] });
@@@ -108,9 +108,23 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
assert.deepEqual(resyncs, [{ from_seq: a.seq + 1, to_seq: c.seq, via: 'pull' }]);
assert.deepEqual(seen, [d.seq, e.seq, f.seq]);
-- // Another project's topic is refused by Events; so is a pull without events.app.read.
++ // Public first-party topics can be added (the earlier one was pruned: publish another).
++ platform.publishEvent(firstParty('public', 'live.stream.started'));
++ platform.publishEvent(firstParty('internal', 'live.stream.ended')); // internal: never to an app
++ const withPlatform = [];
++ s = createSubscriber(loadConfig({ ...env, OV_TOPICS: 'order.*', OV_PLATFORM_TOPICS: 'live.stream.*', OV_CURSOR_PATH: path.join(dir, 'platform.json') }), {
++ fetch, log: quiet, onEvent: (ev) => { withPlatform.push(ev.event_type); },
++ });
++ assert.deepEqual(await s.resolveTopics(), [`app.${key}.order.*`, 'live.stream.*']);
++ await s.pullOnce();
++ assert.ok(withPlatform.includes('live.stream.started'), 'public first-party events as well');
++ assert.ok(!withPlatform.includes('live.stream.ended'), 'but never internal ones');
++ assert.ok(withPlatform.every((t) => t === 'live.stream.started' || t.startsWith(`app.${key}.order.`)), 'only the asked-for topics');
++
++ // Another project's topic is refused (the SDK refuses to build it; Events would answer 403);
++ // so is a pull without events.app.read.
s = createSubscriber(loadConfig({ ...pullEnv, OV_TOPICS: `app.${projectKey(other.projectId)}.*` }), { fetch, log: quiet, onEvent() {} });
-- await assert.rejects(s.pullOnce(), (err) => err.status === 403 && err.code === 'events.topic_not_allowed');
++ await assert.rejects(s.pullOnce(), /another project/);
s = createSubscriber(loadConfig({ ...pullEnv, OV_CLIENT_ID: publishOnly.id, OV_CLIENT_SECRET: publishOnly.secret }), { fetch, log: quiet, onEvent() {} });
await assert.rejects(s.pullOnce(), (err) => err.status === 403 && err.code === 'capability.denied');
ddiff --git a/examples/media-uploader/README.md b/examples/media-uploader/README.md
iindex 40ee0d4..ec02eb9 100644
---- a/examples/media-uploader/README.md
++++ b/examples/media-uploader/README.md
@@@ -6,15 +6,15 @@ Upload a file to OpenVibe.Media as a developer app, read it back and delete it,
```bash
node --env-file=.env upload.js ./logo.png
# {
--# "key": "3f2a9c0d1b7e-logo.png",
--# "url": "https://openvibe.media/f/3f2a9c0d1b7e-logo.png?exp=…&sig=…",
++# "key": "3f2a9c0d1b7e-5d0c11aa-logo.png",
++# "url": "https://openvibe.media/f/3f2a9c0d1b7e-5d0c11aa-logo.png?exp=…&sig=…",
# "url_expires_at": "2026-…",
# "sandbox": true,
# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false,
# "project_id": "prj_…", "contracts": "0.28.0"
# }
--node --env-file=.env upload.js --get 3f2a9c0d1b7e-logo.png
--node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
++node --env-file=.env upload.js --get 3f2a9c0d1b7e-5d0c11aa-logo.png
++node --env-file=.env upload.js --delete 3f2a9c0d1b7e-5d0c11aa-logo.png
```
## What it proves
@@@ -39,7 +39,7 @@ node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
| File | What |
|---|---|
| `upload.js` | `loadConfig()`, `createUploader()` (`upload`, `get`, `remove`), `describe()`, `explain()` (hints for the usual errors), CLI |
--| `test/smoke.test.js` | upload against `openvibe-sdk/testing` (fake Network + Media), with a sandbox app |
++| `test/smoke.test.js` | upload, signed URL, read back, delete, and the refusals, against `openvibe-sdk/testing` with a sandbox app |
## Run the smoke test
@@@ -47,11 +47,10 @@ node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
npm test # from this folder, or `npm test -- media` from the repository root
```
--No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same
--audience, capability, namespace and sandbox checks. `--get` and `--delete` are not covered there:
--the SDK 0.3.0 mock's Media accepts app tokens on upload only, while the real Media accepts
--`media.object.read` for reads. `npm run e2e` at the repository root runs all three against the
--platform.
++No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and Media's
++tenant rules: the project id addresses the tenant, the token's env picks `prj_…` or
++`prj_…-sandbox`, uploads and deletes need `media.object.upload`, reads `media.object.read`, and a
++sandbox file answers with a signed `url` that its `/f/<key>` serves only with the signature.
## Run it against the real platform
ddiff --git a/examples/media-uploader/package.json b/examples/media-uploader/package.json
iindex 83f5793..0e3bd81 100644
---- a/examples/media-uploader/package.json
++++ b/examples/media-uploader/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/media-uploader/test/smoke.test.js b/examples/media-uploader/test/smoke.test.js
iindex ca2f57d..6e8139f 100644
---- a/examples/media-uploader/test/smoke.test.js
++++ b/examples/media-uploader/test/smoke.test.js
@@@ -1,12 +1,9 @@
'use strict';
/**
* Smoke test: the uploader against openvibe-sdk/testing's mock platform (fake Network + Media at
-- * their public origins, real RS256 tokens, audience/capability/namespace/sandbox checks), with a
-- * sandbox app as a new project has. No network.
-- *
-- * Not covered here: --get and --delete. The SDK 0.3.0 mock's Media accepts app tokens on upload
-- * only (reads need its app API key), while the real Media accepts media.object.read for reads;
-- * `npm run e2e` exercises them against the platform.
++ * their public origins, real RS256 tokens, Media's tenant rules: the project id addresses the
++ * tenant, the token's env picks `prj_…` or `prj_…-sandbox`, upload/delete need media.object.upload
++ * and reads media.object.read), with a sandbox app as a new project has. No network.
*/
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
@@@ -14,15 +11,15 @@ const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createMockPlatform } = require('openvibe-sdk/testing');
--const { createUploader, describe, explain, loadConfig } = require('../upload');
++const { createUploader, explain, loadConfig } = require('../upload');
// CI has no network: anything that is not the mock platform fails loudly.
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
(async () => {
-- // Media accepts sandbox app tokens on the project's own tenant.
-- const platform = createMockPlatform({ acceptSandbox: ['openvibe.media'] });
++ const platform = createMockPlatform();
const app = platform.addApp({ env: 'sandbox', grants: ['media.object.upload', 'media.object.read'] });
++ const uploadOnly = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['media.object.upload'] });
const other = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });
const noGrant = platform.addApp({ env: 'sandbox', grants: ['tools.job.read'] });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-'));
@@@ -32,20 +29,27 @@ globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };
// 1. Upload: token for openvibe.media with only media.object.upload, Media origin from
-- // discovery, the project id from the token, the file stored in the project's tenant.
++ // discovery, the project id from the token, the file stored in the project's sandbox tenant.
const uploader = createUploader(loadConfig(env), { fetch: platform.fetch });
const out = await uploader.upload(file);
-- assert.match(out.key, /^[0-9a-f]{12}-hello\.txt$/);
++ assert.match(out.key, /^[0-9a-f]{12}-[0-9a-f]{8}-hello\.txt$/);
assert.equal(out.project_id, app.projectId);
-- assert.equal(out.url, `https://openvibe.media/f/${out.key}`);
assert.equal(out.mime, 'text/plain');
assert.equal(out.sha256, crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'));
-- assert.ok(platform.state.files.has(`${app.projectId}|${out.key}`), 'stored under the project id');
++ assert.ok(platform.state.files.has(`${app.projectId}-sandbox|${out.key}`), 'stored in the project\'s sandbox tenant');
++
++ // Sandbox files are never public: a signed, expiring URL, which the mock (like Media) serves.
++ assert.equal(out.sandbox, true);
++ assert.match(out.url, new RegExp(`^https://openvibe\\.media/f/${out.key}\\?exp=\\d+&sig=`));
++ assert.ok(Date.parse(out.url_expires_at) > Date.now());
++ const signed = await platform.fetch(out.url);
++ assert.equal(signed.status, 200);
++ assert.equal(await signed.text(), fs.readFileSync(file, 'utf8'));
++ assert.equal((await platform.fetch(`https://openvibe.media/f/${out.key}`)).status, 404, 'not without the signature');
// The upload carried a short-lived Network token scoped to the one capability, and a trace.
const call = platform.stats.requests.find((r) => r.url === `https://openvibe.media/api/v1/${app.projectId}/files`);
-- assert.ok(call, 'Media was called at the discovered origin');
-- assert.match(call.headers.authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
++ assert.ok(call, 'Media was called at the discovered origin, addressed by the project id');
assert.match(call.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
const claims = JSON.parse(Buffer.from(call.headers.authorization.split('.')[1], 'base64url'));
assert.deepEqual(claims.cap, ['media.object.upload']);
@@@ -56,31 +60,35 @@ globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
assert.equal(again.key, out.key);
assert.equal(again.deduplicated, true);
-- // 3. Another project's tenant is refused by Media (the token's ns is this project only).
++ // 3. Read it back (media.object.read), with a fresh signed URL; delete it (media.object.upload).
++ const meta = await uploader.get(out.key);
++ assert.equal(meta.sha256, out.sha256);
++ assert.equal(meta.sandbox, true);
++ assert.match(meta.url, /\?exp=\d+&sig=/);
++ const readCall = platform.stats.requests.filter((r) => r.url.startsWith(`https://openvibe.media/api/v1/${app.projectId}/files/`)).at(-1);
++ assert.deepEqual(JSON.parse(Buffer.from(readCall.headers.authorization.split('.')[1], 'base64url')).cap, ['media.object.read']);
++ // Without media.object.read, Network issues no token for the read.
++ const cannotRead = createUploader(loadConfig({ OV_CLIENT_ID: uploadOnly.id, OV_CLIENT_SECRET: uploadOnly.secret }), { fetch: platform.fetch });
++ await assert.rejects(cannotRead.get(out.key), (err) => err.code === 'invalid_scope');
++ assert.equal(await uploader.remove(out.key), true);
++ assert.equal(await uploader.get(out.key), null);
++ assert.equal(await uploader.remove(out.key), false, 'nothing left to delete');
++
++ // 4. Another project's tenant is refused by Media (the token's project decides).
const wrong = createUploader(loadConfig({ ...env, OV_PROJECT_ID: other.projectId }), { fetch: platform.fetch });
await assert.rejects(wrong.upload(file), (err) => err.status === 403 && err.code === 'capability.namespace_denied' && /OV_PROJECT_ID/.test(explain(err)));
-- // 4. No grant: Network issues no token for the capability.
++ // 5. No grant: Network issues no token for the capability.
const nope = createUploader(loadConfig({ OV_CLIENT_ID: noGrant.id, OV_CLIENT_SECRET: noGrant.secret }), { fetch: platform.fetch });
await assert.rejects(nope.upload(file), (err) => err.code === 'invalid_scope' && /Request it/.test(explain(err)));
-- // 5. Wrong secret: invalid_client, and the secret never appears in the explanation.
++ // 6. Wrong secret: invalid_client, and the secret never appears in the explanation.
const badSecret = createUploader(loadConfig({ ...env, OV_CLIENT_SECRET: 'wrong-secret-value' }), { fetch: platform.fetch });
await assert.rejects(badSecret.upload(file), (err) => err.code === 'invalid_client' && !explain(err).includes('wrong-secret-value'));
-- // 6. Missing configuration names the variables, never their values.
++ // 7. Missing configuration names the variables, never their values.
assert.throws(() => loadConfig({ OV_CLIENT_SECRET: app.secret }), (err) => err.code === 'config.missing' && /OV_CLIENT_ID/.test(err.message) && !err.message.includes(app.secret));
-- // 7. A sandbox upload as the real Media answers it: signed, expiring URL, never a public one.
-- const sandboxFile = describe({
-- key: 'a1b2c3d4e5f6-hello.txt', url: 'https://openvibe.media/f/a1b2c3d4e5f6-hello.txt?exp=1790000000&sig=abc',
-- public_url: 'https://openvibe.media/f/a1b2c3d4e5f6-hello.txt?exp=1790000000&sig=abc', url_expires_at: '2026-09-23T12:00:00Z',
-- sandbox: true, size: 31, mime: 'text/plain', sha256: 'x',
-- });
-- assert.equal(sandboxFile.sandbox, true);
-- assert.match(sandboxFile.url, /\?exp=\d+&sig=/);
-- assert.equal(sandboxFile.url_expires_at, '2026-09-23T12:00:00Z');
--
fs.rmSync(dir, { recursive: true, force: true });
console.log('media-uploader: ok');
})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/examples/media-uploader/upload.js b/examples/media-uploader/upload.js
iindex 4196767..1f57399 100644
---- a/examples/media-uploader/upload.js
++++ b/examples/media-uploader/upload.js
@@@ -51,7 +51,7 @@ function describe(file) {
if (!file) return null;
return {
key: file.key,
-- url: file.public_url || file.url || null, // public URL, or a signed one for sandbox files
++ url: file.public_url || file.signed_url || null, // public URL, or a signed one for sandbox files
url_expires_at: file.url_expires_at || null,
sandbox: Boolean(file.sandbox),
size: file.size, mime: file.mime, sha256: file.sha256,
ddiff --git a/examples/mod-manifest/package.json b/examples/mod-manifest/package.json
iindex 5876dc8..e4d8fba 100644
---- a/examples/mod-manifest/package.json
++++ b/examples/mod-manifest/package.json
@@@ -13,7 +13,7 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0"
}
}
ddiff --git a/examples/node-server-app/package.json b/examples/node-server-app/package.json
iindex 6fa1d3d..fde6263 100644
---- a/examples/node-server-app/package.json
++++ b/examples/node-server-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/oauth-app/package.json b/examples/oauth-app/package.json
iindex 64b07c7..5886471 100644
---- a/examples/oauth-app/package.json
++++ b/examples/oauth-app/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/tool-job/README.md b/examples/tool-job/README.md
iindex bf3d2a8..0deb0d1 100644
---- a/examples/tool-job/README.md
++++ b/examples/tool-job/README.md
@@@ -41,9 +41,10 @@ node --env-file=.env run-job.js ./photo.png
npm test
```
--Network and Tools are `openvibe-sdk/testing`'s mock platform with `jobs` enabled (an `img.process`
--handler plays queued → running → progress → succeeded); `dropJobStreams()` cuts the event stream
--after the first event.
++Network and Tools are `openvibe-sdk/testing`'s mock platform with `jobs` enabled, answering at
++`https://img.openvibe.tools` like the real satellite (an `img.process` handler plays queued →
++running → progress → succeeded; `acceptSandbox: ['openvibe.tools']` because Tools accepts sandbox
++app tokens on jobs); `dropJobStreams()` cuts the event stream after the first event.
## Run it against the real platform
ddiff --git a/examples/tool-job/package.json b/examples/tool-job/package.json
iindex 56c0670..aa2c350 100644
---- a/examples/tool-job/package.json
++++ b/examples/tool-job/package.json
@@@ -13,6 +13,6 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/examples/tool-job/test/smoke.test.js b/examples/tool-job/test/smoke.test.js
iindex ead7b4d..041488b 100644
---- a/examples/tool-job/test/smoke.test.js
++++ b/examples/tool-job/test/smoke.test.js
@@@ -38,7 +38,7 @@ const TOOLS = ['tools.job.create', 'tools.job.read'];
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tooljob-'));
const file = path.join(dir, 'photo.png');
fs.writeFileSync(file, Buffer.from('not really a png, the mock does not care'));
-- const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret, OV_TOOLS_JOBS_URL: platform.origins.tools, OV_OUT_DIR: path.join(dir, 'out'), OV_JOB_STATE: path.join(dir, 'state.json') };
++ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret, OV_OUT_DIR: path.join(dir, 'out'), OV_JOB_STATE: path.join(dir, 'state.json') };
const submits = () => platform.stats.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs')).length;
const streams = () => platform.stats.requests.filter((r) => r.url.endsWith('/events'));
@@@ -58,6 +58,7 @@ const TOOLS = ['tools.job.create', 'tools.job.read'];
assert.equal(fs.readFileSync(files[0], 'utf8'), 'converted:not really a png, the mock does not care');
assert.equal(fs.existsSync(env.OV_JOB_STATE), false, 'state cleared once done');
assert.equal(submits(), 1);
++ assert.ok(platform.stats.requests.some((r) => r.url === 'https://img.openvibe.tools/api/v1/jobs'), 'img.process went to the img satellite (the default)');
const submitCall = platform.stats.requests.find((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs'));
assert.match(submitCall.headers['idempotency-key'], /^ex-[0-9a-f]{40}$/);
ddiff --git a/examples/webhook-consumer/README.md b/examples/webhook-consumer/README.md
iindex 6ba521a..eb705e6 100644
---- a/examples/webhook-consumer/README.md
++++ b/examples/webhook-consumer/README.md
@@@ -11,8 +11,9 @@ node --env-file=.env --env-file=.env.webhook server.js
## What it proves
- **An app subscription** (`events.app.subscribe`): `subscribe.js` creates it with
-- `openvibe-sdk/events` `subscriptions.create()`. The default topic is the project's own
-- `app.<project_key>.*`; Events refuses `*`, `app.*` and other projects' keys
++ `openvibe-sdk/events` `createAppEvents().subscriptions.create()`, whose topic pattern is relative
++ to the project (default `*`, i.e. `app.<project_key>.*`). Events refuses `*`, `app.*` and other
++ projects' keys
(`403 events.topic_not_allowed`), and accepts only `https` endpoints that resolve to public
addresses. `subscribe.js` generates the signing secret itself and writes it to `.env.webhook`
(mode 0600); it never prints it.
@@@ -32,7 +33,6 @@ node --env-file=.env --env-file=.env.webhook server.js
|---|---|
| `server.js` | `createConsumer()`: the HTTP endpoint, signature check, inbox, `received_events` table |
| `subscribe.js` | `createSubscription()`: create the Events subscription with an app token |
--| `test/mock-app-events.js` | Events' developer-app rules in front of the SDK mock (same file as in event-subscriber) |
| `test/smoke.test.js` | subscription and its topic/endpoint rules, then deliveries from the mock's delivery worker |
## Run the smoke test
@@@ -44,8 +44,8 @@ npm test
Deliveries come from `openvibe-sdk/testing`'s delivery worker (`deliverEvents()`): signed POSTs
with Events' headers, in order, retried on a non-2xx. The test routes the subscription's https
endpoint to the consumer on a local port. Wrong-secret, tampered and forged deliveries are made by
--hand. The SDK 0.3.0 mock has no `events.app.*`, so `test/mock-app-events.js` plays Events' app
--rules (capability, topic scope, https endpoint) in front of it.
++hand. The mock plays Events' developer-app rules (capability, own-project topics, public https
++endpoints: a private address is `422 events.endpoint_not_allowed`).
## Run it against the real platform
ddiff --git a/examples/webhook-consumer/package.json b/examples/webhook-consumer/package.json
iindex f787a12..56798c5 100644
---- a/examples/webhook-consumer/package.json
++++ b/examples/webhook-consumer/package.json
@@@ -13,7 +13,7 @@
"node": ">=22"
},
"dependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
"better-sqlite3": "^11.10.0"
}
}
ddiff --git a/examples/webhook-consumer/subscribe.js b/examples/webhook-consumer/subscribe.js
iindex b048a01..ebe1634 100644
---- a/examples/webhook-consumer/subscribe.js
++++ b/examples/webhook-consumer/subscribe.js
@@@ -4,7 +4,7 @@
* Create the OpenVibe.Events subscription that delivers to this consumer.
*
* 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>.order.*'
++ * node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe 'order.*' # app.<project_key>.order.*
*
* Needs an app token for openvibe.events holding events.app.subscribe. Events accepts, for an app:
* - topic patterns that start with a literal segment; an app.* pattern must name YOUR project
@@@ -20,14 +20,7 @@ const fs = require('node:fs');
const path = require('node:path');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
--const { createEventsClient } = require('openvibe-sdk/events');
--
--/** `p` + the project's ULID in lowercase (the second segment of your app event types). */
--function projectKey(projectId) {
-- const m = /^prj_([0-9A-HJKMNP-TV-Z]{26})$/i.exec(String(projectId || ''));
-- if (!m) throw Object.assign(new TypeError(`not a project id: ${projectId}`), { code: 'config.invalid' });
-- return `p${m[1].toLowerCase()}`;
--}
++const { createAppEvents } = require('openvibe-sdk/events');
function loadConfig(env = process.env) {
const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
@@@ -47,7 +40,8 @@ function loadConfig(env = process.env) {
/**
* -> { subscription: { id, topic_pattern, endpoint, enabled }, secret } (the secret is kept out of
-- * the printable part). `topicPattern` defaults to the project's own app.<project_key>.*.
++ * the printable part). `topicPattern` is relative to the project (`order.*`; default `*`, i.e.
++ * app.<project_key>.*); createAppEvents() turns it into the full pattern.
*/
async function createSubscription(config, { endpoint, topicPattern, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {
let u;
@@@ -58,13 +52,9 @@ async function createSubscription(config, { endpoint, topicPattern, secret = `wh
scope: { 'openvibe.events': 'events.app.subscribe' },
});
const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined });
-- let pattern = topicPattern;
-- if (!pattern) {
-- const projectId = config.projectId || (await tokens.getTokenInfo({ audience: 'openvibe.events' })).unverifiedClaims.project_id;
-- pattern = `app.${projectKey(projectId)}.*`;
-- }
-- const events = createEventsClient(client);
-- const sub = await events.subscriptions.create({ topicPattern: pattern, endpoint, secret });
++ const projectId = config.projectId || (await tokens.getTokenInfo({ audience: 'openvibe.events' })).unverifiedClaims.project_id;
++ const events = createAppEvents(client, { projectId, appId: config.clientId });
++ const sub = await events.subscriptions.create({ topicPattern: topicPattern || '*', endpoint, secret });
return { subscription: { id: sub.id, topic_pattern: sub.topic_pattern, endpoint: sub.endpoint, enabled: sub.enabled }, secret: sub.secret || secret };
}
@@@ -91,4 +81,4 @@ async function main([endpoint, topicPattern] = process.argv.slice(2)) {
if (require.main === module) main().then((code) => { process.exitCode = code; });
--module.exports = { loadConfig, createSubscription, projectKey, main };
++module.exports = { loadConfig, createSubscription, main };
ddiff --git a/examples/webhook-consumer/test/mock-app-events.js b/examples/webhook-consumer/test/mock-app-events.js
ddeleted file mode 100644
iindex d4ef81d..0000000
---- a/examples/webhook-consumer/test/mock-app-events.js
++++ /dev/null
@@@ -1,122 +0,0 @@
--'use strict';
--/**
-- * For tests only. openvibe-sdk 0.3.0's mock Events knows the first-party capabilities
-- * (events.event.publish|read, events.subscription.manage) but not the developer-app ones, so an
-- * app token holding events.app.* gets 403 there. This fetch plays OpenVibe.Events' developer-app
-- * rules (its README, "Developer apps") in front of the mock, then hands the call to it:
-- *
-- * - app tokens are verified here: RS256 with the mock's key, audience openvibe.events, the
-- * events.app.* capability of the route; env sandbox is accepted (Events accepts it on these
-- * routes only);
-- * - publish (events.app.publish): event_type app.<project_key>.<name…>, source
-- * app-<lowercased app ULID>, actor the app itself;
-- * - read and subscribe (events.app.read / events.app.subscribe): every pattern starts with a
-- * literal segment, an app.* pattern names the app's own project_key, subscriptions go to https
-- * endpoints only; reads only return the app's own project's events (and public first-party ones).
-- *
-- * Not modelled: sandbox/production separation, quotas, revocation, the SSRF address checks.
-- * Everything else (service tokens, /realtime/stream, other origins) goes to the mock unchanged.
-- */
--const crypto = require('node:crypto');
--
--const CAPS = { publish: 'events.app.publish', read: 'events.app.read', subscribe: 'events.app.subscribe' };
--const b64 = (s) => Buffer.from(String(s), 'base64url');
--const projectKey = (projectId) => `p${String(projectId).replace(/^prj_/, '').toLowerCase()}`;
--const appSource = (appId) => `app-${String(appId).replace(/^app:/, '').replace(/^app_/, '').toLowerCase()}`;
--
--function problem(status, code, detail) {
-- return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: code, status, code, detail }), {
-- status, headers: { 'Content-Type': 'application/problem+json' },
-- });
--}
--
--function patternError(pattern, key) {
-- const [first, second] = String(pattern).split('.');
-- if (!first || first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';
-- if (first === 'app' && second !== key) return `app.* patterns must name your project: app.${key}.*`;
-- return null;
--}
--
--function createAppEventsFetch(platform) {
-- const owners = new Map(); // event_id -> project_id of the app that published it
-- const eventsOrigin = new URL(platform.origins.events).origin;
--
-- function appClaims(req) {
-- const auth = req.headers.get('authorization') || '';
-- const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];
-- if (parts.length !== 3) return null;
-- try {
-- const header = JSON.parse(b64(parts[0]));
-- if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), platform.keys.publicKey, b64(parts[2]))) return null;
-- const claims = JSON.parse(b64(parts[1]));
-- return claims.actor_type === 'app' && claims.exp * 1000 > Date.now() && (claims.aud || []).includes('openvibe.events') ? claims : null;
-- } catch { return null; }
-- }
--
-- /** The same call, as a first-party principal the mock understands (consumer = app:app_…). */
-- function forward(req, claims, capability, body) {
-- const token = platform.signServiceToken(claims.sub, { audience: 'openvibe.events', capabilities: [capability] });
-- const headers = new Headers(req.headers);
-- headers.set('authorization', `Bearer ${token}`);
-- return platform.fetch(new Request(req.url, { method: req.method, headers, body: body === undefined ? undefined : body }));
-- }
--
-- return async function fetch(input, init) {
-- const req = input instanceof Request && !init ? input : new Request(input, init);
-- const url = new URL(req.url);
-- if (url.origin !== eventsOrigin || !url.pathname.startsWith('/api/v1/')) return platform.fetch(req);
-- const claims = appClaims(req);
-- if (!claims) return platform.fetch(req);
-- const key = projectKey(claims.project_id);
-- const has = (cap) => (claims.cap || []).includes(cap);
-- const path = url.pathname;
--
-- if (path === '/api/v1/events' && req.method === 'POST') {
-- if (!has(CAPS.publish)) return problem(403, 'capability.denied', `${CAPS.publish} not granted`);
-- const env = await req.json().catch(() => null);
-- if (!env || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.event_id || '') || !env.event_type || !env.actor || !env.subject || !env.timestamp) {
-- return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');
-- }
-- if (env.source !== appSource(claims.sub)) return problem(403, 'events.source_mismatch', `source must be your app's "${appSource(claims.sub)}", not "${env.source}"`);
-- if (!env.event_type.startsWith(`app.${key}.`)) return problem(403, 'events.type_not_allowed', `an app may publish app.${key}.<name> only, not ${env.event_type}`);
-- if (env.actor.type !== 'app' || `app:${env.actor.id}` !== claims.sub) return problem(403, 'events.actor_mismatch', `actor must be { type: 'app', id: '${claims.sub.slice(4)}' }`);
-- const out = platform.publishEvent(env, claims.sub);
-- owners.set(out.event_id, claims.project_id);
-- return new Response(JSON.stringify(out), { status: out.duplicate ? 200 : 201, headers: { 'Content-Type': 'application/json' } });
-- }
--
-- if (path === '/api/v1/events' && req.method === 'GET') {
-- if (!has(CAPS.read)) return problem(403, 'capability.denied', `${CAPS.read} not granted`);
-- for (const p of (url.searchParams.get('topic') || '*').split(',')) {
-- const err = patternError(p, key);
-- if (err) return problem(403, 'events.topic_not_allowed', err);
-- }
-- const res = await forward(req, claims, 'events.event.read');
-- if (!res.ok) return res;
-- const page = await res.json();
-- const mine = (e) => (e.event.event_type.startsWith('app.') ? owners.get(e.event.event_id) === claims.project_id : e.event.visibility === 'public');
-- return new Response(JSON.stringify({ ...page, events: page.events.filter(mine) }), { status: 200, headers: { 'Content-Type': 'application/json' } });
-- }
--
-- if (path.startsWith('/api/v1/subscriptions')) {
-- if (!has(CAPS.subscribe)) return problem(403, 'capability.denied', `${CAPS.subscribe} not granted`);
-- let body;
-- if (req.method === 'POST' && path === '/api/v1/subscriptions') {
-- body = await req.text();
-- const b = JSON.parse(body || '{}');
-- const err = patternError(b.topic_pattern, key);
-- if (err) return problem(403, 'events.topic_not_allowed', err);
-- if (!/^https:\/\/[^/@]+\//.test(String(b.endpoint || ''))) return problem(422, 'events.endpoint_not_allowed', 'an app endpoint must be a public https URL');
-- }
-- return forward(req, claims, 'events.subscription.manage', body);
-- }
--
-- if (path === '/api/v1/checkpoints') {
-- if (!has(CAPS.read)) return problem(403, 'capability.denied', `${CAPS.read} not granted`);
-- return forward(req, claims, 'events.event.read', req.method === 'GET' ? undefined : await req.text());
-- }
-- return problem(403, 'capability.denied', 'app tokens are not accepted on this route');
-- };
--}
--
--module.exports = { createAppEventsFetch, projectKey, appSource };
ddiff --git a/examples/webhook-consumer/test/smoke.test.js b/examples/webhook-consumer/test/smoke.test.js
iindex 8849d2b..5493d13 100644
---- a/examples/webhook-consumer/test/smoke.test.js
++++ b/examples/webhook-consumer/test/smoke.test.js
@@@ -3,18 +3,17 @@
* Smoke test: the app creates its subscription (events.app.subscribe) on the SDK's mock platform,
* then the mock's delivery worker (deliverEvents) POSTs signed deliveries to the consumer running
* on a local port: exactly once, retry after a handler failure, secret rotation. Attacks (wrong
-- * secret, tampered body, forged header) are crafted by hand. mock-app-events.js adds Events'
-- * developer-app rules in front of the mock (the SDK mock has no events.app.*). No network.
++ * secret, tampered body, forged header) are crafted by hand. The mock plays Events' developer-app
++ * rules (events.app.subscribe, own-project topics, public https endpoints). No network.
*/
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { createMockPlatform } = require('openvibe-sdk/testing');
--const { signDelivery } = require('openvibe-sdk/events');
++const { signDelivery, projectKey, appSource } = require('openvibe-sdk/events');
const { createConsumer, loadConfig, openDatabase } = require('../server');
--const { createSubscription, loadConfig: loadSubscribeConfig, projectKey } = require('../subscribe');
--const { createAppEventsFetch } = require('./mock-app-events');
++const { createSubscription, loadConfig: loadSubscribeConfig } = require('../subscribe');
const realFetch = globalThis.fetch;
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
@@@ -23,7 +22,7 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
(async () => {
const platform = createMockPlatform();
-- const fetch = createAppEventsFetch(platform);
++ const { fetch } = platform;
const app = platform.addApp({ env: 'sandbox', grants: ['events.app.subscribe'] });
const noGrant = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['events.app.read'] });
const key = projectKey(app.projectId);
@@@ -36,10 +35,11 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
assert.equal('secret' in created.subscription, false, 'the printable part never carries the secret');
assert.match(created.secret, /^whsec_[0-9a-f]{64}$/);
await assert.rejects(createSubscription(loadSubscribeConfig(subEnv), { endpoint: 'http://hooks.example.com/x' }, { fetch }), /https/);
-- await assert.rejects(createSubscription(loadSubscribeConfig(subEnv), { endpoint: ENDPOINT, topicPattern: 'app.pother.*' }, { fetch }),
-- (err) => err.status === 403 && err.code === 'events.topic_not_allowed');
-- await assert.rejects(createSubscription(loadSubscribeConfig(subEnv), { endpoint: ENDPOINT, topicPattern: '*' }, { fetch }),
-- (err) => err.code === 'events.topic_not_allowed');
++ const otherProject = platform.addApp({ env: 'sandbox', grants: ['events.app.subscribe'] }).projectId;
++ await assert.rejects(createSubscription(loadSubscribeConfig(subEnv), { endpoint: ENDPOINT, topicPattern: `app.${projectKey(otherProject)}.*` }, { fetch }),
++ /another project/, 'another project\'s topic is never even sent');
++ await assert.rejects(createSubscription(loadSubscribeConfig(subEnv), { endpoint: 'https://10.0.0.1/hooks' }, { fetch }),
++ (err) => err.status === 422 && err.code === 'events.endpoint_not_allowed', 'Events refuses private addresses');
await assert.rejects(createSubscription(loadSubscribeConfig({ OV_CLIENT_ID: noGrant.id, OV_CLIENT_SECRET: noGrant.secret }), { endpoint: ENDPOINT }, { fetch }),
(err) => err.code === 'invalid_scope', 'no events.app.subscribe grant: Network issues no token for it');
@@@ -69,7 +69,7 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
return realFetch(local, init);
};
const appEvent = (name) => platform.publishEvent({
-- event_type: `app.${key}.${name}`, source: `app-${app.id.slice(4).toLowerCase()}`,
++ event_type: `app.${key}.${name}`, source: appSource(app.id),
actor: { type: 'app', id: app.id }, subject: { type: 'order', id: '42' }, payload: { size: 12 },
}, `app:${app.id}`);
ddiff --git a/package-lock.json b/package-lock.json
iindex 9d0c798..0cba293 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -12,7 +12,7 @@
"examples/*"
],
"devDependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"engines": {
"node": ">=22"
@@@ -67,8 +67,8 @@
"version": "0.2.0",
"license": "MIT",
"dependencies": {
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.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"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"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.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
"engines": {
"node": ">=22"
@@@ -417,9 +417,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.26.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0",
-- "integrity": "sha512-yigHjNzLIyK578V0Weqv2zL6z8PO8ce3HRXPa/61n1XK5Y7pTi28qHMMiO6stboQjTziN1+8wMeByoCF1sGCnw==",
++ "version": "0.28.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.28.0",
++ "integrity": "sha512-cOOEmIhcjDmnWbo4jsz6kYOKT+Kc+aitYoc4w/EJOKg5uvJDt10vctjKB3IIl53UXeuDOhbckP53yBdBeewC1A==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -469,9 +469,9 @@
"link": true
},
"node_modules/openvibe-sdk": {
-- "version": "0.3.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0",
-- "integrity": "sha512-chHi8cm2gCyttFqdV2eJzdYuBf5j/KBkANGTmK/VBRV2vdHWnqK45A5Pk+XA5KwP190cjHgdfw+RaE33qo/Nzg==",
++ "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==",
"license": "MIT",
"engines": {
"node": ">=20"
ddiff --git a/package.json b/package.json
iindex 29e3097..490e461 100644
---- a/package.json
++++ b/package.json
@@@ -17,6 +17,6 @@
"node": ">=22"
},
"devDependencies": {
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
ddiff --git a/scripts/e2e.js b/scripts/e2e.js
iindex 31abb15..2841053 100644
---- a/scripts/e2e.js
++++ b/scripts/e2e.js
@@@ -68,7 +68,8 @@ async function main() {
maskOutput(env.OV_CLIENT_SECRET);
const { createUploader, explain } = require(path.join(ROOT, 'examples/media-uploader/upload'));
-- const { createSubscriber, createCursorStore, loadConfig: subscriberConfig, projectKey } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
++ const { projectKey } = require('openvibe-sdk/events');
++ const { createSubscriber, createCursorStore, loadConfig: subscriberConfig } = require(path.join(ROOT, 'examples/event-subscriber/subscriber'));
const { publishAppEvent, loadConfig: publishConfig } = require(path.join(ROOT, 'examples/event-subscriber/publish'));
const { createJobRunner, loadConfig: jobConfig } = require(path.join(ROOT, 'examples/tool-job/run-job'));
const { loadConfig: mediaConfig } = require(path.join(ROOT, 'examples/media-uploader/upload'));
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 35dad50..c5304e3 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -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.0';
++const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1';
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.0`);
++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.1`);
}
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');