openvibers/openvibe.examples Commits
OpenVibe.Examples 0.2.0: openvibe-sdk v0.3.0 and the sandbox developer path as it works in production
Changes
@@@ -12,6 +12,7 @@ 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 small local-- # mocks (Chat WebSocket, Tools jobs). No test reaches the network or a real service.++ # 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.- run: npm test@@@ -3,11 +3,11 @@> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials> and public APIs, nothing else.--**Status:** alpha (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on Node--22.22.1 against `openvibe-sdk/testing`'s mock platform and two small local mocks. **None of them--runs against the live platform in CI**, and several cannot run against it yet: see--[What works against the real platform today](#what-works-against-the-real-platform-today).--**Built on:** [openvibe-sdk v0.2.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.2.2), and++**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on++Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three++of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it++is not part of CI or `npm test`, and it was not run against production as part of this release.++**Built on:** [openvibe-sdk v0.3.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.0), andopenvibe-contracts v0.26.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).@@@ -18,19 +18,120 @@ OpenVibe services themselves are AGPL-3.0.| Example | What it proves | Uses ||---|---|---|--| [browser-app](examples/browser-app) | A plain page (no build step) signs in with PKCE and reads a public API from the browser; a public app needs no secret | `openvibe-sdk/auth/browser`, `/registry`, `/core` |--| [node-server-app](examples/node-server-app) | Client-credentials app token per audience, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth`, `/registry`, `/core` |--| [webhook-consumer](examples/webhook-consumer) | `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`parseDelivery`, `createInbox`, `subscriptions.create`) |--| [event-subscriber](examples/event-subscriber) | Realtime subscription and pull with a durable cursor; resume after drops and restarts; gap handling | `openvibe-sdk/realtime`, `/events` |--| [media-uploader](examples/media-uploader) | Upload into your project's Media namespace with an app token; content-addressed, safe retries | `openvibe-sdk/media` |--| [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 |--| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, SSE resumed with `Last-Event-ID` | Tools `/api/v1/jobs`, `openvibe-sdk/core` |++| [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`) |++| [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) || [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |--| [oauth-app](examples/oauth-app) | Authorization code + PKCE with the exchange on the server; offline token verification; session hygiene | `openvibe-sdk/auth` |Each example folder has a README, a `.env.example`, its own `package.json` and a`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,--`OV_CLIENT_SECRET`, `OV_NETWORK_URL`, …); secrets are never logged or sent to a browser.++`OV_CLIENT_SECRET`, `OV_PROJECT_ID`, …); secrets are never logged or sent to a browser.++++## End-to-end walkthrough++++From a new account to the examples running against the platform, with public surfaces only.++Nothing here needs staff: a new project is `sandbox`, and a sandbox app's owner gets the sandbox++capabilities approved at once.++++**1. Create an account** at <https://openvibe.network>.++++**2. Create a project, a sandbox app and its grants.** Either:++++- **in the developer portal, <https://openvibe.codes>:** sign in, create a project, add a++ **confidential** app in the `sandbox` environment with the redirect URI++ `http://localhost:3009/callback` (for [oauth-app](examples/oauth-app)), copy the client secret++ (it is shown **once**), and request the grants `media.object.upload`, `media.object.read`,++ `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`,++ `tools.job.read`; or++- **with the projects API** (`openvibe-sdk/projects`): `npm run new-app` does exactly the above++ and writes `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and `OV_PROJECT_ID` into `./.env` (mode 0600)++ without printing the secret. It signs in with your username and password (not echoed) or uses++ `OV_USER_TOKEN`. The same in code:++++ ```js++ const { createClient } = require('openvibe-sdk/core');++ const { createProjectsClient } = require('openvibe-sdk/projects');++ const projects = createProjectsClient(createClient({ token: userAccessToken })); // a Network user token++ const project = await projects.create({ name: 'My first OpenVibe app' }); // sandbox, you are the owner++ const app = await projects.apps.create(project.id, { name: 'server', environment: 'sandbox', type: 'confidential' });++ app.credential.client_secret; // shown ONCE: store it now++ await projects.grants.request(project.id, app.id, 'media.object.upload'); // approved at once for the owner++ ```++++For [browser-app](examples/browser-app) add a second, **public** app with the redirect URI++`http://localhost:3001/callback` and `media.object.read` (`npm run new-app -- --public --project++prj_…`). A public app has no secret.++++**3. Check it works end to end:**++++```bash++npm ci++npm run e2e # reads ./.env: OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID++```++++It uploads a small file to your Media tenant, reads it back and deletes it; publishes++`app.<project_key>.e2e.ran` and pulls your topic until it finds it; converts a 1x1 PNG to WebP on++`img.openvibe.tools`, following the job's events. It prints each step (never the secret, a token++or a URL signature) and exits `1` if a step failed, `2` if a variable is missing.++++**4. Run the examples.** Each reads `.env` in its own folder (`cp .env.example .env`), or point it++at the root one: `node --env-file=../../.env upload.js ./logo.png`.++++| Example | App | Grants | Environment variables |++|---|---|---|---|++| [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 |++| [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` |++| [browser-app](examples/browser-app) | public, redirect `http://localhost:3001/callback` | `media.object.read` | `OV_CLIENT_ID`, `OV_AUDIENCE`, `OV_SCOPE` |++| [chat-bot](examples/chat-bot) | none: a Live API token (`hbt_…`) of a dedicated bot account | Live token scope `chat` | `OV_CHAT_TOKEN`, one of `OV_CHAT_CHANNEL_USER_ID` / `OV_CHAT_STREAM_ID` |++| [mod-manifest](examples/mod-manifest) | none | none | `OV_NETWORK_URL` for `--registry` |++++Rotate a secret in the portal or with `projects.credentials.rotate()`, revoke the app with++`projects.apps.revoke()`. Issued tokens end within their 5-minute lifetime.++++## How the platform works for a sandbox app++++Verified in production on 2026-09-23 with a brand-new account, using the public endpoints only:++++- **Projects.** A new project is `sandbox`. Its owner's grant requests for the sandbox allowance++ are approved at once: `media.object.upload|read`, `events.app.publish|read|subscribe`,++ `tools.job.create|read|cancel`.++- **Tokens.** `POST https://openvibe.network/oauth/token` with `grant_type=client_credentials`++ gives a confidential sandbox app a 5-minute token for `openvibe.media`, `openvibe.events` or++ `openvibe.tools`, with `project_id`, `env: sandbox`, `ns: [project_id]` and the approved++ capabilities for that audience.++- **Media.** The tenant is the project id: `/api/v1/<prj_…>/files`. Sandbox objects are stored in++ a separate sandbox tenant (`<prj_…>-sandbox`, not addressable by path) and never served++ publicly: signed URLs only.++- **Events.** App events are `app.<project_key>.<name…>` (`project_key` = `p` + the lowercased++ project ULID) with `source: app-<lowercased app ULID>`. Read them with++ `GET /api/v1/events?topic=app.<project_key>.*`; subscribe with public https endpoints only.++ Realtime (SSE) never streams app events.++- **Tools.** `/api/v1/jobs` accepts sandbox app tokens.++- **Discovery.** `/.well-known/openvibe` and `/api/v1/registry/*` answer any origin (CORS), so a++ browser page reads them directly.++++## What still does not work++++- **No consent screen.** Network's account chooser names the app but does not list the++ capabilities it asks for. Apps get no refresh tokens and learn only the person's `usr_…` id.++- **`/oauth/token` is not CORS-open**, like every Network route outside discovery and the++ registry, so a browser app exchanges its code on its own server++ ([browser-app](examples/browser-app) does).++- **Chat has no app principal.** `chat.message.send` is `first-party`; the++ [chat-bot](examples/chat-bot) signs in with a Live API token (`hbt_…`, scope `chat`) of a++ dedicated bot account, not with a developer app.++- **Production apps need staff**: switching a project to `sandbox+production` and setting its++ allowance.++- **Publishing a mod** into Games is not public (`games.mod.manage` is `first-party`).## Running the tests@@@ -44,11 +145,13 @@ cd examples/tool-job && npm testHow the tests stay offline:--- Network, Events and Media are `createMockPlatform()` from `openvibe-sdk/testing`: an in-process-- fake at the real public origins, with real RS256 tokens and the same audience, capability and-- namespace checks.--- Chat and Tools have no SDK mock, so their examples carry a small local mock in their `test/`-- folder (`mock-chat-server.js`, `mock-tools-server.js`) that plays the documented protocol.++- Network, Events, Media and Tools jobs are `createMockPlatform()` from `openvibe-sdk/testing`: an++ in-process fake at the real public origins, with real RS256 tokens, developer apps and projects,++ sandbox refusal unless a service opts in, `/oauth/authorize` with auto-consent, retention gaps++ (`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).- Every smoke test replaces the global `fetch` with one that throws, so an accidental real networkcall fails the test.@@@ -59,116 +162,15 @@ 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.2.2 tag, no `file:` links.----## End-to-end walkthrough----From a new account to running the examples, with public surfaces only. OpenVibe.Codes (the--developer portal) will be the UI for these steps; it has not launched, so this uses the--developer-projects API on OpenVibe.Network directly. Every call is documented in Network's--[developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md) doc.----**1. Create an account** at <https://openvibe.network> (or `POST /api/auth/register`).----**2. Get a user access token** for the projects API (it reads no cookies). This reads your--password without echoing it or putting it on a command line:----```bash--NET=https://openvibe.network--read -r -p 'username: ' OV_USER; read -r -s -p 'password: ' OV_PASS; echo; export OV_USER OV_PASS--TOKEN=$(node -e 'process.stdout.write(JSON.stringify({ username: process.env.OV_USER, password: process.env.OV_PASS }))' \-- | curl -s "$NET/api/auth/login" -H 'Content-Type: application/json' --data-binary @- \-- | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')--unset OV_PASS--auth=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')--```----**3. See what can be granted** (only `public`, `active` capabilities; `first-party` and `internal` never are):----```bash--curl -s "$NET/api/v1/projects/catalog" "${auth[@]}"--```----**4. Create a project.** You are its owner. A new project is `sandbox` only.----```bash--PRJ=$(curl -s -X POST "$NET/api/v1/projects" "${auth[@]}" -d '{"name":"My first OpenVibe app"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')--```----**5. Create a sandbox app.** A confidential app gets a client secret, shown **once**; this writes--it straight into an example's `.env` instead of the terminal:----```bash--cd examples/node-server-app--curl -s -X POST "$NET/api/v1/projects/$PRJ/apps" "${auth[@]}" \-- -d '{"name":"examples","environment":"sandbox","type":"confidential","redirect_uris":["http://localhost:3009/callback"]}' \-- | node -e 'const a = JSON.parse(require("fs").readFileSync(0)); require("fs").writeFileSync(".env", `OV_CLIENT_ID=${a.client_id}\nOV_CLIENT_SECRET=${a.credential.client_secret}\n`, { mode: 0o600 }); console.log(a.client_id)'--```----For [browser-app](examples/browser-app) create a second app with `"type":"public"` and the--redirect URI `http://localhost:3001/callback` (no secret).----**6. Request grants.** One call per capability; the audience is the owning service--(`openvibe.media` for `media.*`). As the project owner, a request inside the project's allowance is--approved at once; a developer's request waits for an owner or admin--(`POST …/grants/<capability>/approve`).----```bash--APP=app_... # the client id printed above--for c in media.object.upload media.object.read tools.job.create tools.job.read; do-- curl -s -X POST "$NET/api/v1/projects/$PRJ/apps/$APP/grants" "${auth[@]}" -d "{\"capability\":\"$c\"}"; echo--done--```----**7. Run the examples.** Start with [node-server-app](examples/node-server-app): its `/status`--shows the services the registry knows and, per audience, whether Network will give your app a--token and which capabilities it carries. Then follow each example's "Run it against the real--platform" section.----Rotate a secret with `POST …/apps/$APP/credentials/rotate`, revoke one with--`POST …/credentials/<id>/revoke`, revoke the app with `DELETE …/apps/$APP`. Issued tokens end--within their 5-minute lifetime.----## What works against the real platform today----These are the platform-side conditions as of this release, read from the services' code and docs.--Nothing here was run against production.----| Example | Today |--|---|---|--| mod-manifest | Works: validation is offline; `--registry` reads the public registry. Publishing a mod is not public yet. |--| node-server-app | Discovery and registry work for anyone. App tokens: see "Tokens" below. |--| chat-bot | Should work on openvibe.live chat with a Live API token (`hbt_…`, scope `chat`) from a dedicated bot account. Not a developer-app credential: Chat has no app principal, and `chat.message.send` is `first-party`. |--| tool-job | Tools accepts app tokens on `/api/v1/jobs`. Needs a token for `openvibe.tools` (see "Tokens"). |--| media-uploader | Needs a token for `openvibe.media` (see "Tokens") **and** a Media tenant named after your project id, created by the Media operators. |--| oauth-app, browser-app | Sign-in needs a token for the requested audience (see "Tokens"); production apps need an https redirect URI. The browser cannot read the registry cross-origin yet (Network's CORS allow-list runs first), so browser-app falls back to its own server and says so. |--| event-subscriber | Realtime works anonymously for `public` events. Pull cannot work: `events.event.read` is `internal` and the pull API is not on Events' public host. |--| webhook-consumer | Cannot be demonstrated: `events.subscription.manage` is `internal`, the subscription API is not on Events' public host, and deliveries only go to allow-listed hosts. The consumer itself is ready. |----**Tokens.** Two defaults on Network block every app token until staff change them:----- `DEV_SANDBOX_AUDIENCES` is empty, so a **sandbox** app gets `400 invalid_target` for every-- audience, and receivers built on openvibe-contracts ≥ 0.26 also refuse `env: sandbox` tokens-- unless they opt in. A **production** app avoids this, but only after staff switch the project to-- `sandbox+production`.--- `DEV_DEFAULT_ALLOWANCE` is empty, so a grant request stays unapproved (`403 grant.beyond_allowance`-- on approval) until staff put the capability in your project's allowance.----Capabilities a developer app can hold today (`public` + `active` in openvibe-contracts v0.26.0):--`media.object.upload`, `media.object.read`, `tools.job.create`, `tools.job.read`,--`tools.job.cancel`, `games.mod.read`, `games.world.announce`, `games.prop.place`. No `events.*`,--`chat.*` or identity capability is grantable.----So the Wave 20 exit criterion ("a new external developer goes from account creation to a working--Media, event and capability integration using only public documentation, the SDK and scoped--credentials") is **not met on the live platform yet**: Media needs staff actions (sandbox--audience or production policy, allowance, Media tenant), and the event integration has no public--path except anonymous realtime.++ code reads, MIT license, `openvibe-sdk` pinned to the v0.3.0 tag, no `file:` links; and that++ `npm run e2e` is outside CI and `npm test` and refuses to start without its variables.## Layout```examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js++scripts/e2e.js npm run e2e: three examples against the real platform (on demand only)++scripts/new-sandbox-app.js npm run new-app: project + sandbox app + grants via openvibe-sdk/projectstest/run.js runs every smoke test and the repository checks (npm test)test/public-surface.test.js, test/structure.test.js.github/workflows/ci.yml Node 22.22.1: npm ci && npm test@@@ -178,22 +180,29 @@ test/public-surface.test.js, test/structure.test.js- the nine charter examples: vanilla browser app, Node server app, webhook consumer, eventsubscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example++- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script## Does not own--- any platform service, the SDK (OpenVibe.SDK) or the contracts (OpenVibe.Contracts)++- any platform service, the SDK (OpenVibe.SDK), the contracts (OpenVibe.Contracts) or the++ developer portal (OpenVibe.Codes)## Depends on--- OpenVibe.SDK (v0.2.2), OpenVibe.Contracts (v0.26.0, mod manifest only)--- at run time against the real platform: Network (tokens, registry, developer projects), Events-- (realtime), Media, Tools, Chat (on openvibe.live)++- OpenVibe.SDK (v0.3.0), OpenVibe.Contracts (v0.26.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 local mocks (no network).-- Running them in CI against an integration environment needs sandbox-enabled audiences and a-- CI project with grants; that environment does not exist yet.++- 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++ credentials; there is no CI project with credentials.++- A new developer goes from account creation to a working Media, event and capability++ integration using only public documentation, the SDK and scoped credentials: the platform path++ was verified in production on 2026-09-23 (see++ [How the platform works](#how-the-platform-works-for-a-sandbox-app)); `npm run e2e` walks it with++ these examples.- No example needs a loopback-only internal key or first-party database access: **yes**, enforcedby `test/public-surface.test.js`.@@@ -3,10 +3,19 @@"stage": "alpha","code": true,"wave": "W20",-- "version": "0.1.0",-- "sdk": "openvibe-sdk v0.2.2",++ "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 and local mocks; none runs against the live platform",++ "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",++ "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": [++ "no consent screen; no refresh tokens or identity for apps",++ "/oauth/token is not CORS-open: browser apps exchange codes server-side",++ "chat.message.send is first-party: the chat bot uses a Live hbt_ token",++ "production apps and allowances need staff",++ "mod publishing is first-party"++ ],"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1, §18.9; roadmap Wave 20, §30, ADR-014",-- "updated": "2026-09-22"++ "updated": "2026-09-23"}@@@ -8,3 +8,6 @@ OV_SCOPE=media.object.readOV_REDIRECT_URI=http://localhost:3001/callback# Defaults to the redirect URI's port.OV_PORT=3001++# Optional: 1 serves /api/registry/services from this server as a fallback for the page.++# Not needed on the live platform: the registry is CORS-open to any origin.++OV_REGISTRY_PROXY=@@@ -1,7 +1,8 @@# Vanilla browser app--A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE--(`openvibe-sdk/auth/browser`) and reads a public API (the ecosystem registry) straight from the browser.++A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE and++reads a public API (the ecosystem registry) straight from the browser. The page imports the SDK's++browser bundle, `openvibe-sdk/browser/openvibe-sdk.mjs`, as an ES module.```bashnode --env-file=.env server.js # open http://localhost:3001@@@ -9,29 +10,31 @@ node --env-file=.env server.js # open http://localhost:3001## What it proves--- **PKCE in the browser.** The page calls `startAuthorization()`; the verifier and `state` live in-- `sessionStorage` until Network redirects back to `/callback`, where `readCallback()` checks-- `state` and the address bar is cleaned. The verifier is used once.++- **The SDK in a page without a bundler.** `public/app.js` is a module that imports++ `createClient`, `auth` and `registry` from `/vendor/openvibe-sdk.mjs`, which the server serves++ unchanged from `node_modules/openvibe-sdk/browser/`. The bundle holds only browser-safe code (no++ service tokens, no JWT verification, no secrets); the SDK tests that, and so does this example.++- **A public API from the page.** The page reads `https://openvibe.network/api/v1/registry/services`++ itself, cross-origin, with no token and no cookie: Network answers the registry and++ `/.well-known/openvibe` to any origin (CORS, preflight included). The DOM is built with++ `textContent`, never `innerHTML`, under a strict CSP.++- **PKCE in the browser.** The page calls `auth.startAuthorization()` with the app's `audience`++ and the capability ids it asks for; the verifier and `state` live in `sessionStorage` until++ Network redirects back to `/callback`, where `readCallback()` checks `state` and the address bar++ is cleaned. The verifier is used once.- **A public app has no secret.** The app is registered as `public`. Its tiny server exchanges-- code + verifier at Network's token endpoint (no secret; `audience` is sent) and keeps the-- resulting token in an HttpOnly session. It holds no secrets at all. The exchange endpoint only-- accepts same-origin JSON requests (`Origin` check, `415` otherwise), so another site cannot drive it.--- **A public API from the page.** `openvibe-sdk/registry` lists the network's services with no-- token and no cookie. The DOM is built with `textContent`, never `innerHTML`, under a strict CSP.--- **The SDK in a page without a bundler.** `openvibe-sdk` is CommonJS, so `lib/sdk-bundle.js`-- serves its browser-safe modules (`core`, `auth/browser`, `registry`) as one script that sets-- `window.OpenVibeSDK`. It refuses to include anything that is not a relative SDK module, and the-- test checks the served script contains no server-only code. With a bundler, import-- `openvibe-sdk/auth/browser` directly instead.++ code + verifier with `exchangeCode()` (no secret; the audience is sent), verifies the app token++ with `verifyAppToken()` and keeps it in an HttpOnly session. It holds no secrets at all. The++ exchange endpoint only accepts same-origin JSON requests (`Origin` check, `415` otherwise), so++ another site cannot drive it.## Files| File | What ||---|---|--| `public/index.html`, `public/app.js` | the page |--| `server.js` | static files, `/sdk/openvibe-sdk.js`, `/config.json`, `/auth/exchange`, `/api/session`, `/api/registry/services`, `/auth/logout` |--| `lib/sdk-bundle.js` | the serve-time wrapper for the SDK's browser modules |--| `test/smoke.test.js` | runs `public/app.js` and the served bundle in a small fake browser (vm) against the server and the SDK mock |++| `public/index.html`, `public/app.js` | the page and its module script |++| `server.js` | static files, `/vendor/openvibe-sdk.mjs`, `/config.json`, `/auth/exchange`, `/api/session`, `/auth/logout`, and the optional `/api/registry/services` |++| `test/smoke.test.js` | runs `public/app.js` and the SDK bundle as ES modules in a small fake browser (vm) against the server and the SDK mock |## Run the smoke test@@@ -39,27 +42,32 @@ node --env-file=.env server.js # open http://localhost:3001npm test```--There is no real browser in CI: the test gives the page's own script a minimal DOM,--`sessionStorage`, `location`, a cookie jar and `fetch`, clicks "Sign in", follows the redirect with--a code from `platform.authorize()` (which stands in for Network's consent page and verifies PKCE),--and checks the signed-in state, a forged callback, the registry read and its fallback.++There is no real browser in CI: the test links the page's module script to the served bundle with++`vm.SourceTextModule` (Node 22 runs it with `--experimental-vm-modules`; the test re-runs itself++with that flag), gives it a minimal DOM, `sessionStorage`, `location`, a cookie jar and `fetch`,++clicks "Sign in", follows Network's authorize URL on the SDK mock (its account chooser redirects++back with a code), and checks the signed-in state, a forged callback, the registry read and the++optional server fallback.## Run it against the real platform--1. Create a project and a **public** app with the redirect URI `http://localhost:3001/callback`-- ([walkthrough](../../README.md#end-to-end-walkthrough)); request `media.object.read` (or any-- grant for the audience you put in `OV_AUDIENCE`).++1. Create a project and a **public** sandbox app with the redirect URI++ `http://localhost:3001/callback` and the grant `media.object.read`++ ([walkthrough](../../README.md#end-to-end-walkthrough); `npm run new-app -- --public++ --project prj_…` at the repository root does exactly that). Any grant works if you change++ `OV_AUDIENCE` / `OV_SCOPE` to match it.2. `cp .env.example .env`, set `OV_CLIENT_ID`.--3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a project member.++3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a member of the++ project (a sandbox app can only be authorized by its project's members).--What does not work yet on the real platform:++Things to know:--- **The registry refuses third-party browser origins.** Network answers the registry with-- `Access-Control-Allow-Origin: *`, but its site-wide CORS allow-list rejects unknown origins-- first, and SDK calls carry `traceparent` / `X-OpenVibe-Request-Id` headers, which need a-- preflight. The page therefore falls back to reading the same public registry through its own-- server (`/api/registry/services`) and says so on the page.--- **Sign-in** needs Network to mint a token for `OV_AUDIENCE`: a sandbox app gets `invalid_target`-- until staff list that audience in `DEV_SANDBOX_AUDIENCES`; a production app needs an https-- redirect URI. Sign-in always asks for a capability: there is no identity-only sign-in for-- apps, and an app learns only the person's `usr_…` id (profile lookups are `first-party`).++- **The token exchange stays on a server.** Network's `/oauth/*` routes are not CORS-open to++ third-party origins, so a browser cannot call the token endpoint itself; this is also where++ the token is best kept (an HttpOnly session, not page scripts).++- **The server-side registry read is optional.** With `OV_REGISTRY_PROXY=1` the server also++ answers `/api/registry/services`, and the page falls back to it when the browser cannot reach++ Network. It is off by default because the registry is readable from the browser.++- **No consent screen** yet (Network's account chooser does not list the capabilities), and an app++ learns only the person's `usr_…` id: there is no identity-only sign-in for apps, and profile++ lookups are `first-party`.ddeleted file mode 100644@@@ -1,62 +0,0 @@--'use strict';--/**-- * Serve openvibe-sdk's browser-safe modules to a page with no build step.-- *-- * openvibe-sdk 0.2.2 is CommonJS, and its ESM entry points import CommonJS files, so a browser-- * cannot load it directly without a bundler. This file wraps the modules a sign-in page needs-- * (core, auth/browser, registry) and everything they require into one script that sets-- * `window.OpenVibeSDK = { core, auth, registry }`. It refuses anything that is not a relative-- * require inside the SDK (no `node:` modules, no packages), so server-only code can never slip in.-- *-- * If you use a bundler (esbuild, Vite, webpack), import 'openvibe-sdk/auth/browser' etc. instead.-- */--const fs = require('node:fs');--const path = require('node:path');--const crypto = require('node:crypto');----const ENTRIES = { core: 'src/core/index.js', auth: 'src/auth/browser.js', registry: 'src/registry.js' };--const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;----function buildSdkBundle(root = path.dirname(require.resolve('openvibe-sdk/package.json'))) {-- const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;-- const modules = new Map();-- const visit = (id) => {-- if (modules.has(id)) return;-- const source = fs.readFileSync(path.join(root, id), 'utf8');-- modules.set(id, null);-- const deps = {};-- for (const [, spec] of source.matchAll(REQUIRE_RE)) {-- if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": not browser-safe, refusing to bundle`);-- let dep = path.posix.normalize(path.posix.join(path.posix.dirname(id), spec));-- if (!dep.endsWith('.js')) dep += '.js';-- deps[spec] = dep;-- visit(dep);-- }-- if (/process\.env|client_secret/.test(source)) throw new Error(`${id} references server-only material`);-- modules.set(id, { source, deps });-- };-- for (const id of Object.values(ENTRIES)) visit(id);---- const defs = [...modules].map(([id, { source, deps }]) => ` ${JSON.stringify(id)}: [${JSON.stringify(deps)}, function (module, exports, require) {\n${source}\n}]`).join(',\n');-- const code = `/* openvibe-sdk ${version}: core, auth/browser, registry (MIT). Generated at serve time by examples/browser-app/lib/sdk-bundle.js. */--(function (global) {-- 'use strict';-- var defs = {--${defs}-- };-- var cache = {};-- function load(id) {-- if (cache[id]) return cache[id].exports;-- var def = defs[id];-- if (!def) throw new Error('openvibe-sdk bundle: no module ' + id);-- var module = cache[id] = { exports: {} };-- def[1](module, module.exports, function (spec) { return load(def[0][spec]); });-- return module.exports;-- }-- global.OpenVibeSDK = { version: ${JSON.stringify(version)}, ${Object.entries(ENTRIES).map(([k, id]) => `${k}: load(${JSON.stringify(id)})`).join(', ')} };--})(typeof window !== 'undefined' ? window : globalThis);--`;-- return { code, version, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"`, modules: [...modules.keys()] };--}----module.exports = { buildSdkBundle, ENTRIES };@@@ -1,6 +1,6 @@{"name": "openvibe-example-browser-app",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Vanilla browser app: sign in with OpenVibe.Network (authorization code + PKCE) and call a public API from the browser.","license": "MIT",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}@@@ -1,98 +1,102 @@/* OpenVibe browser app example: sign in with PKCE, then read the public registry.-- * Plain browser JavaScript, no framework and no build step. window.OpenVibeSDK comes from-- * /sdk/openvibe-sdk.js (openvibe-sdk's core, auth/browser and registry modules). DOM nodes are-- * built with textContent, never innerHTML. */--(function () {-- 'use strict';-- var sdk = window.OpenVibeSDK;-- var PKCE_KEY = 'ov_example_pkce';-- var $ = function (id) { return document.getElementById(id); };++ * Plain browser JavaScript, no framework and no build step: an ES module that imports++ * openvibe-sdk's browser bundle (browser/openvibe-sdk.mjs, served by this app's server).++ * DOM nodes are built with textContent, never innerHTML. */++import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';-- function showError(err) {-- var el = $('auth-error');-- el.textContent = 'Sign-in failed: ' + ((err && (err.code || err.message)) || 'unknown error');-- el.hidden = false;-- }++const PKCE_KEY = 'ov_example_pkce';++const $ = (id) => document.getElementById(id);-- function getJson(url, init) {-- return fetch(url, Object.assign({ credentials: 'same-origin', headers: { Accept: 'application/json' } }, init || {}))-- .then(function (res) { return res.json().then(function (body) { return { status: res.status, body: body }; }); });-- }++function showError(err) {++ const el = $('auth-error');++ el.textContent = `Sign-in failed: ${(err && (err.code || err.message)) || 'unknown error'}`;++ el.hidden = false;++}-- /** Back from Network on /callback: check state, hand code + verifier to this app's server. */-- function finishSignIn() {-- var href = location.href;-- var saved = null;-- try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch (e) { saved = null; }-- sessionStorage.removeItem(PKCE_KEY);-- history.replaceState(null, '', '/');-- var callback;-- try {-- if (!saved) throw { code: 'oauth.no_pending_sign_in' };-- callback = sdk.auth.readCallback(href, { expectedState: saved.state });-- } catch (err) { showError(err); return Promise.resolve(); }-- return getJson('/auth/exchange', {-- method: 'POST',-- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },-- body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),-- }).then(function (r) { if (r.status !== 200) showError({ code: r.body.error }); });-- }++async function getJson(url, init = {}) {++ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' }, ...init });++ return { status: res.status, body: await res.json() };++}-- function startSignIn(config) {-- return sdk.auth.startAuthorization({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope })-- .then(function (a) {-- sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));-- location.assign(a.url);-- });-- }++/** Back from Network on /callback: check state, hand code + verifier to this app's server. */++async function finishSignIn() {++ const href = location.href;++ let saved = null;++ try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch { saved = null; }++ sessionStorage.removeItem(PKCE_KEY);++ history.replaceState(null, '', '/');++ let callback;++ try {++ if (!saved) throw Object.assign(new Error('no sign-in was started here'), { code: 'oauth.no_pending_sign_in' });++ callback = auth.readCallback(href, { expectedState: saved.state });++ } catch (err) { showError(err); return; }++ const r = await getJson('/auth/exchange', {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),++ });++ if (r.status !== 200) showError({ code: r.body.error });++}-- function showSession() {-- return getJson('/api/session').then(function (r) {-- var signedIn = r.status === 200;-- $('who').textContent = signedIn-- ? 'Signed in with OpenVibe as ' + r.body.subject + '. This app may: ' + (r.body.capabilities.join(', ') || 'nothing') + '.'-- : 'Not signed in.';-- $('sign-in').hidden = signedIn;-- $('sign-out').hidden = !signedIn;-- });-- }++/** An app asks for capability ids of ONE audience; the code is bound to that audience. */++async function startSignIn(config) {++ const a = await auth.startAuthorization({++ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,++ });++ sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));++ location.assign(a.url);++}-- function renderServices(services, via) {-- var list = $('services');-- list.textContent = '';-- services.forEach(function (s) {-- var li = document.createElement('li');-- var name = document.createElement('code');-- name.textContent = s.id;-- li.appendChild(name);-- li.appendChild(document.createTextNode(' ' + s.status + (s.publicOrigin ? ' - ' + s.publicOrigin : '')));-- list.appendChild(li);-- });-- $('registry-status').textContent = services.length + ' services, ' + via + '.';++async function showSession() {++ const r = await getJson('/api/session');++ const signedIn = r.status === 200;++ $('who').textContent = signedIn++ ? `Signed in with OpenVibe as ${r.body.subject}. This app may: ${r.body.capabilities.join(', ') || 'nothing'}.`++ : 'Not signed in.';++ $('sign-in').hidden = signedIn;++ $('sign-out').hidden = !signedIn;++}++++function renderServices(services, via) {++ const list = $('services');++ list.textContent = '';++ for (const s of services) {++ const li = document.createElement('li');++ const name = document.createElement('code');++ name.textContent = s.id;++ li.appendChild(name);++ li.appendChild(document.createTextNode(` ${s.status}${s.publicOrigin ? ` - ${s.publicOrigin}` : ''}`));++ list.appendChild(li);}++ $('registry-status').textContent = `${services.length} services, ${via}.`;++}-- /** A public API straight from the browser: no token, no cookie. */-- function loadRegistry(config) {-- var client = sdk.core.createClient({ network: config.network, retries: 1 });-- return sdk.registry.createRegistryClient(client).services()-- .then(function (services) { renderServices(services, 'read by this page from ' + config.network); })-- .catch(function () {-- // The browser refused the cross-origin call (CORS). Read it through this app's server.-- return getJson('/api/registry/services').then(function (r) {-- if (r.status !== 200) throw new Error(r.body.error);-- renderServices(r.body.services, 'read through this app\'s server (the registry does not allow this origin from a browser yet)');-- });-- })-- .catch(function (err) { $('registry-status').textContent = 'Could not read the registry: ' + (err.code || err.message); });++/**++ * A public API straight from the browser: no token, no cookie. Network answers the registry to++ * any origin (CORS, preflight included). The server-side read is an opt-in fallback++ * (OV_REGISTRY_PROXY=1) for pages that cannot reach Network from the browser.++ */++async function loadRegistry(config) {++ try {++ const client = createClient({ network: config.network, retries: 1 });++ renderServices(await registry.createRegistryClient(client).services(), `read by this page from ${config.network}`);++ } catch (err) {++ if (!config.registryProxy) {++ $('registry-status').textContent = `Could not read the registry: ${err.code || err.message}`;++ return;++ }++ const r = await getJson('/api/registry/services');++ if (r.status !== 200) { $('registry-status').textContent = `Could not read the registry: ${r.body.error}`; return; }++ renderServices(r.body.services, 'read through this app\'s server (OV_REGISTRY_PROXY)');}++}-- getJson('/config.json').then(function (r) {-- var config = r.body;-- $('sign-in').addEventListener('click', function () { startSignIn(config).catch(showError); });-- $('sign-out').addEventListener('click', function () {-- getJson('/auth/logout', { method: 'POST' }).then(showSession);-- });-- var done = location.pathname === new URL(config.redirectUri).pathname ? finishSignIn() : Promise.resolve();-- return done.then(showSession).then(function () { return loadRegistry(config); });-- });--}());++const { body: config } = await getJson('/config.json');++$('sign-in').addEventListener('click', () => { startSignIn(config).catch(showError); });++$('sign-out').addEventListener('click', async () => {++ await getJson('/auth/logout', { method: 'POST' });++ await showSession();++});++if (location.pathname === new URL(config.redirectUri).pathname) await finishSignIn();++await showSession();++await loadRegistry(config);@@@ -31,7 +31,6 @@</section><noscript><p>This example needs JavaScript: it shows how a browser signs in with the SDK.</p></noscript>-- <script src="/sdk/openvibe-sdk.js"></script>-- <script src="/app.js"></script>++ <script type="module" src="/app.js"></script></body></html>@@@ -1,39 +1,39 @@#!/usr/bin/env node'use strict';/**-- * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE-- * (openvibe-sdk/auth/browser in the browser) and calls a public API (the ecosystem registry)-- * straight from the browser.++ * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE and reads a++ * public API (the ecosystem registry) straight from the browser. No framework, no build step: the++ * page imports openvibe-sdk's browser bundle (browser/openvibe-sdk.mjs) as an ES module.** node --env-file=.env server.js # http://localhost:3001** The app is a PUBLIC developer app: it has no client secret. The browser makes the PKCE pair and* keeps the verifier in sessionStorage; after the redirect it hands code + verifier to this tiny-- * server, which exchanges them at Network's token endpoint (Network does not open its token-- * endpoint to third-party origins, and the resulting token is kept out of page scripts in an-- * HttpOnly session). The server has no secrets at all.++ * server, which exchanges them at Network's token endpoint with openvibe-sdk exchangeCode() (no++ * secret; the audience is sent). Network's /oauth/* is not CORS-open to third-party origins, and++ * the token is better kept out of page scripts anyway: it lives in an HttpOnly session here.*-- * GET /, /callback the page (public/index.html)-- * GET /app.js the page's script-- * GET /sdk/openvibe-sdk.js openvibe-sdk's browser-safe modules as one script (lib/sdk-bundle.js)-- * GET /config.json public settings: network, client id, redirect URI, scope-- * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }-- * GET /api/session who is signed in-- * GET /api/registry/services the same public registry read, server side: the page's fallback-- * while Network does not allow third-party origins on the registry++ * GET /, /callback the page (public/index.html)++ * GET /app.js the page's module script++ * GET /vendor/openvibe-sdk.mjs openvibe-sdk's browser bundle, served from node_modules as is++ * GET /config.json public settings: network, client id, redirect URI, audience, scope++ * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }++ * GET /api/session who is signed in* POST /auth/logout++ * GET /api/registry/services OPTIONAL (OV_REGISTRY_PROXY=1): the same public registry read,++ * server side, for pages that cannot call Network from the browser*/const crypto = require('node:crypto');const fs = require('node:fs');const http = require('node:http');const path = require('node:path');const { createClient, isOpenVibeError } = require('openvibe-sdk/core');--const { verifyUserToken } = require('openvibe-sdk/auth');++const { exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');const { createRegistryClient } = require('openvibe-sdk/registry');--const { buildSdkBundle } = require('./lib/sdk-bundle');const SESSION_COOKIE = 'ov_browser_example_sid';const PUBLIC = path.join(__dirname, 'public');++const SDK_BUNDLE = require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs');function loadConfig(env = process.env) {const missing = ['OV_CLIENT_ID', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);@@@ -47,20 +47,19 @@ function loadConfig(env = process.env) {redirectUri,origin: new URL(redirectUri).origin,port: Number(env.OV_PORT || new URL(redirectUri).port || 3001),++ registryProxy: ['1', 'true', 'yes'].includes(String(env.OV_REGISTRY_PROXY || '').toLowerCase()),};}--/** Network's token endpoint, authorization_code for a public app (no secret; `audience` is required). */--async function exchangePublicCode(client, { clientId, code, codeVerifier, redirectUri, audience, scope }) {-- return client.json({-- service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,-- urlencoded: { grant_type: 'authorization_code', client_id: clientId, code, code_verifier: codeVerifier, redirect_uri: redirectUri, audience, scope: scope.join(' ') },-- });++/** The SDK's browser bundle, read once: { code, etag }. */++function loadSdkBundle() {++ const code = fs.readFileSync(SDK_BUNDLE);++ return { code, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"` };}function createApp(config, { fetch, log = console } = {}) {const client = createClient({ network: config.network, fetch });-- const bundle = buildSdkBundle();++ const bundle = loadSdkBundle();const sessions = new Map(); // sid -> { subject, capabilities, expiresAt }const secure = config.redirectUri.startsWith('https://');const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;@@@ -72,7 +71,10 @@ function createApp(config, { fetch, log = console } = {}) {const s = sessions.get(sidOf(req));return s && s.expiresAt > Date.now() ? s : null;};-- const publicConfig = JSON.stringify({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope });++ const publicConfig = JSON.stringify({++ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,++ registryProxy: config.registryProxy,++ });function json(res, status, body, headers = {}) {res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });@@@ -102,13 +104,19 @@ function createApp(config, { fetch, log = console } = {}) {return json(res, 400, { error: 'bad_request' });}try {-- const tokens = await exchangePublicCode(client, { ...config, code, codeVerifier });++ // A public app: no client secret, so the PKCE verifier is what proves this exchange.++ const tokens = await exchangeCode({++ network: config.network, fetch, clientId: config.clientId, code, codeVerifier,++ redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,++ });const d = await client.discover();-- const claims = await verifyUserToken(tokens.access_token, {-- jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, allowServiceTokens: true, fetch,++ // acceptSandbox: only a token minted for THIS app is trusted below, so its env is this++ // app's own environment (a new project's apps are sandbox).++ const claims = await verifyAppToken(tokens.access_token, {++ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, acceptSandbox: true, fetch,});// Developer-app tokens name the person in on_behalf_of and must be for this app.-- const subject = claims.actor_type === 'app' ? (claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null) : claims.subject_id;++ const subject = claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null;if (!subject) return json(res, 400, { error: 'no_subject' });for (const [k, v] of sessions) if (v.expiresAt <= Date.now()) sessions.delete(k); // in-memory store: clean upconst sid = crypto.randomBytes(32).toString('base64url');@@@ -126,7 +134,7 @@ function createApp(config, { fetch, log = console } = {}) {try {if (req.method === 'GET' && (url.pathname === '/' || url.pathname === new URL(config.redirectUri).pathname)) return file(res, 'index.html', 'text/html; charset=utf-8');if (req.method === 'GET' && url.pathname === '/app.js') return file(res, 'app.js', 'text/javascript; charset=utf-8');-- if (req.method === 'GET' && url.pathname === '/sdk/openvibe-sdk.js') {++ if (req.method === 'GET' && url.pathname === '/vendor/openvibe-sdk.mjs') {if (req.headers['if-none-match'] === bundle.etag) { res.writeHead(304, { ETag: bundle.etag }); return res.end(); }res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: bundle.etag });return res.end(bundle.code);@@@ -140,7 +148,7 @@ function createApp(config, { fetch, log = console } = {}) {const s = current(req);return s ? json(res, 200, { subject: s.subject, capabilities: s.capabilities, expires_at: new Date(s.expiresAt).toISOString() }) : json(res, 401, { error: 'not_signed_in' });}-- if (req.method === 'GET' && url.pathname === '/api/registry/services') {++ if (config.registryProxy && req.method === 'GET' && url.pathname === '/api/registry/services') {return json(res, 200, { services: await createRegistryClient(client).services() }, { 'Cache-Control': 'public, max-age=30' });}if (req.method === 'POST' && url.pathname === '/auth/logout') {@@@ -164,4 +172,4 @@ if (require.main === module) {server.listen(config.port, () => console.log(`browser-app: ${config.origin} (redirect URI ${config.redirectUri})`));}--module.exports = { loadConfig, createApp, exchangePublicCode };++module.exports = { loadConfig, createApp };@@@ -1,23 +1,32 @@'use strict';/**-- * Smoke test: the page's own script (public/app.js) and the served SDK bundle run in a small fake-- * browser (a vm context with a minimal DOM, sessionStorage, location, a cookie jar and fetch),-- * against this app's server and the SDK's mock platform. Covers: sign-in button -> authorize URL-- * with PKCE, the callback -> exchange -> signed-in session, the registry read from the page, and-- * the fallback through the app's server when the browser refuses the cross-origin call.++ * Smoke test: the page's own module script (public/app.js) and the SDK's browser bundle, as this++ * app's server serves them, run as ES modules in a small fake browser (a vm context with a minimal++ * DOM, sessionStorage, location, a cookie jar and fetch), against the server and the SDK's mock++ * platform. Covers: the registry read from the page, sign-in -> Network's authorize URL with PKCE++ * and the audience, the account chooser's redirect back -> exchange -> signed-in session, a forged++ * callback, the optional server-side registry fallback, and sign-out.++ *++ * ES modules in a vm context need vm.SourceTextModule, which Node 22 has behind++ * --experimental-vm-modules; this file re-runs itself with that flag.*/++const vm = require('node:vm');++++if (typeof vm.SourceTextModule !== 'function') {++ const { spawnSync } = require('node:child_process');++ const r = spawnSync(process.execPath, ['--experimental-vm-modules', '--disable-warning=ExperimentalWarning', __filename], { stdio: 'inherit' });++ process.exit(r.status == null ? 1 : r.status);++}++const assert = require('node:assert/strict');const fs = require('node:fs');const path = require('node:path');--const vm = require('node:vm');const { createMockPlatform } = require('openvibe-sdk/testing');const { createApp, loadConfig } = require('../server');const realFetch = globalThis.fetch;globalThis.fetch = () => { throw new Error('network access in a smoke test'); };--const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TA';--async function waitFor(check, what, ms = 3000) {const until = Date.now() + ms;while (Date.now() < until) {@@@ -54,8 +63,9 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigiassign(u) { this.assigned = u; },};const setUrl = (u) => { const x = new URL(u, origin); location.href = x.href; location.pathname = x.pathname; };++ const crossOriginCalls = [];async function browserFetch(input, init = {}) {-- const url = new URL(String(input), location.href);++ const url = new URL(String(input && input.url ? input.url : input), location.href);if (url.origin === origin) {const headers = new Headers(init.headers || {});if (jar.cookie) headers.set('cookie', jar.cookie);@@@ -65,96 +75,126 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigiif (set) jar.cookie = set.split(';')[0].endsWith('=') ? '' : set.split(';')[0];return res;}++ crossOriginCalls.push({ url: url.href, credentials: init.credentials });if (crossOrigin === 'refuse') throw new TypeError('Failed to fetch'); // what a CORS refusal looks likereturn platform.fetch(input, init);}const context = vm.createContext({document, location, sessionStorage, fetch: browserFetch,history: { replaceState: (_s, _t, u) => setUrl(u) },-- console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController,-- TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto,++ console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController, AbortSignal,++ TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto, Blob, FormData, ReadableStream,});context.window = context;-- return { context, el, location, setUrl };++ return { context, el, location, setUrl, crossOriginCalls };}(async () => {const platform = createMockPlatform({ users: [{ username: 'ana' }] });const ana = [...platform.state.users.values()][0];-- const env = { OV_CLIENT_ID: APP, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read' };++ const project = platform.addProject({ owner: ana.subject_id, environmentPolicy: 'sandbox' });++ const redirectUri = 'http://localhost:3001/callback';++ // A public sandbox app: no secret, redirect URI registered, a grant for the audience it signs in for.++ const devApp = platform.addApp({ project, env: 'sandbox', type: 'public', redirectUris: [redirectUri], grants: ['media.object.read'] });++ assert.equal(devApp.secret, undefined);++ platform.setAuthorization({ subjectId: ana.subject_id });++++ const env = { OV_CLIENT_ID: devApp.id, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read', OV_REDIRECT_URI: redirectUri };const config = loadConfig(env);-- platform.addClient(APP, { secret: null, redirectUris: [config.redirectUri] }); // a public app: no secretconst logs = [];const app = createApp(config, { fetch: platform.fetch, log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });await new Promise((r) => app.server.listen(0, '127.0.0.1', r));const base = `http://127.0.0.1:${app.server.address().port}`;-- // The served bundle is browser-safe: only SDK modules, nothing server-side.-- const bundleRes = await realFetch(`${base}/sdk/openvibe-sdk.js`);++ // The page loads the SDK's own browser bundle, unchanged, as an ES module.++ const page = await (await realFetch(`${base}/`)).text();++ assert.match(page, /<script type="module" src="\/app\.js"><\/script>/);++ const bundleRes = await realFetch(`${base}/vendor/openvibe-sdk.mjs`);assert.equal(bundleRes.status, 200);++ assert.match(bundleRes.headers.get('content-type'), /^text\/javascript/);const bundle = await bundleRes.text();-- assert.ok(!/client_secret|process\.env|require\('node:|require\('crypto'\)/.test(bundle));-- assert.deepEqual(app.bundle.modules.sort(), ['src/auth/browser.js', 'src/core/client.js', 'src/core/errors.js', 'src/core/ids.js', 'src/core/index.js', 'src/core/paginate.js', 'src/core/semver.js', 'src/core/trace.js', 'src/registry.js']);-- assert.equal((await realFetch(`${base}/sdk/openvibe-sdk.js`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);-- const page = await (await realFetch(`${base}/`)).text();-- assert.match(page, /<script src="\/sdk\/openvibe-sdk\.js"><\/script>/);++ assert.equal(bundle, fs.readFileSync(require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'utf8'));++ assert.ok(!/client_secret|process\.env|require\('node:/.test(bundle), 'nothing server-side in the browser bundle');++ assert.equal((await realFetch(`${base}/vendor/openvibe-sdk.mjs`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);const publicConfig = await (await realFetch(`${base}/config.json`)).json();-- assert.deepEqual(Object.keys(publicConfig).sort(), ['clientId', 'network', 'redirectUri', 'scope']);++ assert.deepEqual(Object.keys(publicConfig).sort(), ['audience', 'clientId', 'network', 'redirectUri', 'registryProxy', 'scope']);++ assert.equal((await realFetch(`${base}/api/registry/services`)).status, 404, 'the server-side registry read is opt-in');const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');const storage = new Map();const sessionStorage = { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k) };const jar = { cookie: '' };-- const load = (url, opts = {}) => {-- const b = createBrowser({ base, origin: config.origin, platform, sessionStorage, jar, ...opts });++ /** Load the page at `url` in a fresh browsing context: app.js imports /vendor/openvibe-sdk.mjs. */++ const load = async (url, { serverBase = base, ...opts } = {}) => {++ const b = createBrowser({ base: serverBase, origin: config.origin, platform, sessionStorage, jar, ...opts });b.setUrl(url);-- vm.runInContext(bundle, b.context);-- vm.runInContext(appJs, b.context);++ const sdk = new vm.SourceTextModule(bundle, { context: b.context, identifier: `${config.origin}/vendor/openvibe-sdk.mjs` });++ const mod = new vm.SourceTextModule(appJs, { context: b.context, identifier: `${config.origin}/app.js` });++ await mod.link((specifier) => {++ if (specifier === '/vendor/openvibe-sdk.mjs') return sdk;++ throw new Error(`app.js imports ${specifier}`);++ });++ b.done = mod.evaluate();++ b.done.catch((err) => { b.error = err; });return b;};-- // 1. First visit: not signed in; the registry is read by the page itself (no token, no cookie).-- let b = load('/');++ // 1. First visit: not signed in; the registry is read by the page itself, cross-origin, with++ // no token and no cookie.++ let b = await load('/');await waitFor(() => /services, read by this page/.test(b.el('registry-status').textContent), 'the registry read');assert.equal(b.el('who').textContent, 'Not signed in.');assert.equal(b.el('sign-in').hidden, false);assert.ok(b.el('services').children.some((li) => li.textContent.startsWith('media alpha')));++ assert.ok(b.crossOriginCalls.some((c) => c.url.startsWith('https://openvibe.network/api/v1/registry/services')));const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));assert.ok(!registryCall.headers.authorization);++ assert.ok(b.crossOriginCalls.every((c) => c.credentials !== 'include'));// 2. Sign in: the page makes the PKCE pair and goes to Network's authorize URL.b.el('sign-in').click();await waitFor(() => b.location.assigned, 'the redirect to Network');const to = new URL(b.location.assigned);assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');-- assert.equal(to.searchParams.get('client_id'), APP);++ assert.equal(to.searchParams.get('client_id'), devApp.id);assert.equal(to.searchParams.get('code_challenge_method'), 'S256');++ assert.equal(to.searchParams.get('audience'), 'openvibe.media');assert.equal(to.searchParams.get('scope'), 'media.object.read');const saved = JSON.parse(storage.get('ov_example_pkce'));assert.equal(saved.state, to.searchParams.get('state'));-- // 3. Network sends the browser back with a code (platform.authorize stands in for the consent page).-- const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge') });-- b = load(`/callback?code=${code}&state=${encodeURIComponent(saved.state)}`);++ // 3. At Network the person continues; the account chooser redirects back with a code.++ const atNetwork = await platform.fetch(to.toString(), { redirect: 'manual' });++ assert.equal(atNetwork.status, 302);++ const back = new URL(atNetwork.headers.get('location'));++ assert.equal(`${back.origin}${back.pathname}`, redirectUri);++ b = await load(`${back.pathname}${back.search}`);await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the signed-in session');-- assert.match(b.el('who').textContent, new RegExp(`Signed in with OpenVibe as ${ana.subject_id}`));++ assert.equal(b.el('who').textContent, `Signed in with OpenVibe as ${ana.subject_id}. This app may: media.object.read.`);assert.equal(b.location.pathname, '/', 'the code is removed from the address bar');assert.equal(storage.has('ov_example_pkce'), false, 'the verifier is used once');assert.match(jar.cookie, /^ov_browser_example_sid=/);-- const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));-- assert.equal(exchange.length, 1);++ const exchanges = () => platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));++ assert.equal(exchanges().length, 1, 'exchanged once, by this app\'s server');++ assert.ok(!b.crossOriginCalls.some((c) => c.url.includes('/oauth/token')), 'the browser never calls the token endpoint');// 4. A callback this browser did not start: refused before any exchange.storage.set('ov_example_pkce', JSON.stringify({ state: 'mine', codeVerifier: saved.codeVerifier }));-- b = load('/callback?code=abc&state=forged');++ b = await load('/callback?code=abc&state=forged');await waitFor(() => b.el('auth-error').textContent !== '', 'the state error');assert.match(b.el('auth-error').textContent, /oauth\.state_mismatch/);-- assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);++ assert.equal(exchanges().length, 1);-- // 5. When the browser refuses the cross-origin registry call, the page reads it through its server.-- b = load('/', { crossOrigin: 'refuse' });++ // 5. When the browser cannot reach Network (a CORS refusal), the page says so; with++ // OV_REGISTRY_PROXY=1 it reads the same registry through its own server instead.++ b = await load('/', { crossOrigin: 'refuse' });++ await waitFor(() => /Could not read the registry/.test(b.el('registry-status').textContent), 'the registry error');++ const proxied = createApp(loadConfig({ ...env, OV_REGISTRY_PROXY: '1' }), { fetch: platform.fetch, log: { log() {}, error() {} } });++ await new Promise((r) => proxied.server.listen(0, '127.0.0.1', r));++ b = await load('/', { crossOrigin: 'refuse', serverBase: `http://127.0.0.1:${proxied.server.address().port}` });await waitFor(() => /through this app's server/.test(b.el('registry-status').textContent), 'the registry fallback');assert.ok(b.el('services').children.length > 0);++ proxied.server.close();// 6. The exchange endpoint accepts only this page: same Origin and a JSON body.const cross = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' });@@@ -163,7 +203,7 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigiassert.equal(form.status, 415);// 7. Sign out.-- b = load('/');++ b = await load('/');await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the session on reload');b.el('sign-out').click();await waitFor(() => b.el('who').textContent === 'Not signed in.', 'the sign-out');@@@ -69,8 +69,9 @@ Only use it in a channel whose streamer agreed to have it there; they can ban itLimits today:- **Not an app credential.** Chat has no bot or app principal: the bot signs in with a Live API-- token, which is a scoped credential of a person's account, not a Network developer-app token.-- `chat.message.send` is a `first-party` capability, so a developer app cannot be granted it.++ token (`hbt_…`), which is a scoped credential of a person's account, not a Network++ developer-app token. `chat.message.send` is a `first-party` capability, so a developer app++ cannot be granted it, and it is not in the sandbox allowance.- **No bot flag in the protocol.** The `[bot]` prefix and the account name are the only way thebot can identify itself; chat clients cannot mark bots differently yet.- Chat is still served on `openvibe.live` (`openvibe.chat` has not launched). The Live API-token@@@ -1,6 +1,6 @@{"name": "openvibe-example-chat-bot",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Chat bot: a clearly labelled bot that talks in exactly one configured chat room.","license": "MIT",@@@ -1,11 +1,14 @@--# realtime: SSE, works without credentials for public events. pull: needs events.event.read.--OV_EVENTS_MODE=realtime--OV_TOPICS=chat.message.created++# pull (default): your project's own events as your app. realtime: public first-party events, no credentials.++OV_EVENTS_MODE=pull++# A CONFIDENTIAL sandbox app holding events.app.read (and events.app.publish for publish.js).++OV_NETWORK_URL=https://openvibe.network++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++OV_TOPICS=OV_CURSOR_PATH=./data/cursor.jsonOV_POLL_MS=5000--OV_NETWORK_URL=https://openvibe.network# Optional: an explicit Events origin instead of the one the registry advertises.OV_EVENTS_URL=--# Only for pull mode (or to read non-public events over realtime).--OV_CLIENT_ID=--OV_CLIENT_SECRET=@@@ -1,34 +1,44 @@# Event subscriberFollow OpenVibe.Events topics without losing or double-handling events, across dropped--connections, restarts and retention gaps.++connections, restarts and retention gaps. As a developer app, the topic you own is your project's++`app.<project_key>.*`.```bash--OV_TOPICS='chat.message.created' node --env-file=.env subscriber.js # realtime, no credentials--OV_EVENTS_MODE=pull node --env-file=.env subscriber.js # pull, needs events.event.read++node --env-file=.env publish.js order.created '{"total":3}' # something to receive (events.app.publish)++node --env-file=.env subscriber.js # pull your project's topic (events.app.read)++OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # public first-party events, no credentials```## What it proves--- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`. Without-- credentials you receive `public` events only; `internal` events never reach you. After a-- dropped connection or a restart it resumes from the saved cursor with `Last-Event-ID`, and-- nothing is delivered twice.++- **Your own topic.** A developer app publishes and reads `app.<project_key>.<name…>`, where++ `project_key` is `p` followed by the project's ULID in lowercase (`prj_01JAB…` → `p01jab…`), and++ its events carry `source: app-<app ULID in lowercase>` and `actor: { type: 'app', id: <app id> }`.++ `publish.js` builds exactly that with `openvibe-sdk/events` `publish()` (which fills `event_id`,++ `timestamp`, `version` and `trace_id`; a repeated `event_id` is answered as a duplicate, so++ retries are safe). Events refuses another project's `app.*` topic (`403 events.topic_not_allowed`).- **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`) only once every item of that page is handled. A handler that throws leaves-- the cursor on the last event that succeeded, so the failed event is retried next time.--- **Gaps**: when Events can no longer supply part of the range (retention pruned it, or a restored-- cursor is ahead of the stream), `onResync(gap)` is called. Nothing can replay the missing events,-- so reload whatever state you derive from them from its source of truth.++ `next_after_seq`) in `onPage`, which the SDK calls only after every item of that page was++ handled. A handler that throws leaves the cursor on the last event that succeeded, so the failed++ event is retried next time.++- **Gaps**: when Events can no longer supply part of the range (retention pruned it: sandbox app++ events are kept 7 days; or a restored cursor is ahead of the stream), `onResync(gap)` is called.++ Nothing can replay the missing events, so reload whatever state you derive from them.++- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`,++ anonymously, for public first-party events. It resumes from the saved cursor with++ `Last-Event-ID`, and nothing is delivered twice. The app token is never sent on it.- The cursor file is replaced atomically (write + rename).## Files| File | What ||---|---|--| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`), `createCursorStore()` |--| `test/smoke.test.js` | pull with cursor and failure, pull gap, anonymous realtime, reconnect, restart, cursor-ahead gap |++| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`, `resolveTopics()`), `createCursorStore()`, `projectKey()`, `appSource()` |++| `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 |## Run the smoke test@@@ -36,17 +46,28 @@ OV_EVENTS_MODE=pull node --env-file=.env subscriber.jsnpm test```--The SDK mock never prunes events, so the pull-gap case wraps the mock's `fetch` to add the `gap`--field Events returns after pruning. Realtime gaps come from the mock itself (`cursor_ahead`).++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.## Run it against the real platform--- **Realtime works without an app**: `cp .env.example .env`, keep `OV_EVENTS_MODE=realtime`, set-- `OV_TOPICS`, run `node --env-file=.env subscriber.js`. The stream is-- `https://events.openvibe.network/realtime/stream` (from the registry). You will only see-- events that a producer publishes with visibility `public` on those topics. Chat's-- `chat.message.created` is public, but Chat has not been cut over yet, so whether anything-- arrives depends on which producers are live.--- **Pull cannot be demonstrated yet.** `events.event.read` is an `internal` capability (never-- granted to apps), and the pull API (`/api/v1/events`, checkpoints) is not exposed on Events'-- public host. Checkpoints stored in Events are therefore not used here: the cursor is a local file.++1. Create a project and a **confidential** sandbox app with `events.app.publish` and++ `events.app.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).++2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET` (`OV_PROJECT_ID` is++ optional: it is read from the app token).++3. `node --env-file=.env publish.js`, then `node --env-file=.env subscriber.js`. The subscriber++ prints `pulling app.<project_key>.* every 5000 ms` and each event it handles.++++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.++- Realtime never streams `app.*` events, to anyone; use pull (or a webhook, see++ [webhook-consumer](../webhook-consumer)) for your own events. Anonymous realtime shows public++ first-party events only; which ones arrive depends on which producers publish public events.++- Events limits a sandbox project to 30 published events per minute and 5 MiB of retained events.@@@ -1,8 +1,8 @@{"name": "openvibe-example-event-subscriber",-- "version": "0.1.0",++ "version": "0.2.0","private": true,-- "description": "Event subscriber: realtime subscription and cursor-based pull with gap handling.",++ "description": "Event subscriber: publish to your project's topic, pull it with a durable cursor and gap handling; anonymous realtime for public events.","license": "MIT","main": "subscriber.js","scripts": {@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}nnew file mode 100644@@@ -0,0 +1,70 @@++#!/usr/bin/env node++'use strict';++/**++ * Publish one event to your project's own topic, so the subscriber (and webhook-consumer) have++ * something to receive.++ *++ * 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:++ * 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.++ */++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 NAME_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;++++function loadConfig(env = process.env) {++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);++ if (missing.length) throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });++ return {++ network: env.OV_NETWORK_URL || 'https://openvibe.network',++ clientId: env.OV_CLIENT_ID,++ clientSecret: env.OV_CLIENT_SECRET,++ projectId: env.OV_PROJECT_ID || null,++ eventsUrl: env.OV_EVENTS_URL || null,++ };++}++++/** -> { event_id, seq, duplicate, event_type } */++async function publishAppEvent(config, { name = 'example.ping', subject = { type: 'example', id: '1' }, payload = {}, eventId } = {}, { fetch } = {}) {++ if (!NAME_RE.test(name)) throw new TypeError('the event name is dot-separated segments of a-z, 0-9 and _ (e.g. order.created)');++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ scope: { 'openvibe.events': 'events.app.publish' },++ });++ 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 };++}++++async function main([name, payload] = process.argv.slice(2)) {++ try {++ const out = await publishAppEvent(loadConfig(), { name: name || undefined, payload: payload ? JSON.parse(payload) : {} });++ console.log(JSON.stringify(out, null, 2));++ return 0;++ } catch (err) {++ console.error(`publish failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} (request ${err.requestId})` : err.message}`);++ return 1;++ }++}++++if (require.main === module) main().then((code) => { process.exitCode = code; });++++module.exports = { loadConfig, publishAppEvent, main };@@@ -7,19 +7,22 @@** Two ways in, one cursor discipline:*-- * realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(). Works without-- * credentials for events whose visibility is `public`. Resumes with Last-Event-ID from-- * the saved cursor after a restart or a dropped connection.-- * pull GET /api/v1/events through openvibe-sdk/events iterate(), from the saved cursor.-- * Needs an app token holding events.event.read.++ * 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.++ * 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.** The cursor is saved only AFTER an event was handled, so a crash replays at most the event in* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events-- * that did not match the topic, to the page's next_after_seq, once every item of that page is done.++ * that did not match the topic, to the page's next_after_seq: iterate() calls onPage only after++ * every item of that page was handled, so saving it there is crash-safe.*-- * A gap means Events can no longer give you part of the range (retention pruned it, or the cursor-- * is ahead of the stream after a restore). Nothing can replay it, so the subscriber calls-- * onResync(gap): reload whatever state you derive from these events from its source of truth.++ * A gap means Events can no longer give you part of the range (retention pruned it: sandbox app++ * events are kept 7 days; or the cursor is ahead of the stream after a restore). Nothing can replay++ * it, so the subscriber calls onResync(gap): reload whatever state you derive from these events.*/const fs = require('node:fs');const path = require('node:path');@@@ -28,20 +31,39 @@ const { createServiceTokenClient } = require('openvibe-sdk/auth');const { createEventsClient } = 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 || 'realtime';-- if (!['realtime', 'pull'].includes(mode)) throw Object.assign(new Error('OV_EVENTS_MODE must be realtime or pull'), { code: 'config.invalid' });++ 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' });const hasCreds = Boolean(env.OV_CLIENT_ID && env.OV_CLIENT_SECRET);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);++ 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' });++ }return {mode,network: env.OV_NETWORK_URL || 'https://openvibe.network',clientId: env.OV_CLIENT_ID || null,clientSecret: env.OV_CLIENT_SECRET || null,++ projectId: env.OV_PROJECT_ID || null,eventsUrl: env.OV_EVENTS_URL || null,-- topics: String(env.OV_TOPICS || 'chat.message.created').split(',').map((t) => t.trim()).filter(Boolean),++ topics, // empty in pull mode: your project's app.<project_key>.*cursorPath: env.OV_CURSOR_PATH || path.join(__dirname, 'data', 'cursor.json'),pollMs: Number(env.OV_POLL_MS || 5000),};@@@ -71,15 +93,13 @@ function createCursorStore(file) {*/function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = console, cursor = createCursorStore(config.cursorPath) } = {}) {if (typeof onEvent !== 'function') throw new TypeError('onEvent is required');-- const tokenProvider = config.clientId && config.clientSecret++ const baseUrls = config.eventsUrl ? { events: config.eventsUrl } : undefined;++ const tokens = config.clientId && config.clientSecret? createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch })-- : undefined;-- const client = createClient({-- network: config.network,-- fetch,-- tokenProvider,-- baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,-- });++ : null;++ // Pull runs as your app; realtime is anonymous (public events only), so it never sends the app token.++ const client = createClient({ network: config.network, fetch, tokenProvider: tokens || undefined, baseUrls });++ const anonymous = createClient({ network: config.network, fetch, baseUrls });const stats = { handled: 0, gaps: 0 };async function gap(g, via) {@@@ -88,22 +108,31 @@ 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;++ let projectId = config.projectId;++ if (!projectId) {++ const info = await tokens.getTokenInfo({ audience: 'openvibe.events' }); // decoded, not verified: fine for naming++ projectId = info.unverifiedClaims && info.unverifiedClaims.project_id;++ }++ topics = [`app.${projectKey(projectId)}.*`];++ return topics;++ }++// ── 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 } = {}) {-- let pageNext = null;let handled = 0;const iterator = events.iterate({-- topic: config.topics,++ topic: await resolveTopics(),afterSeq: cursor.get('pull') || 0,limit,onGap: (g) => gap(g, 'pull'),-- // Called when a page arrives, before its items are handed out: the previous page is done.-- onPage: (page) => {-- if (pageNext != null) cursor.set('pull', pageNext);-- pageNext = page.next_after_seq;-- },++ // After every item of the page was handled: also moves past events of other topics.++ onPage: (page) => cursor.set('pull', page.next_after_seq),});for await (const { seq, event } of iterator) {await onEvent(event, { seq, via: 'pull' });@@@ -111,7 +140,6 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = chandled++;stats.handled++;}-- if (pageNext != null) cursor.set('pull', pageNext);return handled;}@@@ -122,7 +150,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = casync function pollLoop() {while (!stopped) {try { await pullOnce(); } catch (err) {-- log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);++ log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} request=${err.requestId}` : err.message}`);}if (stopped) break;await new Promise((r) => { timer = setTimeout(r, config.pollMs); });@@@ -140,7 +168,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = cstats.handled++;}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));}, {-- client,++ client: anonymous,fetch,transport: 'fetch',lastEventId: cursor.get('realtime'),@@@ -156,6 +184,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = cstats,cursor,pullOnce,++ resolveTopics,start() {stopped = false;if (config.mode === 'pull') { pollLoop(); return null; }@@@ -182,9 +211,12 @@ if (require.main === module) {},});s.start();++ if (config.mode === 'pull') {++ s.resolveTopics().then((t) => console.log(`pulling ${t.join(', ')} every ${config.pollMs} ms`), () => {});++ }const stop = () => { s.stop(); process.exit(0); };process.on('SIGINT', stop);process.on('SIGTERM', stop);}--module.exports = { loadConfig, createCursorStore, createSubscriber };++module.exports = { loadConfig, createCursorStore, createSubscriber, projectKey, appSource };nnew file mode 100644@@@ -0,0 +1,122 @@++'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,19 +1,21 @@'use strict';/**-- * Smoke test: pull with a durable cursor, realtime with resume, and gap handling in both, against-- * the SDK's mock platform (fake Network + Events, including /realtime/stream). No network.++ * 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.*/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 } = require('../subscriber');++const { createSubscriber, loadConfig, createCursorStore, projectKey, appSource } = 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'); };--const APP = 'app_01JEXAMPLESUBSCRIBER000000';--const SECRET = 'ovsec_event_subscriber_test_secret';const quiet = { log() {}, error() {} };async function waitFor(check, what, ms = 3000) {@@@ -25,85 +27,113 @@ async function waitFor(check, what, ms = 3000) {throw new Error(`timed out waiting for ${what}`);}--const media = (visibility = 'public', type = 'media.object.uploaded') => ({++const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({event_type: type, source: 'media', visibility,-- actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' },++ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPA0BJECT0000000000' },});(async () => {const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-subscriber-'));-- const platform = createMockPlatform({-- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.event.read', audience: 'openvibe.events' }] } },-- });---- // ── pull: durable cursor ────────────────────────────────-- const a = platform.publishEvent(media());-- platform.publishEvent(media('public', 'chat.message.created')); // another topic: skipped, cursor moves past it-- const b = platform.publishEvent(media('internal', 'media.object.deleted'));-- const pullEnv = { OV_EVENTS_MODE: 'pull', OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'pull.json') };++ const platform = createMockPlatform();++ const fetch = createAppEventsFetch(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'] });++ const publishOnly = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['events.app.publish'] });++++ // Names: project_key and source exactly as Events defines them.++ assert.equal(projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.equal(appSource('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.equal(appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');++ const key = projectKey(app.projectId);++++ // ── publish to the own topic ────────────────────────────++ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };++ const pub = (e, opts) => publishAppEvent(loadPublishConfig(e || env), opts || {}, { fetch });++ const a = await pub(env, { name: 'order.created', payload: { total: 3 } });++ assert.equal(a.event_type, `app.${key}.order.created`);++ assert.equal(a.duplicate, false);++ const stored = platform.state.events.find((x) => x.event.event_id === a.event_id).event;++ assert.equal(stored.source, appSource(app.id));++ assert.deepEqual(stored.actor, { type: 'app', id: app.id });++ // Same event_id again: Events answers with the stored seq, nothing new.++ assert.equal((await pub(env, { name: 'order.created', eventId: a.event_id })).duplicate, true);++ await assert.rejects(pub(env, { name: 'Bad Name' }), /dot-separated/);++++ platform.publishEvent(firstParty('public', 'live.stream.started')); // first-party, another topic++ await pub({ OV_CLIENT_ID: other.id, OV_CLIENT_SECRET: other.secret }, { name: 'order.created' }); // another project++ platform.publishEvent(firstParty('internal', `app.${key}.forged`)); // not from this project's apps: never shown++ const b = await pub(env, { name: 'order.paid' });++++ // ── pull: own topic by default, durable cursor ──────────++ const pullEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'pull.json') };let seen = [];-- let s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });++ let s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });++ assert.deepEqual(await s.resolveTopics(), [`app.${key}.*`], 'the project id comes from the app token');assert.equal(await s.pullOnce({ limit: 2 }), 2);-- assert.deepEqual(seen, [a.seq, b.seq]);-- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), 3, 'cursor saved at the head');++ assert.deepEqual(seen, [a.seq, b.seq], 'own project only: not the other project, not the forged one');++ const head = platform.state.events.at(-1).seq;++ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), head, 'cursor saved at the head');// A new process resumes from the cursor: only what is new.-- const c = platform.publishEvent(media());++ const c = await pub(env, { name: 'order.shipped' });seen = [];-- s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });++ s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });assert.equal(await s.pullOnce(), 1);assert.deepEqual(seen, [c.seq]);// A handler that fails leaves the cursor on the last event that succeeded.-- const d = platform.publishEvent(media());-- const e = platform.publishEvent(media());++ const d = await pub(env);++ const e = await pub(env);seen = [];s = createSubscriber(loadConfig(pullEnv), {-- fetch: platform.fetch, log: quiet,++ fetch, log: quiet,onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },});await assert.rejects(s.pullOnce(), /boom/);assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);-- s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });++ s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });await s.pullOnce();assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');-- // A gap (retention pruned the range): onResync is told, reading continues. The mock never-- // prunes, so this wraps its fetch to answer the way Events does after pruning.-- const gapFetch = async (input, init) => {-- const res = await platform.fetch(input, init);-- const url = String(input && input.url ? input.url : input);-- if (!url.includes('/api/v1/events?')) return res;-- const body = await res.json();-- return new Response(JSON.stringify({ ...body, gap: { from_seq: 7, to_seq: 9, reason: 'retention' } }), { status: res.status, headers: { 'Content-Type': 'application/json' } });-- };-- const f = platform.publishEvent(media());++ // A gap: retention pruned the range the cursor still needs. onResync is told, reading continues.++ const gapEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'gap.json') };++ createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', a.seq);++ platform.pruneEvents(c.seq);++ const f = await pub(env);const resyncs = [];seen = [];-- s = createSubscriber(loadConfig(pullEnv), { fetch: gapFetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });++ s = createSubscriber(loadConfig(gapEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });await s.pullOnce();-- assert.deepEqual(resyncs, [{ from_seq: 7, to_seq: 9, reason: 'retention', via: 'pull' }]);-- assert.deepEqual(seen, [f.seq]);++ 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.++ 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');++ 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');-- // Pull needs credentials; realtime does not.-- assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'pull' }), /OV_CLIENT_ID/);++ // Pull needs credentials; realtime needs topics but no credentials.++ assert.throws(() => loadConfig({}), /OV_CLIENT_ID/);++ assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'realtime' }), /OV_TOPICS/);-- // ── realtime: anonymous, public events only, resume ────++ // ── realtime: anonymous, public first-party events only, resume ──const rtEnv = { OV_EVENTS_MODE: 'realtime', OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'realtime.json') };const live = [];-- const rt = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });++ const rt = createSubscriber(loadConfig(rtEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });const subscription = rt.start();await waitFor(() => subscription.connected, 'the realtime connection');-- const p1 = platform.publishEvent(media());-- platform.publishEvent(media('internal')); // never reaches an anonymous viewer-- const p2 = platform.publishEvent(media('public', 'media.object.deleted'));++ const p1 = platform.publishEvent(firstParty());++ platform.publishEvent(firstParty('internal')); // never reaches an anonymous viewer++ const p2 = platform.publishEvent(firstParty('public', 'media.object.deleted'));await waitFor(() => live.length === 2, 'two public events');assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');// The connection drops; an event published meanwhile arrives once after the reconnect.platform.dropRealtime();-- const p3 = platform.publishEvent(media());++ const p3 = platform.publishEvent(firstParty());await waitFor(() => live.length === 3, 'the event published during the drop');assert.equal(live[2].seq, p3.seq);await new Promise((r) => setTimeout(r, 100));@@@ -111,9 +141,9 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({rt.stop();// A restart resumes from the saved cursor: the event published while stopped is delivered.-- const p4 = platform.publishEvent(media());++ const p4 = platform.publishEvent(firstParty());const after = [];-- const rt2 = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });++ const rt2 = createSubscriber(loadConfig(rtEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });rt2.start();await waitFor(() => after.length === 1, 'the event published while stopped');assert.deepEqual(after, [p4.seq]);@@@ -123,14 +153,14 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({const aheadPath = path.join(dir, 'ahead.json');createCursorStore(aheadPath).set('realtime', 9999);const rtGaps = [];-- const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch: platform.fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });++ const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });rt3.start();await waitFor(() => rtGaps.length === 1, 'the realtime gap');assert.equal(rtGaps[0].reason, 'cursor_ahead');assert.equal(rtGaps[0].via, 'realtime');rt3.stop();-- // No credentials were sent on the anonymous stream.++ // Realtime never carried credentials, even with an app configured.const streamCalls = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream'));assert.ok(streamCalls.length >= 3);assert.ok(streamCalls.every((r) => !r.headers.authorization));@@@ -1,8 +1,8 @@--# A CONFIDENTIAL developer app holding media.object.upload (audience openvibe.media).++# A CONFIDENTIAL sandbox app holding media.object.upload (and media.object.read for --get), audience openvibe.media.OV_NETWORK_URL=https://openvibe.networkOV_CLIENT_ID=OV_CLIENT_SECRET=--# Your project id (prj_...): Network puts it in the token's ns claim, and Media stores under it.--OV_MEDIA_NAMESPACE=++# Optional: your project id (prj_...), the Media tenant. Read from the app token when empty.++OV_PROJECT_ID=# Optional: an explicit Media origin instead of the one the registry advertises.OV_MEDIA_URL=@@@ -1,25 +1,36 @@# Media uploader--Upload a file to OpenVibe.Media as a developer app, using `openvibe-sdk/media` and nothing else.++Upload a file to OpenVibe.Media as a developer app, read it back and delete it, using++`openvibe-sdk/media` and nothing else.```bashnode --env-file=.env upload.js ./logo.png# {# "key": "3f2a9c0d1b7e-logo.png",--# "public_url": "https://openvibe.media/f/3f2a9c0d1b7e-logo.png",--# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false++# "url": "https://openvibe.media/f/3f2a9c0d1b7e-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```## What it proves--- An app with only a client id, a secret and one grant (`media.object.upload`) gets a 5-minute-- app token from Network's public token endpoint, asking for exactly that capability (`scope`).++- An app with only a client id, a secret and its grants gets a 5-minute app token from Network's++ public token endpoint, asking for exactly the capability each call needs (`scope`):++ `media.object.upload` to upload or delete, `media.object.read` to read.- Media's origin comes from the platform descriptor (`client.discover()`), not a hard-coded host,and the contracts version the network runs is checked against the SDK's supported range.--- The upload lands in the app's own namespace. For a developer app that is the **project id**-- (`prj_…`), because Network puts the project id in the token's `ns` claim. Another namespace is-- refused by Media with `403 capability.namespace_denied`.++- **Your project is your Media tenant.** The path names the project id (`prj_…`), which is also the++ token's `project_id` and `ns`; it is read from the token when `OV_PROJECT_ID` is empty. Another++ project's tenant is refused with `403 capability.namespace_denied`.++- **Sandbox files are private.** Media keeps a sandbox app's files in a separate sandbox tenant of++ the project (you still address it by the project id) and never serves them publicly: an upload++ comes back with `sandbox: true` and a signed, expiring `url`. A production app's files get a++ public `/f/<key>` URL.- Uploads are content-addressed: the same bytes again come back `deduplicated`, so retries are safe.- The secret is read from the environment and never printed, not even in error messages.@@@ -27,8 +38,8 @@ node --env-file=.env upload.js ./logo.png| File | What ||---|---|--| `upload.js` | `loadConfig()`, `createUploader()`, `explain()` (hints for the usual errors), CLI |--| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (fake Network + Media) |++| `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 |## Run the smoke test@@@ -37,24 +48,18 @@ npm test # from this folder, or `npm test -- media` from the repository```No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same--audience, capability and namespace checks.++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.## Run it against the real platform--1. Follow the [walkthrough](../../README.md#end-to-end-walkthrough) to create a project and a-- **confidential** app, and request `media.object.upload` on it.--2. `cp .env.example .env` and fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and-- `OV_MEDIA_NAMESPACE` (your `prj_…` project id).++1. Create a project and a **confidential** sandbox app with `media.object.upload` and++ `media.object.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).++2. `cp .env.example .env` and fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.3. `node --env-file=.env upload.js ./some-file.png`--What has to be true on the platform side first, and is **not** true by default today:----- **Sandbox apps get no Media token.** Network issues sandbox tokens only for audiences listed in-- `DEV_SANDBOX_AUDIENCES`, which is empty by default, so a sandbox app gets `invalid_target`.-- Media must also accept `env: sandbox` tokens. Until both opt in, use a production app (staff-- switch the project to `sandbox+production`).--- **The capability must be in your project's allowance** (staff set it; the default allowance is empty).--- **Media needs a tenant named after your project id.** Media only accepts uploads for tenants it-- knows, and tenants are created by the Media operators.--- Media v1 accepts app tokens for **upload only**. Listing, reading metadata and deleting-- (`media.files.list/get/delete`) need a Media app API key, which developer apps do not have.++Media creates the project's tenant on first use; no operator step is needed. A sandbox tenant has++a small quota (100 MB by default; beyond it an upload is `413`). Open a sandbox file with the signed `url`++before `url_expires_at`; `--get` returns a fresh one.@@@ -1,8 +1,8 @@{"name": "openvibe-example-media-uploader",-- "version": "0.1.0",++ "version": "0.2.0","private": true,-- "description": "Media uploader: upload a file to OpenVibe.Media with an app token.",++ "description": "Media uploader: upload, read and delete files in your project's OpenVibe.Media tenant with app tokens.","license": "MIT","main": "upload.js","scripts": {@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}@@@ -1,73 +1,85 @@'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 checks). No network.++ * 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.*/const assert = require('node:assert/strict');++const crypto = require('node:crypto');const fs = require('node:fs');const os = require('node:os');const path = require('node:path');const { createMockPlatform } = require('openvibe-sdk/testing');--const { createUploader, explain, loadConfig } = require('../upload');++const { createUploader, describe, 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'); };--const APP = 'app_01JEXAMPLEMEDIA0000000000';--const PRJ = 'prj_01JEXAMPLEPROJECT00000000';--const OTHER = 'prj_01JSOMEONEELSE00000000000';--const SECRET = 'ovsec_media_uploader_test_secret';--(async () => {-- const platform = createMockPlatform({-- clients: {-- [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] },-- app_01JNOGRANTS000000000000000: { secret: 'x', grants: [] },-- },-- mediaApps: { [PRJ]: {}, [OTHER]: {} },-- });++ // Media accepts sandbox app tokens on the project's own tenant.++ const platform = createMockPlatform({ acceptSandbox: ['openvibe.media'] });++ const app = platform.addApp({ env: 'sandbox', grants: ['media.object.upload', 'media.object.read'] });++ 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-'));const file = path.join(dir, 'hello.txt');fs.writeFileSync(file, 'hello from an OpenVibe example\n');-- const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_MEDIA_NAMESPACE: PRJ };++ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };-- // 1. Upload: token for openvibe.media, Media origin from discovery, file stored in the project namespace.++ // 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.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.equal(out.public_url, `https://openvibe.media/f/${out.key}`);++ 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, require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex'));-- assert.ok(platform.state.files.has(`${PRJ}|${out.key}`), 'stored under the project namespace');++ 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');-- // The upload carried a short-lived Network token (not an API key) and a trace.-- const call = platform.stats.requests.find((r) => r.url === `https://openvibe.media/api/v1/${PRJ}/files`);++ // 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.match(call.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);-- const token = platform.stats.requests.find((r) => r.url.endsWith('/oauth/token'));-- assert.ok(token);++ const claims = JSON.parse(Buffer.from(call.headers.authorization.split('.')[1], 'base64url'));++ assert.deepEqual(claims.cap, ['media.object.upload']);++ assert.equal(claims.env, 'sandbox');// 2. Same bytes again: content-addressed, deduplicated, safe to retry.const again = await uploader.upload(file);assert.equal(again.key, out.key);assert.equal(again.deduplicated, true);-- // 3. Another project's namespace is refused by Media (the token's ns is this project only).-- const wrongNs = createUploader(loadConfig({ ...env, OV_MEDIA_NAMESPACE: OTHER }), { fetch: platform.fetch });-- await assert.rejects(wrongNs.upload(file), (err) => err.status === 403 && err.code === 'capability.namespace_denied');++ // 3. Another project's tenant is refused by Media (the token's ns is this project only).++ 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 audience.-- const noGrant = createUploader(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANTS000000000000000', OV_CLIENT_SECRET: 'x' }), { fetch: platform.fetch });-- await assert.rejects(noGrant.upload(file), (err) => err.code === 'invalid_scope' && /request it/i.test(explain(err)));++ // 4. 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.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.-- assert.throws(() => loadConfig({ OV_CLIENT_SECRET: SECRET }), (err) => err.code === 'config.missing' && /OV_CLIENT_ID/.test(err.message) && !err.message.includes(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 });console.log('media-uploader: ok');@@@ -1,15 +1,19 @@#!/usr/bin/env node'use strict';/**-- * Media uploader: upload a file to OpenVibe.Media as a developer app.++ * Media uploader: upload a file to OpenVibe.Media as a developer app, read it back, delete it.*-- * node --env-file=.env upload.js ./logo.png++ * node --env-file=.env upload.js ./logo.png # media.object.upload++ * node --env-file=.env upload.js --get <key> # media.object.read++ * node --env-file=.env upload.js --delete <key> # media.object.upload*-- * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media, scope-- * media.object.upload).++ * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media) carrying++ * exactly the capability the operation needs (`scope`).* 2. Find Media's origin in the platform descriptor (registry discovery), never a hard-coded host.-- * 3. Upload to the Media namespace your grant names. For a developer app that is your project id-- * (prj_…), because Network puts the project id in the token's `ns` claim.++ * 3. Use the Media tenant of your project: the path names your project id (prj_…), the same id++ * Network puts in the token's `project_id` and `ns` claims. Media keeps a sandbox app's files in++ * a separate sandbox tenant of that project (you still address it by the project id) and never++ * serves them publicly: a sandbox upload comes back with a signed, expiring `url` instead.** Only openvibe-sdk and the public token endpoint are used. The client secret is read from the* environment and never printed.@@@ -27,7 +31,7 @@ const TYPES = {};function loadConfig(env = process.env) {-- const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_MEDIA_NAMESPACE'].filter((k) => !env[k]);++ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);if (missing.length) {const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);err.code = 'config.missing';@@@ -37,37 +41,68 @@ function loadConfig(env = process.env) {network: env.OV_NETWORK_URL || 'https://openvibe.network',clientId: env.OV_CLIENT_ID,clientSecret: env.OV_CLIENT_SECRET,-- namespace: env.OV_MEDIA_NAMESPACE,++ projectId: env.OV_PROJECT_ID || null,mediaUrl: env.OV_MEDIA_URL || null,};}++/** What a caller should see of a Media file: never more than this. */++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_expires_at: file.url_expires_at || null,++ sandbox: Boolean(file.sandbox),++ size: file.size, mime: file.mime, sha256: file.sha256,++ deduplicated: Boolean(file.deduplicated),++ };++}++function createUploader(config, { fetch } = {}) {-- const tokens = createServiceTokenClient({-- network: config.network,-- clientId: config.clientId,-- clientSecret: config.clientSecret,-- scope: { 'openvibe.media': 'media.object.upload' }, // ask for exactly what this program uses-- fetch,-- });-- const client = createClient({-- network: config.network,-- fetch,-- tokenProvider: tokens,-- baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined,-- });++ const clients = new Map();++ /** One token client per capability, so each token carries only what that call needs. */++ function clientFor(capability) {++ if (!clients.has(capability)) {++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ scope: { 'openvibe.media': capability },++ });++ clients.set(capability, {++ tokens,++ client: createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined }),++ });++ }++ return clients.get(capability);++ }++++ /** The Media client for the project, for calls that need `capability`. */++ async function mediaFor(capability) {++ const { client, tokens } = clientFor(capability);++ const discovery = await client.discover(); // origins + contracts version check++ const projectId = config.projectId || (await tokens.getTokenInfo({ audience: 'openvibe.media' })).unverifiedClaims.project_id;++ return { media: createMediaClient(client, { app: projectId, publicOrigin: await client.origin('media') }), projectId, discovery };++ }return {-- client,-- /** Upload one file -> { key, url, public_url, size, mime, sha256, deduplicated? } */++ /** Upload one file -> { key, url, url_expires_at, sandbox, size, mime, sha256, deduplicated, project_id, contracts } */async upload(filePath) {-- const discovery = await client.discover(); // origins + contracts version check-- const media = createMediaClient(client, { app: config.namespace, publicOrigin: await client.origin('media') });++ const { media, projectId, discovery } = await mediaFor('media.object.upload');const bytes = await fs.promises.readFile(filePath);const filename = path.basename(filePath);const contentType = TYPES[path.extname(filename).toLowerCase()] || 'application/octet-stream';const file = await media.upload(bytes, { filename, contentType });-- return { ...file, contracts: discovery.contractsVersion };++ return { ...describe(file), project_id: projectId, contracts: discovery.contractsVersion };++ },++ /** A file's metadata (media.object.read), or null when it does not exist in this project and environment. */++ async get(key) {++ const { media } = await mediaFor('media.object.read');++ return describe(await media.files.get(key));++ },++ /** Delete a file (media.object.upload): true, or false when there was no such file. */++ async remove(key) {++ const { media } = await mediaFor('media.object.upload');++ return media.files.delete(key);},};}@@@ -78,31 +113,39 @@ function explain(err) {switch (code) {case 'config.missing': return err.message;case 'invalid_client': return 'Network refused the client id or secret. Check OV_CLIENT_ID / OV_CLIENT_SECRET (rotate the secret in your project if it was lost).';-- case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app. Sandbox apps only get tokens for audiences that accept sandbox traffic; see the README.';-- case 'invalid_scope': return 'The app holds no approved media.object.upload grant. Request it on your app and have a project admin approve it.';-- case 'capability.namespace_denied': return 'Your token is not valid for this namespace. OV_MEDIA_NAMESPACE must be your project id (prj_...).';-- case 'token.sandbox_refused': return 'Media does not accept sandbox tokens yet. See the README section "Running against the real platform".';++ case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app (its environment is not enabled for that audience).';++ case 'invalid_scope': return 'The app holds no approved grant for this operation (media.object.upload to upload or delete, media.object.read to read). Request it on your app (openvibe.codes, or the projects API).';++ case 'capability.namespace_denied': return 'Your token is not valid for this project. OV_PROJECT_ID must be the project your app belongs to (or leave it empty).';++ case 'token.sandbox_refused': return 'Media refused a sandbox token on this route. Sandbox apps can use their own project tenant only (/api/v1/<project id>/files).';default: return isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : String(err && err.message);}}async function main(argv = process.argv.slice(2)) {-- const file = argv[0];-- if (!file) {-- console.error('usage: node upload.js <file>');++ const [first, key] = argv;++ if (!first || (['--get', '--delete'].includes(first) && !key)) {++ console.error('usage: node upload.js <file> | --get <key> | --delete <key>');return 2;}try {const uploader = createUploader(loadConfig());-- const out = await uploader.upload(file);-- console.log(JSON.stringify({ key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256, deduplicated: Boolean(out.deduplicated) }, null, 2));++ if (first === '--get') {++ const file = await uploader.get(key);++ if (!file) { console.error(`no file ${key} in this project (and environment)`); return 1; }++ console.log(JSON.stringify(file, null, 2));++ } else if (first === '--delete') {++ console.log((await uploader.remove(key)) ? `deleted ${key}` : `no file ${key}`);++ } else {++ const out = await uploader.upload(first);++ console.log(JSON.stringify(out, null, 2));++ }return 0;} catch (err) {-- console.error(`upload failed: ${explain(err)}`);++ console.error(`media failed: ${explain(err)}`);return 1;}}if (require.main === module) main().then((code) => { process.exitCode = code; });--module.exports = { loadConfig, createUploader, explain, main };++module.exports = { loadConfig, createUploader, describe, explain, main };@@@ -45,7 +45,8 @@ npm testValidation is offline and works today: `node validate.js mod.json`. The `--registry` check reads`https://openvibe.network/api/v1/registry/capabilities/<id>` (public, no credentials).--Publishing is not public yet: registering a mod and managing installs (`games.mod.manage`) is a--`first-party` capability, install grants (`mods.grant.manage`) are `planned`, and the manifest--editor and release flow belong to OpenVibe.Codes, which has not launched. Mod ids are assigned--by that flow; `--new-id` only gives a draft a well-formed id.++Publishing a mod is not public yet: registering a mod and managing installs (`games.mod.manage`)++is a `first-party` capability and install grants (`mods.grant.manage`) are `planned`. The++developer portal, [OpenVibe.Codes](https://openvibe.codes), is where manifests and releases are++meant to be managed, but a mod still cannot reach Games through it. Mod ids are assigned by that++flow; `--new-id` only gives a draft a well-formed id.@@@ -1,6 +1,6 @@{"name": "openvibe-example-mod-manifest",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Mod manifest: validate a mods.mod-manifest@1 document with openvibe-contracts.","license": "MIT",@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0"}}@@@ -3,5 +3,5 @@ OV_NETWORK_URL=https://openvibe.networkOV_CLIENT_ID=OV_CLIENT_SECRET=# Audiences to ask for app tokens (comma-separated); /status shows the capabilities each token carries.--OV_AUDIENCES=openvibe.media,openvibe.tools++OV_AUDIENCES=openvibe.media,openvibe.events,openvibe.toolsOV_PORT=3002@@@ -13,8 +13,10 @@ curl -s localhost:3002/status- `contracts`: the openvibe-contracts release the network runs, and whether this SDK supports it;- `services`: every registered service with its status and origin (from the registry);- `grants`: for each audience in `OV_AUDIENCES`, the capabilities an app token for it carries-- (approved grants within your project's allowance), each described by the registry, plus the-- token's `project_id`, `env` and `ns`; or why Network refused (`invalid_scope`, `invalid_target`).++ (approved grants within your project's allowance, plus the sandbox allowance for a sandbox app),++ each described by the registry, plus the token's `project_id`, `env`, `ns` and expiry++ (`getTokenInfo()`, decoded for display, not verified); or why Network refused (`invalid_scope`,++ `invalid_target`).## What it proves@@@ -29,7 +31,7 @@ curl -s localhost:3002/status| File | What ||---|---|--| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`), `peekClaims()` |++| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`) || `test/smoke.test.js` | discovery, registry, grants per audience, token caching, trace propagation |## Run the smoke test@@@ -40,12 +42,13 @@ npm test## Run it against the real platform--1. Create a project and a **confidential** app ([walkthrough](../../README.md#end-to-end-walkthrough)).--2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, set `OV_AUDIENCES` to-- the audiences you hold grants for (`openvibe.media`, `openvibe.tools`, …).++1. Create a project and a **confidential** sandbox app and request its grants++ ([walkthrough](../../README.md#end-to-end-walkthrough)).++2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`. `OV_AUDIENCES` defaults++ to the three audiences a sandbox app can get tokens for: `openvibe.media`, `openvibe.events`,++ `openvibe.tools`.3. `node --env-file=.env server.js` and open `http://localhost:3002/status`.--Discovery and the registry work for anyone today. The `grants` part shows a token only when--Network will issue one: a **sandbox** app gets `invalid_target` for every audience until staff list--that audience in `DEV_SANDBOX_AUDIENCES` (empty by default), and any app gets `invalid_scope` until--a grant is approved inside the project's allowance (also empty by default).++Each audience shows the token's `project_id`, `env: sandbox` and the capabilities it carries, or++why Network refused: `invalid_scope` when no grant for that audience is approved, `invalid_target`++for an audience that does not take sandbox tokens (for example `openvibe.network`).@@@ -1,6 +1,6 @@{"name": "openvibe-example-node-server-app",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Node server app: client-credentials app token and registry discovery.","license": "MIT",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}@@@ -11,7 +11,8 @@* release the network runs, checked against the range this SDK supports;* - the registry's services;* - for each audience in OV_AUDIENCES, the capabilities an app token for that audience carries-- * (approved grants within the project's allowance), described by the registry.++ * (approved grants within the project's allowance, plus the sandbox allowance for a sandbox++ * app), with the token's project_id and env, each capability described by the registry.** The app token is requested from the public token endpoint and cached per audience until shortly* before it expires (5-minute lifetime). The client secret is never printed or returned.@@@ -32,35 +33,29 @@ function loadConfig(env = process.env) {network: env.OV_NETWORK_URL || 'https://openvibe.network',clientId: env.OV_CLIENT_ID,clientSecret: env.OV_CLIENT_SECRET,-- audiences: String(env.OV_AUDIENCES || 'openvibe.media').split(',').map((s) => s.trim()).filter(Boolean),++ audiences: String(env.OV_AUDIENCES || 'openvibe.media,openvibe.events,openvibe.tools').split(',').map((s) => s.trim()).filter(Boolean),port: Number(env.OV_PORT || 3002),};}--/**-- * The claims of a token this process just received from Network over TLS. Only read for display;-- * the services that receive the token are the ones that verify it.-- */--function peekClaims(token) {-- try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return {}; }--}--function createApp(config, { fetch, log = console } = {}) {const tokens = createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch });const client = createClient({ network: config.network, fetch, tokenProvider: tokens });/** What an app token for `audience` carries right now, or the reason there is none. */async function grantsFor(audience, registry) {-- let token;++ let info;try {-- token = await tokens.getToken({ audience });++ info = await tokens.getTokenInfo({ audience });} catch (err) {if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'unauthorized_client'].includes(err.code)) {return { audience, capabilities: [], refused: err.code, detail: err.detail || null };}throw err;}-- const claims = peekClaims(token);++ // Decoded, NOT verified: fine for showing what Network granted. The services that receive++ // the token are the ones that verify it.++ const claims = info.unverifiedClaims || {};const capabilities = await Promise.all((claims.cap || []).map(async (id) => {const c = await registry.capability(id);return { id, known: Boolean(c), visibility: c ? c.visibility || null : null, description: c ? c.description || null : null };@@@ -72,7 +67,7 @@ function createApp(config, { fetch, log = console } = {}) {project_id: claims.project_id || null,env: claims.env || null,namespaces: claims.ns || [],-- expires_at: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,++ expires_at: info.expiresAt,};}@@@ -119,4 +114,4 @@ if (require.main === module) {server.listen(config.port, () => console.log(`node-server-app: http://localhost:${config.port}/status (app ${config.clientId})`));}--module.exports = { loadConfig, createApp, peekClaims };++module.exports = { loadConfig, createApp };@@@ -8,9 +8,6 @@ const { createApp, loadConfig } = require('../server');const realFetch = globalThis.fetch;globalThis.fetch = () => { throw new Error('network access in a smoke test'); };--const APP = 'app_01JEXAMPLESERVER000000000';--const PRJ = 'prj_01JEXAMPLEPROJECT00000000';--const SECRET = 'ovsec_node_server_test_secret';function get(port, path, headers = {}) {return new Promise((resolve, reject) => {@@@ -24,10 +21,12 @@ function get(port, path, headers = {}) {(async () => {const platform = createMockPlatform({-- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] } },capabilities: [{ id: 'media.object.upload', owner: 'media', visibility: 'public', status: 'active', description: 'Upload an object into a granted namespace' }],-- contractsVersion: '0.26.0',++ contractsVersion: '0.28.0',});++ // A sandbox confidential app, as a new project has, with one Media grant and nothing for Tools.++ const devApp = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });++ const [APP, SECRET, PRJ] = [devApp.id, devApp.secret, devApp.projectId];const logs = [];const log = { error: (m) => logs.push(m), log: (m) => logs.push(m) };const app = createApp(loadConfig({ OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCES: 'openvibe.media,openvibe.tools' }), { fetch: platform.fetch, log });@@@ -40,7 +39,7 @@ function get(port, path, headers = {}) {const s = res.body;// Discovery: origins and the contracts version check.-- assert.equal(s.contracts.version, '0.26.0');++ assert.equal(s.contracts.version, '0.28.0');assert.equal(s.contracts.compatible, true);const media = s.services.find((x) => x.id === 'media');assert.equal(media.origin, 'https://openvibe.media');@@@ -50,6 +49,10 @@ function get(port, path, headers = {}) {assert.deepEqual(mediaGrants.capabilities.map((c) => c.id), ['media.object.upload']);assert.equal(mediaGrants.capabilities[0].visibility, 'public');assert.deepEqual(mediaGrants.namespaces, [PRJ]);++ assert.equal(mediaGrants.project_id, PRJ);++ assert.equal(mediaGrants.env, 'sandbox');++ assert.equal(mediaGrants.subject, `app:${APP}`);++ assert.ok(Date.parse(mediaGrants.expires_at) > Date.now());// No grant for openvibe.tools: reported, not thrown.const toolsGrants = s.grants.find((g) => g.audience === 'openvibe.tools');@@@ -11,9 +11,9 @@ node --env-file=.env server.js # open http://localhost:3009 and click "Sign| Step | Where | What ||---|---|---|--| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the capability ids to ask for (`scope`) |++| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the `audience` and the capability ids to ask for (`scope`) || Network | | the person signs in and chooses to continue; Network redirects back with `code` and `state` |--| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); the code is exchanged **once**, with the client secret, the PKCE verifier and the `audience`; the token is verified offline against Network's JWKS (signature, issuer, audience, expiry); the session id is replaced (no session fixation) |++| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); `exchangeCode()` exchanges the code **once**, with the client secret, the PKCE verifier and the same `audience`; `verifyAppToken()` checks the app token offline against Network's JWKS (signature, issuer, audience, expiry, claim shape); the session id is replaced (no session fixation) || `/api/me` | server | who signed in (`usr_…`), and the capabilities this app may use for them |What comes back is an **app token acting for the person**: `actor_type: app`, `sub: app:<your app id>`,@@@ -21,7 +21,8 @@ What comes back is an **app token acting for the person**: `actor_type: app`, `sand **no refresh token**. When it expires the person signs in again. The app checks the token wasminted for itself (`sub`) before trusting `on_behalf_of`.--Also covered by the test: a replayed callback, a forged `state`, a declined consent++Also covered by the test: a replayed callback, a forged `state`, a declined sign-in++(`oauth.access_denied`), someone outside the project trying to authorize its sandbox app(`oauth.access_denied`), a code bound to another PKCE challenge (`invalid_grant`), sign-out, andthat neither the secret nor any token reaches the browser or the logs.@@@ -29,8 +30,8 @@ that neither the secret nor any token reaches the browser or the logs.| File | What ||---|---|--| `server.js` | `createApp()`, `exchangeAppCode()`, `signedInSubject()` |--| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (`platform.authorize()` stands in for the consent page) |++| `server.js` | `createApp()`, `signedInSubject()` |++| `test/smoke.test.js` | the flow against `openvibe-sdk/testing`, whose `/oauth/authorize` plays Network's account chooser |## Run the smoke test@@@ -40,23 +41,20 @@ npm test## Run it against the real platform--1. Create a project and a **confidential** app with the redirect URI-- `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough)).-- `localhost` redirect URIs are allowed for sandbox apps only; a production app needs an https one.--2. Request a grant for every capability in `OV_SCOPE` (for example `media.object.read` for audience-- `openvibe.media`). Signing in needs at least one approved grant for `OV_AUDIENCE`.++1. Create a project and a **confidential** sandbox app with the redirect URI++ `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough);++ `npm run new-app` at the repository root registers exactly that one). `http://localhost`++ redirect URIs are allowed for sandbox apps only; a production app needs an https one.++2. Request a grant for every capability in `OV_SCOPE` (the default `media.object.upload++ media.object.read`, audience `openvibe.media`, are both in the sandbox allowance).3. `cp .env.example .env`, fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE`.--4. `node --env-file=.env server.js` and sign in **as a member of the project** (a sandbox app can-- only be authorized by its project's members).++4. `node --env-file=.env server.js` and sign in **as a member of the project**: a sandbox app can++ only be authorized by its project's members.--What blocks it today: Network mints the token only if the app may have one for `OV_AUDIENCE`. A--sandbox app gets `invalid_target` at the exchange until staff list that audience in--`DEV_SANDBOX_AUDIENCES` (empty by default). A production app avoids that but needs an https--redirect URI, so it has to run somewhere with TLS. Network's account chooser does not list the--requested capabilities yet (no consent screen).++What is still missing on the platform:--## Why not `exchangeCode()`?----`openvibe-sdk` 0.2.2's `exchangeCode()` requires a client secret and cannot send `audience`, which--Network requires when the client is a developer app. `exchangeAppCode()` here calls the same--public token endpoint through the SDK's core client (with retries off: a code works once).++- **No consent screen.** Network's account chooser names the app (marked "third-party app") but++ does not list the capabilities it asks for yet.++- **No refresh tokens for apps.** The token lasts 5 minutes; then the person signs in again.++- **No identity for apps.** An app learns the person's `usr_…` id (`on_behalf_of`) and nothing++ else: profile lookups are `first-party` capabilities.@@@ -1,6 +1,6 @@{"name": "openvibe-example-oauth-app",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "OAuth app: server-side authorization code + PKCE exchange with OpenVibe.Network.","license": "MIT",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}@@@ -5,11 +5,13 @@** node --env-file=.env server.js # http://localhost:3009*-- * /login make a PKCE pair and a state (openvibe-sdk/auth), keep them in this browser's++ * /login make a PKCE pair and a state (openvibe-sdk/auth startAuthorization, with the++ * audience and the capability ids to ask for), keep them in this browser's* server-side session, redirect to Network's /oauth/authorize.* /callback check the state (readCallback), exchange the code on the server with the client-- * secret AND the PKCE verifier, verify the token offline against Network's JWKS, then-- * start a fresh session (new session id: no session fixation).++ * secret AND the PKCE verifier (exchangeCode, same audience), verify the app token++ * offline against Network's JWKS (verifyAppToken), then start a fresh session (new++ * session id: no session fixation).* /api/me who signed in, and what this app may do on their behalf.** What comes back is an APP token acting for the person (actor_type app, on_behalf_of usr_…,@@@ -19,7 +21,7 @@const crypto = require('node:crypto');const http = require('node:http');const { createClient, isOpenVibeError } = require('openvibe-sdk/core');--const { startAuthorization, readCallback, verifyUserToken } = require('openvibe-sdk/auth');++const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');const SESSION_COOKIE = 'ov_example_sid';const PENDING_TTL_MS = 10 * 60 * 1000;@@@ -42,28 +44,10 @@ function loadConfig(env = process.env) {};}--/**-- * POST <network>/oauth/token (authorization_code) for a developer app. openvibe-sdk 0.2.2's-- * exchangeCode() cannot send `audience`, which Network requires for app clients, so this calls-- * the public token endpoint through the SDK's core client. No retries: a code works once.-- */--async function exchangeAppCode(client, { clientId, clientSecret, code, codeVerifier, redirectUri, audience, scope }) {-- return client.json({-- service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,-- urlencoded: {-- grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier,-- client_id: clientId, client_secret: clientSecret || undefined, audience, scope: scope && scope.length ? scope.join(' ') : undefined,-- },-- });--}----/** Who signed in, from verified claims: an app token names them in on_behalf_of. */++/** Who signed in, from verified app-token claims: the person is on_behalf_of, and the token must be ours. */function signedInSubject(claims, clientId) {-- if (claims.actor_type === 'app') {-- if (claims.sub !== `app:${clientId}`) return null; // a token minted for some other app-- return claims.on_behalf_of || null;-- }-- return claims.subject_id || null; // a plain user token++ if (claims.actor_type !== 'app' || claims.sub !== `app:${clientId}`) return null; // minted for some other app++ return claims.on_behalf_of || null;}const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);@@@ -109,7 +93,7 @@ function createApp(config, { fetch, log = console } = {}) {const found = sessionOf(req);const sid = found ? found.sid : newSid();const { url, state, codeVerifier } = await startAuthorization({-- network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope,++ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,});sessions.set(sid, { ...(found ? found.s : {}), pending: { state, codeVerifier, at: Date.now() } });res.writeHead(302, { Location: url, 'Set-Cookie': cookie(sid, 3600), 'Cache-Control': 'no-store' });@@@ -129,11 +113,17 @@ function createApp(config, { fetch, log = console } = {}) {return page(res, 400, 'Sign-in failed', err.code === 'oauth.state_mismatch' ? 'This sign-in was not started here.' : `Network answered ${err.code}.`, err.code);}try {-- const tokens = await exchangeAppCode(client, { ...config, code, codeVerifier: pending.codeVerifier });++ // A code works once: exchangeCode() never retries. App tokens come without a refresh token.++ const tokens = await exchangeCode({++ network: config.network, fetch, clientId: config.clientId, clientSecret: config.clientSecret,++ code, codeVerifier: pending.codeVerifier, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,++ });const d = await client.discover();-- const claims = await verifyUserToken(tokens.access_token, {++ // acceptSandbox: signedInSubject() below only trusts a token minted for THIS app, so its++ // env is this app's own environment (a new project's apps are sandbox).++ const claims = await verifyAppToken(tokens.access_token, {jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience,-- allowServiceTokens: true, fetch,++ acceptSandbox: true, fetch,});const subject = signedInSubject(claims, config.clientId);if (!subject) return page(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', 'token.no_subject');@@@ -204,4 +194,4 @@ if (require.main === module) {server.listen(config.port, () => console.log(`oauth-app: http://localhost:${config.port} (redirect URI ${config.redirectUri})`));}--module.exports = { loadConfig, createApp, exchangeAppCode, signedInSubject };++module.exports = { loadConfig, createApp, signedInSubject };@@@ -1,58 +1,70 @@'use strict';/*** Smoke test: authorization code + PKCE with a server-side exchange, against the SDK's mock-- * platform. The mock has no /oauth/authorize page; platform.authorize() stands in for the person-- * choosing "Continue" on Network, and it verifies PKCE at the token endpoint like Network does.++ * platform. Its GET /oauth/authorize plays Network's account chooser (it continues, or declines,++ * as platform.setAuthorization() says) and its token endpoint checks PKCE, the audience the code++ * was bound to, and that a sandbox app is authorized by a member of its project.*/const assert = require('node:assert/strict');const { createMockPlatform } = require('openvibe-sdk/testing');++const { createPkcePair } = require('openvibe-sdk/auth');const { createApp, loadConfig, signedInSubject } = require('../server');const realFetch = globalThis.fetch;globalThis.fetch = () => { throw new Error('network access in a smoke test'); };--const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';--const SECRET = 'ovsec_oauth_app_test_secret_value';--(async () => {-- const platform = createMockPlatform({ users: [{ username: 'ana' }] });-- const ana = [...platform.state.users.values()][0];++ const platform = createMockPlatform({ users: [{ username: 'ana' }, { username: 'bo' }] });++ const [ana, bo] = [...platform.state.users.values()];++ // ana owns the project; its sandbox app may only be authorized by members.++ const project = platform.addProject({ owner: ana.subject_id, environmentPolicy: 'sandbox' });++ const redirectUri = 'http://localhost:3009/callback';++ const app = platform.addApp({ project, env: 'sandbox', type: 'confidential', redirectUris: [redirectUri], grants: ['media.object.upload', 'media.object.read'] });++ const SECRET = app.secret;++const logs = [];const log = { log: (m) => logs.push(m), error: (m) => logs.push(m) };-- const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.upload media.object.read' };-- const app = createApp(loadConfig(env), { fetch: platform.fetch, log });-- await new Promise((r) => app.server.listen(0, '127.0.0.1', r));-- const base = `http://127.0.0.1:${app.server.address().port}`;++ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read', OV_REDIRECT_URI: redirectUri };const config = loadConfig(env);-- platform.addClient(APP, { secret: SECRET, redirectUris: [config.redirectUri] });++ const server = createApp(config, { fetch: platform.fetch, log });++ await new Promise((r) => server.server.listen(0, '127.0.0.1', r));++ const base = `http://127.0.0.1:${server.server.address().port}`;const get = (path, cookie, method = 'GET') => realFetch(`${base}${path}`, { method, redirect: 'manual', headers: cookie ? { cookie } : {} });const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];-- /** /login -> the authorize URL's parameters and this browser's session cookie. */++ /** /login -> the authorize URL and this browser's session cookie. */async function startLogin() {const res = await get('/login');assert.equal(res.status, 302);-- const to = new URL(res.headers.get('location'));-- return { to, cookie: cookieOf(res) };++ return { to: new URL(res.headers.get('location')), cookie: cookieOf(res) };++ }++ /** The browser at Network: the account chooser answers with a redirect back to the app. */++ async function atNetwork(to) {++ const res = await platform.fetch(to.toString(), { redirect: 'manual' });++ assert.equal(res.status, 302);++ const back = new URL(res.headers.get('location'));++ assert.equal(`${back.origin}${back.pathname}`, redirectUri);++ return `${back.pathname}${back.search}`;}-- const callbackPath = (params) => `/callback?${new URLSearchParams(params)}`;-- // 1. Sign in.++ // 1. Sign in as ana (a project member).++ platform.setAuthorization({ subjectId: ana.subject_id });const { to, cookie } = await startLogin();assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');-- assert.equal(to.searchParams.get('client_id'), APP);-- assert.equal(to.searchParams.get('redirect_uri'), config.redirectUri);++ assert.equal(to.searchParams.get('client_id'), app.id);++ assert.equal(to.searchParams.get('redirect_uri'), redirectUri);assert.equal(to.searchParams.get('code_challenge_method'), 'S256');assert.match(to.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);-- assert.equal(to.searchParams.get('scope'), 'media.object.upload media.object.read');++ assert.equal(to.searchParams.get('audience'), 'openvibe.media');++ assert.equal(to.searchParams.get('scope'), 'media.object.read', 'capability ids, not "profile theme"');assert.match(cookie, /^ov_example_sid=/);++ assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');const res1 = await get('/login');assert.match(res1.headers.get('set-cookie'), /HttpOnly; SameSite=Lax/);-- assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');-- const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge'), scope: to.searchParams.get('scope') });-- const cb = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);++ const callbackPath = await atNetwork(to);++ const cb = await get(callbackPath, cookie);assert.equal(cb.status, 302);assert.equal(cb.headers.get('location'), '/');const signedIn = cookieOf(cb);@@@ -62,52 +74,65 @@ const SECRET = 'ovsec_oauth_app_test_secret_value';assert.equal(me.status, 200);const body = await me.json();assert.equal(body.subject, ana.subject_id);++ assert.equal(body.app, `app:${app.id}`);++ assert.equal(body.project_id, project);++ assert.equal(body.env, 'sandbox');++ assert.deepEqual(body.capabilities, ['media.object.read'], 'only what was asked for, not everything the app holds');assert.ok(!JSON.stringify(body).includes('access_token'));assert.match(await (await get('/', signedIn)).text(), new RegExp(`Signed in with OpenVibe as <code>${ana.subject_id}</code>`));-- // The exchange sent the verifier, the secret and the audience to the token endpoint.-- const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));-- assert.equal(exchange.length, 1);++ // The exchange sent the verifier, the secret and the audience to the token endpoint, once.++ assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);assert.equal((await get('/api/me', cookie)).status, 401, 'the pre-login session id is dead');// 2. The same callback again (replayed link): refused, no second exchange.-- const replay = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);++ const replay = await get(callbackPath, cookie);assert.equal(replay.status, 400);// 3. A callback this browser did not start (state mismatch, login CSRF): refused before any exchange.const second = await startLogin();-- const mismatch = await get(callbackPath({ code: 'whatever', state: 'not-the-state' }), second.cookie);++ const mismatch = await get(`/callback?${new URLSearchParams({ code: 'whatever', state: 'not-the-state' })}`, second.cookie);assert.equal(mismatch.status, 400);assert.match(await mismatch.text(), /oauth\.state_mismatch/);// 4. The person declined on Network.++ platform.setAuthorization({ decision: 'deny' });const third = await startLogin();-- const denied = await get(callbackPath({ error: 'access_denied', state: third.to.searchParams.get('state') }), third.cookie);++ const denied = await get(await atNetwork(third.to), third.cookie);assert.equal(denied.status, 400);assert.match(await denied.text(), /oauth\.access_denied/);++ platform.setAuthorization({ decision: 'allow' });-- // 5. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.++ // 5. Someone who is not a member of the project cannot authorize its sandbox app.++ platform.setAuthorization({ subjectId: bo.subject_id });const fourth = await startLogin();-- const other = await require('openvibe-sdk/auth').createPkcePair();-- const stolen = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge });-- const pkce = await get(callbackPath({ code: stolen, state: fourth.to.searchParams.get('state') }), fourth.cookie);++ const outsider = await get(await atNetwork(fourth.to), fourth.cookie);++ assert.equal(outsider.status, 400);++ assert.match(await outsider.text(), /oauth\.access_denied/);++ platform.setAuthorization({ subjectId: ana.subject_id });++++ // 6. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.++ const fifth = await startLogin();++ const other = await createPkcePair();++ const stolen = platform.authorize({ clientId: app.id, redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge, audience: 'openvibe.media', scope: 'media.object.read' });++ const pkce = await get(`/callback?${new URLSearchParams({ code: stolen, state: fifth.to.searchParams.get('state') })}`, fifth.cookie);assert.equal(pkce.status, 400);assert.match(await pkce.text(), /invalid_grant/);-- // 6. Sign out.++ // 7. Sign out.const out = await get('/logout', signedIn, 'POST');assert.equal(out.status, 303);assert.equal((await get('/api/me', signedIn)).status, 401);// App tokens name the person in on_behalf_of and must be for this app.-- assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${APP}`, on_behalf_of: 'usr_X' }, APP), 'usr_X');-- assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, APP), null);++ assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${app.id}`, on_behalf_of: 'usr_X' }, app.id), 'usr_X');++ assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, app.id), null);// Nothing secret in the logs.const text = logs.join('\n');assert.ok(!text.includes(SECRET));assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(text), 'no tokens in logs');-- app.server.close();++ server.server.close();console.log('oauth-app: ok');})().catch((err) => { console.error(err); process.exit(1); });@@@ -1,38 +1,38 @@# Tool job--Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app, and survive--dropped connections and restarts without running the job twice.++Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app with++`openvibe-sdk/jobs`, and survive dropped connections and restarts without running the job twice.```bashnode --env-file=.env run-job.js ./photo.png# submitted job_01K… (queued)# job.queued# job.running--# job.progress 50% converting++# job.progress 20% Processing# job.succeeded 100%# saved ./out/photo.webp```## What it proves--- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both `public`-- capabilities a developer app can hold).--- **Submit once.** `POST /api/v1/jobs` carries an `Idempotency-Key` derived from the request++- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both in the++ sandbox allowance: a sandbox app gets them without staff).++- **Submit once.** `jobs.submit()` carries an `Idempotency-Key` derived from the request(type, input, file). If the process dies after submitting, running it again gets the **same**-- job back (`200 Idempotent-Replayed: true`), not a second one.++ job back (`replayed: true`), not a second one.- **Reattach.** The job id and the last event id are kept in `.job-state.json`; a restartreattaches to the job instead of submitting.--- **Resume the stream.** `GET /api/v1/jobs/:id/events` (SSE) is reopened with `Last-Event-ID`-- after a drop, and Tools replays only later events: every event is seen once, in order.-- `204` means the job finished and nothing is newer.--- The result files are downloaded with the same token. Jobs are owner-scoped: another app gets `404`.++- **Resume the stream.** `jobs.events()` reopens the job's SSE stream with `Last-Event-ID` after a++ drop, and Tools replays only later events: every event is seen once, in order. A finished job++ with nothing newer answers `204` and the stream ends.++- The result files are downloaded with `jobs.file()` and the same token. Jobs are owner-scoped:++ another app gets `404` (`jobs.get()` returns `null`).## Files| File | What ||---|---|--| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), `readEventStream()`, CLI |--| `test/mock-tools-server.js` | a local `/api/v1/jobs` that behaves as documented (the SDK mock has no Tools) |++| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), CLI || `test/smoke.test.js` | full run with a dropped stream, reattach after a crash, idempotent resubmit, 204, owner scoping, no grant |## Run the smoke test@@@ -41,19 +41,19 @@ node --env-file=.env run-job.js ./photo.pngnpm test```--Network is the SDK's mock platform; the Tools mock verifies its tokens (RS256, audience--`openvibe.tools`, the `tools.job.*` capability) and plays queued → running → progress → succeeded.++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.## Run it against the real platform--1. Create a project and a **confidential** app, request `tools.job.create` and `tools.job.read`-- ([walkthrough](../../README.md#end-to-end-walkthrough)).++1. Create a project and a **confidential** sandbox app with `tools.job.create` and++ `tools.job.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.3. `node --env-file=.env run-job.js ./photo.png`--`img.process` runs on `https://img.openvibe.tools`; audio and docs jobs run on their own--satellites (set `OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts app tokens on--this API today. What has to be true on the platform side first: the capabilities are in your--project's allowance, and your app can get an `openvibe.tools` token — a **sandbox** app cannot--until staff list `openvibe.tools` in Network's `DEV_SANDBOX_AUDIENCES` (and Tools accepts--sandbox tokens), so use a production app.++`img.process` runs on `https://img.openvibe.tools` (`input.tool` is one of its tools, for example++`convert` with `format`); audio and docs jobs run on their own satellites (set++`OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts sandbox app tokens on++`/api/v1/jobs`: a sandbox project may have 2 active jobs of at most 30 minutes, and its results are++never copied to Media.@@@ -1,6 +1,6 @@{"name": "openvibe-example-tool-job",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Tool job: submit an OpenVibe.Tools job and reattach to its progress stream.","license": "MIT",@@@ -13,6 +13,6 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}@@@ -6,26 +6,21 @@* node --env-file=.env run-job.js ./photo.png # img.process, convert to WebP by default** 1. App token for openvibe.tools (client credentials, scope tools.job.create tools.job.read).-- * 2. POST /api/v1/jobs (multipart: type, input, file) with an Idempotency-Key derived from the-- * request, so re-running after a crash gets the SAME job back (200 Idempotent-Replayed).++ * Tools accepts sandbox app tokens (a sandbox project may have 2 active jobs, 30 minutes each).++ * 2. openvibe-sdk/jobs submit() with an Idempotency-Key derived from the request, so re-running++ * after a crash gets the SAME job back (Tools answers 200 Idempotent-Replayed).* 3. The job id and the last event id are saved in a state file. Running the script again* reattaches to the job instead of submitting a new one.-- * 4. GET /api/v1/jobs/:id/events (SSE). When the stream drops, reconnect with Last-Event-ID: the-- * server replays only later events. 204 means the job finished and nothing is newer.-- * 5. GET /api/v1/jobs/:id for the result, then download each result file.-- *-- * openvibe-sdk has no jobs client yet (sdk-jobs), so the job routes are called with the SDK's core-- * client, which still supplies the token, deadlines, safe retries, problem errors and tracing. The-- * event stream is the exception: the core client cannot return a streaming body, so it is read-- * with fetch and the same app token.++ * 4. jobs.events() follows the job's SSE stream and reconnects with Last-Event-ID after a drop;++ * every event arrives once, in order, and a finished job ends the stream (204).++ * 5. jobs.get() for the result, jobs.file() for each result file.*/const crypto = require('node:crypto');const fs = require('node:fs');const path = require('node:path');--const { createClient, isOpenVibeError, OpenVibeError, startSpan } = require('openvibe-sdk/core');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');const { createServiceTokenClient } = require('openvibe-sdk/auth');----const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const { createJobsClient } = require('openvibe-sdk/jobs');function loadConfig(env = process.env) {const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);@@@ -48,47 +43,13 @@ function loadConfig(env = process.env) {};}--/** Parse a text/event-stream body: calls onEvent({ id, event, data }) per event, onRetry(ms). */--async function readEventStream(body, onEvent, onRetry = () => {}) {-- const reader = body.getReader();-- const decoder = new TextDecoder();-- let buf = '';-- let cur = { data: [] };-- const line = (l) => {-- if (l === '') {-- if (cur.data.length) onEvent({ id: cur.id, event: cur.event || 'message', data: cur.data.join('\n') });-- cur = { data: [] };-- return;-- }-- if (l.startsWith(':')) return;-- const i = l.indexOf(':');-- const field = i < 0 ? l : l.slice(0, i);-- const value = i < 0 ? '' : l.slice(i + 1).replace(/^ /, '');-- if (field === 'data') cur.data.push(value);-- else if (field === 'event') cur.event = value;-- else if (field === 'id') cur.id = value;-- else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));-- };-- for (;;) {-- const { value, done } = await reader.read();-- if (done) break;-- buf += decoder.decode(value, { stream: true });-- let m;-- while ((m = /\r\n|\n|\r/.exec(buf))) {-- if (m[0] === '\r' && m.index === buf.length - 1) break;-- line(buf.slice(0, m.index));-- buf = buf.slice(m.index + m[0].length);-- }-- }--}----function createJobRunner(config, { fetch, log = console } = {}) {++function createJobRunner(config, { fetch, log = console, reconnectDelayMs } = {}) {const tokens = createServiceTokenClient({network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,scope: { 'openvibe.tools': 'tools.job.create tools.job.read' },});-- const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: { tools: config.toolsUrl } });-- const fetchImpl = fetch || globalThis.fetch;++ const client = createClient({ network: config.network, fetch, tokenProvider: tokens });++ const jobs = createJobsClient(client, { baseUrl: config.toolsUrl });const loadState = () => { try { return JSON.parse(fs.readFileSync(config.statePath, 'utf8')); } catch { return null; } };const saveState = (s) => {@@@ -107,82 +68,37 @@ function createJobRunner(config, { fetch, log = console } = {}) {log.log(`reattaching to ${saved.jobId}`);return { jobId: saved.jobId, reattached: true };}-- const form = new FormData();-- form.append('type', config.type);-- form.append('input', JSON.stringify(config.input));-- form.append('file', new Blob([bytes]), path.basename(filePath));-- const res = await client.request({-- service: 'tools', method: 'POST', path: '/api/v1/jobs', form,-- idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash++ const { job, replayed } = await jobs.submit({++ type: config.type, input: config.input,++ files: [{ name: path.basename(filePath), data: bytes }],++ idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash});-- const job = res.data;saveState({ jobId: job.id, fingerprint, lastEventId: 0, submittedAt: new Date().toISOString() });-- log.log(`${res.headers.get('idempotent-replayed') ? 'existing' : 'submitted'} ${job.id} (${job.state})`);-- return { jobId: job.id, reattached: false, replayed: Boolean(res.headers.get('idempotent-replayed')) };-- }---- /**-- * Open the job's event stream. The SDK's core client cannot hand back a streaming body-- * (responseType 'response' drops the Response), so this one call uses fetch directly with the-- * same app token.-- */-- async function openStream(jobId, lastEventId) {-- const url = `${config.toolsUrl.replace(/\/+$/, '')}/api/v1/jobs/${encodeURIComponent(jobId)}/events`;-- for (let attempt = 0; attempt < 2; attempt++) {-- const token = await tokens.getToken({ audience: 'openvibe.tools' });-- const res = await fetchImpl(url, {-- headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, 'Last-Event-ID': String(lastEventId), traceparent: startSpan().traceparent },-- });-- if (res.status === 401 && attempt === 0) { tokens.invalidate({ audience: 'openvibe.tools' }); continue; }-- if (!res.ok) {-- const body = await res.json().catch(() => null);-- throw OpenVibeError.fromResponse({ status: res.status, body, method: 'GET', url });-- }-- return res;-- }-- throw new Error('unreachable');++ log.log(`${replayed ? 'existing' : 'submitted'} ${job.id} (${job.state})`);++ return { jobId: job.id, reattached: false, replayed };}-- /** Follow the job's events until it finishes, reconnecting with Last-Event-ID. */-- async function watch(jobId, { onEvent = () => {}, maxReconnects = 20 } = {}) {-- let retryMs = 1000;-- let reconnects = 0;-- for (;;) {-- const state = loadState() || {};-- const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;-- let finished = false;-- try {-- const res = await openStream(jobId, lastEventId);-- if (res.status === 204) return; // finished, nothing newer-- await readEventStream(res.body, (e) => {-- let data = null;-- try { data = JSON.parse(e.data); } catch { /* keep null */ }-- if (e.id) saveState({ ...(loadState() || {}), jobId, lastEventId: Number(e.id) });-- onEvent({ id: Number(e.id), event: e.event, job: data });-- if (data && TERMINAL.has(data.state) && e.event !== 'job.progress') finished = true;-- }, (ms) => { retryMs = ms; });-- } catch (err) {-- if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err; // 401/403/404: do not loop-- log.error(`event stream interrupted: ${isOpenVibeError(err) ? err.code : err.message}`);-- }-- if (finished) return;-- if (++reconnects > maxReconnects) throw new Error(`gave up after ${maxReconnects} reconnects`);-- await new Promise((r) => setTimeout(r, retryMs));++ /** Follow the job's events until it finishes; the last event id survives a restart. */++ async function watch(jobId, { onEvent = () => {} } = {}) {++ const state = loadState() || {};++ const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;++ for await (const e of jobs.events(jobId, { lastEventId, reconnectDelayMs })) {++ if (e.id != null) saveState({ ...(loadState() || {}), jobId, lastEventId: e.id });++ await onEvent(e);}}-- async function get(jobId) {-- return client.json({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(jobId)}` });-- }++ /** The job, or null when it does not exist or is not yours (Tools answers both with 404). */++ const get = (jobId) => jobs.get(jobId);async function download(job) {const saved = [];fs.mkdirSync(config.outDir, { recursive: true });for (const [n, f] of ((job.result && job.result.files) || []).entries()) {-- const res = await client.request({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(job.id)}/files/${n}`, responseType: 'arrayBuffer' });++ const res = await jobs.file(job.id, n);const name = path.basename(String(f.name || `result-${n}`)).replace(/[^\w.-]/g, '_');const out = path.join(config.outDir, name);-- fs.writeFileSync(out, Buffer.from(res.data));++ fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));saved.push(out);}return saved;@@@ -193,12 +109,12 @@ function createJobRunner(config, { fetch, log = console } = {}) {const { jobId } = await submit(filePath);await watch(jobId, { onEvent });const job = await get(jobId);-- const files = job.state === 'succeeded' ? await download(job) : [];++ const files = job && job.state === 'succeeded' ? await download(job) : [];clearState();return { job, files };}-- return { client, submit, watch, get, download, run, loadState };++ return { client, jobs, submit, watch, get, download, run, loadState };}async function main([file] = process.argv.slice(2)) {@@@ -211,18 +127,18 @@ async function main([file] = process.argv.slice(2)) {console.log(`${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);},});-- if (job.state !== 'succeeded') {-- console.error(`job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}`);++ if (!job || job.state !== 'succeeded') {++ console.error(job ? `job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}` : 'the job is gone (expired, or not this app\'s)');return 1;}for (const f of files) console.log(`saved ${f}`);return 0;} catch (err) {-- console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);++ console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : err.message}`);return 1;}}if (require.main === module) main().then((code) => { process.exitCode = code; });--module.exports = { loadConfig, createJobRunner, readEventStream, main };++module.exports = { loadConfig, createJobRunner, main };ddeleted file mode 100644@@@ -1,173 +0,0 @@--'use strict';--/**-- * A small local stand-in for OpenVibe.Tools' job API (/api/v1/jobs), for tests only. The SDK's-- * createMockPlatform() has no Tools, so this mock plays the documented behaviour:-- *-- * POST /api/v1/jobs multipart type/input/file, Idempotency-Key -> 202 (200 + Idempotent-Replayed on repeat)-- * GET /api/v1/jobs/:id the job view-- * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events; 204 when finished and nothing newer-- * GET /api/v1/jobs/:id/files/:n a result file-- *-- * Tokens are verified against the mock platform's key: RS256, audience openvibe.tools, the-- * tools.job.* capability for the action. Jobs are owner-scoped (the token's sub).-- */--const crypto = require('node:crypto');--const http = require('node:http');----const ULID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';--const newJobId = () => `job_${Array.from(crypto.randomBytes(26), (b) => ULID[b & 31]).join('')}`;--const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);--const CAPS = { create: 'tools.job.create', read: 'tools.job.read' };----function createMockToolsServer({ publicKey, stepMs = 25, dropStreamsAfter = 0 } = {}) {-- const jobs = new Map();-- const idem = new Map();-- const listeners = new Map();-- const stats = { submits: 0, streams: [], replays: 0 };-- let drops = dropStreamsAfter;---- function verify(req, action) {-- const auth = String(req.headers.authorization || '');-- const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];-- if (parts.length !== 3) return { error: [401, 'token.missing'] };-- let header, claims;-- try {-- header = JSON.parse(Buffer.from(parts[0], 'base64url'));-- claims = JSON.parse(Buffer.from(parts[1], 'base64url'));-- } catch { return { error: [401, 'token.malformed'] }; }-- if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return { error: [401, 'token.bad_signature'] };-- if (!claims.exp || claims.exp * 1000 < Date.now()) return { error: [401, 'token.expired'] };-- if (!(claims.aud || []).includes('openvibe.tools')) return { error: [401, 'token.wrong_audience'] };-- if (!(claims.cap || []).includes(CAPS[action])) return { error: [403, 'capability.denied'] };-- return { owner: claims.sub };-- }---- function view(job) {-- return {-- id: job.id, object: 'tools.job', service: 'img', type: job.type, type_version: 1, state: job.state,-- progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 2,-- result: job.result ? { files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, url: `/api/v1/jobs/${job.id}/files/${i}` })), data: job.result.data } : null,-- error: null, retryable: false,-- links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: TERMINAL.has(job.state) ? null : `/api/v1/jobs/${job.id}` },-- };-- }---- function emit(job, event) {-- const e = { seq: job.events.length + 1, event, data: view(job) };-- job.events.push(e);-- for (const fn of listeners.get(job.id) || []) fn(e);-- }---- function lifecycle(job) {-- const steps = [-- () => { job.state = 'running'; emit(job, 'job.running'); },-- () => { job.percent = 50; job.message = 'converting'; emit(job, 'job.progress'); },-- () => {-- job.state = 'succeeded'; job.percent = 100; job.message = null;-- job.result = { files: [{ name: job.fileName.replace(/\.[^.]+$/, '') + '.' + (job.input.format || 'out'), mime: `image/${job.input.format || 'png'}`, bytes: Buffer.concat([Buffer.from('converted:'), job.bytes]) }], data: { tool: job.input.tool } };-- emit(job, 'job.succeeded');-- },-- ];-- const next = () => { const s = steps.shift(); if (s) { s(); setTimeout(next, stepMs); } };-- setTimeout(next, stepMs);-- }---- const problem = (res, status, code, detail) => {-- res.writeHead(status, { 'Content-Type': 'application/problem+json' });-- res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail: detail || code }));-- };---- const server = http.createServer(async (req, res) => {-- const url = new URL(req.url, 'http://tools.invalid');-- let m;-- if (req.method === 'POST' && url.pathname === '/api/v1/jobs') {-- const who = verify(req, 'create');-- if (who.error) return problem(res, ...who.error);-- const chunks = [];-- for await (const c of req) chunks.push(c);-- const raw = Buffer.concat(chunks);-- const form = await new Request('http://tools.invalid/', { method: 'POST', headers: { 'content-type': req.headers['content-type'] }, body: raw }).formData();-- const key = String(req.headers['idempotency-key'] || '');-- if (key.length < 8 || key.length > 200) return problem(res, 400, 'tools.job.invalid', 'Idempotency-Key must be 8-200 characters');-- // Like Tools: the request is identified by type, input and the files' hashes (not the multipart bytes).-- const fileHashes = [];-- for (const f of form.getAll('file')) if (typeof f !== 'string') fileHashes.push(crypto.createHash('sha256').update(Buffer.from(await f.arrayBuffer())).digest('hex'));-- const hash = crypto.createHash('sha256').update(JSON.stringify([String(form.get('type')), String(form.get('input') || '{}'), fileHashes])).digest('hex');-- const prior = idem.get(`${who.owner}|${key}`);-- if (prior) {-- if (prior.hash !== hash) return problem(res, 409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');-- stats.replays++;-- res.writeHead(200, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true' });-- return res.end(JSON.stringify(view(jobs.get(prior.id))));-- }-- const file = form.get('file');-- if (!file || typeof file === 'string') return problem(res, 400, 'tools.job.invalid', 'one file is required');-- stats.submits++;-- const job = {-- id: newJobId(), owner: who.owner, type: String(form.get('type')), input: JSON.parse(String(form.get('input') || '{}')),-- state: 'queued', percent: null, message: null, events: [], result: null,-- fileName: file.name, bytes: Buffer.from(await file.arrayBuffer()),-- };-- jobs.set(job.id, job);-- idem.set(`${who.owner}|${key}`, { id: job.id, hash });-- emit(job, 'job.queued');-- lifecycle(job);-- res.writeHead(202, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${job.id}` });-- return res.end(JSON.stringify(view(job)));-- }-- if ((m = url.pathname.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/)) && req.method === 'GET') {-- const who = verify(req, 'read');-- if (who.error) return problem(res, ...who.error);-- const job = jobs.get(m[1]);-- if (!job || job.owner !== who.owner) return problem(res, 404, 'tools.job.not_found', 'No such job');-- if (!m[2]) {-- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });-- return res.end(JSON.stringify(view(job)));-- }-- if (m[3] !== undefined) {-- if (job.state !== 'succeeded') return problem(res, 409, 'tools.job.not_ready', `The job is ${job.state}`);-- const f = job.result.files[Number(m[3])];-- if (!f) return problem(res, 404, 'tools.job.file_not_found', 'No such result file');-- res.writeHead(200, { 'Content-Type': f.mime, 'Content-Disposition': `attachment; filename="${f.name}"` });-- return res.end(f.bytes);-- }-- const after = Math.max(0, parseInt(req.headers['last-event-id'] || url.searchParams.get('last_event_id') || '0', 10) || 0);-- stats.streams.push(after);-- const pending = job.events.filter((e) => e.seq > after);-- if (TERMINAL.has(job.state) && !pending.length) { res.writeHead(204); return res.end(); }-- res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' });-- res.write(`retry: ${stepMs}\n\n`);-- let last = after;-- let written = 0;-- const set = listeners.get(job.id) || new Set();-- listeners.set(job.id, set);-- const close = () => { set.delete(write); res.end(); };-- const write = (e) => {-- if (e.seq <= last || res.writableEnded) return;-- last = e.seq;-- res.write(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);-- written++;-- if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') return setImmediate(close);-- if (drops > 0 && written >= 1) { drops--; setImmediate(close); } // simulate a dropped connection-- };-- set.add(write);-- for (const e of job.events) write(e);-- req.on('close', () => set.delete(write));-- return undefined;-- }-- return problem(res, 404, 'not_found', 'Not found');-- });---- return {-- server,-- stats,-- jobs,-- async listen() {-- await new Promise((r) => server.listen(0, '127.0.0.1', r));-- return `http://127.0.0.1:${server.address().port}`;-- },-- close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),-- };--}----module.exports = { createMockToolsServer };@@@ -1,8 +1,8 @@'use strict';/*** Smoke test: submit -> watch (with a dropped stream) -> result -> download, reattach after a-- * restart, idempotent resubmission. Network is the SDK's mock platform; Tools is the local mock in-- * this folder (the SDK mock has no Tools). No network.++ * restart, idempotent resubmission, owner scoping. Network and Tools jobs are openvibe-sdk/testing's++ * mock platform (jobs: true), with a sandbox app as a new project has. No network.*/const assert = require('node:assert/strict');const fs = require('node:fs');@@@ -10,44 +10,56 @@ const os = require('node:os');const path = require('node:path');const { createMockPlatform } = require('openvibe-sdk/testing');const { createJobRunner, loadConfig } = require('../run-job');--const { createMockToolsServer } = require('./mock-tools-server');--const realFetch = globalThis.fetch;globalThis.fetch = () => { throw new Error('network access in a smoke test'); };--const APP = 'app_01JEXAMPLETOOLJOB000000000';--const OTHER = 'app_01JANOTHERAPP0000000000000';--const SECRET = 'ovsec_tool_job_test_secret';const quiet = { log() {}, error() {} };++const TOOLS = ['tools.job.create', 'tools.job.read'];(async () => {-- const toolsGrants = [{ capability: 'tools.job.create', audience: 'openvibe.tools' }, { capability: 'tools.job.read', audience: 'openvibe.tools' }];const platform = createMockPlatform({-- clients: { [APP]: { secret: SECRET, grants: toolsGrants }, [OTHER]: { secret: 'o', grants: toolsGrants }, app_01JNOGRANT0000000000000000: { secret: 'n', grants: [] } },++ acceptSandbox: ['openvibe.tools'], // Tools accepts developer-app sandbox tokens on /api/v1/jobs++ jobs: {++ stepMs: 5,++ handlers: {++ 'img.process': async ({ input, files, progress }) => {++ await progress(50, 'converting');++ const name = files[0].name.replace(/\.[^.]+$/, '') + `.${input.format}`;++ return { data: { format: input.format }, files: [{ name, mime: `image/${input.format}`, bytes: Buffer.concat([Buffer.from('converted:'), files[0].bytes]) }] };++ },++ },++ },});-- const tools = createMockToolsServer({ publicKey: platform.keys.publicKey, dropStreamsAfter: 1 });-- const toolsUrl = await tools.listen();-- // Network calls go to the mock platform, Tools calls to the local mock server.-- const fetch = (input, init) => (String(input && input.url ? input.url : input).startsWith(toolsUrl) ? realFetch(input, init) : platform.fetch(input, init));++ const app = platform.addApp({ env: 'sandbox', grants: TOOLS });++ const other = platform.addApp({ env: 'sandbox', grants: TOOLS });++ const noGrant = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });++ const { fetch } = platform;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, OV_CLIENT_SECRET: SECRET, OV_TOOLS_JOBS_URL: toolsUrl, 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_TOOLS_JOBS_URL: platform.origins.tools, 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'));-- // 1. The whole flow, with the event stream dropped after its first event.++ // 1. The whole flow, with the event stream dropped right after its first event.const seen = [];-- const runner = createJobRunner(loadConfig(env), { fetch, log: quiet });-- const { job, files } = await runner.run(file, { onEvent: (e) => seen.push(e) });++ const runner = createJobRunner(loadConfig(env), { fetch, log: quiet, reconnectDelayMs: 10 });++ const { job, files } = await runner.run(file, {++ onEvent: (e) => { seen.push(e); if (seen.length === 1) platform.dropJobStreams(); },++ });assert.equal(job.state, 'succeeded');assert.deepEqual(seen.map((e) => e.event), ['job.queued', 'job.running', 'job.progress', 'job.succeeded']);assert.deepEqual(seen.map((e) => e.id), [1, 2, 3, 4], 'every event once, in order');-- assert.deepEqual(tools.stats.streams.slice(0, 2), [0, 1], 'reconnected with Last-Event-ID: 1');++ assert.ok(streams().length >= 2, 'reconnected after the drop');++ assert.equal(streams()[1].headers['last-event-id'], '1', 'with Last-Event-ID');assert.equal(files.length, 1);assert.equal(path.basename(files[0]), 'photo.webp');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(tools.stats.submits, 1);++ assert.equal(submits(), 1);++ 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}$/);// 2. Crash after submitting: a new process with the same state file reattaches, no second job.const file2 = path.join(dir, 'second.png');@@@ -58,32 +70,30 @@ const quiet = { log() {}, error() {} };const restarted = createJobRunner(loadConfig(env), { fetch, log: quiet });const again = await restarted.run(file2);assert.equal(again.job.id, first.jobId);-- assert.equal(tools.stats.submits, 2, 'reattached instead of resubmitting');++ assert.equal(submits(), 2, 'reattached instead of resubmitting');// 3. Lost state file: the Idempotency-Key is derived from the request, so Tools replays the same job.const lost = createJobRunner(loadConfig(env), { fetch, log: quiet });const replay = await lost.submit(file2);assert.equal(replay.jobId, first.jobId);assert.equal(replay.replayed, true);-- assert.equal(tools.stats.submits, 2);// 4. A finished job with nothing newer answers 204: watch() returns at once.const t0 = Date.now();await lost.watch(first.jobId);assert.ok(Date.now() - t0 < 1000);-- // 5. Jobs are owner-scoped: another app gets 404, and a 4xx is not retried in a loop.-- const other = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: OTHER, OV_CLIENT_SECRET: 'o', OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });-- await assert.rejects(other.get(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');-- await assert.rejects(other.watch(first.jobId), (err) => err.status === 404);++ // 5. Jobs are owner-scoped: another app sees nothing (404), and a 4xx is not retried in a loop.++ const otherRunner = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: other.id, OV_CLIENT_SECRET: other.secret, OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });++ assert.equal(await otherRunner.get(first.jobId), null);++ await assert.rejects(otherRunner.watch(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');// 6. No grant: Network refuses the token, nothing reaches Tools.-- const before = tools.stats.submits;-- const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANT0000000000000000', OV_CLIENT_SECRET: 'n' }), { fetch, log: quiet });++ const before = submits();++ const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: noGrant.id, OV_CLIENT_SECRET: noGrant.secret }), { fetch, log: quiet });await assert.rejects(nogrant.submit(path.join(dir, 'photo.png')), (err) => err.code === 'invalid_scope');-- assert.equal(tools.stats.submits, before);++ assert.equal(submits(), before);-- await tools.close();fs.rmSync(dir, { recursive: true, force: true });console.log('tool-job: ok');})().catch((err) => { console.error(err); process.exit(1); });@@@ -6,9 +6,11 @@ OV_WEBHOOK_PATH=/webhooks/openvibeOV_DB_PATH=./data/webhook-consumer.dbOV_PORT=3003--# subscribe.js: an app holding events.subscription.manage (audience openvibe.events).++# subscribe.js: a CONFIDENTIAL sandbox app holding events.app.subscribe (audience openvibe.events).OV_NETWORK_URL=https://openvibe.networkOV_CLIENT_ID=OV_CLIENT_SECRET=++# Optional: your project id (prj_...); read from the app token when empty.++OV_PROJECT_ID=# Optional: an explicit Events origin instead of the one the registry advertises.OV_EVENTS_URL=@@@ -1,16 +1,22 @@# Webhook consumer--Receive OpenVibe.Events deliveries over HTTPS, verify `X-OpenVibe-Signature`, and handle every--event **exactly once** even though delivery is at least once.++Receive OpenVibe.Events deliveries for your project's events over HTTPS, verify++`X-OpenVibe-Signature`, and handle every event **exactly once** even though delivery is at least once.```bash--node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe--node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003++node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*++node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003```## What it proves--- **Signature first, on the raw bytes.** `openvibe-sdk/events` `parseDelivery()` checks++- **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++ (`403 events.topic_not_allowed`), and accepts only `https` endpoints that resolve to public++ addresses. `subscribe.js` generates the signing secret itself and writes it to `.env.webhook`++ (mode 0600); it never prints it.++- **Signature first, on the raw bytes.** `parseDelivery()` checks`sha256=<HMAC-SHA256 of the raw body>` in constant time before anything is parsed. A wrongsecret, a changed byte or a missing header is `401`, with no detail.- **Exactly once.** `createInbox(db).once(consumer, event_id, fn)` writes the receipt and runs your@@@ -19,8 +25,6 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openno receipt, the consumer answers `500`, and Events retries with backoff.- **The signed body is the truth.** `X-OpenVibe-Event-Id` must match the body's `event_id`.- **Secret rotation** without downtime: `OV_WEBHOOK_SECRET_PREVIOUS` is accepted while it is set.--- `subscribe.js` creates the subscription with `events.subscriptions.create()`. It generates the-- signing secret itself and writes it to `.env.webhook` (mode 0600); it never prints it.## Files@@@ -28,7 +32,8 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open|---|---|| `server.js` | `createConsumer()`: the HTTP endpoint, signature check, inbox, `received_events` table || `subscribe.js` | `createSubscription()`: create the Events subscription with an app token |--| `test/smoke.test.js` | subscription via the SDK mock, then deliveries signed and shaped exactly like the Events delivery worker's |++| `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@@@ -36,24 +41,28 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/opennpm test```--The SDK's mock platform stores subscriptions but has no delivery worker, so the test plays the--worker: it signs each body with `signDelivery()` and sends the same headers Events sends--(`X-OpenVibe-Event-Id`, `-Event-Type`, `-Seq`, `-Subscription-Id`, `-Delivery-Attempt`, `-Signature`).++Deliveries come from `openvibe-sdk/testing`'s delivery worker (`deliverEvents()`): signed POSTs++with Events' headers, in order, retried on a non-2xx. The test routes the subscription's https++endpoint to the consumer on a local port. Wrong-secret, tampered and forged deliveries are made by++hand. The SDK 0.3.0 mock has no `events.app.*`, so `test/mock-app-events.js` plays Events' app++rules (capability, topic scope, https endpoint) in front of it.## Run it against the real platform--**This cannot be demonstrated against the real platform yet.** The consumer side is ready, but a--developer app cannot create a subscription:++1. Create a project and a **confidential** sandbox app with `events.app.subscribe` (and++ `events.app.publish` if you want to send yourself events with++ [event-subscriber](../event-subscriber)'s `publish.js`); see the++ [walkthrough](../../README.md#end-to-end-walkthrough).++2. Run `server.js` somewhere Events can reach over **https** with a public address (a VPS behind a++ TLS proxy, or a tunnel). `localhost`, private and link-local addresses are refused, when you++ subscribe and again on every delivery, and redirects are not followed.++3. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, then++ `node --env-file=.env subscribe.js https://<your host>/webhooks/openvibe`.++4. Start the consumer with `--env-file=.env --env-file=.env.webhook` and publish an event++ (`node --env-file=.env ../event-subscriber/publish.js`).--- `events.subscription.manage` (and every other `events.*` capability) has visibility `internal`-- in openvibe-contracts, so Network never grants it to an app.--- OpenVibe.Events' public host exposes only `/realtime/stream` and health; its subscription API-- is reachable from the platform's own hosts only.--- The Events delivery worker only POSTs to endpoint hosts on its allow-list.----When those change, the steps are: an app with `events.subscription.manage`, `cp .env.example .env`,--`node --env-file=.env subscribe.js '<topic pattern>' https://<your host>/webhooks/openvibe`, then run--`server.js` behind HTTPS with `--env-file=.env.webhook`.++Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox events++only. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured deliverycan be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the@@@ -1,6 +1,6 @@{"name": "openvibe-example-webhook-consumer",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Webhook consumer: verify X-OpenVibe-Signature and process each event exactly once through an inbox.","license": "MIT",@@@ -13,7 +13,7 @@"node": ">=22"},"dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0","better-sqlite3": "^11.10.0"}}@@@ -3,11 +3,17 @@/*** Create the OpenVibe.Events subscription that delivers to this consumer.*-- * node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe++ * 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.*'*-- * Needs an app token for openvibe.events holding events.subscription.manage. The signing secret-- * is generated here and sent with the request, then written to .env.webhook (mode 0600) for-- * server.js to read. It is never printed.++ * 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++ * (app.<project_key>.*, project_key = p + the project's ULID in lowercase); first-party++ * patterns (live.*) deliver only public events;++ * - endpoints that are https, without credentials, resolving only to public addresses (checked++ * at subscribe time and again on every delivery; redirects are never followed).++ * The signing secret is generated here and sent with the request, then written to .env.webhook++ * (mode 0600) for server.js to read. It is never printed.*/const crypto = require('node:crypto');const fs = require('node:fs');@@@ -16,6 +22,13 @@ 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()}`;++}++function loadConfig(env = process.env) {const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);if (missing.length) {@@@ -27,33 +40,41 @@ function loadConfig(env = process.env) {network: env.OV_NETWORK_URL || 'https://openvibe.network',clientId: env.OV_CLIENT_ID,clientSecret: env.OV_CLIENT_SECRET,++ projectId: env.OV_PROJECT_ID || null,eventsUrl: env.OV_EVENTS_URL || null,};}--/** -> { id, topic_pattern, endpoint, enabled } plus the secret it was created with (kept out of logs). */--async function createSubscription(config, { topicPattern, endpoint, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {-- if (!/^https:\/\//.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(endpoint)) {-- throw new TypeError('the endpoint must be an https:// URL');-- }-- const client = createClient({-- network: config.network,-- fetch,-- tokenProvider: createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch }),-- baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,++/**++ * -> { 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>.*.++ */++async function createSubscription(config, { endpoint, topicPattern, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {++ let u;++ try { u = new URL(endpoint); } catch { u = null; }++ if (!u || u.protocol !== 'https:' || u.username || u.password) throw new TypeError('the endpoint must be a public https:// URL without credentials');++ const tokens = createServiceTokenClient({++ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,++ scope: { 'openvibe.events': 'events.app.subscribe' },});-- const events = createEventsClient(client, { source: config.clientId });-- const sub = await events.subscriptions.create({ topicPattern, endpoint, secret });++ 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 });return { subscription: { id: sub.id, topic_pattern: sub.topic_pattern, endpoint: sub.endpoint, enabled: sub.enabled }, secret: sub.secret || secret };}--async function main([topicPattern, endpoint] = process.argv.slice(2)) {-- if (!topicPattern || !endpoint) {-- console.error('usage: node subscribe.js <topic-pattern> <https endpoint>');++async function main([endpoint, topicPattern] = process.argv.slice(2)) {++ if (!endpoint) {++ console.error('usage: node subscribe.js <https endpoint> [topic-pattern]');return 2;}try {-- const { subscription, secret } = await createSubscription(loadConfig(), { topicPattern, endpoint });++ const { subscription, secret } = await createSubscription(loadConfig(), { endpoint, topicPattern });const file = path.join(__dirname, '.env.webhook');fs.writeFileSync(file, `OV_WEBHOOK_SECRET=${secret}\n`, { mode: 0o600 });console.log(JSON.stringify(subscription, null, 2));@@@ -61,8 +82,8 @@ async function main([topicPattern, endpoint] = process.argv.slice(2)) {return 0;} catch (err) {console.error(`subscribe failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);-- if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'capability.denied'].includes(err.code)) {-- console.error('events.subscription.manage is not grantable to developer apps today; see the README.');++ if (isOpenVibeError(err) && ['invalid_scope', 'capability.denied'].includes(err.code)) {++ console.error('Request events.app.subscribe on your app (it is in the sandbox allowance) and try again.');}return 1;}@@@ -70,4 +91,4 @@ async function main([topicPattern, endpoint] = process.argv.slice(2)) {if (require.main === module) main().then((code) => { process.exitCode = code; });--module.exports = { loadConfig, createSubscription, main };++module.exports = { loadConfig, createSubscription, projectKey, main };nnew file mode 100644@@@ -0,0 +1,122 @@++'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,8 +1,10 @@'use strict';/**-- * Smoke test: subscription creation against the SDK's mock platform, then deliveries shaped and-- * signed exactly like OpenVibe.Events' delivery worker sends them (the mock has no delivery-- * worker, so this test plays that part with openvibe-sdk/events signDelivery()).++ * 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.*/const assert = require('node:assert/strict');const fs = require('node:fs');@@@ -10,60 +12,36 @@ const os = require('node:os');const path = require('node:path');const { createMockPlatform } = require('openvibe-sdk/testing');const { signDelivery } = require('openvibe-sdk/events');--const { ulid } = require('openvibe-sdk/core');const { createConsumer, loadConfig, openDatabase } = require('../server');--const { createSubscription } = require('../subscribe');++const { createSubscription, loadConfig: loadSubscribeConfig, projectKey } = require('../subscribe');++const { createAppEventsFetch } = require('./mock-app-events');const realFetch = globalThis.fetch;globalThis.fetch = () => { throw new Error('network access in a smoke test'); };--const APP = 'app_01JEXAMPLEWEBHOOK000000000';--const SECRET = 'ovsec_webhook_consumer_test_secret';----function envelope(type = 'media.object.uploaded') {-- return {-- event_id: `evt_${ulid()}`, event_type: type, version: 1, source: 'media', timestamp: new Date().toISOString(),-- actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' }, payload: { size: 12 },-- };--}----/** POST a delivery the way the Events worker does. */--async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers = {} } = {}) {-- const body = JSON.stringify({ event, seq });-- const sent = tamper ? tamper(body) : body;-- const res = await realFetch(`http://127.0.0.1:${port}/webhooks/openvibe`, {-- method: 'POST',-- headers: {-- 'Content-Type': 'application/json',-- 'X-OpenVibe-Event-Id': event.event_id,-- 'X-OpenVibe-Event-Type': event.event_type,-- 'X-OpenVibe-Seq': String(seq),-- 'X-OpenVibe-Subscription-Id': 'sub_test',-- 'X-OpenVibe-Delivery-Attempt': String(attempt),-- 'X-OpenVibe-Signature': signDelivery(body, secret),-- ...headers,-- },-- body: sent,-- });-- return { status: res.status, body: await res.json() };--}++const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';(async () => {-- // 1. Create the subscription (events.subscription.manage) and keep the secret it was created with.-- const platform = createMockPlatform({-- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.subscription.manage', audience: 'openvibe.events' }] } },-- });-- const created = await createSubscription(-- { network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },-- { topicPattern: 'media.object.*', endpoint: 'https://hooks.example.com/webhooks/openvibe' },-- { fetch: platform.fetch },-- );++ const platform = createMockPlatform();++ const fetch = createAppEventsFetch(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);++ const subEnv = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };++++ // 1. Create the subscription: the project's own topic by default, https only, own project only.++ const created = await createSubscription(loadSubscribeConfig(subEnv), { endpoint: ENDPOINT }, { fetch });assert.match(created.subscription.id, /^sub_/);-- assert.equal(created.subscription.topic_pattern, 'media.object.*');++ assert.equal(created.subscription.topic_pattern, `app.${key}.*`);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({ network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },-- { topicPattern: 'x.y.*', endpoint: 'http://hooks.example.com/x' }, { fetch: platform.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');++ 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');// 2. Run the consumer with that secret.const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-webhook-'));@@@ -80,48 +58,72 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headerslog: { log: (m) => logs.push(m), error: (m) => logs.push(m) },});await new Promise((r) => consumer.server.listen(0, '127.0.0.1', r));-- const { port } = consumer.server.address();---- const e1 = envelope();-- let r = await deliver(port, e1, 1, created.secret);-- assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: false } });++ let local = `http://127.0.0.1:${consumer.server.address().port}/webhooks/openvibe`;++++ // The mock's delivery worker POSTs to the subscription's https endpoint; route it to the local++ // consumer and keep what was sent (to replay one later, like a retry after a lost 2xx).++ const sent = [];++ const worker = (url, init) => {++ assert.equal(url, ENDPOINT);++ sent.push({ body: init.body, headers: init.headers });++ return realFetch(local, init);++ };++ const appEvent = (name) => platform.publishEvent({++ event_type: `app.${key}.${name}`, source: `app-${app.id.slice(4).toLowerCase()}`,++ actor: { type: 'app', id: app.id }, subject: { type: 'order', id: '42' }, payload: { size: 12 },++ }, `app:${app.id}`);++++ const e1 = appEvent('order.created');++ platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' } });++ let round = await platform.deliverEvents({ fetch: worker });++ assert.deepEqual([round.delivered, round.failed], [1, 0], 'only the subscribed topic is delivered');assert.deepEqual(handled, [e1.event_id]);++ const firstDelivery = sent[0];++ assert.equal(firstDelivery.headers['X-OpenVibe-Event-Id'], e1.event_id);-- // Redelivery (a retry after a lost 2xx, or a replay): acknowledged, not processed again.-- r = await deliver(port, e1, 1, created.secret, { attempt: 2 });-- assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: true } });++ // Redelivery of the same bytes (a retry after a lost 2xx, or a replay): acknowledged, not processed again.++ const replay = async (d, extra = {}, body = d.body) => {++ const res = await realFetch(local, { method: 'POST', headers: { ...d.headers, ...extra }, body });++ return { status: res.status, body: await res.json() };++ };++ assert.deepEqual(await replay(firstDelivery, { 'X-OpenVibe-Delivery-Attempt': '2' }), { status: 200, body: { ok: true, duplicate: true } });assert.deepEqual(handled, [e1.event_id]);-- // Wrong secret, tampered body, missing signature: 401 and no side effect.-- const e2 = envelope();-- assert.equal((await deliver(port, e2, 2, 'whsec_not_the_secret')).status, 401);-- assert.equal((await deliver(port, e2, 2, created.secret, { tamper: (b) => b.replace('"size":12', '"size":13') })).status, 401);-- assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Signature': '' } })).status, 401);-- assert.equal(handled.length, 1);---- // Header and signed body disagree on the event id: refused.-- assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Event-Id': 'evt_01JOTHER0000000000000000000' } })).status, 400);---- // A handler failure leaves no receipt: 500 now, processed on the retry.++ // A handler failure leaves no receipt: the worker sees a 500, retries on its next round (attempt 2).++ const e2 = appEvent('order.paid');failNext = true;-- assert.equal((await deliver(port, e2, 2, created.secret)).status, 500);++ round = await platform.deliverEvents({ fetch: worker });++ assert.equal(round.failed, 1);++ assert.equal(round.attempts[0].status, 500);assert.equal(consumer.inbox.seen('webhook-consumer', e2.event_id), false);-- r = await deliver(port, e2, 2, created.secret, { attempt: 2 });-- assert.equal(r.status, 200);++ round = await platform.deliverEvents({ fetch: worker });++ assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);++ assert.deepEqual(handled, [e1.event_id, e2.event_id]);++++ // Attacks: wrong secret, tampered body, missing signature: 401 and no side effect.++ const last = sent.at(-1);++ const e3 = appEvent('order.refunded');++ const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);++ assert.equal((await replay(last, { 'X-OpenVibe-Signature': signDelivery(forgedBody, 'whsec_not_the_secret'), 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401);++ assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401, 'changed bytes, old signature');++ assert.equal((await replay(last, { 'X-OpenVibe-Signature': '' })).status, 401);++ // Header and signed body disagree on the event id: refused.++ assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);assert.deepEqual(handled, [e1.event_id, e2.event_id]);// Secret rotation: deliveries signed with the previous secret are accepted while it is configured.consumer.server.close();-- const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {++ const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new_secret_after_rotation_0000000000', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {db: consumer.db, handle: (event) => handled.push(event.event_id), log: { log() {}, error() {} },});await new Promise((res) => rotated.server.listen(0, '127.0.0.1', res));-- const p2 = rotated.server.address().port;-- const e3 = envelope();-- assert.equal((await deliver(p2, e3, 3, created.secret)).status, 200);-- const e4 = envelope();-- assert.equal((await deliver(p2, e4, 4, 'whsec_new')).status, 200);-- assert.equal((await deliver(p2, e4, 4, 'whsec_new')).body.duplicate, true);++ local = `http://127.0.0.1:${rotated.server.address().port}/webhooks/openvibe`;++ round = await platform.deliverEvents({ fetch: worker }); // e3, still signed with the old secret++ assert.equal(round.delivered, 1);++ platform.state.subscriptions.get(created.subscription.id).secret = 'whsec_new_secret_after_rotation_0000000000';++ const e4 = appEvent('order.closed');++ round = await platform.deliverEvents({ fetch: worker }); // e4, signed with the new one++ assert.equal(round.delivered, 1);assert.deepEqual(handled, [e1.event_id, e2.event_id, e3.event_id, e4.event_id]);// The receipt and the app's own row were written together.@@@ -129,7 +131,7 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headersassert.deepEqual(rows, handled);// Oversized bodies are refused before parsing.-- const big = await realFetch(`http://127.0.0.1:${p2}/webhooks/openvibe`, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });++ const big = await realFetch(local, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });assert.equal(big.status, 413);// No secret in the logs.@@@ -1,26 +1,29 @@{"name": "openvibe-examples",-- "version": "0.1.0",++ "version": "0.2.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-examples",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","workspaces": ["examples/*"],++ "devDependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"++ },"engines": {"node": ">=22"}},"examples/browser-app": {"name": "openvibe-example-browser-app",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -28,11 +31,8 @@},"examples/chat-bot": {"name": "openvibe-example-chat-bot",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT",-- "dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"-- },"devDependencies": {"ws": "^8.18.0"},@@@ -42,10 +42,10 @@},"examples/event-subscriber": {"name": "openvibe-example-event-subscriber",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -53,10 +53,10 @@},"examples/media-uploader": {"name": "openvibe-example-media-uploader",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -64,11 +64,11 @@},"examples/mod-manifest": {"name": "openvibe-example-mod-manifest",-- "version": "0.1.0",++ "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.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -76,10 +76,10 @@},"examples/node-server-app": {"name": "openvibe-example-node-server-app",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -87,10 +87,10 @@},"examples/oauth-app": {"name": "openvibe-example-oauth-app",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -98,10 +98,10 @@},"examples/tool-job": {"name": "openvibe-example-tool-job",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -109,11 +109,11 @@},"examples/webhook-consumer": {"name": "openvibe-example-webhook-consumer",-- "version": "0.1.0",++ "version": "0.2.0","license": "MIT","dependencies": {"better-sqlite3": "^11.10.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"},"engines": {"node": ">=22"@@@ -469,9 +469,9 @@"link": true},"node_modules/openvibe-sdk": {-- "version": "0.2.2",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",-- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",++ "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==","license": "MIT","engines": {"node": ">=20"@@@ -1,6 +1,6 @@{"name": "openvibe-examples",-- "version": "0.1.0",++ "version": "0.2.0","private": true,"description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.","license": "MIT",@@@ -9,9 +9,14 @@"examples/*"],"scripts": {-- "test": "node test/run.js"++ "test": "node test/run.js",++ "e2e": "node --env-file-if-exists=.env scripts/e2e.js",++ "new-app": "node --env-file-if-exists=.env scripts/new-sandbox-app.js"},"engines": {"node": ">=22"++ },++ "devDependencies": {++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"}}nnew file mode 100644@@@ -0,0 +1,151 @@++#!/usr/bin/env node++'use strict';++/**++ * npm run e2e: three examples against the REAL platform, as your sandbox app. Not part of npm test++ * and not run in CI: it needs your credentials and the network.++ *++ * OV_CLIENT_ID=app_… OV_CLIENT_SECRET=… OV_PROJECT_ID=prj_… npm run e2e # or put them in ./.env++ *++ * The app must be a CONFIDENTIAL app of that project with the sandbox grants media.object.upload,++ * media.object.read, events.app.publish, events.app.read, tools.job.create and tools.job.read++ * (scripts/new-sandbox-app.js or https://openvibe.codes create one). Optional: OV_NETWORK_URL,++ * OV_TOOLS_JOBS_URL.++ *++ * 1. media-uploader upload a small text file into the project's Media tenant, read its++ * metadata back, delete it++ * 2. event-subscriber publish app.<project_key>.e2e.ran, then pull the project's topic from just++ * before it with a durable cursor and find it++ * 3. tool-job convert a 1x1 PNG to WebP on img.openvibe.tools, following the job's events++ *++ * It refuses to start without the three variables, prints what each step did, and never prints a++ * secret: the client secret and anything shaped like a token are masked in all output, and signed++ * URLs are shown without their signature.++ */++const fs = require('node:fs');++const os = require('node:os');++const path = require('node:path');++++const ROOT = path.join(__dirname, '..');++const REQUIRED = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_PROJECT_ID'];++const STEP_TIMEOUT_MS = 180000;++// A 1x1 PNG, so the Tools step needs no file of yours.++const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');++++function refuse(message) {++ console.error(message);++ console.error('\nusage: OV_CLIENT_ID=app_… OV_CLIENT_SECRET=… OV_PROJECT_ID=prj_… npm run e2e');++ console.error('The app: a confidential sandbox app with media.object.upload, media.object.read, events.app.publish,');++ console.error('events.app.read, tools.job.create and tools.job.read (see the README walkthrough).');++ process.exit(2);++}++++/** Mask the secret and anything token-shaped in everything this process writes. */++function maskOutput(secret) {++ const mask = (s) => String(s).split(secret).join('[secret]').replace(/eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, '[token]');++ for (const stream of [process.stdout, process.stderr]) {++ const write = stream.write.bind(stream);++ stream.write = (chunk, ...rest) => write(typeof chunk === 'string' || Buffer.isBuffer(chunk) ? mask(chunk.toString()) : chunk, ...rest);++ }++}++++const withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };++const describeError = (err) => (err && err.code && err.status !== undefined++ ? `${err.code}${err.status ? ` (${err.status})` : ''}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` request=${err.requestId}` : ''}`++ : String((err && err.message) || err));++++function timeout(promise, what) {++ let timer;++ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} took longer than ${STEP_TIMEOUT_MS / 1000} s`)), STEP_TIMEOUT_MS); })])++ .finally(() => clearTimeout(timer));++}++++async function main() {++ const missing = REQUIRED.filter((k) => !process.env[k]);++ if (missing.length) refuse(`e2e: missing ${missing.join(', ')}. Nothing was sent.`);++ const env = process.env;++ if (!/^app_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.OV_CLIENT_ID)) refuse('e2e: OV_CLIENT_ID is not an app id (app_<ULID>). Nothing was sent.');++ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.OV_PROJECT_ID)) refuse('e2e: OV_PROJECT_ID is not a project id (prj_<ULID>). Nothing was sent.');++ 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 { 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'));++++ const base = { OV_CLIENT_ID: env.OV_CLIENT_ID, OV_CLIENT_SECRET: env.OV_CLIENT_SECRET, OV_PROJECT_ID: env.OV_PROJECT_ID, ...(env.OV_NETWORK_URL ? { OV_NETWORK_URL: env.OV_NETWORK_URL } : {}) };++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-examples-e2e-'));++ const log = { log: (m) => console.log(` ${m}`), error: (m) => console.log(` ${m}`) };++ console.log(`OpenVibe.Examples e2e against ${env.OV_NETWORK_URL || 'https://openvibe.network'}`);++ console.log(`app ${env.OV_CLIENT_ID}, project ${env.OV_PROJECT_ID}\n`);++++ const results = [];++ async function step(name, fn) {++ console.log(`[${results.length + 1}/3] ${name}`);++ try {++ await timeout(fn(), name);++ results.push({ name, ok: true });++ console.log(' ok\n');++ } catch (err) {++ results.push({ name, ok: false });++ console.log(` FAILED: ${describeError(err)}\n`);++ }++ }++++ await step('media-uploader: upload, read back, delete', async () => {++ const file = path.join(dir, 'openvibe-examples-e2e.txt');++ fs.writeFileSync(file, `OpenVibe.Examples e2e ${new Date().toISOString()}\n`);++ const uploader = createUploader(mediaConfig(base));++ let out;++ try { out = await uploader.upload(file); } catch (err) { throw Object.assign(err, { detail: explain(err) }); }++ console.log(` uploaded ${out.key} (${out.size} bytes, ${out.mime}) into project ${out.project_id}${out.deduplicated ? ', deduplicated' : ''}`);++ console.log(` sandbox=${out.sandbox} url=${withoutQuery(out.url)}${out.url_expires_at ? ` expires ${out.url_expires_at}` : ''}`);++ const meta = await uploader.get(out.key);++ if (!meta || meta.sha256 !== out.sha256) throw new Error('media.object.read did not return the file just uploaded');++ console.log(` read back with media.object.read: sha256 ${meta.sha256.slice(0, 16)}…`);++ console.log(` deleted: ${await uploader.remove(out.key)}`);++ });++++ await step('event-subscriber: publish to the own topic, then pull it', async () => {++ const pub = await publishAppEvent(publishConfig(base), { name: 'e2e.ran', subject: { type: 'check', id: 'e2e' }, payload: { at: new Date().toISOString() } });++ console.log(` published ${pub.event_type} ${pub.event_id} -> seq ${pub.seq}${pub.duplicate ? ' (duplicate)' : ''}`);++ const cursorPath = path.join(dir, 'cursor.json');++ createCursorStore(cursorPath).set('pull', Math.max(0, pub.seq - 1));++ const seen = [];++ const s = createSubscriber(subscriberConfig({ ...base, OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {++ log, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id, type: event.event_type }); },++ });++ const topics = await s.resolveTopics();++ if (topics[0] !== `app.${projectKey(env.OV_PROJECT_ID)}.*`) throw new Error(`unexpected topic ${topics[0]}`);++ const n = await s.pullOnce();++ console.log(` pulled ${topics.join(', ')} from seq ${pub.seq - 1}: ${n} event(s), cursor now ${createCursorStore(cursorPath).get('pull')}`);++ const mine = seen.find((e) => e.id === pub.event_id);++ if (!mine) throw new Error(`the published event ${pub.event_id} was not in the pull`);++ console.log(` found ${mine.id} at seq ${mine.seq}`);++ });++++ await step('tool-job: img.process convert to WebP', async () => {++ const file = path.join(dir, 'pixel.png');++ fs.writeFileSync(file, PNG_1X1);++ const runner = createJobRunner(jobConfig({++ ...base, OV_JOB_STATE: path.join(dir, 'job-state.json'), OV_OUT_DIR: path.join(dir, 'out'),++ ...(env.OV_TOOLS_JOBS_URL ? { OV_TOOLS_JOBS_URL: env.OV_TOOLS_JOBS_URL } : {}),++ }), { log });++ const { job, files } = await runner.run(file, {++ onEvent: ({ id, event, job: j }) => {++ const pct = j && j.progress && j.progress.percent != null ? ` ${j.progress.percent}%` : '';++ console.log(` #${id} ${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);++ },++ });++ if (!job || job.state !== 'succeeded') throw new Error(job ? `job ${job.id} ${job.state}${job.error ? `: ${job.error.code}` : ''}` : 'the job is gone');++ for (const f of files) console.log(` result ${path.basename(f)} (${fs.statSync(f).size} bytes)`);++ if (!files.length) throw new Error('the job succeeded but returned no file');++ });++++ fs.rmSync(dir, { recursive: true, force: true });++ const ok = results.filter((r) => r.ok).length;++ console.log(`${ok}/${results.length} steps ok`);++ return ok === results.length ? 0 : 1;++}++++main().then((code) => { process.exitCode = code; }, (err) => { console.error(`e2e: ${describeError(err)}`); process.exitCode = 1; });nnew file mode 100644@@@ -0,0 +1,99 @@++#!/usr/bin/env node++'use strict';++/**++ * Create a project and a sandbox app for the examples with openvibe-sdk/projects, the same++ * /api/v1/projects API https://openvibe.codes uses. Nothing here needs staff: a new project is++ * sandbox, and the project owner's grant requests inside the sandbox allowance are approved at once.++ *++ * npm run new-app # new project + confidential sandbox app -> ./.env++ * npm run new-app -- --project prj_… # add the app to a project you already have++ * npm run new-app -- --public # a PUBLIC app for examples/browser-app (no secret)++ *++ * It needs a Network user access token: OV_USER_TOKEN, or it asks for your username and password++ * (the password is not echoed) and signs in at <network>/api/auth/login. The confidential app's++ * secret is shown ONCE by Network; this writes it straight into ./.env (mode 0600) with++ * OV_CLIENT_ID and OV_PROJECT_ID and never prints it. An existing .env is never overwritten.++ */++const fs = require('node:fs');++const path = require('node:path');++const readline = require('node:readline');++const { createClient, isOpenVibeError } = require('openvibe-sdk/core');++const { createProjectsClient } = require('openvibe-sdk/projects');++++const ROOT = path.join(__dirname, '..');++const SANDBOX_GRANTS = [++ 'media.object.upload', 'media.object.read',++ 'events.app.publish', 'events.app.read', 'events.app.subscribe',++ 'tools.job.create', 'tools.job.read', 'tools.job.cancel',++];++++function ask(question, { hidden = false } = {}) {++ return new Promise((resolve) => {++ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });++ if (hidden) rl._writeToOutput = (s) => { if (s.includes(question)) process.stdout.write(s); };++ rl.question(question, (answer) => { rl.close(); if (hidden) process.stdout.write('\n'); resolve(answer); });++ });++}++++async function userToken(network) {++ if (process.env.OV_USER_TOKEN) return process.env.OV_USER_TOKEN;++ if (!process.stdin.isTTY) throw new Error('set OV_USER_TOKEN, or run this in a terminal to sign in');++ const username = await ask('OpenVibe username: ');++ const password = await ask('password: ', { hidden: true });++ const res = await fetch(`${network}/api/auth/login`, {++ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },++ body: JSON.stringify({ username, password }),++ });++ const body = await res.json().catch(() => ({}));++ if (!res.ok || !body.token) throw new Error(`sign-in refused (${res.status})`);++ return body.token;++}++++async function main(argv = process.argv.slice(2)) {++ const network = (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '');++ const isPublic = argv.includes('--public');++ const pi = argv.indexOf('--project');++ const existing = pi >= 0 ? argv[pi + 1] : null;++ const envFile = path.join(ROOT, '.env');++ if (!isPublic && fs.existsSync(envFile)) {++ console.error(`${envFile} exists; move it away first (this never overwrites a secret).`);++ return 2;++ }++ try {++ const projects = createProjectsClient(createClient({ network, token: await userToken(network) }));++ let projectId = existing;++ if (!projectId) {++ const p = await projects.create({ name: 'OpenVibe examples' });++ projectId = (p.project || p).id;++ console.log(`project ${projectId} (sandbox)`);++ }++ const created = await projects.apps.create(projectId, isPublic++ ? { name: 'examples browser-app', environment: 'sandbox', type: 'public', redirectUris: ['http://localhost:3001/callback'] }++ : { name: 'examples', environment: 'sandbox', type: 'confidential', redirectUris: ['http://localhost:3009/callback'] });++ const app = created.app || created;++ const appId = app.id || app.client_id;++ console.log(`app ${appId} (sandbox, ${isPublic ? 'public' : 'confidential'})`);++ for (const capability of isPublic ? ['media.object.read'] : SANDBOX_GRANTS) {++ try {++ const g = await projects.grants.request(projectId, appId, capability);++ console.log(` grant ${capability}: ${(g.grant || g).status}`);++ } catch (err) {++ console.log(` grant ${capability}: ${isOpenVibeError(err) ? err.code : err.message}`);++ }++ }++ if (isPublic) {++ console.log(`\nexamples/browser-app/.env: OV_CLIENT_ID=${appId} (a public app has no secret)`);++ return 0;++ }++ const secret = (created.credential || app.credential || {}).client_secret || created.client_secret;++ if (!secret) throw new Error('Network returned no client secret; rotate one on the app (openvibe.codes)');++ fs.writeFileSync(envFile, `OV_CLIENT_ID=${appId}\nOV_CLIENT_SECRET=${secret}\nOV_PROJECT_ID=${projectId}\n`, { mode: 0o600 });++ console.log(`\nwrote ${envFile} (OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID; mode 0600). Next: npm run e2e`);++ return 0;++ } catch (err) {++ console.error(`failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} (request ${err.requestId})` : err.message}`);++ return 1;++ }++}++++main().then((code) => { process.exitCode = code; });@@@ -2,15 +2,17 @@/*** Every example is copyable on its own: a README (with how to run it against the real platform),* a .env.example naming every variable its code reads, an MIT package.json whose dependencies are-- * pinned tags (no file: links), and a smoke test that the root runner picks up.++ * pinned tags (no file: links), and a smoke test that the root runner picks up. `npm run e2e`++ * (the real platform) stays out of npm test and CI, and refuses to run without credentials.*/const assert = require('node:assert/strict');++const { spawnSync } = require('node:child_process');const fs = require('node:fs');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.2.2';++const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0';const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');@@@ -36,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.2.2`);++ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.0`);}const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');@@@ -56,6 +58,18 @@ for (const name of EXPECTED) {const ci = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');assert.match(ci, /node-version: 22\.22\.1/);assert.match(ci, /npm test/);++const rootPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));++assert.equal(rootPkg.devDependencies['openvibe-sdk'], SDK, 'the root scripts use the same SDK tag');++++// npm run e2e: real platform only on demand. Not in CI, not in npm test, and it refuses to start++// (exit 2, before any network access) without OV_CLIENT_ID / OV_CLIENT_SECRET / OV_PROJECT_ID.++assert.match(rootPkg.scripts.e2e, /scripts\/e2e\.js/);++assert.ok(!/e2e/.test(rootPkg.scripts.test), 'npm test does not run the e2e');++assert.ok(!ci.split('\n').filter((l) => /^\s*-?\s*run:/.test(l)).some((l) => /e2e/.test(l)), 'CI does not run the e2e');++assert.ok(!fs.existsSync(path.join(ROOT, 'test', 'e2e.test.js')), 'the root runner never picks it up');++const refused = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'e2e.js')], { env: { PATH: process.env.PATH }, encoding: 'utf8', timeout: 20000 });++assert.equal(refused.status, 2, refused.stderr);++assert.match(refused.stderr, /missing OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID\. Nothing was sent\./);assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);console.log('structure: ok');
@@ -12,6 +12,7 @@ jobs:
@@ -12,6 +12,7 @@ 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 small local
- # mocks (Chat WebSocket, Tools jobs). No test reaches the network or a real service.
+ # 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.
- run: npm test
- run: npm test
@@ -3,11 +3,11 @@
@@ -3,11 +3,11 @@
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> Executable public integration examples for the OpenVibe platform: the SDK, scoped credentials
> and public APIs, nothing else.
> and public APIs, nothing else.
-**Status:** alpha (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on Node
-22.22.1 against `openvibe-sdk/testing`'s mock platform and two small local mocks. **None of them
-runs against the live platform in CI**, and several cannot run against it yet: see
-[What works against the real platform today](#what-works-against-the-real-platform-today).
-**Built on:** [openvibe-sdk v0.2.2](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.2.2), and
+**Status:** alpha, 0.2.0 (roadmap Wave 20). Nine examples, each with a smoke test that CI runs on
+Node 22.22.1 against `openvibe-sdk/testing`'s mock platform (no network). `npm run e2e` runs three
+of them (Media, Events pull, a Tools job) against the real platform with your own sandbox app; it
+is not part of CI or `npm test`, and it was not run against production as part of this release.
+**Built on:** [openvibe-sdk v0.3.0](https://github.com/OpenVibers/OpenVibe.SDK/tree/v0.3.0), and
openvibe-contracts v0.26.0 for the mod manifest.
openvibe-contracts v0.26.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).
@@ -18,19 +18,120 @@ OpenVibe services themselves are AGPL-3.0.
@@ -18,19 +18,120 @@ OpenVibe services themselves are AGPL-3.0.
| Example | What it proves | Uses |
| Example | What it proves | Uses |
|---|---|---|
|---|---|---|
-| [browser-app](examples/browser-app) | A plain page (no build step) signs in with PKCE and reads a public API from the browser; a public app needs no secret | `openvibe-sdk/auth/browser`, `/registry`, `/core` |
-| [node-server-app](examples/node-server-app) | Client-credentials app token per audience, registry discovery, contracts version check, trace propagation | `openvibe-sdk/auth`, `/registry`, `/core` |
-| [webhook-consumer](examples/webhook-consumer) | `X-OpenVibe-Signature` verified on the raw body; exactly-once handling with an inbox; secret rotation | `openvibe-sdk/events` (`parseDelivery`, `createInbox`, `subscriptions.create`) |
-| [event-subscriber](examples/event-subscriber) | Realtime subscription and pull with a durable cursor; resume after drops and restarts; gap handling | `openvibe-sdk/realtime`, `/events` |
-| [media-uploader](examples/media-uploader) | Upload into your project's Media namespace with an app token; content-addressed, safe retries | `openvibe-sdk/media` |
-| [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 |
-| [tool-job](examples/tool-job) | An OpenVibe.Tools job submitted once (idempotency key), reattached after a restart, SSE resumed with `Last-Event-ID` | Tools `/api/v1/jobs`, `openvibe-sdk/core` |
+| [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`) |
+| [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) |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
| [mod-manifest](examples/mod-manifest) | A `mods.mod-manifest@1` validated against the schema, the capability catalog and compatibility ranges | `openvibe-contracts`, `openvibe-sdk/registry` |
-| [oauth-app](examples/oauth-app) | Authorization code + PKCE with the exchange on the server; offline token verification; session hygiene | `openvibe-sdk/auth` |
Each example folder has a README, a `.env.example`, its own `package.json` and a
Each example folder has a README, a `.env.example`, its own `package.json` and a
`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
`test/smoke.test.js`. Configuration is environment variables only (`OV_CLIENT_ID`,
-`OV_CLIENT_SECRET`, `OV_NETWORK_URL`, …); secrets are never logged or sent to a browser.
+`OV_CLIENT_SECRET`, `OV_PROJECT_ID`, …); secrets are never logged or sent to a browser.
+
+## End-to-end walkthrough
+
+From a new account to the examples running against the platform, with public surfaces only.
+Nothing here needs staff: a new project is `sandbox`, and a sandbox app's owner gets the sandbox
+capabilities approved at once.
+
+**1. Create an account** at <https://openvibe.network>.
+
+**2. Create a project, a sandbox app and its grants.** Either:
+
+- **in the developer portal, <https://openvibe.codes>:** sign in, create a project, add a
+ **confidential** app in the `sandbox` environment with the redirect URI
+ `http://localhost:3009/callback` (for [oauth-app](examples/oauth-app)), copy the client secret
+ (it is shown **once**), and request the grants `media.object.upload`, `media.object.read`,
+ `events.app.publish`, `events.app.read`, `events.app.subscribe`, `tools.job.create`,
+ `tools.job.read`; or
+- **with the projects API** (`openvibe-sdk/projects`): `npm run new-app` does exactly the above
+ and writes `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and `OV_PROJECT_ID` into `./.env` (mode 0600)
+ without printing the secret. It signs in with your username and password (not echoed) or uses
+ `OV_USER_TOKEN`. The same in code:
+
+ ```js
+ const { createClient } = require('openvibe-sdk/core');
+ const { createProjectsClient } = require('openvibe-sdk/projects');
+ const projects = createProjectsClient(createClient({ token: userAccessToken })); // a Network user token
+ const project = await projects.create({ name: 'My first OpenVibe app' }); // sandbox, you are the owner
+ const app = await projects.apps.create(project.id, { name: 'server', environment: 'sandbox', type: 'confidential' });
+ app.credential.client_secret; // shown ONCE: store it now
+ await projects.grants.request(project.id, app.id, 'media.object.upload'); // approved at once for the owner
+ ```
+
+For [browser-app](examples/browser-app) add a second, **public** app with the redirect URI
+`http://localhost:3001/callback` and `media.object.read` (`npm run new-app -- --public --project
+prj_…`). A public app has no secret.
+
+**3. Check it works end to end:**
+
+```bash
+npm ci
+npm run e2e # reads ./.env: OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID
+```
+
+It uploads a small file to your Media tenant, reads it back and deletes it; publishes
+`app.<project_key>.e2e.ran` and pulls your topic until it finds it; converts a 1x1 PNG to WebP on
+`img.openvibe.tools`, following the job's events. It prints each step (never the secret, a token
+or a URL signature) and exits `1` if a step failed, `2` if a variable is missing.
+
+**4. Run the examples.** Each reads `.env` in its own folder (`cp .env.example .env`), or point it
+at the root one: `node --env-file=../../.env upload.js ./logo.png`.
+
+| Example | App | Grants | Environment variables |
+|---|---|---|---|
+| [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 |
+| [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` |
+| [browser-app](examples/browser-app) | public, redirect `http://localhost:3001/callback` | `media.object.read` | `OV_CLIENT_ID`, `OV_AUDIENCE`, `OV_SCOPE` |
+| [chat-bot](examples/chat-bot) | none: a Live API token (`hbt_…`) of a dedicated bot account | Live token scope `chat` | `OV_CHAT_TOKEN`, one of `OV_CHAT_CHANNEL_USER_ID` / `OV_CHAT_STREAM_ID` |
+| [mod-manifest](examples/mod-manifest) | none | none | `OV_NETWORK_URL` for `--registry` |
+
+Rotate a secret in the portal or with `projects.credentials.rotate()`, revoke the app with
+`projects.apps.revoke()`. Issued tokens end within their 5-minute lifetime.
+
+## How the platform works for a sandbox app
+
+Verified in production on 2026-09-23 with a brand-new account, using the public endpoints only:
+
+- **Projects.** A new project is `sandbox`. Its owner's grant requests for the sandbox allowance
+ are approved at once: `media.object.upload|read`, `events.app.publish|read|subscribe`,
+ `tools.job.create|read|cancel`.
+- **Tokens.** `POST https://openvibe.network/oauth/token` with `grant_type=client_credentials`
+ gives a confidential sandbox app a 5-minute token for `openvibe.media`, `openvibe.events` or
+ `openvibe.tools`, with `project_id`, `env: sandbox`, `ns: [project_id]` and the approved
+ capabilities for that audience.
+- **Media.** The tenant is the project id: `/api/v1/<prj_…>/files`. Sandbox objects are stored in
+ a separate sandbox tenant (`<prj_…>-sandbox`, not addressable by path) and never served
+ publicly: signed URLs only.
+- **Events.** App events are `app.<project_key>.<name…>` (`project_key` = `p` + the lowercased
+ project ULID) with `source: app-<lowercased app ULID>`. Read them with
+ `GET /api/v1/events?topic=app.<project_key>.*`; subscribe with public https endpoints only.
+ Realtime (SSE) never streams app events.
+- **Tools.** `/api/v1/jobs` accepts sandbox app tokens.
+- **Discovery.** `/.well-known/openvibe` and `/api/v1/registry/*` answer any origin (CORS), so a
+ browser page reads them directly.
+
+## What still does not work
+
+- **No consent screen.** Network's account chooser names the app but does not list the
+ capabilities it asks for. Apps get no refresh tokens and learn only the person's `usr_…` id.
+- **`/oauth/token` is not CORS-open**, like every Network route outside discovery and the
+ registry, so a browser app exchanges its code on its own server
+ ([browser-app](examples/browser-app) does).
+- **Chat has no app principal.** `chat.message.send` is `first-party`; the
+ [chat-bot](examples/chat-bot) signs in with a Live API token (`hbt_…`, scope `chat`) of a
+ dedicated bot account, not with a developer app.
+- **Production apps need staff**: switching a project to `sandbox+production` and setting its
+ allowance.
+- **Publishing a mod** into Games is not public (`games.mod.manage` is `first-party`).
## Running the tests
## Running the tests
@@ -44,11 +145,13 @@ cd examples/tool-job && npm test
@@ -44,11 +145,13 @@ cd examples/tool-job && npm test
How the tests stay offline:
How the tests stay offline:
-- Network, Events and Media are `createMockPlatform()` from `openvibe-sdk/testing`: an in-process
- fake at the real public origins, with real RS256 tokens and the same audience, capability and
- namespace checks.
-- Chat and Tools have no SDK mock, so their examples carry a small local mock in their `test/`
- folder (`mock-chat-server.js`, `mock-tools-server.js`) that plays the documented protocol.
+- Network, Events, Media and Tools jobs are `createMockPlatform()` from `openvibe-sdk/testing`: an
+ in-process fake at the real public origins, with real RS256 tokens, developer apps and projects,
+ sandbox refusal unless a service opts in, `/oauth/authorize` with auto-consent, retention gaps
+ (`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).
- 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.
@@ -59,116 +162,15 @@ Repository checks (`test/`):
@@ -59,116 +162,15 @@ 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.2.2 tag, no `file:` links.
-
-## End-to-end walkthrough
-
-From a new account to running the examples, with public surfaces only. OpenVibe.Codes (the
-developer portal) will be the UI for these steps; it has not launched, so this uses the
-developer-projects API on OpenVibe.Network directly. Every call is documented in Network's
-[developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md) doc.
-
-**1. Create an account** at <https://openvibe.network> (or `POST /api/auth/register`).
-
-**2. Get a user access token** for the projects API (it reads no cookies). This reads your
-password without echoing it or putting it on a command line:
-
-```bash
-NET=https://openvibe.network
-read -r -p 'username: ' OV_USER; read -r -s -p 'password: ' OV_PASS; echo; export OV_USER OV_PASS
-TOKEN=$(node -e 'process.stdout.write(JSON.stringify({ username: process.env.OV_USER, password: process.env.OV_PASS }))' \
- | curl -s "$NET/api/auth/login" -H 'Content-Type: application/json' --data-binary @- \
- | node -pe 'JSON.parse(require("fs").readFileSync(0)).token')
-unset OV_PASS
-auth=(-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json')
-```
-
-**3. See what can be granted** (only `public`, `active` capabilities; `first-party` and `internal` never are):
-
-```bash
-curl -s "$NET/api/v1/projects/catalog" "${auth[@]}"
-```
-
-**4. Create a project.** You are its owner. A new project is `sandbox` only.
-
-```bash
-PRJ=$(curl -s -X POST "$NET/api/v1/projects" "${auth[@]}" -d '{"name":"My first OpenVibe app"}' | node -pe 'JSON.parse(require("fs").readFileSync(0)).id')
-```
-
-**5. Create a sandbox app.** A confidential app gets a client secret, shown **once**; this writes
-it straight into an example's `.env` instead of the terminal:
-
-```bash
-cd examples/node-server-app
-curl -s -X POST "$NET/api/v1/projects/$PRJ/apps" "${auth[@]}" \
- -d '{"name":"examples","environment":"sandbox","type":"confidential","redirect_uris":["http://localhost:3009/callback"]}' \
- | node -e 'const a = JSON.parse(require("fs").readFileSync(0)); require("fs").writeFileSync(".env", `OV_CLIENT_ID=${a.client_id}\nOV_CLIENT_SECRET=${a.credential.client_secret}\n`, { mode: 0o600 }); console.log(a.client_id)'
-```
-
-For [browser-app](examples/browser-app) create a second app with `"type":"public"` and the
-redirect URI `http://localhost:3001/callback` (no secret).
-
-**6. Request grants.** One call per capability; the audience is the owning service
-(`openvibe.media` for `media.*`). As the project owner, a request inside the project's allowance is
-approved at once; a developer's request waits for an owner or admin
-(`POST …/grants/<capability>/approve`).
-
-```bash
-APP=app_... # the client id printed above
-for c in media.object.upload media.object.read tools.job.create tools.job.read; do
- curl -s -X POST "$NET/api/v1/projects/$PRJ/apps/$APP/grants" "${auth[@]}" -d "{\"capability\":\"$c\"}"; echo
-done
-```
-
-**7. Run the examples.** Start with [node-server-app](examples/node-server-app): its `/status`
-shows the services the registry knows and, per audience, whether Network will give your app a
-token and which capabilities it carries. Then follow each example's "Run it against the real
-platform" section.
-
-Rotate a secret with `POST …/apps/$APP/credentials/rotate`, revoke one with
-`POST …/credentials/<id>/revoke`, revoke the app with `DELETE …/apps/$APP`. Issued tokens end
-within their 5-minute lifetime.
-
-## What works against the real platform today
-
-These are the platform-side conditions as of this release, read from the services' code and docs.
-Nothing here was run against production.
-
-| Example | Today |
-|---|---|
-| mod-manifest | Works: validation is offline; `--registry` reads the public registry. Publishing a mod is not public yet. |
-| node-server-app | Discovery and registry work for anyone. App tokens: see "Tokens" below. |
-| chat-bot | Should work on openvibe.live chat with a Live API token (`hbt_…`, scope `chat`) from a dedicated bot account. Not a developer-app credential: Chat has no app principal, and `chat.message.send` is `first-party`. |
-| tool-job | Tools accepts app tokens on `/api/v1/jobs`. Needs a token for `openvibe.tools` (see "Tokens"). |
-| media-uploader | Needs a token for `openvibe.media` (see "Tokens") **and** a Media tenant named after your project id, created by the Media operators. |
-| oauth-app, browser-app | Sign-in needs a token for the requested audience (see "Tokens"); production apps need an https redirect URI. The browser cannot read the registry cross-origin yet (Network's CORS allow-list runs first), so browser-app falls back to its own server and says so. |
-| event-subscriber | Realtime works anonymously for `public` events. Pull cannot work: `events.event.read` is `internal` and the pull API is not on Events' public host. |
-| webhook-consumer | Cannot be demonstrated: `events.subscription.manage` is `internal`, the subscription API is not on Events' public host, and deliveries only go to allow-listed hosts. The consumer itself is ready. |
-
-**Tokens.** Two defaults on Network block every app token until staff change them:
-
-- `DEV_SANDBOX_AUDIENCES` is empty, so a **sandbox** app gets `400 invalid_target` for every
- audience, and receivers built on openvibe-contracts ≥ 0.26 also refuse `env: sandbox` tokens
- unless they opt in. A **production** app avoids this, but only after staff switch the project to
- `sandbox+production`.
-- `DEV_DEFAULT_ALLOWANCE` is empty, so a grant request stays unapproved (`403 grant.beyond_allowance`
- on approval) until staff put the capability in your project's allowance.
-
-Capabilities a developer app can hold today (`public` + `active` in openvibe-contracts v0.26.0):
-`media.object.upload`, `media.object.read`, `tools.job.create`, `tools.job.read`,
-`tools.job.cancel`, `games.mod.read`, `games.world.announce`, `games.prop.place`. No `events.*`,
-`chat.*` or identity capability is grantable.
-
-So the Wave 20 exit criterion ("a new external developer goes from account creation to a working
-Media, event and capability integration using only public documentation, the SDK and scoped
-credentials") is **not met on the live platform yet**: Media needs staff actions (sandbox
-audience or production policy, allowance, Media tenant), and the event integration has no public
-path except anonymous realtime.
+ code reads, MIT license, `openvibe-sdk` pinned to the v0.3.0 tag, no `file:` links; and that
+ `npm run e2e` is outside CI and `npm test` and refuses to start without its variables.
## Layout
## Layout
```
```
examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js
examples/<name>/ one example: README.md, .env.example, package.json, code, test/smoke.test.js
+scripts/e2e.js npm run e2e: three examples against the real platform (on demand only)
+scripts/new-sandbox-app.js npm run new-app: project + sandbox app + grants via openvibe-sdk/projects
test/run.js runs every smoke test and the repository checks (npm test)
test/run.js runs every smoke test and the repository checks (npm test)
test/public-surface.test.js, test/structure.test.js
test/public-surface.test.js, test/structure.test.js
.github/workflows/ci.yml Node 22.22.1: npm ci && npm test
.github/workflows/ci.yml Node 22.22.1: npm ci && npm test
@@ -178,22 +180,29 @@ test/public-surface.test.js, test/structure.test.js
@@ -178,22 +180,29 @@ test/public-surface.test.js, test/structure.test.js
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
- the nine charter examples: vanilla browser app, Node server app, webhook consumer, event
subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
subscriber, Media uploader, Chat bot, tool/job example, mod manifest example, OAuth app example
+- the on-demand end-to-end run (`npm run e2e`) and the sandbox-app setup script
## Does not own
## Does not own
-- any platform service, the SDK (OpenVibe.SDK) or the contracts (OpenVibe.Contracts)
+- any platform service, the SDK (OpenVibe.SDK), the contracts (OpenVibe.Contracts) or the
+ developer portal (OpenVibe.Codes)
## Depends on
## Depends on
-- OpenVibe.SDK (v0.2.2), OpenVibe.Contracts (v0.26.0, mod manifest only)
-- at run time against the real platform: Network (tokens, registry, developer projects), Events
- (realtime), Media, Tools, Chat (on openvibe.live)
+- OpenVibe.SDK (v0.3.0), OpenVibe.Contracts (v0.26.0, mod manifest only)
+- at run time against the real platform: Network (tokens, registry, developer projects), Events,
+ Media, Tools, Chat (on openvibe.live)
## Acceptance
## Acceptance
-- CI runs every example: **yes**, against the SDK's mock platform and local mocks (no network).
- Running them in CI against an integration environment needs sandbox-enabled audiences and a
- CI project with grants; that environment does not exist yet.
+- 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
+ credentials; there is no CI project with credentials.
+- A new developer goes from account creation to a working Media, event and capability
+ integration using only public documentation, the SDK and scoped credentials: the platform path
+ was verified in production on 2026-09-23 (see
+ [How the platform works](#how-the-platform-works-for-a-sandbox-app)); `npm run e2e` walks it with
+ these examples.
- No example needs a loopback-only internal key or first-party database access: **yes**, enforced
- No example needs a loopback-only internal key or first-party database access: **yes**, enforced
by `test/public-surface.test.js`.
by `test/public-surface.test.js`.
@@ -3,10 +3,19 @@
@@ -3,10 +3,19 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W20",
"wave": "W20",
- "version": "0.1.0",
- "sdk": "openvibe-sdk v0.2.2",
+ "version": "0.2.0",
+ "sdk": "openvibe-sdk v0.3.0",
"contracts": "openvibe-contracts v0.26.0 (mod-manifest example)",
"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 and local mocks; none runs against the live platform",
+ "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",
+ "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": [
+ "no consent screen; no refresh tokens or identity for apps",
+ "/oauth/token is not CORS-open: browser apps exchange codes server-side",
+ "chat.message.send is first-party: the chat bot uses a Live hbt_ token",
+ "production apps and allowances need staff",
+ "mod publishing is first-party"
+ ],
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1, §18.9; roadmap Wave 20, §30, ADR-014",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1, §18.9; roadmap Wave 20, §30, ADR-014",
- "updated": "2026-09-22"
+ "updated": "2026-09-23"
}
}
@@ -8,3 +8,6 @@ OV_SCOPE=media.object.read
@@ -8,3 +8,6 @@ OV_SCOPE=media.object.read
OV_REDIRECT_URI=http://localhost:3001/callback
OV_REDIRECT_URI=http://localhost:3001/callback
# Defaults to the redirect URI's port.
# Defaults to the redirect URI's port.
OV_PORT=3001
OV_PORT=3001
+# Optional: 1 serves /api/registry/services from this server as a fallback for the page.
+# Not needed on the live platform: the registry is CORS-open to any origin.
+OV_REGISTRY_PROXY=
@@ -1,7 +1,8 @@
@@ -1,7 +1,8 @@
# Vanilla browser app
# Vanilla browser app
-A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE
-(`openvibe-sdk/auth/browser`) and reads a public API (the ecosystem registry) straight from the browser.
+A plain HTML page, no framework and no build step, that signs in with OpenVibe using PKCE and
+reads a public API (the ecosystem registry) straight from the browser. The page imports the SDK's
+browser bundle, `openvibe-sdk/browser/openvibe-sdk.mjs`, as an ES module.
```bash
```bash
node --env-file=.env server.js # open http://localhost:3001
node --env-file=.env server.js # open http://localhost:3001
@@ -9,29 +10,31 @@ node --env-file=.env server.js # open http://localhost:3001
@@ -9,29 +10,31 @@ node --env-file=.env server.js # open http://localhost:3001
## What it proves
## What it proves
-- **PKCE in the browser.** The page calls `startAuthorization()`; the verifier and `state` live in
- `sessionStorage` until Network redirects back to `/callback`, where `readCallback()` checks
- `state` and the address bar is cleaned. The verifier is used once.
+- **The SDK in a page without a bundler.** `public/app.js` is a module that imports
+ `createClient`, `auth` and `registry` from `/vendor/openvibe-sdk.mjs`, which the server serves
+ unchanged from `node_modules/openvibe-sdk/browser/`. The bundle holds only browser-safe code (no
+ service tokens, no JWT verification, no secrets); the SDK tests that, and so does this example.
+- **A public API from the page.** The page reads `https://openvibe.network/api/v1/registry/services`
+ itself, cross-origin, with no token and no cookie: Network answers the registry and
+ `/.well-known/openvibe` to any origin (CORS, preflight included). The DOM is built with
+ `textContent`, never `innerHTML`, under a strict CSP.
+- **PKCE in the browser.** The page calls `auth.startAuthorization()` with the app's `audience`
+ and the capability ids it asks for; the verifier and `state` live in `sessionStorage` until
+ Network redirects back to `/callback`, where `readCallback()` checks `state` and the address bar
+ is cleaned. The verifier is used once.
- **A public app has no secret.** The app is registered as `public`. Its tiny server exchanges
- **A public app has no secret.** The app is registered as `public`. Its tiny server exchanges
- code + verifier at Network's token endpoint (no secret; `audience` is sent) and keeps the
- resulting token in an HttpOnly session. It holds no secrets at all. The exchange endpoint only
- accepts same-origin JSON requests (`Origin` check, `415` otherwise), so another site cannot drive it.
-- **A public API from the page.** `openvibe-sdk/registry` lists the network's services with no
- token and no cookie. The DOM is built with `textContent`, never `innerHTML`, under a strict CSP.
-- **The SDK in a page without a bundler.** `openvibe-sdk` is CommonJS, so `lib/sdk-bundle.js`
- serves its browser-safe modules (`core`, `auth/browser`, `registry`) as one script that sets
- `window.OpenVibeSDK`. It refuses to include anything that is not a relative SDK module, and the
- test checks the served script contains no server-only code. With a bundler, import
- `openvibe-sdk/auth/browser` directly instead.
+ code + verifier with `exchangeCode()` (no secret; the audience is sent), verifies the app token
+ with `verifyAppToken()` and keeps it in an HttpOnly session. It holds no secrets at all. The
+ exchange endpoint only accepts same-origin JSON requests (`Origin` check, `415` otherwise), so
+ another site cannot drive it.
## Files
## Files
| File | What |
| File | What |
|---|---|
|---|---|
-| `public/index.html`, `public/app.js` | the page |
-| `server.js` | static files, `/sdk/openvibe-sdk.js`, `/config.json`, `/auth/exchange`, `/api/session`, `/api/registry/services`, `/auth/logout` |
-| `lib/sdk-bundle.js` | the serve-time wrapper for the SDK's browser modules |
-| `test/smoke.test.js` | runs `public/app.js` and the served bundle in a small fake browser (vm) against the server and the SDK mock |
+| `public/index.html`, `public/app.js` | the page and its module script |
+| `server.js` | static files, `/vendor/openvibe-sdk.mjs`, `/config.json`, `/auth/exchange`, `/api/session`, `/auth/logout`, and the optional `/api/registry/services` |
+| `test/smoke.test.js` | runs `public/app.js` and the SDK bundle as ES modules in a small fake browser (vm) against the server and the SDK mock |
## Run the smoke test
## Run the smoke test
@@ -39,27 +42,32 @@ node --env-file=.env server.js # open http://localhost:3001
@@ -39,27 +42,32 @@ node --env-file=.env server.js # open http://localhost:3001
npm test
npm test
```
```
-There is no real browser in CI: the test gives the page's own script a minimal DOM,
-`sessionStorage`, `location`, a cookie jar and `fetch`, clicks "Sign in", follows the redirect with
-a code from `platform.authorize()` (which stands in for Network's consent page and verifies PKCE),
-and checks the signed-in state, a forged callback, the registry read and its fallback.
+There is no real browser in CI: the test links the page's module script to the served bundle with
+`vm.SourceTextModule` (Node 22 runs it with `--experimental-vm-modules`; the test re-runs itself
+with that flag), gives it a minimal DOM, `sessionStorage`, `location`, a cookie jar and `fetch`,
+clicks "Sign in", follows Network's authorize URL on the SDK mock (its account chooser redirects
+back with a code), and checks the signed-in state, a forged callback, the registry read and the
+optional server fallback.
## Run it against the real platform
## Run it against the real platform
-1. Create a project and a **public** app with the redirect URI `http://localhost:3001/callback`
- ([walkthrough](../../README.md#end-to-end-walkthrough)); request `media.object.read` (or any
- grant for the audience you put in `OV_AUDIENCE`).
+1. Create a project and a **public** sandbox app with the redirect URI
+ `http://localhost:3001/callback` and the grant `media.object.read`
+ ([walkthrough](../../README.md#end-to-end-walkthrough); `npm run new-app -- --public
+ --project prj_…` at the repository root does exactly that). Any grant works if you change
+ `OV_AUDIENCE` / `OV_SCOPE` to match it.
2. `cp .env.example .env`, set `OV_CLIENT_ID`.
2. `cp .env.example .env`, set `OV_CLIENT_ID`.
-3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a project member.
+3. `node --env-file=.env server.js`, open `http://localhost:3001`, sign in as a member of the
+ project (a sandbox app can only be authorized by its project's members).
-What does not work yet on the real platform:
+Things to know:
-- **The registry refuses third-party browser origins.** Network answers the registry with
- `Access-Control-Allow-Origin: *`, but its site-wide CORS allow-list rejects unknown origins
- first, and SDK calls carry `traceparent` / `X-OpenVibe-Request-Id` headers, which need a
- preflight. The page therefore falls back to reading the same public registry through its own
- server (`/api/registry/services`) and says so on the page.
-- **Sign-in** needs Network to mint a token for `OV_AUDIENCE`: a sandbox app gets `invalid_target`
- until staff list that audience in `DEV_SANDBOX_AUDIENCES`; a production app needs an https
- redirect URI. Sign-in always asks for a capability: there is no identity-only sign-in for
- apps, and an app learns only the person's `usr_…` id (profile lookups are `first-party`).
+- **The token exchange stays on a server.** Network's `/oauth/*` routes are not CORS-open to
+ third-party origins, so a browser cannot call the token endpoint itself; this is also where
+ the token is best kept (an HttpOnly session, not page scripts).
+- **The server-side registry read is optional.** With `OV_REGISTRY_PROXY=1` the server also
+ answers `/api/registry/services`, and the page falls back to it when the browser cannot reach
+ Network. It is off by default because the registry is readable from the browser.
+- **No consent screen** yet (Network's account chooser does not list the capabilities), and an app
+ learns only the person's `usr_…` id: there is no identity-only sign-in for apps, and profile
+ lookups are `first-party`.
deleted file mode 100644
deleted file mode 100644
@@ -1,62 +0,0 @@
@@ -1,62 +0,0 @@
-'use strict';
-/**
- * Serve openvibe-sdk's browser-safe modules to a page with no build step.
- *
- * openvibe-sdk 0.2.2 is CommonJS, and its ESM entry points import CommonJS files, so a browser
- * cannot load it directly without a bundler. This file wraps the modules a sign-in page needs
- * (core, auth/browser, registry) and everything they require into one script that sets
- * `window.OpenVibeSDK = { core, auth, registry }`. It refuses anything that is not a relative
- * require inside the SDK (no `node:` modules, no packages), so server-only code can never slip in.
- *
- * If you use a bundler (esbuild, Vite, webpack), import 'openvibe-sdk/auth/browser' etc. instead.
- */
-const fs = require('node:fs');
-const path = require('node:path');
-const crypto = require('node:crypto');
-
-const ENTRIES = { core: 'src/core/index.js', auth: 'src/auth/browser.js', registry: 'src/registry.js' };
-const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;
-
-function buildSdkBundle(root = path.dirname(require.resolve('openvibe-sdk/package.json'))) {
- const version = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
- const modules = new Map();
- const visit = (id) => {
- if (modules.has(id)) return;
- const source = fs.readFileSync(path.join(root, id), 'utf8');
- modules.set(id, null);
- const deps = {};
- for (const [, spec] of source.matchAll(REQUIRE_RE)) {
- if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": not browser-safe, refusing to bundle`);
- let dep = path.posix.normalize(path.posix.join(path.posix.dirname(id), spec));
- if (!dep.endsWith('.js')) dep += '.js';
- deps[spec] = dep;
- visit(dep);
- }
- if (/process\.env|client_secret/.test(source)) throw new Error(`${id} references server-only material`);
- modules.set(id, { source, deps });
- };
- for (const id of Object.values(ENTRIES)) visit(id);
-
- const defs = [...modules].map(([id, { source, deps }]) => ` ${JSON.stringify(id)}: [${JSON.stringify(deps)}, function (module, exports, require) {\n${source}\n}]`).join(',\n');
- const code = `/* openvibe-sdk ${version}: core, auth/browser, registry (MIT). Generated at serve time by examples/browser-app/lib/sdk-bundle.js. */
-(function (global) {
- 'use strict';
- var defs = {
-${defs}
- };
- var cache = {};
- function load(id) {
- if (cache[id]) return cache[id].exports;
- var def = defs[id];
- if (!def) throw new Error('openvibe-sdk bundle: no module ' + id);
- var module = cache[id] = { exports: {} };
- def[1](module, module.exports, function (spec) { return load(def[0][spec]); });
- return module.exports;
- }
- global.OpenVibeSDK = { version: ${JSON.stringify(version)}, ${Object.entries(ENTRIES).map(([k, id]) => `${k}: load(${JSON.stringify(id)})`).join(', ')} };
-})(typeof window !== 'undefined' ? window : globalThis);
-`;
- return { code, version, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"`, modules: [...modules.keys()] };
-}
-
-module.exports = { buildSdkBundle, ENTRIES };
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-browser-app",
"name": "openvibe-example-browser-app",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Vanilla browser app: sign in with OpenVibe.Network (authorization code + PKCE) and call a public API from the browser.",
"description": "Vanilla browser app: sign in with OpenVibe.Network (authorization code + PKCE) and call a public API from the browser.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
@@ -1,98 +1,102 @@
@@ -1,98 +1,102 @@
/* OpenVibe browser app example: sign in with PKCE, then read the public registry.
/* OpenVibe browser app example: sign in with PKCE, then read the public registry.
- * Plain browser JavaScript, no framework and no build step. window.OpenVibeSDK comes from
- * /sdk/openvibe-sdk.js (openvibe-sdk's core, auth/browser and registry modules). DOM nodes are
- * built with textContent, never innerHTML. */
-(function () {
- 'use strict';
- var sdk = window.OpenVibeSDK;
- var PKCE_KEY = 'ov_example_pkce';
- var $ = function (id) { return document.getElementById(id); };
+ * Plain browser JavaScript, no framework and no build step: an ES module that imports
+ * openvibe-sdk's browser bundle (browser/openvibe-sdk.mjs, served by this app's server).
+ * DOM nodes are built with textContent, never innerHTML. */
+import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';
- function showError(err) {
- var el = $('auth-error');
- el.textContent = 'Sign-in failed: ' + ((err && (err.code || err.message)) || 'unknown error');
- el.hidden = false;
- }
+const PKCE_KEY = 'ov_example_pkce';
+const $ = (id) => document.getElementById(id);
- function getJson(url, init) {
- return fetch(url, Object.assign({ credentials: 'same-origin', headers: { Accept: 'application/json' } }, init || {}))
- .then(function (res) { return res.json().then(function (body) { return { status: res.status, body: body }; }); });
- }
+function showError(err) {
+ const el = $('auth-error');
+ el.textContent = `Sign-in failed: ${(err && (err.code || err.message)) || 'unknown error'}`;
+ el.hidden = false;
+}
- /** Back from Network on /callback: check state, hand code + verifier to this app's server. */
- function finishSignIn() {
- var href = location.href;
- var saved = null;
- try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch (e) { saved = null; }
- sessionStorage.removeItem(PKCE_KEY);
- history.replaceState(null, '', '/');
- var callback;
- try {
- if (!saved) throw { code: 'oauth.no_pending_sign_in' };
- callback = sdk.auth.readCallback(href, { expectedState: saved.state });
- } catch (err) { showError(err); return Promise.resolve(); }
- return getJson('/auth/exchange', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
- body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),
- }).then(function (r) { if (r.status !== 200) showError({ code: r.body.error }); });
- }
+async function getJson(url, init = {}) {
+ const res = await fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' }, ...init });
+ return { status: res.status, body: await res.json() };
+}
- function startSignIn(config) {
- return sdk.auth.startAuthorization({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope })
- .then(function (a) {
- sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));
- location.assign(a.url);
- });
- }
+/** Back from Network on /callback: check state, hand code + verifier to this app's server. */
+async function finishSignIn() {
+ const href = location.href;
+ let saved = null;
+ try { saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); } catch { saved = null; }
+ sessionStorage.removeItem(PKCE_KEY);
+ history.replaceState(null, '', '/');
+ let callback;
+ try {
+ if (!saved) throw Object.assign(new Error('no sign-in was started here'), { code: 'oauth.no_pending_sign_in' });
+ callback = auth.readCallback(href, { expectedState: saved.state });
+ } catch (err) { showError(err); return; }
+ const r = await getJson('/auth/exchange', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ code: callback.code, codeVerifier: saved.codeVerifier }),
+ });
+ if (r.status !== 200) showError({ code: r.body.error });
+}
- function showSession() {
- return getJson('/api/session').then(function (r) {
- var signedIn = r.status === 200;
- $('who').textContent = signedIn
- ? 'Signed in with OpenVibe as ' + r.body.subject + '. This app may: ' + (r.body.capabilities.join(', ') || 'nothing') + '.'
- : 'Not signed in.';
- $('sign-in').hidden = signedIn;
- $('sign-out').hidden = !signedIn;
- });
- }
+/** An app asks for capability ids of ONE audience; the code is bound to that audience. */
+async function startSignIn(config) {
+ const a = await auth.startAuthorization({
+ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,
+ });
+ sessionStorage.setItem(PKCE_KEY, JSON.stringify({ state: a.state, codeVerifier: a.codeVerifier }));
+ location.assign(a.url);
+}
- function renderServices(services, via) {
- var list = $('services');
- list.textContent = '';
- services.forEach(function (s) {
- var li = document.createElement('li');
- var name = document.createElement('code');
- name.textContent = s.id;
- li.appendChild(name);
- li.appendChild(document.createTextNode(' ' + s.status + (s.publicOrigin ? ' - ' + s.publicOrigin : '')));
- list.appendChild(li);
- });
- $('registry-status').textContent = services.length + ' services, ' + via + '.';
+async function showSession() {
+ const r = await getJson('/api/session');
+ const signedIn = r.status === 200;
+ $('who').textContent = signedIn
+ ? `Signed in with OpenVibe as ${r.body.subject}. This app may: ${r.body.capabilities.join(', ') || 'nothing'}.`
+ : 'Not signed in.';
+ $('sign-in').hidden = signedIn;
+ $('sign-out').hidden = !signedIn;
+}
+
+function renderServices(services, via) {
+ const list = $('services');
+ list.textContent = '';
+ for (const s of services) {
+ const li = document.createElement('li');
+ const name = document.createElement('code');
+ name.textContent = s.id;
+ li.appendChild(name);
+ li.appendChild(document.createTextNode(` ${s.status}${s.publicOrigin ? ` - ${s.publicOrigin}` : ''}`));
+ list.appendChild(li);
}
}
+ $('registry-status').textContent = `${services.length} services, ${via}.`;
+}
- /** A public API straight from the browser: no token, no cookie. */
- function loadRegistry(config) {
- var client = sdk.core.createClient({ network: config.network, retries: 1 });
- return sdk.registry.createRegistryClient(client).services()
- .then(function (services) { renderServices(services, 'read by this page from ' + config.network); })
- .catch(function () {
- // The browser refused the cross-origin call (CORS). Read it through this app's server.
- return getJson('/api/registry/services').then(function (r) {
- if (r.status !== 200) throw new Error(r.body.error);
- renderServices(r.body.services, 'read through this app\'s server (the registry does not allow this origin from a browser yet)');
- });
- })
- .catch(function (err) { $('registry-status').textContent = 'Could not read the registry: ' + (err.code || err.message); });
+/**
+ * A public API straight from the browser: no token, no cookie. Network answers the registry to
+ * any origin (CORS, preflight included). The server-side read is an opt-in fallback
+ * (OV_REGISTRY_PROXY=1) for pages that cannot reach Network from the browser.
+ */
+async function loadRegistry(config) {
+ try {
+ const client = createClient({ network: config.network, retries: 1 });
+ renderServices(await registry.createRegistryClient(client).services(), `read by this page from ${config.network}`);
+ } catch (err) {
+ if (!config.registryProxy) {
+ $('registry-status').textContent = `Could not read the registry: ${err.code || err.message}`;
+ return;
+ }
+ const r = await getJson('/api/registry/services');
+ if (r.status !== 200) { $('registry-status').textContent = `Could not read the registry: ${r.body.error}`; return; }
+ renderServices(r.body.services, 'read through this app\'s server (OV_REGISTRY_PROXY)');
}
}
+}
- getJson('/config.json').then(function (r) {
- var config = r.body;
- $('sign-in').addEventListener('click', function () { startSignIn(config).catch(showError); });
- $('sign-out').addEventListener('click', function () {
- getJson('/auth/logout', { method: 'POST' }).then(showSession);
- });
- var done = location.pathname === new URL(config.redirectUri).pathname ? finishSignIn() : Promise.resolve();
- return done.then(showSession).then(function () { return loadRegistry(config); });
- });
-}());
+const { body: config } = await getJson('/config.json');
+$('sign-in').addEventListener('click', () => { startSignIn(config).catch(showError); });
+$('sign-out').addEventListener('click', async () => {
+ await getJson('/auth/logout', { method: 'POST' });
+ await showSession();
+});
+if (location.pathname === new URL(config.redirectUri).pathname) await finishSignIn();
+await showSession();
+await loadRegistry(config);
@@ -31,7 +31,6 @@
@@ -31,7 +31,6 @@
</section>
</section>
<noscript><p>This example needs JavaScript: it shows how a browser signs in with the SDK.</p></noscript>
<noscript><p>This example needs JavaScript: it shows how a browser signs in with the SDK.</p></noscript>
- <script src="/sdk/openvibe-sdk.js"></script>
- <script src="/app.js"></script>
+ <script type="module" src="/app.js"></script>
</body>
</body>
</html>
</html>
@@ -1,39 +1,39 @@
@@ -1,39 +1,39 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE
- * (openvibe-sdk/auth/browser in the browser) and calls a public API (the ecosystem registry)
- * straight from the browser.
+ * Vanilla browser app: a static page that signs in with OpenVibe.Network using PKCE and reads a
+ * public API (the ecosystem registry) straight from the browser. No framework, no build step: the
+ * page imports openvibe-sdk's browser bundle (browser/openvibe-sdk.mjs) as an ES module.
*
*
* node --env-file=.env server.js # http://localhost:3001
* node --env-file=.env server.js # http://localhost:3001
*
*
* The app is a PUBLIC developer app: it has no client secret. The browser makes the PKCE pair and
* The app is a PUBLIC developer app: it has no client secret. The browser makes the PKCE pair and
* keeps the verifier in sessionStorage; after the redirect it hands code + verifier to this tiny
* keeps the verifier in sessionStorage; after the redirect it hands code + verifier to this tiny
- * server, which exchanges them at Network's token endpoint (Network does not open its token
- * endpoint to third-party origins, and the resulting token is kept out of page scripts in an
- * HttpOnly session). The server has no secrets at all.
+ * server, which exchanges them at Network's token endpoint with openvibe-sdk exchangeCode() (no
+ * secret; the audience is sent). Network's /oauth/* is not CORS-open to third-party origins, and
+ * the token is better kept out of page scripts anyway: it lives in an HttpOnly session here.
*
*
- * GET /, /callback the page (public/index.html)
- * GET /app.js the page's script
- * GET /sdk/openvibe-sdk.js openvibe-sdk's browser-safe modules as one script (lib/sdk-bundle.js)
- * GET /config.json public settings: network, client id, redirect URI, scope
- * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }
- * GET /api/session who is signed in
- * GET /api/registry/services the same public registry read, server side: the page's fallback
- * while Network does not allow third-party origins on the registry
+ * GET /, /callback the page (public/index.html)
+ * GET /app.js the page's module script
+ * GET /vendor/openvibe-sdk.mjs openvibe-sdk's browser bundle, served from node_modules as is
+ * GET /config.json public settings: network, client id, redirect URI, audience, scope
+ * POST /auth/exchange { code, codeVerifier } -> session cookie, { subject }
+ * GET /api/session who is signed in
* POST /auth/logout
* POST /auth/logout
+ * GET /api/registry/services OPTIONAL (OV_REGISTRY_PROXY=1): the same public registry read,
+ * server side, for pages that cannot call Network from the browser
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const fs = require('node:fs');
const fs = require('node:fs');
const http = require('node:http');
const http = require('node:http');
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 { verifyUserToken } = require('openvibe-sdk/auth');
+const { exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');
const { createRegistryClient } = require('openvibe-sdk/registry');
const { createRegistryClient } = require('openvibe-sdk/registry');
-const { buildSdkBundle } = require('./lib/sdk-bundle');
const SESSION_COOKIE = 'ov_browser_example_sid';
const SESSION_COOKIE = 'ov_browser_example_sid';
const PUBLIC = path.join(__dirname, 'public');
const PUBLIC = path.join(__dirname, 'public');
+const SDK_BUNDLE = require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs');
function loadConfig(env = process.env) {
function loadConfig(env = process.env) {
const missing = ['OV_CLIENT_ID', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);
const missing = ['OV_CLIENT_ID', 'OV_AUDIENCE', 'OV_SCOPE'].filter((k) => !env[k]);
@@ -47,20 +47,19 @@ function loadConfig(env = process.env) {
@@ -47,20 +47,19 @@ function loadConfig(env = process.env) {
redirectUri,
redirectUri,
origin: new URL(redirectUri).origin,
origin: new URL(redirectUri).origin,
port: Number(env.OV_PORT || new URL(redirectUri).port || 3001),
port: Number(env.OV_PORT || new URL(redirectUri).port || 3001),
+ registryProxy: ['1', 'true', 'yes'].includes(String(env.OV_REGISTRY_PROXY || '').toLowerCase()),
};
};
}
}
-/** Network's token endpoint, authorization_code for a public app (no secret; `audience` is required). */
-async function exchangePublicCode(client, { clientId, code, codeVerifier, redirectUri, audience, scope }) {
- return client.json({
- service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,
- urlencoded: { grant_type: 'authorization_code', client_id: clientId, code, code_verifier: codeVerifier, redirect_uri: redirectUri, audience, scope: scope.join(' ') },
- });
+/** The SDK's browser bundle, read once: { code, etag }. */
+function loadSdkBundle() {
+ const code = fs.readFileSync(SDK_BUNDLE);
+ return { code, etag: `"${crypto.createHash('sha256').update(code).digest('base64url').slice(0, 27)}"` };
}
}
function createApp(config, { fetch, log = console } = {}) {
function createApp(config, { fetch, log = console } = {}) {
const client = createClient({ network: config.network, fetch });
const client = createClient({ network: config.network, fetch });
- const bundle = buildSdkBundle();
+ const bundle = loadSdkBundle();
const sessions = new Map(); // sid -> { subject, capabilities, expiresAt }
const sessions = new Map(); // sid -> { subject, capabilities, expiresAt }
const secure = config.redirectUri.startsWith('https://');
const secure = config.redirectUri.startsWith('https://');
const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
const cookie = (sid, maxAge) => `${SESSION_COOKIE}=${sid}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${maxAge}${secure ? '; Secure' : ''}`;
@@ -72,7 +71,10 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -72,7 +71,10 @@ function createApp(config, { fetch, log = console } = {}) {
const s = sessions.get(sidOf(req));
const s = sessions.get(sidOf(req));
return s && s.expiresAt > Date.now() ? s : null;
return s && s.expiresAt > Date.now() ? s : null;
};
};
- const publicConfig = JSON.stringify({ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope });
+ const publicConfig = JSON.stringify({
+ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,
+ registryProxy: config.registryProxy,
+ });
function json(res, status, body, headers = {}) {
function json(res, status, body, headers = {}) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...headers });
@@ -102,13 +104,19 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -102,13 +104,19 @@ function createApp(config, { fetch, log = console } = {}) {
return json(res, 400, { error: 'bad_request' });
return json(res, 400, { error: 'bad_request' });
}
}
try {
try {
- const tokens = await exchangePublicCode(client, { ...config, code, codeVerifier });
+ // A public app: no client secret, so the PKCE verifier is what proves this exchange.
+ const tokens = await exchangeCode({
+ network: config.network, fetch, clientId: config.clientId, code, codeVerifier,
+ redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,
+ });
const d = await client.discover();
const d = await client.discover();
- const claims = await verifyUserToken(tokens.access_token, {
- jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, allowServiceTokens: true, fetch,
+ // acceptSandbox: only a token minted for THIS app is trusted below, so its env is this
+ // app's own environment (a new project's apps are sandbox).
+ const claims = await verifyAppToken(tokens.access_token, {
+ jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience, acceptSandbox: true, fetch,
});
});
// Developer-app tokens name the person in on_behalf_of and must be for this app.
// Developer-app tokens name the person in on_behalf_of and must be for this app.
- const subject = claims.actor_type === 'app' ? (claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null) : claims.subject_id;
+ const subject = claims.sub === `app:${config.clientId}` ? claims.on_behalf_of : null;
if (!subject) return json(res, 400, { error: 'no_subject' });
if (!subject) return json(res, 400, { error: 'no_subject' });
for (const [k, v] of sessions) if (v.expiresAt <= Date.now()) sessions.delete(k); // in-memory store: clean up
for (const [k, v] of sessions) if (v.expiresAt <= Date.now()) sessions.delete(k); // in-memory store: clean up
const sid = crypto.randomBytes(32).toString('base64url');
const sid = crypto.randomBytes(32).toString('base64url');
@@ -126,7 +134,7 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -126,7 +134,7 @@ function createApp(config, { fetch, log = console } = {}) {
try {
try {
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === new URL(config.redirectUri).pathname)) return file(res, 'index.html', 'text/html; charset=utf-8');
if (req.method === 'GET' && (url.pathname === '/' || url.pathname === new URL(config.redirectUri).pathname)) return file(res, 'index.html', 'text/html; charset=utf-8');
if (req.method === 'GET' && url.pathname === '/app.js') return file(res, 'app.js', 'text/javascript; charset=utf-8');
if (req.method === 'GET' && url.pathname === '/app.js') return file(res, 'app.js', 'text/javascript; charset=utf-8');
- if (req.method === 'GET' && url.pathname === '/sdk/openvibe-sdk.js') {
+ if (req.method === 'GET' && url.pathname === '/vendor/openvibe-sdk.mjs') {
if (req.headers['if-none-match'] === bundle.etag) { res.writeHead(304, { ETag: bundle.etag }); return res.end(); }
if (req.headers['if-none-match'] === bundle.etag) { res.writeHead(304, { ETag: bundle.etag }); return res.end(); }
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: bundle.etag });
res.writeHead(200, { 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'no-cache', ETag: bundle.etag });
return res.end(bundle.code);
return res.end(bundle.code);
@@ -140,7 +148,7 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -140,7 +148,7 @@ function createApp(config, { fetch, log = console } = {}) {
const s = current(req);
const s = current(req);
return s ? json(res, 200, { subject: s.subject, capabilities: s.capabilities, expires_at: new Date(s.expiresAt).toISOString() }) : json(res, 401, { error: 'not_signed_in' });
return s ? json(res, 200, { subject: s.subject, capabilities: s.capabilities, expires_at: new Date(s.expiresAt).toISOString() }) : json(res, 401, { error: 'not_signed_in' });
}
}
- if (req.method === 'GET' && url.pathname === '/api/registry/services') {
+ if (config.registryProxy && req.method === 'GET' && url.pathname === '/api/registry/services') {
return json(res, 200, { services: await createRegistryClient(client).services() }, { 'Cache-Control': 'public, max-age=30' });
return json(res, 200, { services: await createRegistryClient(client).services() }, { 'Cache-Control': 'public, max-age=30' });
}
}
if (req.method === 'POST' && url.pathname === '/auth/logout') {
if (req.method === 'POST' && url.pathname === '/auth/logout') {
@@ -164,4 +172,4 @@ if (require.main === module) {
@@ -164,4 +172,4 @@ if (require.main === module) {
server.listen(config.port, () => console.log(`browser-app: ${config.origin} (redirect URI ${config.redirectUri})`));
server.listen(config.port, () => console.log(`browser-app: ${config.origin} (redirect URI ${config.redirectUri})`));
}
}
-module.exports = { loadConfig, createApp, exchangePublicCode };
+module.exports = { loadConfig, createApp };
@@ -1,23 +1,32 @@
@@ -1,23 +1,32 @@
'use strict';
'use strict';
/**
/**
- * Smoke test: the page's own script (public/app.js) and the served SDK bundle run in a small fake
- * browser (a vm context with a minimal DOM, sessionStorage, location, a cookie jar and fetch),
- * against this app's server and the SDK's mock platform. Covers: sign-in button -> authorize URL
- * with PKCE, the callback -> exchange -> signed-in session, the registry read from the page, and
- * the fallback through the app's server when the browser refuses the cross-origin call.
+ * Smoke test: the page's own module script (public/app.js) and the SDK's browser bundle, as this
+ * app's server serves them, run as ES modules in a small fake browser (a vm context with a minimal
+ * DOM, sessionStorage, location, a cookie jar and fetch), against the server and the SDK's mock
+ * platform. Covers: the registry read from the page, sign-in -> Network's authorize URL with PKCE
+ * and the audience, the account chooser's redirect back -> exchange -> signed-in session, a forged
+ * callback, the optional server-side registry fallback, and sign-out.
+ *
+ * ES modules in a vm context need vm.SourceTextModule, which Node 22 has behind
+ * --experimental-vm-modules; this file re-runs itself with that flag.
*/
*/
+const vm = require('node:vm');
+
+if (typeof vm.SourceTextModule !== 'function') {
+ const { spawnSync } = require('node:child_process');
+ const r = spawnSync(process.execPath, ['--experimental-vm-modules', '--disable-warning=ExperimentalWarning', __filename], { stdio: 'inherit' });
+ process.exit(r.status == null ? 1 : r.status);
+}
+
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
-const vm = require('node:vm');
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createApp, loadConfig } = require('../server');
const { createApp, loadConfig } = require('../server');
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'); };
-const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TA';
-
async function waitFor(check, what, ms = 3000) {
async function waitFor(check, what, ms = 3000) {
const until = Date.now() + ms;
const until = Date.now() + ms;
while (Date.now() < until) {
while (Date.now() < until) {
@@ -54,8 +63,9 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
@@ -54,8 +63,9 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
assign(u) { this.assigned = u; },
assign(u) { this.assigned = u; },
};
};
const setUrl = (u) => { const x = new URL(u, origin); location.href = x.href; location.pathname = x.pathname; };
const setUrl = (u) => { const x = new URL(u, origin); location.href = x.href; location.pathname = x.pathname; };
+ const crossOriginCalls = [];
async function browserFetch(input, init = {}) {
async function browserFetch(input, init = {}) {
- const url = new URL(String(input), location.href);
+ const url = new URL(String(input && input.url ? input.url : input), location.href);
if (url.origin === origin) {
if (url.origin === origin) {
const headers = new Headers(init.headers || {});
const headers = new Headers(init.headers || {});
if (jar.cookie) headers.set('cookie', jar.cookie);
if (jar.cookie) headers.set('cookie', jar.cookie);
@@ -65,96 +75,126 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
@@ -65,96 +75,126 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
if (set) jar.cookie = set.split(';')[0].endsWith('=') ? '' : set.split(';')[0];
if (set) jar.cookie = set.split(';')[0].endsWith('=') ? '' : set.split(';')[0];
return res;
return res;
}
}
+ crossOriginCalls.push({ url: url.href, credentials: init.credentials });
if (crossOrigin === 'refuse') throw new TypeError('Failed to fetch'); // what a CORS refusal looks like
if (crossOrigin === 'refuse') throw new TypeError('Failed to fetch'); // what a CORS refusal looks like
return platform.fetch(input, init);
return platform.fetch(input, init);
}
}
const context = vm.createContext({
const context = vm.createContext({
document, location, sessionStorage, fetch: browserFetch,
document, location, sessionStorage, fetch: browserFetch,
history: { replaceState: (_s, _t, u) => setUrl(u) },
history: { replaceState: (_s, _t, u) => setUrl(u) },
- console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController,
- TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto,
+ console, setTimeout, clearTimeout, URL, URLSearchParams, Headers, Request, Response, AbortController, AbortSignal,
+ TextEncoder, TextDecoder, btoa, atob, crypto: globalThis.crypto, Blob, FormData, ReadableStream,
});
});
context.window = context;
context.window = context;
- return { context, el, location, setUrl };
+ return { context, el, location, setUrl, crossOriginCalls };
}
}
(async () => {
(async () => {
const platform = createMockPlatform({ users: [{ username: 'ana' }] });
const platform = createMockPlatform({ users: [{ username: 'ana' }] });
const ana = [...platform.state.users.values()][0];
const ana = [...platform.state.users.values()][0];
- const env = { OV_CLIENT_ID: APP, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read' };
+ const project = platform.addProject({ owner: ana.subject_id, environmentPolicy: 'sandbox' });
+ const redirectUri = 'http://localhost:3001/callback';
+ // A public sandbox app: no secret, redirect URI registered, a grant for the audience it signs in for.
+ const devApp = platform.addApp({ project, env: 'sandbox', type: 'public', redirectUris: [redirectUri], grants: ['media.object.read'] });
+ assert.equal(devApp.secret, undefined);
+ platform.setAuthorization({ subjectId: ana.subject_id });
+
+ const env = { OV_CLIENT_ID: devApp.id, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read', OV_REDIRECT_URI: redirectUri };
const config = loadConfig(env);
const config = loadConfig(env);
- platform.addClient(APP, { secret: null, redirectUris: [config.redirectUri] }); // a public app: no secret
const logs = [];
const logs = [];
const app = createApp(config, { fetch: platform.fetch, log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });
const app = createApp(config, { fetch: platform.fetch, log: { log: (m) => logs.push(m), error: (m) => logs.push(m) } });
await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${app.server.address().port}`;
const base = `http://127.0.0.1:${app.server.address().port}`;
- // The served bundle is browser-safe: only SDK modules, nothing server-side.
- const bundleRes = await realFetch(`${base}/sdk/openvibe-sdk.js`);
+ // The page loads the SDK's own browser bundle, unchanged, as an ES module.
+ const page = await (await realFetch(`${base}/`)).text();
+ assert.match(page, /<script type="module" src="\/app\.js"><\/script>/);
+ const bundleRes = await realFetch(`${base}/vendor/openvibe-sdk.mjs`);
assert.equal(bundleRes.status, 200);
assert.equal(bundleRes.status, 200);
+ assert.match(bundleRes.headers.get('content-type'), /^text\/javascript/);
const bundle = await bundleRes.text();
const bundle = await bundleRes.text();
- assert.ok(!/client_secret|process\.env|require\('node:|require\('crypto'\)/.test(bundle));
- assert.deepEqual(app.bundle.modules.sort(), ['src/auth/browser.js', 'src/core/client.js', 'src/core/errors.js', 'src/core/ids.js', 'src/core/index.js', 'src/core/paginate.js', 'src/core/semver.js', 'src/core/trace.js', 'src/registry.js']);
- assert.equal((await realFetch(`${base}/sdk/openvibe-sdk.js`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);
- const page = await (await realFetch(`${base}/`)).text();
- assert.match(page, /<script src="\/sdk\/openvibe-sdk\.js"><\/script>/);
+ assert.equal(bundle, fs.readFileSync(require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'utf8'));
+ assert.ok(!/client_secret|process\.env|require\('node:/.test(bundle), 'nothing server-side in the browser bundle');
+ assert.equal((await realFetch(`${base}/vendor/openvibe-sdk.mjs`, { headers: { 'if-none-match': bundleRes.headers.get('etag') } })).status, 304);
const publicConfig = await (await realFetch(`${base}/config.json`)).json();
const publicConfig = await (await realFetch(`${base}/config.json`)).json();
- assert.deepEqual(Object.keys(publicConfig).sort(), ['clientId', 'network', 'redirectUri', 'scope']);
+ assert.deepEqual(Object.keys(publicConfig).sort(), ['audience', 'clientId', 'network', 'redirectUri', 'registryProxy', 'scope']);
+ assert.equal((await realFetch(`${base}/api/registry/services`)).status, 404, 'the server-side registry read is opt-in');
const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
const appJs = fs.readFileSync(path.join(__dirname, '..', 'public', 'app.js'), 'utf8');
const storage = new Map();
const storage = new Map();
const sessionStorage = { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k) };
const sessionStorage = { getItem: (k) => (storage.has(k) ? storage.get(k) : null), setItem: (k, v) => storage.set(k, String(v)), removeItem: (k) => storage.delete(k) };
const jar = { cookie: '' };
const jar = { cookie: '' };
- const load = (url, opts = {}) => {
- const b = createBrowser({ base, origin: config.origin, platform, sessionStorage, jar, ...opts });
+ /** Load the page at `url` in a fresh browsing context: app.js imports /vendor/openvibe-sdk.mjs. */
+ const load = async (url, { serverBase = base, ...opts } = {}) => {
+ const b = createBrowser({ base: serverBase, origin: config.origin, platform, sessionStorage, jar, ...opts });
b.setUrl(url);
b.setUrl(url);
- vm.runInContext(bundle, b.context);
- vm.runInContext(appJs, b.context);
+ const sdk = new vm.SourceTextModule(bundle, { context: b.context, identifier: `${config.origin}/vendor/openvibe-sdk.mjs` });
+ const mod = new vm.SourceTextModule(appJs, { context: b.context, identifier: `${config.origin}/app.js` });
+ await mod.link((specifier) => {
+ if (specifier === '/vendor/openvibe-sdk.mjs') return sdk;
+ throw new Error(`app.js imports ${specifier}`);
+ });
+ b.done = mod.evaluate();
+ b.done.catch((err) => { b.error = err; });
return b;
return b;
};
};
- // 1. First visit: not signed in; the registry is read by the page itself (no token, no cookie).
- let b = load('/');
+ // 1. First visit: not signed in; the registry is read by the page itself, cross-origin, with
+ // no token and no cookie.
+ let b = await load('/');
await waitFor(() => /services, read by this page/.test(b.el('registry-status').textContent), 'the registry read');
await waitFor(() => /services, read by this page/.test(b.el('registry-status').textContent), 'the registry read');
assert.equal(b.el('who').textContent, 'Not signed in.');
assert.equal(b.el('who').textContent, 'Not signed in.');
assert.equal(b.el('sign-in').hidden, false);
assert.equal(b.el('sign-in').hidden, false);
assert.ok(b.el('services').children.some((li) => li.textContent.startsWith('media alpha')));
assert.ok(b.el('services').children.some((li) => li.textContent.startsWith('media alpha')));
+ assert.ok(b.crossOriginCalls.some((c) => c.url.startsWith('https://openvibe.network/api/v1/registry/services')));
const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));
const registryCall = platform.stats.requests.find((r) => r.url.includes('/api/v1/registry/services'));
assert.ok(!registryCall.headers.authorization);
assert.ok(!registryCall.headers.authorization);
+ assert.ok(b.crossOriginCalls.every((c) => c.credentials !== 'include'));
// 2. Sign in: the page makes the PKCE pair and goes to Network's authorize URL.
// 2. Sign in: the page makes the PKCE pair and goes to Network's authorize URL.
b.el('sign-in').click();
b.el('sign-in').click();
await waitFor(() => b.location.assigned, 'the redirect to Network');
await waitFor(() => b.location.assigned, 'the redirect to Network');
const to = new URL(b.location.assigned);
const to = new URL(b.location.assigned);
assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
- assert.equal(to.searchParams.get('client_id'), APP);
+ assert.equal(to.searchParams.get('client_id'), devApp.id);
assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
+ assert.equal(to.searchParams.get('audience'), 'openvibe.media');
assert.equal(to.searchParams.get('scope'), 'media.object.read');
assert.equal(to.searchParams.get('scope'), 'media.object.read');
const saved = JSON.parse(storage.get('ov_example_pkce'));
const saved = JSON.parse(storage.get('ov_example_pkce'));
assert.equal(saved.state, to.searchParams.get('state'));
assert.equal(saved.state, to.searchParams.get('state'));
- // 3. Network sends the browser back with a code (platform.authorize stands in for the consent page).
- const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge') });
- b = load(`/callback?code=${code}&state=${encodeURIComponent(saved.state)}`);
+ // 3. At Network the person continues; the account chooser redirects back with a code.
+ const atNetwork = await platform.fetch(to.toString(), { redirect: 'manual' });
+ assert.equal(atNetwork.status, 302);
+ const back = new URL(atNetwork.headers.get('location'));
+ assert.equal(`${back.origin}${back.pathname}`, redirectUri);
+ b = await load(`${back.pathname}${back.search}`);
await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the signed-in session');
await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the signed-in session');
- assert.match(b.el('who').textContent, new RegExp(`Signed in with OpenVibe as ${ana.subject_id}`));
+ assert.equal(b.el('who').textContent, `Signed in with OpenVibe as ${ana.subject_id}. This app may: media.object.read.`);
assert.equal(b.location.pathname, '/', 'the code is removed from the address bar');
assert.equal(b.location.pathname, '/', 'the code is removed from the address bar');
assert.equal(storage.has('ov_example_pkce'), false, 'the verifier is used once');
assert.equal(storage.has('ov_example_pkce'), false, 'the verifier is used once');
assert.match(jar.cookie, /^ov_browser_example_sid=/);
assert.match(jar.cookie, /^ov_browser_example_sid=/);
- const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));
- assert.equal(exchange.length, 1);
+ const exchanges = () => platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));
+ assert.equal(exchanges().length, 1, 'exchanged once, by this app\'s server');
+ assert.ok(!b.crossOriginCalls.some((c) => c.url.includes('/oauth/token')), 'the browser never calls the token endpoint');
// 4. A callback this browser did not start: refused before any exchange.
// 4. A callback this browser did not start: refused before any exchange.
storage.set('ov_example_pkce', JSON.stringify({ state: 'mine', codeVerifier: saved.codeVerifier }));
storage.set('ov_example_pkce', JSON.stringify({ state: 'mine', codeVerifier: saved.codeVerifier }));
- b = load('/callback?code=abc&state=forged');
+ b = await load('/callback?code=abc&state=forged');
await waitFor(() => b.el('auth-error').textContent !== '', 'the state error');
await waitFor(() => b.el('auth-error').textContent !== '', 'the state error');
assert.match(b.el('auth-error').textContent, /oauth\.state_mismatch/);
assert.match(b.el('auth-error').textContent, /oauth\.state_mismatch/);
- assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);
+ assert.equal(exchanges().length, 1);
- // 5. When the browser refuses the cross-origin registry call, the page reads it through its server.
- b = load('/', { crossOrigin: 'refuse' });
+ // 5. When the browser cannot reach Network (a CORS refusal), the page says so; with
+ // OV_REGISTRY_PROXY=1 it reads the same registry through its own server instead.
+ b = await load('/', { crossOrigin: 'refuse' });
+ await waitFor(() => /Could not read the registry/.test(b.el('registry-status').textContent), 'the registry error');
+ const proxied = createApp(loadConfig({ ...env, OV_REGISTRY_PROXY: '1' }), { fetch: platform.fetch, log: { log() {}, error() {} } });
+ await new Promise((r) => proxied.server.listen(0, '127.0.0.1', r));
+ b = await load('/', { crossOrigin: 'refuse', serverBase: `http://127.0.0.1:${proxied.server.address().port}` });
await waitFor(() => /through this app's server/.test(b.el('registry-status').textContent), 'the registry fallback');
await waitFor(() => /through this app's server/.test(b.el('registry-status').textContent), 'the registry fallback');
assert.ok(b.el('services').children.length > 0);
assert.ok(b.el('services').children.length > 0);
+ proxied.server.close();
// 6. The exchange endpoint accepts only this page: same Origin and a JSON body.
// 6. The exchange endpoint accepts only this page: same Origin and a JSON body.
const cross = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' });
const cross = await realFetch(`${base}/auth/exchange`, { method: 'POST', headers: { origin: 'https://evil.example', 'content-type': 'application/json' }, body: '{}' });
@@ -163,7 +203,7 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
@@ -163,7 +203,7 @@ function createBrowser({ base, origin, platform, sessionStorage, jar, crossOrigi
assert.equal(form.status, 415);
assert.equal(form.status, 415);
// 7. Sign out.
// 7. Sign out.
- b = load('/');
+ b = await load('/');
await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the session on reload');
await waitFor(() => b.el('who').textContent.startsWith('Signed in'), 'the session on reload');
b.el('sign-out').click();
b.el('sign-out').click();
await waitFor(() => b.el('who').textContent === 'Not signed in.', 'the sign-out');
await waitFor(() => b.el('who').textContent === 'Not signed in.', 'the sign-out');
@@ -69,8 +69,9 @@ Only use it in a channel whose streamer agreed to have it there; they can ban it
@@ -69,8 +69,9 @@ Only use it in a channel whose streamer agreed to have it there; they can ban it
Limits today:
Limits today:
- **Not an app credential.** Chat has no bot or app principal: the bot signs in with a Live API
- **Not an app credential.** Chat has no bot or app principal: the bot signs in with a Live API
- token, which is a scoped credential of a person's account, not a Network developer-app token.
- `chat.message.send` is a `first-party` capability, so a developer app cannot be granted it.
+ token (`hbt_…`), which is a scoped credential of a person's account, not a Network
+ developer-app token. `chat.message.send` is a `first-party` capability, so a developer app
+ cannot be granted it, and it is not in the sandbox allowance.
- **No bot flag in the protocol.** The `[bot]` prefix and the account name are the only way the
- **No bot flag in the protocol.** The `[bot]` prefix and the account name are the only way the
bot can identify itself; chat clients cannot mark bots differently yet.
bot can identify itself; chat clients cannot mark bots differently yet.
- Chat is still served on `openvibe.live` (`openvibe.chat` has not launched). The Live API-token
- Chat is still served on `openvibe.live` (`openvibe.chat` has not launched). The Live API-token
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-chat-bot",
"name": "openvibe-example-chat-bot",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Chat bot: a clearly labelled bot that talks in exactly one configured chat room.",
"description": "Chat bot: a clearly labelled bot that talks in exactly one configured chat room.",
"license": "MIT",
"license": "MIT",
@@ -1,11 +1,14 @@
@@ -1,11 +1,14 @@
-# realtime: SSE, works without credentials for public events. pull: needs events.event.read.
-OV_EVENTS_MODE=realtime
-OV_TOPICS=chat.message.created
+# pull (default): your project's own events as your app. realtime: public first-party events, no credentials.
+OV_EVENTS_MODE=pull
+# A CONFIDENTIAL sandbox app holding events.app.read (and events.app.publish for publish.js).
+OV_NETWORK_URL=https://openvibe.network
+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
+OV_TOPICS=
OV_CURSOR_PATH=./data/cursor.json
OV_CURSOR_PATH=./data/cursor.json
OV_POLL_MS=5000
OV_POLL_MS=5000
-OV_NETWORK_URL=https://openvibe.network
# Optional: an explicit Events origin instead of the one the registry advertises.
# Optional: an explicit Events origin instead of the one the registry advertises.
OV_EVENTS_URL=
OV_EVENTS_URL=
-# Only for pull mode (or to read non-public events over realtime).
-OV_CLIENT_ID=
-OV_CLIENT_SECRET=
@@ -1,34 +1,44 @@
@@ -1,34 +1,44 @@
# Event subscriber
# Event subscriber
Follow OpenVibe.Events topics without losing or double-handling events, across dropped
Follow OpenVibe.Events topics without losing or double-handling events, across dropped
-connections, restarts and retention gaps.
+connections, restarts and retention gaps. As a developer app, the topic you own is your project's
+`app.<project_key>.*`.
```bash
```bash
-OV_TOPICS='chat.message.created' node --env-file=.env subscriber.js # realtime, no credentials
-OV_EVENTS_MODE=pull node --env-file=.env subscriber.js # pull, needs events.event.read
+node --env-file=.env publish.js order.created '{"total":3}' # something to receive (events.app.publish)
+node --env-file=.env subscriber.js # pull your project's topic (events.app.read)
+OV_EVENTS_MODE=realtime OV_TOPICS=chat.message.created node subscriber.js # public first-party events, no credentials
```
```
## What it proves
## What it proves
-- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`. Without
- credentials you receive `public` events only; `internal` events never reach you. After a
- dropped connection or a restart it resumes from the saved cursor with `Last-Event-ID`, and
- nothing is delivered twice.
+- **Your own topic.** A developer app publishes and reads `app.<project_key>.<name…>`, where
+ `project_key` is `p` followed by the project's ULID in lowercase (`prj_01JAB…` → `p01jab…`), and
+ its events carry `source: app-<app ULID in lowercase>` and `actor: { type: 'app', id: <app id> }`.
+ `publish.js` builds exactly that with `openvibe-sdk/events` `publish()` (which fills `event_id`,
+ `timestamp`, `version` and `trace_id`; a repeated `event_id` is answered as a duplicate, so
+ retries are safe). Events refuses another project's `app.*` topic (`403 events.topic_not_allowed`).
- **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`) only once every item of that page is handled. A handler that throws leaves
- the cursor on the last event that succeeded, so the failed event is retried next time.
-- **Gaps**: when Events can no longer supply part of the range (retention pruned it, or a restored
- cursor is ahead of the stream), `onResync(gap)` is called. Nothing can replay the missing events,
- so reload whatever state you derive from them from its source of truth.
+ `next_after_seq`) in `onPage`, which the SDK calls only after every item of that page was
+ handled. A handler that throws leaves the cursor on the last event that succeeded, so the failed
+ event is retried next time.
+- **Gaps**: when Events can no longer supply part of the range (retention pruned it: sandbox app
+ events are kept 7 days; or a restored cursor is ahead of the stream), `onResync(gap)` is called.
+ Nothing can replay the missing events, so reload whatever state you derive from them.
+- **Realtime** (`openvibe-sdk/realtime` `subscribe()`): SSE from `/realtime/stream`,
+ anonymously, for public first-party events. It resumes from the saved cursor with
+ `Last-Event-ID`, and nothing is delivered twice. The app token is never sent on it.
- The cursor file is replaced atomically (write + rename).
- The cursor file is replaced atomically (write + rename).
## Files
## Files
| File | What |
| File | What |
|---|---|
|---|---|
-| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`), `createCursorStore()` |
-| `test/smoke.test.js` | pull with cursor and failure, pull gap, anonymous realtime, reconnect, restart, cursor-ahead gap |
+| `subscriber.js` | `createSubscriber()` (`start()`, `stop()`, `pullOnce()`, `resolveTopics()`), `createCursorStore()`, `projectKey()`, `appSource()` |
+| `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 |
## Run the smoke test
## Run the smoke test
@@ -36,17 +46,28 @@ OV_EVENTS_MODE=pull node --env-file=.env subscriber.js
@@ -36,17 +46,28 @@ OV_EVENTS_MODE=pull node --env-file=.env subscriber.js
npm test
npm test
```
```
-The SDK mock never prunes events, so the pull-gap case wraps the mock's `fetch` to add the `gap`
-field Events returns after pruning. Realtime gaps come from the mock itself (`cursor_ahead`).
+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.
## Run it against the real platform
## Run it against the real platform
-- **Realtime works without an app**: `cp .env.example .env`, keep `OV_EVENTS_MODE=realtime`, set
- `OV_TOPICS`, run `node --env-file=.env subscriber.js`. The stream is
- `https://events.openvibe.network/realtime/stream` (from the registry). You will only see
- events that a producer publishes with visibility `public` on those topics. Chat's
- `chat.message.created` is public, but Chat has not been cut over yet, so whether anything
- arrives depends on which producers are live.
-- **Pull cannot be demonstrated yet.** `events.event.read` is an `internal` capability (never
- granted to apps), and the pull API (`/api/v1/events`, checkpoints) is not exposed on Events'
- public host. Checkpoints stored in Events are therefore not used here: the cursor is a local file.
+1. Create a project and a **confidential** sandbox app with `events.app.publish` and
+ `events.app.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).
+2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET` (`OV_PROJECT_ID` is
+ optional: it is read from the app token).
+3. `node --env-file=.env publish.js`, then `node --env-file=.env subscriber.js`. The subscriber
+ prints `pulling app.<project_key>.* every 5000 ms` and each event it handles.
+
+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.
+- Realtime never streams `app.*` events, to anyone; use pull (or a webhook, see
+ [webhook-consumer](../webhook-consumer)) for your own events. Anonymous realtime shows public
+ first-party events only; which ones arrive depends on which producers publish public events.
+- Events limits a sandbox project to 30 published events per minute and 5 MiB of retained events.
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
"name": "openvibe-example-event-subscriber",
"name": "openvibe-example-event-subscriber",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
- "description": "Event subscriber: realtime subscription and cursor-based pull with gap handling.",
+ "description": "Event subscriber: publish to your project's topic, pull it with a durable cursor and gap handling; anonymous realtime for public events.",
"license": "MIT",
"license": "MIT",
"main": "subscriber.js",
"main": "subscriber.js",
"scripts": {
"scripts": {
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Publish one event to your project's own topic, so the subscriber (and webhook-consumer) have
+ * something to receive.
+ *
+ * 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:
+ * 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.
+ */
+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 NAME_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)*$/;
+
+function loadConfig(env = process.env) {
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
+ if (missing.length) throw Object.assign(new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`), { code: 'config.missing' });
+ return {
+ network: env.OV_NETWORK_URL || 'https://openvibe.network',
+ clientId: env.OV_CLIENT_ID,
+ clientSecret: env.OV_CLIENT_SECRET,
+ projectId: env.OV_PROJECT_ID || null,
+ eventsUrl: env.OV_EVENTS_URL || null,
+ };
+}
+
+/** -> { event_id, seq, duplicate, event_type } */
+async function publishAppEvent(config, { name = 'example.ping', subject = { type: 'example', id: '1' }, payload = {}, eventId } = {}, { fetch } = {}) {
+ if (!NAME_RE.test(name)) throw new TypeError('the event name is dot-separated segments of a-z, 0-9 and _ (e.g. order.created)');
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ scope: { 'openvibe.events': 'events.app.publish' },
+ });
+ 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 };
+}
+
+async function main([name, payload] = process.argv.slice(2)) {
+ try {
+ const out = await publishAppEvent(loadConfig(), { name: name || undefined, payload: payload ? JSON.parse(payload) : {} });
+ console.log(JSON.stringify(out, null, 2));
+ return 0;
+ } catch (err) {
+ console.error(`publish failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} (request ${err.requestId})` : err.message}`);
+ return 1;
+ }
+}
+
+if (require.main === module) main().then((code) => { process.exitCode = code; });
+
+module.exports = { loadConfig, publishAppEvent, main };
@@ -7,19 +7,22 @@
@@ -7,19 +7,22 @@
*
*
* Two ways in, one cursor discipline:
* Two ways in, one cursor discipline:
*
*
- * realtime GET /realtime/stream (SSE) through openvibe-sdk/realtime subscribe(). Works without
- * credentials for events whose visibility is `public`. Resumes with Last-Event-ID from
- * the saved cursor after a restart or a dropped connection.
- * pull GET /api/v1/events through openvibe-sdk/events iterate(), from the saved cursor.
- * Needs an app token holding events.event.read.
+ * 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.
+ * 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.
*
*
* The cursor is saved only AFTER an event was handled, so a crash replays at most the event in
* The cursor is saved only AFTER an event was handled, so a crash replays at most the event in
* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events
* hand (make your handler idempotent on event_id). In pull mode the cursor also moves past events
- * that did not match the topic, to the page's next_after_seq, once every item of that page is done.
+ * that did not match the topic, to the page's next_after_seq: iterate() calls onPage only after
+ * every item of that page was handled, so saving it there is crash-safe.
*
*
- * A gap means Events can no longer give you part of the range (retention pruned it, or the cursor
- * is ahead of the stream after a restore). Nothing can replay it, so the subscriber calls
- * onResync(gap): reload whatever state you derive from these events from its source of truth.
+ * A gap means Events can no longer give you part of the range (retention pruned it: sandbox app
+ * events are kept 7 days; or the cursor is ahead of the stream after a restore). Nothing can replay
+ * it, so the subscriber calls onResync(gap): reload whatever state you derive from these events.
*/
*/
const fs = require('node:fs');
const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
@@ -28,20 +31,39 @@ const { createServiceTokenClient } = require('openvibe-sdk/auth');
@@ -28,20 +31,39 @@ const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createEventsClient } = require('openvibe-sdk/events');
const { createEventsClient } = 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 || 'realtime';
- if (!['realtime', 'pull'].includes(mode)) throw Object.assign(new Error('OV_EVENTS_MODE must be realtime or pull'), { code: 'config.invalid' });
+ 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' });
const hasCreds = Boolean(env.OV_CLIENT_ID && env.OV_CLIENT_SECRET);
const hasCreds = Boolean(env.OV_CLIENT_ID && env.OV_CLIENT_SECRET);
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);
+ 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' });
+ }
return {
return {
mode,
mode,
network: env.OV_NETWORK_URL || 'https://openvibe.network',
network: env.OV_NETWORK_URL || 'https://openvibe.network',
clientId: env.OV_CLIENT_ID || null,
clientId: env.OV_CLIENT_ID || null,
clientSecret: env.OV_CLIENT_SECRET || null,
clientSecret: env.OV_CLIENT_SECRET || null,
+ projectId: env.OV_PROJECT_ID || null,
eventsUrl: env.OV_EVENTS_URL || null,
eventsUrl: env.OV_EVENTS_URL || null,
- topics: String(env.OV_TOPICS || 'chat.message.created').split(',').map((t) => t.trim()).filter(Boolean),
+ topics, // empty in pull mode: your project's app.<project_key>.*
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),
};
};
@@ -71,15 +93,13 @@ function createCursorStore(file) {
@@ -71,15 +93,13 @@ function createCursorStore(file) {
*/
*/
function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = console, cursor = createCursorStore(config.cursorPath) } = {}) {
function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = console, cursor = createCursorStore(config.cursorPath) } = {}) {
if (typeof onEvent !== 'function') throw new TypeError('onEvent is required');
if (typeof onEvent !== 'function') throw new TypeError('onEvent is required');
- const tokenProvider = config.clientId && config.clientSecret
+ const baseUrls = config.eventsUrl ? { events: config.eventsUrl } : undefined;
+ const tokens = config.clientId && config.clientSecret
? createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch })
? createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch })
- : undefined;
- const client = createClient({
- network: config.network,
- fetch,
- tokenProvider,
- baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,
- });
+ : null;
+ // Pull runs as your app; realtime is anonymous (public events only), so it never sends the app token.
+ const client = createClient({ network: config.network, fetch, tokenProvider: tokens || undefined, baseUrls });
+ const anonymous = createClient({ network: config.network, fetch, baseUrls });
const stats = { handled: 0, gaps: 0 };
const stats = { handled: 0, gaps: 0 };
async function gap(g, via) {
async function gap(g, via) {
@@ -88,22 +108,31 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -88,22 +108,31 @@ 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;
+ let projectId = config.projectId;
+ if (!projectId) {
+ const info = await tokens.getTokenInfo({ audience: 'openvibe.events' }); // decoded, not verified: fine for naming
+ projectId = info.unverifiedClaims && info.unverifiedClaims.project_id;
+ }
+ topics = [`app.${projectKey(projectId)}.*`];
+ return topics;
+ }
+
// ── pull ────────────────────────────────────────────────
// ── pull ────────────────────────────────────────────────
const events = createEventsClient(client);
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 } = {}) {
- let pageNext = null;
let handled = 0;
let handled = 0;
const iterator = events.iterate({
const iterator = events.iterate({
- topic: config.topics,
+ topic: await resolveTopics(),
afterSeq: cursor.get('pull') || 0,
afterSeq: cursor.get('pull') || 0,
limit,
limit,
onGap: (g) => gap(g, 'pull'),
onGap: (g) => gap(g, 'pull'),
- // Called when a page arrives, before its items are handed out: the previous page is done.
- onPage: (page) => {
- if (pageNext != null) cursor.set('pull', pageNext);
- pageNext = page.next_after_seq;
- },
+ // After every item of the page was handled: also moves past events of other topics.
+ onPage: (page) => cursor.set('pull', page.next_after_seq),
});
});
for await (const { seq, event } of iterator) {
for await (const { seq, event } of iterator) {
await onEvent(event, { seq, via: 'pull' });
await onEvent(event, { seq, via: 'pull' });
@@ -111,7 +140,6 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -111,7 +140,6 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
handled++;
handled++;
stats.handled++;
stats.handled++;
}
}
- if (pageNext != null) cursor.set('pull', pageNext);
return handled;
return handled;
}
}
@@ -122,7 +150,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -122,7 +150,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
async function pollLoop() {
async function pollLoop() {
while (!stopped) {
while (!stopped) {
try { await pullOnce(); } catch (err) {
try { await pullOnce(); } catch (err) {
- log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code} request=${err.requestId}` : err.message}`);
+ log.error(`[events] pull failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} request=${err.requestId}` : err.message}`);
}
}
if (stopped) break;
if (stopped) break;
await new Promise((r) => { timer = setTimeout(r, config.pollMs); });
await new Promise((r) => { timer = setTimeout(r, config.pollMs); });
@@ -140,7 +168,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -140,7 +168,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
stats.handled++;
stats.handled++;
}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));
}).catch((err) => log.error(`[events] handler failed at seq ${seq}: ${err.message}`));
}, {
}, {
- client,
+ client: anonymous,
fetch,
fetch,
transport: 'fetch',
transport: 'fetch',
lastEventId: cursor.get('realtime'),
lastEventId: cursor.get('realtime'),
@@ -156,6 +184,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
@@ -156,6 +184,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
stats,
stats,
cursor,
cursor,
pullOnce,
pullOnce,
+ resolveTopics,
start() {
start() {
stopped = false;
stopped = false;
if (config.mode === 'pull') { pollLoop(); return null; }
if (config.mode === 'pull') { pollLoop(); return null; }
@@ -182,9 +211,12 @@ if (require.main === module) {
@@ -182,9 +211,12 @@ if (require.main === module) {
},
},
});
});
s.start();
s.start();
+ if (config.mode === 'pull') {
+ s.resolveTopics().then((t) => console.log(`pulling ${t.join(', ')} every ${config.pollMs} ms`), () => {});
+ }
const stop = () => { s.stop(); process.exit(0); };
const stop = () => { s.stop(); process.exit(0); };
process.on('SIGINT', stop);
process.on('SIGINT', stop);
process.on('SIGTERM', stop);
process.on('SIGTERM', stop);
}
}
-module.exports = { loadConfig, createCursorStore, createSubscriber };
+module.exports = { loadConfig, createCursorStore, createSubscriber, projectKey, appSource };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'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,19 +1,21 @@
@@ -1,19 +1,21 @@
'use strict';
'use strict';
/**
/**
- * Smoke test: pull with a durable cursor, realtime with resume, and gap handling in both, against
- * the SDK's mock platform (fake Network + Events, including /realtime/stream). No network.
+ * 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.
*/
*/
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 } = require('../subscriber');
+const { createSubscriber, loadConfig, createCursorStore, projectKey, appSource } = 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'); };
globalThis.fetch = () => { throw new Error('network access in a smoke test'); };
-const APP = 'app_01JEXAMPLESUBSCRIBER000000';
-const SECRET = 'ovsec_event_subscriber_test_secret';
const quiet = { log() {}, error() {} };
const quiet = { log() {}, error() {} };
async function waitFor(check, what, ms = 3000) {
async function waitFor(check, what, ms = 3000) {
@@ -25,85 +27,113 @@ async function waitFor(check, what, ms = 3000) {
@@ -25,85 +27,113 @@ async function waitFor(check, what, ms = 3000) {
throw new Error(`timed out waiting for ${what}`);
throw new Error(`timed out waiting for ${what}`);
}
}
-const media = (visibility = 'public', type = 'media.object.uploaded') => ({
+const firstParty = (visibility = 'public', type = 'media.object.uploaded') => ({
event_type: type, source: 'media', visibility,
event_type: type, source: 'media', visibility,
- actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' },
+ actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPA0BJECT0000000000' },
});
});
(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({
- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.event.read', audience: 'openvibe.events' }] } },
- });
-
- // ── pull: durable cursor ────────────────────────────────
- const a = platform.publishEvent(media());
- platform.publishEvent(media('public', 'chat.message.created')); // another topic: skipped, cursor moves past it
- const b = platform.publishEvent(media('internal', 'media.object.deleted'));
- const pullEnv = { OV_EVENTS_MODE: 'pull', OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'pull.json') };
+ const platform = createMockPlatform();
+ const fetch = createAppEventsFetch(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'] });
+ const publishOnly = platform.addApp({ env: 'sandbox', project: app.projectId, grants: ['events.app.publish'] });
+
+ // Names: project_key and source exactly as Events defines them.
+ assert.equal(projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.equal(appSource('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.equal(appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');
+ const key = projectKey(app.projectId);
+
+ // ── publish to the own topic ────────────────────────────
+ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };
+ const pub = (e, opts) => publishAppEvent(loadPublishConfig(e || env), opts || {}, { fetch });
+ const a = await pub(env, { name: 'order.created', payload: { total: 3 } });
+ assert.equal(a.event_type, `app.${key}.order.created`);
+ assert.equal(a.duplicate, false);
+ const stored = platform.state.events.find((x) => x.event.event_id === a.event_id).event;
+ assert.equal(stored.source, appSource(app.id));
+ assert.deepEqual(stored.actor, { type: 'app', id: app.id });
+ // Same event_id again: Events answers with the stored seq, nothing new.
+ assert.equal((await pub(env, { name: 'order.created', eventId: a.event_id })).duplicate, true);
+ await assert.rejects(pub(env, { name: 'Bad Name' }), /dot-separated/);
+
+ platform.publishEvent(firstParty('public', 'live.stream.started')); // first-party, another topic
+ await pub({ OV_CLIENT_ID: other.id, OV_CLIENT_SECRET: other.secret }, { name: 'order.created' }); // another project
+ platform.publishEvent(firstParty('internal', `app.${key}.forged`)); // not from this project's apps: never shown
+ const b = await pub(env, { name: 'order.paid' });
+
+ // ── pull: own topic by default, durable cursor ──────────
+ const pullEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'pull.json') };
let seen = [];
let seen = [];
- let s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
+ let s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
+ assert.deepEqual(await s.resolveTopics(), [`app.${key}.*`], 'the project id comes from the app token');
assert.equal(await s.pullOnce({ limit: 2 }), 2);
assert.equal(await s.pullOnce({ limit: 2 }), 2);
- assert.deepEqual(seen, [a.seq, b.seq]);
- assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), 3, 'cursor saved at the head');
+ assert.deepEqual(seen, [a.seq, b.seq], 'own project only: not the other project, not the forged one');
+ const head = platform.state.events.at(-1).seq;
+ assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), head, 'cursor saved at the head');
// A new process resumes from the cursor: only what is new.
// A new process resumes from the cursor: only what is new.
- const c = platform.publishEvent(media());
+ const c = await pub(env, { name: 'order.shipped' });
seen = [];
seen = [];
- s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
+ s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (e, { seq }) => { seen.push(seq); } });
assert.equal(await s.pullOnce(), 1);
assert.equal(await s.pullOnce(), 1);
assert.deepEqual(seen, [c.seq]);
assert.deepEqual(seen, [c.seq]);
// A handler that fails leaves the cursor on the last event that succeeded.
// A handler that fails leaves the cursor on the last event that succeeded.
- const d = platform.publishEvent(media());
- const e = platform.publishEvent(media());
+ const d = await pub(env);
+ const e = await pub(env);
seen = [];
seen = [];
s = createSubscriber(loadConfig(pullEnv), {
s = createSubscriber(loadConfig(pullEnv), {
- fetch: platform.fetch, log: quiet,
+ fetch, log: quiet,
onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },
onEvent: (ev, { seq }) => { if (seq === e.seq) throw new Error('boom'); seen.push(seq); },
});
});
await assert.rejects(s.pullOnce(), /boom/);
await assert.rejects(s.pullOnce(), /boom/);
assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);
assert.equal(createCursorStore(pullEnv.OV_CURSOR_PATH).get('pull'), d.seq);
- s = createSubscriber(loadConfig(pullEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });
+ s = createSubscriber(loadConfig(pullEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); } });
await s.pullOnce();
await s.pullOnce();
assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');
assert.deepEqual(seen, [d.seq, e.seq], 'the failed event is retried, nothing is skipped');
- // A gap (retention pruned the range): onResync is told, reading continues. The mock never
- // prunes, so this wraps its fetch to answer the way Events does after pruning.
- const gapFetch = async (input, init) => {
- const res = await platform.fetch(input, init);
- const url = String(input && input.url ? input.url : input);
- if (!url.includes('/api/v1/events?')) return res;
- const body = await res.json();
- return new Response(JSON.stringify({ ...body, gap: { from_seq: 7, to_seq: 9, reason: 'retention' } }), { status: res.status, headers: { 'Content-Type': 'application/json' } });
- };
- const f = platform.publishEvent(media());
+ // A gap: retention pruned the range the cursor still needs. onResync is told, reading continues.
+ const gapEnv = { ...env, OV_CURSOR_PATH: path.join(dir, 'gap.json') };
+ createCursorStore(gapEnv.OV_CURSOR_PATH).set('pull', a.seq);
+ platform.pruneEvents(c.seq);
+ const f = await pub(env);
const resyncs = [];
const resyncs = [];
seen = [];
seen = [];
- s = createSubscriber(loadConfig(pullEnv), { fetch: gapFetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });
+ s = createSubscriber(loadConfig(gapEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { seen.push(seq); }, onResync: (g, { via }) => resyncs.push({ ...g, via }) });
await s.pullOnce();
await s.pullOnce();
- assert.deepEqual(resyncs, [{ from_seq: 7, to_seq: 9, reason: 'retention', via: 'pull' }]);
- assert.deepEqual(seen, [f.seq]);
+ 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.
+ 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');
+ 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');
- // Pull needs credentials; realtime does not.
- assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'pull' }), /OV_CLIENT_ID/);
+ // Pull needs credentials; realtime needs topics but no credentials.
+ assert.throws(() => loadConfig({}), /OV_CLIENT_ID/);
+ assert.throws(() => loadConfig({ OV_EVENTS_MODE: 'realtime' }), /OV_TOPICS/);
- // ── realtime: anonymous, public events only, resume ────
+ // ── realtime: anonymous, public first-party events only, resume ──
const rtEnv = { OV_EVENTS_MODE: 'realtime', OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'realtime.json') };
const rtEnv = { OV_EVENTS_MODE: 'realtime', OV_TOPICS: 'media.object.*', OV_CURSOR_PATH: path.join(dir, 'realtime.json') };
const live = [];
const live = [];
- const rt = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });
+ const rt = createSubscriber(loadConfig(rtEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { live.push({ seq, type: ev.event_type }); } });
const subscription = rt.start();
const subscription = rt.start();
await waitFor(() => subscription.connected, 'the realtime connection');
await waitFor(() => subscription.connected, 'the realtime connection');
- const p1 = platform.publishEvent(media());
- platform.publishEvent(media('internal')); // never reaches an anonymous viewer
- const p2 = platform.publishEvent(media('public', 'media.object.deleted'));
+ const p1 = platform.publishEvent(firstParty());
+ platform.publishEvent(firstParty('internal')); // never reaches an anonymous viewer
+ const p2 = platform.publishEvent(firstParty('public', 'media.object.deleted'));
await waitFor(() => live.length === 2, 'two public events');
await waitFor(() => live.length === 2, 'two public events');
assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);
assert.deepEqual(live.map((x) => x.seq), [p1.seq, p2.seq]);
await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');
await waitFor(() => createCursorStore(rtEnv.OV_CURSOR_PATH).get('realtime') === p2.seq, 'the realtime cursor');
// The connection drops; an event published meanwhile arrives once after the reconnect.
// The connection drops; an event published meanwhile arrives once after the reconnect.
platform.dropRealtime();
platform.dropRealtime();
- const p3 = platform.publishEvent(media());
+ const p3 = platform.publishEvent(firstParty());
await waitFor(() => live.length === 3, 'the event published during the drop');
await waitFor(() => live.length === 3, 'the event published during the drop');
assert.equal(live[2].seq, p3.seq);
assert.equal(live[2].seq, p3.seq);
await new Promise((r) => setTimeout(r, 100));
await new Promise((r) => setTimeout(r, 100));
@@ -111,9 +141,9 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -111,9 +141,9 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({
rt.stop();
rt.stop();
// A restart resumes from the saved cursor: the event published while stopped is delivered.
// A restart resumes from the saved cursor: the event published while stopped is delivered.
- const p4 = platform.publishEvent(media());
+ const p4 = platform.publishEvent(firstParty());
const after = [];
const after = [];
- const rt2 = createSubscriber(loadConfig(rtEnv), { fetch: platform.fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });
+ const rt2 = createSubscriber(loadConfig(rtEnv), { fetch, log: quiet, onEvent: (ev, { seq }) => { after.push(seq); } });
rt2.start();
rt2.start();
await waitFor(() => after.length === 1, 'the event published while stopped');
await waitFor(() => after.length === 1, 'the event published while stopped');
assert.deepEqual(after, [p4.seq]);
assert.deepEqual(after, [p4.seq]);
@@ -123,14 +153,14 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({
@@ -123,14 +153,14 @@ const media = (visibility = 'public', type = 'media.object.uploaded') => ({
const aheadPath = path.join(dir, 'ahead.json');
const aheadPath = path.join(dir, 'ahead.json');
createCursorStore(aheadPath).set('realtime', 9999);
createCursorStore(aheadPath).set('realtime', 9999);
const rtGaps = [];
const rtGaps = [];
- const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch: platform.fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });
+ const rt3 = createSubscriber(loadConfig({ ...rtEnv, OV_CURSOR_PATH: aheadPath }), { fetch, log: quiet, onEvent() {}, onResync: (g, { via }) => rtGaps.push({ ...g, via }) });
rt3.start();
rt3.start();
await waitFor(() => rtGaps.length === 1, 'the realtime gap');
await waitFor(() => rtGaps.length === 1, 'the realtime gap');
assert.equal(rtGaps[0].reason, 'cursor_ahead');
assert.equal(rtGaps[0].reason, 'cursor_ahead');
assert.equal(rtGaps[0].via, 'realtime');
assert.equal(rtGaps[0].via, 'realtime');
rt3.stop();
rt3.stop();
- // No credentials were sent on the anonymous stream.
+ // Realtime never carried credentials, even with an app configured.
const streamCalls = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream'));
const streamCalls = platform.stats.requests.filter((r) => r.url.includes('/realtime/stream'));
assert.ok(streamCalls.length >= 3);
assert.ok(streamCalls.length >= 3);
assert.ok(streamCalls.every((r) => !r.headers.authorization));
assert.ok(streamCalls.every((r) => !r.headers.authorization));
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
-# A CONFIDENTIAL developer app holding media.object.upload (audience openvibe.media).
+# A CONFIDENTIAL sandbox app holding media.object.upload (and media.object.read for --get), audience openvibe.media.
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_CLIENT_ID=
OV_CLIENT_ID=
OV_CLIENT_SECRET=
OV_CLIENT_SECRET=
-# Your project id (prj_...): Network puts it in the token's ns claim, and Media stores under it.
-OV_MEDIA_NAMESPACE=
+# Optional: your project id (prj_...), the Media tenant. Read from the app token when empty.
+OV_PROJECT_ID=
# Optional: an explicit Media origin instead of the one the registry advertises.
# Optional: an explicit Media origin instead of the one the registry advertises.
OV_MEDIA_URL=
OV_MEDIA_URL=
@@ -1,25 +1,36 @@
@@ -1,25 +1,36 @@
# Media uploader
# Media uploader
-Upload a file to OpenVibe.Media as a developer app, using `openvibe-sdk/media` and nothing else.
+Upload a file to OpenVibe.Media as a developer app, read it back and delete it, using
+`openvibe-sdk/media` and nothing else.
```bash
```bash
node --env-file=.env upload.js ./logo.png
node --env-file=.env upload.js ./logo.png
# {
# {
# "key": "3f2a9c0d1b7e-logo.png",
# "key": "3f2a9c0d1b7e-logo.png",
-# "public_url": "https://openvibe.media/f/3f2a9c0d1b7e-logo.png",
-# "size": 5120, "mime": "image/png", "sha256": "…", "deduplicated": false
+# "url": "https://openvibe.media/f/3f2a9c0d1b7e-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
```
```
## What it proves
## What it proves
-- An app with only a client id, a secret and one grant (`media.object.upload`) gets a 5-minute
- app token from Network's public token endpoint, asking for exactly that capability (`scope`).
+- An app with only a client id, a secret and its grants gets a 5-minute app token from Network's
+ public token endpoint, asking for exactly the capability each call needs (`scope`):
+ `media.object.upload` to upload or delete, `media.object.read` to read.
- Media's origin comes from the platform descriptor (`client.discover()`), not a hard-coded host,
- Media's origin comes from the platform descriptor (`client.discover()`), not a hard-coded host,
and the contracts version the network runs is checked against the SDK's supported range.
and the contracts version the network runs is checked against the SDK's supported range.
-- The upload lands in the app's own namespace. For a developer app that is the **project id**
- (`prj_…`), because Network puts the project id in the token's `ns` claim. Another namespace is
- refused by Media with `403 capability.namespace_denied`.
+- **Your project is your Media tenant.** The path names the project id (`prj_…`), which is also the
+ token's `project_id` and `ns`; it is read from the token when `OV_PROJECT_ID` is empty. Another
+ project's tenant is refused with `403 capability.namespace_denied`.
+- **Sandbox files are private.** Media keeps a sandbox app's files in a separate sandbox tenant of
+ the project (you still address it by the project id) and never serves them publicly: an upload
+ comes back with `sandbox: true` and a signed, expiring `url`. A production app's files get a
+ public `/f/<key>` URL.
- Uploads are content-addressed: the same bytes again come back `deduplicated`, so retries are safe.
- Uploads are content-addressed: the same bytes again come back `deduplicated`, so retries are safe.
- The secret is read from the environment and never printed, not even in error messages.
- The secret is read from the environment and never printed, not even in error messages.
@@ -27,8 +38,8 @@ node --env-file=.env upload.js ./logo.png
@@ -27,8 +38,8 @@ node --env-file=.env upload.js ./logo.png
| File | What |
| File | What |
|---|---|
|---|---|
-| `upload.js` | `loadConfig()`, `createUploader()`, `explain()` (hints for the usual errors), CLI |
-| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (fake Network + Media) |
+| `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 |
## Run the smoke test
## Run the smoke test
@@ -37,24 +48,18 @@ npm test # from this folder, or `npm test -- media` from the repository
@@ -37,24 +48,18 @@ npm test # from this folder, or `npm test -- media` from the repository
```
```
No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same
No network: Network and Media are the SDK's in-process mock, with real RS256 tokens and the same
-audience, capability and namespace checks.
+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.
## Run it against the real platform
## Run it against the real platform
-1. Follow the [walkthrough](../../README.md#end-to-end-walkthrough) to create a project and a
- **confidential** app, and request `media.object.upload` on it.
-2. `cp .env.example .env` and fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET` and
- `OV_MEDIA_NAMESPACE` (your `prj_…` project id).
+1. Create a project and a **confidential** sandbox app with `media.object.upload` and
+ `media.object.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).
+2. `cp .env.example .env` and fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.
3. `node --env-file=.env upload.js ./some-file.png`
3. `node --env-file=.env upload.js ./some-file.png`
-What has to be true on the platform side first, and is **not** true by default today:
-
-- **Sandbox apps get no Media token.** Network issues sandbox tokens only for audiences listed in
- `DEV_SANDBOX_AUDIENCES`, which is empty by default, so a sandbox app gets `invalid_target`.
- Media must also accept `env: sandbox` tokens. Until both opt in, use a production app (staff
- switch the project to `sandbox+production`).
-- **The capability must be in your project's allowance** (staff set it; the default allowance is empty).
-- **Media needs a tenant named after your project id.** Media only accepts uploads for tenants it
- knows, and tenants are created by the Media operators.
-- Media v1 accepts app tokens for **upload only**. Listing, reading metadata and deleting
- (`media.files.list/get/delete`) need a Media app API key, which developer apps do not have.
+Media creates the project's tenant on first use; no operator step is needed. A sandbox tenant has
+a small quota (100 MB by default; beyond it an upload is `413`). Open a sandbox file with the signed `url`
+before `url_expires_at`; `--get` returns a fresh one.
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
"name": "openvibe-example-media-uploader",
"name": "openvibe-example-media-uploader",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
- "description": "Media uploader: upload a file to OpenVibe.Media with an app token.",
+ "description": "Media uploader: upload, read and delete files in your project's OpenVibe.Media tenant with app tokens.",
"license": "MIT",
"license": "MIT",
"main": "upload.js",
"main": "upload.js",
"scripts": {
"scripts": {
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
@@ -1,73 +1,85 @@
@@ -1,73 +1,85 @@
'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 checks). No network.
+ * 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.
*/
*/
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const crypto = require('node:crypto');
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 { createUploader, explain, loadConfig } = require('../upload');
+const { createUploader, describe, 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'); };
-const APP = 'app_01JEXAMPLEMEDIA0000000000';
-const PRJ = 'prj_01JEXAMPLEPROJECT00000000';
-const OTHER = 'prj_01JSOMEONEELSE00000000000';
-const SECRET = 'ovsec_media_uploader_test_secret';
-
(async () => {
(async () => {
- const platform = createMockPlatform({
- clients: {
- [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] },
- app_01JNOGRANTS000000000000000: { secret: 'x', grants: [] },
- },
- mediaApps: { [PRJ]: {}, [OTHER]: {} },
- });
+ // Media accepts sandbox app tokens on the project's own tenant.
+ const platform = createMockPlatform({ acceptSandbox: ['openvibe.media'] });
+ const app = platform.addApp({ env: 'sandbox', grants: ['media.object.upload', 'media.object.read'] });
+ 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-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-'));
const file = path.join(dir, 'hello.txt');
const file = path.join(dir, 'hello.txt');
fs.writeFileSync(file, 'hello from an OpenVibe example\n');
fs.writeFileSync(file, 'hello from an OpenVibe example\n');
- const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_MEDIA_NAMESPACE: PRJ };
+ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };
- // 1. Upload: token for openvibe.media, Media origin from discovery, file stored in the project namespace.
+ // 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.
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}-hello\.txt$/);
- assert.equal(out.public_url, `https://openvibe.media/f/${out.key}`);
+ 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, require('node:crypto').createHash('sha256').update(fs.readFileSync(file)).digest('hex'));
- assert.ok(platform.state.files.has(`${PRJ}|${out.key}`), 'stored under the project namespace');
+ 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');
- // The upload carried a short-lived Network token (not an API key) and a trace.
- const call = platform.stats.requests.find((r) => r.url === `https://openvibe.media/api/v1/${PRJ}/files`);
+ // 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.ok(call, 'Media was called at the discovered origin');
assert.match(call.headers.authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
assert.match(call.headers.authorization, /^Bearer [\w-]+\.[\w-]+\.[\w-]+$/);
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 token = platform.stats.requests.find((r) => r.url.endsWith('/oauth/token'));
- assert.ok(token);
+ const claims = JSON.parse(Buffer.from(call.headers.authorization.split('.')[1], 'base64url'));
+ assert.deepEqual(claims.cap, ['media.object.upload']);
+ assert.equal(claims.env, 'sandbox');
// 2. Same bytes again: content-addressed, deduplicated, safe to retry.
// 2. Same bytes again: content-addressed, deduplicated, safe to retry.
const again = await uploader.upload(file);
const again = await uploader.upload(file);
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 namespace is refused by Media (the token's ns is this project only).
- const wrongNs = createUploader(loadConfig({ ...env, OV_MEDIA_NAMESPACE: OTHER }), { fetch: platform.fetch });
- await assert.rejects(wrongNs.upload(file), (err) => err.status === 403 && err.code === 'capability.namespace_denied');
+ // 3. Another project's tenant is refused by Media (the token's ns is this project only).
+ 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 audience.
- const noGrant = createUploader(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANTS000000000000000', OV_CLIENT_SECRET: 'x' }), { fetch: platform.fetch });
- await assert.rejects(noGrant.upload(file), (err) => err.code === 'invalid_scope' && /request it/i.test(explain(err)));
+ // 4. 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.
// 5. 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.
// 6. Missing configuration names the variables, never their values.
- assert.throws(() => loadConfig({ OV_CLIENT_SECRET: SECRET }), (err) => err.code === 'config.missing' && /OV_CLIENT_ID/.test(err.message) && !err.message.includes(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');
@@ -1,15 +1,19 @@
@@ -1,15 +1,19 @@
#!/usr/bin/env node
#!/usr/bin/env node
'use strict';
'use strict';
/**
/**
- * Media uploader: upload a file to OpenVibe.Media as a developer app.
+ * Media uploader: upload a file to OpenVibe.Media as a developer app, read it back, delete it.
*
*
- * node --env-file=.env upload.js ./logo.png
+ * node --env-file=.env upload.js ./logo.png # media.object.upload
+ * node --env-file=.env upload.js --get <key> # media.object.read
+ * node --env-file=.env upload.js --delete <key> # media.object.upload
*
*
- * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media, scope
- * media.object.upload).
+ * 1. Ask OpenVibe.Network for an app token (client credentials, audience openvibe.media) carrying
+ * exactly the capability the operation needs (`scope`).
* 2. Find Media's origin in the platform descriptor (registry discovery), never a hard-coded host.
* 2. Find Media's origin in the platform descriptor (registry discovery), never a hard-coded host.
- * 3. Upload to the Media namespace your grant names. For a developer app that is your project id
- * (prj_…), because Network puts the project id in the token's `ns` claim.
+ * 3. Use the Media tenant of your project: the path names your project id (prj_…), the same id
+ * Network puts in the token's `project_id` and `ns` claims. Media keeps a sandbox app's files in
+ * a separate sandbox tenant of that project (you still address it by the project id) and never
+ * serves them publicly: a sandbox upload comes back with a signed, expiring `url` instead.
*
*
* Only openvibe-sdk and the public token endpoint are used. The client secret is read from the
* Only openvibe-sdk and the public token endpoint are used. The client secret is read from the
* environment and never printed.
* environment and never printed.
@@ -27,7 +31,7 @@ const TYPES = {
@@ -27,7 +31,7 @@ const TYPES = {
};
};
function loadConfig(env = process.env) {
function loadConfig(env = process.env) {
- const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_MEDIA_NAMESPACE'].filter((k) => !env[k]);
+ const missing = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET'].filter((k) => !env[k]);
if (missing.length) {
if (missing.length) {
const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);
const err = new Error(`missing environment variables: ${missing.join(', ')} (see .env.example)`);
err.code = 'config.missing';
err.code = 'config.missing';
@@ -37,37 +41,68 @@ function loadConfig(env = process.env) {
@@ -37,37 +41,68 @@ function loadConfig(env = process.env) {
network: env.OV_NETWORK_URL || 'https://openvibe.network',
network: env.OV_NETWORK_URL || 'https://openvibe.network',
clientId: env.OV_CLIENT_ID,
clientId: env.OV_CLIENT_ID,
clientSecret: env.OV_CLIENT_SECRET,
clientSecret: env.OV_CLIENT_SECRET,
- namespace: env.OV_MEDIA_NAMESPACE,
+ projectId: env.OV_PROJECT_ID || null,
mediaUrl: env.OV_MEDIA_URL || null,
mediaUrl: env.OV_MEDIA_URL || null,
};
};
}
}
+/** What a caller should see of a Media file: never more than this. */
+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_expires_at: file.url_expires_at || null,
+ sandbox: Boolean(file.sandbox),
+ size: file.size, mime: file.mime, sha256: file.sha256,
+ deduplicated: Boolean(file.deduplicated),
+ };
+}
+
function createUploader(config, { fetch } = {}) {
function createUploader(config, { fetch } = {}) {
- const tokens = createServiceTokenClient({
- network: config.network,
- clientId: config.clientId,
- clientSecret: config.clientSecret,
- scope: { 'openvibe.media': 'media.object.upload' }, // ask for exactly what this program uses
- fetch,
- });
- const client = createClient({
- network: config.network,
- fetch,
- tokenProvider: tokens,
- baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined,
- });
+ const clients = new Map();
+ /** One token client per capability, so each token carries only what that call needs. */
+ function clientFor(capability) {
+ if (!clients.has(capability)) {
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ scope: { 'openvibe.media': capability },
+ });
+ clients.set(capability, {
+ tokens,
+ client: createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: config.mediaUrl ? { media: config.mediaUrl } : undefined }),
+ });
+ }
+ return clients.get(capability);
+ }
+
+ /** The Media client for the project, for calls that need `capability`. */
+ async function mediaFor(capability) {
+ const { client, tokens } = clientFor(capability);
+ const discovery = await client.discover(); // origins + contracts version check
+ const projectId = config.projectId || (await tokens.getTokenInfo({ audience: 'openvibe.media' })).unverifiedClaims.project_id;
+ return { media: createMediaClient(client, { app: projectId, publicOrigin: await client.origin('media') }), projectId, discovery };
+ }
return {
return {
- client,
- /** Upload one file -> { key, url, public_url, size, mime, sha256, deduplicated? } */
+ /** Upload one file -> { key, url, url_expires_at, sandbox, size, mime, sha256, deduplicated, project_id, contracts } */
async upload(filePath) {
async upload(filePath) {
- const discovery = await client.discover(); // origins + contracts version check
- const media = createMediaClient(client, { app: config.namespace, publicOrigin: await client.origin('media') });
+ const { media, projectId, discovery } = await mediaFor('media.object.upload');
const bytes = await fs.promises.readFile(filePath);
const bytes = await fs.promises.readFile(filePath);
const filename = path.basename(filePath);
const filename = path.basename(filePath);
const contentType = TYPES[path.extname(filename).toLowerCase()] || 'application/octet-stream';
const contentType = TYPES[path.extname(filename).toLowerCase()] || 'application/octet-stream';
const file = await media.upload(bytes, { filename, contentType });
const file = await media.upload(bytes, { filename, contentType });
- return { ...file, contracts: discovery.contractsVersion };
+ return { ...describe(file), project_id: projectId, contracts: discovery.contractsVersion };
+ },
+ /** A file's metadata (media.object.read), or null when it does not exist in this project and environment. */
+ async get(key) {
+ const { media } = await mediaFor('media.object.read');
+ return describe(await media.files.get(key));
+ },
+ /** Delete a file (media.object.upload): true, or false when there was no such file. */
+ async remove(key) {
+ const { media } = await mediaFor('media.object.upload');
+ return media.files.delete(key);
},
},
};
};
}
}
@@ -78,31 +113,39 @@ function explain(err) {
@@ -78,31 +113,39 @@ function explain(err) {
switch (code) {
switch (code) {
case 'config.missing': return err.message;
case 'config.missing': return err.message;
case 'invalid_client': return 'Network refused the client id or secret. Check OV_CLIENT_ID / OV_CLIENT_SECRET (rotate the secret in your project if it was lost).';
case 'invalid_client': return 'Network refused the client id or secret. Check OV_CLIENT_ID / OV_CLIENT_SECRET (rotate the secret in your project if it was lost).';
- case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app. Sandbox apps only get tokens for audiences that accept sandbox traffic; see the README.';
- case 'invalid_scope': return 'The app holds no approved media.object.upload grant. Request it on your app and have a project admin approve it.';
- case 'capability.namespace_denied': return 'Your token is not valid for this namespace. OV_MEDIA_NAMESPACE must be your project id (prj_...).';
- case 'token.sandbox_refused': return 'Media does not accept sandbox tokens yet. See the README section "Running against the real platform".';
+ case 'invalid_target': return 'Network does not issue tokens for openvibe.media to this app (its environment is not enabled for that audience).';
+ case 'invalid_scope': return 'The app holds no approved grant for this operation (media.object.upload to upload or delete, media.object.read to read). Request it on your app (openvibe.codes, or the projects API).';
+ case 'capability.namespace_denied': return 'Your token is not valid for this project. OV_PROJECT_ID must be the project your app belongs to (or leave it empty).';
+ case 'token.sandbox_refused': return 'Media refused a sandbox token on this route. Sandbox apps can use their own project tenant only (/api/v1/<project id>/files).';
default: return isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : String(err && err.message);
default: return isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : String(err && err.message);
}
}
}
}
async function main(argv = process.argv.slice(2)) {
async function main(argv = process.argv.slice(2)) {
- const file = argv[0];
- if (!file) {
- console.error('usage: node upload.js <file>');
+ const [first, key] = argv;
+ if (!first || (['--get', '--delete'].includes(first) && !key)) {
+ console.error('usage: node upload.js <file> | --get <key> | --delete <key>');
return 2;
return 2;
}
}
try {
try {
const uploader = createUploader(loadConfig());
const uploader = createUploader(loadConfig());
- const out = await uploader.upload(file);
- console.log(JSON.stringify({ key: out.key, public_url: out.public_url, size: out.size, mime: out.mime, sha256: out.sha256, deduplicated: Boolean(out.deduplicated) }, null, 2));
+ if (first === '--get') {
+ const file = await uploader.get(key);
+ if (!file) { console.error(`no file ${key} in this project (and environment)`); return 1; }
+ console.log(JSON.stringify(file, null, 2));
+ } else if (first === '--delete') {
+ console.log((await uploader.remove(key)) ? `deleted ${key}` : `no file ${key}`);
+ } else {
+ const out = await uploader.upload(first);
+ console.log(JSON.stringify(out, null, 2));
+ }
return 0;
return 0;
} catch (err) {
} catch (err) {
- console.error(`upload failed: ${explain(err)}`);
+ console.error(`media failed: ${explain(err)}`);
return 1;
return 1;
}
}
}
}
if (require.main === module) main().then((code) => { process.exitCode = code; });
if (require.main === module) main().then((code) => { process.exitCode = code; });
-module.exports = { loadConfig, createUploader, explain, main };
+module.exports = { loadConfig, createUploader, describe, explain, main };
@@ -45,7 +45,8 @@ npm test
@@ -45,7 +45,8 @@ npm test
Validation is offline and works today: `node validate.js mod.json`. The `--registry` check reads
Validation is offline and works today: `node validate.js mod.json`. The `--registry` check reads
`https://openvibe.network/api/v1/registry/capabilities/<id>` (public, no credentials).
`https://openvibe.network/api/v1/registry/capabilities/<id>` (public, no credentials).
-Publishing is not public yet: registering a mod and managing installs (`games.mod.manage`) is a
-`first-party` capability, install grants (`mods.grant.manage`) are `planned`, and the manifest
-editor and release flow belong to OpenVibe.Codes, which has not launched. Mod ids are assigned
-by that flow; `--new-id` only gives a draft a well-formed id.
+Publishing a mod is not public yet: registering a mod and managing installs (`games.mod.manage`)
+is a `first-party` capability and install grants (`mods.grant.manage`) are `planned`. The
+developer portal, [OpenVibe.Codes](https://openvibe.codes), is where manifests and releases are
+meant to be managed, but a mod still cannot reach Games through it. Mod ids are assigned by that
+flow; `--new-id` only gives a draft a well-formed id.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-mod-manifest",
"name": "openvibe-example-mod-manifest",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Mod manifest: validate a mods.mod-manifest@1 document with openvibe-contracts.",
"description": "Mod manifest: validate a mods.mod-manifest@1 document with openvibe-contracts.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2",
+ "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-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.26.0"
}
}
}
}
@@ -3,5 +3,5 @@ OV_NETWORK_URL=https://openvibe.network
@@ -3,5 +3,5 @@ OV_NETWORK_URL=https://openvibe.network
OV_CLIENT_ID=
OV_CLIENT_ID=
OV_CLIENT_SECRET=
OV_CLIENT_SECRET=
# Audiences to ask for app tokens (comma-separated); /status shows the capabilities each token carries.
# Audiences to ask for app tokens (comma-separated); /status shows the capabilities each token carries.
-OV_AUDIENCES=openvibe.media,openvibe.tools
+OV_AUDIENCES=openvibe.media,openvibe.events,openvibe.tools
OV_PORT=3002
OV_PORT=3002
@@ -13,8 +13,10 @@ curl -s localhost:3002/status
@@ -13,8 +13,10 @@ curl -s localhost:3002/status
- `contracts`: the openvibe-contracts release the network runs, and whether this SDK supports it;
- `contracts`: the openvibe-contracts release the network runs, and whether this SDK supports it;
- `services`: every registered service with its status and origin (from the registry);
- `services`: every registered service with its status and origin (from the registry);
- `grants`: for each audience in `OV_AUDIENCES`, the capabilities an app token for it carries
- `grants`: for each audience in `OV_AUDIENCES`, the capabilities an app token for it carries
- (approved grants within your project's allowance), each described by the registry, plus the
- token's `project_id`, `env` and `ns`; or why Network refused (`invalid_scope`, `invalid_target`).
+ (approved grants within your project's allowance, plus the sandbox allowance for a sandbox app),
+ each described by the registry, plus the token's `project_id`, `env`, `ns` and expiry
+ (`getTokenInfo()`, decoded for display, not verified); or why Network refused (`invalid_scope`,
+ `invalid_target`).
## What it proves
## What it proves
@@ -29,7 +31,7 @@ curl -s localhost:3002/status
@@ -29,7 +31,7 @@ curl -s localhost:3002/status
| File | What |
| File | What |
|---|---|
|---|---|
-| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`), `peekClaims()` |
+| `server.js` | `loadConfig()`, `createApp()` (the HTTP server and `status()`) |
| `test/smoke.test.js` | discovery, registry, grants per audience, token caching, trace propagation |
| `test/smoke.test.js` | discovery, registry, grants per audience, token caching, trace propagation |
## Run the smoke test
## Run the smoke test
@@ -40,12 +42,13 @@ npm test
@@ -40,12 +42,13 @@ npm test
## Run it against the real platform
## Run it against the real platform
-1. Create a project and a **confidential** app ([walkthrough](../../README.md#end-to-end-walkthrough)).
-2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, set `OV_AUDIENCES` to
- the audiences you hold grants for (`openvibe.media`, `openvibe.tools`, …).
+1. Create a project and a **confidential** sandbox app and request its grants
+ ([walkthrough](../../README.md#end-to-end-walkthrough)).
+2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`. `OV_AUDIENCES` defaults
+ to the three audiences a sandbox app can get tokens for: `openvibe.media`, `openvibe.events`,
+ `openvibe.tools`.
3. `node --env-file=.env server.js` and open `http://localhost:3002/status`.
3. `node --env-file=.env server.js` and open `http://localhost:3002/status`.
-Discovery and the registry work for anyone today. The `grants` part shows a token only when
-Network will issue one: a **sandbox** app gets `invalid_target` for every audience until staff list
-that audience in `DEV_SANDBOX_AUDIENCES` (empty by default), and any app gets `invalid_scope` until
-a grant is approved inside the project's allowance (also empty by default).
+Each audience shows the token's `project_id`, `env: sandbox` and the capabilities it carries, or
+why Network refused: `invalid_scope` when no grant for that audience is approved, `invalid_target`
+for an audience that does not take sandbox tokens (for example `openvibe.network`).
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-node-server-app",
"name": "openvibe-example-node-server-app",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Node server app: client-credentials app token and registry discovery.",
"description": "Node server app: client-credentials app token and registry discovery.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
* release the network runs, checked against the range this SDK supports;
* release the network runs, checked against the range this SDK supports;
* - the registry's services;
* - the registry's services;
* - for each audience in OV_AUDIENCES, the capabilities an app token for that audience carries
* - for each audience in OV_AUDIENCES, the capabilities an app token for that audience carries
- * (approved grants within the project's allowance), described by the registry.
+ * (approved grants within the project's allowance, plus the sandbox allowance for a sandbox
+ * app), with the token's project_id and env, each capability described by the registry.
*
*
* The app token is requested from the public token endpoint and cached per audience until shortly
* The app token is requested from the public token endpoint and cached per audience until shortly
* before it expires (5-minute lifetime). The client secret is never printed or returned.
* before it expires (5-minute lifetime). The client secret is never printed or returned.
@@ -32,35 +33,29 @@ function loadConfig(env = process.env) {
@@ -32,35 +33,29 @@ function loadConfig(env = process.env) {
network: env.OV_NETWORK_URL || 'https://openvibe.network',
network: env.OV_NETWORK_URL || 'https://openvibe.network',
clientId: env.OV_CLIENT_ID,
clientId: env.OV_CLIENT_ID,
clientSecret: env.OV_CLIENT_SECRET,
clientSecret: env.OV_CLIENT_SECRET,
- audiences: String(env.OV_AUDIENCES || 'openvibe.media').split(',').map((s) => s.trim()).filter(Boolean),
+ audiences: String(env.OV_AUDIENCES || 'openvibe.media,openvibe.events,openvibe.tools').split(',').map((s) => s.trim()).filter(Boolean),
port: Number(env.OV_PORT || 3002),
port: Number(env.OV_PORT || 3002),
};
};
}
}
-/**
- * The claims of a token this process just received from Network over TLS. Only read for display;
- * the services that receive the token are the ones that verify it.
- */
-function peekClaims(token) {
- try { return JSON.parse(Buffer.from(String(token).split('.')[1], 'base64url').toString('utf8')); } catch { return {}; }
-}
-
function createApp(config, { fetch, log = console } = {}) {
function createApp(config, { fetch, log = console } = {}) {
const tokens = createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch });
const tokens = createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch });
const client = createClient({ network: config.network, fetch, tokenProvider: tokens });
const client = createClient({ network: config.network, fetch, tokenProvider: tokens });
/** What an app token for `audience` carries right now, or the reason there is none. */
/** What an app token for `audience` carries right now, or the reason there is none. */
async function grantsFor(audience, registry) {
async function grantsFor(audience, registry) {
- let token;
+ let info;
try {
try {
- token = await tokens.getToken({ audience });
+ info = await tokens.getTokenInfo({ audience });
} catch (err) {
} catch (err) {
if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'unauthorized_client'].includes(err.code)) {
if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'unauthorized_client'].includes(err.code)) {
return { audience, capabilities: [], refused: err.code, detail: err.detail || null };
return { audience, capabilities: [], refused: err.code, detail: err.detail || null };
}
}
throw err;
throw err;
}
}
- const claims = peekClaims(token);
+ // Decoded, NOT verified: fine for showing what Network granted. The services that receive
+ // the token are the ones that verify it.
+ const claims = info.unverifiedClaims || {};
const capabilities = await Promise.all((claims.cap || []).map(async (id) => {
const capabilities = await Promise.all((claims.cap || []).map(async (id) => {
const c = await registry.capability(id);
const c = await registry.capability(id);
return { id, known: Boolean(c), visibility: c ? c.visibility || null : null, description: c ? c.description || null : null };
return { id, known: Boolean(c), visibility: c ? c.visibility || null : null, description: c ? c.description || null : null };
@@ -72,7 +67,7 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -72,7 +67,7 @@ function createApp(config, { fetch, log = console } = {}) {
project_id: claims.project_id || null,
project_id: claims.project_id || null,
env: claims.env || null,
env: claims.env || null,
namespaces: claims.ns || [],
namespaces: claims.ns || [],
- expires_at: claims.exp ? new Date(claims.exp * 1000).toISOString() : null,
+ expires_at: info.expiresAt,
};
};
}
}
@@ -119,4 +114,4 @@ if (require.main === module) {
@@ -119,4 +114,4 @@ if (require.main === module) {
server.listen(config.port, () => console.log(`node-server-app: http://localhost:${config.port}/status (app ${config.clientId})`));
server.listen(config.port, () => console.log(`node-server-app: http://localhost:${config.port}/status (app ${config.clientId})`));
}
}
-module.exports = { loadConfig, createApp, peekClaims };
+module.exports = { loadConfig, createApp };
@@ -8,9 +8,6 @@ const { createApp, loadConfig } = require('../server');
@@ -8,9 +8,6 @@ const { createApp, loadConfig } = require('../server');
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'); };
-const APP = 'app_01JEXAMPLESERVER000000000';
-const PRJ = 'prj_01JEXAMPLEPROJECT00000000';
-const SECRET = 'ovsec_node_server_test_secret';
function get(port, path, headers = {}) {
function get(port, path, headers = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
@@ -24,10 +21,12 @@ function get(port, path, headers = {}) {
@@ -24,10 +21,12 @@ function get(port, path, headers = {}) {
(async () => {
(async () => {
const platform = createMockPlatform({
const platform = createMockPlatform({
- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: [PRJ] }] } },
capabilities: [{ id: 'media.object.upload', owner: 'media', visibility: 'public', status: 'active', description: 'Upload an object into a granted namespace' }],
capabilities: [{ id: 'media.object.upload', owner: 'media', visibility: 'public', status: 'active', description: 'Upload an object into a granted namespace' }],
- contractsVersion: '0.26.0',
+ contractsVersion: '0.28.0',
});
});
+ // A sandbox confidential app, as a new project has, with one Media grant and nothing for Tools.
+ const devApp = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });
+ const [APP, SECRET, PRJ] = [devApp.id, devApp.secret, devApp.projectId];
const logs = [];
const logs = [];
const log = { error: (m) => logs.push(m), log: (m) => logs.push(m) };
const log = { error: (m) => logs.push(m), log: (m) => logs.push(m) };
const app = createApp(loadConfig({ OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCES: 'openvibe.media,openvibe.tools' }), { fetch: platform.fetch, log });
const app = createApp(loadConfig({ OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCES: 'openvibe.media,openvibe.tools' }), { fetch: platform.fetch, log });
@@ -40,7 +39,7 @@ function get(port, path, headers = {}) {
@@ -40,7 +39,7 @@ function get(port, path, headers = {}) {
const s = res.body;
const s = res.body;
// Discovery: origins and the contracts version check.
// Discovery: origins and the contracts version check.
- assert.equal(s.contracts.version, '0.26.0');
+ assert.equal(s.contracts.version, '0.28.0');
assert.equal(s.contracts.compatible, true);
assert.equal(s.contracts.compatible, true);
const media = s.services.find((x) => x.id === 'media');
const media = s.services.find((x) => x.id === 'media');
assert.equal(media.origin, 'https://openvibe.media');
assert.equal(media.origin, 'https://openvibe.media');
@@ -50,6 +49,10 @@ function get(port, path, headers = {}) {
@@ -50,6 +49,10 @@ function get(port, path, headers = {}) {
assert.deepEqual(mediaGrants.capabilities.map((c) => c.id), ['media.object.upload']);
assert.deepEqual(mediaGrants.capabilities.map((c) => c.id), ['media.object.upload']);
assert.equal(mediaGrants.capabilities[0].visibility, 'public');
assert.equal(mediaGrants.capabilities[0].visibility, 'public');
assert.deepEqual(mediaGrants.namespaces, [PRJ]);
assert.deepEqual(mediaGrants.namespaces, [PRJ]);
+ assert.equal(mediaGrants.project_id, PRJ);
+ assert.equal(mediaGrants.env, 'sandbox');
+ assert.equal(mediaGrants.subject, `app:${APP}`);
+ assert.ok(Date.parse(mediaGrants.expires_at) > Date.now());
// No grant for openvibe.tools: reported, not thrown.
// No grant for openvibe.tools: reported, not thrown.
const toolsGrants = s.grants.find((g) => g.audience === 'openvibe.tools');
const toolsGrants = s.grants.find((g) => g.audience === 'openvibe.tools');
@@ -11,9 +11,9 @@ node --env-file=.env server.js # open http://localhost:3009 and click "Sign
@@ -11,9 +11,9 @@ node --env-file=.env server.js # open http://localhost:3009 and click "Sign
| Step | Where | What |
| Step | Where | What |
|---|---|---|
|---|---|---|
-| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the capability ids to ask for (`scope`) |
+| `/login` | server | `startAuthorization()` makes the PKCE pair (S256) and `state`; both stay in this browser's **server-side** session (HttpOnly, SameSite=Lax cookie); redirect to Network's `/oauth/authorize` with the `audience` and the capability ids to ask for (`scope`) |
| Network | | the person signs in and chooses to continue; Network redirects back with `code` and `state` |
| Network | | the person signs in and chooses to continue; Network redirects back with `code` and `state` |
-| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); the code is exchanged **once**, with the client secret, the PKCE verifier and the `audience`; the token is verified offline against Network's JWKS (signature, issuer, audience, expiry); the session id is replaced (no session fixation) |
+| `/callback` | server | `readCallback()` checks `state` (refuses callbacks this browser did not start, and `error=` answers); `exchangeCode()` exchanges the code **once**, with the client secret, the PKCE verifier and the same `audience`; `verifyAppToken()` checks the app token offline against Network's JWKS (signature, issuer, audience, expiry, claim shape); the session id is replaced (no session fixation) |
| `/api/me` | server | who signed in (`usr_…`), and the capabilities this app may use for them |
| `/api/me` | server | who signed in (`usr_…`), and the capabilities this app may use for them |
What comes back is an **app token acting for the person**: `actor_type: app`, `sub: app:<your app id>`,
What comes back is an **app token acting for the person**: `actor_type: app`, `sub: app:<your app id>`,
@@ -21,7 +21,8 @@ What comes back is an **app token acting for the person**: `actor_type: app`, `s
@@ -21,7 +21,8 @@ What comes back is an **app token acting for the person**: `actor_type: app`, `s
and **no refresh token**. When it expires the person signs in again. The app checks the token was
and **no refresh token**. When it expires the person signs in again. The app checks the token was
minted for itself (`sub`) before trusting `on_behalf_of`.
minted for itself (`sub`) before trusting `on_behalf_of`.
-Also covered by the test: a replayed callback, a forged `state`, a declined consent
+Also covered by the test: a replayed callback, a forged `state`, a declined sign-in
+(`oauth.access_denied`), someone outside the project trying to authorize its sandbox app
(`oauth.access_denied`), a code bound to another PKCE challenge (`invalid_grant`), sign-out, and
(`oauth.access_denied`), a code bound to another PKCE challenge (`invalid_grant`), sign-out, and
that neither the secret nor any token reaches the browser or the logs.
that neither the secret nor any token reaches the browser or the logs.
@@ -29,8 +30,8 @@ that neither the secret nor any token reaches the browser or the logs.
@@ -29,8 +30,8 @@ that neither the secret nor any token reaches the browser or the logs.
| File | What |
| File | What |
|---|---|
|---|---|
-| `server.js` | `createApp()`, `exchangeAppCode()`, `signedInSubject()` |
-| `test/smoke.test.js` | the flow against `openvibe-sdk/testing` (`platform.authorize()` stands in for the consent page) |
+| `server.js` | `createApp()`, `signedInSubject()` |
+| `test/smoke.test.js` | the flow against `openvibe-sdk/testing`, whose `/oauth/authorize` plays Network's account chooser |
## Run the smoke test
## Run the smoke test
@@ -40,23 +41,20 @@ npm test
@@ -40,23 +41,20 @@ npm test
## Run it against the real platform
## Run it against the real platform
-1. Create a project and a **confidential** app with the redirect URI
- `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough)).
- `localhost` redirect URIs are allowed for sandbox apps only; a production app needs an https one.
-2. Request a grant for every capability in `OV_SCOPE` (for example `media.object.read` for audience
- `openvibe.media`). Signing in needs at least one approved grant for `OV_AUDIENCE`.
+1. Create a project and a **confidential** sandbox app with the redirect URI
+ `http://localhost:3009/callback` ([walkthrough](../../README.md#end-to-end-walkthrough);
+ `npm run new-app` at the repository root registers exactly that one). `http://localhost`
+ redirect URIs are allowed for sandbox apps only; a production app needs an https one.
+2. Request a grant for every capability in `OV_SCOPE` (the default `media.object.upload
+ media.object.read`, audience `openvibe.media`, are both in the sandbox allowance).
3. `cp .env.example .env`, fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE`.
3. `cp .env.example .env`, fill in `OV_CLIENT_ID`, `OV_CLIENT_SECRET`, `OV_AUDIENCE`, `OV_SCOPE`.
-4. `node --env-file=.env server.js` and sign in **as a member of the project** (a sandbox app can
- only be authorized by its project's members).
+4. `node --env-file=.env server.js` and sign in **as a member of the project**: a sandbox app can
+ only be authorized by its project's members.
-What blocks it today: Network mints the token only if the app may have one for `OV_AUDIENCE`. A
-sandbox app gets `invalid_target` at the exchange until staff list that audience in
-`DEV_SANDBOX_AUDIENCES` (empty by default). A production app avoids that but needs an https
-redirect URI, so it has to run somewhere with TLS. Network's account chooser does not list the
-requested capabilities yet (no consent screen).
+What is still missing on the platform:
-## Why not `exchangeCode()`?
-
-`openvibe-sdk` 0.2.2's `exchangeCode()` requires a client secret and cannot send `audience`, which
-Network requires when the client is a developer app. `exchangeAppCode()` here calls the same
-public token endpoint through the SDK's core client (with retries off: a code works once).
+- **No consent screen.** Network's account chooser names the app (marked "third-party app") but
+ does not list the capabilities it asks for yet.
+- **No refresh tokens for apps.** The token lasts 5 minutes; then the person signs in again.
+- **No identity for apps.** An app learns the person's `usr_…` id (`on_behalf_of`) and nothing
+ else: profile lookups are `first-party` capabilities.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-oauth-app",
"name": "openvibe-example-oauth-app",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "OAuth app: server-side authorization code + PKCE exchange with OpenVibe.Network.",
"description": "OAuth app: server-side authorization code + PKCE exchange with OpenVibe.Network.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
@@ -5,11 +5,13 @@
@@ -5,11 +5,13 @@
*
*
* node --env-file=.env server.js # http://localhost:3009
* node --env-file=.env server.js # http://localhost:3009
*
*
- * /login make a PKCE pair and a state (openvibe-sdk/auth), keep them in this browser's
+ * /login make a PKCE pair and a state (openvibe-sdk/auth startAuthorization, with the
+ * audience and the capability ids to ask for), keep them in this browser's
* server-side session, redirect to Network's /oauth/authorize.
* server-side session, redirect to Network's /oauth/authorize.
* /callback check the state (readCallback), exchange the code on the server with the client
* /callback check the state (readCallback), exchange the code on the server with the client
- * secret AND the PKCE verifier, verify the token offline against Network's JWKS, then
- * start a fresh session (new session id: no session fixation).
+ * secret AND the PKCE verifier (exchangeCode, same audience), verify the app token
+ * offline against Network's JWKS (verifyAppToken), then start a fresh session (new
+ * session id: no session fixation).
* /api/me who signed in, and what this app may do on their behalf.
* /api/me who signed in, and what this app may do on their behalf.
*
*
* What comes back is an APP token acting for the person (actor_type app, on_behalf_of usr_…,
* What comes back is an APP token acting for the person (actor_type app, on_behalf_of usr_…,
@@ -19,7 +21,7 @@
@@ -19,7 +21,7 @@
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const http = require('node:http');
const http = require('node:http');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
-const { startAuthorization, readCallback, verifyUserToken } = require('openvibe-sdk/auth');
+const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');
const SESSION_COOKIE = 'ov_example_sid';
const SESSION_COOKIE = 'ov_example_sid';
const PENDING_TTL_MS = 10 * 60 * 1000;
const PENDING_TTL_MS = 10 * 60 * 1000;
@@ -42,28 +44,10 @@ function loadConfig(env = process.env) {
@@ -42,28 +44,10 @@ function loadConfig(env = process.env) {
};
};
}
}
-/**
- * POST <network>/oauth/token (authorization_code) for a developer app. openvibe-sdk 0.2.2's
- * exchangeCode() cannot send `audience`, which Network requires for app clients, so this calls
- * the public token endpoint through the SDK's core client. No retries: a code works once.
- */
-async function exchangeAppCode(client, { clientId, clientSecret, code, codeVerifier, redirectUri, audience, scope }) {
- return client.json({
- service: 'network', path: '/oauth/token', method: 'POST', auth: false, retries: 0, idempotencyKey: false,
- urlencoded: {
- grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier,
- client_id: clientId, client_secret: clientSecret || undefined, audience, scope: scope && scope.length ? scope.join(' ') : undefined,
- },
- });
-}
-
-/** Who signed in, from verified claims: an app token names them in on_behalf_of. */
+/** Who signed in, from verified app-token claims: the person is on_behalf_of, and the token must be ours. */
function signedInSubject(claims, clientId) {
function signedInSubject(claims, clientId) {
- if (claims.actor_type === 'app') {
- if (claims.sub !== `app:${clientId}`) return null; // a token minted for some other app
- return claims.on_behalf_of || null;
- }
- return claims.subject_id || null; // a plain user token
+ if (claims.actor_type !== 'app' || claims.sub !== `app:${clientId}`) return null; // minted for some other app
+ return claims.on_behalf_of || null;
}
}
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
const escapeHtml = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]);
@@ -109,7 +93,7 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -109,7 +93,7 @@ function createApp(config, { fetch, log = console } = {}) {
const found = sessionOf(req);
const found = sessionOf(req);
const sid = found ? found.sid : newSid();
const sid = found ? found.sid : newSid();
const { url, state, codeVerifier } = await startAuthorization({
const { url, state, codeVerifier } = await startAuthorization({
- network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, scope: config.scope,
+ network: config.network, clientId: config.clientId, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,
});
});
sessions.set(sid, { ...(found ? found.s : {}), pending: { state, codeVerifier, at: Date.now() } });
sessions.set(sid, { ...(found ? found.s : {}), pending: { state, codeVerifier, at: Date.now() } });
res.writeHead(302, { Location: url, 'Set-Cookie': cookie(sid, 3600), 'Cache-Control': 'no-store' });
res.writeHead(302, { Location: url, 'Set-Cookie': cookie(sid, 3600), 'Cache-Control': 'no-store' });
@@ -129,11 +113,17 @@ function createApp(config, { fetch, log = console } = {}) {
@@ -129,11 +113,17 @@ function createApp(config, { fetch, log = console } = {}) {
return page(res, 400, 'Sign-in failed', err.code === 'oauth.state_mismatch' ? 'This sign-in was not started here.' : `Network answered ${err.code}.`, err.code);
return page(res, 400, 'Sign-in failed', err.code === 'oauth.state_mismatch' ? 'This sign-in was not started here.' : `Network answered ${err.code}.`, err.code);
}
}
try {
try {
- const tokens = await exchangeAppCode(client, { ...config, code, codeVerifier: pending.codeVerifier });
+ // A code works once: exchangeCode() never retries. App tokens come without a refresh token.
+ const tokens = await exchangeCode({
+ network: config.network, fetch, clientId: config.clientId, clientSecret: config.clientSecret,
+ code, codeVerifier: pending.codeVerifier, redirectUri: config.redirectUri, audience: config.audience, scope: config.scope,
+ });
const d = await client.discover();
const d = await client.discover();
- const claims = await verifyUserToken(tokens.access_token, {
+ // acceptSandbox: signedInSubject() below only trusts a token minted for THIS app, so its
+ // env is this app's own environment (a new project's apps are sandbox).
+ const claims = await verifyAppToken(tokens.access_token, {
jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience,
jwks: d.jwksUri || `${config.network}/api/.well-known/jwks`, issuer: d.issuer || undefined, audience: config.audience,
- allowServiceTokens: true, fetch,
+ acceptSandbox: true, fetch,
});
});
const subject = signedInSubject(claims, config.clientId);
const subject = signedInSubject(claims, config.clientId);
if (!subject) return page(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', 'token.no_subject');
if (!subject) return page(res, 400, 'Sign-in failed', 'The token does not name a person for this app.', 'token.no_subject');
@@ -204,4 +194,4 @@ if (require.main === module) {
@@ -204,4 +194,4 @@ if (require.main === module) {
server.listen(config.port, () => console.log(`oauth-app: http://localhost:${config.port} (redirect URI ${config.redirectUri})`));
server.listen(config.port, () => console.log(`oauth-app: http://localhost:${config.port} (redirect URI ${config.redirectUri})`));
}
}
-module.exports = { loadConfig, createApp, exchangeAppCode, signedInSubject };
+module.exports = { loadConfig, createApp, signedInSubject };
@@ -1,58 +1,70 @@
@@ -1,58 +1,70 @@
'use strict';
'use strict';
/**
/**
* Smoke test: authorization code + PKCE with a server-side exchange, against the SDK's mock
* Smoke test: authorization code + PKCE with a server-side exchange, against the SDK's mock
- * platform. The mock has no /oauth/authorize page; platform.authorize() stands in for the person
- * choosing "Continue" on Network, and it verifies PKCE at the token endpoint like Network does.
+ * platform. Its GET /oauth/authorize plays Network's account chooser (it continues, or declines,
+ * as platform.setAuthorization() says) and its token endpoint checks PKCE, the audience the code
+ * was bound to, and that a sandbox app is authorized by a member of its project.
*/
*/
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createMockPlatform } = require('openvibe-sdk/testing');
+const { createPkcePair } = require('openvibe-sdk/auth');
const { createApp, loadConfig, signedInSubject } = require('../server');
const { createApp, loadConfig, signedInSubject } = require('../server');
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'); };
-const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';
-const SECRET = 'ovsec_oauth_app_test_secret_value';
-
(async () => {
(async () => {
- const platform = createMockPlatform({ users: [{ username: 'ana' }] });
- const ana = [...platform.state.users.values()][0];
+ const platform = createMockPlatform({ users: [{ username: 'ana' }, { username: 'bo' }] });
+ const [ana, bo] = [...platform.state.users.values()];
+ // ana owns the project; its sandbox app may only be authorized by members.
+ const project = platform.addProject({ owner: ana.subject_id, environmentPolicy: 'sandbox' });
+ const redirectUri = 'http://localhost:3009/callback';
+ const app = platform.addApp({ project, env: 'sandbox', type: 'confidential', redirectUris: [redirectUri], grants: ['media.object.upload', 'media.object.read'] });
+ const SECRET = app.secret;
+
const logs = [];
const logs = [];
const log = { log: (m) => logs.push(m), error: (m) => logs.push(m) };
const log = { log: (m) => logs.push(m), error: (m) => logs.push(m) };
- const env = { OV_CLIENT_ID: APP, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.upload media.object.read' };
- const app = createApp(loadConfig(env), { fetch: platform.fetch, log });
- await new Promise((r) => app.server.listen(0, '127.0.0.1', r));
- const base = `http://127.0.0.1:${app.server.address().port}`;
+ const env = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: SECRET, OV_AUDIENCE: 'openvibe.media', OV_SCOPE: 'media.object.read', OV_REDIRECT_URI: redirectUri };
const config = loadConfig(env);
const config = loadConfig(env);
- platform.addClient(APP, { secret: SECRET, redirectUris: [config.redirectUri] });
+ const server = createApp(config, { fetch: platform.fetch, log });
+ await new Promise((r) => server.server.listen(0, '127.0.0.1', r));
+ const base = `http://127.0.0.1:${server.server.address().port}`;
const get = (path, cookie, method = 'GET') => realFetch(`${base}${path}`, { method, redirect: 'manual', headers: cookie ? { cookie } : {} });
const get = (path, cookie, method = 'GET') => realFetch(`${base}${path}`, { method, redirect: 'manual', headers: cookie ? { cookie } : {} });
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
const cookieOf = (res) => (res.headers.get('set-cookie') || '').split(';')[0];
- /** /login -> the authorize URL's parameters and this browser's session cookie. */
+ /** /login -> the authorize URL and this browser's session cookie. */
async function startLogin() {
async function startLogin() {
const res = await get('/login');
const res = await get('/login');
assert.equal(res.status, 302);
assert.equal(res.status, 302);
- const to = new URL(res.headers.get('location'));
- return { to, cookie: cookieOf(res) };
+ return { to: new URL(res.headers.get('location')), cookie: cookieOf(res) };
+ }
+ /** The browser at Network: the account chooser answers with a redirect back to the app. */
+ async function atNetwork(to) {
+ const res = await platform.fetch(to.toString(), { redirect: 'manual' });
+ assert.equal(res.status, 302);
+ const back = new URL(res.headers.get('location'));
+ assert.equal(`${back.origin}${back.pathname}`, redirectUri);
+ return `${back.pathname}${back.search}`;
}
}
- const callbackPath = (params) => `/callback?${new URLSearchParams(params)}`;
- // 1. Sign in.
+ // 1. Sign in as ana (a project member).
+ platform.setAuthorization({ subjectId: ana.subject_id });
const { to, cookie } = await startLogin();
const { to, cookie } = await startLogin();
assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
assert.equal(`${to.origin}${to.pathname}`, 'https://openvibe.network/oauth/authorize');
- assert.equal(to.searchParams.get('client_id'), APP);
- assert.equal(to.searchParams.get('redirect_uri'), config.redirectUri);
+ assert.equal(to.searchParams.get('client_id'), app.id);
+ assert.equal(to.searchParams.get('redirect_uri'), redirectUri);
assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
assert.equal(to.searchParams.get('code_challenge_method'), 'S256');
assert.match(to.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
assert.match(to.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
- assert.equal(to.searchParams.get('scope'), 'media.object.upload media.object.read');
+ assert.equal(to.searchParams.get('audience'), 'openvibe.media');
+ assert.equal(to.searchParams.get('scope'), 'media.object.read', 'capability ids, not "profile theme"');
assert.match(cookie, /^ov_example_sid=/);
assert.match(cookie, /^ov_example_sid=/);
+ assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');
const res1 = await get('/login');
const res1 = await get('/login');
assert.match(res1.headers.get('set-cookie'), /HttpOnly; SameSite=Lax/);
assert.match(res1.headers.get('set-cookie'), /HttpOnly; SameSite=Lax/);
- assert.ok(!to.toString().includes(SECRET), 'the secret never goes to the browser');
- const code = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: to.searchParams.get('code_challenge'), scope: to.searchParams.get('scope') });
- const cb = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);
+ const callbackPath = await atNetwork(to);
+ const cb = await get(callbackPath, cookie);
assert.equal(cb.status, 302);
assert.equal(cb.status, 302);
assert.equal(cb.headers.get('location'), '/');
assert.equal(cb.headers.get('location'), '/');
const signedIn = cookieOf(cb);
const signedIn = cookieOf(cb);
@@ -62,52 +74,65 @@ const SECRET = 'ovsec_oauth_app_test_secret_value';
@@ -62,52 +74,65 @@ const SECRET = 'ovsec_oauth_app_test_secret_value';
assert.equal(me.status, 200);
assert.equal(me.status, 200);
const body = await me.json();
const body = await me.json();
assert.equal(body.subject, ana.subject_id);
assert.equal(body.subject, ana.subject_id);
+ assert.equal(body.app, `app:${app.id}`);
+ assert.equal(body.project_id, project);
+ assert.equal(body.env, 'sandbox');
+ assert.deepEqual(body.capabilities, ['media.object.read'], 'only what was asked for, not everything the app holds');
assert.ok(!JSON.stringify(body).includes('access_token'));
assert.ok(!JSON.stringify(body).includes('access_token'));
assert.match(await (await get('/', signedIn)).text(), new RegExp(`Signed in with OpenVibe as <code>${ana.subject_id}</code>`));
assert.match(await (await get('/', signedIn)).text(), new RegExp(`Signed in with OpenVibe as <code>${ana.subject_id}</code>`));
- // The exchange sent the verifier, the secret and the audience to the token endpoint.
- const exchange = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token'));
- assert.equal(exchange.length, 1);
+ // The exchange sent the verifier, the secret and the audience to the token endpoint, once.
+ assert.equal(platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).length, 1);
assert.equal((await get('/api/me', cookie)).status, 401, 'the pre-login session id is dead');
assert.equal((await get('/api/me', cookie)).status, 401, 'the pre-login session id is dead');
// 2. The same callback again (replayed link): refused, no second exchange.
// 2. The same callback again (replayed link): refused, no second exchange.
- const replay = await get(callbackPath({ code, state: to.searchParams.get('state') }), cookie);
+ const replay = await get(callbackPath, cookie);
assert.equal(replay.status, 400);
assert.equal(replay.status, 400);
// 3. A callback this browser did not start (state mismatch, login CSRF): refused before any exchange.
// 3. A callback this browser did not start (state mismatch, login CSRF): refused before any exchange.
const second = await startLogin();
const second = await startLogin();
- const mismatch = await get(callbackPath({ code: 'whatever', state: 'not-the-state' }), second.cookie);
+ const mismatch = await get(`/callback?${new URLSearchParams({ code: 'whatever', state: 'not-the-state' })}`, second.cookie);
assert.equal(mismatch.status, 400);
assert.equal(mismatch.status, 400);
assert.match(await mismatch.text(), /oauth\.state_mismatch/);
assert.match(await mismatch.text(), /oauth\.state_mismatch/);
// 4. The person declined on Network.
// 4. The person declined on Network.
+ platform.setAuthorization({ decision: 'deny' });
const third = await startLogin();
const third = await startLogin();
- const denied = await get(callbackPath({ error: 'access_denied', state: third.to.searchParams.get('state') }), third.cookie);
+ const denied = await get(await atNetwork(third.to), third.cookie);
assert.equal(denied.status, 400);
assert.equal(denied.status, 400);
assert.match(await denied.text(), /oauth\.access_denied/);
assert.match(await denied.text(), /oauth\.access_denied/);
+ platform.setAuthorization({ decision: 'allow' });
- // 5. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.
+ // 5. Someone who is not a member of the project cannot authorize its sandbox app.
+ platform.setAuthorization({ subjectId: bo.subject_id });
const fourth = await startLogin();
const fourth = await startLogin();
- const other = await require('openvibe-sdk/auth').createPkcePair();
- const stolen = platform.authorize({ clientId: APP, redirectUri: config.redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge });
- const pkce = await get(callbackPath({ code: stolen, state: fourth.to.searchParams.get('state') }), fourth.cookie);
+ const outsider = await get(await atNetwork(fourth.to), fourth.cookie);
+ assert.equal(outsider.status, 400);
+ assert.match(await outsider.text(), /oauth\.access_denied/);
+ platform.setAuthorization({ subjectId: ana.subject_id });
+
+ // 6. A code bound to a different PKCE challenge (stolen code): the token endpoint refuses it.
+ const fifth = await startLogin();
+ const other = await createPkcePair();
+ const stolen = platform.authorize({ clientId: app.id, redirectUri, subjectId: ana.subject_id, codeChallenge: other.codeChallenge, audience: 'openvibe.media', scope: 'media.object.read' });
+ const pkce = await get(`/callback?${new URLSearchParams({ code: stolen, state: fifth.to.searchParams.get('state') })}`, fifth.cookie);
assert.equal(pkce.status, 400);
assert.equal(pkce.status, 400);
assert.match(await pkce.text(), /invalid_grant/);
assert.match(await pkce.text(), /invalid_grant/);
- // 6. Sign out.
+ // 7. Sign out.
const out = await get('/logout', signedIn, 'POST');
const out = await get('/logout', signedIn, 'POST');
assert.equal(out.status, 303);
assert.equal(out.status, 303);
assert.equal((await get('/api/me', signedIn)).status, 401);
assert.equal((await get('/api/me', signedIn)).status, 401);
// App tokens name the person in on_behalf_of and must be for this app.
// App tokens name the person in on_behalf_of and must be for this app.
- assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${APP}`, on_behalf_of: 'usr_X' }, APP), 'usr_X');
- assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, APP), null);
+ assert.equal(signedInSubject({ actor_type: 'app', sub: `app:${app.id}`, on_behalf_of: 'usr_X' }, app.id), 'usr_X');
+ assert.equal(signedInSubject({ actor_type: 'app', sub: 'app:app_SOMEONEELSE', on_behalf_of: 'usr_X' }, app.id), null);
// Nothing secret in the logs.
// Nothing secret in the logs.
const text = logs.join('\n');
const text = logs.join('\n');
assert.ok(!text.includes(SECRET));
assert.ok(!text.includes(SECRET));
assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(text), 'no tokens in logs');
assert.ok(!/eyJ[\w-]+\.[\w-]+\./.test(text), 'no tokens in logs');
- app.server.close();
+ server.server.close();
console.log('oauth-app: ok');
console.log('oauth-app: ok');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -1,38 +1,38 @@
@@ -1,38 +1,38 @@
# Tool job
# Tool job
-Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app, and survive
-dropped connections and restarts without running the job twice.
+Run an OpenVibe.Tools job (here: convert an image to WebP) as a developer app with
+`openvibe-sdk/jobs`, and survive dropped connections and restarts without running the job twice.
```bash
```bash
node --env-file=.env run-job.js ./photo.png
node --env-file=.env run-job.js ./photo.png
# submitted job_01K… (queued)
# submitted job_01K… (queued)
# job.queued
# job.queued
# job.running
# job.running
-# job.progress 50% converting
+# job.progress 20% Processing
# job.succeeded 100%
# job.succeeded 100%
# saved ./out/photo.webp
# saved ./out/photo.webp
```
```
## What it proves
## What it proves
-- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both `public`
- capabilities a developer app can hold).
-- **Submit once.** `POST /api/v1/jobs` carries an `Idempotency-Key` derived from the request
+- An app token for `openvibe.tools` scoped to `tools.job.create tools.job.read` (both in the
+ sandbox allowance: a sandbox app gets them without staff).
+- **Submit once.** `jobs.submit()` carries an `Idempotency-Key` derived from the request
(type, input, file). If the process dies after submitting, running it again gets the **same**
(type, input, file). If the process dies after submitting, running it again gets the **same**
- job back (`200 Idempotent-Replayed: true`), not a second one.
+ job back (`replayed: true`), not a second one.
- **Reattach.** The job id and the last event id are kept in `.job-state.json`; a restart
- **Reattach.** The job id and the last event id are kept in `.job-state.json`; a restart
reattaches to the job instead of submitting.
reattaches to the job instead of submitting.
-- **Resume the stream.** `GET /api/v1/jobs/:id/events` (SSE) is reopened with `Last-Event-ID`
- after a drop, and Tools replays only later events: every event is seen once, in order.
- `204` means the job finished and nothing is newer.
-- The result files are downloaded with the same token. Jobs are owner-scoped: another app gets `404`.
+- **Resume the stream.** `jobs.events()` reopens the job's SSE stream with `Last-Event-ID` after a
+ drop, and Tools replays only later events: every event is seen once, in order. A finished job
+ with nothing newer answers `204` and the stream ends.
+- The result files are downloaded with `jobs.file()` and the same token. Jobs are owner-scoped:
+ another app gets `404` (`jobs.get()` returns `null`).
## Files
## Files
| File | What |
| File | What |
|---|---|
|---|---|
-| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), `readEventStream()`, CLI |
-| `test/mock-tools-server.js` | a local `/api/v1/jobs` that behaves as documented (the SDK mock has no Tools) |
+| `run-job.js` | `createJobRunner()` (`submit`, `watch`, `get`, `download`, `run`), CLI |
| `test/smoke.test.js` | full run with a dropped stream, reattach after a crash, idempotent resubmit, 204, owner scoping, no grant |
| `test/smoke.test.js` | full run with a dropped stream, reattach after a crash, idempotent resubmit, 204, owner scoping, no grant |
## Run the smoke test
## Run the smoke test
@@ -41,19 +41,19 @@ node --env-file=.env run-job.js ./photo.png
@@ -41,19 +41,19 @@ node --env-file=.env run-job.js ./photo.png
npm test
npm test
```
```
-Network is the SDK's mock platform; the Tools mock verifies its tokens (RS256, audience
-`openvibe.tools`, the `tools.job.*` capability) and plays queued → running → progress → succeeded.
+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.
## Run it against the real platform
## Run it against the real platform
-1. Create a project and a **confidential** app, request `tools.job.create` and `tools.job.read`
- ([walkthrough](../../README.md#end-to-end-walkthrough)).
+1. Create a project and a **confidential** sandbox app with `tools.job.create` and
+ `tools.job.read` ([walkthrough](../../README.md#end-to-end-walkthrough)).
2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.
2. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`.
3. `node --env-file=.env run-job.js ./photo.png`
3. `node --env-file=.env run-job.js ./photo.png`
-`img.process` runs on `https://img.openvibe.tools`; audio and docs jobs run on their own
-satellites (set `OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts app tokens on
-this API today. What has to be true on the platform side first: the capabilities are in your
-project's allowance, and your app can get an `openvibe.tools` token — a **sandbox** app cannot
-until staff list `openvibe.tools` in Network's `DEV_SANDBOX_AUDIENCES` (and Tools accepts
-sandbox tokens), so use a production app.
+`img.process` runs on `https://img.openvibe.tools` (`input.tool` is one of its tools, for example
+`convert` with `format`); audio and docs jobs run on their own satellites (set
+`OV_TOOLS_JOBS_URL`, `OV_JOB_TYPE`, `OV_JOB_INPUT`). Tools accepts sandbox app tokens on
+`/api/v1/jobs`: a sandbox project may have 2 active jobs of at most 30 minutes, and its results are
+never copied to Media.
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-tool-job",
"name": "openvibe-example-tool-job",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Tool job: submit an OpenVibe.Tools job and reattach to its progress stream.",
"description": "Tool job: submit an OpenVibe.Tools job and reattach to its progress stream.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
@@ -6,26 +6,21 @@
@@ -6,26 +6,21 @@
* node --env-file=.env run-job.js ./photo.png # img.process, convert to WebP by default
* node --env-file=.env run-job.js ./photo.png # img.process, convert to WebP by default
*
*
* 1. App token for openvibe.tools (client credentials, scope tools.job.create tools.job.read).
* 1. App token for openvibe.tools (client credentials, scope tools.job.create tools.job.read).
- * 2. POST /api/v1/jobs (multipart: type, input, file) with an Idempotency-Key derived from the
- * request, so re-running after a crash gets the SAME job back (200 Idempotent-Replayed).
+ * Tools accepts sandbox app tokens (a sandbox project may have 2 active jobs, 30 minutes each).
+ * 2. openvibe-sdk/jobs submit() with an Idempotency-Key derived from the request, so re-running
+ * after a crash gets the SAME job back (Tools answers 200 Idempotent-Replayed).
* 3. The job id and the last event id are saved in a state file. Running the script again
* 3. The job id and the last event id are saved in a state file. Running the script again
* reattaches to the job instead of submitting a new one.
* reattaches to the job instead of submitting a new one.
- * 4. GET /api/v1/jobs/:id/events (SSE). When the stream drops, reconnect with Last-Event-ID: the
- * server replays only later events. 204 means the job finished and nothing is newer.
- * 5. GET /api/v1/jobs/:id for the result, then download each result file.
- *
- * openvibe-sdk has no jobs client yet (sdk-jobs), so the job routes are called with the SDK's core
- * client, which still supplies the token, deadlines, safe retries, problem errors and tracing. The
- * event stream is the exception: the core client cannot return a streaming body, so it is read
- * with fetch and the same app token.
+ * 4. jobs.events() follows the job's SSE stream and reconnects with Last-Event-ID after a drop;
+ * every event arrives once, in order, and a finished job ends the stream (204).
+ * 5. jobs.get() for the result, jobs.file() for each result file.
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const fs = require('node:fs');
const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
-const { createClient, isOpenVibeError, OpenVibeError, startSpan } = require('openvibe-sdk/core');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
-
-const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const { createJobsClient } = require('openvibe-sdk/jobs');
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]);
@@ -48,47 +43,13 @@ function loadConfig(env = process.env) {
@@ -48,47 +43,13 @@ function loadConfig(env = process.env) {
};
};
}
}
-/** Parse a text/event-stream body: calls onEvent({ id, event, data }) per event, onRetry(ms). */
-async function readEventStream(body, onEvent, onRetry = () => {}) {
- const reader = body.getReader();
- const decoder = new TextDecoder();
- let buf = '';
- let cur = { data: [] };
- const line = (l) => {
- if (l === '') {
- if (cur.data.length) onEvent({ id: cur.id, event: cur.event || 'message', data: cur.data.join('\n') });
- cur = { data: [] };
- return;
- }
- if (l.startsWith(':')) return;
- const i = l.indexOf(':');
- const field = i < 0 ? l : l.slice(0, i);
- const value = i < 0 ? '' : l.slice(i + 1).replace(/^ /, '');
- if (field === 'data') cur.data.push(value);
- else if (field === 'event') cur.event = value;
- else if (field === 'id') cur.id = value;
- else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));
- };
- for (;;) {
- const { value, done } = await reader.read();
- if (done) break;
- buf += decoder.decode(value, { stream: true });
- let m;
- while ((m = /\r\n|\n|\r/.exec(buf))) {
- if (m[0] === '\r' && m.index === buf.length - 1) break;
- line(buf.slice(0, m.index));
- buf = buf.slice(m.index + m[0].length);
- }
- }
-}
-
-function createJobRunner(config, { fetch, log = console } = {}) {
+function createJobRunner(config, { fetch, log = console, reconnectDelayMs } = {}) {
const tokens = createServiceTokenClient({
const tokens = createServiceTokenClient({
network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
scope: { 'openvibe.tools': 'tools.job.create tools.job.read' },
scope: { 'openvibe.tools': 'tools.job.create tools.job.read' },
});
});
- const client = createClient({ network: config.network, fetch, tokenProvider: tokens, baseUrls: { tools: config.toolsUrl } });
- const fetchImpl = fetch || globalThis.fetch;
+ const client = createClient({ network: config.network, fetch, tokenProvider: tokens });
+ const jobs = createJobsClient(client, { baseUrl: config.toolsUrl });
const loadState = () => { try { return JSON.parse(fs.readFileSync(config.statePath, 'utf8')); } catch { return null; } };
const loadState = () => { try { return JSON.parse(fs.readFileSync(config.statePath, 'utf8')); } catch { return null; } };
const saveState = (s) => {
const saveState = (s) => {
@@ -107,82 +68,37 @@ function createJobRunner(config, { fetch, log = console } = {}) {
@@ -107,82 +68,37 @@ function createJobRunner(config, { fetch, log = console } = {}) {
log.log(`reattaching to ${saved.jobId}`);
log.log(`reattaching to ${saved.jobId}`);
return { jobId: saved.jobId, reattached: true };
return { jobId: saved.jobId, reattached: true };
}
}
- const form = new FormData();
- form.append('type', config.type);
- form.append('input', JSON.stringify(config.input));
- form.append('file', new Blob([bytes]), path.basename(filePath));
- const res = await client.request({
- service: 'tools', method: 'POST', path: '/api/v1/jobs', form,
- idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash
+ const { job, replayed } = await jobs.submit({
+ type: config.type, input: config.input,
+ files: [{ name: path.basename(filePath), data: bytes }],
+ idempotencyKey: `ex-${fingerprint.slice(0, 40)}`, // same request -> same job, even after a crash
});
});
- const job = res.data;
saveState({ jobId: job.id, fingerprint, lastEventId: 0, submittedAt: new Date().toISOString() });
saveState({ jobId: job.id, fingerprint, lastEventId: 0, submittedAt: new Date().toISOString() });
- log.log(`${res.headers.get('idempotent-replayed') ? 'existing' : 'submitted'} ${job.id} (${job.state})`);
- return { jobId: job.id, reattached: false, replayed: Boolean(res.headers.get('idempotent-replayed')) };
- }
-
- /**
- * Open the job's event stream. The SDK's core client cannot hand back a streaming body
- * (responseType 'response' drops the Response), so this one call uses fetch directly with the
- * same app token.
- */
- async function openStream(jobId, lastEventId) {
- const url = `${config.toolsUrl.replace(/\/+$/, '')}/api/v1/jobs/${encodeURIComponent(jobId)}/events`;
- for (let attempt = 0; attempt < 2; attempt++) {
- const token = await tokens.getToken({ audience: 'openvibe.tools' });
- const res = await fetchImpl(url, {
- headers: { Accept: 'text/event-stream', Authorization: `Bearer ${token}`, 'Last-Event-ID': String(lastEventId), traceparent: startSpan().traceparent },
- });
- if (res.status === 401 && attempt === 0) { tokens.invalidate({ audience: 'openvibe.tools' }); continue; }
- if (!res.ok) {
- const body = await res.json().catch(() => null);
- throw OpenVibeError.fromResponse({ status: res.status, body, method: 'GET', url });
- }
- return res;
- }
- throw new Error('unreachable');
+ log.log(`${replayed ? 'existing' : 'submitted'} ${job.id} (${job.state})`);
+ return { jobId: job.id, reattached: false, replayed };
}
}
- /** Follow the job's events until it finishes, reconnecting with Last-Event-ID. */
- async function watch(jobId, { onEvent = () => {}, maxReconnects = 20 } = {}) {
- let retryMs = 1000;
- let reconnects = 0;
- for (;;) {
- const state = loadState() || {};
- const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;
- let finished = false;
- try {
- const res = await openStream(jobId, lastEventId);
- if (res.status === 204) return; // finished, nothing newer
- await readEventStream(res.body, (e) => {
- let data = null;
- try { data = JSON.parse(e.data); } catch { /* keep null */ }
- if (e.id) saveState({ ...(loadState() || {}), jobId, lastEventId: Number(e.id) });
- onEvent({ id: Number(e.id), event: e.event, job: data });
- if (data && TERMINAL.has(data.state) && e.event !== 'job.progress') finished = true;
- }, (ms) => { retryMs = ms; });
- } catch (err) {
- if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err; // 401/403/404: do not loop
- log.error(`event stream interrupted: ${isOpenVibeError(err) ? err.code : err.message}`);
- }
- if (finished) return;
- if (++reconnects > maxReconnects) throw new Error(`gave up after ${maxReconnects} reconnects`);
- await new Promise((r) => setTimeout(r, retryMs));
+ /** Follow the job's events until it finishes; the last event id survives a restart. */
+ async function watch(jobId, { onEvent = () => {} } = {}) {
+ const state = loadState() || {};
+ const lastEventId = state.jobId === jobId ? state.lastEventId || 0 : 0;
+ for await (const e of jobs.events(jobId, { lastEventId, reconnectDelayMs })) {
+ if (e.id != null) saveState({ ...(loadState() || {}), jobId, lastEventId: e.id });
+ await onEvent(e);
}
}
}
}
- async function get(jobId) {
- return client.json({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(jobId)}` });
- }
+ /** The job, or null when it does not exist or is not yours (Tools answers both with 404). */
+ const get = (jobId) => jobs.get(jobId);
async function download(job) {
async function download(job) {
const saved = [];
const saved = [];
fs.mkdirSync(config.outDir, { recursive: true });
fs.mkdirSync(config.outDir, { recursive: true });
for (const [n, f] of ((job.result && job.result.files) || []).entries()) {
for (const [n, f] of ((job.result && job.result.files) || []).entries()) {
- const res = await client.request({ service: 'tools', path: `/api/v1/jobs/${encodeURIComponent(job.id)}/files/${n}`, responseType: 'arrayBuffer' });
+ const res = await jobs.file(job.id, n);
const name = path.basename(String(f.name || `result-${n}`)).replace(/[^\w.-]/g, '_');
const name = path.basename(String(f.name || `result-${n}`)).replace(/[^\w.-]/g, '_');
const out = path.join(config.outDir, name);
const out = path.join(config.outDir, name);
- fs.writeFileSync(out, Buffer.from(res.data));
+ fs.writeFileSync(out, Buffer.from(await res.arrayBuffer()));
saved.push(out);
saved.push(out);
}
}
return saved;
return saved;
@@ -193,12 +109,12 @@ function createJobRunner(config, { fetch, log = console } = {}) {
@@ -193,12 +109,12 @@ function createJobRunner(config, { fetch, log = console } = {}) {
const { jobId } = await submit(filePath);
const { jobId } = await submit(filePath);
await watch(jobId, { onEvent });
await watch(jobId, { onEvent });
const job = await get(jobId);
const job = await get(jobId);
- const files = job.state === 'succeeded' ? await download(job) : [];
+ const files = job && job.state === 'succeeded' ? await download(job) : [];
clearState();
clearState();
return { job, files };
return { job, files };
}
}
- return { client, submit, watch, get, download, run, loadState };
+ return { client, jobs, submit, watch, get, download, run, loadState };
}
}
async function main([file] = process.argv.slice(2)) {
async function main([file] = process.argv.slice(2)) {
@@ -211,18 +127,18 @@ async function main([file] = process.argv.slice(2)) {
@@ -211,18 +127,18 @@ async function main([file] = process.argv.slice(2)) {
console.log(`${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);
console.log(`${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);
},
},
});
});
- if (job.state !== 'succeeded') {
- console.error(`job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}`);
+ if (!job || job.state !== 'succeeded') {
+ console.error(job ? `job ${job.id} ${job.state}${job.error ? `: ${job.error.code} ${job.error.detail || ''}` : ''}` : 'the job is gone (expired, or not this app\'s)');
return 1;
return 1;
}
}
for (const f of files) console.log(`saved ${f}`);
for (const f of files) console.log(`saved ${f}`);
return 0;
return 0;
} catch (err) {
} catch (err) {
- console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);
+ console.error(`job failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` (request ${err.requestId})` : ''}` : err.message}`);
return 1;
return 1;
}
}
}
}
if (require.main === module) main().then((code) => { process.exitCode = code; });
if (require.main === module) main().then((code) => { process.exitCode = code; });
-module.exports = { loadConfig, createJobRunner, readEventStream, main };
+module.exports = { loadConfig, createJobRunner, main };
deleted file mode 100644
deleted file mode 100644
@@ -1,173 +0,0 @@
@@ -1,173 +0,0 @@
-'use strict';
-/**
- * A small local stand-in for OpenVibe.Tools' job API (/api/v1/jobs), for tests only. The SDK's
- * createMockPlatform() has no Tools, so this mock plays the documented behaviour:
- *
- * POST /api/v1/jobs multipart type/input/file, Idempotency-Key -> 202 (200 + Idempotent-Replayed on repeat)
- * GET /api/v1/jobs/:id the job view
- * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events; 204 when finished and nothing newer
- * GET /api/v1/jobs/:id/files/:n a result file
- *
- * Tokens are verified against the mock platform's key: RS256, audience openvibe.tools, the
- * tools.job.* capability for the action. Jobs are owner-scoped (the token's sub).
- */
-const crypto = require('node:crypto');
-const http = require('node:http');
-
-const ULID = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
-const newJobId = () => `job_${Array.from(crypto.randomBytes(26), (b) => ULID[b & 31]).join('')}`;
-const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
-const CAPS = { create: 'tools.job.create', read: 'tools.job.read' };
-
-function createMockToolsServer({ publicKey, stepMs = 25, dropStreamsAfter = 0 } = {}) {
- const jobs = new Map();
- const idem = new Map();
- const listeners = new Map();
- const stats = { submits: 0, streams: [], replays: 0 };
- let drops = dropStreamsAfter;
-
- function verify(req, action) {
- const auth = String(req.headers.authorization || '');
- const parts = auth.startsWith('Bearer ') ? auth.slice(7).split('.') : [];
- if (parts.length !== 3) return { error: [401, 'token.missing'] };
- let header, claims;
- try {
- header = JSON.parse(Buffer.from(parts[0], 'base64url'));
- claims = JSON.parse(Buffer.from(parts[1], 'base64url'));
- } catch { return { error: [401, 'token.malformed'] }; }
- if (header.alg !== 'RS256' || !crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicKey, Buffer.from(parts[2], 'base64url'))) return { error: [401, 'token.bad_signature'] };
- if (!claims.exp || claims.exp * 1000 < Date.now()) return { error: [401, 'token.expired'] };
- if (!(claims.aud || []).includes('openvibe.tools')) return { error: [401, 'token.wrong_audience'] };
- if (!(claims.cap || []).includes(CAPS[action])) return { error: [403, 'capability.denied'] };
- return { owner: claims.sub };
- }
-
- function view(job) {
- return {
- id: job.id, object: 'tools.job', service: 'img', type: job.type, type_version: 1, state: job.state,
- progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 2,
- result: job.result ? { files: job.result.files.map((f, i) => ({ name: f.name, mime: f.mime, size: f.bytes.length, url: `/api/v1/jobs/${job.id}/files/${i}` })), data: job.result.data } : null,
- error: null, retryable: false,
- links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: TERMINAL.has(job.state) ? null : `/api/v1/jobs/${job.id}` },
- };
- }
-
- function emit(job, event) {
- const e = { seq: job.events.length + 1, event, data: view(job) };
- job.events.push(e);
- for (const fn of listeners.get(job.id) || []) fn(e);
- }
-
- function lifecycle(job) {
- const steps = [
- () => { job.state = 'running'; emit(job, 'job.running'); },
- () => { job.percent = 50; job.message = 'converting'; emit(job, 'job.progress'); },
- () => {
- job.state = 'succeeded'; job.percent = 100; job.message = null;
- job.result = { files: [{ name: job.fileName.replace(/\.[^.]+$/, '') + '.' + (job.input.format || 'out'), mime: `image/${job.input.format || 'png'}`, bytes: Buffer.concat([Buffer.from('converted:'), job.bytes]) }], data: { tool: job.input.tool } };
- emit(job, 'job.succeeded');
- },
- ];
- const next = () => { const s = steps.shift(); if (s) { s(); setTimeout(next, stepMs); } };
- setTimeout(next, stepMs);
- }
-
- const problem = (res, status, code, detail) => {
- res.writeHead(status, { 'Content-Type': 'application/problem+json' });
- res.end(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail: detail || code }));
- };
-
- const server = http.createServer(async (req, res) => {
- const url = new URL(req.url, 'http://tools.invalid');
- let m;
- if (req.method === 'POST' && url.pathname === '/api/v1/jobs') {
- const who = verify(req, 'create');
- if (who.error) return problem(res, ...who.error);
- const chunks = [];
- for await (const c of req) chunks.push(c);
- const raw = Buffer.concat(chunks);
- const form = await new Request('http://tools.invalid/', { method: 'POST', headers: { 'content-type': req.headers['content-type'] }, body: raw }).formData();
- const key = String(req.headers['idempotency-key'] || '');
- if (key.length < 8 || key.length > 200) return problem(res, 400, 'tools.job.invalid', 'Idempotency-Key must be 8-200 characters');
- // Like Tools: the request is identified by type, input and the files' hashes (not the multipart bytes).
- const fileHashes = [];
- for (const f of form.getAll('file')) if (typeof f !== 'string') fileHashes.push(crypto.createHash('sha256').update(Buffer.from(await f.arrayBuffer())).digest('hex'));
- const hash = crypto.createHash('sha256').update(JSON.stringify([String(form.get('type')), String(form.get('input') || '{}'), fileHashes])).digest('hex');
- const prior = idem.get(`${who.owner}|${key}`);
- if (prior) {
- if (prior.hash !== hash) return problem(res, 409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
- stats.replays++;
- res.writeHead(200, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true' });
- return res.end(JSON.stringify(view(jobs.get(prior.id))));
- }
- const file = form.get('file');
- if (!file || typeof file === 'string') return problem(res, 400, 'tools.job.invalid', 'one file is required');
- stats.submits++;
- const job = {
- id: newJobId(), owner: who.owner, type: String(form.get('type')), input: JSON.parse(String(form.get('input') || '{}')),
- state: 'queued', percent: null, message: null, events: [], result: null,
- fileName: file.name, bytes: Buffer.from(await file.arrayBuffer()),
- };
- jobs.set(job.id, job);
- idem.set(`${who.owner}|${key}`, { id: job.id, hash });
- emit(job, 'job.queued');
- lifecycle(job);
- res.writeHead(202, { 'Content-Type': 'application/json', Location: `/api/v1/jobs/${job.id}` });
- return res.end(JSON.stringify(view(job)));
- }
- if ((m = url.pathname.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/)) && req.method === 'GET') {
- const who = verify(req, 'read');
- if (who.error) return problem(res, ...who.error);
- const job = jobs.get(m[1]);
- if (!job || job.owner !== who.owner) return problem(res, 404, 'tools.job.not_found', 'No such job');
- if (!m[2]) {
- res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
- return res.end(JSON.stringify(view(job)));
- }
- if (m[3] !== undefined) {
- if (job.state !== 'succeeded') return problem(res, 409, 'tools.job.not_ready', `The job is ${job.state}`);
- const f = job.result.files[Number(m[3])];
- if (!f) return problem(res, 404, 'tools.job.file_not_found', 'No such result file');
- res.writeHead(200, { 'Content-Type': f.mime, 'Content-Disposition': `attachment; filename="${f.name}"` });
- return res.end(f.bytes);
- }
- const after = Math.max(0, parseInt(req.headers['last-event-id'] || url.searchParams.get('last_event_id') || '0', 10) || 0);
- stats.streams.push(after);
- const pending = job.events.filter((e) => e.seq > after);
- if (TERMINAL.has(job.state) && !pending.length) { res.writeHead(204); return res.end(); }
- res.writeHead(200, { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' });
- res.write(`retry: ${stepMs}\n\n`);
- let last = after;
- let written = 0;
- const set = listeners.get(job.id) || new Set();
- listeners.set(job.id, set);
- const close = () => { set.delete(write); res.end(); };
- const write = (e) => {
- if (e.seq <= last || res.writableEnded) return;
- last = e.seq;
- res.write(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`);
- written++;
- if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') return setImmediate(close);
- if (drops > 0 && written >= 1) { drops--; setImmediate(close); } // simulate a dropped connection
- };
- set.add(write);
- for (const e of job.events) write(e);
- req.on('close', () => set.delete(write));
- return undefined;
- }
- return problem(res, 404, 'not_found', 'Not found');
- });
-
- return {
- server,
- stats,
- jobs,
- async listen() {
- await new Promise((r) => server.listen(0, '127.0.0.1', r));
- return `http://127.0.0.1:${server.address().port}`;
- },
- close: () => new Promise((r) => { server.closeAllConnections(); server.close(r); }),
- };
-}
-
-module.exports = { createMockToolsServer };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
* Smoke test: submit -> watch (with a dropped stream) -> result -> download, reattach after a
* Smoke test: submit -> watch (with a dropped stream) -> result -> download, reattach after a
- * restart, idempotent resubmission. Network is the SDK's mock platform; Tools is the local mock in
- * this folder (the SDK mock has no Tools). No network.
+ * restart, idempotent resubmission, owner scoping. Network and Tools jobs are openvibe-sdk/testing's
+ * mock platform (jobs: true), with a sandbox app as a new project has. No network.
*/
*/
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fs = require('node:fs');
@@ -10,44 +10,56 @@ const os = require('node:os');
@@ -10,44 +10,56 @@ 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 { createJobRunner, loadConfig } = require('../run-job');
const { createJobRunner, loadConfig } = require('../run-job');
-const { createMockToolsServer } = require('./mock-tools-server');
-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'); };
-const APP = 'app_01JEXAMPLETOOLJOB000000000';
-const OTHER = 'app_01JANOTHERAPP0000000000000';
-const SECRET = 'ovsec_tool_job_test_secret';
const quiet = { log() {}, error() {} };
const quiet = { log() {}, error() {} };
+const TOOLS = ['tools.job.create', 'tools.job.read'];
(async () => {
(async () => {
- const toolsGrants = [{ capability: 'tools.job.create', audience: 'openvibe.tools' }, { capability: 'tools.job.read', audience: 'openvibe.tools' }];
const platform = createMockPlatform({
const platform = createMockPlatform({
- clients: { [APP]: { secret: SECRET, grants: toolsGrants }, [OTHER]: { secret: 'o', grants: toolsGrants }, app_01JNOGRANT0000000000000000: { secret: 'n', grants: [] } },
+ acceptSandbox: ['openvibe.tools'], // Tools accepts developer-app sandbox tokens on /api/v1/jobs
+ jobs: {
+ stepMs: 5,
+ handlers: {
+ 'img.process': async ({ input, files, progress }) => {
+ await progress(50, 'converting');
+ const name = files[0].name.replace(/\.[^.]+$/, '') + `.${input.format}`;
+ return { data: { format: input.format }, files: [{ name, mime: `image/${input.format}`, bytes: Buffer.concat([Buffer.from('converted:'), files[0].bytes]) }] };
+ },
+ },
+ },
});
});
- const tools = createMockToolsServer({ publicKey: platform.keys.publicKey, dropStreamsAfter: 1 });
- const toolsUrl = await tools.listen();
- // Network calls go to the mock platform, Tools calls to the local mock server.
- const fetch = (input, init) => (String(input && input.url ? input.url : input).startsWith(toolsUrl) ? realFetch(input, init) : platform.fetch(input, init));
+ const app = platform.addApp({ env: 'sandbox', grants: TOOLS });
+ const other = platform.addApp({ env: 'sandbox', grants: TOOLS });
+ const noGrant = platform.addApp({ env: 'sandbox', grants: ['media.object.upload'] });
+ const { fetch } = platform;
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, OV_CLIENT_SECRET: SECRET, OV_TOOLS_JOBS_URL: toolsUrl, 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_TOOLS_JOBS_URL: platform.origins.tools, 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'));
- // 1. The whole flow, with the event stream dropped after its first event.
+ // 1. The whole flow, with the event stream dropped right after its first event.
const seen = [];
const seen = [];
- const runner = createJobRunner(loadConfig(env), { fetch, log: quiet });
- const { job, files } = await runner.run(file, { onEvent: (e) => seen.push(e) });
+ const runner = createJobRunner(loadConfig(env), { fetch, log: quiet, reconnectDelayMs: 10 });
+ const { job, files } = await runner.run(file, {
+ onEvent: (e) => { seen.push(e); if (seen.length === 1) platform.dropJobStreams(); },
+ });
assert.equal(job.state, 'succeeded');
assert.equal(job.state, 'succeeded');
assert.deepEqual(seen.map((e) => e.event), ['job.queued', 'job.running', 'job.progress', 'job.succeeded']);
assert.deepEqual(seen.map((e) => e.event), ['job.queued', 'job.running', 'job.progress', 'job.succeeded']);
assert.deepEqual(seen.map((e) => e.id), [1, 2, 3, 4], 'every event once, in order');
assert.deepEqual(seen.map((e) => e.id), [1, 2, 3, 4], 'every event once, in order');
- assert.deepEqual(tools.stats.streams.slice(0, 2), [0, 1], 'reconnected with Last-Event-ID: 1');
+ assert.ok(streams().length >= 2, 'reconnected after the drop');
+ assert.equal(streams()[1].headers['last-event-id'], '1', 'with Last-Event-ID');
assert.equal(files.length, 1);
assert.equal(files.length, 1);
assert.equal(path.basename(files[0]), 'photo.webp');
assert.equal(path.basename(files[0]), 'photo.webp');
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(tools.stats.submits, 1);
+ assert.equal(submits(), 1);
+ 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}$/);
// 2. Crash after submitting: a new process with the same state file reattaches, no second job.
// 2. Crash after submitting: a new process with the same state file reattaches, no second job.
const file2 = path.join(dir, 'second.png');
const file2 = path.join(dir, 'second.png');
@@ -58,32 +70,30 @@ const quiet = { log() {}, error() {} };
@@ -58,32 +70,30 @@ const quiet = { log() {}, error() {} };
const restarted = createJobRunner(loadConfig(env), { fetch, log: quiet });
const restarted = createJobRunner(loadConfig(env), { fetch, log: quiet });
const again = await restarted.run(file2);
const again = await restarted.run(file2);
assert.equal(again.job.id, first.jobId);
assert.equal(again.job.id, first.jobId);
- assert.equal(tools.stats.submits, 2, 'reattached instead of resubmitting');
+ assert.equal(submits(), 2, 'reattached instead of resubmitting');
// 3. Lost state file: the Idempotency-Key is derived from the request, so Tools replays the same job.
// 3. Lost state file: the Idempotency-Key is derived from the request, so Tools replays the same job.
const lost = createJobRunner(loadConfig(env), { fetch, log: quiet });
const lost = createJobRunner(loadConfig(env), { fetch, log: quiet });
const replay = await lost.submit(file2);
const replay = await lost.submit(file2);
assert.equal(replay.jobId, first.jobId);
assert.equal(replay.jobId, first.jobId);
assert.equal(replay.replayed, true);
assert.equal(replay.replayed, true);
- assert.equal(tools.stats.submits, 2);
// 4. A finished job with nothing newer answers 204: watch() returns at once.
// 4. A finished job with nothing newer answers 204: watch() returns at once.
const t0 = Date.now();
const t0 = Date.now();
await lost.watch(first.jobId);
await lost.watch(first.jobId);
assert.ok(Date.now() - t0 < 1000);
assert.ok(Date.now() - t0 < 1000);
- // 5. Jobs are owner-scoped: another app gets 404, and a 4xx is not retried in a loop.
- const other = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: OTHER, OV_CLIENT_SECRET: 'o', OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });
- await assert.rejects(other.get(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');
- await assert.rejects(other.watch(first.jobId), (err) => err.status === 404);
+ // 5. Jobs are owner-scoped: another app sees nothing (404), and a 4xx is not retried in a loop.
+ const otherRunner = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: other.id, OV_CLIENT_SECRET: other.secret, OV_JOB_STATE: path.join(dir, 'other.json') }), { fetch, log: quiet });
+ assert.equal(await otherRunner.get(first.jobId), null);
+ await assert.rejects(otherRunner.watch(first.jobId), (err) => err.status === 404 && err.code === 'tools.job.not_found');
// 6. No grant: Network refuses the token, nothing reaches Tools.
// 6. No grant: Network refuses the token, nothing reaches Tools.
- const before = tools.stats.submits;
- const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: 'app_01JNOGRANT0000000000000000', OV_CLIENT_SECRET: 'n' }), { fetch, log: quiet });
+ const before = submits();
+ const nogrant = createJobRunner(loadConfig({ ...env, OV_CLIENT_ID: noGrant.id, OV_CLIENT_SECRET: noGrant.secret }), { fetch, log: quiet });
await assert.rejects(nogrant.submit(path.join(dir, 'photo.png')), (err) => err.code === 'invalid_scope');
await assert.rejects(nogrant.submit(path.join(dir, 'photo.png')), (err) => err.code === 'invalid_scope');
- assert.equal(tools.stats.submits, before);
+ assert.equal(submits(), before);
- await tools.close();
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true });
console.log('tool-job: ok');
console.log('tool-job: ok');
})().catch((err) => { console.error(err); process.exit(1); });
})().catch((err) => { console.error(err); process.exit(1); });
@@ -6,9 +6,11 @@ OV_WEBHOOK_PATH=/webhooks/openvibe
@@ -6,9 +6,11 @@ OV_WEBHOOK_PATH=/webhooks/openvibe
OV_DB_PATH=./data/webhook-consumer.db
OV_DB_PATH=./data/webhook-consumer.db
OV_PORT=3003
OV_PORT=3003
-# subscribe.js: an app holding events.subscription.manage (audience openvibe.events).
+# subscribe.js: a CONFIDENTIAL sandbox app holding events.app.subscribe (audience openvibe.events).
OV_NETWORK_URL=https://openvibe.network
OV_NETWORK_URL=https://openvibe.network
OV_CLIENT_ID=
OV_CLIENT_ID=
OV_CLIENT_SECRET=
OV_CLIENT_SECRET=
+# Optional: your project id (prj_...); read from the app token when empty.
+OV_PROJECT_ID=
# Optional: an explicit Events origin instead of the one the registry advertises.
# Optional: an explicit Events origin instead of the one the registry advertises.
OV_EVENTS_URL=
OV_EVENTS_URL=
@@ -1,16 +1,22 @@
@@ -1,16 +1,22 @@
# Webhook consumer
# Webhook consumer
-Receive OpenVibe.Events deliveries over HTTPS, verify `X-OpenVibe-Signature`, and handle every
-event **exactly once** even though delivery is at least once.
+Receive OpenVibe.Events deliveries for your project's events over HTTPS, verify
+`X-OpenVibe-Signature`, and handle every event **exactly once** even though delivery is at least once.
```bash
```bash
-node --env-file=.env subscribe.js 'media.object.*' https://hooks.example.com/webhooks/openvibe
-node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003
+node --env-file=.env subscribe.js https://hooks.example.com/webhooks/openvibe # app.<project_key>.*
+node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/openvibe on :3003
```
```
## What it proves
## What it proves
-- **Signature first, on the raw bytes.** `openvibe-sdk/events` `parseDelivery()` checks
+- **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
+ (`403 events.topic_not_allowed`), and accepts only `https` endpoints that resolve to public
+ addresses. `subscribe.js` generates the signing secret itself and writes it to `.env.webhook`
+ (mode 0600); it never prints it.
+- **Signature first, on the raw bytes.** `parseDelivery()` checks
`sha256=<HMAC-SHA256 of the raw body>` in constant time before anything is parsed. A wrong
`sha256=<HMAC-SHA256 of the raw body>` in constant time before anything is parsed. A wrong
secret, a changed byte or a missing header is `401`, with no detail.
secret, a changed byte or a missing header is `401`, with no detail.
- **Exactly once.** `createInbox(db).once(consumer, event_id, fn)` writes the receipt and runs your
- **Exactly once.** `createInbox(db).once(consumer, event_id, fn)` writes the receipt and runs your
@@ -19,8 +25,6 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
@@ -19,8 +25,6 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
no receipt, the consumer answers `500`, and Events retries with backoff.
no receipt, the consumer answers `500`, and Events retries with backoff.
- **The signed body is the truth.** `X-OpenVibe-Event-Id` must match the body's `event_id`.
- **The signed body is the truth.** `X-OpenVibe-Event-Id` must match the body's `event_id`.
- **Secret rotation** without downtime: `OV_WEBHOOK_SECRET_PREVIOUS` is accepted while it is set.
- **Secret rotation** without downtime: `OV_WEBHOOK_SECRET_PREVIOUS` is accepted while it is set.
-- `subscribe.js` creates the subscription with `events.subscriptions.create()`. It generates the
- signing secret itself and writes it to `.env.webhook` (mode 0600); it never prints it.
## Files
## Files
@@ -28,7 +32,8 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
@@ -28,7 +32,8 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
|---|---|
|---|---|
| `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/smoke.test.js` | subscription via the SDK mock, then deliveries signed and shaped exactly like the Events delivery worker's |
+| `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
## Run the smoke test
@@ -36,24 +41,28 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
@@ -36,24 +41,28 @@ node --env-file=.env --env-file=.env.webhook server.js # POST /webhooks/open
npm test
npm test
```
```
-The SDK's mock platform stores subscriptions but has no delivery worker, so the test plays the
-worker: it signs each body with `signDelivery()` and sends the same headers Events sends
-(`X-OpenVibe-Event-Id`, `-Event-Type`, `-Seq`, `-Subscription-Id`, `-Delivery-Attempt`, `-Signature`).
+Deliveries come from `openvibe-sdk/testing`'s delivery worker (`deliverEvents()`): signed POSTs
+with Events' headers, in order, retried on a non-2xx. The test routes the subscription's https
+endpoint to the consumer on a local port. Wrong-secret, tampered and forged deliveries are made by
+hand. The SDK 0.3.0 mock has no `events.app.*`, so `test/mock-app-events.js` plays Events' app
+rules (capability, topic scope, https endpoint) in front of it.
## Run it against the real platform
## Run it against the real platform
-**This cannot be demonstrated against the real platform yet.** The consumer side is ready, but a
-developer app cannot create a subscription:
+1. Create a project and a **confidential** sandbox app with `events.app.subscribe` (and
+ `events.app.publish` if you want to send yourself events with
+ [event-subscriber](../event-subscriber)'s `publish.js`); see the
+ [walkthrough](../../README.md#end-to-end-walkthrough).
+2. Run `server.js` somewhere Events can reach over **https** with a public address (a VPS behind a
+ TLS proxy, or a tunnel). `localhost`, private and link-local addresses are refused, when you
+ subscribe and again on every delivery, and redirects are not followed.
+3. `cp .env.example .env`, fill in `OV_CLIENT_ID` and `OV_CLIENT_SECRET`, then
+ `node --env-file=.env subscribe.js https://<your host>/webhooks/openvibe`.
+4. Start the consumer with `--env-file=.env --env-file=.env.webhook` and publish an event
+ (`node --env-file=.env ../event-subscriber/publish.js`).
-- `events.subscription.manage` (and every other `events.*` capability) has visibility `internal`
- in openvibe-contracts, so Network never grants it to an app.
-- OpenVibe.Events' public host exposes only `/realtime/stream` and health; its subscription API
- is reachable from the platform's own hosts only.
-- The Events delivery worker only POSTs to endpoint hosts on its allow-list.
-
-When those change, the steps are: an app with `events.subscription.manage`, `cp .env.example .env`,
-`node --env-file=.env subscribe.js '<topic pattern>' https://<your host>/webhooks/openvibe`, then run
-`server.js` behind HTTPS with `--env-file=.env.webhook`.
+Limits: a sandbox project may have 5 subscriptions. Sandbox subscriptions receive sandbox events
+only. When the app is revoked or loses `events.app.subscribe`, Events disables its subscriptions.
Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery
Signature scheme note: the HMAC covers the body only, with no timestamp, so a captured delivery
can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-example-webhook-consumer",
"name": "openvibe-example-webhook-consumer",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Webhook consumer: verify X-OpenVibe-Signature and process each event exactly once through an inbox.",
"description": "Webhook consumer: verify X-OpenVibe-Signature and process each event exactly once through an inbox.",
"license": "MIT",
"license": "MIT",
@@ -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.2.2",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0",
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
}
}
}
}
@@ -3,11 +3,17 @@
@@ -3,11 +3,17 @@
/**
/**
* 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 'media.object.*' https://hooks.example.com/webhooks/openvibe
+ * 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.*'
*
*
- * Needs an app token for openvibe.events holding events.subscription.manage. The signing secret
- * is generated here and sent with the request, then written to .env.webhook (mode 0600) for
- * server.js to read. It is never printed.
+ * 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
+ * (app.<project_key>.*, project_key = p + the project's ULID in lowercase); first-party
+ * patterns (live.*) deliver only public events;
+ * - endpoints that are https, without credentials, resolving only to public addresses (checked
+ * at subscribe time and again on every delivery; redirects are never followed).
+ * The signing secret is generated here and sent with the request, then written to .env.webhook
+ * (mode 0600) for server.js to read. It is never printed.
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const fs = require('node:fs');
const fs = require('node:fs');
@@ -16,6 +22,13 @@ const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
@@ -16,6 +22,13 @@ 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 { 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()}`;
+}
+
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]);
if (missing.length) {
if (missing.length) {
@@ -27,33 +40,41 @@ function loadConfig(env = process.env) {
@@ -27,33 +40,41 @@ function loadConfig(env = process.env) {
network: env.OV_NETWORK_URL || 'https://openvibe.network',
network: env.OV_NETWORK_URL || 'https://openvibe.network',
clientId: env.OV_CLIENT_ID,
clientId: env.OV_CLIENT_ID,
clientSecret: env.OV_CLIENT_SECRET,
clientSecret: env.OV_CLIENT_SECRET,
+ projectId: env.OV_PROJECT_ID || null,
eventsUrl: env.OV_EVENTS_URL || null,
eventsUrl: env.OV_EVENTS_URL || null,
};
};
}
}
-/** -> { id, topic_pattern, endpoint, enabled } plus the secret it was created with (kept out of logs). */
-async function createSubscription(config, { topicPattern, endpoint, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {
- if (!/^https:\/\//.test(endpoint) && !/^http:\/\/(localhost|127\.0\.0\.1)(:\d+)?\//.test(endpoint)) {
- throw new TypeError('the endpoint must be an https:// URL');
- }
- const client = createClient({
- network: config.network,
- fetch,
- tokenProvider: createServiceTokenClient({ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch }),
- baseUrls: config.eventsUrl ? { events: config.eventsUrl } : undefined,
+/**
+ * -> { 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>.*.
+ */
+async function createSubscription(config, { endpoint, topicPattern, secret = `whsec_${crypto.randomBytes(32).toString('hex')}` }, { fetch } = {}) {
+ let u;
+ try { u = new URL(endpoint); } catch { u = null; }
+ if (!u || u.protocol !== 'https:' || u.username || u.password) throw new TypeError('the endpoint must be a public https:// URL without credentials');
+ const tokens = createServiceTokenClient({
+ network: config.network, clientId: config.clientId, clientSecret: config.clientSecret, fetch,
+ scope: { 'openvibe.events': 'events.app.subscribe' },
});
});
- const events = createEventsClient(client, { source: config.clientId });
- const sub = await events.subscriptions.create({ topicPattern, endpoint, secret });
+ 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 });
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 };
}
}
-async function main([topicPattern, endpoint] = process.argv.slice(2)) {
- if (!topicPattern || !endpoint) {
- console.error('usage: node subscribe.js <topic-pattern> <https endpoint>');
+async function main([endpoint, topicPattern] = process.argv.slice(2)) {
+ if (!endpoint) {
+ console.error('usage: node subscribe.js <https endpoint> [topic-pattern]');
return 2;
return 2;
}
}
try {
try {
- const { subscription, secret } = await createSubscription(loadConfig(), { topicPattern, endpoint });
+ const { subscription, secret } = await createSubscription(loadConfig(), { endpoint, topicPattern });
const file = path.join(__dirname, '.env.webhook');
const file = path.join(__dirname, '.env.webhook');
fs.writeFileSync(file, `OV_WEBHOOK_SECRET=${secret}\n`, { mode: 0o600 });
fs.writeFileSync(file, `OV_WEBHOOK_SECRET=${secret}\n`, { mode: 0o600 });
console.log(JSON.stringify(subscription, null, 2));
console.log(JSON.stringify(subscription, null, 2));
@@ -61,8 +82,8 @@ async function main([topicPattern, endpoint] = process.argv.slice(2)) {
@@ -61,8 +82,8 @@ async function main([topicPattern, endpoint] = process.argv.slice(2)) {
return 0;
return 0;
} catch (err) {
} catch (err) {
console.error(`subscribe failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);
console.error(`subscribe failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''}` : err.message}`);
- if (isOpenVibeError(err) && ['invalid_scope', 'invalid_target', 'capability.denied'].includes(err.code)) {
- console.error('events.subscription.manage is not grantable to developer apps today; see the README.');
+ if (isOpenVibeError(err) && ['invalid_scope', 'capability.denied'].includes(err.code)) {
+ console.error('Request events.app.subscribe on your app (it is in the sandbox allowance) and try again.');
}
}
return 1;
return 1;
}
}
@@ -70,4 +91,4 @@ async function main([topicPattern, endpoint] = process.argv.slice(2)) {
@@ -70,4 +91,4 @@ async function main([topicPattern, endpoint] = 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, main };
+module.exports = { loadConfig, createSubscription, projectKey, main };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'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,8 +1,10 @@
@@ -1,8 +1,10 @@
'use strict';
'use strict';
/**
/**
- * Smoke test: subscription creation against the SDK's mock platform, then deliveries shaped and
- * signed exactly like OpenVibe.Events' delivery worker sends them (the mock has no delivery
- * worker, so this test plays that part with openvibe-sdk/events signDelivery()).
+ * 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.
*/
*/
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fs = require('node:fs');
@@ -10,60 +12,36 @@ const os = require('node:os');
@@ -10,60 +12,36 @@ 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 } = require('openvibe-sdk/events');
-const { ulid } = require('openvibe-sdk/core');
const { createConsumer, loadConfig, openDatabase } = require('../server');
const { createConsumer, loadConfig, openDatabase } = require('../server');
-const { createSubscription } = require('../subscribe');
+const { createSubscription, loadConfig: loadSubscribeConfig, projectKey } = require('../subscribe');
+const { createAppEventsFetch } = require('./mock-app-events');
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'); };
-const APP = 'app_01JEXAMPLEWEBHOOK000000000';
-const SECRET = 'ovsec_webhook_consumer_test_secret';
-
-function envelope(type = 'media.object.uploaded') {
- return {
- event_id: `evt_${ulid()}`, event_type: type, version: 1, source: 'media', timestamp: new Date().toISOString(),
- actor: { type: 'service', id: 'media' }, subject: { type: 'object', id: 'med_01JEXAMPLEOBJECT0000000000' }, payload: { size: 12 },
- };
-}
-
-/** POST a delivery the way the Events worker does. */
-async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers = {} } = {}) {
- const body = JSON.stringify({ event, seq });
- const sent = tamper ? tamper(body) : body;
- const res = await realFetch(`http://127.0.0.1:${port}/webhooks/openvibe`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- 'X-OpenVibe-Event-Id': event.event_id,
- 'X-OpenVibe-Event-Type': event.event_type,
- 'X-OpenVibe-Seq': String(seq),
- 'X-OpenVibe-Subscription-Id': 'sub_test',
- 'X-OpenVibe-Delivery-Attempt': String(attempt),
- 'X-OpenVibe-Signature': signDelivery(body, secret),
- ...headers,
- },
- body: sent,
- });
- return { status: res.status, body: await res.json() };
-}
+const ENDPOINT = 'https://hooks.example.com/webhooks/openvibe';
(async () => {
(async () => {
- // 1. Create the subscription (events.subscription.manage) and keep the secret it was created with.
- const platform = createMockPlatform({
- clients: { [APP]: { secret: SECRET, grants: [{ capability: 'events.subscription.manage', audience: 'openvibe.events' }] } },
- });
- const created = await createSubscription(
- { network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },
- { topicPattern: 'media.object.*', endpoint: 'https://hooks.example.com/webhooks/openvibe' },
- { fetch: platform.fetch },
- );
+ const platform = createMockPlatform();
+ const fetch = createAppEventsFetch(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);
+ const subEnv = { OV_CLIENT_ID: app.id, OV_CLIENT_SECRET: app.secret };
+
+ // 1. Create the subscription: the project's own topic by default, https only, own project only.
+ const created = await createSubscription(loadSubscribeConfig(subEnv), { endpoint: ENDPOINT }, { fetch });
assert.match(created.subscription.id, /^sub_/);
assert.match(created.subscription.id, /^sub_/);
- assert.equal(created.subscription.topic_pattern, 'media.object.*');
+ assert.equal(created.subscription.topic_pattern, `app.${key}.*`);
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({ network: 'https://openvibe.network', clientId: APP, clientSecret: SECRET },
- { topicPattern: 'x.y.*', endpoint: 'http://hooks.example.com/x' }, { fetch: platform.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');
+ 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');
// 2. Run the consumer with that secret.
// 2. Run the consumer with that secret.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-webhook-'));
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-webhook-'));
@@ -80,48 +58,72 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers
@@ -80,48 +58,72 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers
log: { log: (m) => logs.push(m), error: (m) => logs.push(m) },
log: { log: (m) => logs.push(m), error: (m) => logs.push(m) },
});
});
await new Promise((r) => consumer.server.listen(0, '127.0.0.1', r));
await new Promise((r) => consumer.server.listen(0, '127.0.0.1', r));
- const { port } = consumer.server.address();
-
- const e1 = envelope();
- let r = await deliver(port, e1, 1, created.secret);
- assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: false } });
+ let local = `http://127.0.0.1:${consumer.server.address().port}/webhooks/openvibe`;
+
+ // The mock's delivery worker POSTs to the subscription's https endpoint; route it to the local
+ // consumer and keep what was sent (to replay one later, like a retry after a lost 2xx).
+ const sent = [];
+ const worker = (url, init) => {
+ assert.equal(url, ENDPOINT);
+ sent.push({ body: init.body, headers: init.headers });
+ return realFetch(local, init);
+ };
+ const appEvent = (name) => platform.publishEvent({
+ event_type: `app.${key}.${name}`, source: `app-${app.id.slice(4).toLowerCase()}`,
+ actor: { type: 'app', id: app.id }, subject: { type: 'order', id: '42' }, payload: { size: 12 },
+ }, `app:${app.id}`);
+
+ const e1 = appEvent('order.created');
+ platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' } });
+ let round = await platform.deliverEvents({ fetch: worker });
+ assert.deepEqual([round.delivered, round.failed], [1, 0], 'only the subscribed topic is delivered');
assert.deepEqual(handled, [e1.event_id]);
assert.deepEqual(handled, [e1.event_id]);
+ const firstDelivery = sent[0];
+ assert.equal(firstDelivery.headers['X-OpenVibe-Event-Id'], e1.event_id);
- // Redelivery (a retry after a lost 2xx, or a replay): acknowledged, not processed again.
- r = await deliver(port, e1, 1, created.secret, { attempt: 2 });
- assert.deepEqual(r, { status: 200, body: { ok: true, duplicate: true } });
+ // Redelivery of the same bytes (a retry after a lost 2xx, or a replay): acknowledged, not processed again.
+ const replay = async (d, extra = {}, body = d.body) => {
+ const res = await realFetch(local, { method: 'POST', headers: { ...d.headers, ...extra }, body });
+ return { status: res.status, body: await res.json() };
+ };
+ assert.deepEqual(await replay(firstDelivery, { 'X-OpenVibe-Delivery-Attempt': '2' }), { status: 200, body: { ok: true, duplicate: true } });
assert.deepEqual(handled, [e1.event_id]);
assert.deepEqual(handled, [e1.event_id]);
- // Wrong secret, tampered body, missing signature: 401 and no side effect.
- const e2 = envelope();
- assert.equal((await deliver(port, e2, 2, 'whsec_not_the_secret')).status, 401);
- assert.equal((await deliver(port, e2, 2, created.secret, { tamper: (b) => b.replace('"size":12', '"size":13') })).status, 401);
- assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Signature': '' } })).status, 401);
- assert.equal(handled.length, 1);
-
- // Header and signed body disagree on the event id: refused.
- assert.equal((await deliver(port, e2, 2, created.secret, { headers: { 'X-OpenVibe-Event-Id': 'evt_01JOTHER0000000000000000000' } })).status, 400);
-
- // A handler failure leaves no receipt: 500 now, processed on the retry.
+ // A handler failure leaves no receipt: the worker sees a 500, retries on its next round (attempt 2).
+ const e2 = appEvent('order.paid');
failNext = true;
failNext = true;
- assert.equal((await deliver(port, e2, 2, created.secret)).status, 500);
+ round = await platform.deliverEvents({ fetch: worker });
+ assert.equal(round.failed, 1);
+ assert.equal(round.attempts[0].status, 500);
assert.equal(consumer.inbox.seen('webhook-consumer', e2.event_id), false);
assert.equal(consumer.inbox.seen('webhook-consumer', e2.event_id), false);
- r = await deliver(port, e2, 2, created.secret, { attempt: 2 });
- assert.equal(r.status, 200);
+ round = await platform.deliverEvents({ fetch: worker });
+ assert.deepEqual([round.delivered, round.attempts[0].attempt], [1, 2]);
+ assert.deepEqual(handled, [e1.event_id, e2.event_id]);
+
+ // Attacks: wrong secret, tampered body, missing signature: 401 and no side effect.
+ const last = sent.at(-1);
+ const e3 = appEvent('order.refunded');
+ const forgedBody = last.body.replaceAll(e2.event_id, e3.event_id);
+ assert.equal((await replay(last, { 'X-OpenVibe-Signature': signDelivery(forgedBody, 'whsec_not_the_secret'), 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401);
+ assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id }, forgedBody)).status, 401, 'changed bytes, old signature');
+ assert.equal((await replay(last, { 'X-OpenVibe-Signature': '' })).status, 401);
+ // Header and signed body disagree on the event id: refused.
+ assert.equal((await replay(last, { 'X-OpenVibe-Event-Id': e3.event_id })).status, 400);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
assert.deepEqual(handled, [e1.event_id, e2.event_id]);
// Secret rotation: deliveries signed with the previous secret are accepted while it is configured.
// Secret rotation: deliveries signed with the previous secret are accepted while it is configured.
consumer.server.close();
consumer.server.close();
- const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {
+ const rotated = createConsumer(loadConfig({ OV_WEBHOOK_SECRET: 'whsec_new_secret_after_rotation_0000000000', OV_WEBHOOK_SECRET_PREVIOUS: created.secret, OV_DB_PATH: config.dbPath }), {
db: consumer.db, handle: (event) => handled.push(event.event_id), log: { log() {}, error() {} },
db: consumer.db, handle: (event) => handled.push(event.event_id), log: { log() {}, error() {} },
});
});
await new Promise((res) => rotated.server.listen(0, '127.0.0.1', res));
await new Promise((res) => rotated.server.listen(0, '127.0.0.1', res));
- const p2 = rotated.server.address().port;
- const e3 = envelope();
- assert.equal((await deliver(p2, e3, 3, created.secret)).status, 200);
- const e4 = envelope();
- assert.equal((await deliver(p2, e4, 4, 'whsec_new')).status, 200);
- assert.equal((await deliver(p2, e4, 4, 'whsec_new')).body.duplicate, true);
+ local = `http://127.0.0.1:${rotated.server.address().port}/webhooks/openvibe`;
+ round = await platform.deliverEvents({ fetch: worker }); // e3, still signed with the old secret
+ assert.equal(round.delivered, 1);
+ platform.state.subscriptions.get(created.subscription.id).secret = 'whsec_new_secret_after_rotation_0000000000';
+ const e4 = appEvent('order.closed');
+ round = await platform.deliverEvents({ fetch: worker }); // e4, signed with the new one
+ assert.equal(round.delivered, 1);
assert.deepEqual(handled, [e1.event_id, e2.event_id, e3.event_id, e4.event_id]);
assert.deepEqual(handled, [e1.event_id, e2.event_id, e3.event_id, e4.event_id]);
// The receipt and the app's own row were written together.
// The receipt and the app's own row were written together.
@@ -129,7 +131,7 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers
@@ -129,7 +131,7 @@ async function deliver(port, event, seq, secret, { attempt = 1, tamper, headers
assert.deepEqual(rows, handled);
assert.deepEqual(rows, handled);
// Oversized bodies are refused before parsing.
// Oversized bodies are refused before parsing.
- const big = await realFetch(`http://127.0.0.1:${p2}/webhooks/openvibe`, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });
+ const big = await realFetch(local, { method: 'POST', body: 'x'.repeat(1024 * 1024 + 10) });
assert.equal(big.status, 413);
assert.equal(big.status, 413);
// No secret in the logs.
// No secret in the logs.
@@ -1,26 +1,29 @@
@@ -1,26 +1,29 @@
{
{
"name": "openvibe-examples",
"name": "openvibe-examples",
- "version": "0.1.0",
+ "version": "0.2.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-examples",
"name": "openvibe-examples",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "MIT",
"license": "MIT",
"workspaces": [
"workspaces": [
"examples/*"
"examples/*"
],
],
+ "devDependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
+ },
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
}
}
},
},
"examples/browser-app": {
"examples/browser-app": {
"name": "openvibe-example-browser-app",
"name": "openvibe-example-browser-app",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -28,11 +31,8 @@
@@ -28,11 +31,8 @@
},
},
"examples/chat-bot": {
"examples/chat-bot": {
"name": "openvibe-example-chat-bot",
"name": "openvibe-example-chat-bot",
- "version": "0.1.0",
+ "version": "0.2.0",
"license": "MIT",
"license": "MIT",
- "dependencies": {
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2"
- },
"devDependencies": {
"devDependencies": {
"ws": "^8.18.0"
"ws": "^8.18.0"
},
},
@@ -42,10 +42,10 @@
@@ -42,10 +42,10 @@
},
},
"examples/event-subscriber": {
"examples/event-subscriber": {
"name": "openvibe-example-event-subscriber",
"name": "openvibe-example-event-subscriber",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -53,10 +53,10 @@
@@ -53,10 +53,10 @@
},
},
"examples/media-uploader": {
"examples/media-uploader": {
"name": "openvibe-example-media-uploader",
"name": "openvibe-example-media-uploader",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -64,11 +64,11 @@
@@ -64,11 +64,11 @@
},
},
"examples/mod-manifest": {
"examples/mod-manifest": {
"name": "openvibe-example-mod-manifest",
"name": "openvibe-example-mod-manifest",
- "version": "0.1.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-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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -76,10 +76,10 @@
@@ -76,10 +76,10 @@
},
},
"examples/node-server-app": {
"examples/node-server-app": {
"name": "openvibe-example-node-server-app",
"name": "openvibe-example-node-server-app",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -87,10 +87,10 @@
@@ -87,10 +87,10 @@
},
},
"examples/oauth-app": {
"examples/oauth-app": {
"name": "openvibe-example-oauth-app",
"name": "openvibe-example-oauth-app",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -98,10 +98,10 @@
@@ -98,10 +98,10 @@
},
},
"examples/tool-job": {
"examples/tool-job": {
"name": "openvibe-example-tool-job",
"name": "openvibe-example-tool-job",
- "version": "0.1.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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -109,11 +109,11 @@
@@ -109,11 +109,11 @@
},
},
"examples/webhook-consumer": {
"examples/webhook-consumer": {
"name": "openvibe-example-webhook-consumer",
"name": "openvibe-example-webhook-consumer",
- "version": "0.1.0",
+ "version": "0.2.0",
"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.2.2"
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
@@ -469,9 +469,9 @@
@@ -469,9 +469,9 @@
"link": true
"link": true
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.2.2",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.2.2",
- "integrity": "sha512-kDifUp3EcmnTWzElAmaCZnX659WErcnm4+qVEoAiYQWX/w/1p4TWvoHbUPsrX65TSROVR3tiyVkl801n/0yyCw==",
+ "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==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-examples",
"name": "openvibe-examples",
- "version": "0.1.0",
+ "version": "0.2.0",
"private": true,
"private": true,
"description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.",
"description": "Executable public integration examples for the OpenVibe platform: SDK, scoped credentials and public APIs only.",
"license": "MIT",
"license": "MIT",
@@ -9,9 +9,14 @@
@@ -9,9 +9,14 @@
"examples/*"
"examples/*"
],
],
"scripts": {
"scripts": {
- "test": "node test/run.js"
+ "test": "node test/run.js",
+ "e2e": "node --env-file-if-exists=.env scripts/e2e.js",
+ "new-app": "node --env-file-if-exists=.env scripts/new-sandbox-app.js"
},
},
"engines": {
"engines": {
"node": ">=22"
"node": ">=22"
+ },
+ "devDependencies": {
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0"
}
}
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,151 @@
@@ -0,0 +1,151 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * npm run e2e: three examples against the REAL platform, as your sandbox app. Not part of npm test
+ * and not run in CI: it needs your credentials and the network.
+ *
+ * OV_CLIENT_ID=app_… OV_CLIENT_SECRET=… OV_PROJECT_ID=prj_… npm run e2e # or put them in ./.env
+ *
+ * The app must be a CONFIDENTIAL app of that project with the sandbox grants media.object.upload,
+ * media.object.read, events.app.publish, events.app.read, tools.job.create and tools.job.read
+ * (scripts/new-sandbox-app.js or https://openvibe.codes create one). Optional: OV_NETWORK_URL,
+ * OV_TOOLS_JOBS_URL.
+ *
+ * 1. media-uploader upload a small text file into the project's Media tenant, read its
+ * metadata back, delete it
+ * 2. event-subscriber publish app.<project_key>.e2e.ran, then pull the project's topic from just
+ * before it with a durable cursor and find it
+ * 3. tool-job convert a 1x1 PNG to WebP on img.openvibe.tools, following the job's events
+ *
+ * It refuses to start without the three variables, prints what each step did, and never prints a
+ * secret: the client secret and anything shaped like a token are masked in all output, and signed
+ * URLs are shown without their signature.
+ */
+const fs = require('node:fs');
+const os = require('node:os');
+const path = require('node:path');
+
+const ROOT = path.join(__dirname, '..');
+const REQUIRED = ['OV_CLIENT_ID', 'OV_CLIENT_SECRET', 'OV_PROJECT_ID'];
+const STEP_TIMEOUT_MS = 180000;
+// A 1x1 PNG, so the Tools step needs no file of yours.
+const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
+
+function refuse(message) {
+ console.error(message);
+ console.error('\nusage: OV_CLIENT_ID=app_… OV_CLIENT_SECRET=… OV_PROJECT_ID=prj_… npm run e2e');
+ console.error('The app: a confidential sandbox app with media.object.upload, media.object.read, events.app.publish,');
+ console.error('events.app.read, tools.job.create and tools.job.read (see the README walkthrough).');
+ process.exit(2);
+}
+
+/** Mask the secret and anything token-shaped in everything this process writes. */
+function maskOutput(secret) {
+ const mask = (s) => String(s).split(secret).join('[secret]').replace(/eyJ[\w-]{8,}\.[\w-]{8,}\.[\w-]+/g, '[token]');
+ for (const stream of [process.stdout, process.stderr]) {
+ const write = stream.write.bind(stream);
+ stream.write = (chunk, ...rest) => write(typeof chunk === 'string' || Buffer.isBuffer(chunk) ? mask(chunk.toString()) : chunk, ...rest);
+ }
+}
+
+const withoutQuery = (u) => { try { const x = new URL(u); return `${x.origin}${x.pathname}${x.search ? ' (signed)' : ''}`; } catch { return u; } };
+const describeError = (err) => (err && err.code && err.status !== undefined
+ ? `${err.code}${err.status ? ` (${err.status})` : ''}${err.detail ? `: ${err.detail}` : ''}${err.requestId ? ` request=${err.requestId}` : ''}`
+ : String((err && err.message) || err));
+
+function timeout(promise, what) {
+ let timer;
+ return Promise.race([promise, new Promise((_, reject) => { timer = setTimeout(() => reject(new Error(`${what} took longer than ${STEP_TIMEOUT_MS / 1000} s`)), STEP_TIMEOUT_MS); })])
+ .finally(() => clearTimeout(timer));
+}
+
+async function main() {
+ const missing = REQUIRED.filter((k) => !process.env[k]);
+ if (missing.length) refuse(`e2e: missing ${missing.join(', ')}. Nothing was sent.`);
+ const env = process.env;
+ if (!/^app_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.OV_CLIENT_ID)) refuse('e2e: OV_CLIENT_ID is not an app id (app_<ULID>). Nothing was sent.');
+ if (!/^prj_[0-9A-HJKMNP-TV-Z]{26}$/.test(env.OV_PROJECT_ID)) refuse('e2e: OV_PROJECT_ID is not a project id (prj_<ULID>). Nothing was sent.');
+ 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 { 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'));
+
+ const base = { OV_CLIENT_ID: env.OV_CLIENT_ID, OV_CLIENT_SECRET: env.OV_CLIENT_SECRET, OV_PROJECT_ID: env.OV_PROJECT_ID, ...(env.OV_NETWORK_URL ? { OV_NETWORK_URL: env.OV_NETWORK_URL } : {}) };
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-examples-e2e-'));
+ const log = { log: (m) => console.log(` ${m}`), error: (m) => console.log(` ${m}`) };
+ console.log(`OpenVibe.Examples e2e against ${env.OV_NETWORK_URL || 'https://openvibe.network'}`);
+ console.log(`app ${env.OV_CLIENT_ID}, project ${env.OV_PROJECT_ID}\n`);
+
+ const results = [];
+ async function step(name, fn) {
+ console.log(`[${results.length + 1}/3] ${name}`);
+ try {
+ await timeout(fn(), name);
+ results.push({ name, ok: true });
+ console.log(' ok\n');
+ } catch (err) {
+ results.push({ name, ok: false });
+ console.log(` FAILED: ${describeError(err)}\n`);
+ }
+ }
+
+ await step('media-uploader: upload, read back, delete', async () => {
+ const file = path.join(dir, 'openvibe-examples-e2e.txt');
+ fs.writeFileSync(file, `OpenVibe.Examples e2e ${new Date().toISOString()}\n`);
+ const uploader = createUploader(mediaConfig(base));
+ let out;
+ try { out = await uploader.upload(file); } catch (err) { throw Object.assign(err, { detail: explain(err) }); }
+ console.log(` uploaded ${out.key} (${out.size} bytes, ${out.mime}) into project ${out.project_id}${out.deduplicated ? ', deduplicated' : ''}`);
+ console.log(` sandbox=${out.sandbox} url=${withoutQuery(out.url)}${out.url_expires_at ? ` expires ${out.url_expires_at}` : ''}`);
+ const meta = await uploader.get(out.key);
+ if (!meta || meta.sha256 !== out.sha256) throw new Error('media.object.read did not return the file just uploaded');
+ console.log(` read back with media.object.read: sha256 ${meta.sha256.slice(0, 16)}…`);
+ console.log(` deleted: ${await uploader.remove(out.key)}`);
+ });
+
+ await step('event-subscriber: publish to the own topic, then pull it', async () => {
+ const pub = await publishAppEvent(publishConfig(base), { name: 'e2e.ran', subject: { type: 'check', id: 'e2e' }, payload: { at: new Date().toISOString() } });
+ console.log(` published ${pub.event_type} ${pub.event_id} -> seq ${pub.seq}${pub.duplicate ? ' (duplicate)' : ''}`);
+ const cursorPath = path.join(dir, 'cursor.json');
+ createCursorStore(cursorPath).set('pull', Math.max(0, pub.seq - 1));
+ const seen = [];
+ const s = createSubscriber(subscriberConfig({ ...base, OV_EVENTS_MODE: 'pull', OV_CURSOR_PATH: cursorPath }), {
+ log, onEvent: (event, { seq }) => { seen.push({ seq, id: event.event_id, type: event.event_type }); },
+ });
+ const topics = await s.resolveTopics();
+ if (topics[0] !== `app.${projectKey(env.OV_PROJECT_ID)}.*`) throw new Error(`unexpected topic ${topics[0]}`);
+ const n = await s.pullOnce();
+ console.log(` pulled ${topics.join(', ')} from seq ${pub.seq - 1}: ${n} event(s), cursor now ${createCursorStore(cursorPath).get('pull')}`);
+ const mine = seen.find((e) => e.id === pub.event_id);
+ if (!mine) throw new Error(`the published event ${pub.event_id} was not in the pull`);
+ console.log(` found ${mine.id} at seq ${mine.seq}`);
+ });
+
+ await step('tool-job: img.process convert to WebP', async () => {
+ const file = path.join(dir, 'pixel.png');
+ fs.writeFileSync(file, PNG_1X1);
+ const runner = createJobRunner(jobConfig({
+ ...base, OV_JOB_STATE: path.join(dir, 'job-state.json'), OV_OUT_DIR: path.join(dir, 'out'),
+ ...(env.OV_TOOLS_JOBS_URL ? { OV_TOOLS_JOBS_URL: env.OV_TOOLS_JOBS_URL } : {}),
+ }), { log });
+ const { job, files } = await runner.run(file, {
+ onEvent: ({ id, event, job: j }) => {
+ const pct = j && j.progress && j.progress.percent != null ? ` ${j.progress.percent}%` : '';
+ console.log(` #${id} ${event}${pct}${j && j.progress && j.progress.message ? ` ${j.progress.message}` : ''}`);
+ },
+ });
+ if (!job || job.state !== 'succeeded') throw new Error(job ? `job ${job.id} ${job.state}${job.error ? `: ${job.error.code}` : ''}` : 'the job is gone');
+ for (const f of files) console.log(` result ${path.basename(f)} (${fs.statSync(f).size} bytes)`);
+ if (!files.length) throw new Error('the job succeeded but returned no file');
+ });
+
+ fs.rmSync(dir, { recursive: true, force: true });
+ const ok = results.filter((r) => r.ok).length;
+ console.log(`${ok}/${results.length} steps ok`);
+ return ok === results.length ? 0 : 1;
+}
+
+main().then((code) => { process.exitCode = code; }, (err) => { console.error(`e2e: ${describeError(err)}`); process.exitCode = 1; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Create a project and a sandbox app for the examples with openvibe-sdk/projects, the same
+ * /api/v1/projects API https://openvibe.codes uses. Nothing here needs staff: a new project is
+ * sandbox, and the project owner's grant requests inside the sandbox allowance are approved at once.
+ *
+ * npm run new-app # new project + confidential sandbox app -> ./.env
+ * npm run new-app -- --project prj_… # add the app to a project you already have
+ * npm run new-app -- --public # a PUBLIC app for examples/browser-app (no secret)
+ *
+ * It needs a Network user access token: OV_USER_TOKEN, or it asks for your username and password
+ * (the password is not echoed) and signs in at <network>/api/auth/login. The confidential app's
+ * secret is shown ONCE by Network; this writes it straight into ./.env (mode 0600) with
+ * OV_CLIENT_ID and OV_PROJECT_ID and never prints it. An existing .env is never overwritten.
+ */
+const fs = require('node:fs');
+const path = require('node:path');
+const readline = require('node:readline');
+const { createClient, isOpenVibeError } = require('openvibe-sdk/core');
+const { createProjectsClient } = require('openvibe-sdk/projects');
+
+const ROOT = path.join(__dirname, '..');
+const SANDBOX_GRANTS = [
+ 'media.object.upload', 'media.object.read',
+ 'events.app.publish', 'events.app.read', 'events.app.subscribe',
+ 'tools.job.create', 'tools.job.read', 'tools.job.cancel',
+];
+
+function ask(question, { hidden = false } = {}) {
+ return new Promise((resolve) => {
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
+ if (hidden) rl._writeToOutput = (s) => { if (s.includes(question)) process.stdout.write(s); };
+ rl.question(question, (answer) => { rl.close(); if (hidden) process.stdout.write('\n'); resolve(answer); });
+ });
+}
+
+async function userToken(network) {
+ if (process.env.OV_USER_TOKEN) return process.env.OV_USER_TOKEN;
+ if (!process.stdin.isTTY) throw new Error('set OV_USER_TOKEN, or run this in a terminal to sign in');
+ const username = await ask('OpenVibe username: ');
+ const password = await ask('password: ', { hidden: true });
+ const res = await fetch(`${network}/api/auth/login`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
+ body: JSON.stringify({ username, password }),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok || !body.token) throw new Error(`sign-in refused (${res.status})`);
+ return body.token;
+}
+
+async function main(argv = process.argv.slice(2)) {
+ const network = (process.env.OV_NETWORK_URL || 'https://openvibe.network').replace(/\/+$/, '');
+ const isPublic = argv.includes('--public');
+ const pi = argv.indexOf('--project');
+ const existing = pi >= 0 ? argv[pi + 1] : null;
+ const envFile = path.join(ROOT, '.env');
+ if (!isPublic && fs.existsSync(envFile)) {
+ console.error(`${envFile} exists; move it away first (this never overwrites a secret).`);
+ return 2;
+ }
+ try {
+ const projects = createProjectsClient(createClient({ network, token: await userToken(network) }));
+ let projectId = existing;
+ if (!projectId) {
+ const p = await projects.create({ name: 'OpenVibe examples' });
+ projectId = (p.project || p).id;
+ console.log(`project ${projectId} (sandbox)`);
+ }
+ const created = await projects.apps.create(projectId, isPublic
+ ? { name: 'examples browser-app', environment: 'sandbox', type: 'public', redirectUris: ['http://localhost:3001/callback'] }
+ : { name: 'examples', environment: 'sandbox', type: 'confidential', redirectUris: ['http://localhost:3009/callback'] });
+ const app = created.app || created;
+ const appId = app.id || app.client_id;
+ console.log(`app ${appId} (sandbox, ${isPublic ? 'public' : 'confidential'})`);
+ for (const capability of isPublic ? ['media.object.read'] : SANDBOX_GRANTS) {
+ try {
+ const g = await projects.grants.request(projectId, appId, capability);
+ console.log(` grant ${capability}: ${(g.grant || g).status}`);
+ } catch (err) {
+ console.log(` grant ${capability}: ${isOpenVibeError(err) ? err.code : err.message}`);
+ }
+ }
+ if (isPublic) {
+ console.log(`\nexamples/browser-app/.env: OV_CLIENT_ID=${appId} (a public app has no secret)`);
+ return 0;
+ }
+ const secret = (created.credential || app.credential || {}).client_secret || created.client_secret;
+ if (!secret) throw new Error('Network returned no client secret; rotate one on the app (openvibe.codes)');
+ fs.writeFileSync(envFile, `OV_CLIENT_ID=${appId}\nOV_CLIENT_SECRET=${secret}\nOV_PROJECT_ID=${projectId}\n`, { mode: 0o600 });
+ console.log(`\nwrote ${envFile} (OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID; mode 0600). Next: npm run e2e`);
+ return 0;
+ } catch (err) {
+ console.error(`failed: ${isOpenVibeError(err) ? `${err.code}${err.detail ? `: ${err.detail}` : ''} (request ${err.requestId})` : err.message}`);
+ return 1;
+ }
+}
+
+main().then((code) => { process.exitCode = code; });
@@ -2,15 +2,17 @@
@@ -2,15 +2,17 @@
/**
/**
* Every example is copyable on its own: a README (with how to run it against the real platform),
* Every example is copyable on its own: a README (with how to run it against the real platform),
* a .env.example naming every variable its code reads, an MIT package.json whose dependencies are
* a .env.example naming every variable its code reads, an MIT package.json whose dependencies are
- * pinned tags (no file: links), and a smoke test that the root runner picks up.
+ * pinned tags (no file: links), and a smoke test that the root runner picks up. `npm run e2e`
+ * (the real platform) stays out of npm test and CI, and refuses to run without credentials.
*/
*/
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
const fs = require('node:fs');
const fs = require('node:fs');
const path = require('node:path');
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.2.2';
+const SDK = 'https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0';
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
const dirs = fs.readdirSync(path.join(ROOT, 'examples')).filter((d) => fs.statSync(path.join(ROOT, 'examples', d)).isDirectory()).sort();
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
assert.deepEqual(dirs, EXPECTED, 'the nine charter examples');
@@ -36,7 +38,7 @@ for (const name of EXPECTED) {
@@ -36,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.2.2`);
+ if (dep === 'openvibe-sdk') assert.equal(spec, SDK, `${name}: openvibe-sdk pinned to v0.3.0`);
}
}
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
const readme = fs.readFileSync(path.join(dir, 'README.md'), 'utf8');
@@ -56,6 +58,18 @@ for (const name of EXPECTED) {
@@ -56,6 +58,18 @@ for (const name of EXPECTED) {
const ci = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');
const ci = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'ci.yml'), 'utf8');
assert.match(ci, /node-version: 22\.22\.1/);
assert.match(ci, /node-version: 22\.22\.1/);
assert.match(ci, /npm test/);
assert.match(ci, /npm test/);
+const rootPkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
+assert.equal(rootPkg.devDependencies['openvibe-sdk'], SDK, 'the root scripts use the same SDK tag');
+
+// npm run e2e: real platform only on demand. Not in CI, not in npm test, and it refuses to start
+// (exit 2, before any network access) without OV_CLIENT_ID / OV_CLIENT_SECRET / OV_PROJECT_ID.
+assert.match(rootPkg.scripts.e2e, /scripts\/e2e\.js/);
+assert.ok(!/e2e/.test(rootPkg.scripts.test), 'npm test does not run the e2e');
+assert.ok(!ci.split('\n').filter((l) => /^\s*-?\s*run:/.test(l)).some((l) => /e2e/.test(l)), 'CI does not run the e2e');
+assert.ok(!fs.existsSync(path.join(ROOT, 'test', 'e2e.test.js')), 'the root runner never picks it up');
+const refused = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'e2e.js')], { env: { PATH: process.env.PATH }, encoding: 'utf8', timeout: 20000 });
+assert.equal(refused.status, 2, refused.stderr);
+assert.match(refused.stderr, /missing OV_CLIENT_ID, OV_CLIENT_SECRET, OV_PROJECT_ID\. Nothing was sent\./);
assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');
assert.equal(JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8')).stage, 'alpha');
assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
assert.match(fs.readFileSync(path.join(ROOT, 'LICENSE'), 'utf8'), /^MIT License/);
console.log('structure: ok');
console.log('structure: ok');