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
Changes
@@@ -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@@@ -7,8 +7,8 @@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.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.1and §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`: anin-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 networkcall 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 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.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'scredentials; there is no CI project with credentials.- A new developer goes from account creation to a working Media, event and capabilityintegration using only public documentation, the SDK and scoped credentials: the platform path@@@ -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": [@@@ -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"}}@@@ -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.createdOV_TOPICS=++# Pull only: public first-party topics to read as well (e.g. live.stream.*)++OV_PLATFORM_TOPICS=OV_CURSOR_PATH=./data/cursor.jsonOV_POLL_MS=5000# Optional: an explicit Events origin instead of the one the registry advertises.@@@ -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…`), andits 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. Thecursor 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 # punpm 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 publicfirst-party events only; which ones arrive depends on which producers publish public events.@@@ -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"}}@@@ -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)) {@@@ -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 wellcursorPath: 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 = cawait 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 namingprojectId = 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 };ddeleted file mode 100644@@@ -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 };@@@ -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');@@@ -6,15 +6,15 @@ Upload a file to OpenVibe.Media as a developer app, read it back and delete it,```bashnode --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.pngnpm 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@@@ -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"}}@@@ -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); });@@@ -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 filesurl_expires_at: file.url_expires_at || null,sandbox: Boolean(file.sandbox),size: file.size, mime: file.mime, sha256: file.sha256,@@@ -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"}}@@@ -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"}}@@@ -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"}}@@@ -41,9 +41,10 @@ node --env-file=.env run-job.js ./photo.pngnpm 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@@@ -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"}}@@@ -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}$/);@@@ -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 publicaddresses. `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 testDeliveries come from `openvibe-sdk/testing`'s delivery worker (`deliverEvents()`): signed POSTswith Events' headers, in order, retried on a non-2xx. The test routes the subscription's httpsendpoint 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@@@ -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"}}@@@ -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 = `whscope: { '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 };ddeleted file mode 100644@@@ -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 };@@@ -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}`);@@@ -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"@@@ -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"}}@@@ -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'));@@@ -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');
@@ -12,7 +12,6 @@ jobs:
@@ -12,7 +12,6 @@ jobs:
with:
with:
node-version: 22.22.1 # the Node version the platform's services run
node-version: 22.22.1 # the Node version the platform's services run
- run: npm ci # one install for every example (npm workspaces)
- 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
- run: npm test
@@ -7,8 +7,8 @@
@@ -7,8 +7,8 @@
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.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
**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).
**License:** MIT ([LICENSE](LICENSE)), so you can copy any example into your own project. The
**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.
@@ -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`) |
| [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` |
| [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`, `/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` |
| [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) |
@@ -86,7 +86,7 @@ at the root one: `node --env-file=../../.env upload.js ./logo.png`.
@@ -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` |
| [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` |
| [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 |
| [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` |
| [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` |
| [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:
@@ -147,11 +147,12 @@ How the tests stay offline:
- Network, Events, Media and Tools jobs are `createMockPlatform()` from `openvibe-sdk/testing`: an
- 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,
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.
(`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
- Every smoke test replaces the global `fetch` with one that throws, so an accidental real network
call fails the test.
call fails the test.
@@ -162,7 +163,7 @@ Repository checks (`test/`):
@@ -162,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.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.
`npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
## Layout
## Layout
@@ -189,14 +190,14 @@ test/public-surface.test.js, test/structure.test.js
@@ -189,14 +190,14 @@ test/public-surface.test.js, test/structure.test.js
## Depends on
## 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,
- 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)
## Acceptance
## 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.
credentials; there is no CI project with credentials.
- A new developer goes from account creation to a working Media, event and capability
- 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
integration using only public documentation, the SDK and scoped credentials: the platform path
@@ -4,9 +4,9 @@
@@ -4,9 +4,9 @@
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
"version": "0.2.0",
"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",
"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",
"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": [
"not_working": [
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -6,8 +6,11 @@ OV_CLIENT_ID=
@@ -6,8 +6,11 @@ OV_CLIENT_ID=
OV_CLIENT_SECRET=
OV_CLIENT_SECRET=
# Optional: your project id (prj_...); read from the app token when empty.
# Optional: your project id (prj_...); read from the app token when empty.
OV_PROJECT_ID=
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=
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_CURSOR_PATH=./data/cursor.json
OV_POLL_MS=5000
OV_POLL_MS=5000
# Optional: an explicit Events origin instead of the one the registry advertises.
# Optional: an explicit Events origin instead of the one the registry advertises.
@@ -15,9 +15,12 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
@@ -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
- **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
`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> }`.
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
- **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
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
`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
@@ -35,10 +38,9 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
| File | What |
| 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 |
| `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
## Run the smoke test
@@ -46,11 +48,10 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
@@ -46,11 +48,10 @@ OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # pu
npm test
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
## Run it against the real platform
@@ -65,8 +66,8 @@ Things to know:
@@ -65,8 +66,8 @@ Things to know:
- Pull goes to `https://events.openvibe.network/api/v1/events` (the origin comes from the registry).
- 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
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
- 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
[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.
first-party events only; which ones arrive depends on which producers publish public events.
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -7,17 +7,17 @@
@@ -7,17 +7,17 @@
* node --env-file=.env publish.js # app.<project_key>.example.ping
* node --env-file=.env publish.js # app.<project_key>.example.ping
* node --env-file=.env publish.js order.created '{"total":3}'
* 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
* event_type app.<project_key>.<name> project_key = p + your project's ULID in lowercase
* source app-<your app's ULID in lowercase>
* source app-<your app's ULID in lowercase>
* actor { type: 'app', id: <your app id> }
* 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 { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
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_]+)*$/;
const NAME_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
@@ -42,16 +42,9 @@ async function publishAppEvent(config, { name = 'example.ping', subject = { type
@@ -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 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 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)) {
async function main([name, payload] = process.argv.slice(2)) {
@@ -7,10 +7,11 @@
@@ -7,10 +7,11 @@
*
*
* Two ways in, one cursor discipline:
* 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:
* realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(), anonymously:
* public first-party events only. Events never streams app.* events over realtime.
* 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.
* Resumes with Last-Event-ID from the saved cursor after a restart or a drop.
@@ -28,23 +29,9 @@ const fs = require('node:fs');
@@ -28,23 +29,9 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
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');
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) {
function loadConfig(env = process.env) {
const mode = env.OV_EVENTS_MODE || 'pull';
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' });
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) {
@@ -52,7 +39,8 @@ function loadConfig(env = process.env) {
if (mode === 'pull' && !hasCreds) {
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' });
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) {
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' });
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) {
@@ -63,7 +51,8 @@ function loadConfig(env = process.env) {
clientSecret: env.OV_CLIENT_SECRET || null,
clientSecret: env.OV_CLIENT_SECRET || null,
projectId: env.OV_PROJECT_ID || null,
projectId: env.OV_PROJECT_ID || null,
eventsUrl: env.OV_EVENTS_URL || 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'),
cursorPath: env.OV_CURSOR_PATH || path.join(__dirname, 'data', 'cursor.json'),
pollMs: Number(env.OV_POLL_MS || 5000),
pollMs: Number(env.OV_POLL_MS || 5000),
};
};
@@ -108,26 +97,32 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -108,26 +97,32 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
await onResync(g, { via });
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;
let projectId = config.projectId;
if (!projectId) {
if (!projectId) {
const info = await tokens.getTokenInfo({ audience: 'openvibe.events' }); // decoded, not verified: fine for naming
const info = await tokens.getTokenInfo({ audience: 'openvibe.events' }); // decoded, not verified: fine for naming
projectId = info.unverifiedClaims && info.unverifiedClaims.project_id;
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 ────────────────────────────────────────────────
// ── pull ────────────────────────────────────────────────
- const events = createEventsClient(client);
/** Read from the saved cursor to the head once. Returns the number of events handled. */
/** Read from the saved cursor to the head once. Returns the number of events handled. */
async function pullOnce({ limit = 100 } = {}) {
async function pullOnce({ limit = 100 } = {}) {
+ const ev = await eventsForApp();
let handled = 0;
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,
afterSeq: cursor.get('pull') || 0,
limit,
limit,
onGap: (g) => gap(g, 'pull'),
onGap: (g) => gap(g, 'pull'),
@@ -219,4 +214,4 @@ if (require.main === module) {
@@ -219,4 +214,4 @@ if (require.main === module) {
process.on('SIGTERM', stop);
process.on('SIGTERM', stop);
}
}
-module.exports = { loadConfig, createCursorStore, createSubscriber, projectKey, appSource };
+module.exports = { loadConfig, createCursorStore, createSubscriber };
deleted file mode 100644
deleted file mode 100644
@@ -1,122 +0,0 @@
@@ -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 };
@@ -1,18 +1,18 @@
@@ -1,18 +1,18 @@
'use strict';
'use strict';
/**
/**
* Smoke test: publish to the project's own topic, pull it with a durable cursor as the app, gap
* 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 assert = require('node:assert/strict');
const fs = require('node:fs');
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 { 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 { publishAppEvent, loadConfig: loadPublishConfig } = require('../publish');
-const { createAppEventsFetch } = require('./mock-app-events');
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
@@ -35,7 +35,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -35,7 +35,7 @@ const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
(async () => {
(async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));
const platform = createMockPlatform();
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.
// 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 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'] });
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') => ({
@@ -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(resyncs, [{ from_seq: a.seq + 1, to_seq: c.seq, via: 'pull' }]);
assert.deepEqual(seen, [d.seq, e.seq, f.seq]);
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() {} });
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() {} });
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');
await assert.rejects(s.pullOnce(), (err) => err.status === 403 && err.code === 'capability.denied');
@@ -6,15 +6,15 @@ Upload a file to OpenVibe.Media as a developer app, read it back and delete it,
@@ -6,15 +6,15 @@ Upload a file to OpenVibe.Media as a developer app, read it back and delete it,
```bash
```bash
node --env-file=.env upload.js ./logo.png
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-…",
# "url_expires_at": "2026-…",
# "sandbox": true,
# "sandbox": true,
# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false,
# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false,
# "project_id": "prj_…", "contracts": "0.28.0"
# "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
## What it proves
@@ -39,7 +39,7 @@ node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
@@ -39,7 +39,7 @@ node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
| File | What |
| File | What |
|---|---|
|---|---|
| `upload.js` | `loadConfig()`, `createUploader()` (`upload`, `get`, `remove`), `describe()`, `explain()` (hints for the usual errors), CLI |
| `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
## Run the smoke test
@@ -47,11 +47,10 @@ node --env-file=.env upload.js --delete 3f2a9c0d1b7e-logo.png
@@ -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
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
## Run it against the real platform
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -1,12 +1,9 @@
@@ -1,12 +1,9 @@
'use strict';
'use strict';
/**
/**
* Smoke test: the uploader against openvibe-sdk/testing's mock platform (fake Network + Media at
* 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 assert = require('node:assert/strict');
const crypto = require('node:crypto');
const crypto = require('node:crypto');
@@ -14,15 +11,15 @@ const fs = require('node:fs');
@@ -14,15 +11,15 @@ 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 { 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.
// CI has no network: anything that is not the mock platform fails loudly.
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
(async () => {
(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 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 other = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });
const noGrant = platform.addApp({ env: 'sandbox', grants: ['tools.job.read'] });
const noGrant = platform.addApp({ env: 'sandbox', grants: ['tools.job.read'] });
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-'));
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'); };
@@ -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 };
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
// 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 uploader = createUploader(loadConfig(env), { fetch: platform.fetch });
const out = await uploader.upload(file);
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.project_id, app.projectId);
- assert.equal(out.url, `https://openvibe.media/f/${out.key}`);
assert.equal(out.mime, 'text/plain');
assert.equal(out.mime, 'text/plain');
assert.equal(out.sha256, crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'));
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.
// 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`);
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$/);
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'));
const claims = JSON.parse(Buffer.from(call.headers.authorization.split('.')[1], 'base64url'));
assert.deepEqual(claims.cap, ['media.object.upload']);
assert.deepEqual(claims.cap, ['media.object.upload']);
@@ -56,31 +60,35 @@ globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
@@ -56,31 +60,35 @@ globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
assert.equal(again.key, out.key);
assert.equal(again.key, out.key);
assert.equal(again.deduplicated, true);
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 });
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)));
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 });
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)));
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 });
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'));
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));
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 });
fs.rmSync(dir, { recursive: true, force: true });
console.log('media-uploader: ok');
console.log('media-uploader: ok');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -51,7 +51,7 @@ function describe(file) {
@@ -51,7 +51,7 @@ function describe(file) {
if (!file) return null;
if (!file) return null;
return {
return {
key: file.key,
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,
url_expires_at: file.url_expires_at || null,
sandbox: Boolean(file.sandbox),
sandbox: Boolean(file.sandbox),
size: file.size, mime: file.mime, sha256: file.sha256,
size: file.size, mime: file.mime, sha256: file.sha256,
@@ -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.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"
}
}
}
}
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -41,9 +41,10 @@ node --env-file=.env run-job.js ./photo.png
@@ -41,9 +41,10 @@ node --env-file=.env run-job.js ./photo.png
npm test
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
## Run it against the real platform
@@ -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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -38,7 +38,7 @@ const TOOLS = ['tools.job.create', 'tools.job.read'];
@@ -38,7 +38,7 @@ const TOOLS = ['tools.job.create', 'tools.job.read'];
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tooljob-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-tooljob-'));
const file = path.join(dir, 'photo.png');
const file = path.join(dir, 'photo.png');
fs.writeFileSync(file, Buffer.from('not really a png, the mock does not care'));
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 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'));
const streams = () => platform.stats.requests.filter((r) => r.url.endsWith('/events'));
@@ -58,6 +58,7 @@ const TOOLS = ['tools.job.create', 'tools.job.read'];
@@ -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.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(fs.existsSync(env.OV_JOB_STATE), false, 'state cleared once done');
assert.equal(submits(), 1);
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'));
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}$/);
assert.match(submitCall.headers['idempotency-key'], /^ex-[0-9a-f]{40}$/);
@@ -11,8 +11,9 @@ node --env-file=.env --env-file=.env.webhook server.js
@@ -11,8 +11,9 @@ node --env-file=.env --env-file=.env.webhook server.js
## What it proves
## What it proves
- **An app subscription** (`events.app.subscribe`): `subscribe.js` creates it with
- **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
(`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`
addresses. `subscribe.js` generates the signing secret itself and writes it to `.env.webhook`
(mode 0600); it never prints it.
(mode 0600); it never prints it.
@@ -32,7 +33,6 @@ node --env-file=.env --env-file=.env.webhook server.js
@@ -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 |
| `server.js` | `createConsumer()`: the HTTP endpoint, signature check, inbox, `received_events` table |
| `subscribe.js` | `createSubscription()`: create the Events subscription with an app token |
| `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 |
| `test/smoke.test.js` | subscription and its topic/endpoint rules, then deliveries from the mock's delivery worker |
## Run the smoke test
## Run the smoke test
@@ -44,8 +44,8 @@ npm test
@@ -44,8 +44,8 @@ npm test
Deliveries come from `openvibe-sdk/testing`'s delivery worker (`deliverEvents()`): signed POSTs
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
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
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
## Run it against the real platform
@@ -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.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1",
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
}
}
}
}
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* Create the OpenVibe.Events subscription that delivers to this consumer.
* 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>.*
- * 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:
* 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
* - topic patterns that start with a literal segment; an app.* pattern must name YOUR project
@@ -20,14 +20,7 @@ const fs = require('node:fs');
@@ -20,14 +20,7 @@ const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
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) {
function loadConfig(env = process.env) {
const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
@@ -47,7 +40,8 @@ function loadConfig(env = process.env) {
@@ -47,7 +40,8 @@ function loadConfig(env = process.env) {
/**
/**
* -> { subscription: { id, topic_pattern, endpoint, enabled }, secret } (the secret is kept out of
* -> { 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 } = {}) {
async function createSubscription(config, { endpoint, topicPattern, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {
let u;
let u;
@@ -58,13 +52,9 @@ async function createSubscription(config, { endpoint, topicPattern, secret = `wh
@@ -58,13 +52,9 @@ async function createSubscription(config, { endpoint, topicPattern, secret = `wh
scope: { 'openvibe.events': 'events.app.subscribe' },
scope: { 'openvibe.events': 'events.app.subscribe' },
});
});
const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined });
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 };
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)) {
@@ -91,4 +81,4 @@ async function main([endpoint, topicPattern] = process.argv.slice(2)) {
if (require.main === module) main().then((code) => { process.exitCode = code; });
if (require.main === module) main().then((code) => { process.exitCode = code; });
-module.exports = { loadConfig, createSubscription, projectKey, main };
+module.exports = { loadConfig, createSubscription, main };
deleted file mode 100644
deleted file mode 100644
@@ -1,122 +0,0 @@
@@ -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 };
@@ -3,18 +3,17 @@
@@ -3,18 +3,17 @@
* Smoke test: the app creates its subscription (events.app.subscribe) on the SDK's mock platform,
* 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
* 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
* 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 assert = require('node:assert/strict');
const fs = require('node:fs');
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 } = require('openvibe-sdk/events');
+const { signDelivery, projectKey, appSource } = require('openvibe-sdk/events');
const { createConsumer, loadConfig, openDatabase } = require('../server');
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;
const realFetch = globalThis.fetch;
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
@@ -23,7 +22,7 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
@@ -23,7 +22,7 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
(async () => {
(async () => {
const platform = createMockPlatform();
const platform = createMockPlatform();
- const fetch = createAppEventsFetch(platform);
+ const { fetch } = platform;
const app = platform.addApp({ env: 'sandbox', grants: ['events.app.subscribe'] });
const app = platform.addApp({ env: 'sandbox', grants: ['events.app.subscribe'] });
const noGrant = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['events.app.read'] });
const noGrant = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['events.app.read'] });
const key = projectKey(app.projectId);
const key = projectKey(app.projectId);
@@ -36,10 +35,11 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
@@ -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.equal('secret' in created.subscription, false, 'the printable part never carries the secret');
assert.match(created.secret, /^whsec_[0-9a-f]{64}$/);
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: '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 }),
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');
(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';
@@ -69,7 +69,7 @@ const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
return realFetch(local, init);
return realFetch(local, init);
};
};
const appEvent = (name) => platform.publishEvent({
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 },
actor: { type: 'app', id: app.id }, subject: { type: 'order', id: '42' }, payload: { size: 12 },
}, `app:${app.id}`);
}, `app:${app.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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -67,8 +67,8 @@
@@ -67,8 +67,8 @@
"version": "0.2.0",
"version": "0.2.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"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": {
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -417,9 +417,9 @@
@@ -417,9 +417,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -469,9 +469,9 @@
@@ -469,9 +469,9 @@
"link": true
"link": true
},
},
"node_modules/openvibe-sdk": {
"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",
"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.0"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1"
}
}
}
}
@@ -68,7 +68,8 @@ async function main() {
@@ -68,7 +68,8 @@ async function main() {
maskOutput(env.OV_CLIENT_SECRET);
maskOutput(env.OV_CLIENT_SECRET);
const { createUploader, explain } = require(path.join(ROOT, 'examples/media-uploader/upload'));
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 { 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 { createJobRunner, loadConfig: jobConfig } = require(path.join(ROOT, 'examples/tool-job/run-job'));
const { loadConfig: mediaConfig } = require(path.join(ROOT, 'examples/media-uploader/upload'));
const { loadConfig: mediaConfig } = require(path.join(ROOT, 'examples/media-uploader/upload'));
@@ -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.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();
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.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');
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');