openvibers/openvibe.examples Commits

OpenVibe.Examples 0.2.0: openvibe-sdk v0.3.0 and the sandbox developer path as it works in production

OP OpenVibers committed 23 Sept 2026 · d980a58505 · 52 files, +1798 −1247

- Pin openvibe-sdk v0.3.0 everywhere (examples and the root scripts).
- oauth-app, browser-app: SDK exchangeCode() with audience (public PKCE for browser-app) and
  verifyAppToken(); the hand-rolled token calls are gone.
- browser-app: the page imports browser/openvibe-sdk.mjs as an ES module (lib/sdk-bundle.js is
  gone) and reads the registry cross-origin; the server-side read is opt-in (OV_REGISTRY_PROXY).
- event-subscriber: pull as an app with events.app.read on the project's app.<project_key>.*;
  publish.js publishes with events.app.publish; realtime stays anonymous for public events;
  retention gaps from the mock's pruneEvents() instead of a wrapped fetch.
- webhook-consumer: subscription with events.app.subscribe on the own topic, https only;
  deliveries in the smoke test come from the mock's deliverEvents().
- tool-job: openvibe-sdk/jobs (submit with Idempotency-Key, events() with Last-Event-ID, file());
  the SSE parser and the local Tools mock are gone, the SDK mock's jobs replace it.
- media-uploader: the project id is the tenant (from the token when OV_PROJECT_ID is empty);
  sandbox files come back with signed URLs; --get (media.object.read) and --delete.
- node-server-app: getTokenInfo() instead of decoding tokens by hand.
- npm run new-app (openvibe-sdk/projects) creates a project, a sandbox app and its grants and
  writes .env without printing the secret; npm run e2e runs media-uploader, event-subscriber
  (pull) and tool-job against the real platform with OV_CLIENT_ID/OV_CLIENT_SECRET/OV_PROJECT_ID,
  refuses to start without them and masks secrets in its output. Not in CI or npm test.
- README: the walkthrough starts at openvibe.codes (or the projects API); what the platform does
  for a sandbox app today, and what still does not work.
- test/mock-app-events.js (event-subscriber, webhook-consumer): Events' events.app.* rules in
  front of the SDK mock, which has none.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 852edca..e71673b 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/README.md b/README.md
iindex e2b050c..029545c 100644
---- a/README.md
++++ b/README.md
@@@ -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), and
openvibe-contracts v0.26.0 for the mod manifest.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §15.1
and §18.9; roadmap Wave 20, §30 (public SDK surface), ADR-014 (developer projects).
@@@ -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 test
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
call 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 it
proves" and "Run it against the real platform", a `.env.example` that names every variable the
-- code reads, MIT license, `openvibe-sdk` pinned to the v0.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/projects
test/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, event
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
--- 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**, enforced
by `test/public-surface.test.js`.
ddiff --git a/STATUS.json b/STATUS.json
iindex 54cfb34..8a05cda 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/examples/browser-app/.env.example b/examples/browser-app/.env.example
iindex 2119f63..a9e9f4b 100644
---- a/examples/browser-app/.env.example
++++ b/examples/browser-app/.env.example
@@@ -8,3 +8,6 @@ OV_SCOPE=media.object.read
OV_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=
ddiff --git a/examples/browser-app/README.md b/examples/browser-app/README.md
iindex f90006d..e9ec3ff 100644
---- a/examples/browser-app/README.md
++++ b/examples/browser-app/README.md
@@@ -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.
```bash
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
--- **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:3001
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
--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`.
ddiff --git a/examples/browser-app/lib/sdk-bundle.js b/examples/browser-app/lib/sdk-bundle.js
ddeleted file mode 100644
iindex 46cef85..0000000
---- a/examples/browser-app/lib/sdk-bundle.js
++++ /dev/null
@@@ -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 };
ddiff --git a/examples/browser-app/package.json b/examples/browser-app/package.json
iindex a94a5b2..6649c2e 100644
---- a/examples/browser-app/package.json
++++ b/examples/browser-app/package.json
@@@ -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"
}
}
ddiff --git a/examples/browser-app/public/app.js b/examples/browser-app/public/app.js
iindex aebab2b..926258f 100644
---- a/examples/browser-app/public/app.js
++++ b/examples/browser-app/public/app.js
@@@ -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);
ddiff --git a/examples/browser-app/public/index.html b/examples/browser-app/public/index.html
iindex d074b47..0311251 100644
---- a/examples/browser-app/public/index.html
++++ b/examples/browser-app/public/index.html
@@@ -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>
ddiff --git a/examples/browser-app/server.js b/examples/browser-app/server.js
iindex bae293d..78464b0 100644
---- a/examples/browser-app/server.js
++++ b/examples/browser-app/server.js
@@@ -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 up
const 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 };
ddiff --git a/examples/browser-app/test/smoke.test.js b/examples/browser-app/test/smoke.test.js
iindex 176879f..7df21be 100644
---- a/examples/browser-app/test/smoke.test.js
++++ b/examples/browser-app/test/smoke.test.js
@@@ -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, crossOrigi
assign(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, crossOrigi
if (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 like
return 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 secret
const 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, crossOrigi
assert.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');
ddiff --git a/examples/chat-bot/README.md b/examples/chat-bot/README.md
iindex d993d64..c7f9f29 100644
---- a/examples/chat-bot/README.md
++++ b/examples/chat-bot/README.md
@@@ -69,8 +69,9 @@ Only use it in a channel whose streamer agreed to have it there; they can ban it
Limits 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 the
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
ddiff --git a/examples/chat-bot/package.json b/examples/chat-bot/package.json
iindex 0c4315b..632f7d1 100644
---- a/examples/chat-bot/package.json
++++ b/examples/chat-bot/package.json
@@@ -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",
ddiff --git a/examples/event-subscriber/.env.example b/examples/event-subscriber/.env.example
iindex 73678ed..8b9864e 100644
---- a/examples/event-subscriber/.env.example
++++ b/examples/event-subscriber/.env.example
@@@ -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_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=
ddiff --git a/examples/event-subscriber/README.md b/examples/event-subscriber/README.md
iindex 47cfdfa..92653f7 100644
---- a/examples/event-subscriber/README.md
++++ b/examples/event-subscriber/README.md
@@@ -1,34 +1,44 @@
# Event subscriber
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
--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. The
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).
## 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.js
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
--- **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.
ddiff --git a/examples/event-subscriber/package.json b/examples/event-subscriber/package.json
iindex 0905398..5fbc2d5 100644
---- a/examples/event-subscriber/package.json
++++ b/examples/event-subscriber/package.json
@@@ -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"
}
}
ddiff --git a/examples/event-subscriber/publish.js b/examples/event-subscriber/publish.js
nnew file mode 100644
iindex 0000000..54f5b53
---- /dev/null
++++ b/examples/event-subscriber/publish.js
@@@ -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 };
ddiff --git a/examples/event-subscriber/subscriber.js b/examples/event-subscriber/subscriber.js
iindex 7b3b266..42deb24 100644
---- a/examples/event-subscriber/subscriber.js
++++ b/examples/event-subscriber/subscriber.js
@@@ -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 = c
await onResync(g, { via });
}
++ /** OV_TOPICS, or your project's own topic app.<project_key>.* (project id from OV_PROJECT_ID or the token). */
++ let topics = config.topics.length ? config.topics : null;
++ async function resolveTopics() {
++ if (topics) return topics;
++ 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 = c
handled++;
stats.handled++;
}
-- if (pageNext != null) cursor.set('pull', pageNext);
return handled;
}
@@@ -122,7 +150,7 @@ function createSubscriber(config, { onEvent, onResync = () => {}, fetch, log = c
async 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 = c
stats.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 = c
stats,
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 };
ddiff --git a/examples/event-subscriber/test/mock-app-events.js b/examples/event-subscriber/test/mock-app-events.js
nnew file mode 100644
iindex 0000000..d4ef81d
---- /dev/null
++++ b/examples/event-subscriber/test/mock-app-events.js
@@@ -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 };
ddiff --git a/examples/event-subscriber/test/smoke.test.js b/examples/event-subscriber/test/smoke.test.js
iindex e956143..e77822c 100644
---- a/examples/event-subscriber/test/smoke.test.js
++++ b/examples/event-subscriber/test/smoke.test.js
@@@ -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));
ddiff --git a/examples/media-uploader/.env.example b/examples/media-uploader/.env.example
iindex 89c540d..942f91f 100644
---- a/examples/media-uploader/.env.example
++++ b/examples/media-uploader/.env.example
@@@ -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_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=
ddiff --git a/examples/media-uploader/README.md b/examples/media-uploader/README.md
iindex c06176d..40ee0d4 100644
---- a/examples/media-uploader/README.md
++++ b/examples/media-uploader/README.md
@@@ -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.
```bash
node --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.
ddiff --git a/examples/media-uploader/package.json b/examples/media-uploader/package.json
iindex bf166a2..83f5793 100644
---- a/examples/media-uploader/package.json
++++ b/examples/media-uploader/package.json
@@@ -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"
}
}
ddiff --git a/examples/media-uploader/test/smoke.test.js b/examples/media-uploader/test/smoke.test.js
iindex fac9d87..ca2f57d 100644
---- a/examples/media-uploader/test/smoke.test.js
++++ b/examples/media-uploader/test/smoke.test.js
@@@ -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');
ddiff --git a/examples/media-uploader/upload.js b/examples/media-uploader/upload.js
iindex 9175906..4196767 100644
---- a/examples/media-uploader/upload.js
++++ b/examples/media-uploader/upload.js
@@@ -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 };
ddiff --git a/examples/mod-manifest/README.md b/examples/mod-manifest/README.md
iindex 6836e4f..4e4f8fc 100644
---- a/examples/mod-manifest/README.md
++++ b/examples/mod-manifest/README.md
@@@ -45,7 +45,8 @@ npm test
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).
--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.
ddiff --git a/examples/mod-manifest/package.json b/examples/mod-manifest/package.json
iindex def2a17..5876dc8 100644
---- a/examples/mod-manifest/package.json
++++ b/examples/mod-manifest/package.json
@@@ -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"
}
}
ddiff --git a/examples/node-server-app/.env.example b/examples/node-server-app/.env.example
iindex 8ec8af3..65755bb 100644
---- a/examples/node-server-app/.env.example
++++ b/examples/node-server-app/.env.example
@@@ -3,5 +3,5 @@ OV_NETWORK_URL=https://openvibe.network
OV_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.tools
OV_PORT=3002
ddiff --git a/examples/node-server-app/README.md b/examples/node-server-app/README.md
iindex 65963d1..7db0480 100644
---- a/examples/node-server-app/README.md
++++ b/examples/node-server-app/README.md
@@@ -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`).
ddiff --git a/examples/node-server-app/package.json b/examples/node-server-app/package.json
iindex e031850..6fa1d3d 100644
---- a/examples/node-server-app/package.json
++++ b/examples/node-server-app/package.json
@@@ -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"
}
}
ddiff --git a/examples/node-server-app/server.js b/examples/node-server-app/server.js
iindex 9c52487..2244273 100644
---- a/examples/node-server-app/server.js
++++ b/examples/node-server-app/server.js
@@@ -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 };
ddiff --git a/examples/node-server-app/test/smoke.test.js b/examples/node-server-app/test/smoke.test.js
iindex d83e63c..36f2ee5 100644
---- a/examples/node-server-app/test/smoke.test.js
++++ b/examples/node-server-app/test/smoke.test.js
@@@ -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');
ddiff --git a/examples/oauth-app/README.md b/examples/oauth-app/README.md
iindex e629f2e..dce3fb9 100644
---- a/examples/oauth-app/README.md
++++ b/examples/oauth-app/README.md
@@@ -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`, `s
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`.
--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
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 |
|---|---|
--| `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.
ddiff --git a/examples/oauth-app/package.json b/examples/oauth-app/package.json
iindex a78efa1..64b07c7 100644
---- a/examples/oauth-app/package.json
++++ b/examples/oauth-app/package.json
@@@ -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"
}
}
ddiff --git a/examples/oauth-app/server.js b/examples/oauth-app/server.js
iindex 3a0b5fc..9a25b39 100644
---- a/examples/oauth-app/server.js
++++ b/examples/oauth-app/server.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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 };
ddiff --git a/examples/oauth-app/test/smoke.test.js b/examples/oauth-app/test/smoke.test.js
iindex 17c9c27..5404137 100644
---- a/examples/oauth-app/test/smoke.test.js
++++ b/examples/oauth-app/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/tool-job/README.md b/examples/tool-job/README.md
iindex fbfdb51..bf3d2a8 100644
---- a/examples/tool-job/README.md
++++ b/examples/tool-job/README.md
@@@ -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.
```bash
node --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 restart
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
| 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.png
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
--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.
ddiff --git a/examples/tool-job/package.json b/examples/tool-job/package.json
iindex a7598b8..56c0670 100644
---- a/examples/tool-job/package.json
++++ b/examples/tool-job/package.json
@@@ -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"
}
}
ddiff --git a/examples/tool-job/run-job.js b/examples/tool-job/run-job.js
iindex c810ae7..0fca950 100644
---- a/examples/tool-job/run-job.js
++++ b/examples/tool-job/run-job.js
@@@ -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 };
ddiff --git a/examples/tool-job/test/mock-tools-server.js b/examples/tool-job/test/mock-tools-server.js
ddeleted file mode 100644
iindex 8579694..0000000
---- a/examples/tool-job/test/mock-tools-server.js
++++ /dev/null
@@@ -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 };
ddiff --git a/examples/tool-job/test/smoke.test.js b/examples/tool-job/test/smoke.test.js
iindex 6504339..ead7b4d 100644
---- a/examples/tool-job/test/smoke.test.js
++++ b/examples/tool-job/test/smoke.test.js
@@@ -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); });
ddiff --git a/examples/webhook-consumer/.env.example b/examples/webhook-consumer/.env.example
iindex b9255f8..52d41dd 100644
---- a/examples/webhook-consumer/.env.example
++++ b/examples/webhook-consumer/.env.example
@@@ -6,9 +6,11 @@ OV_WEBHOOK_PATH=/webhooks/openvibe
OV_DB_PATH=./data/webhook-consumer.db
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_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=
ddiff --git a/examples/webhook-consumer/README.md b/examples/webhook-consumer/README.md
iindex a2c65c3..6ba521a 100644
---- a/examples/webhook-consumer/README.md
++++ b/examples/webhook-consumer/README.md
@@@ -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 wrong
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
@@@ -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.
- **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/open
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
--**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
can be replayed later. The inbox makes a replay harmless (it is a duplicate), which is why the
ddiff --git a/examples/webhook-consumer/package.json b/examples/webhook-consumer/package.json
iindex 6659944..f787a12 100644
---- a/examples/webhook-consumer/package.json
++++ b/examples/webhook-consumer/package.json
@@@ -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"
}
}
ddiff --git a/examples/webhook-consumer/subscribe.js b/examples/webhook-consumer/subscribe.js
iindex 2886bab..b048a01 100644
---- a/examples/webhook-consumer/subscribe.js
++++ b/examples/webhook-consumer/subscribe.js
@@@ -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 };
ddiff --git a/examples/webhook-consumer/test/mock-app-events.js b/examples/webhook-consumer/test/mock-app-events.js
nnew file mode 100644
iindex 0000000..d4ef81d
---- /dev/null
++++ b/examples/webhook-consumer/test/mock-app-events.js
@@@ -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 };
ddiff --git a/examples/webhook-consumer/test/smoke.test.js b/examples/webhook-consumer/test/smoke.test.js
iindex c36e6af..8849d2b 100644
---- a/examples/webhook-consumer/test/smoke.test.js
++++ b/examples/webhook-consumer/test/smoke.test.js
@@@ -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, headers
log: { 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, headers
assert.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.
ddiff --git a/package-lock.json b/package-lock.json
iindex 72604dd..9d0c798 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 9333843..29e3097 100644
---- a/package.json
++++ b/package.json
@@@ -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"
}
}
ddiff --git a/scripts/e2e.js b/scripts/e2e.js
nnew file mode 100644
iindex 0000000..31abb15
---- /dev/null
++++ b/scripts/e2e.js
@@@ -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; });
ddiff --git a/scripts/new-sandbox-app.js b/scripts/new-sandbox-app.js
nnew file mode 100644
iindex 0000000..57e6641
---- /dev/null
++++ b/scripts/new-sandbox-app.js
@@@ -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; });
ddiff --git a/test/structure.test.js b/test/structure.test.js
iindex 05cbd4e..35dad50 100644
---- a/test/structure.test.js
++++ b/test/structure.test.js
@@@ -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');