openvibers/openvibe.sdk Commits
0.3.0: developer apps and the gaps OpenVibe.Examples found
Changes
@@@ -12,6 +12,6 @@ jobs:with:node-version: 22.22.1 # production services run Node 22# types.test.js compares types/contracts.d.ts with the pinned Contracts release-- - run: git clone --depth 1 --branch v0.6.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts++ - run: git clone --depth 1 --branch v0.26.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts- run: npm test # includes the pack test (npm pack + install into a temp project)- run: node scripts/esm.js --check@@@ -3,6 +3,60 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.3.0 (2026-09-23)++++Developer apps (Network developer projects, ADR-014) and the gaps OpenVibe.Examples found.++Contract types re-copied from openvibe-contracts v0.26.0 (`[email protected]`:++`project_id`, `env`, `on_behalf_of`); CI checks them against that tag.++++- **Auth, developer apps.** `exchangeCode()` sends `audience` and `scope`, supports public clients++ (no `clientSecret`; the PKCE `codeVerifier` is then required, and always for `app_…` clients)++ and returns app tokens as they come (no `refresh_token`). New `verifyAppToken(token, { jwks |++ publicKey, issuer, audience, acceptSandbox })`: RS256, expiry, issuer, the required audience,++ the claim shape, and `env: sandbox` refused (`token.sandbox_refused`) unless `acceptSandbox`;++ returns the claims including `project_id`, `env` and `on_behalf_of`. `verifyUserToken()` is++ unchanged and still refuses app tokens (`token.not_user`).++- **Authorize URL.** `buildAuthorizeUrl()` / `startAuthorization()` take `audience`; `scope` takes++ capability ids. **Behaviour change:** the `'profile theme'` default now applies only when no++ `audience` is given.++- **Token client.** `createServiceTokenClient().getTokenInfo(ctx)` returns the granted `scope`, the++ expiry and `unverifiedClaims` (decoded, NOT verified). New `decodeUnverified()` /++ `unverifiedClaims()` helpers in `openvibe-sdk/auth` (browser build too).++- **Core.** `client.request({ responseType: 'response' })` now returns the raw fetch `Response`++ (as `data` and `response`, body unread) instead of `data: null`; error statuses are still read++ and thrown, the per-attempt timeout covers the headers only, and `signal` still cancels the body.++ `core.SDK_VERSION` is the package version again (it said 0.1.0 in 0.2.x); a test keeps them equal.++- **Realtime.** `parseSSE(body, { onRetry })` is exported: an async iterator of++ `{ event, data, id }` over a fetch body or any async iterable.++- **Events.** `iterate()` calls `onPage(page)` after every item of that page was yielded and++ handled (it used to run before the page's items), so saving `next_after_seq` there is++ crash-safe; a break or throw inside the page leaves the previous cursor. New `maxPages` option.++ **Behaviour change** for anyone who relied on the old order.++- **New `openvibe-sdk/jobs`** (browser-safe): OpenVibe.Tools jobs — `submit()` (always with an++ `Idempotency-Key`; JSON or multipart with files), `get()`, `cancel()`, `events()` (SSE that++ reattaches with `Last-Event-ID` after drops and restarts, each event once), `wait()`, `file()`++ (raw Response).++- **New `openvibe-sdk/projects`** (browser-safe; needs a Network user token): the++ `/api/v1/projects` API — projects, members, apps, credentials rotate/revoke, grants++ request/approve/deny/revoke, quotas, audit, the catalog. Calls that mint a secret or that++ Network does not dedupe are never retried.++- **Browser bundle.** `browser/openvibe-sdk.mjs` (export `openvibe-sdk/browser/openvibe-sdk.mjs`):++ one self-contained ES module of the browser-safe subpaths for pages with no build step,++ generated by `scripts/browser-bundle.js` (no dependencies) from the CommonJS sources, checked++ in, with a staleness test, and covered by the browser secret scan.++- **Mock platform.** Developer apps (`apps`, `projects`, `addApp()`, `signAppToken()`):++ confidential and public app clients, app tokens with `project_id`/`env`/`ns`/`on_behalf_of`,++ `GET /oauth/authorize` with auto-consent (`setAuthorization()`), audience-bound codes, PKCE++ required for apps, sandbox membership, `sandboxAudiences`; mock services refuse sandbox tokens++ unless `acceptSandbox` names the audience or capability; a Media tenant per project; the++ `/api/v1/projects` API. Events: `pruneEvents()` so pulls and realtime resumes report retention++ gaps; `deliverEvents()` / `startDeliveries()` play the delivery worker (signed POSTs to a local++ endpoint, in order, retries, dead after `max_attempts`). Optional Tools jobs (`jobs: true |++ { stepMs, handlers }`): submit with idempotency replay, get, cancel, SSE with `Last-Event-ID`,++ 204 when finished, result files, `dropJobStreams()`. Fixed: a realtime resume with a cursor++ ahead of the stream no longer swallows the next events. Default `contractsVersion` 0.26.0; a++ `tools` origin. Not included: Chat (no WebSocket mock; OpenVibe.Examples keeps its own).++## 0.2.2 (2026-09-23)- No peer dependency on `openvibe-contracts`: the SDK never loads it at runtime, and a peer pinned@@@ -2,17 +2,17 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.1.0 (roadmap Wave 2). Runs and is tested against local stub servers and the built-in mock platform. No OpenVibe service or app uses it in production yet.++**Status:** alpha, v0.3.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; nothing uses 0.3.0 yet.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.```bash--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.1.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0```--It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. It does not depend on `openvibe-contracts`: it copies the contract types it uses (built against Contracts v0.6.0+, checked in CI).++It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.26.0, checked in CI).## Quick start@@@ -45,7 +45,7 @@ const sub = subscribe(['live.stream.*'], (event, { seq }) => render(event), {});```--**The token exchange happens on your server.** Every OpenVibe OAuth client is confidential: it has a client secret, and a secret must never reach a browser. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`.++**The token exchange happens on your server.** First-party clients and confidential developer apps have a client secret, and a secret must never reach a browser. A public developer app has no secret, but its server should still do the exchange and keep the token in an HttpOnly session. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach (and the browser bundle itself) and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`.### Server service (service principal)@@@ -75,38 +75,79 @@ const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.nclaims.subject_id; // usr_…```--### External developer (an app outside the network)++### External developer (a developer app)--An app with only a client id, a secret and a capability grant (for example `media.object.upload` for its own namespace) authenticates, finds Media through the registry and uploads a file:++Developer apps belong to a project on OpenVibe.Network ([developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md)). An app is a principal `app_<ULID>`: `confidential` (has a secret, may use client credentials) or `public` (no secret, authorization code + PKCE only), in `sandbox` or `production`. Its tokens last 5 minutes, carry one audience, the approved capabilities (`cap`), `project_id`, `env` and `ns: [project_id]`, and there are no refresh tokens.++++A server app with a secret and a grant (here `media.object.upload`) uploads into its project's Media namespace:```jsconst sdk = require('openvibe-sdk');--const client = sdk.createClient({-- tokenProvider: sdk.auth.createServiceTokenClient({ clientId: 'my-app', clientSecret: process.env.OV_CLIENT_SECRET }),--});++const tokens = sdk.auth.createServiceTokenClient({ clientId: process.env.OV_CLIENT_ID, clientSecret: process.env.OV_CLIENT_SECRET });++const client = sdk.createClient({ tokenProvider: tokens });await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe--const media = sdk.media.createMediaClient(client, { app: 'my-app' });++const { scope, unverifiedClaims } = await tokens.getTokenInfo({ audience: 'openvibe.media' }); // what Network granted (display only)++const media = sdk.media.createMediaClient(client, { app: unverifiedClaims.project_id }); // Media tenancy is the project idconst file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' });file.public_url; // https://openvibe.media/f/<key>```--`test/testing.test.js` runs this flow end to end against `openvibe-sdk/testing`.++"Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`).++++```js++const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');++++// /login: capability ids as scope, and the audience the token is for.++const { url, state, codeVerifier } = await startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] });++// /callback:++const { code } = readCallback(req.url, { expectedState: state });++const t = await exchangeCode({ clientId, clientSecret /* omit for a public app */, code, codeVerifier, redirectUri, audience: 'openvibe.media' });++t.refresh_token; // undefined: when it expires, the person signs in again++++// A service receiving app tokens verifies them offline:++const claims = await verifyAppToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.media' });++claims.project_id; claims.env; claims.on_behalf_of; // env=sandbox is refused (token.sandbox_refused) unless acceptSandbox: true++```++++Manage projects, apps, secrets and grants with `openvibe-sdk/projects` and a Network user token:++++```js++const projects = require('openvibe-sdk/projects').createProjectsClient(sdk.createClient({ token: userAccessToken }));++const prj = await projects.create({ name: 'My app' });++const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential' });++app.credential.client_secret; // shown ONCE: store it now++await projects.grants.request(prj.id, app.id, 'media.object.upload');++```++++Run a Tools job and follow it across disconnects and restarts:++++```js++const jobs = require('openvibe-sdk/jobs').createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });++const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' }, files: [{ name: 'a.png', data: bytes }], idempotencyKey });++for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id); // reconnects with Last-Event-ID++const res = await jobs.file(job.id, 0); // raw Response++```++++`test/testing.test.js`, `test/apps.test.js`, `test/projects.test.js` and `test/jobs.test.js` run these flows end to end against `openvibe-sdk/testing`. What the live platform allows today (sandbox audiences, allowances, which capabilities are grantable) is in the Network doc above and in OpenVibe.Examples' README.## API| Subpath | Where | What ||---|---|---|| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |--| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()`, `verifyUserToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()` |++| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` || `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` || `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` || `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services || `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery` |--| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)` |++| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` || `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers || `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |--| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network, Events and Media on an in-process `fetch` |++| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()` |++| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |++| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media and Tools jobs on an in-process `fetch` |++| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, projects |Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.@@@ -128,7 +169,8 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun- Legacy `{ error: 'text' }` bodies become `http.<status>`.- Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`.- **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage.--- **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. The `iterate()` methods build on it: Events uses `after_seq`, and Media and Community use offsets.++- **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. Media and Community use offsets. Events `iterate()` walks `after_seq` and calls `onPage(page)` only after every item of that page was handled, so saving `page.next_after_seq` there is crash-safe.++- **Streams and downloads.** `responseType: 'response'` resolves with the raw fetch `Response` (`data` and `response`, body unread). Error statuses are still read and thrown. The timeout covers the headers only, and `signal` still cancels the body. `parseSSE(res.body)` (from `openvibe-sdk/realtime`) iterates a `text/event-stream`.### Auth details@@@ -140,27 +182,47 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun- It rejects service-principal tokens (`token.not_user`).It returns the claims, including `subject_id`.--- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. See the gaps below for what the Network does with it today.++- `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts v0.26 `verifyServiceToken()` does: the same signature and time rules, the audience (required), `actor_type: app`, the claim shape (`sub app:app_…`, `cap`, `jti`, `project_id prj_…`, `env`, optional `on_behalf_of usr_…`), and `env: sandbox` refused as `token.sandbox_refused` unless `acceptSandbox: true`. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: test `claims.cap` for the one your route performs.++- `exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? })`: without `clientSecret` (a public app) the PKCE verifier is required; `app_…` clients must send the verifier and an `audience`. App tokens come back without a `refresh_token`.++- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.++- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.++- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.### Testing your app```jsconst { createMockPlatform } = require('openvibe-sdk/testing');const platform = createMockPlatform({-- clients: { 'my-app': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } },++ clients: { 'my-service': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } },++ apps: { 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR': { env: 'production', type: 'confidential', secret: 's', redirectUris: ['http://localhost:3009/callback'], grants: ['media.object.upload'] } },mediaApps: { 'my-app': {} },users: [{ username: 'ana' }],++ jobs: true,});--const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'my-app', clientSecret: 's', fetch: platform.fetch }) });++const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', clientSecret: 's', fetch: platform.fetch }) });```--The mock answers at the real public origins with real RS256 tokens and checks audience, capability and namespace. It covers:++The mock answers at the real public origins with real RS256 tokens and checks audience, capability, namespace and sandbox the way the services do. It covers:--- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh), the JWKS, the registry, `/api/modules` and `/internal/modules`, and `/internal/identity`.--- **Events:** publish with `event_id` dedupe, pull, checkpoints, subscriptions, and `/realtime/stream` SSE with `Last-Event-ID`.++- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.++- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. Each project gets a Media tenant named after its id.++- **Sandbox:** mock services refuse `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.++- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries to your local endpoint in order, retry, and mark them dead after `max_attempts`.- **Media:** the files API.++- **Tools jobs** (`jobs: true | { stepMs, handlers }`): submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.++++Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent()`, `pruneEvents()`, `deliverEvents()`, `dropRealtime()`, `dropJobStreams()`, and `stats` and `state` for assertions. It is a fake. It has no persistence, its visibility rules are simplified, and it has no Chat (no WebSocket mock).++++### Browser without a bundler++++```html++<script type="module">++ import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs'; // copied or served from node_modules/openvibe-sdk/browser/++ const { url, state, codeVerifier } = await auth.startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] });++</script>++```--Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, and `stats` and `state` for assertions. It is a fake. It has no persistence and no delivery worker, and its visibility rules are simplified.++`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `jobs` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.## Versioning@@@ -171,11 +233,11 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, a## Not wrapped yet (intentionally)- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.--- **Media v2 objects** (`med_` ids, ADR-006) are being added to Media right now. This release wraps only the v1 files API that exists on Media's `main`, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).++- **Media v2 objects** (`med_` ids, ADR-006, `/api/v2/:app/objects`) exist on Media but are not wrapped yet. This release wraps only the v1 files API, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.--- **PKCE enforcement:** the SDK sends `code_challenge` and `code_verifier`, but the Network's `/oauth/authorize` and `/oauth/token` don't verify them yet. The mock platform does. Security today rests on the client secret, which never leaves the server.- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.--- **Chat, Live, Billing, Games and Jobs** from the original charter come when those services publish their capabilities in Contracts.++- **Chat, Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat has no app principal (`chat.message.send` is `first-party`), so there is no Chat client and no Chat mock.++- **Tools job types** are not wrapped one by one: `openvibe-sdk/jobs` submits any `type` with its `input` and files; each satellite (img, audio, docs) documents its own types.- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.## Development@@@ -184,6 +246,7 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, anpm test # every test/*.test.js: stub servers + mock platform, then npm pack + installfnm exec --using=22.22.1 npm test # the Node version production runsnode scripts/esm.js # regenerate esm/*.mjs after changing a module's exports++node scripts/browser-bundle.js # regenerate browser/openvibe-sdk.mjs after changing a browser-safe file```Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`.@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.1.0",++ "version": "0.3.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",-- "updated": "2026-09-22"++ "updated": "2026-09-23"}@@@ -13,4 +13,6 @@ module.exports = {realtime: require('./src/realtime'),media: require('./src/media'),community: require('./src/community'),++ jobs: require('./src/jobs'),++ projects: require('./src/projects'),};nnew file mode 100644@@@ -0,0 +1,1714 @@++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.0, MIT). Do not edit.++// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces++// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.++/* eslint-disable */++const __defs = [++// ── 0: browser.js ──────────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk (browser entry): core plus the browser-safe families. No server-only code: no++ * service-token client, no JWT verification, no events publishing, no identity lookups.++ */++const core = __load(1);++++module.exports = {++ ...core,++ auth: __load(8),++ registry: __load(9),++ modules: __load(10),++ realtime: __load(11),++ media: __load(12),++ community: __load(13),++ jobs: __load(14),++ projects: __load(15),++};++},++// ── 1: src/core/index.js ───────────────────────────────────────────++function (module, exports) {++'use strict';++/** openvibe-sdk/core: the HTTP client, errors, tracing, ids, pagination. Browser-safe. */++const { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK } = __load(2);++const { OpenVibeError, isOpenVibeError } = __load(3);++const { parseTraceparent, startSpan, contextFromHeaders } = __load(4);++const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = __load(5);++const { paginate, offsetPager } = __load(7);++const semver = __load(6);++++/** The package version (test/version.test.js keeps it equal to package.json). */++const SDK_VERSION = '0.3.0';++++module.exports = {++ createClient,++ OpenVibeError,++ isOpenVibeError,++ paginate,++ offsetPager,++ parseTraceparent,++ startSpan,++ contextFromHeaders,++ ulid,++ newEventId,++ newIdempotencyKey,++ isActingSubjectId,++ satisfiesRange: semver.satisfies,++ compareVersions: semver.compare,++ CONTRACTS_RANGE,++ DEFAULT_NETWORK,++ SDK_VERSION,++};++},++// ── 2: src/core/client.js ──────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * createClient(): the one HTTP client every SDK subpath uses. Browser-safe (global fetch, Web++ * Crypto, no Node modules).++ *++ * - service origins come from explicit `baseUrls` or from the registry (`discover()`); callers++ * name a service ('media', 'events', …) and a path, never a hard-coded host++ * - every call has a deadline (`timeoutMs` per attempt, `deadlineMs` for the whole call)++ * - retries only when repeating is safe: idempotent methods, or a request carrying an++ * Idempotency-Key (one is generated for mutations when retries > 0 unless the caller opts out++ * with `idempotencyKey: false`)++ * - W3C traceparent + X-OpenVibe-Request-Id on every call++ * - failures are OpenVibeError (RFC 9457 problem details mapped to code/status/detail/requestId/traceId)++ */++const { OpenVibeError } = __load(3);++const { startSpan, contextFromHeaders } = __load(4);++const { newIdempotencyKey, randomHex } = __load(5);++const { satisfies } = __load(6);++++const DEFAULT_NETWORK = 'https://openvibe.network';++/** Contract releases this SDK version was built and tested against. */++const CONTRACTS_RANGE = '>=0.5.0 <1.0.0';++const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);++const RETRY_STATUS = new Set([408, 425, 429, 502, 503, 504]);++++const trimSlash = (s) => String(s).replace(/\/+$/, '');++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));++++function createClient(options = {}) {++ const shared = { discovery: null, discoveredAt: 0, inflight: null, warned: false };++ return build(normalize(options), shared, {});++}++++function normalize(o) {++ const fetchImpl = o.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);++ if (!fetchImpl) throw new TypeError('openvibe-sdk: no fetch available; pass options.fetch');++ const sources = [o.token != null, typeof o.getToken === 'function', Boolean(o.tokenProvider)].filter(Boolean).length;++ if (sources > 1) throw new TypeError('openvibe-sdk: pass only one of token, getToken, tokenProvider');++ const network = trimSlash(o.network || DEFAULT_NETWORK);++ let getToken = null;++ if (typeof o.getToken === 'function') getToken = o.getToken;++ else if (o.tokenProvider) getToken = (ctx) => o.tokenProvider.getToken(ctx);++ let invalidateToken = null;++ if (o.tokenProvider && typeof o.tokenProvider.invalidate === 'function') invalidateToken = (ctx) => o.tokenProvider.invalidate(ctx);++ else if (typeof o.invalidateToken === 'function') invalidateToken = o.invalidateToken;++ return Object.freeze({++ network,++ discoveryUrl: o.discoveryUrl || `${network}/.well-known/openvibe`,++ baseUrls: Object.freeze(Object.fromEntries(Object.entries(o.baseUrls || {}).map(([k, v]) => [k, trimSlash(v)]))),++ audiences: Object.freeze({ ...o.audiences }),++ fetch: fetchImpl,++ timeoutMs: o.timeoutMs ?? 10000,++ deadlineMs: o.deadlineMs ?? 30000,++ retries: o.retries ?? 2,++ retryDelayMs: o.retryDelayMs ?? 250,++ maxRetryDelayMs: o.maxRetryDelayMs ?? 5000,++ token: o.token ?? null,++ getToken,++ invalidateToken,++ headers: Object.freeze({ ...o.headers }),++ credentials: o.credentials,++ discoveryTtlMs: o.discoveryTtlMs ?? 5 * 60 * 1000,++ autoDiscover: o.autoDiscover !== false,++ contractsRange: o.contractsRange || CONTRACTS_RANGE,++ strictContracts: Boolean(o.strictContracts),++ traceparent: o.traceparent || null,++ onWarning: typeof o.onWarning === 'function' ? o.onWarning : (msg) => { if (typeof console !== 'undefined') console.warn(`[openvibe-sdk] ${msg}`); },++ });++}++++function build(cfg, shared, ctx) {++ const audienceOf = (service) => cfg.audiences[service] || `openvibe.${service}`;++++ function cachedDiscovery() {++ return shared.discovery && Date.now() - shared.discoveredAt < cfg.discoveryTtlMs ? shared.discovery : null;++ }++++ /**++ * Read the platform descriptor (GET <network>/.well-known/openvibe) and cache it: service++ * origins, token/JWKS endpoints, and the contracts release the network runs. Concurrent++ * callers share one request.++ */++ async function discover({ force = false } = {}) {++ const fresh = cachedDiscovery();++ if (fresh && !force) return fresh;++ if (!shared.inflight) {++ shared.inflight = (async () => {++ const { data } = await request({ url: cfg.discoveryUrl, auth: false });++ if (!data || typeof data !== 'object' || !Array.isArray(data.services)) {++ throw new OpenVibeError({ code: 'sdk.bad_response', message: `${cfg.discoveryUrl} is not an OpenVibe platform descriptor` });++ }++ const version = (data.contracts && data.contracts.version) || null;++ const compatible = Boolean(version) && satisfies(version, cfg.contractsRange);++ const origins = {};++ for (const s of data.services) if (s && s.id && s.origin) origins[s.id] = trimSlash(s.origin);++ const out = Object.freeze({++ issuer: data.issuer || null,++ tokenEndpoint: data.token_endpoint || null,++ jwksUri: data.jwks_uri || null,++ registry: data.registry || null,++ services: data.services,++ origins: Object.freeze(origins),++ contractsVersion: version,++ contractsRange: cfg.contractsRange,++ compatible,++ fetchedAt: new Date().toISOString(),++ raw: data,++ });++ if (!compatible) {++ const msg = `network runs openvibe-contracts ${version || '(unknown)'}, this SDK supports ${cfg.contractsRange}`;++ if (cfg.strictContracts) throw new OpenVibeError({ code: 'sdk.incompatible_contracts', message: msg });++ if (!shared.warned) { shared.warned = true; cfg.onWarning(msg); }++ }++ shared.discovery = out;++ shared.discoveredAt = Date.now();++ return out;++ })().finally(() => { shared.inflight = null; });++ }++ return shared.inflight;++ }++++ /** Origin for a service: explicit baseUrls win, then the registry. */++ async function origin(service) {++ if (cfg.baseUrls[service]) return cfg.baseUrls[service];++ if (service === 'network') return cfg.network;++ let d = cachedDiscovery();++ if (!d && cfg.autoDiscover) d = await discover();++ if (d && d.origins[service]) return d.origins[service];++ throw new OpenVibeError({ code: 'sdk.unknown_service', message: `no base URL for service "${service}" (pass baseUrls.${service} or let discover() find it)` });++ }++++ /** Is the service registered and running (alpha or later)? Uses the cached descriptor. */++ async function supports(service) {++ const d = await discover();++ const s = d.services.find((x) => x && x.id === service);++ return Boolean(s && ['alpha', 'beta', 'stable', 'degraded'].includes(s.status));++ }++++ async function buildUrl({ url, service, baseUrl, path = '', query } = {}) {++ let u;++ if (url) u = String(url);++ else {++ const base = baseUrl ? trimSlash(baseUrl) : await origin(service || 'network');++ u = `${base}${path.startsWith('/') ? '' : '/'}${path}`;++ }++ if (query) {++ const qs = new URLSearchParams();++ for (const [k, v] of Object.entries(query)) {++ if (v === undefined || v === null || v === false) continue;++ qs.set(k, v === true ? '1' : Array.isArray(v) ? v.join(',') : String(v));++ }++ const s = qs.toString();++ if (s) u += (u.includes('?') ? '&' : '?') + s;++ }++ return u;++ }++++ async function tokenFor(service, audience) {++ if (cfg.token != null) return cfg.token;++ if (cfg.getToken) return (await cfg.getToken({ service, audience })) || null;++ return null;++ }++++ function currentTraceparent() {++ if (ctx.traceparent) return ctx.traceparent;++ return typeof cfg.traceparent === 'function' ? cfg.traceparent() : cfg.traceparent;++ }++++ function backoff(attempt) {++ const base = Math.min(cfg.maxRetryDelayMs, cfg.retryDelayMs * 2 ** (attempt - 1));++ return Math.round(base / 2 + Math.random() * base / 2);++ }++++ function retryAfterMs(headers) {++ const v = headers && headers.get('retry-after');++ if (!v) return null;++ const s = Number(v);++ const ms = Number.isFinite(s) ? s * 1000 : Date.parse(v) - Date.now();++ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, cfg.maxRetryDelayMs) : null;++ }++++ /**++ * request({ service | baseUrl | url, path, method, query, json | form | urlencoded | body,++ * headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,++ * deadlineMs, signal, traceparent, requestId, responseType })++ * -> { status, headers, data, requestId, traceId, traceparent, attempts }++ *++ * responseType 'response' (streams, downloads): a 2xx resolves with `data` and `response` set++ * to the raw fetch Response, body unread. The timeout then covers the response headers only,++ * and `signal` still cancels the body afterwards. An error status is read and thrown as++ * usual, so the body you get is always a success.++ */++ async function request(opts = {}) {++ const method = String(opts.method || 'GET').toUpperCase();++ const url = await buildUrl(opts);++ const retries = Math.max(0, opts.retries ?? cfg.retries);++ let idem = opts.idempotencyKey;++ if (idem === undefined && !IDEMPOTENT.has(method) && !opts.idempotent && retries > 0) idem = newIdempotencyKey();++ if (idem === false || idem === undefined) idem = null;++ const canRetry = IDEMPOTENT.has(method) || Boolean(idem) || opts.idempotent === true;++ const span = startSpan(opts.traceparent || currentTraceparent());++ const requestId = opts.requestId || ctx.requestId || `req_${randomHex(12)}`;++ const timeoutMs = opts.timeoutMs ?? cfg.timeoutMs;++ const deadline = Date.now() + (opts.deadlineMs ?? cfg.deadlineMs);++ const service = opts.service;++ const audience = opts.audience || (service ? audienceOf(service) : undefined);++ const meta = { method, url, requestId, traceId: span.traceId };++++ let attempt = 0;++ let reauthed = false;++ for (;;) {++ const headers = new Headers({ Accept: 'application/json' });++ for (const src of [cfg.headers, ctx.headers, opts.headers]) {++ for (const [k, v] of Object.entries(src || {})) if (v !== undefined && v !== null) headers.set(k, String(v));++ }++ headers.set('traceparent', span.traceparent);++ headers.set('X-OpenVibe-Request-Id', requestId);++ if (idem) headers.set('Idempotency-Key', idem);++ if (opts.auth !== false && !headers.has('authorization')) {++ const t = opts.token !== undefined ? opts.token : await tokenFor(service, audience);++ if (t) headers.set('Authorization', `Bearer ${t}`);++ }++ const body = encodeBody(opts, headers);++ const remaining = deadline - Date.now();++ if (remaining <= 0) throw new OpenVibeError({ ...meta, code: 'sdk.deadline_exceeded', message: `${method} ${url.split('?')[0]}: deadline exceeded after ${attempt} attempt(s)` });++++ let out;++ try {++ out = await attemptOnce(cfg.fetch, url, { method, headers, body, credentials: opts.credentials ?? cfg.credentials, signal: opts.signal, responseType: opts.responseType }, Math.min(timeoutMs, remaining), meta);++ } catch (err) {++ if (err.code === 'sdk.aborted' || !canRetry || attempt >= retries) throw err;++ const wait = backoff(attempt + 1);++ if (Date.now() + wait >= deadline) throw err;++ attempt++;++ await sleep(wait);++ continue;++ }++ const { res, data } = out;++ if (res.status === 401 && !reauthed && opts.auth !== false && opts.token === undefined && cfg.invalidateToken) {++ reauthed = true; // a rejected token was never acted on: refresh it and try once more++ await cfg.invalidateToken({ service, audience });++ continue;++ }++ const retryable = RETRY_STATUS.has(res.status);++ if (!res.ok && retryable && canRetry && attempt < retries) {++ const wait = retryAfterMs(res.headers) ?? backoff(attempt + 1);++ if (Date.now() + wait < deadline) {++ attempt++;++ await sleep(wait);++ continue;++ }++ }++ const rid = res.headers.get('x-openvibe-request-id') || requestId;++ if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });++ const result = { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };++ if (opts.responseType === 'response') result.response = res;++ return result;++ }++ }++++ const client = {++ request,++ /** request() but resolves to the parsed body only. */++ async json(opts) { return (await request(opts)).data; },++ discover,++ discovery: () => cachedDiscovery(),++ origin,++ supports,++ url: buildUrl,++ audienceOf,++ traceparent: currentTraceparent,++ /** A client that continues an incoming request's trace ({ traceparent, requestId, headers }). */++ withContext(c = {}) {++ const next = { ...ctx };++ if (c.traceparent) next.traceparent = c.traceparent;++ if (c.requestId) next.requestId = c.requestId;++ if (c.headers) next.headers = { ...ctx.headers, ...c.headers };++ return build(cfg, shared, next);++ },++ /** withContext() from an incoming request's headers (Node req.headers or a Fetch Headers). */++ fromRequest(req) {++ return client.withContext(contextFromHeaders(req && req.headers ? req.headers : req));++ },++ options: cfg,++ };++ return client;++}++++function encodeBody(opts, headers) {++ if (opts.json !== undefined) {++ if (!headers.has('content-type')) headers.set('Content-Type', 'application/json');++ return JSON.stringify(opts.json);++ }++ if (opts.form !== undefined) return opts.form; // FormData: fetch sets the multipart boundary++ if (opts.urlencoded !== undefined) {++ headers.set('Content-Type', 'application/x-www-form-urlencoded');++ const p = opts.urlencoded instanceof URLSearchParams ? opts.urlencoded : new URLSearchParams(Object.entries(opts.urlencoded).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]));++ return p.toString();++ }++ return opts.body;++}++++/** One fetch with its own timeout, covering the body read too. */++async function attemptOnce(fetchImpl, url, { method, headers, body, credentials, signal, responseType }, ms, meta) {++ const ctrl = new AbortController();++ let timedOut = false;++ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);++ const onAbort = () => ctrl.abort();++ let keepSignal = false;++ if (signal) {++ if (signal.aborted) ctrl.abort();++ else signal.addEventListener('abort', onAbort, { once: true });++ }++ try {++ const init = { method, headers, body, signal: ctrl.signal };++ if (credentials) init.credentials = credentials;++ const res = await fetchImpl(url, init);++ if (responseType === 'response' && res.ok) {++ keepSignal = true; // the caller reads the body later: its signal must still cancel it++ return { res, data: res };++ }++ return { res, data: await parseBody(res, responseType === 'response' ? undefined : responseType) };++ } catch (err) {++ if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });++ if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });++ if (err && err.name === 'OpenVibeError') throw err;++ throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });++ } finally {++ clearTimeout(timer);++ if (signal && !keepSignal) signal.removeEventListener('abort', onAbort);++ }++}++++async function parseBody(res, responseType) {++ if (res.status === 204 || res.status === 205 || res.status === 304) return null;++ if (responseType === 'text') return res.text();++ if (responseType === 'arrayBuffer') return res.arrayBuffer();++ const text = await res.text();++ if (!text) return null;++ const type = res.headers.get('content-type') || '';++ if (responseType === 'json' || /[/+]json\b/.test(type)) {++ try { return JSON.parse(text); } catch { return text; }++ }++ return text;++}++++module.exports = { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK, IDEMPOTENT_METHODS: IDEMPOTENT, RETRY_STATUS };++},++// ── 3: src/core/errors.js ──────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * OpenVibeError: one error type for every failed call.++ *++ * Services answer with RFC 9457 problem details (contract errors.problem@1: type, title, status,++ * detail, code, request_id, trace_id). Older routes still answer { error: 'text' } and the OAuth++ * token endpoint answers { error, error_description }; all three become the same shape here, so++ * callers branch on `err.code` and `err.status` only.++ *++ * SDK-side failures use codes in the `sdk.` family: sdk.timeout, sdk.deadline_exceeded,++ * sdk.aborted, sdk.network_error, sdk.unknown_service, sdk.incompatible_contracts, sdk.bad_response.++ */++++const CODE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/;++++class OpenVibeError extends Error {++ constructor({ message, code, status = 0, title, detail, type, requestId, traceId, errors, problem, retryable = false, cause, method, url } = {}) {++ super(message || detail || title || code || 'OpenVibe request failed', cause ? { cause } : undefined);++ this.name = 'OpenVibeError';++ this.code = code || (status ? `http.${status}` : 'sdk.error');++ this.status = status;++ if (title) this.title = title;++ if (detail) this.detail = detail;++ if (type) this.type = type;++ this.requestId = requestId || null;++ this.traceId = traceId || null;++ if (errors) this.errors = errors;++ this.problem = problem || null;++ this.retryable = Boolean(retryable);++ if (method) this.method = method;++ if (url) this.url = url;++ }++++ /** Build from an HTTP error response body (problem+json, legacy { error }, OAuth error, or text). */++ static fromResponse({ status, body, requestId, traceId, method, url, retryable = false }) {++ const obj = body && typeof body === 'object' && !Array.isArray(body) ? body : null;++ let code = null;++ if (obj && typeof obj.code === 'string' && obj.code) code = obj.code;++ else if (obj && typeof obj.error === 'string' && CODE_RE.test(obj.error) && obj.error.includes('_')) code = obj.error; // OAuth: invalid_client++ const detail = (obj && (obj.detail || obj.error_description || (typeof obj.error === 'string' ? obj.error : null)))++ || (typeof body === 'string' && body.trim() ? body.trim().slice(0, 500) : null);++ const title = obj && typeof obj.title === 'string' ? obj.title : null;++ return new OpenVibeError({++ message: `${method || 'GET'} ${stripQuery(url)} -> ${status}${code ? ` ${code}` : ''}${detail ? `: ${detail}` : ''}`,++ code: code || `http.${status}`,++ status,++ title,++ detail,++ type: obj && obj.type,++ requestId: (obj && obj.request_id) || requestId,++ traceId: (obj && obj.trace_id) || traceId,++ errors: obj && Array.isArray(obj.errors) ? obj.errors : undefined,++ problem: obj && typeof obj.code === 'string' && typeof obj.status === 'number' ? obj : null,++ retryable,++ method,++ url,++ });++ }++++ toJSON() {++ return {++ name: this.name, code: this.code, status: this.status, title: this.title, detail: this.detail, type: this.type,++ request_id: this.requestId, trace_id: this.traceId, errors: this.errors, message: this.message,++ };++ }++}++++function stripQuery(url) {++ return typeof url === 'string' ? url.split('?')[0] : '';++}++++function isOpenVibeError(err) {++ return err instanceof OpenVibeError || Boolean(err && err.name === 'OpenVibeError' && typeof err.code === 'string');++}++++module.exports = { OpenVibeError, isOpenVibeError };++},++// ── 4: src/core/trace.js ───────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * W3C Trace Context (traceparent) propagation, browser-safe.++ * Every SDK call is one span: it continues the caller's trace when one is given (same trace id,++ * new span id) and starts a new trace otherwise.++ */++const { randomHex } = __load(5);++++const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;++++/** Parse a traceparent header; null when absent or malformed (all-zero ids are invalid). */++function parseTraceparent(value) {++ const m = typeof value === 'string' && value.trim().toLowerCase().match(TRACEPARENT_RE);++ if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;++ return { traceId: m[1], parentId: m[2], flags: m[3] };++}++++/** A child span of `parent` (a traceparent string), or the root of a new trace. */++function startSpan(parent) {++ const p = parseTraceparent(parent);++ const traceId = p ? p.traceId : randomHex(16);++ const spanId = randomHex(8);++ const flags = p ? p.flags : '01';++ return { traceId, spanId, parentId: p ? p.parentId : null, traceparent: `00-${traceId}-${spanId}-${flags}` };++}++++/**++ * Context to continue from an incoming request's headers (Node IncomingMessage headers, a Fetch++ * Headers object, or a plain object): { traceparent, requestId }.++ */++function contextFromHeaders(headers) {++ const get = (k) => {++ if (!headers) return undefined;++ if (typeof headers.get === 'function') return headers.get(k) ?? undefined;++ return headers[k] ?? headers[k.toLowerCase()];++ };++ const tp = get('traceparent');++ const rid = get('x-openvibe-request-id');++ return {++ traceparent: parseTraceparent(tp) ? String(tp).trim().toLowerCase() : undefined,++ requestId: typeof rid === 'string' && /^[A-Za-z0-9._-]{8,128}$/.test(rid) ? rid : undefined,++ };++}++++module.exports = { parseTraceparent, startSpan, contextFromHeaders };++},++// ── 5: src/core/ids.js ─────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * Identifiers, browser-safe (Web Crypto getRandomValues; no Node modules).++ * ULIDs match openvibe-contracts ids.ulid(): Crockford base32, 48-bit ms time + 80-bit randomness.++ */++++const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++++function randomBytes(n) {++ const c = globalThis.crypto;++ if (!c || typeof c.getRandomValues !== 'function') throw new Error('openvibe-sdk: Web Crypto (globalThis.crypto) is required');++ return c.getRandomValues(new Uint8Array(n));++}++++function randomHex(nBytes) {++ let out = '';++ for (const b of randomBytes(nBytes)) out += b.toString(16).padStart(2, '0');++ return out;++}++++function ulid(now = Date.now()) {++ let time = '';++ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;++ let rand = '';++ for (const b of randomBytes(16)) rand += ALPHABET[b & 31]; // 256 is a multiple of 32: unbiased++ return time + rand;++}++++/** evt_<ULID>, the event id format of events.event-envelope@1. */++function newEventId(now) {++ return `evt_${ulid(now)}`;++}++++/** A fresh Idempotency-Key value. */++function newIdempotencyKey() {++ return `idem_${ulid()}`;++}++++const SUBJECT_RE = { user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/ };++++/** Is this a usr_… or gst_… subject id (the ids a service may act for)? */++function isActingSubjectId(id) {++ return typeof id === 'string' && (SUBJECT_RE.user.test(id) || SUBJECT_RE.guest.test(id));++}++++module.exports = { ulid, newEventId, newIdempotencyKey, randomBytes, randomHex, isActingSubjectId };++},++// ── 6: src/core/semver.js ──────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * Just enough semver for version negotiation: parse, compare, and ranges made of space-separated++ * comparators (>=, >, <=, <, =, bare), caret (^1.2.3), tilde (~1.2.3) and `||` alternatives.++ */++++const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;++++function parse(v) {++ const m = typeof v === 'string' && v.trim().match(VERSION_RE);++ return m ? { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || null } : null;++}++++function compare(a, b) {++ const x = typeof a === 'string' ? parse(a) : a;++ const y = typeof b === 'string' ? parse(b) : b;++ if (!x || !y) throw new TypeError(`invalid version ${!x ? a : b}`);++ for (const k of ['major', 'minor', 'patch']) if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;++ if (x.pre === y.pre) return 0;++ if (!x.pre) return 1;++ if (!y.pre) return -1;++ return x.pre < y.pre ? -1 : 1;++}++++function comparators(part) {++ const out = [];++ for (const tok of part.trim().split(/\s+/).filter(Boolean)) {++ const m = tok.match(/^(>=|<=|>|<|=|\^|~)?(.+)$/);++ const op = m[1] || '=';++ const v = parse(m[2]);++ if (!v) throw new TypeError(`invalid range token ${tok}`);++ if (op === '^') {++ const upper = v.major > 0 ? `${v.major + 1}.0.0` : v.minor > 0 ? `0.${v.minor + 1}.0` : `0.0.${v.patch + 1}`;++ out.push(['>=', v], ['<', parse(upper)]);++ } else if (op === '~') {++ out.push(['>=', v], ['<', parse(`${v.major}.${v.minor + 1}.0`)]);++ } else {++ out.push([op, v]);++ }++ }++ return out;++}++++function satisfies(version, range) {++ const v = parse(version);++ if (!v) return false;++ return String(range).split('||').some((part) => comparators(part).every(([op, c]) => {++ const r = compare(v, c);++ return op === '>=' ? r >= 0 : op === '>' ? r > 0 : op === '<=' ? r <= 0 : op === '<' ? r < 0 : r === 0;++ }));++}++++module.exports = { parse, compare, satisfies };++},++// ── 7: src/core/paginate.js ────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * Cursor pagination as an async iterator.++ *++ * for await (const item of paginate((cursor) => fetchPage(cursor), { cursor: 0 })) { … }++ *++ * `fetchPage(cursor)` returns { items, next }; iteration stops when `next` is null/undefined or++ * does not move. Offset APIs use `offsetPager()` to produce the same shape.++ */++++async function* paginate(fetchPage, { cursor, maxPages = Infinity, maxItems = Infinity } = {}) {++ let c = cursor;++ let pages = 0;++ let items = 0;++ while (pages < maxPages) {++ pages++;++ const page = await fetchPage(c);++ for (const item of (page && page.items) || []) {++ if (items >= maxItems) return;++ items++;++ yield item;++ }++ if (!page || page.next == null || page.next === c) return;++ c = page.next;++ }++}++++/**++ * For limit/offset endpoints: wraps `load(offset, limit)` -> { items, total? } into a fetchPage.++ * Stops on a short page, or when `total` is known and reached.++ */++function offsetPager(load, { limit = 50 } = {}) {++ return async (offset = 0) => {++ const { items, total } = await load(offset, limit);++ const end = offset + items.length;++ const more = items.length === limit && (typeof total !== 'number' || end < total);++ return { items, next: more ? end : null };++ };++}++++module.exports = { paginate, offsetPager };++},++// ── 8: src/auth/browser.js ─────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).++ *++ * The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's++ * session). The code is then exchanged on your server (openvibe-sdk/auth server entry,++ * exchangeCode()): a confidential client's credentials never reach a browser, and even a public++ * developer app (no secret) is best served by keeping the token in an HttpOnly server session.++ * This file holds no credentials and uses Web Crypto only.++ *++ * Two kinds of client use this flow:++ * first-party sign-in scope 'profile theme' (the default); the exchange yields a user token++ * developer app `audience` (openvibe.<service>) and `scope` = capability ids; the exchange++ * yields a 5-minute app token acting for the person (no refresh token)++ */++const { OpenVibeError } = __load(3);++const { randomBytes } = __load(5);++const { DEFAULT_NETWORK } = __load(2);++++function base64url(bytes) {++ let bin = '';++ for (const b of bytes) bin += String.fromCharCode(b);++ return btoa(bin).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');++}++++/** A random code verifier: 43..128 characters of [A-Za-z0-9-._~] (default 64). */++function createCodeVerifier(length = 64) {++ if (!Number.isInteger(length) || length < 43 || length > 128) throw new RangeError('PKCE verifier length must be 43..128');++ return base64url(randomBytes(Math.ceil(length * 3 / 4))).slice(0, length);++}++++/** S256 challenge: BASE64URL(SHA-256(ASCII(verifier))). */++async function pkceChallenge(verifier) {++ const subtle = globalThis.crypto && globalThis.crypto.subtle;++ if (!subtle) throw new Error('openvibe-sdk: Web Crypto subtle digest is required for PKCE');++ const digest = await subtle.digest('SHA-256', new TextEncoder().encode(String(verifier)));++ return base64url(new Uint8Array(digest));++}++++/** { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' } */++async function createPkcePair(length) {++ const codeVerifier = createCodeVerifier(length);++ return { codeVerifier, codeChallenge: await pkceChallenge(codeVerifier), codeChallengeMethod: 'S256' };++}++++/** An unguessable `state` value that ties the callback to this browser. */++function createState() {++ return base64url(randomBytes(24));++}++++/**++ * GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state++ * &code_challenge&code_challenge_method=S256[&audience][&prompt=none]++ *++ * `scope` defaults to 'profile theme' (first-party sign-in) only when no `audience` is given. A++ * developer app passes `audience` and its capability ids as `scope`; without a scope the code may++ * yield every capability the app holds for that audience. Network refuses prompt=none for apps++ * (error=interaction_required): a person always chooses to continue.++ */++function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope, audience, state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {++ if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');++ const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);++ u.searchParams.set('response_type', 'code');++ u.searchParams.set('client_id', clientId);++ u.searchParams.set('redirect_uri', redirectUri);++ const s = scope === undefined && !audience ? 'profile theme' : scope;++ const scopeText = Array.isArray(s) ? s.filter(Boolean).join(' ') : s;++ if (scopeText) u.searchParams.set('scope', scopeText);++ if (audience) u.searchParams.set('audience', audience);++ if (state) u.searchParams.set('state', state);++ if (codeChallenge) {++ u.searchParams.set('code_challenge', codeChallenge);++ u.searchParams.set('code_challenge_method', codeChallengeMethod);++ }++ if (prompt) u.searchParams.set('prompt', prompt);++ return u.toString();++}++++/**++ * Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.++ * Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.++ * A developer app passes { audience, scope: [capability ids] } and exchanges with the same audience.++ */++async function startAuthorization(opts = {}) {++ const pair = await createPkcePair(opts.verifierLength);++ const state = opts.state || createState();++ const url = buildAuthorizeUrl({ ...opts, state, codeChallenge: pair.codeChallenge, codeChallengeMethod: 'S256' });++ return { url, state, codeVerifier: pair.codeVerifier, codeChallenge: pair.codeChallenge };++}++++/**++ * Read the redirect back from the Network: { code, state } or throws OpenVibeError++ * (oauth.<error> such as oauth.login_required after prompt=none, or oauth.state_mismatch).++ */++function readCallback(location, { expectedState } = {}) {++ const u = new URL(String(location), 'http://localhost');++ const p = u.searchParams;++ const state = p.get('state') || '';++ if (p.get('error')) {++ throw new OpenVibeError({ code: `oauth.${p.get('error')}`, status: 400, detail: p.get('error_description') || p.get('error'), message: `authorization failed: ${p.get('error')}` });++ }++ if (expectedState !== undefined && state !== expectedState) {++ throw new OpenVibeError({ code: 'oauth.state_mismatch', status: 400, message: 'the callback state does not match the one this browser started with' });++ }++ const code = p.get('code');++ if (!code) throw new OpenVibeError({ code: 'oauth.missing_code', status: 400, message: 'the callback carries no authorization code' });++ return { code, state };++}++++/**++ * The header and claims of a JWT, decoded WITHOUT checking its signature, issuer, audience or++ * expiry: { header, claims } or null when it is not a JWT. For display and logging only (who a++ * token names, what it may do, when it expires). Never authorize anything with it: a receiver++ * verifies tokens with verifyUserToken() / verifyAppToken() (server).++ */++function decodeUnverified(token) {++ const parts = typeof token === 'string' ? token.split('.') : [];++ if (parts.length !== 3) return null;++ try {++ const part = (p) => {++ const b64 = p.replace(/-/g, '+').replace(/_/g, '/');++ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));++ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));++ };++ const header = part(parts[0]);++ const claims = part(parts[1]);++ if (!header || typeof header !== 'object' || !claims || typeof claims !== 'object' || Array.isArray(claims)) return null;++ return { header, claims };++ } catch {++ return null;++ }++}++++/** decodeUnverified(token).claims, or null. UNVERIFIED: see decodeUnverified(). */++function unverifiedClaims(token) {++ const d = decodeUnverified(token);++ return d ? d.claims : null;++}++++module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url, decodeUnverified, unverifiedClaims };++},++// ── 9: src/registry.js ─────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/registry: the ecosystem registry on OpenVibe.Network (/api/v1/registry), built from++ * openvibe-contracts manifests. Public, cacheable, no token. Browser-safe.++ *++ * const registry = createRegistryClient(client);++ * await registry.services({ status: 'alpha' });++ * await registry.domain('openvibe.media'); // which service answers for a host++ */++const { isOpenVibeError } = __load(3);++++function createRegistryClient(client, { baseUrl } = {}) {++ const get = (path, query) => client.json({ service: 'network', baseUrl, path: `/api/v1/registry${path}`, query, auth: false });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++++ return {++ /** The platform descriptor (cached by the client): origins, endpoints, contracts version. */++ descriptor: (opts) => client.discover(opts),++ /** Service manifests with polled runtime health. */++ async services({ status } = {}) { return (await get('/services', { status })).services; },++ /** One manifest (+ capability_details), or null. */++ service: (id) => orNull(get(`/services/${encodeURIComponent(id)}`)),++ async capabilities({ owner } = {}) { return (await get('/capabilities', { owner })).capabilities; },++ capability: (id) => orNull(get(`/capabilities/${encodeURIComponent(id)}`)),++ async namespaces() { return (await get('/namespaces')).namespaces; },++ /** { version, contracts: [catalog entries] } */++ contracts: () => get('/contracts'),++ async topics() { return (await get('/topics')).topics; },++ /** { domain, service } for a host name, or null. */++ domain: (host) => orNull(get(`/domains/${encodeURIComponent(String(host).toLowerCase())}`)),++ };++}++++module.exports = { createRegistryClient };++},++// ── 10: src/modules.js ──────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/modules: per-subject module records on OpenVibe.Network (contracts++ * modules.module-record@1). Every write names the revision it read (If-Match); a moved revision++ * is 412 modules.revision_conflict, so two writers never silently overwrite each other.++ * update(ns, fn) does the read-modify-write loop for you. Browser-safe.++ *++ * user GET/PUT/DELETE /api/modules/:ns, GET /api/modules, GET /api/modules/:ns/public/:subject++ * service GET/PUT /internal/modules/:ns/:subject (token with network.modules.read / .write for ns)++ */++const { isOpenVibeError, OpenVibeError } = __load(3);++++const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));++const enc = encodeURIComponent;++++function createModulesClient(client, { baseUrl, maxAttempts = 5 } = {}) {++ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const ifMatch = (revision) => (revision === undefined || revision === null ? undefined : `"${Number(revision)}"`);++++ async function readModifyWrite(read, write, fn, attempts) {++ let last;++ for (let i = 0; i < attempts; i++) {++ const rec = await read();++ const next = await fn(rec ? clone(rec.data) : undefined, rec);++ if (next === undefined) return rec; // nothing to change++ try {++ return await write(next, rec ? rec.revision : 0);++ } catch (err) {++ if (!(isOpenVibeError(err) && err.status === 412)) throw err;++ last = err; // someone else wrote first: read again++ }++ }++ throw last || new OpenVibeError({ code: 'modules.revision_conflict', status: 412, message: 'update gave up' });++ }++++ const mine = {++ /** Every record of the signed-in subject: { subject, modules, namespaces }. */++ list: () => call({ path: '/api/modules' }),++ /** The record, or null when there is none yet. */++ get: (ns) => orNull(call({ path: `/api/modules/${enc(ns)}` })),++ /** Replace the data. revision = the one you read (0 for a new record); required for users. */++ put(ns, data, { revision } = {}) {++ if (revision === undefined) throw new TypeError('put: pass { revision } (0 for a new record)');++ return call({ method: 'PUT', path: `/api/modules/${enc(ns)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) } });++ },++ /** true when a record was deleted, false when there was none. */++ async delete(ns) {++ try { await call({ method: 'DELETE', path: `/api/modules/${enc(ns)}` }); return true; } catch (err) {++ if (isOpenVibeError(err) && err.status === 404) return false;++ throw err;++ }++ },++ /** Read, apply fn(data, record) -> newData (undefined = no change), write; retries on 412. */++ update(ns, fn, { attempts = maxAttempts } = {}) {++ return readModifyWrite(() => mine.get(ns), (data, rev) => mine.put(ns, data, { revision: rev }), fn, attempts);++ },++ /** Anyone: only the namespace's public fields. */++ publicGet: (ns, subjectId) => orNull(call({ path: `/api/modules/${enc(ns)}/public/${enc(subjectId)}`, auth: false })),++ };++++ const service = {++ get: (ns, subjectId) => orNull(call({ path: `/internal/modules/${enc(ns)}/${enc(subjectId)}` })),++ /** The owning service may write unconditionally (no revision); pass one to guard against races. */++ put: (ns, subjectId, data, { revision } = {}) => call({++ method: 'PUT', path: `/internal/modules/${enc(ns)}/${enc(subjectId)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) },++ }),++ update(ns, subjectId, fn, { attempts = maxAttempts } = {}) {++ return readModifyWrite(() => service.get(ns, subjectId), (data, rev) => service.put(ns, subjectId, data, { revision: rev }), fn, attempts);++ },++ };++++ return { ...mine, forSubject: service };++}++++module.exports = { createModulesClient };++},++// ── 11: src/realtime.js ─────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/realtime: the browser realtime plane inside OpenVibe.Events (ADR-005), over SSE.++ *++ * const sub = subscribe(['live.stream.*'], (event, { seq }) => { … }, {++ * client, // or url: 'https://events.openvibe.network'++ * lastEventId: savedSeq, // resume after a reload++ * onGap: (gap) => refetchState(),++ * });++ * sub.close();++ *++ * Transport: EventSource when the environment has one and no Bearer token is needed (cookies ride++ * along with withCredentials); otherwise fetch with a streamed body (Node, or a token). Either++ * way it resumes from the last seq it saw (Last-Event-ID), skips anything it already delivered,++ * and reports an `event: gap` (events missed beyond retention or replay limits) through onGap.++ * Browser-safe.++ */++const { OpenVibeError } = __load(3);++++const DEFAULT_ORIGIN = 'https://events.openvibe.network';++const FATAL = new Set([400, 401, 403, 404]);++const sleep = (ms, signal) => new Promise((resolve) => {++ const t = setTimeout(resolve, ms);++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });++});++++function subscribe(topics, onEvent, opts = {}) {++ const list = (Array.isArray(topics) ? topics : String(topics).split(',')).map((t) => String(t).trim()).filter(Boolean);++ if (!list.length) throw new TypeError('subscribe: at least one topic pattern');++ if (typeof onEvent !== 'function') throw new TypeError('subscribe: onEvent must be a function');++ const {++ client, url, baseUrl, lastEventId = null, onGap, onOpen, onError, token, getToken,++ withCredentials = true, transport = 'auto', reconnectDelayMs = 3000, maxReconnectDelayMs = 30000,++ } = opts;++ const fetchImpl = opts.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);++ const ES = opts.EventSource !== undefined ? opts.EventSource : globalThis.EventSource;++++ const state = { lastSeq: lastEventId != null && lastEventId !== '' ? Number(lastEventId) : null, closed: false, transport: null, connected: false };++ const stop = new AbortController();++ let es = null;++ let retryMs = reconnectDelayMs;++ let failures = 0;++++ const report = (err) => { if (onError) { try { onError(err); } catch { /* the error hook must not break the loop */ } } };++++ function dispatch(type, data, id) {++ if (type === 'gap') {++ let gap = null;++ try { gap = JSON.parse(data); } catch { gap = { reason: 'unknown' }; }++ if (onGap) { try { onGap(gap); } catch (err) { report(err); } }++ return;++ }++ if (type !== 'message' || !data) return;++ let msg;++ try { msg = JSON.parse(data); } catch { report(new OpenVibeError({ code: 'sdk.bad_response', message: 'realtime: undecodable message' })); return; }++ const seq = Number(id !== undefined && id !== '' ? id : msg && msg.seq);++ if (Number.isFinite(seq)) {++ if (state.lastSeq != null && seq <= state.lastSeq) return; // already delivered++ state.lastSeq = seq;++ }++ try { onEvent(msg && msg.event, { seq }); } catch (err) { report(err); }++ }++++ async function streamUrl() {++ let base = url || baseUrl;++ if (!base && client) {++ try { base = await client.origin('events'); } catch { base = DEFAULT_ORIGIN; }++ }++ base = String(base || DEFAULT_ORIGIN).replace(/\/+$/, '');++ const u = new URL(base.endsWith('/realtime/stream') ? base : `${base}/realtime/stream`);++ u.searchParams.set('topics', list.join(','));++ return u;++ }++++ // A Bearer token: given here, or the client's own (a service token client, a user JWT).++ const clientAuth = client && client.options && (client.options.token || client.options.getToken) ? client.options : null;++ async function bearer() {++ if (token) return token;++ const fn = getToken || (clientAuth && clientAuth.getToken);++ if (fn) return fn({ service: 'events', audience: client ? client.audienceOf('events') : 'openvibe.events' });++ return clientAuth ? clientAuth.token : null;++ }++++ // ── fetch transport ─────────────────────────────────────++ async function runFetch() {++ if (!fetchImpl) throw new TypeError('realtime: no fetch available');++ const u = await streamUrl();++ while (!state.closed) {++ const ctrl = new AbortController();++ const abort = () => ctrl.abort();++ stop.signal.addEventListener('abort', abort, { once: true });++ try {++ const headers = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' };++ if (state.lastSeq != null) headers['Last-Event-ID'] = String(state.lastSeq);++ const t = await bearer();++ if (t) headers.Authorization = `Bearer ${t}`;++ const init = { headers, signal: ctrl.signal, cache: 'no-store' };++ if (withCredentials) init.credentials = 'include';++ const res = await fetchImpl(u.toString(), init);++ if (!res.ok) {++ const body = await res.text().catch(() => '');++ let parsed = body;++ try { parsed = JSON.parse(body); } catch { /* text */ }++ const err = OpenVibeError.fromResponse({ status: res.status, body: parsed, method: 'GET', url: u.toString(), retryable: !FATAL.has(res.status) });++ report(err);++ if (FATAL.has(res.status)) { state.closed = true; return; }++ } else {++ failures = 0;++ state.connected = true;++ if (onOpen) { try { onOpen(); } catch (err) { report(err); } }++ await readSse(res.body, dispatch, (ms) => { retryMs = ms; });++ state.connected = false;++ }++ } catch (err) {++ state.connected = false;++ if (state.closed) return;++ report(err && err.name === 'OpenVibeError' ? err : new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `realtime: ${err && err.message}`, cause: err }));++ } finally {++ stop.signal.removeEventListener('abort', abort);++ }++ if (state.closed) return;++ failures++;++ await sleep(Math.min(maxReconnectDelayMs, retryMs * 2 ** Math.max(0, failures - 1)), stop.signal);++ }++ }++++ // ── EventSource transport ───────────────────────────────++ async function runEventSource() {++ const u = await streamUrl();++ const open = () => {++ if (state.closed) return;++ const target = new URL(u.toString());++ if (state.lastSeq != null) target.searchParams.set('last_event_id', String(state.lastSeq));++ es = new ES(target.toString(), { withCredentials });++ es.onopen = () => { failures = 0; state.connected = true; if (onOpen) { try { onOpen(); } catch (err) { report(err); } } };++ es.onmessage = (m) => dispatch('message', m.data, m.lastEventId);++ es.addEventListener('gap', (m) => dispatch('gap', m.data));++ es.onerror = () => {++ state.connected = false;++ // CONNECTING (0): the browser reconnects by itself and sends Last-Event-ID.++ // CLOSED (2): it gave up (HTTP error); reconnect ourselves from the last seq.++ if (es.readyState === 2 && !state.closed) {++ report(new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: 'realtime: stream closed' }));++ failures++;++ sleep(Math.min(maxReconnectDelayMs, reconnectDelayMs * 2 ** Math.max(0, failures - 1)), stop.signal).then(open);++ }++ };++ };++ open();++ }++++ const needsHeader = Boolean(token || getToken || clientAuth);++ const useEs = transport === 'eventsource' || (transport === 'auto' && typeof ES === 'function' && !needsHeader);++ state.transport = useEs ? 'eventsource' : 'fetch';++ const done = (useEs ? runEventSource() : runFetch()).catch((err) => { report(err); });++++ return {++ close() {++ state.closed = true;++ stop.abort();++ if (es) { try { es.close(); } catch { /* already closed */ } }++ },++ get lastEventId() { return state.lastSeq; },++ get transport() { return state.transport; },++ get connected() { return state.connected; },++ get closed() { return state.closed; },++ /** Resolves when the fetch loop has ended (after close() or a fatal error). */++ done,++ };++}++++/**++ * parseSSE(body, { onRetry }) -> async iterator of { event, data, id } (WHATWG event-stream rules).++ *++ * for await (const { event, data, id } of parseSSE(res.body)) { … }++ *++ * `body` is a ReadableStream<Uint8Array> (fetch), or any async iterable of Uint8Array/string chunks++ * (a Node stream). `event` defaults to 'message'; `id` is the id field of that event (undefined when++ * it had none); comments are skipped; `retry: <ms>` is passed to onRetry. A line split across chunks,++ * including a \r\n pair, is reassembled. Breaking out of the loop releases the stream.++ */++async function* parseSSE(body, { onRetry } = {}) {++ if (!body || (typeof body.getReader !== 'function' && typeof body[Symbol.asyncIterator] !== 'function')) {++ throw new TypeError('parseSSE: pass a ReadableStream or an async iterable body');++ }++ const decoder = new TextDecoder();++ let buf = '';++ let type = '';++ let data = [];++ let id;++ const out = [];++ const line = (l) => {++ if (l === '') {++ if (data.length || type) out.push({ event: type || 'message', data: data.join('\n'), id });++ type = ''; data = []; id = undefined;++ return;++ }++ if (l[0] === ':') return;++ const i = l.indexOf(':');++ const field = i < 0 ? l : l.slice(0, i);++ let value = i < 0 ? '' : l.slice(i + 1);++ if (value[0] === ' ') value = value.slice(1);++ if (field === 'event') type = value;++ else if (field === 'data') data.push(value);++ else if (field === 'id' && !value.includes('\0')) id = value;++ else if (field === 'retry' && /^\d+$/.test(value) && onRetry) onRetry(Number(value));++ };++ const reader = typeof body.getReader === 'function' ? body.getReader() : null;++ const iter = reader ? null : body[Symbol.asyncIterator]();++ try {++ for (;;) {++ const { value, done } = reader ? await reader.read() : await iter.next();++ if (done) break;++ buf += typeof value === 'string' ? value : decoder.decode(value, { stream: true });++ let m;++ while ((m = /\r\n|\r|\n/.exec(buf))) {++ if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunks++ line(buf.slice(0, m.index));++ buf = buf.slice(m.index + m[0].length);++ }++ while (out.length) yield out.shift();++ }++ } finally {++ if (reader) {++ try { await reader.cancel(); } catch { /* stream already gone */ }++ try { reader.releaseLock(); } catch { /* already released */ }++ } else if (iter && typeof iter.return === 'function') {++ try { await iter.return(); } catch { /* already closed */ }++ }++ }++}++++/** The realtime loop's reader: every parsed event goes to dispatch(type, data, id). */++async function readSse(body, dispatch, onRetry) {++ for await (const e of parseSSE(body, { onRetry })) dispatch(e.event, e.data, e.id);++}++++function createRealtimeClient(client, defaults = {}) {++ return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };++}++++module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };++},++// ── 12: src/media.js ────────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.++ *++ * Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a++ * secret) or a Network service token holding media.object.upload for the app's namespace++ * (accepted on upload only). Media refuses a user JWT here: a browser uploads through its own++ * app server, which names the user with X-OV-User-Id (the `actingUserId` option).++ * Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.++ */++const { isOpenVibeError } = __load(3);++const { paginate, offsetPager } = __load(7);++++const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';++const enc = encodeURIComponent;++++/** Absolute public URLs for Media objects (no auth unless the item is private). */++function mediaUrls(origin = DEFAULT_PUBLIC_ORIGIN) {++ const o = String(origin).replace(/\/+$/, '');++ return {++ origin: o,++ file: (key) => `${o}/f/${enc(key)}`,++ vod: (id) => `${o}/v/${enc(id)}`,++ clip: (id) => `${o}/c/${enc(id)}`,++ thumbnail: (name) => `${o}/t/${enc(name)}`,++ paste: (slug) => `${o}/p/${enc(slug)}`,++ pasteRaw: (slug) => `${o}/p/${enc(slug)}/raw`,++ pasteScreenshot: (slug) => `${o}/p/${enc(slug)}/screenshot`,++ vodTranscript: (id) => `${o}/v/${enc(id)}/transcript.json`,++ liveTranscript: (sel, { limit, app } = {}) => withQuery(`${o}/live/${enc(sel)}/transcript.json`, { limit, app }),++ liveFrame: (sel, { width, app, format } = {}) => withQuery(`${o}/live/${enc(sel)}/frame.jpg`, { w: width, app, format }),++ /** Make a Media-relative path (e.g. an upload's `/f/<key>`) absolute. */++ absolute: (path) => (/^https?:\/\//.test(path) ? path : `${o}${String(path).startsWith('/') ? '' : '/'}${path}`),++ };++}++++function withQuery(u, q) {++ const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])).toString();++ return qs ? `${u}?${qs}` : u;++}++++function toBlob(file, contentType) {++ if (typeof Blob !== 'undefined' && file instanceof Blob) return file;++ if (typeof file === 'string' || file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {++ return new Blob([file], contentType ? { type: contentType } : undefined);++ }++ throw new TypeError('upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');++}++++function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOrigin } = {}) {++ if (!app) throw new TypeError('createMediaClient: app (the tenant id, e.g. "live") is required');++ const base = `/api/v1/${enc(app)}`;++ const urls = mediaUrls(publicOrigin || DEFAULT_PUBLIC_ORIGIN);++ const call = (opts, perCall = {}) => {++ const headers = { ...opts.headers };++ const acting = perCall.actingUserId !== undefined ? perCall.actingUserId : actingUserId;++ if (acting != null) headers['X-OV-User-Id'] = String(acting);++ return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });++ };++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const withPublic = (meta) => (meta && meta.url ? { ...meta, public_url: urls.absolute(meta.url) } : meta);++++ const files = {++ /**++ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.++ * Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.++ */++ async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {++ const blob = toBlob(file, contentType);++ const form = new FormData();++ if (userId != null) form.append('user_id', String(userId));++ form.append('file', blob, filename || (file && file.name) || 'file');++ return withPublic(await call({ method: 'POST', path: `${base}/files`, form, idempotent: true, signal }, { actingUserId: acting }));++ },++ /** { files, used_bytes, quota_bytes, limit, offset } */++ async list({ limit, offset } = {}) {++ const out = await call({ path: `${base}/files`, query: { limit, offset } });++ return { ...out, files: (out.files || []).map(withPublic) };++ },++ /** Async iterator over every file of the app. */++ iterate({ pageSize = 100 } = {}) {++ return paginate(offsetPager(async (offset, limit) => ({ items: (await files.list({ limit, offset })).files }), { limit: pageSize }), { cursor: 0 });++ },++ get: async (key) => withPublic(await orNull(call({ path: `${base}/files/${enc(key)}` }))),++ /** true when deleted, false when there was no such file. */++ async delete(key, { actingUserId: acting } = {}) {++ try { await call({ method: 'DELETE', path: `${base}/files/${enc(key)}` }, { actingUserId: acting }); return true; } catch (err) {++ if (isOpenVibeError(err) && err.status === 404) return false;++ throw err;++ }++ },++ };++++ return { app, files, urls, upload: files.upload };++}++++module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };++},++// ── 13: src/community.js ────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/community: OpenVibe.Community pastes (/api/pastes). Browser-safe.++ *++ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or++ * a Bearer user JWT) or anonymously. First-party services call with a Network service token++ * (audience openvibe.community, community.paste.create / .write / .moderate) and say who they++ * act for:++ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject (without it a write is anonymous)++ * origin: 'ai' -> X-OV-Origin: ai (AI output; never attributed to a person,++ * so no subject is sent with it)++ * sourceRef: { service, type, id } -> X-OV-Source-Ref (EntityRef, e.g. the stream it came from)++ * staff: true -> X-OV-Staff: 1 (the acting person is staff; needs .moderate)++ * Community ignores X-OV-* headers from browsers.++ *++ * TODO: comments on other content, the forum and Pulse APIs are being added to Community now and++ * are not wrapped yet; paste comments are.++ */++const { isOpenVibeError } = __load(3);++const { isActingSubjectId } = __load(5);++const { paginate, offsetPager } = __load(7);++++const enc = encodeURIComponent;++++function actingHeaders({ actingSubject, origin, sourceRef, staff } = {}) {++ const h = {};++ if (origin !== undefined && origin !== null) {++ if (origin !== 'ai' && origin !== 'user') throw new TypeError('origin must be "ai" or "user"');++ h['X-OV-Origin'] = origin;++ }++ if (actingSubject && origin !== 'ai') {++ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');++ h['X-OV-Subject'] = actingSubject;++ }++ if (sourceRef) h['X-OV-Source-Ref'] = typeof sourceRef === 'string' ? sourceRef : JSON.stringify(sourceRef);++ if (staff) h['X-OV-Staff'] = '1';++ return h;++}++++function createCommunityClient(client, defaults = {}) {++ const { baseUrl } = defaults;++ const pick = (o) => ({ actingSubject: o.actingSubject, origin: o.origin, sourceRef: o.sourceRef, staff: o.staff });++ const base = pick(defaults);++++ function call(opts, perCall = {}) {++ const acting = { ...base };++ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;++ return client.json({++ service: 'community', baseUrl, audience: 'openvibe.community', ...opts,++ headers: { ...actingHeaders(acting), ...opts.headers }, signal: perCall.signal,++ });++ }++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const once = { idempotencyKey: false }; // Community does not dedupe these writes: never repeat them++++ const pastes = {++ /** { pastes, total, limit, offset } — query: limit, offset, type, search, sort, origin, username */++ list: (query = {}, o = {}) => call({ path: '/api/pastes', query }, o),++ iterate(query = {}, o = {}) {++ const limit = query.limit || 50;++ return paginate(offsetPager(async (offset) => {++ const page = await pastes.list({ ...query, limit, offset }, o);++ return { items: page.pastes || [], total: page.total };++ }, { limit }), { cursor: query.offset || 0 });++ },++ /** { paste, liked } or null. noView: true does not count a view. */++ get: (slug, { noView, ...o } = {}) => orNull(call({ path: `/api/pastes/${enc(slug)}`, query: { no_view: noView ? '1' : undefined } }, o)),++ /**++ * Text paste: { title?, content, language?, visibility?, burn_after_read?, is_nsfw?, … }.++ * Screenshot paste: { screenshot: Blob|File|Buffer, filename?, title?, visibility? } (multipart).++ * -> { id, slug, url, … }++ */++ create(input = {}, o = {}) {++ if (input.screenshot != null) {++ const { screenshot, filename, contentType, ...fields } = input;++ const form = new FormData();++ for (const [k, v] of Object.entries(fields)) if (v !== undefined && v !== null) form.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v));++ const blob = typeof Blob !== 'undefined' && screenshot instanceof Blob ? screenshot : new Blob([screenshot], { type: contentType || 'image/png' });++ form.append('screenshot', blob, filename || (screenshot && screenshot.name) || 'screenshot.png');++ return call({ method: 'POST', path: '/api/pastes', form, ...once }, o);++ }++ return call({ method: 'POST', path: '/api/pastes', json: input, ...once }, o);++ },++ /** { paste } — owner or staff. */++ update: (slug, patch, o = {}) => call({ method: 'PUT', path: `/api/pastes/${enc(slug)}`, json: patch }, o),++ delete: (slug, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}` }, o),++ /** A copy of a text paste owned by the caller -> { id, slug, url, … } */++ fork: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/fork`, json: {}, ...once }, o),++ /** Toggles the caller's like -> { liked, likes }. */++ like: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/like`, ...once }, o),++ /** Counts a copy -> { copies }. */++ copy: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/copy`, ...once }, o),++ versions: (slug, o = {}) => call({ path: `/api/pastes/${enc(slug)}/versions` }, o),++ byUser: (username, query = {}, o = {}) => call({ path: `/api/pastes/by-user/${enc(username)}`, query }, o),++ config: (o = {}) => call({ path: '/api/pastes/config' }, o),++ comments: {++ /** { comments, total } */++ list: (slug, query = {}, o = {}) => call({ path: `/api/pastes/${enc(slug)}/comments`, query }, o),++ /** { content, parent_id? } -> { comment } */++ create: (slug, input, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/comments`, json: input, ...once }, o),++ delete: (slug, commentId, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}/comments/${enc(commentId)}` }, o),++ },++ };++++ return {++ pastes,++ /** The same client acting for someone else: as('usr_…') or as({ actingSubject, origin, sourceRef, staff }). */++ as(who) {++ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };++ return createCommunityClient(client, { ...defaults, ...next });++ },++ headers: () => actingHeaders(base),++ };++}++++module.exports = { createCommunityClient, actingHeaders };++},++// ── 14: src/jobs.js ─────────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs++ * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.++ *++ * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });++ * const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },++ * files: [{ name: 'a.png', data: bytes }], idempotencyKey });++ * for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops++ * const done = await jobs.get(job.id);++ * const res = await jobs.file(job.id, 0); // raw Response: stream or save it++ *++ * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,++ * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same++ * answer as for a job that does not exist.++ *++ * POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);++ * Idempotency-Key -> 202 new | 200 + Idempotent-Replayed++ * GET /api/v1/jobs/:id++ * DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished++ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;++ * 204 when the job finished and nothing is newer++ * GET /api/v1/jobs/:id/files/:n a result file++ */++const { OpenVibeError, isOpenVibeError } = __load(3);++const { newIdempotencyKey } = __load(5);++const { parseSSE } = __load(11);++++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const TERMINAL_EVENTS = new Set(['job.succeeded', 'job.failed', 'job.cancelled']);++const enc = encodeURIComponent;++const sleep = (ms, signal) => new Promise((resolve) => {++ const t = setTimeout(resolve, ms);++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });++});++++function toBlob(data, type) {++ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;++ if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);++ throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');++}++++/** Is this job finished (succeeded, failed or cancelled)? */++function isTerminal(job) {++ return Boolean(job && TERMINAL.has(job.state));++}++++function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++++ /**++ * submit({ type, input = {}, files, idempotencyKey, signal }) -> { job, replayed, idempotencyKey }++ *++ * Always carries an Idempotency-Key (one is generated when you pass none), so retries after a++ * timeout or a 503 are safe. To survive a crash between submitting and saving the job id, derive++ * the key from the request (or store it first) and submit again with the same key: Tools answers++ * with the SAME job (`replayed: true`). A different request under a used key is 409++ * tools.job.idempotency_conflict.++ */++ async function submit({ type, input = {}, files, idempotencyKey, signal } = {}) {++ if (!type) throw new TypeError('jobs.submit: type is required (e.g. img.process)');++ if (input === null || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('jobs.submit: input must be an object');++ const key = idempotencyKey || newIdempotencyKey();++ const list = files == null ? [] : Array.isArray(files) ? files : [files];++ const opts = { method: 'POST', path: '/api/v1/jobs', idempotencyKey: key, signal };++ if (list.length) {++ const form = new FormData();++ form.append('type', String(type));++ form.append('input', JSON.stringify(input));++ for (const f of list) {++ const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;++ const blob = isBlob ? f : toBlob(f && f.data, f && f.type);++ form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');++ }++ opts.form = form;++ } else {++ opts.json = { type: String(type), input };++ }++ const res = await call(opts);++ return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };++ }++++ /** The job, or null when it does not exist or is not yours. */++ async function get(id, { signal } = {}) {++ const res = await orNull(call({ path: `/api/v1/jobs/${enc(id)}`, signal }));++ return res ? res.data : null;++ }++++ /**++ * Cancel -> the job (state `cancelled`, or `cancel_requested: true` while it is running).++ * A finished job answers 409 tools.job.already_finished (thrown).++ */++ async function cancel(id, { signal } = {}) {++ return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;++ }++++ /**++ * events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })++ * -> async iterator of { id, event, job }++ *++ * Follows the job's SSE stream until the job finishes. When the connection drops it reconnects++ * with Last-Event-ID = the last id it yielded, so every event arrives once and in order; a 204++ * (finished, nothing newer) ends the iteration. Keep `id` somewhere durable and pass it back as++ * `lastEventId` to reattach after a restart. 4xx answers (401 after one token refresh, 403,++ * 404) are thrown; network errors and 5xx reconnect, up to maxReconnects in a row.++ */++ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {++ let last = Number(lastEventId) || 0;++ let retryMs = reconnectDelayMs;++ let failures = 0;++ while (!(signal && signal.aborted)) {++ let res;++ let delivered = false;++ try {++ res = await call({++ path: `/api/v1/jobs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,++ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(last) },++ });++ if (res.status === 204) return;++ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { retryMs = ms; } })) {++ let job = null;++ try { job = JSON.parse(msg.data); } catch { job = null; }++ const n = Number(msg.id);++ if (Number.isFinite(n) && msg.id !== undefined && msg.id !== '') {++ if (n <= last) continue; // already yielded before a reconnect++ last = n;++ }++ delivered = true;++ failures = 0;++ yield { id: Number.isFinite(n) ? n : null, event: msg.event, job };++ if (TERMINAL_EVENTS.has(msg.event) || (job && isTerminal(job) && msg.event !== 'job.progress')) return;++ }++ } catch (err) {++ if (signal && signal.aborted) return;++ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;++ if (!isOpenVibeError(err) && !(err && (err.name === 'TypeError' || err.name === 'AbortError'))) throw err;++ }++ if (!delivered) failures++;++ if (failures > maxReconnects) {++ throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `jobs: the event stream of ${id} failed ${failures} times in a row` });++ }++ await sleep(retryMs, signal);++ }++ }++++ /**++ * wait(id, { lastEventId, signal, onEvent }) -> the finished job (GET after the terminal event).++ * onEvent({ id, event, job }) sees every event, e.g. to save `id` and show progress.++ */++ async function wait(id, { onEvent, ...opts } = {}) {++ for await (const e of events(id, opts)) if (onEvent) await onEvent(e);++ return get(id, { signal: opts.signal });++ }++++ /**++ * A result file as the raw fetch Response (body unread): stream it, or++ * `Buffer.from(await res.arrayBuffer())`. 409 tools.job.not_ready until the job succeeded.++ */++ async function file(id, n = 0, { signal, inline = false } = {}) {++ return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;++ }++++ return { submit, get, cancel, events, wait, file };++}++++module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };++},++// ── 15: src/projects.js ─────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/projects: developer projects on OpenVibe.Network (/api/v1/projects, ADR-014), the++ * API the developer portal (OpenVibe.Codes) and your own tooling use. Browser-safe code.++ *++ * Auth: a Network USER access token (Authorization: Bearer). The API reads no cookies, and service++ * or app tokens get 401. Errors are problem+json with stable codes (project.not_found,++ * grant.beyond_allowance, …); a project you are not a member of is 404, never 403.++ *++ * const projects = createProjectsClient(createClient({ token: userAccessToken }));++ * const prj = await projects.create({ name: 'My app' });++ * const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: [] });++ * app.credential; // the secret, returned ONCE: store it now++ * await projects.grants.request(prj.id, app.id, 'media.object.upload');++ *++ * Secrets appear only in the responses of apps.create() (confidential apps) and++ * credentials.rotate(). Those two calls are never retried: a retry would mint a second secret.++ * Other mutations that Network does not dedupe are not retried either; reads, PUT and DELETE are.++ */++const { isOpenVibeError } = __load(3);++const { paginate } = __load(7);++++const enc = encodeURIComponent;++++function createProjectsClient(client, { baseUrl } = {}) {++ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });++ const once = (opts) => call({ idempotencyKey: false, ...opts }); // never repeated automatically++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const P = (project) => `/api/v1/projects/${enc(project)}`;++ const A = (project, app) => `${P(project)}/apps/${enc(app)}`;++++ const members = {++ async list(project) { return (await call({ path: `${P(project)}/members` })).members; },++ /** { username | subjectId, role: 'admin' | 'developer' | 'viewer' } */++ add: (project, { username, subjectId, role } = {}) => once({ method: 'POST', path: `${P(project)}/members`, json: { username, subject_id: subjectId, role } }),++ update: (project, subject, { role } = {}) => once({ method: 'PATCH', path: `${P(project)}/members/${enc(subject)}`, json: { role } }),++ /** Remove a member (or yourself: leave). The owner cannot leave. */++ remove: (project, subject) => call({ method: 'DELETE', path: `${P(project)}/members/${enc(subject)}` }),++ };++++ const apps = {++ async list(project) { return (await call({ path: `${P(project)}/apps` })).apps; },++ get: (project, app) => orNull(call({ path: A(project, app) })),++ /**++ * { name, environment: 'sandbox' | 'production', type: 'confidential' | 'public', redirectUris }++ * -> the app; a confidential app also carries `credential` with its secret, shown once.++ */++ create: (project, { name, environment = 'sandbox', type = 'confidential', redirectUris = [] } = {}) => once({++ method: 'POST', path: `${P(project)}/apps`, json: { name, environment, type, redirect_uris: redirectUris },++ }),++ update: (project, app, { name, redirectUris } = {}) => once({ method: 'PATCH', path: A(project, app), json: { name, redirect_uris: redirectUris } }),++ /** Revoke the app: its credentials and pending codes fail at once; issued tokens end within 5 minutes. */++ revoke: (project, app) => call({ method: 'DELETE', path: A(project, app) }),++ };++++ const credentials = {++ /** Metadata only (id, last four characters, state, dates): secrets cannot be read back. */++ async list(project, app) { return (await call({ path: `${A(project, app)}/credentials` })).credentials; },++ /** A new secret, returned once. Older secrets keep working for overlapSeconds (Network default 86400). */++ rotate: (project, app, { overlapSeconds } = {}) => once({ method: 'POST', path: `${A(project, app)}/credentials/rotate`, json: { overlap_seconds: overlapSeconds } }),++ revoke: (project, app, credential) => once({ method: 'POST', path: `${A(project, app)}/credentials/${enc(credential)}/revoke` }),++ };++++ const grants = {++ async list(project, app) { return (await call({ path: `${A(project, app)}/grants` })).grants; },++ /** Request a capability. Owners and admins get it approved at once when it is inside the allowance. */++ request: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants`, json: { capability } }),++ approve: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/approve` }),++ deny: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/deny` }),++ revoke: (project, app, capability) => call({ method: 'DELETE', path: `${A(project, app)}/grants/${enc(capability)}` }),++ };++++ const quotas = {++ async list(project) { return (await call({ path: `${P(project)}/quotas` })).quotas; },++ /** Staff only. { limit, window: minute|hour|day|month|total, unit } */++ set: (project, capability, { limit, window, unit } = {}) => call({ method: 'PUT', path: `${P(project)}/quotas/${enc(capability)}`, json: { limit, window, unit } }),++ delete: (project, capability) => call({ method: 'DELETE', path: `${P(project)}/quotas/${enc(capability)}` }),++ };++++ /** One page of the audit, newest first: { entries, next_before }. Admins, owners and staff. */++ const audit = (project, { before, limit } = {}) => call({ path: `${P(project)}/audit`, query: { before, limit } });++++ return {++ /** Capabilities an app could ever be granted (active + public). */++ async catalog() { return (await call({ path: '/api/v1/projects/catalog' })).capabilities; },++ /** Your projects; staff may pass { all: true }. */++ async list({ all } = {}) { return (await call({ path: '/api/v1/projects', query: { all } })).projects; },++ /** { name } -> the project; you are its owner. A new project is sandbox only. */++ create: ({ name } = {}) => once({ method: 'POST', path: '/api/v1/projects', json: { name } }),++ /** The project with your role, allowance, environments and counts, or null (404 for non-members). */++ get: (project) => orNull(call({ path: P(project) })),++ update: (project, { name } = {}) => once({ method: 'PATCH', path: P(project), json: { name } }),++ /** Irreversible: revokes every app. Owner or staff. */++ archive: (project) => once({ method: 'POST', path: `${P(project)}/archive` }),++ /** Staff only: the capabilities this project's apps may hold -> { allowance, trimmed }. */++ setAllowance: (project, capabilities) => call({ method: 'PUT', path: `${P(project)}/allowance`, json: { capabilities } }),++ /** Staff only: 'sandbox' | 'sandbox+production'. */++ setEnvironmentPolicy: (project, environmentPolicy) => call({ method: 'PUT', path: `${P(project)}/environment-policy`, json: { environment_policy: environmentPolicy } }),++ members,++ apps,++ credentials,++ grants,++ quotas,++ audit,++ /** Every audit entry, newest first, across pages. */++ iterateAudit: (project, { pageSize = 100 } = {}) => paginate(async (before) => {++ const page = await audit(project, { before, limit: pageSize });++ return { items: page.entries || [], next: page.next_before ?? null };++ }, { cursor: undefined }),++ };++}++++module.exports = { createProjectsClient };++},++];++const __cache = new Array(__defs.length);++function __load(i) {++ if (__cache[i]) return __cache[i].exports;++ const module = { exports: {} };++ __cache[i] = module;++ __defs[i].call(module.exports, module, module.exports);++ return module.exports;++}++const sdk = __load(0);++++export const {++ createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,++ startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,++ satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,++ registry, modules, realtime, media, community, jobs,++ projects,++} = sdk;++export default sdk;@@@ -3,6 +3,6 @@ import cjs from '../src/auth/browser.js';export const {createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,-- readCallback, base64url,++ readCallback, base64url, decodeUnverified, unverifiedClaims,} = cjs;export default cjs;@@@ -3,6 +3,7 @@ import cjs from '../src/auth/index.js';export const {createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,-- readCallback, base64url, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken,++ readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,++ verifyAppToken, exchangeCode, refreshUserToken,} = cjs;export default cjs;@@@ -6,6 +6,6 @@ export const {startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,registry, identity, modules, events, realtime, media,-- community,++ community, jobs, projects,} = cjs;export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/jobs.js. Do not edit.++import cjs from '../src/jobs.js';++++export const {++ createJobsClient, isTerminal, TERMINAL_STATES,++} = cjs;++export default cjs;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/projects.js. Do not edit.++import cjs from '../src/projects.js';++++export const {++ createProjectsClient,++} = cjs;++export default cjs;@@@ -2,6 +2,6 @@import cjs from '../src/realtime.js';export const {-- subscribe, createRealtimeClient, DEFAULT_ORIGIN,++ subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN,} = cjs;export default cjs;@@@ -2,6 +2,6 @@import cjs from '../src/testing/index.js';export const {-- createMockPlatform, DEFAULT_ORIGINS,++ createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG,} = cjs;export default cjs;@@@ -16,4 +16,6 @@ module.exports = {realtime: require('./src/realtime'),media: require('./src/media'),community: require('./src/community'),++ jobs: require('./src/jobs'),++ projects: require('./src/projects'),};@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.2.2",++ "version": "0.3.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.2.2",++ "version": "0.3.0","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0"@@@ -1,7 +1,7 @@{"name": "openvibe-sdk",-- "version": "0.2.2",-- "description": "Supported browser and server clients for the OpenVibe platform: auth, registry discovery, identity, user modules, events, realtime, media and community, with deadlines, safe retries, problem errors and trace propagation.",++ "version": "0.3.0",++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts","browser": "browser.js",@@@ -65,12 +65,26 @@"import": "./esm/community.mjs","default": "./src/community.js"},++ "./jobs": {++ "types": "./types/jobs.d.ts",++ "import": "./esm/jobs.mjs",++ "default": "./src/jobs.js"++ },++ "./projects": {++ "types": "./types/projects.d.ts",++ "import": "./esm/projects.mjs",++ "default": "./src/projects.js"++ },"./testing": {"types": "./types/testing.d.ts","browser": null,"import": "./esm/testing.mjs","default": "./src/testing/index.js"},++ "./browser/openvibe-sdk.mjs": {++ "types": "./types/bundle.d.ts",++ "default": "./browser/openvibe-sdk.mjs"++ },"./package.json": "./package.json"},"files": [@@@ -78,6 +92,7 @@"browser.js","src/","esm/",++ "browser/","types/","README.md","CHANGELOG.md",@@@ -85,7 +100,8 @@],"scripts": {"test": "node test/run.js",-- "esm": "node scripts/esm.js"++ "esm": "node scripts/esm.js",++ "browser-bundle": "node scripts/browser-bundle.js"},"engines": {"node": ">=20"@@@ -98,7 +114,9 @@"pkce","events","sse",-- "media"++ "media",++ "jobs",++ "developer-apps"],"repository": "github:OpenVibers/OpenVibe.SDK","license": "MIT",nnew file mode 100644@@@ -0,0 +1,116 @@++#!/usr/bin/env node++'use strict';++/**++ * browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK++ * (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,++ * community, jobs, projects), for pages with no build step:++ *++ * <script type="module">++ * import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';++ * </script>++ *++ * Generated from the CommonJS sources with no dependencies: each file reachable from browser.js++ * becomes a function in a small module table, and its relative require() calls become table++ * lookups. It refuses anything that is not a relative require, a node: import, process.env or a++ * client secret, so server-only code cannot slip in. Checked in; test/bundle.test.js fails when it++ * is stale, and test/browser-entries.test.js scans it like the other browser files.++ *++ * node scripts/browser-bundle.js write browser/openvibe-sdk.mjs++ * node scripts/browser-bundle.js --check exit 1 if it differs from the sources++ */++const fs = require('fs');++const path = require('path');++++const ROOT = path.join(__dirname, '..');++const ENTRY = 'browser.js';++const OUT = path.join(ROOT, 'browser', 'openvibe-sdk.mjs');++const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;++const REFUSE = [++ [/client_secret/, 'mentions client_secret'],++ [/process\.env/, 'reads process.env'],++ [/(?:require\s*\(|from\s+|import\s*\()\s*['"]node:/, 'imports a node: module'],++];++++function resolve(fromId, spec) {++ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromId), spec));++ for (const cand of [base, `${base}.js`, `${base}/index.js`]) {++ const abs = path.join(ROOT, cand);++ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return cand;++ }++ throw new Error(`${fromId}: cannot resolve ${spec}`);++}++++/** Every module reachable from the entry, in a stable depth-first order: [{ id, source, deps }]. */++function collect() {++ const order = [];++ const seen = new Map();++ const visit = (id) => {++ if (seen.has(id)) return;++ const source = fs.readFileSync(path.join(ROOT, id), 'utf8');++ for (const [re, why] of REFUSE) if (re.test(source)) throw new Error(`${id} ${why}: not browser-safe, refusing to bundle`);++ const mod = { id, source, deps: {} };++ seen.set(id, mod);++ order.push(mod);++ for (const [, spec] of source.matchAll(REQUIRE_RE)) {++ if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": only relative SDK files may be bundled`);++ mod.deps[spec] = resolve(id, spec);++ visit(mod.deps[spec]);++ }++ };++ visit(ENTRY);++ return order;++}++++function render() {++ const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;++ const mods = collect();++ const index = new Map(mods.map((m, i) => [m.id, i]));++ const defs = mods.map((m, i) => {++ const body = m.source.replace(REQUIRE_RE, (_all, spec) => `__load(${index.get(m.deps[spec])})`).replace(/\s+$/, '');++ return `// ── ${i}: ${m.id} ${'─'.repeat(Math.max(3, 60 - m.id.length))}\nfunction (module, exports) {\n${body}\n}`;++ });++ // Load the entry now, the same way Node would, to learn its export names.++ const entry = require(path.join(ROOT, ENTRY));++ const names = Object.keys(entry);++ const namespaces = names.filter((n) => entry[n] && typeof entry[n] === 'object');++ const lines = [];++ for (let i = 0; i < names.length; i += 6) lines.push(` ${names.slice(i, i + 6).join(', ')},`);++ return `// Generated by scripts/browser-bundle.js from ${ENTRY} (openvibe-sdk ${version}, MIT). Do not edit.++// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces++// (${namespaces.join(', ')}). No server-only code: no service tokens, JWT verification, events, identity.++/* eslint-disable */++const __defs = [++${defs.join(',\n')},++];++const __cache = new Array(__defs.length);++function __load(i) {++ if (__cache[i]) return __cache[i].exports;++ const module = { exports: {} };++ __cache[i] = module;++ __defs[i].call(module.exports, module, module.exports);++ return module.exports;++}++const sdk = __load(0);++++export const {++${lines.join('\n')}++} = sdk;++export default sdk;++`;++}++++function main() {++ const check = process.argv.includes('--check');++ const out = render();++ const have = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : null;++ if (have === out) return;++ if (check) {++ console.error('stale: browser/openvibe-sdk.mjs (run node scripts/browser-bundle.js)');++ process.exit(1);++ }++ fs.mkdirSync(path.dirname(OUT), { recursive: true });++ fs.writeFileSync(OUT, out);++}++++if (require.main === module) main();++module.exports = { render, collect, OUT, REFUSE };@@@ -23,6 +23,8 @@ const ENTRIES = {'realtime.mjs': '../src/realtime.js','media.mjs': '../src/media.js','community.mjs': '../src/community.js',++ 'jobs.mjs': '../src/jobs.js',++ 'projects.mjs': '../src/projects.js','testing.mjs': '../src/testing/index.js',};@@@ -3,9 +3,15 @@* openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).** The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's-- * session). The code is then exchanged ON YOUR SERVER (openvibe-sdk/auth server entry,-- * exchangeCode()), because every OpenVibe OAuth client is a confidential client: its credentials-- * never reach a browser. This file holds no credentials and uses Web Crypto only.++ * session). The code is then exchanged on your server (openvibe-sdk/auth server entry,++ * exchangeCode()): a confidential client's credentials never reach a browser, and even a public++ * developer app (no secret) is best served by keeping the token in an HttpOnly server session.++ * This file holds no credentials and uses Web Crypto only.++ *++ * Two kinds of client use this flow:++ * first-party sign-in scope 'profile theme' (the default); the exchange yields a user token++ * developer app `audience` (openvibe.<service>) and `scope` = capability ids; the exchange++ * yields a 5-minute app token acting for the person (no refresh token)*/const { OpenVibeError } = require('../core/errors');const { randomBytes } = require('../core/ids');@@@ -44,15 +50,23 @@ function createState() {/*** GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state-- * &code_challenge&code_challenge_method=S256[&prompt=none]++ * &code_challenge&code_challenge_method=S256[&audience][&prompt=none]++ *++ * `scope` defaults to 'profile theme' (first-party sign-in) only when no `audience` is given. A++ * developer app passes `audience` and its capability ids as `scope`; without a scope the code may++ * yield every capability the app holds for that audience. Network refuses prompt=none for apps++ * (error=interaction_required): a person always chooses to continue.*/--function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope = 'profile theme', state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {++function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope, audience, state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);u.searchParams.set('response_type', 'code');u.searchParams.set('client_id', clientId);u.searchParams.set('redirect_uri', redirectUri);-- if (scope) u.searchParams.set('scope', Array.isArray(scope) ? scope.join(' ') : scope);++ const s = scope === undefined && !audience ? 'profile theme' : scope;++ const scopeText = Array.isArray(s) ? s.filter(Boolean).join(' ') : s;++ if (scopeText) u.searchParams.set('scope', scopeText);++ if (audience) u.searchParams.set('audience', audience);if (state) u.searchParams.set('state', state);if (codeChallenge) {u.searchParams.set('code_challenge', codeChallenge);@@@ -65,6 +79,7 @@ function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId,/*** Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.* Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.++ * A developer app passes { audience, scope: [capability ids] } and exchanges with the same audience.*/async function startAuthorization(opts = {}) {const pair = await createPkcePair(opts.verifierLength);@@@ -92,4 +107,34 @@ function readCallback(location, { expectedState } = {}) {return { code, state };}--module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url };++/**++ * The header and claims of a JWT, decoded WITHOUT checking its signature, issuer, audience or++ * expiry: { header, claims } or null when it is not a JWT. For display and logging only (who a++ * token names, what it may do, when it expires). Never authorize anything with it: a receiver++ * verifies tokens with verifyUserToken() / verifyAppToken() (server).++ */++function decodeUnverified(token) {++ const parts = typeof token === 'string' ? token.split('.') : [];++ if (parts.length !== 3) return null;++ try {++ const part = (p) => {++ const b64 = p.replace(/-/g, '+').replace(/_/g, '/');++ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));++ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));++ };++ const header = part(parts[0]);++ const claims = part(parts[1]);++ if (!header || typeof header !== 'object' || !claims || typeof claims !== 'object' || Array.isArray(claims)) return null;++ return { header, claims };++ } catch {++ return null;++ }++}++++/** decodeUnverified(token).claims, or null. UNVERIFIED: see decodeUnverified(). */++function unverifiedClaims(token) {++ const d = decodeUnverified(token);++ return d ? d.claims : null;++}++++module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url, decodeUnverified, unverifiedClaims };@@@ -5,7 +5,7 @@*/const browser = require('./browser');const { createServiceTokenClient } = require('./tokens');--const { verifyUserToken } = require('./jwt');++const { verifyUserToken, verifyAppToken } = require('./jwt');const { exchangeCode, refreshUserToken } = require('./oauth');--module.exports = { ...browser, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken };++module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken };@@@ -1,8 +1,12 @@'use strict';/**-- * Server only. Offline RS256 verification of an OpenVibe.Network user token (the SSO access token:-- * sub, id, subject_id, username, role, …) against the Network JWKS. No JWT library: the rules-- * are the ones written here.++ * Server only. Offline RS256 verification of OpenVibe.Network tokens against the Network JWKS. No++ * JWT library: the rules are the ones written here.++ *++ * verifyUserToken(token, { jwks }) a person's SSO access token (sub, id, subject_id, username, role, …)++ * verifyAppToken(token, { jwks, audience, acceptSandbox })++ * a developer app's token (identity.service-token-claims@1:++ * sub app:app_…, actor_type app, cap, project_id, env, on_behalf_of?)** const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });* claims.subject_id // usr_… (canonical subject; absent on very old tokens)@@@ -10,7 +14,8 @@* `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL* to fetch it from (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |-- * token.wrong_issuer | token.wrong_audience | token.not_user | token.no_key.++ * token.wrong_issuer | token.wrong_audience | token.not_user | token.not_app | token.invalid_claims |++ * token.sandbox_refused | token.no_key.*/const crypto = require('node:crypto');const { OpenVibeError } = require('../core/errors');@@@ -19,8 +24,8 @@ const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g,const JWKS_TTL_MS = 6 * 60 * 60 * 1000;const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }--function fail(code, detail) {-- return new OpenVibeError({ code, status: 401, detail, message: `user token rejected: ${detail}` });++function fail(code, detail, kind = 'user') {++ return new OpenVibeError({ code, status: 401, detail, message: `${kind} token rejected: ${detail}` });}function keysFromJwks(doc) {@@@ -60,19 +65,20 @@ function verifyWith(keys, header, input, sig) {});}--async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch, allowServiceTokens = false } = {}) {++/** Signature, time and issuer/audience checks shared by both verifiers; returns the claims. */++async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch } = {}, kind) {const parts = typeof token === 'string' ? token.split('.') : [];-- if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT');++ if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT', kind);let header, claims;-- try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { throw fail('token.malformed', 'undecodable'); }-- if (!header || header.alg !== 'RS256') throw fail('token.malformed', `alg ${header && header.alg} not accepted`);-- if (!claims || typeof claims !== 'object') throw fail('token.malformed', 'claims are not an object');++ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { throw fail('token.malformed', 'undecodable', kind); }++ if (!header || header.alg !== 'RS256') throw fail('token.malformed', `alg ${header && header.alg} not accepted`, kind);++ if (!claims || typeof claims !== 'object' || Array.isArray(claims)) throw fail('token.malformed', 'claims are not an object', kind);let keys;if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);else keys = keysFromJwks(jwks);-- if (!keys.length) throw fail('token.no_key', 'no RS256 verification key');++ if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);const input = `${parts[0]}.${parts[1]}`;const sig = fromB64url(parts[2]);@@@ -80,21 +86,60 @@ async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clockif (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation}-- if (!good) throw fail('token.bad_signature', 'signature does not verify');++ if (!good) throw fail('token.bad_signature', 'signature does not verify', kind);const t = Math.floor(now / 1000);-- if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) throw fail('token.expired', 'expired');-- if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) throw fail('token.not_yet_valid', 'not valid yet');-- if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) throw fail('token.not_yet_valid', 'issued in the future');-- if (issuer && claims.iss !== issuer) throw fail('token.wrong_issuer', `issuer ${claims.iss}`);++ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) throw fail('token.expired', 'expired', kind);++ if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) throw fail('token.not_yet_valid', 'not valid yet', kind);++ if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) throw fail('token.not_yet_valid', 'issued in the future', kind);++ if (issuer && claims.iss !== issuer) throw fail('token.wrong_issuer', `issuer ${claims.iss}`, kind);if (audience) {const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud != null ? [claims.aud] : [];const want = Array.isArray(audience) ? audience : [audience];-- if (!want.some((a) => aud.includes(a))) throw fail('token.wrong_audience', `not for ${want.join(', ')}`);++ if (!want.some((a) => aud.includes(a))) throw fail('token.wrong_audience', `not for ${want.join(', ')}`, kind);}++ return claims;++}++++async function verifyUserToken(token, opts = {}) {++ const claims = await verifyJwt(token, opts, 'user');const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));-- if (principal && !allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');++ if (principal && !opts.allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');++ return claims;++}++++const APP_SUB_RE = /^app:app_[0-9A-HJKMNP-TV-Z]{26}$/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++++/**++ * verifyAppToken(token, { jwks | publicKey, issuer, audience, acceptSandbox = false })++ * -> claims { sub: 'app:app_…', actor_type: 'app', aud, cap, ns, project_id, env, on_behalf_of?, iat, exp, jti }++ *++ * A developer app's token (Network developer projects, ADR-014), checked the way openvibe-contracts++ * v0.26 verifyServiceToken() checks it: RS256 signature, expiry, issuer, the ONE audience you++ * serve (required), the claim shape, and env. `env: sandbox` is refused with++ * token.sandbox_refused unless you pass acceptSandbox: true, which you should only do when your++ * service keeps sandbox traffic apart from real data. `on_behalf_of` (usr_…) is present only on++ * tokens from the authorization-code flow: it names the person who authorized the app.++ * Capabilities are not checked here: test `claims.cap` for the one your route performs.++ */++async function verifyAppToken(token, opts = {}) {++ if (!opts.audience) throw new TypeError('verifyAppToken: pass the audience your service answers for (openvibe.<service>)');++ const claims = await verifyJwt(token, opts, 'app');++ if (claims.actor_type !== 'app' || !/^app:/.test(String(claims.sub || ''))) throw fail('token.not_app', 'not a developer app token', 'app');++ const bad = [];++ if (!APP_SUB_RE.test(String(claims.sub))) bad.push('sub');++ if (!Array.isArray(claims.aud) || !claims.aud.length) bad.push('aud');++ if (!Array.isArray(claims.cap) || !claims.cap.every((c) => typeof c === 'string')) bad.push('cap');++ if (claims.ns !== undefined && (!Array.isArray(claims.ns) || !claims.ns.every((n) => typeof n === 'string'))) bad.push('ns');++ if (typeof claims.jti !== 'string' || !claims.jti) bad.push('jti');++ if (!PROJECT_RE.test(String(claims.project_id || ''))) bad.push('project_id');++ if (claims.env !== 'sandbox' && claims.env !== 'production') bad.push('env');++ if (claims.on_behalf_of !== undefined && !USER_RE.test(String(claims.on_behalf_of))) bad.push('on_behalf_of');++ if (bad.length) throw fail('token.invalid_claims', `bad or missing claims: ${bad.join(', ')}`, 'app');++ if (claims.env === 'sandbox' && !opts.acceptSandbox) throw fail('token.sandbox_refused', 'sandbox tokens are not accepted here', 'app');return claims;}--module.exports = { verifyUserToken, keysFromJwks, _jwksCache: jwksCache };++module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksCache: jwksCache };@@@ -1,11 +1,17 @@'use strict';/**-- * Server only. The half of the authorization-code flow that needs the client's credentials:-- * exchanging the code the browser brought back (with its PKCE verifier) and refreshing.++ * Server only. The half of the authorization-code flow that runs on your server: exchanging the++ * code the browser brought back (with its PKCE verifier), and refreshing first-party user tokens.** POST <network>/oauth/token grant_type=authorization_code, code, redirect_uri, client_id,-- * client_secret, code_verifier++ * [client_secret], code_verifier, [audience], [scope]++ *++ * First-party client (confidential, no audience)* -> { access_token, refresh_token, token_type, expires_in, scope, user, preferences }++ * Developer app (`app_<ULID>`; audience required; PKCE required; confidential apps also send their++ * secret, public apps send none)++ * -> { access_token, token_type, expires_in: 300, scope } (no refresh token: sign in again)++ * The app token names the person in `on_behalf_of`; verify it with verifyAppToken().*/const { OpenVibeError } = require('../core/errors');const { DEFAULT_NETWORK } = require('../core/client');@@@ -31,10 +37,20 @@ function tokenUrlOf({ tokenUrl, network = DEFAULT_NETWORK }) {return tokenUrl || `${String(network).replace(/\/+$/, '')}/oauth/token`;}--async function exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {-- if (!code || !redirectUri || !clientId || !clientSecret) throw new TypeError('exchangeCode: code, redirectUri, clientId and clientSecret are required');++/**++ * exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret?, audience?, scope? })++ * A public client (no clientSecret) must send the PKCE codeVerifier; so must every developer app.++ * `scope` (capability ids) may narrow what the person authorized, never widen it. The response is++ * returned as the token endpoint sent it: `refresh_token` is absent for app tokens.++ */++async function exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret, audience, scope, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {++ if (!code || !redirectUri || !clientId) throw new TypeError('exchangeCode: code, redirectUri and clientId are required');++ if (!clientSecret && !codeVerifier) throw new TypeError('exchangeCode: a public client (no clientSecret) must send its PKCE codeVerifier');++ if (/^app_/.test(String(clientId)) && !codeVerifier) throw new TypeError('exchangeCode: developer apps always use PKCE; pass codeVerifier');++ if (/^app_/.test(String(clientId)) && !audience) throw new TypeError('exchangeCode: developer apps must name the audience (openvibe.<service>) the token is for');return postToken(tokenUrlOf(rest), {-- grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier,++ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret || undefined,++ code_verifier: codeVerifier, audience: audience || undefined, scope: Array.isArray(scope) ? scope.join(' ') || undefined : scope || undefined,}, fetchImpl, timeoutMs);}@@@ -10,9 +10,15 @@* per audience (and scope), refreshed 60 s before expiry; concurrent callers share one request.* Plug it into createClient({ tokenProvider }) and every call gets a token for the audience of the* service it calls (openvibe.media, openvibe.events, …).++ *++ * Developer apps (client id app_<ULID>, confidential) use the same client: their tokens carry++ * project_id, env and the approved capabilities for that audience. getTokenInfo() shows the++ * granted `scope` and the token's claims, decoded but NOT verified (for display and diagnostics;++ * the receiver is the one that verifies).*/const { OpenVibeError } = require('../core/errors');const { DEFAULT_NETWORK } = require('../core/client');++const { unverifiedClaims } = require('./browser');function createServiceTokenClient({network = DEFAULT_NETWORK, tokenUrl, clientId, clientSecret, audience, scope,@@@ -47,7 +53,8 @@ function createServiceTokenClient({if (!res.ok || !json || !json.access_token) {throw OpenVibeError.fromResponse({ status: res.status, body: json || { error: 'no_token', error_description: 'no access_token in the response' }, method: 'POST', url });}-- cache.set(key, { token: json.access_token, exp: now() + (Number(json.expires_in) || 300) * 1000, scope: json.scope });++ const scopeList = typeof json.scope === 'string' ? json.scope.split(/\s+/).filter(Boolean) : [];++ cache.set(key, { token: json.access_token, exp: now() + (Number(json.expires_in) || 300) * 1000, scope: scopeList, audience: aud });return json.access_token;}@@@ -63,8 +70,29 @@ function createServiceTokenClient({return inflight.get(key);}++ /**++ * getTokenInfo({ audience?, scope? }) -> { accessToken, tokenType, audience, scope, expiresAt,++ * unverifiedClaims }. `scope` is the capability list the token endpoint granted (it may be++ * narrower than you asked for when you asked for none). `unverifiedClaims` is the JWT payload++ * decoded WITHOUT verification: fine for showing project_id, env or cap, never for trusting them.++ */++ async function getTokenInfo(ctx = {}) {++ const aud = ctx.audience || audience;++ const accessToken = await getToken(ctx);++ const hit = cache.get(keyOf(aud, scopeFor(aud, ctx.scope))) || {};++ return {++ accessToken,++ tokenType: 'Bearer',++ audience: aud,++ scope: hit.scope ? [...hit.scope] : [],++ expiresAt: hit.exp ? new Date(hit.exp).toISOString() : null,++ unverifiedClaims: unverifiedClaims(accessToken),++ };++ }++return {getToken,++ getTokenInfo,async authHeaders(ctx) { return { Authorization: `Bearer ${await getToken(ctx)}` }; },/** Drop the cached token (one audience, or all): the next call fetches a new one. */invalidate(ctx = {}) {@@@ -184,6 +184,11 @@ function build(cfg, shared, ctx) {* headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,* deadlineMs, signal, traceparent, requestId, responseType })* -> { status, headers, data, requestId, traceId, traceparent, attempts }++ *++ * responseType 'response' (streams, downloads): a 2xx resolves with `data` and `response` set++ * to the raw fetch Response, body unread. The timeout then covers the response headers only,++ * and `signal` still cancels the body afterwards. An error status is read and thrown as++ * usual, so the body you get is always a success.*/async function request(opts = {}) {const method = String(opts.method || 'GET').toUpperCase();@@@ -247,7 +252,9 @@ function build(cfg, shared, ctx) {}const rid = res.headers.get('x-openvibe-request-id') || requestId;if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });-- return { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };++ const result = { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };++ if (opts.responseType === 'response') result.response = res;++ return result;}}@@@ -299,6 +306,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,let timedOut = false;const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);const onAbort = () => ctrl.abort();++ let keepSignal = false;if (signal) {if (signal.aborted) ctrl.abort();else signal.addEventListener('abort', onAbort, { once: true });@@@ -307,8 +315,11 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,const init = { method, headers, body, signal: ctrl.signal };if (credentials) init.credentials = credentials;const res = await fetchImpl(url, init);-- if (responseType === 'response') return { res, data: null };-- return { res, data: await parseBody(res, responseType) };++ if (responseType === 'response' && res.ok) {++ keepSignal = true; // the caller reads the body later: its signal must still cancel it++ return { res, data: res };++ }++ return { res, data: await parseBody(res, responseType === 'response' ? undefined : responseType) };} catch (err) {if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });@@@ -316,7 +327,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });} finally {clearTimeout(timer);-- if (signal) signal.removeEventListener('abort', onAbort);++ if (signal && !keepSignal) signal.removeEventListener('abort', onAbort);}}@@@ -7,7 +7,8 @@ const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = require('./idconst { paginate, offsetPager } = require('./paginate');const semver = require('./semver');--const SDK_VERSION = '0.1.0';++/** The package version (test/version.test.js keeps it equal to package.json). */++const SDK_VERSION = '0.3.0';module.exports = {createClient,@@@ -13,7 +13,6 @@ const crypto = require('node:crypto');const { isOpenVibeError } = require('./core/errors');const { newEventId } = require('./core/ids');const { startSpan } = require('./core/trace');--const { paginate } = require('./core/paginate');// ── Webhook signatures ───────────────────────────────────────@@@ -97,18 +96,28 @@ function createEventsClient(client, { source, baseUrl } = {}) {}/**-- * Async iterator over { seq, event } from afterSeq up to the current head. onGap({ from_seq,-- * to_seq }) is called when retention already pruned part of the range. Keep the last page's-- * next_after_seq (onPage) as your durable cursor: it moves past events that did not match.++ * Async iterator over { seq, event } from afterSeq up to the current head.++ *++ * onGap(gap) called before a page's items when retention already pruned part of the range++ * ({ from_seq, to_seq }): nothing can replay it, so resync derived state.++ * onPage(page) called AFTER every item of that page was yielded and handled (your loop body++ * ran for the page's last item and asked for the next one). Save++ * page.next_after_seq there as your durable cursor: it also moves past events++ * that did not match your topics, and a crash (or a break/throw in your loop)++ * before onPage leaves the cursor on the last page you finished, so nothing is++ * skipped. Pages with no matching events still call onPage.*/-- function iterate({ topic, afterSeq = 0, limit, onGap, onPage } = {}) {-- return paginate(async (cursor) => {++ async function* iterate({ topic, afterSeq = 0, limit, onGap, onPage, maxPages = Infinity } = {}) {++ let cursor = afterSeq;++ for (let pages = 0; pages < maxPages; pages++) {const page = await pull({ topic, afterSeq: cursor, limit });if (page.gap && onGap) await onGap(page.gap);++ for (const item of page.events || []) yield item;if (onPage) await onPage(page);-- const next = page.next_after_seq < page.latest_seq ? page.next_after_seq : null;-- return { items: page.events, next };-- }, { cursor: afterSeq });++ const next = page.next_after_seq;++ if (next == null || next === cursor || !(next < page.latest_seq)) return;++ cursor = next;++ }}const subscriptions = {nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs++ * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.++ *++ * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });++ * const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },++ * files: [{ name: 'a.png', data: bytes }], idempotencyKey });++ * for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops++ * const done = await jobs.get(job.id);++ * const res = await jobs.file(job.id, 0); // raw Response: stream or save it++ *++ * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,++ * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same++ * answer as for a job that does not exist.++ *++ * POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);++ * Idempotency-Key -> 202 new | 200 + Idempotent-Replayed++ * GET /api/v1/jobs/:id++ * DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished++ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;++ * 204 when the job finished and nothing is newer++ * GET /api/v1/jobs/:id/files/:n a result file++ */++const { OpenVibeError, isOpenVibeError } = require('./core/errors');++const { newIdempotencyKey } = require('./core/ids');++const { parseSSE } = require('./realtime');++++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const TERMINAL_EVENTS = new Set(['job.succeeded', 'job.failed', 'job.cancelled']);++const enc = encodeURIComponent;++const sleep = (ms, signal) => new Promise((resolve) => {++ const t = setTimeout(resolve, ms);++ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });++});++++function toBlob(data, type) {++ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;++ if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);++ throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');++}++++/** Is this job finished (succeeded, failed or cancelled)? */++function isTerminal(job) {++ return Boolean(job && TERMINAL.has(job.state));++}++++function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {++ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++++ /**++ * submit({ type, input = {}, files, idempotencyKey, signal }) -> { job, replayed, idempotencyKey }++ *++ * Always carries an Idempotency-Key (one is generated when you pass none), so retries after a++ * timeout or a 503 are safe. To survive a crash between submitting and saving the job id, derive++ * the key from the request (or store it first) and submit again with the same key: Tools answers++ * with the SAME job (`replayed: true`). A different request under a used key is 409++ * tools.job.idempotency_conflict.++ */++ async function submit({ type, input = {}, files, idempotencyKey, signal } = {}) {++ if (!type) throw new TypeError('jobs.submit: type is required (e.g. img.process)');++ if (input === null || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('jobs.submit: input must be an object');++ const key = idempotencyKey || newIdempotencyKey();++ const list = files == null ? [] : Array.isArray(files) ? files : [files];++ const opts = { method: 'POST', path: '/api/v1/jobs', idempotencyKey: key, signal };++ if (list.length) {++ const form = new FormData();++ form.append('type', String(type));++ form.append('input', JSON.stringify(input));++ for (const f of list) {++ const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;++ const blob = isBlob ? f : toBlob(f && f.data, f && f.type);++ form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');++ }++ opts.form = form;++ } else {++ opts.json = { type: String(type), input };++ }++ const res = await call(opts);++ return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };++ }++++ /** The job, or null when it does not exist or is not yours. */++ async function get(id, { signal } = {}) {++ const res = await orNull(call({ path: `/api/v1/jobs/${enc(id)}`, signal }));++ return res ? res.data : null;++ }++++ /**++ * Cancel -> the job (state `cancelled`, or `cancel_requested: true` while it is running).++ * A finished job answers 409 tools.job.already_finished (thrown).++ */++ async function cancel(id, { signal } = {}) {++ return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;++ }++++ /**++ * events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })++ * -> async iterator of { id, event, job }++ *++ * Follows the job's SSE stream until the job finishes. When the connection drops it reconnects++ * with Last-Event-ID = the last id it yielded, so every event arrives once and in order; a 204++ * (finished, nothing newer) ends the iteration. Keep `id` somewhere durable and pass it back as++ * `lastEventId` to reattach after a restart. 4xx answers (401 after one token refresh, 403,++ * 404) are thrown; network errors and 5xx reconnect, up to maxReconnects in a row.++ */++ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {++ let last = Number(lastEventId) || 0;++ let retryMs = reconnectDelayMs;++ let failures = 0;++ while (!(signal && signal.aborted)) {++ let res;++ let delivered = false;++ try {++ res = await call({++ path: `/api/v1/jobs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,++ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(last) },++ });++ if (res.status === 204) return;++ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { retryMs = ms; } })) {++ let job = null;++ try { job = JSON.parse(msg.data); } catch { job = null; }++ const n = Number(msg.id);++ if (Number.isFinite(n) && msg.id !== undefined && msg.id !== '') {++ if (n <= last) continue; // already yielded before a reconnect++ last = n;++ }++ delivered = true;++ failures = 0;++ yield { id: Number.isFinite(n) ? n : null, event: msg.event, job };++ if (TERMINAL_EVENTS.has(msg.event) || (job && isTerminal(job) && msg.event !== 'job.progress')) return;++ }++ } catch (err) {++ if (signal && signal.aborted) return;++ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;++ if (!isOpenVibeError(err) && !(err && (err.name === 'TypeError' || err.name === 'AbortError'))) throw err;++ }++ if (!delivered) failures++;++ if (failures > maxReconnects) {++ throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `jobs: the event stream of ${id} failed ${failures} times in a row` });++ }++ await sleep(retryMs, signal);++ }++ }++++ /**++ * wait(id, { lastEventId, signal, onEvent }) -> the finished job (GET after the terminal event).++ * onEvent({ id, event, job }) sees every event, e.g. to save `id` and show progress.++ */++ async function wait(id, { onEvent, ...opts } = {}) {++ for await (const e of events(id, opts)) if (onEvent) await onEvent(e);++ return get(id, { signal: opts.signal });++ }++++ /**++ * A result file as the raw fetch Response (body unread): stream it, or++ * `Buffer.from(await res.arrayBuffer())`. 409 tools.job.not_ready until the job succeeded.++ */++ async function file(id, n = 0, { signal, inline = false } = {}) {++ return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;++ }++++ return { submit, get, cancel, events, wait, file };++}++++module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };nnew file mode 100644@@@ -0,0 +1,113 @@++'use strict';++/**++ * openvibe-sdk/projects: developer projects on OpenVibe.Network (/api/v1/projects, ADR-014), the++ * API the developer portal (OpenVibe.Codes) and your own tooling use. Browser-safe code.++ *++ * Auth: a Network USER access token (Authorization: Bearer). The API reads no cookies, and service++ * or app tokens get 401. Errors are problem+json with stable codes (project.not_found,++ * grant.beyond_allowance, …); a project you are not a member of is 404, never 403.++ *++ * const projects = createProjectsClient(createClient({ token: userAccessToken }));++ * const prj = await projects.create({ name: 'My app' });++ * const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: [] });++ * app.credential; // the secret, returned ONCE: store it now++ * await projects.grants.request(prj.id, app.id, 'media.object.upload');++ *++ * Secrets appear only in the responses of apps.create() (confidential apps) and++ * credentials.rotate(). Those two calls are never retried: a retry would mint a second secret.++ * Other mutations that Network does not dedupe are not retried either; reads, PUT and DELETE are.++ */++const { isOpenVibeError } = require('./core/errors');++const { paginate } = require('./core/paginate');++++const enc = encodeURIComponent;++++function createProjectsClient(client, { baseUrl } = {}) {++ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });++ const once = (opts) => call({ idempotencyKey: false, ...opts }); // never repeated automatically++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const P = (project) => `/api/v1/projects/${enc(project)}`;++ const A = (project, app) => `${P(project)}/apps/${enc(app)}`;++++ const members = {++ async list(project) { return (await call({ path: `${P(project)}/members` })).members; },++ /** { username | subjectId, role: 'admin' | 'developer' | 'viewer' } */++ add: (project, { username, subjectId, role } = {}) => once({ method: 'POST', path: `${P(project)}/members`, json: { username, subject_id: subjectId, role } }),++ update: (project, subject, { role } = {}) => once({ method: 'PATCH', path: `${P(project)}/members/${enc(subject)}`, json: { role } }),++ /** Remove a member (or yourself: leave). The owner cannot leave. */++ remove: (project, subject) => call({ method: 'DELETE', path: `${P(project)}/members/${enc(subject)}` }),++ };++++ const apps = {++ async list(project) { return (await call({ path: `${P(project)}/apps` })).apps; },++ get: (project, app) => orNull(call({ path: A(project, app) })),++ /**++ * { name, environment: 'sandbox' | 'production', type: 'confidential' | 'public', redirectUris }++ * -> the app; a confidential app also carries `credential` with its secret, shown once.++ */++ create: (project, { name, environment = 'sandbox', type = 'confidential', redirectUris = [] } = {}) => once({++ method: 'POST', path: `${P(project)}/apps`, json: { name, environment, type, redirect_uris: redirectUris },++ }),++ update: (project, app, { name, redirectUris } = {}) => once({ method: 'PATCH', path: A(project, app), json: { name, redirect_uris: redirectUris } }),++ /** Revoke the app: its credentials and pending codes fail at once; issued tokens end within 5 minutes. */++ revoke: (project, app) => call({ method: 'DELETE', path: A(project, app) }),++ };++++ const credentials = {++ /** Metadata only (id, last four characters, state, dates): secrets cannot be read back. */++ async list(project, app) { return (await call({ path: `${A(project, app)}/credentials` })).credentials; },++ /** A new secret, returned once. Older secrets keep working for overlapSeconds (Network default 86400). */++ rotate: (project, app, { overlapSeconds } = {}) => once({ method: 'POST', path: `${A(project, app)}/credentials/rotate`, json: { overlap_seconds: overlapSeconds } }),++ revoke: (project, app, credential) => once({ method: 'POST', path: `${A(project, app)}/credentials/${enc(credential)}/revoke` }),++ };++++ const grants = {++ async list(project, app) { return (await call({ path: `${A(project, app)}/grants` })).grants; },++ /** Request a capability. Owners and admins get it approved at once when it is inside the allowance. */++ request: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants`, json: { capability } }),++ approve: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/approve` }),++ deny: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/deny` }),++ revoke: (project, app, capability) => call({ method: 'DELETE', path: `${A(project, app)}/grants/${enc(capability)}` }),++ };++++ const quotas = {++ async list(project) { return (await call({ path: `${P(project)}/quotas` })).quotas; },++ /** Staff only. { limit, window: minute|hour|day|month|total, unit } */++ set: (project, capability, { limit, window, unit } = {}) => call({ method: 'PUT', path: `${P(project)}/quotas/${enc(capability)}`, json: { limit, window, unit } }),++ delete: (project, capability) => call({ method: 'DELETE', path: `${P(project)}/quotas/${enc(capability)}` }),++ };++++ /** One page of the audit, newest first: { entries, next_before }. Admins, owners and staff. */++ const audit = (project, { before, limit } = {}) => call({ path: `${P(project)}/audit`, query: { before, limit } });++++ return {++ /** Capabilities an app could ever be granted (active + public). */++ async catalog() { return (await call({ path: '/api/v1/projects/catalog' })).capabilities; },++ /** Your projects; staff may pass { all: true }. */++ async list({ all } = {}) { return (await call({ path: '/api/v1/projects', query: { all } })).projects; },++ /** { name } -> the project; you are its owner. A new project is sandbox only. */++ create: ({ name } = {}) => once({ method: 'POST', path: '/api/v1/projects', json: { name } }),++ /** The project with your role, allowance, environments and counts, or null (404 for non-members). */++ get: (project) => orNull(call({ path: P(project) })),++ update: (project, { name } = {}) => once({ method: 'PATCH', path: P(project), json: { name } }),++ /** Irreversible: revokes every app. Owner or staff. */++ archive: (project) => once({ method: 'POST', path: `${P(project)}/archive` }),++ /** Staff only: the capabilities this project's apps may hold -> { allowance, trimmed }. */++ setAllowance: (project, capabilities) => call({ method: 'PUT', path: `${P(project)}/allowance`, json: { capabilities } }),++ /** Staff only: 'sandbox' | 'sandbox+production'. */++ setEnvironmentPolicy: (project, environmentPolicy) => call({ method: 'PUT', path: `${P(project)}/environment-policy`, json: { environment_policy: environmentPolicy } }),++ members,++ apps,++ credentials,++ grants,++ quotas,++ audit,++ /** Every audit entry, newest first, across pages. */++ iterateAudit: (project, { pageSize = 100 } = {}) => paginate(async (before) => {++ const page = await audit(project, { before, limit: pageSize });++ return { items: page.entries || [], next: page.next_before ?? null };++ }, { cursor: undefined }),++ };++}++++module.exports = { createProjectsClient };@@@ -169,18 +169,29 @@ function subscribe(topics, onEvent, opts = {}) {};}--/** Minimal SSE parser over a ReadableStream<Uint8Array> (WHATWG event-stream rules). */--async function readSse(body, dispatch, onRetry) {-- if (!body || typeof body.getReader !== 'function') throw new TypeError('realtime: response has no readable body');-- const reader = body.getReader();++/**++ * parseSSE(body, { onRetry }) -> async iterator of { event, data, id } (WHATWG event-stream rules).++ *++ * for await (const { event, data, id } of parseSSE(res.body)) { … }++ *++ * `body` is a ReadableStream<Uint8Array> (fetch), or any async iterable of Uint8Array/string chunks++ * (a Node stream). `event` defaults to 'message'; `id` is the id field of that event (undefined when++ * it had none); comments are skipped; `retry: <ms>` is passed to onRetry. A line split across chunks,++ * including a \r\n pair, is reassembled. Breaking out of the loop releases the stream.++ */++async function* parseSSE(body, { onRetry } = {}) {++ if (!body || (typeof body.getReader !== 'function' && typeof body[Symbol.asyncIterator] !== 'function')) {++ throw new TypeError('parseSSE: pass a ReadableStream or an async iterable body');++ }const decoder = new TextDecoder();let buf = '';let type = '';let data = [];let id;++ const out = [];const line = (l) => {if (l === '') {-- if (data.length || type) dispatch(type || 'message', data.join('\n'), id);++ if (data.length || type) out.push({ event: type || 'message', data: data.join('\n'), id });type = ''; data = []; id = undefined;return;}@@@ -192,27 +203,40 @@ async function readSse(body, dispatch, onRetry) {if (field === 'event') type = value;else if (field === 'data') data.push(value);else if (field === 'id' && !value.includes('\0')) id = value;-- else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));++ else if (field === 'retry' && /^\d+$/.test(value) && onRetry) onRetry(Number(value));};++ const reader = typeof body.getReader === 'function' ? body.getReader() : null;++ const iter = reader ? null : body[Symbol.asyncIterator]();try {for (;;) {-- const { value, done } = await reader.read();++ const { value, done } = reader ? await reader.read() : await iter.next();if (done) break;-- buf += decoder.decode(value, { stream: true });++ buf += typeof value === 'string' ? value : decoder.decode(value, { stream: true });let m;while ((m = /\r\n|\r|\n/.exec(buf))) {if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunksline(buf.slice(0, m.index));buf = buf.slice(m.index + m[0].length);}++ while (out.length) yield out.shift();}} finally {-- try { reader.releaseLock(); } catch { /* stream already gone */ }++ if (reader) {++ try { await reader.cancel(); } catch { /* stream already gone */ }++ try { reader.releaseLock(); } catch { /* already released */ }++ } else if (iter && typeof iter.return === 'function') {++ try { await iter.return(); } catch { /* already closed */ }++ }}}++/** The realtime loop's reader: every parsed event goes to dispatch(type, data, id). */++async function readSse(body, dispatch, onRetry) {++ for await (const e of parseSSE(body, { onRetry })) dispatch(e.event, e.data, e.id);++}++function createRealtimeClient(client, defaults = {}) {return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };}--module.exports = { subscribe, createRealtimeClient, DEFAULT_ORIGIN };++module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };nnew file mode 100644@@@ -0,0 +1,541 @@++'use strict';++/**++ * Mock developer projects (Network ADR-014): projects, members, apps (app_<ULID> principals),++ * credentials, grants, the /api/v1/projects API, app tokens from /oauth/token, and the app half of++ * /oauth/authorize. Node only; part of createMockPlatform().++ *++ * Deliberate differences from Network, so tests need no staff steps (each is an option):++ * - sandbox apps get tokens for any audience (`sandboxAudiences` restricts it like++ * DEV_SANDBOX_AUDIENCES); receivers still refuse them unless `acceptSandbox` opts them in++ * - projects created through the API start with every catalog capability in their allowance++ * (`defaultAllowance: []` matches Network's empty default)++ * - projects declared in options allow sandbox and production apps and any capability++ */++const crypto = require('node:crypto');++const { ULID_RE, ulid, json, problem, redirect, s256 } = require('./util');++++const APP_ID_RE = new RegExp(`^app_${ULID_RE.source.slice(1, -1)}$`);++const PRJ_ID_RE = new RegExp(`^prj_${ULID_RE.source.slice(1, -1)}$`);++const PKCE_RE = /^[A-Za-z0-9_-]{43,128}$/;++const TOKEN_TTL_S = 300;++const CODE_TTL_MS = 5 * 60 * 1000;++const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };++const ENV_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };++++/** Capabilities that are public + active in openvibe-contracts v0.26.0 (grantable to apps). */++const DEFAULT_APP_CATALOG = [++ 'media.object.upload', 'media.object.read', 'tools.job.create', 'tools.job.read', 'tools.job.cancel',++ 'games.mod.read', 'games.world.announce', 'games.prop.place',++];++++function createDeveloper(ctx) {++ const { opts } = ctx;++ const projects = new Map();++ const apps = new Map();++ const codes = new Map();++ const sandboxAudiences = opts.sandboxAudiences == null ? null : new Set(opts.sandboxAudiences);++ const nowIso = () => new Date().toISOString();++++ // ── Catalog ─────────────────────────────────────────────++ function capabilityDef(id) { return (opts.capabilities || []).find((c) => c.id === id) || null; }++ function audienceOf(cap) {++ const def = capabilityDef(cap);++ return `openvibe.${def ? def.owner : String(cap).split('.')[0]}`;++ }++ function catalog() {++ const declared = (opts.capabilities || []).filter((c) => c.visibility === 'public' && (c.status || 'active') === 'active');++ const list = declared.length ? declared : DEFAULT_APP_CATALOG.map((id) => ({ id, owner: id.split('.')[0], visibility: 'public', description: '' }));++ return list.map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));++ }++ const grantable = (cap) => catalog().some((c) => c.id === cap);++++ // ── Model ───────────────────────────────────────────────++ function audit(project, actor, action, target, detail = {}) {++ project.audit.push({ id: project.audit.length + 1, at: nowIso(), actor, action, target, detail });++ }++++ function addProject({ id, name, owner, environmentPolicy = 'sandbox+production', allowance = '*', members } = {}) {++ const pid = id || `prj_${ulid()}`;++ if (!PRJ_ID_RE.test(pid)) throw new TypeError(`mock platform: project id ${pid} is not prj_<ULID>`);++ if (projects.has(pid)) return projects.get(pid);++ const p = {++ id: pid, name: name || pid, owner: owner || null, environment_policy: environmentPolicy,++ allowance: allowance === '*' ? '*' : new Set(allowance), members: new Map(), open: !members && !owner,++ created_at: nowIso(), archived_at: null, quotas: new Map(), audit: [],++ };++ if (owner) p.members.set(owner, 'owner');++ for (const [subject, role] of Object.entries(members || {})) p.members.set(subject, role);++ projects.set(pid, p);++ // Media keys tenancy by project id; the real Media needs its operators to create the tenant.++ if (ctx.mediaApps && !ctx.mediaApps.has(pid)) ctx.mediaApps.set(pid, { apiKey: null });++ return p;++ }++++ function newCredential(app, secret) {++ const c = { id: `crd_${ulid()}`, secret: secret || `ovsec_${crypto.randomBytes(32).toString('base64url')}`, created_at: nowIso(), expires_at: null, revoked_at: null };++ c.hint = c.secret.slice(-4);++ app.credentials.push(c);++ return c;++ }++ function credentialState(c) {++ if (c.revoked_at) return 'revoked';++ if (c.expires_at && Date.parse(c.expires_at) <= Date.now()) return 'expired';++ return c.expires_at ? 'expiring' : 'active';++ }++ const matchSecret = (app, secret) => Boolean(secret) && app.credentials.some((c) => c.secret === secret && ['active', 'expiring'].includes(credentialState(c)));++++ /**++ * addApp({ id?, project?, name?, env|environment = 'sandbox', type = 'confidential', secret?,++ * redirectUris = [], grants = [capability | { capability, audience }] })++ * -> { id, clientId, projectId, env, type, secret? } (grants listed here are approved)++ */++ function addApp(spec = {}) {++ const id = spec.id || `app_${ulid()}`;++ if (!APP_ID_RE.test(id)) throw new TypeError(`mock platform: app id ${id} is not app_<ULID>`);++ const project = projects.get(spec.project) || addProject({ id: spec.project });++ const environment = spec.env || spec.environment || 'sandbox';++ const type = spec.type || 'confidential';++ if (!['sandbox', 'production'].includes(environment)) throw new TypeError('mock platform: env is sandbox or production');++ if (!['confidential', 'public'].includes(type)) throw new TypeError('mock platform: type is confidential or public');++ const app = {++ id, project_id: project.id, name: spec.name || id, environment, client_type: type, redirect_uris: [...(spec.redirectUris || [])],++ created_at: nowIso(), revoked_at: null, credentials: [], grants: new Map(),++ };++ const cred = type === 'confidential' ? newCredential(app, spec.secret) : null;++ for (const g of spec.grants || []) {++ const cap = typeof g === 'string' ? g : g.capability;++ app.grants.set(cap, { capability: cap, audience: (typeof g === 'object' && g.audience) || audienceOf(cap), status: 'approved', requested_by: 'mock', requested_at: nowIso(), decided_by: 'mock', decided_at: nowIso() });++ }++ apps.set(id, app);++ return { id, clientId: id, projectId: project.id, env: environment, type, secret: cred ? cred.secret : undefined };++ }++++ // opts.projects: { prj_…: { name?, owner?, members?: { usr_…: role }, allowance?: '*' | [], environmentPolicy? } }++ // A project with an owner or members lets only them authorize its sandbox apps.++ for (const [id, spec] of Object.entries(opts.projects || {})) addProject({ ...spec, id });++ for (const [id, spec] of Object.entries(opts.apps || {})) addApp({ ...spec, id });++++ // ── Tokens ──────────────────────────────────────────────++ const oauth = (status, error, description) => json(status, { error, error_description: description }, { 'Cache-Control': 'no-store' });++++ function usable(clientId) {++ const app = apps.get(String(clientId || ''));++ if (!app || app.revoked_at) return { res: oauth(401, 'invalid_client', 'unknown or revoked app') };++ const project = projects.get(app.project_id);++ if (!project || project.archived_at) return { res: oauth(401, 'invalid_client', 'project archived') };++ if (!ENV_POLICIES[project.environment_policy].includes(app.environment)) return { res: oauth(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };++ return { app, project };++ }++++ const inAllowance = (project, cap) => project.allowance === '*' || project.allowance.has(cap);++++ function mint({ app, project, audience, scope, limit, onBehalfOf }) {++ const aud = String(audience || '').trim();++ if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauth(400, 'invalid_request', 'audience is required');++ if (app.environment === 'sandbox' && sandboxAudiences && !sandboxAudiences.has(aud)) return oauth(400, 'invalid_target', `${aud} does not accept sandbox tokens`);++ const held = [...app.grants.values()].filter((g) => g.status === 'approved' && g.audience === aud && inAllowance(project, g.capability))++ .map((g) => g.capability).filter((c) => !limit || limit.includes(c)).sort();++ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;++ const missing = wanted ? wanted.filter((w) => !held.includes(w)) : [];++ if (missing.length) return oauth(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);++ const cap = wanted || held;++ if (!cap.length) return oauth(400, 'invalid_scope', `no grants for audience ${aud}`);++ const now = Math.floor(Date.now() / 1000);++ const token = ctx.sign({++ iss: ctx.issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id], project_id: project.id, env: app.environment,++ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,++ });++ return json(200, { access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: cap.join(' ') }, { 'Cache-Control': 'no-store' });++ }++++ /** /oauth/token for a registered app client id; null when the client id is not one. */++ function token(p) {++ if (!apps.has(p.get('client_id') || '')) return null;++ const found = usable(p.get('client_id'));++ if (found.res) return found.res;++ const { app, project } = found;++ const secret = p.get('client_secret');++ const authenticate = () => {++ if (app.client_type === 'public') return secret ? oauth(401, 'invalid_client', 'public apps have no client secret') : null;++ return matchSecret(app, secret) ? null : oauth(401, 'invalid_client', 'Invalid client credentials');++ };++ const grant = p.get('grant_type');++ if (grant === 'client_credentials') {++ if (app.client_type !== 'confidential') return oauth(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');++ return authenticate() || mint({ app, project, audience: p.get('audience'), scope: p.get('scope') });++ }++ if (grant === 'authorization_code') {++ const bad = authenticate();++ if (bad) return bad;++ const c = codes.get(p.get('code') || '');++ if (!c || c.appId !== app.id) return oauth(400, 'invalid_grant', 'Invalid authorization code');++ if (c.used) return oauth(400, 'invalid_grant', 'Code already used');++ c.used = true; // single use; a failed check below still burns it++ if (c.expiresAt < Date.now()) return oauth(400, 'invalid_grant', 'Authorization code expired');++ if (c.redirectUri !== (p.get('redirect_uri') || '')) return oauth(400, 'invalid_grant', 'Redirect URI mismatch');++ const v = p.get('code_verifier') || '';++ if (!PKCE_RE.test(v) || s256(v) !== c.codeChallenge) return oauth(400, 'invalid_grant', 'PKCE verification failed');++ if (c.audience && c.audience !== p.get('audience')) return oauth(400, 'invalid_grant', `the code was authorized for ${c.audience}`);++ const authorized = c.scope.length ? c.scope : null;++ const asked = p.get('scope') ? p.get('scope').split(/\s+/).filter(Boolean) : null;++ if (authorized && asked && asked.some((s) => !authorized.includes(s))) return oauth(400, 'invalid_scope', 'scope exceeds what the user authorized');++ return mint({ app, project, audience: p.get('audience'), scope: p.get('scope'), limit: authorized, onBehalfOf: c.subjectId });++ }++ return oauth(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');++ }++++ // ── Authorization ───────────────────────────────────────++ const isApp = (clientId) => apps.has(String(clientId || ''));++++ /** Issue a code for an app, or throw with { status, error } when Network would refuse. */++ function issueCode({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope, audience }) {++ const found = usable(clientId);++ if (found.res) throw Object.assign(new Error('unknown or unusable app'), { status: 400, error: 'invalid_request' });++ const { app, project } = found;++ if (!redirectUri || !app.redirect_uris.includes(String(redirectUri))) throw Object.assign(new Error('Invalid redirect_uri'), { status: 400, error: 'invalid_request' });++ if (!codeChallenge || codeChallengeMethod !== 'S256' || !PKCE_RE.test(String(codeChallenge))) {++ throw Object.assign(new Error('apps must send a PKCE code_challenge with code_challenge_method=S256'), { status: 400, error: 'invalid_request' });++ }++ const user = ctx.users.get(subjectId) || [...ctx.users.values()][0] || ctx.addUser();++ if (app.environment === 'sandbox' && !project.open && !project.members.has(user.subject_id)) {++ throw Object.assign(new Error('sandbox apps can only be authorized by members of their project'), { status: 403, error: 'access_denied', redirectable: true });++ }++ const code = crypto.randomBytes(32).toString('hex');++ const list = (Array.isArray(scope) ? scope : String(scope || '').split(/\s+/)).filter(Boolean);++ codes.set(code, { appId: app.id, redirectUri: String(redirectUri), subjectId: user.subject_id, codeChallenge: String(codeChallenge), scope: list, audience: audience || null, expiresAt: Date.now() + CODE_TTL_MS, used: false });++ return code;++ }++++ /** GET /oauth/authorize for an app client id (auto-consent as ctx.authorization.subjectId). */++ function authorizeRoute(url) {++ const q = url.searchParams;++ const clientId = q.get('client_id');++ const found = usable(clientId);++ if (found.res) return json(400, { error: 'invalid_request', error_description: 'Unknown client_id' });++ const redirectUri = q.get('redirect_uri') || '';++ if (!found.app.redirect_uris.includes(redirectUri)) return json(400, { error: 'invalid_request', error_description: 'Invalid redirect_uri' });++ const back = (params) => redirect(redirectUri, { ...params, state: q.get('state') });++ if (q.get('response_type') !== 'code') return back({ error: 'unsupported_response_type' });++ if (q.get('prompt') === 'none') return back({ error: 'interaction_required', error_description: 'apps need the person to choose to continue' });++ if (!q.get('code_challenge') || q.get('code_challenge_method') !== 'S256' || !PKCE_RE.test(q.get('code_challenge'))) {++ return json(400, { error: 'invalid_request', error_description: 'apps must send a PKCE code_challenge with code_challenge_method=S256' });++ }++ if (ctx.authorization.decision === 'deny') return back({ error: 'access_denied', error_description: 'the person declined' });++ try {++ const code = issueCode({++ clientId, redirectUri, subjectId: ctx.authorization.subjectId, codeChallenge: q.get('code_challenge'),++ codeChallengeMethod: q.get('code_challenge_method'), scope: q.get('scope'), audience: q.get('audience'),++ });++ return back({ code });++ } catch (err) {++ return back({ error: err.error || 'server_error', error_description: err.message });++ }++ }++++ // ── /api/v1/projects ────────────────────────────────────++ class DevError extends Error { constructor(status, code, detail) { super(detail); this.status = status; this.code = code; } }++ const fail = (status, code, detail) => { throw new DevError(status, code, detail); };++++ function access(actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {++ const p = projects.get(projectId);++ const role = p ? p.members.get(actor.subject) || null : null;++ if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');++ if (p.archived_at && !allowArchived) fail(409, 'project.archived', 'project is archived');++ if (role && RANK[role] >= RANK[need]) return { project: p, role };++ if (actor.staff && staffOk) return { project: p, role };++ return fail(403, 'project.forbidden', `needs ${need}`);++ }++ function loadApp(project, appId) {++ const a = apps.get(appId);++ if (!a || a.project_id !== project.id) fail(404, 'app.not_found', 'no such app');++ return a;++ }++ const manageRole = (env) => (env === 'production' ? 'admin' : 'developer');++ const allowanceList = (p) => (p.allowance === '*' ? ['*'] : [...p.allowance].sort());++ function projectView(p, role) {++ return {++ id: p.id, name: p.name, owner: p.owner ? { type: 'user', id: p.owner } : null, role: role || null,++ environment_policy: p.environment_policy, environments: ENV_POLICIES[p.environment_policy], allowance: allowanceList(p),++ created_at: p.created_at, archived_at: p.archived_at,++ counts: { members: p.members.size, apps: [...apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },++ };++ }++ function appView(a) {++ return {++ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,++ client_type: a.client_type, redirect_uris: [...a.redirect_uris], created_at: a.created_at, revoked_at: a.revoked_at,++ grants: [...a.grants.values()].filter((g) => g.status === 'approved').map((g) => g.capability).sort(),++ };++ }++ const credentialView = (c) => ({ id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at });++ const grantView = (a, g) => ({ app_id: a.id, ...g });++ function memberView(subject, role) {++ const u = ctx.users.get(subject);++ return { subject: { type: 'user', id: subject }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role };++ }++ const cleanName = (n) => {++ const s = String(n || '').trim();++ if (!s || s.length > 80) fail(422, 'project.invalid', 'name is 1..80 characters');++ return s;++ };++++ function setGrant(p, a, g, actor, status) {++ const from = g.status;++ Object.assign(g, { status, decided_by: actor, decided_at: nowIso() });++ audit(p, actor, `grant.${status}`, `app:${a.id}`, { capability: g.capability, from });++ }++++ async function projectsApi(req, url) {++ const h = req.headers.get('authorization') || '';++ if (!h.startsWith('Bearer ')) return problem(401, 'auth.required', 'send Authorization: Bearer <Network access token>');++ const user = ctx.userOf(req);++ if (!user) return problem(401, 'auth.invalid', 'not a Network user access token');++ const u = ctx.users.get(user.subject_id);++ const actor = { subject: user.subject_id, staff: (u ? u.role : user.role) === 'admin', label: `user:${user.subject_id}` };++ const path = url.pathname.slice('/api/v1/projects'.length).replace(/\/+$/, '');++ const seg = path.split('/').filter(Boolean).map(decodeURIComponent);++ const m = req.method;++ const body = ['POST', 'PUT', 'PATCH'].includes(m) ? await req.json().catch(() => ({})) || {} : {};++ const ok = (status, out) => json(status, out, { 'Cache-Control': 'private, no-store' });++ try {++ if (!seg.length) {++ if (m === 'GET') {++ const all = url.searchParams.get('all') === '1' && actor.staff;++ return ok(200, { projects: [...projects.values()].filter((p) => all || p.members.has(actor.subject)).map((p) => projectView(p, p.members.get(actor.subject))) });++ }++ if (m === 'POST') {++ const allowance = opts.defaultAllowance === undefined ? catalog().map((c) => c.id) : opts.defaultAllowance;++ const p = addProject({ name: cleanName(body.name), owner: actor.subject, environmentPolicy: 'sandbox', allowance });++ audit(p, actor.label, 'project.created', `project:${p.id}`, { name: p.name });++ return ok(201, projectView(p, 'owner'));++ }++ }++ if (seg[0] === 'catalog' && seg.length === 1 && m === 'GET') return ok(200, { capabilities: catalog() });++ const pid = seg[0];++ if (seg.length === 1) {++ if (m === 'GET') { const { project, role } = access(actor, pid, { allowArchived: true }); return ok(200, projectView(project, role)); }++ if (m === 'PATCH') { const { project, role } = access(actor, pid, { need: 'admin', staffOk: false }); project.name = cleanName(body.name); return ok(200, projectView(project, role)); }++ }++ if (seg.length === 2 && seg[1] === 'archive' && m === 'POST') {++ const { project, role } = access(actor, pid, { need: 'owner' });++ project.archived_at = nowIso();++ for (const a of apps.values()) if (a.project_id === project.id && !a.revoked_at) a.revoked_at = project.archived_at;++ audit(project, actor.label, 'project.archived', `project:${project.id}`);++ return ok(200, projectView(project, role));++ }++ if (seg.length === 2 && seg[1] === 'allowance' && m === 'PUT') {++ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');++ const { project } = access(actor, pid);++ const wanted = (Array.isArray(body.capabilities) ? body.capabilities : []).map(String);++ const bad = wanted.find((c) => !grantable(c));++ if (bad) fail(403, 'grant.not_grantable', `${bad} is not grantable to apps`);++ project.allowance = new Set(wanted);++ const trimmed = [];++ for (const a of apps.values()) {++ if (a.project_id !== project.id) continue;++ for (const g of a.grants.values()) {++ if (wanted.includes(g.capability)) continue;++ if (g.status === 'approved') { setGrant(project, a, g, actor.label, 'revoked'); trimmed.push({ app_id: a.id, capability: g.capability, to: 'revoked' }); }++ if (g.status === 'requested') { setGrant(project, a, g, actor.label, 'denied'); trimmed.push({ app_id: a.id, capability: g.capability, to: 'denied' }); }++ }++ }++ return ok(200, { allowance: allowanceList(project), trimmed });++ }++ if (seg.length === 2 && seg[1] === 'environment-policy' && m === 'PUT') {++ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');++ const { project } = access(actor, pid);++ if (!ENV_POLICIES[body.environment_policy]) fail(422, 'project.invalid', 'environment_policy is sandbox or sandbox+production');++ project.environment_policy = body.environment_policy;++ return ok(200, { environment_policy: project.environment_policy, environments: ENV_POLICIES[project.environment_policy] });++ }++ if (seg[1] === 'members') return members(actor, pid, seg, m, body, ok);++ if (seg[1] === 'apps') return appsRoutes(actor, pid, seg, m, body, ok);++ if (seg[1] === 'quotas') {++ const { project } = access(actor, pid, { allowArchived: true });++ if (seg.length === 2 && m === 'GET') return ok(200, { quotas: [...project.quotas.values()], note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });++ if (seg.length === 3 && (m === 'PUT' || m === 'DELETE')) {++ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');++ if (m === 'DELETE') { if (!project.quotas.delete(seg[2])) fail(404, 'quota.not_found', 'no such quota'); return new Response(null, { status: 204 }); }++ const q = { capability: seg[2], limit: Number(body.limit), window: body.window, unit: body.unit, enforced_by: audienceOf(seg[2]), updated_at: nowIso() };++ project.quotas.set(seg[2], q);++ return ok(200, q);++ }++ }++ if (seg.length === 2 && seg[1] === 'audit' && m === 'GET') {++ const { project } = access(actor, pid, { need: 'admin', allowArchived: true });++ const before = Number(url.searchParams.get('before')) || Infinity;++ const n = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));++ const rows = project.audit.filter((e) => e.id < before).reverse().slice(0, n);++ return ok(200, { entries: rows, next_before: rows.length === n ? rows[rows.length - 1].id : null });++ }++ return problem(404, 'not_found', 'no such route');++ } catch (err) {++ if (err instanceof DevError) return problem(err.status, err.code, err.message);++ throw err;++ }++ }++++ function members(actor, pid, seg, m, body, ok) {++ if (seg.length === 2 && m === 'GET') {++ const { project } = access(actor, pid, { allowArchived: true });++ return ok(200, { members: [...project.members].map(([s, r]) => memberView(s, r)) });++ }++ if (seg.length === 2 && m === 'POST') {++ const role = String(body.role || '');++ if (!['admin', 'developer', 'viewer'].includes(role)) fail(422, 'member.invalid', 'role is admin, developer or viewer');++ const { project } = access(actor, pid, { need: role === 'admin' ? 'owner' : 'admin', staffOk: false });++ const u = body.subject_id ? ctx.users.get(body.subject_id) : [...ctx.users.values()].find((x) => x.username === body.username);++ if (!u) fail(404, 'member.user_not_found', 'no such user');++ if (project.members.has(u.subject_id)) fail(409, 'member.exists', 'already a member');++ project.members.set(u.subject_id, role);++ audit(project, actor.label, 'member.added', `user:${u.subject_id}`, { role });++ return ok(201, memberView(u.subject_id, role));++ }++ if (seg.length === 3) {++ const { project, role: mine } = access(actor, pid, { allowArchived: m === 'DELETE' });++ const subject = seg[2];++ const theirs = project.members.get(subject);++ if (!theirs) fail(404, 'member.not_found', 'not a member');++ if (m === 'PATCH') {++ const role = String(body.role || '');++ if (!['admin', 'developer', 'viewer'].includes(role)) fail(422, 'member.invalid', 'role is admin, developer or viewer');++ if (theirs === 'owner') fail(409, 'member.owner', 'ownership cannot be changed here');++ const need = role === 'admin' || theirs === 'admin' ? 'owner' : 'admin';++ if (!mine || RANK[mine] < RANK[need]) fail(403, 'project.forbidden', `needs ${need}`);++ project.members.set(subject, role);++ return ok(200, memberView(subject, role));++ }++ if (m === 'DELETE') {++ if (theirs === 'owner') fail(409, 'member.owner', 'the owner cannot leave');++ const self = subject === actor.subject;++ const need = theirs === 'admin' ? 'owner' : 'admin';++ if (!self && (!mine || RANK[mine] < RANK[need])) fail(403, 'project.forbidden', `needs ${need}`);++ project.members.delete(subject);++ audit(project, actor.label, 'member.removed', `user:${subject}`);++ return new Response(null, { status: 204 });++ }++ }++ return problem(404, 'not_found', 'no such route');++ }++++ function appsRoutes(actor, pid, seg, m, body, ok) {++ if (seg.length === 2 && m === 'GET') {++ const { project } = access(actor, pid, { allowArchived: true });++ return ok(200, { apps: [...apps.values()].filter((a) => a.project_id === project.id).map(appView) });++ }++ if (seg.length === 2 && m === 'POST') {++ const environment = String(body.environment || 'sandbox');++ if (!['sandbox', 'production'].includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');++ const { project } = access(actor, pid, { need: manageRole(environment), staffOk: false });++ if (!ENV_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${ENV_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);++ const type = String(body.type || 'confidential');++ if (!['confidential', 'public'].includes(type)) fail(422, 'app.invalid', 'type is confidential or public');++ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];++ for (const r of redirects) {++ let u;++ try { u = new URL(r); } catch { fail(422, 'app.invalid_redirect', `${r} is not a URL`); }++ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);++ if (u.hash || u.username || u.password) fail(422, 'app.invalid_redirect', 'no fragments or credentials in redirect URIs');++ if (u.protocol !== 'https:' && !(local && u.protocol === 'http:' && environment === 'sandbox')) fail(422, 'app.invalid_redirect', 'redirect URIs are https (http://localhost for sandbox apps only)');++ }++ if (type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');++ const made = addApp({ project: project.id, name: cleanName(body.name), environment, type, redirectUris: redirects });++ const a = apps.get(made.id);++ audit(project, actor.label, 'app.created', `app:${a.id}`, { environment, client_type: type });++ const out = appView(a);++ if (made.secret) {++ const c = a.credentials[0];++ out.credential = { id: c.id, client_secret: c.secret, hint: c.hint, shown_once: true };++ }++ return ok(201, out);++ }++ const { project, role } = access(actor, pid, { allowArchived: true });++ const a = loadApp(project, seg[2]);++ const manage = () => {++ if (!role || RANK[role] < RANK[manageRole(a.environment)]) fail(403, 'project.forbidden', `needs ${manageRole(a.environment)}`);++ if (project.archived_at) fail(409, 'project.archived', 'project is archived');++ };++ if (seg.length === 3) {++ if (m === 'GET') return ok(200, appView(a));++ if (m === 'PATCH') {++ manage();++ if (body.name !== undefined) a.name = cleanName(body.name);++ if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris.map(String);++ return ok(200, appView(a));++ }++ if (m === 'DELETE') {++ if (!actor.staff) manage();++ if (!a.revoked_at) { a.revoked_at = nowIso(); audit(project, actor.label, 'app.revoked', `app:${a.id}`); }++ return ok(200, appView(a));++ }++ }++ if (seg[3] === 'credentials') {++ if (seg.length === 4 && m === 'GET') return ok(200, { credentials: a.credentials.map(credentialView) });++ if (seg.length === 5 && seg[4] === 'rotate' && m === 'POST') {++ manage();++ if (a.revoked_at) fail(409, 'app.revoked', 'app is revoked');++ if (a.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');++ const overlap = body.overlap_seconds === undefined ? 86400 : Number(body.overlap_seconds);++ if (!Number.isInteger(overlap) || overlap < 0 || overlap > 7 * 86400) fail(422, 'credential.invalid', 'overlap_seconds is an integer from 0 to 604800');++ const until = new Date(Date.now() + overlap * 1000).toISOString();++ const kept = [];++ for (const c of a.credentials) {++ if (c.revoked_at || credentialState(c) === 'expired') continue;++ c.expires_at = c.expires_at && c.expires_at < until ? c.expires_at : until;++ kept.push({ id: c.id, expires_at: c.expires_at });++ }++ const c = newCredential(a);++ audit(project, actor.label, 'credential.rotated', `app:${a.id}`, { credential_id: c.id, overlap_seconds: overlap });++ return ok(201, { credential: { id: c.id, client_secret: c.secret, hint: c.hint, shown_once: true }, previous: kept });++ }++ if (seg.length === 6 && seg[5] === 'revoke' && m === 'POST') {++ if (!actor.staff) manage();++ const c = a.credentials.find((x) => x.id === seg[4]);++ if (!c) fail(404, 'credential.not_found', 'no such credential');++ if (!c.revoked_at) { c.revoked_at = nowIso(); audit(project, actor.label, 'credential.revoked', `app:${a.id}`, { credential_id: c.id }); }++ return ok(200, credentialView(c));++ }++ }++ if (seg[3] === 'grants') {++ if (seg.length === 4 && m === 'GET') return ok(200, { grants: [...a.grants.values()].map((g) => grantView(a, g)) });++ if (seg.length === 4 && m === 'POST') {++ if (!role || RANK[role] < RANK.developer) fail(403, 'project.forbidden', 'needs developer');++ if (a.revoked_at) fail(409, 'app.revoked', 'app is revoked');++ const cap = String(body.capability || '');++ if (!grantable(cap)) fail(403, 'grant.not_grantable', `${cap} is not in the capability catalog for apps`);++ const existing = a.grants.get(cap);++ if (existing && ['approved', 'requested'].includes(existing.status)) return ok(201, grantView(a, existing));++ const now = RANK[role] >= RANK.admin && inAllowance(project, cap);++ const g = { capability: cap, audience: audienceOf(cap), status: now ? 'approved' : 'requested', requested_by: actor.label, requested_at: nowIso(), decided_by: now ? actor.label : null, decided_at: now ? nowIso() : null };++ a.grants.set(cap, g);++ audit(project, actor.label, now ? 'grant.approved' : 'grant.requested', `app:${a.id}`, { capability: cap });++ return ok(201, grantView(a, g));++ }++ const g = a.grants.get(seg[4]);++ if (seg.length === 6 && ['approve', 'deny'].includes(seg[5]) && m === 'POST') {++ if (!role || RANK[role] < RANK.admin) fail(403, 'project.forbidden', 'needs admin');++ if (!g) fail(404, 'grant.not_found', 'no such grant');++ if (seg[5] === 'approve') {++ if (!inAllowance(project, g.capability)) fail(403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance`);++ if (g.status !== 'approved') setGrant(project, a, g, actor.label, 'approved');++ } else {++ if (g.status !== 'requested') fail(409, 'grant.not_pending', `grant is ${g.status}`);++ setGrant(project, a, g, actor.label, 'denied');++ }++ return ok(200, grantView(a, g));++ }++ if (seg.length === 5 && m === 'DELETE') {++ if (!actor.staff && (!role || RANK[role] < RANK.admin)) fail(403, 'project.forbidden', 'needs admin');++ if (!g) fail(404, 'grant.not_found', 'no such grant');++ if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);++ setGrant(project, a, g, actor.label, 'revoked');++ return ok(200, grantView(a, g));++ }++ }++ return problem(404, 'not_found', 'no such route');++ }++++ return { projects, apps, addApp, addProject, token, isApp, issueCode, authorizeRoute, projectsApi, catalog };++}++++module.exports = { createDeveloper, DEFAULT_APP_CATALOG, APP_ID_RE, PRJ_ID_RE };@@@ -5,61 +5,42 @@* const { createMockPlatform } = require('openvibe-sdk/testing');* const platform = createMockPlatform({* clients: { demo: { secret: 's3cret', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['demo'] }] } },++ * apps: { [appId]: { env: 'sandbox', type: 'confidential', secret: 's', grants: ['media.object.upload'] } },* mediaApps: { demo: {} },* });* const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'demo', clientSecret: 's3cret', fetch: platform.fetch }) });** `platform.fetch` answers like the real services at their public origins:-- * Network /.well-known/openvibe, /oauth/token (client_credentials, authorization_code with PKCE,-- * refresh_token), /api/.well-known/jwks, /api/v1/registry/*, /api/modules/*,-- * /internal/modules/*, /internal/identity/*-- * Events /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints, /realtime/stream (SSE)-- * Media /api/v1/:app/files++ * Network /.well-known/openvibe, /oauth/authorize (auto-consent), /oauth/token (client_credentials,++ * authorization_code with PKCE, refresh_token; developer apps), /api/.well-known/jwks,++ * /api/v1/registry/*, /api/v1/projects/*, /api/modules/*, /internal/modules/*,++ * /internal/identity/*++ * Events /api/v1/events (with retention gaps after pruneEvents()), /api/v1/subscriptions,++ * /api/v1/checkpoints, /realtime/stream (SSE); deliverEvents() plays the delivery worker++ * Media /api/v1/:app/files (developer apps: the tenant is the project id)++ * Tools /api/v1/jobs (with { jobs: true })* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real-- * services check them (audience, capability, namespace). It is a fake: no persistence, no-- * delivery worker, simplified visibility rules. It is stricter than the Network on one point: it-- * verifies PKCE code_verifier when the authorization carried a challenge.++ * services check them (audience, capability, namespace, sandbox refusal). It is a fake: no++ * persistence, simplified visibility rules, no Chat. Like the Network, it verifies the PKCE++ * code_verifier whenever the authorization carried a challenge, and requires one from apps.*/const crypto = require('node:crypto');++const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');++const { createDeveloper, DEFAULT_APP_CATALOG } = require('./developer');++const { createJobsService } = require('./jobs');const DEFAULT_ORIGINS = {network: 'https://openvibe.network',events: 'https://events.openvibe.network',media: 'https://openvibe.media',community: 'https://openvibe.community',++ tools: 'https://openvibe.tools',};--const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';--const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');--const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');----function ulid() {-- let t = Date.now();-- let time = '';-- for (let i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ULID_ALPHABET[t % 32] + time;-- let rand = '';-- for (const b of crypto.randomBytes(16)) rand += ULID_ALPHABET[b & 31];-- return time + rand;--}----function topicRegex(pattern) {-- const seg = '[a-z0-9_]+';-- const parts = String(pattern).split('.').map((p) => (p === '*' ? `${seg}(?:\\.${seg})*` : p.replace(/[^a-z0-9_]/g, '')));-- return new RegExp(`^${parts.join('\\.')}$`);--}----function json(status, body, headers = {}) {-- return new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });--}--function problem(status, code, detail) {-- return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail, error: detail || code }), {-- status, headers: { 'Content-Type': 'application/problem+json' },-- });--}function createMockPlatform(opts = {}) {const origins = { ...DEFAULT_ORIGINS, ...opts.origins };const issuer = opts.issuer || origins.network;-- const contractsVersion = opts.contractsVersion || '0.6.0';++ const contractsVersion = opts.contractsVersion || '0.26.0';const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const kid = 'mock-1';const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };@@@ -70,13 +51,17 @@ function createMockPlatform(opts = {}) {const codes = new Map();const refreshTokens = new Map();const modules = new Map(); // `${subject}|${ns}` -> record-- const events = []; // { seq, event, publisher }++ const events = []; // { seq, event, publisher }, oldest first; pruneEvents() drops the head++ let lastSeq = 0;++ const oldestSeq = () => (events.length ? events[0].seq : lastSeq + 1);const subscriptions = new Map();const checkpoints = new Map();const files = new Map(); // `${app}|${key}` -> metaconst mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));const streams = new Set();-- const stats = { tokenRequests: 0, requests: [] };++ const stats = { tokenRequests: 0, requests: [], deliveries: [] };++ const acceptSandbox = opts.acceptSandbox === true ? true : new Set(opts.acceptSandbox || []);++ const authorization = { subjectId: null, decision: 'allow' };const namespaces = opts.namespaces || [{ namespace: 'demo.prefs', owner: 'demo', version: 1, writers: ['owner', 'user'], publicFields: ['theme'], quotaBytes: 4096, onOwnerRemoved: 'retain-readonly', schema: { type: 'object' } }];const capabilities = opts.capabilities || [];@@@ -117,13 +102,34 @@ function createMockPlatform(opts = {}) {return sign({ iss: issuer, sub: `svc:${clientId}`, actor_type: 'service', aud: [audience], cap, ns, iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });}-- /** { claims } of a verified caller, or { res } with the error to answer. */++ /**++ * An app token signed directly, without the token endpoint (e.g. to test a receiver). A++ * registered app's project and env are used unless given.++ */++ function signAppToken(appId, { audience, capabilities: cap = [], projectId, env, onBehalfOf, expiresInSec = 300 } = {}) {++ const app = developer.apps.get(appId);++ const project = projectId || (app && app.project_id) || `prj_${ulid()}`;++ return sign({++ iss: issuer, sub: `app:${appId}`, actor_type: 'app', aud: [audience], cap, ns: [project], project_id: project,++ env: env || (app && app.environment) || 'sandbox', ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),++ iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,++ });++ }++++ /**++ * { claims, service } of a verified service or app principal, or { res } with the error to++ * answer. App tokens with env=sandbox are refused (401 token.sandbox_refused) unless++ * `acceptSandbox` lists this audience or capability (or is true).++ */function principal(req, audience, capability, namespace) {const auth = req.headers.get('authorization') || '';if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };const claims = decode(auth.slice(7));-- if (!claims || claims.actor_type !== 'service') return { res: problem(401, 'token.bad_signature', 'not a valid service token') };++ if (!claims || !['service', 'app', 'mod'].includes(claims.actor_type)) return { res: problem(401, 'token.bad_signature', 'not a valid service token') };if (!(claims.aud || []).includes(audience)) return { res: problem(401, 'token.wrong_audience', `not for ${audience}`) };++ if (claims.env === 'sandbox' && !(acceptSandbox === true || acceptSandbox.has(audience) || acceptSandbox.has(capability))) {++ return { res: problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here') };++ }const granted = (claims.cap || []).some((c) => c === capability || (c.endsWith('.*') && capability.startsWith(c.slice(0, -1))));if (!granted) return { res: problem(403, 'capability.denied', `${capability} not granted`) };if (namespace && claims.ns && claims.ns.length && !claims.ns.some((n) => n === namespace || (n.endsWith('.*') && namespace.startsWith(n.slice(0, -1))))) {@@@ -137,6 +143,9 @@ function createMockPlatform(opts = {}) {return claims && !claims.actor_type ? claims : null;}++ const developer = createDeveloper({ opts, issuer, sign, users, addUser, userOf, mediaApps, authorization });++ const jobsService = createJobsService({ opts, decode, principal });++// ── Network ─────────────────────────────────────────────function descriptor() {return {@@@ -155,6 +164,8 @@ function createMockPlatform(opts = {}) {async function tokenEndpoint(req) {stats.tokenRequests++;const p = new URLSearchParams(await req.text());++ const appAnswer = developer.token(p);++ if (appAnswer) return appAnswer;const client = clients.get(p.get('client_id') || '');if (!client || client.secret !== p.get('client_secret')) return json(401, { error: 'invalid_client', error_description: 'Invalid client credentials' });const grant = p.get('grant_type');@@@ -195,14 +206,39 @@ function createMockPlatform(opts = {}) {}}-- function authorize({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope = 'profile theme' } = {}) {++ /**++ * What the Network does after the account chooser: returns an authorization code. For a++ * developer app it checks what Network checks (registered redirect URI, S256 challenge,++ * sandbox membership) and binds the code to `audience` when one is given.++ */++ function authorize({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope, audience } = {}) {++ if (developer.isApp(clientId)) return developer.issueCode({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod, scope, audience });if (codeChallenge && codeChallengeMethod !== 'S256') throw new Error('mock authorize: only S256');const user = users.get(subjectId) || [...users.values()][0] || addUser();const code = crypto.randomBytes(16).toString('hex');-- codes.set(code, { clientId, redirectUri, subjectId: user.subject_id, codeChallenge, scope, used: false });++ codes.set(code, { clientId, redirectUri, subjectId: user.subject_id, codeChallenge, scope: scope === undefined ? 'profile theme' : scope, used: false });return code;}++ /** GET /oauth/authorize: consents automatically as setAuthorization()'s person (default: the first user). */++ function authorizeRoute(url) {++ const q = url.searchParams;++ if (developer.isApp(q.get('client_id'))) return developer.authorizeRoute(url);++ const client = clients.get(q.get('client_id') || '');++ const redirectUri = q.get('redirect_uri') || '';++ if (!client) return json(400, { error: 'invalid_request', error_description: 'Unknown client_id' });++ if (!redirectUri || (client.redirectUris.length && !client.redirectUris.includes(redirectUri))) return json(400, { error: 'invalid_request', error_description: 'Invalid redirect_uri' });++ const back = (params) => redirect(redirectUri, { ...params, state: q.get('state') });++ if (q.get('response_type') !== 'code') return back({ error: 'unsupported_response_type' });++ if (q.get('prompt') === 'none' && !authorization.subjectId && !users.size) return back({ error: 'login_required' });++ if (authorization.decision === 'deny') return back({ error: 'access_denied', error_description: 'the person declined' });++ const code = authorize({++ clientId: client.id, redirectUri, subjectId: authorization.subjectId, codeChallenge: q.get('code_challenge') || undefined,++ codeChallengeMethod: q.get('code_challenge_method') || 'S256', scope: q.get('scope') || undefined,++ });++ return back({ code });++ }++function projection(u) {return u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: null, banned: false } : null;}@@@ -231,6 +267,8 @@ function createMockPlatform(opts = {}) {let r;if (req.method === 'GET' && path === '/.well-known/openvibe') return json(200, descriptor());if (req.method === 'POST' && path === '/oauth/token') return tokenEndpoint(req);++ if (req.method === 'GET' && path === '/oauth/authorize') return authorizeRoute(url);++ if (path === '/api/v1/projects' || path.startsWith('/api/v1/projects/')) return developer.projectsApi(req, url);if (req.method === 'GET' && path === '/api/.well-known/jwks') return json(200, jwks);if (req.method === 'GET' && path.startsWith('/api/v1/registry')) {const sub = path.slice('/api/v1/registry'.length);@@@ -309,7 +347,7 @@ function createMockPlatform(opts = {}) {function publishEvent(env, publisher) {const dup = events.find((e) => e.event.event_id === env.event_id);if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };-- const stored = { seq: events.length + 1, event: { priority: 'important', visibility: 'internal', ...env }, publisher };++ const stored = { seq: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher };events.push(stored);for (const s of streams) s.push(stored);return { event_id: env.event_id, seq: stored.seq, duplicate: false };@@@ -339,15 +377,21 @@ function createMockPlatform(opts = {}) {const after = Number(url.searchParams.get('after_seq') || 0);const limit = Number(url.searchParams.get('limit') || 100);const out = [];-- let cursor = after;++ const page = {};++ let from = after;++ if (after < oldestSeq() - 1) { // retention already pruned part of the range++ page.gap = { from_seq: after + 1, to_seq: oldestSeq() - 1 };++ from = oldestSeq() - 1;++ }++ let cursor = from;for (const e of events) {-- if (e.seq <= after) continue;++ if (e.seq <= from) continue;if (out.length >= limit) break;cursor = e.seq;if (pats.some((re) => re.test(e.event.event_type))) out.push({ seq: e.seq, event: e.event });}-- if (out.length < limit) cursor = Math.max(cursor, events.length);-- return json(200, { events: out, next_after_seq: cursor, latest_seq: events.length });++ if (out.length < limit) cursor = Math.max(cursor, lastSeq);++ return json(200, { ...page, events: out, next_after_seq: cursor, latest_seq: lastSeq });}if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {const who = principal(req, 'openvibe.events', 'events.event.read');@@@ -375,7 +419,11 @@ function createMockPlatform(opts = {}) {if (!b.topic_pattern || !b.endpoint) return problem(422, 'events.bad_request', 'topic_pattern and endpoint are required');const dup = [...subscriptions.values()].find((s) => s.consumer === who.service && s.topic_pattern === b.topic_pattern && s.endpoint === b.endpoint);if (dup) return problem(409, 'events.subscription_exists', 'already subscribed');-- const s = { id: `sub_${ulid()}`, consumer: who.service, topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true, retry_policy: b.retry_policy || null, secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}` };++ const s = {++ id: `sub_${ulid()}`, consumer: who.service, topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true, retry_policy: b.retry_policy || null,++ secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,++ cursor: lastSeq, attempts: new Map(), // deliverEvents(): events published after the subscription++ };subscriptions.set(s.id, s);return json(201, view(s, true));}@@@ -396,6 +444,7 @@ function createMockPlatform(opts = {}) {const auth = req.headers.get('authorization') || '';const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');++ if (claims && claims.env === 'sandbox' && !(acceptSandbox === true || acceptSandbox.has('openvibe.events'))) return problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here');const viewer = claims ? (claims.actor_type ? { kind: 'service' } : { kind: 'user', subject: claims.subject_id }) : { kind: 'anonymous' };const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;@@@ -405,7 +454,7 @@ function createMockPlatform(opts = {}) {start(controller) {const send = (s) => { try { controller.enqueue(enc.encode(s)); } catch { streams.delete(conn); } };conn = {-- lastSeq: last ?? events.length,++ lastSeq: last ?? lastSeq,push(e) {if (e.seq <= conn.lastSeq || !pats.some((re) => re.test(e.event.event_type)) || !visible(viewer, e)) return;conn.lastSeq = e.seq;@@@ -415,9 +464,14 @@ function createMockPlatform(opts = {}) {};send(`retry: ${opts.realtimeRetryMs ?? 50}\n: connected ${viewer.kind}\n\n`);if (last != null) {-- if (last > events.length) send(`event: gap\ndata: ${JSON.stringify({ reason: 'cursor_ahead', from_seq: events.length + 1, to_seq: last, latest_seq: events.length })}\n\n`);++ if (last > lastSeq) {++ send(`event: gap\ndata: ${JSON.stringify({ reason: 'cursor_ahead', from_seq: lastSeq + 1, to_seq: last, latest_seq: lastSeq })}\n\n`);++ conn.lastSeq = lastSeq;++ } else if (last < oldestSeq() - 1) {++ send(`event: gap\ndata: ${JSON.stringify({ reason: 'retention', from_seq: last + 1, to_seq: oldestSeq() - 1, latest_seq: lastSeq })}\n\n`);++ }for (const e of events) conn.push(e);-- conn.lastSeq = Math.max(conn.lastSeq, events.length);++ conn.lastSeq = Math.max(conn.lastSeq, lastSeq);}streams.add(conn);if (req.signal) req.signal.addEventListener('abort', () => conn.close(), { once: true });@@@ -427,6 +481,83 @@ function createMockPlatform(opts = {}) {return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' } });}++ // ── Delivery worker ─────────────────────────────────────++ /**++ * deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:++ * for each enabled subscription, POSTs every event published after it was created (in seq++ * order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries++ * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>, plus++ * X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,++ * traceparent). A 2xx moves on; anything else stops that subscription until the next call,++ * and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.++ * `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.++ * -> { delivered, failed, dead, attempts: [{ subscription_id, event_id, seq, attempt, status, outcome, error? }] }++ */++ async function deliverEvents({ fetch: send = opts.deliveryFetch || globalThis.fetch, subscriptionId, timeoutMs = 5000 } = {}) {++ const out = { delivered: 0, failed: 0, dead: 0, attempts: [] };++ for (const sub of subscriptions.values()) {++ if (!sub.enabled || (subscriptionId && sub.id !== subscriptionId)) continue;++ const re = topicRegex(sub.topic_pattern);++ const max = (sub.retry_policy && Number.isInteger(sub.retry_policy.max_attempts) && sub.retry_policy.max_attempts) || 5;++ for (const e of events) {++ if (e.seq <= sub.cursor) continue;++ if (!re.test(e.event.event_type)) { sub.cursor = e.seq; continue; }++ const attempt = (sub.attempts.get(e.event.event_id) || 0) + 1;++ sub.attempts.set(e.event.event_id, attempt);++ const body = JSON.stringify({ event: e.event, seq: e.seq });++ const trace = /^[0-9a-f]{32}$/.test(e.event.trace_id || '') ? e.event.trace_id : crypto.randomBytes(16).toString('hex');++ let status = null;++ let error;++ try {++ const res = await send(sub.endpoint, {++ method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), body,++ headers: {++ 'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',++ 'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),++ 'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',++ 'X-OpenVibe-Signature': `sha256=${crypto.createHmac('sha256', String(sub.secret)).update(body).digest('hex')}`,++ traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,++ },++ });++ status = res.status;++ try { await (res.body && res.body.cancel()); } catch { /* not needed */ }++ } catch (err) {++ error = err && err.name === 'TimeoutError' ? 'timeout' : String((err && err.message) || err);++ }++ const ok = status >= 200 && status < 300;++ const outcome = ok ? 'delivered' : attempt >= max ? 'dead' : 'retry';++ const rec = { subscription_id: sub.id, event_id: e.event.event_id, seq: e.seq, attempt, status, outcome, ...(error ? { error } : {}) };++ out.attempts.push(rec);++ stats.deliveries.push(rec);++ if (ok) { out.delivered++; sub.cursor = e.seq; continue; }++ if (outcome === 'dead') { out.dead++; sub.cursor = e.seq; continue; }++ out.failed++;++ break; // in order: retry this one next time++ }++ }++ return out;++ }++++ /** deliverEvents() every intervalMs until stop() (resolves after the round in flight). */++ function startDeliveries({ intervalMs = 20, ...deliverOpts } = {}) {++ let stopped = false;++ let timer = null;++ let current = Promise.resolve();++ const tick = () => {++ if (stopped) return;++ current = deliverEvents(deliverOpts).catch(() => {}).then(() => { if (!stopped) timer = setTimeout(tick, intervalMs); });++ };++ tick();++ return { async stop() { stopped = true; clearTimeout(timer); await current; } };++ }++++ /** Retention: drop every stored event with seq <= throughSeq (pulls and resumes now see a gap). */++ function pruneEvents(throughSeq) {++ let n = 0;++ while (events.length && events[0].seq <= throughSeq) { events.shift(); n++; }++ return n;++ }++// ── Media files ─────────────────────────────────────────async function media(req, url) {const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);@@@ -480,6 +611,7 @@ function createMockPlatform(opts = {}) {if (origin === new URL(origins.network).origin) return network(req, url);if (origin === new URL(origins.events).origin) return eventsService(req, url);if (origin === new URL(origins.media).origin) return media(req, url);++ if (origin === new URL(origins.tools).origin) return jobsService.handle(req, url);throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);}@@@ -493,8 +625,22 @@ function createMockPlatform(opts = {}) {addClient,addUser,authorize,++ /** Who /oauth/authorize signs in as ({ subjectId }) and whether they continue or decline ({ decision: 'deny' }). */++ setAuthorization({ subjectId, decision } = {}) {++ if (subjectId !== undefined) authorization.subjectId = subjectId;++ if (decision !== undefined) authorization.decision = decision === 'deny' ? 'deny' : 'allow';++ },++ /** A developer app (see createDeveloper): -> { id, clientId, projectId, env, type, secret? } */++ addApp: (spec) => developer.addApp(spec),++ addProject: (spec) => developer.addProject(spec).id,++ signAppToken,stats,-- state: { events, subscriptions, modules, files, users, checkpoints },++ state: { events, subscriptions, modules, files, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },++ deliverEvents,++ startDeliveries,++ pruneEvents,++ /** End every open Tools job event stream (clients reconnect with Last-Event-ID). */++ dropJobStreams: () => jobsService.dropStreams(),/** Store an event as if a producer had published it (for realtime/pull tests). */publishEvent: (env, publisher = 'svc:mock') => publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher),/** End every open realtime stream (clients reconnect with Last-Event-ID). */@@@ -502,4 +648,4 @@ function createMockPlatform(opts = {}) {};}--module.exports = { createMockPlatform, DEFAULT_ORIGINS };++module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG };nnew file mode 100644@@@ -0,0 +1,207 @@++'use strict';++/**++ * Mock OpenVibe.Tools jobs (/api/v1/jobs), enabled with createMockPlatform({ jobs: true | {…} }).++ * Node only. Plays the documented lifecycle queued -> running -> (progress…) -> succeeded | failed++ * | cancelled, with owner scoping, Idempotency-Key replay, SSE events with ids and Last-Event-ID++ * replay, 204 once finished, and result files.++ *++ * jobs: {++ * stepMs: 5, // delay between lifecycle steps++ * handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },++ * }++ * Without a handler for a type, the job echoes: data { echo: input }, files = the uploaded files.++ * A handler that throws fails the job with { code: err.code || 'tools.job.failed', detail }.++ */++const { ulid, json, problem, sha256hex } = require('./util');++++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);++const CAPS = { create: 'tools.job.create', read: 'tools.job.read', cancel: 'tools.job.cancel' };++++function createJobsService(ctx) {++ const cfg = ctx.opts.jobs && typeof ctx.opts.jobs === 'object' ? ctx.opts.jobs : {};++ const stepMs = cfg.stepMs ?? 5;++ const handlers = cfg.handlers || {};++ const jobs = new Map();++ const idem = new Map();++ const streams = new Set();++ const iso = (t) => (t ? new Date(t).toISOString() : null);++++ function view(job) {++ const live = job.state === 'queued' || job.state === 'running';++ return {++ id: job.id, object: 'tools.job', service: 'mock', type: job.type, type_version: 1, state: job.state,++ progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 1,++ cancel_requested: job.cancelRequested, created_at: iso(job.createdAt), started_at: iso(job.startedAt), finished_at: iso(job.finishedAt), expires_at: null,++ 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: job.error, retryable: false,++ links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: live ? `/api/v1/jobs/${job.id}` : null },++ };++ }++++ function emit(job, event) {++ const e = { seq: job.events.length + 1, event, data: view(job) };++ job.events.push(e);++ for (const s of [...streams]) if (s.jobId === job.id) s.push(e);++ }++++ function finish(job, state, fields = {}) {++ if (TERMINAL.has(job.state)) return;++ Object.assign(job, { state, finishedAt: Date.now() }, fields);++ emit(job, `job.${state}`);++ }++++ async function runJob(job) {++ await new Promise((r) => setTimeout(r, stepMs));++ if (TERMINAL.has(job.state)) return;++ Object.assign(job, { state: 'running', startedAt: Date.now() });++ emit(job, 'job.running');++ const handler = handlers[job.type] || (async ({ input, files, progress }) => {++ await progress(50, 'working');++ return { data: { echo: input }, files: files.map((f) => ({ name: f.name, mime: f.type, bytes: f.bytes })) };++ });++ try {++ const out = await handler({++ input: job.input,++ files: job.files,++ progress: async (percent, message = null) => {++ await new Promise((r) => setTimeout(r, stepMs));++ if (TERMINAL.has(job.state)) return;++ Object.assign(job, { percent, message });++ emit(job, 'job.progress');++ },++ get cancelled() { return job.cancelRequested; },++ });++ await new Promise((r) => setTimeout(r, stepMs));++ if (job.cancelRequested) return finish(job, 'cancelled');++ const files = ((out && out.files) || []).map((f) => ({ name: f.name || 'result', mime: f.mime || 'application/octet-stream', bytes: Buffer.from(f.bytes || f.data || '') }));++ return finish(job, 'succeeded', { percent: 100, message: null, result: { data: (out && out.data) || {}, files } });++ } catch (err) {++ if (job.cancelRequested) return finish(job, 'cancelled');++ return finish(job, 'failed', { error: { code: (err && err.code) || 'tools.job.failed', detail: (err && err.message) || 'failed' } });++ }++ }++++ /** Who owns what this request does: a principal (svc:/app:) or a Network user. */++ function owner(req, action) {++ const auth = req.headers.get('authorization') || '';++ if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };++ const claims = ctx.decode(auth.slice(7));++ if (claims && claims.actor_type) {++ const who = ctx.principal(req, 'openvibe.tools', CAPS[action]);++ return who.res ? who : { owner: who.claims.sub };++ }++ return claims ? { owner: `user:${claims.subject_id}` } : { res: problem(401, 'token.bad_signature', 'not a valid token') };++ }++++ function stream(req, job, after) {++ const enc = new TextEncoder();++ let conn;++ const body = new ReadableStream({++ start(controller) {++ const close = () => { streams.delete(conn); try { controller.close(); } catch { /* closed */ } };++ conn = {++ jobId: job.id,++ last: after,++ push(e) {++ if (e.seq <= conn.last) return;++ conn.last = e.seq;++ try { controller.enqueue(enc.encode(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)); } catch { streams.delete(conn); return; }++ if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') setImmediate(close);++ },++ close,++ };++ controller.enqueue(enc.encode(`retry: ${stepMs}\n\n`));++ streams.add(conn);++ for (const e of job.events) conn.push(e);++ if (req.signal) req.signal.addEventListener('abort', close, { once: true });++ },++ cancel() { streams.delete(conn); },++ });++ return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' } });++ }++++ async function handle(req, url) {++ if (!ctx.opts.jobs) return problem(404, 'not_found', 'jobs are not enabled on this mock (createMockPlatform({ jobs: true }))');++ const path = url.pathname;++ let m;++ if (path === '/api/v1/jobs' && req.method === 'POST') {++ const who = owner(req, 'create');++ if (who.res) return who.res;++ let type, input, files = [];++ const ct = req.headers.get('content-type') || '';++ if (ct.startsWith('multipart/form-data')) {++ const form = await req.formData();++ type = String(form.get('type') || '');++ try { input = JSON.parse(String(form.get('input') || '{}')); } catch { return problem(400, 'tools.job.invalid', 'input must be JSON'); }++ for (const f of [...form.getAll('file'), ...form.getAll('files')]) {++ if (typeof f !== 'string') files.push({ name: f.name, type: f.type || 'application/octet-stream', bytes: Buffer.from(await f.arrayBuffer()) });++ }++ } else {++ const b = await req.json().catch(() => null);++ if (!b) return problem(400, 'tools.job.invalid', 'send JSON { type, input } or multipart');++ type = String(b.type || '');++ input = b.input == null ? {} : b.input;++ }++ if (!type) return problem(400, 'tools.job.invalid', 'type is required');++ if (!input || typeof input !== 'object' || Array.isArray(input)) return problem(400, 'tools.job.invalid', 'input must be a JSON object');++ if (Object.keys(handlers).length && !handlers[type]) return problem(400, 'tools.job.unknown_type', `no job type ${type}`);++ const key = req.headers.get('idempotency-key');++ const hash = sha256hex(JSON.stringify([type, input, files.map((f) => sha256hex(f.bytes))]));++ if (key) {++ const prior = idem.get(`${who.owner}|${key}`);++ if (prior) {++ if (prior.hash !== hash) return problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');++ return json(200, view(jobs.get(prior.id)), { Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true', 'Cache-Control': 'no-store' });++ }++ }++ const job = {++ id: `job_${ulid()}`, owner: who.owner, type, input, files, state: 'queued', percent: null, message: null,++ cancelRequested: false, createdAt: Date.now(), startedAt: null, finishedAt: null, result: null, error: null, events: [],++ };++ jobs.set(job.id, job);++ if (key) idem.set(`${who.owner}|${key}`, { id: job.id, hash });++ emit(job, 'job.queued');++ runJob(job);++ return json(202, view(job), { Location: `/api/v1/jobs/${job.id}`, 'Cache-Control': 'no-store' });++ }++ if ((m = path.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/))) {++ const action = req.method === 'DELETE' ? 'cancel' : 'read';++ if (!['GET', 'DELETE'].includes(req.method) || (req.method === 'DELETE' && m[2])) return problem(405, 'method_not_allowed', 'method not allowed');++ const who = owner(req, action);++ if (who.res) return who.res;++ const job = jobs.get(decodeURIComponent(m[1]));++ if (!job || job.owner !== who.owner) return problem(404, 'tools.job.not_found', 'No such job');++ if (req.method === 'DELETE') {++ if (job.state === 'succeeded' || job.state === 'failed') return problem(409, 'tools.job.already_finished', `The job already ${job.state}`);++ if (job.state === 'queued') finish(job, 'cancelled');++ else if (job.state === 'running' && !job.cancelRequested) { job.cancelRequested = true; emit(job, 'job.cancel_requested'); }++ return json(job.state === 'cancelled' ? 200 : 202, view(job), { 'Cache-Control': 'no-store' });++ }++ if (!m[2]) return json(200, view(job), { 'Cache-Control': 'no-store' });++ if (m[3] !== undefined) {++ if (job.state !== 'succeeded') return problem(409, 'tools.job.not_ready', `The job is ${job.state}`);++ const f = job.result.files[Number(m[3])];++ if (!f) return problem(404, 'tools.job.file_not_found', 'No such result file');++ const kind = ['1', 'true'].includes(url.searchParams.get('inline') || '') ? 'inline' : 'attachment';++ return new Response(f.bytes, { status: 200, headers: { 'Content-Type': f.mime, 'Content-Disposition': `${kind}; filename="${f.name.replace(/["\\]/g, '_')}"`, 'Cache-Control': 'private, no-store' } });++ }++ const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');++ const after = Math.max(0, parseInt(raw, 10) || 0);++ if (TERMINAL.has(job.state) && !job.events.some((e) => e.seq > after)) return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });++ return stream(req, job, after);++ }++ return problem(404, 'not_found', 'Not found');++ }++++ return {++ handle,++ jobs,++ /** End every open job event stream (clients reconnect with Last-Event-ID). */++ dropStreams() { for (const s of [...streams]) s.close(); },++ };++}++++module.exports = { createJobsService, JOB_CAPS: CAPS };nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/** Shared helpers of the mock platform (Node only). */++const crypto = require('node:crypto');++++const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;++const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');++const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');++++function ulid() {++ let t = Date.now();++ let time = '';++ for (let i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ULID_ALPHABET[t % 32] + time;++ let rand = '';++ for (const b of crypto.randomBytes(16)) rand += ULID_ALPHABET[b & 31];++ return time + rand;++}++++function topicRegex(pattern) {++ const seg = '[a-z0-9_]+';++ const parts = String(pattern).split('.').map((p) => (p === '*' ? `${seg}(?:\\.${seg})*` : p.replace(/[^a-z0-9_]/g, '')));++ return new RegExp(`^${parts.join('\\.')}$`);++}++++function json(status, body, headers = {}) {++ return new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });++}++++function problem(status, code, detail, extra = {}) {++ return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail, error: detail || code, ...extra }), {++ status, headers: { 'Content-Type': 'application/problem+json' },++ });++}++++/** A 302 to `target` with query parameters added. */++function redirect(target, params) {++ const u = new URL(target);++ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));++ return new Response(null, { status: 302, headers: { Location: u.toString(), 'Cache-Control': 'no-store' } });++}++++const sha256hex = (v) => crypto.createHash('sha256').update(v).digest('hex');++const s256 = (verifier) => b64url(crypto.createHash('sha256').update(String(verifier)).digest());++++module.exports = { ULID_RE, b64url, fromB64url, ulid, topicRegex, json, problem, redirect, sha256hex, s256 };nnew file mode 100644@@@ -0,0 +1,194 @@++'use strict';++/**++ * Developer apps (Network ADR-014) end to end against the mock platform: authorize URL with an++ * audience, /oauth/authorize, exchangeCode for public and confidential apps (no refresh token),++ * verifyAppToken (project_id, env, on_behalf_of, sandbox refusal), client credentials with++ * getTokenInfo(), and a project-scoped Media upload.++ */++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const { createClient } = require('../src/core');++const auth = require('../src/auth');++const { createMediaClient } = require('../src/media');++const { createMockPlatform } = require('../src/testing');++++const PUBLIC_APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';++const SERVER_APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TS';++const PRJ = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';++const REDIRECT = 'http://localhost:3001/callback';++++function setup(extra = {}) {++ const platform = createMockPlatform({++ users: [{ username: 'ana' }, { username: 'bo' }],++ apps: {++ [PUBLIC_APP]: { project: PRJ, type: 'public', env: 'sandbox', redirectUris: [REDIRECT], grants: ['media.object.read', 'media.object.upload'] },++ [SERVER_APP]: { project: PRJ, type: 'confidential', env: 'production', secret: 'ovsec_server', redirectUris: [REDIRECT], grants: ['media.object.upload', 'tools.job.create'] },++ },++ ...extra,++ });++ const [ana, bo] = [...platform.state.users.values()];++ return { platform, ana, bo };++}++++/** Browser half + Network: returns { code, codeVerifier } after /oauth/authorize redirects back. */++async function signIn(platform, opts) {++ const { url, state, codeVerifier } = await auth.startAuthorization({ clientId: PUBLIC_APP, redirectUri: REDIRECT, ...opts });++ const res = await platform.fetch(url);++ assert.equal(res.status, 302);++ const back = res.headers.get('location');++ return { back, state, codeVerifier, ...(new URL(back).searchParams.get('code') ? auth.readCallback(back, { expectedState: state }) : {}) };++}++++run([++ ['buildAuthorizeUrl: audience + capability scope for apps, profile theme only for first-party', async () => {++ const app = new URL(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media', scope: ['media.object.read', 'media.object.upload'], codeChallenge: 'x'.repeat(43) }));++ assert.equal(app.searchParams.get('audience'), 'openvibe.media');++ assert.equal(app.searchParams.get('scope'), 'media.object.read media.object.upload');++ const noScope = new URL(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media' }));++ assert.equal(noScope.searchParams.get('scope'), null, 'no first-party default when an audience is given');++ const firstParty = new URL(auth.buildAuthorizeUrl({ clientId: 'live', redirectUri: REDIRECT }));++ assert.equal(firstParty.searchParams.get('scope'), 'profile theme');++ assert.equal(firstParty.searchParams.get('audience'), null);++ }],++++ ['public app: /oauth/authorize -> code -> exchange without a secret -> app token for the person', async () => {++ const { platform, ana } = setup({ acceptSandbox: ['openvibe.media'] });++ platform.setAuthorization({ subjectId: ana.subject_id });++ const { code, codeVerifier } = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });++ const t = await auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code, codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' });++ assert.equal(t.refresh_token, undefined, 'apps get no refresh token');++ assert.equal(t.expires_in, 300);++ assert.equal(t.scope, 'media.object.read', 'the authorized scope limits the token');++ const claims = await auth.verifyAppToken(t.access_token, { jwks: platform.keys.jwks, issuer: platform.issuer, audience: 'openvibe.media', acceptSandbox: true });++ assert.equal(claims.sub, `app:${PUBLIC_APP}`);++ assert.equal(claims.actor_type, 'app');++ assert.equal(claims.project_id, PRJ);++ assert.deepEqual(claims.ns, [PRJ]);++ assert.equal(claims.env, 'sandbox');++ assert.equal(claims.on_behalf_of, ana.subject_id);++ assert.deepEqual(claims.cap, ['media.object.read']);++ const tokenCall = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).at(-1);++ assert.ok(tokenCall, 'the exchange hit the token endpoint');++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code, codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_grant' }, 'codes work once');++ }],++++ ['exchangeCode refuses to run without what apps need; Network-side refusals come back as codes', async () => {++ const { platform } = setup();++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: 'c', redirectUri: REDIRECT, audience: 'openvibe.media' }), TypeError, 'public: verifier required');++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: 'c', codeVerifier: 'v'.repeat(43), redirectUri: REDIRECT }), /audience/, 'apps: audience required');++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, clientSecret: 'ovsec_server', code: 'c', redirectUri: REDIRECT, audience: 'openvibe.media' }), /PKCE/, 'apps always use PKCE');++++ // The code is bound to the audience it was authorized for.++ let s = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' }), (e) => e.code === 'invalid_grant' && /openvibe\.media/.test(e.detail));++ // The exchange may narrow, never widen.++ s = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media', scope: ['media.object.upload'] }), { code: 'invalid_scope' });++ // A public app that sends a secret, and a wrong verifier (which burns the code).++ s = await signIn(platform, { audience: 'openvibe.media' });++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, clientSecret: 'nope', code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_client' });++ s = await signIn(platform, { audience: 'openvibe.media' });++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: auth.createCodeVerifier(), redirectUri: REDIRECT, audience: 'openvibe.media' }), (e) => e.code === 'invalid_grant' && /PKCE/.test(e.detail));++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_grant' }, 'burnt');++ }],++++ ['/oauth/authorize: prompt=none, declined consent, unknown redirect, missing PKCE, sandbox membership', async () => {++ const { platform, ana, bo } = setup();++ const none = await signIn(platform, { audience: 'openvibe.media', prompt: 'none' });++ assert.throws(() => auth.readCallback(none.back), { code: 'oauth.interaction_required' });++ platform.setAuthorization({ decision: 'deny' });++ const denied = await signIn(platform, { audience: 'openvibe.media' });++ assert.throws(() => auth.readCallback(denied.back), { code: 'oauth.access_denied' });++ platform.setAuthorization({ decision: 'allow' });++ const bad = await platform.fetch(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: 'https://evil.example/cb', codeChallenge: 'x'.repeat(43) }));++ assert.equal(bad.status, 400, 'never redirects to an unregistered URI');++ const noPkce = await platform.fetch(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media' }));++ assert.equal(noPkce.status, 400);++++ // A project with members: only they may authorize its sandbox app.++ const m = createMockPlatform({++ users: [ana, bo],++ projects: { [PRJ]: { owner: ana.subject_id } },++ apps: { [PUBLIC_APP]: { project: PRJ, type: 'public', redirectUris: [REDIRECT], grants: ['media.object.read'] } },++ });++ m.setAuthorization({ subjectId: bo.subject_id });++ const outsider = await signIn(m, { audience: 'openvibe.media' });++ assert.throws(() => auth.readCallback(outsider.back), { code: 'oauth.access_denied' });++ m.setAuthorization({ subjectId: ana.subject_id });++ assert.ok((await signIn(m, { audience: 'openvibe.media' })).code);++ assert.throws(() => m.authorize({ clientId: PUBLIC_APP, redirectUri: REDIRECT, subjectId: ana.subject_id }), /PKCE/, 'the helper checks PKCE for apps too');++ }],++++ ['confidential app code flow needs its secret too; first-party sign-in keeps its refresh token', async () => {++ const { platform, bo } = setup();++ platform.setAuthorization({ subjectId: bo.subject_id });++ const start = await auth.startAuthorization({ clientId: SERVER_APP, redirectUri: REDIRECT, audience: 'openvibe.tools', scope: 'tools.job.create' });++ const { code } = auth.readCallback((await platform.fetch(start.url)).headers.get('location'), { expectedState: start.state });++ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, code, codeVerifier: start.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' }), { code: 'invalid_client' });++ const start2 = await auth.startAuthorization({ clientId: SERVER_APP, redirectUri: REDIRECT, audience: 'openvibe.tools', scope: 'tools.job.create' });++ const code2 = auth.readCallback((await platform.fetch(start2.url)).headers.get('location')).code;++ const t = await auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, clientSecret: 'ovsec_server', code: code2, codeVerifier: start2.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' });++ const claims = await auth.verifyAppToken(t.access_token, { jwks: platform.keys.jwks, audience: 'openvibe.tools' });++ assert.equal(claims.on_behalf_of, bo.subject_id);++ assert.equal(claims.env, 'production');++++ platform.addClient('live', { secret: 'live-secret', redirectUris: ['https://openvibe.live/cb'] });++ const fp = await auth.startAuthorization({ clientId: 'live', redirectUri: 'https://openvibe.live/cb' });++ const back = (await platform.fetch(fp.url)).headers.get('location');++ const user = await auth.exchangeCode({ fetch: platform.fetch, clientId: 'live', clientSecret: 'live-secret', code: auth.readCallback(back, { expectedState: fp.state }).code, codeVerifier: fp.codeVerifier, redirectUri: 'https://openvibe.live/cb' });++ assert.ok(user.refresh_token, 'first-party user tokens still refresh');++ assert.equal((await auth.verifyUserToken(user.access_token, { jwks: platform.keys.jwks })).subject_id, bo.subject_id);++ }],++++ ['verifyAppToken: audience required, user tokens are not app tokens, sandbox refused unless accepted', async () => {++ const { platform, ana } = setup();++ const jwks = platform.keys.jwks;++ const sandbox = platform.signAppToken(PUBLIC_APP, { audience: 'openvibe.media', capabilities: ['media.object.read'] });++ await assert.rejects(auth.verifyAppToken(sandbox, { jwks }), TypeError);++ await assert.rejects(auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.media' }), { code: 'token.sandbox_refused', status: 401 });++ assert.equal((await auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.media', acceptSandbox: true })).env, 'sandbox');++ await assert.rejects(auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.tools', acceptSandbox: true }), { code: 'token.wrong_audience' });++ const prod = platform.signAppToken(SERVER_APP, { audience: 'openvibe.media', capabilities: ['media.object.upload'] });++ assert.equal((await auth.verifyAppToken(prod, { jwks, audience: 'openvibe.media' })).env, 'production');++ await assert.rejects(auth.verifyAppToken(platform.signUserToken(ana), { jwks, audience: 'openvibe.media' }), { code: 'token.not_app' });++ await assert.rejects(auth.verifyAppToken(platform.signServiceToken('live', { audience: 'openvibe.media' }), { jwks, audience: 'openvibe.media' }), { code: 'token.not_app' });++ await assert.rejects(auth.verifyAppToken(platform.signAppToken(SERVER_APP, { audience: 'openvibe.media', projectId: 'prj_nope' }), { jwks, audience: 'openvibe.media' }), (e) => e.code === 'token.invalid_claims' && /project_id/.test(e.detail));++ await assert.rejects(auth.verifyUserToken(prod, { jwks }), { code: 'token.not_user' }, 'verifyUserToken stays for people');++ }],++++ ['confidential app: client credentials, getTokenInfo() shows scope and (unverified) claims', async () => {++ const { platform } = setup();++ const tokens = auth.createServiceTokenClient({ clientId: SERVER_APP, clientSecret: 'ovsec_server', fetch: platform.fetch });++ const info = await tokens.getTokenInfo({ audience: 'openvibe.media' });++ assert.deepEqual(info.scope, ['media.object.upload']);++ assert.equal(info.audience, 'openvibe.media');++ assert.equal(info.tokenType, 'Bearer');++ assert.ok(Date.parse(info.expiresAt) > Date.now());++ assert.equal(info.unverifiedClaims.project_id, PRJ);++ assert.equal(info.unverifiedClaims.env, 'production');++ assert.equal(info.unverifiedClaims.on_behalf_of, undefined);++ assert.equal(info.accessToken, await tokens.getToken({ audience: 'openvibe.media' }), 'same cached token');++ assert.deepEqual(auth.unverifiedClaims(info.accessToken), info.unverifiedClaims);++ assert.equal(auth.decodeUnverified('nope'), null);++ assert.equal(auth.decodeUnverified(info.accessToken).header.alg, 'RS256');++ await assert.rejects(tokens.getToken({ audience: 'openvibe.events' }), { code: 'invalid_scope' }, 'no grant for that audience');++ await assert.rejects(auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 'x', fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'unauthorized_client' }, 'public apps have no client credentials grant');++ }],++++ ['Media: an app uploads into its project namespace; sandbox tokens are refused unless Media opted in', async () => {++ const { platform } = setup();++ const client = createClient({ fetch: platform.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: SERVER_APP, clientSecret: 'ovsec_server', fetch: platform.fetch }) });++ const file = await createMediaClient(client, { app: PRJ }).upload('hello', { filename: 'a.txt' });++ assert.ok(platform.state.files.has(`${PRJ}|${file.key}`));++ await assert.rejects(createMediaClient(client, { app: 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ' }).upload('x'), (e) => e.status === 404 || e.code === 'capability.namespace_denied');++++ const sandboxApp = platform.addApp({ project: PRJ, env: 'sandbox', grants: ['media.object.upload'] });++ const sandboxClient = createClient({ fetch: platform.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: sandboxApp.id, clientSecret: sandboxApp.secret, fetch: platform.fetch }) });++ await assert.rejects(createMediaClient(sandboxClient, { app: PRJ }).upload('x'), { code: 'token.sandbox_refused', status: 401 });++ const opted = createMockPlatform({ acceptSandbox: ['media.object.upload'], apps: { [PUBLIC_APP]: { project: PRJ, secret: 's', type: 'confidential', grants: ['media.object.upload'] } } });++ const optedClient = createClient({ fetch: opted.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 's', fetch: opted.fetch }) });++ assert.ok((await createMediaClient(optedClient, { app: PRJ }).upload('x')).key);++ const limited = createMockPlatform({ sandboxAudiences: ['openvibe.tools'], apps: { [PUBLIC_APP]: { project: PRJ, secret: 's', type: 'confidential', grants: ['media.object.upload'] } } });++ await assert.rejects(auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 's', fetch: limited.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'invalid_target' });++ }],++]);@@@ -78,7 +78,7 @@ run([assert.deepEqual(problems, []);assert.deepEqual(blocked.sort(), ['./events', './identity', './testing']);const rel = [...scanned].map((f) => path.relative(ROOT, f));-- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js']) {++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);}for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++/**++ * browser/openvibe-sdk.mjs: up to date with the sources, self-contained (no import or require of++ * anything), free of server-only code, the same exports as the browser entry, and working.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const { pathToFileURL } = require('node:url');++const { execFileSync } = require('node:child_process');++const { run } = require('./helpers');++const { OUT, collect } = require('../scripts/browser-bundle');++const { createMockPlatform } = require('../src/testing');++++const ROOT = path.join(__dirname, '..');++const load = () => import(pathToFileURL(OUT).href);++++run([++ ['browser/openvibe-sdk.mjs is up to date', async () => {++ execFileSync(process.execPath, [path.join(ROOT, 'scripts/browser-bundle.js'), '--check'], { stdio: 'pipe' });++ }],++++ ['it is one self-contained module with no server-only code', async () => {++ const src = fs.readFileSync(OUT, 'utf8');++ assert.ok(!/\brequire\s*\(/.test(src), 'no require() left');++ assert.ok(!/^\s*import\s/m.test(src) && !/\bimport\s*\(/.test(src), 'imports nothing');++ for (const [re, why] of [[/client_secret/, 'client_secret'], [/process\.env/, 'process.env'], [/['"]node:/, 'node: module'], [/function (?:createServiceTokenClient|verifyUserToken|verifyAppToken|exchangeCode|createEventsClient|createIdentityClient|createMockPlatform)\b/, 'server-only API']]) {++ assert.ok(!re.test(src), `bundle contains ${why}`);++ }++ const ids = collect().map((m) => m.id);++ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);++ }],++++ ['exports match the browser entry', async () => {++ const esm = await load();++ const cjs = require('../browser.js');++ assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());++ assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());++ for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'projects']) {++ assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);++ }++ assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');++ assert.equal(esm.SDK_VERSION, require('../package.json').version);++ }],++++ ['it works: PKCE, an app sign-in URL, and a registry read through its own client', async () => {++ const sdk = await load();++ assert.equal(await sdk.auth.pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');++ const { url } = await sdk.auth.startAuthorization({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', redirectUri: 'http://localhost:3001/callback', audience: 'openvibe.media', scope: ['media.object.read'] });++ const u = new URL(url);++ assert.equal(u.searchParams.get('audience'), 'openvibe.media');++ assert.equal(u.searchParams.get('scope'), 'media.object.read');++ const platform = createMockPlatform();++ const registry = sdk.registry.createRegistryClient(sdk.createClient({ fetch: platform.fetch }));++ assert.equal((await registry.domain('openvibe.media')).service.id, 'media');++ const err = new sdk.OpenVibeError({ code: 'x.y' });++ assert.ok(sdk.isOpenVibeError(err));++ }],++]);@@@ -216,4 +216,43 @@ run([for await (const x of paginate(async (c) => ({ items: [c], next: c < 3 ? c + 1 : null }), { cursor: 1 })) cursorPages.push(x);assert.deepEqual(cursorPages, [1, 2, 3]);}],++++ ["responseType 'response': the raw Response for streams and downloads; errors still throw", async () => {++ let busy = 0;++ const srv = await stubServer((req, res) => {++ if (req.url === '/busy' && ++busy === 1) return problem(res, 503, 'service.unavailable', 'busy');++ if (req.url === '/gone') return problem(res, 404, 'thing.not_found', 'no such thing');++ if (req.url === '/stream') {++ res.writeHead(200, { 'Content-Type': 'text/plain' });++ res.write('part one;');++ return setTimeout(() => res.end('part two'), 30);++ }++ if (req.url === '/forever') { res.writeHead(200, { 'Content-Type': 'text/plain' }); return res.write('start;'); }++ return send(res, 200, Buffer.from([1, 2, 3]), { 'Content-Type': 'application/octet-stream' });++ });++ const client = createClient({ baseUrls: { x: srv.url }, ...fast });++ const out = await client.request({ service: 'x', path: '/file', responseType: 'response' });++ assert.ok(out.data instanceof Response);++ assert.equal(out.response, out.data);++ assert.equal(out.status, 200);++ assert.ok(!out.data.bodyUsed, 'body unread');++ assert.deepEqual([...new Uint8Array(await out.data.arrayBuffer())], [1, 2, 3]);++++ // The per-attempt timeout covers the headers only; the body may take longer.++ const slow = await client.json({ service: 'x', path: '/stream', responseType: 'response', timeoutMs: 15 });++ assert.equal(await slow.text(), 'part one;part two');++++ const retried = await client.request({ service: 'x', path: '/busy', responseType: 'response' });++ assert.equal(retried.attempts, 2, 'a 503 is retried before any body is handed out');++ await assert.rejects(client.request({ service: 'x', path: '/gone', responseType: 'response' }), (e) => e.code === 'thing.not_found' && e.status === 404, 'error bodies are read into OpenVibeError');++++ // The caller's signal still cancels the body after the call returned.++ const ctrl = new AbortController();++ const endless = await client.json({ service: 'x', path: '/forever', responseType: 'response', signal: ctrl.signal });++ const reader = endless.body.getReader();++ assert.equal(new TextDecoder().decode((await reader.read()).value), 'start;');++ setTimeout(() => ctrl.abort(), 10);++ await assert.rejects(reader.read());++ await srv.close();++ }],]);@@@ -27,7 +27,7 @@ run([['every subpath resolves by package name for require and import', async () => {const pkg = require('../package.json');for (const sub of Object.keys(pkg.exports)) {-- if (sub === './package.json') continue;++ if (sub === './package.json' || sub.endsWith('.mjs')) continue; // the browser bundle: test/bundle.test.jsconst spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;const cjs = require(spec);const esm = await import(spec);@@@ -2,10 +2,11 @@/** Events: publish fills the envelope, pull cursor + gaps, subscriptions, webhook signatures. */const assert = require('node:assert/strict');const crypto = require('node:crypto');--const { stubServer, send, run } = require('./helpers');++const { stubServer, send, run, waitFor } = require('./helpers');const { createClient, parseTraceparent } = require('../src/core');const { createServiceTokenClient } = require('../src/auth');--const { createEventsClient, verifyDelivery, signDelivery, parseDelivery } = require('../src/events');++const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox } = require('../src/events');++const { subscribe } = require('../src/realtime');const { createMockPlatform } = require('../src/testing');const actor = { type: 'service', id: 'live' };@@@ -130,4 +131,99 @@ run([assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7, subscriptionId: 'sub_1', attempt: 2 });assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);}],++++ ['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {++ const { platform, events } = setup();++ for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });++ const log = [];++ let saved = 0;++ const consume = async (failAt) => {++ for await (const { seq } of events.iterate({ topic: 'media.vod.*', afterSeq: saved, limit: 3, onPage: (p) => { log.push(`page ${p.next_after_seq}`); saved = p.next_after_seq; } })) {++ if (seq === failAt) throw new Error(`crash at ${seq}`);++ log.push(`item ${seq}`);++ }++ };++ await assert.rejects(consume(2), /crash at 2/);++ assert.deepEqual(log, ['item 1']);++ assert.equal(saved, 0, 'a crash inside the first page saves nothing');++ log.length = 0;++ await assert.rejects(consume(5), /crash at 5/);++ assert.deepEqual(log, ['item 1', 'item 2', 'item 4', 'page 4'], 'page 1 (seq 1-4; 3 did not match) was saved after its last item; page 2 never was');++ assert.equal(saved, 4);++ log.length = 0;++ await consume(null);++ assert.deepEqual(log, ['item 5', 'item 6', 'page 6'], 'resumed from the saved cursor: nothing skipped');++ let pages = 0;++ for await (const e of events.iterate({ topic: 'media.vod.*', limit: 2, onPage: () => { pages++; } })) { void e; break; }++ assert.equal(pages, 0, 'breaking out before the page is done: no onPage');++ }],++++ ['mock Events retention: pull and realtime report the pruned range as a gap', async () => {++ const { platform, events } = setup();++ for (let i = 1; i <= 5; i++) platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) }, visibility: 'public' });++ assert.equal(platform.pruneEvents(3), 3);++ const page = await events.pull({ topic: 'media.vod.*', afterSeq: 1 });++ assert.deepEqual(page.gap, { from_seq: 2, to_seq: 3 });++ assert.deepEqual(page.events.map((e) => e.seq), [4, 5]);++ assert.equal((await events.pull({ topic: 'media.vod.*', afterSeq: 3 })).gap, undefined);++ const gaps = [];++ const seqs = [];++ for await (const e of events.iterate({ topic: 'media.vod.*', onGap: (g) => gaps.push(g) })) seqs.push(e.seq);++ assert.deepEqual(gaps, [{ from_seq: 1, to_seq: 3 }]);++ assert.deepEqual(seqs, [4, 5]);++ const rtGaps = [];++ const got = [];++ const sub = subscribe('media.vod.*', (ev, { seq }) => got.push(seq), { client: createClient({ fetch: platform.fetch }), fetch: platform.fetch, transport: 'fetch', lastEventId: 1, onGap: (g) => rtGaps.push(g) });++ await waitFor(() => got.length === 2);++ sub.close();++ assert.deepEqual(rtGaps, [{ reason: 'retention', from_seq: 2, to_seq: 3, latest_seq: 5 }]);++ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '6' } });++ assert.equal((await events.pull({ afterSeq: 5 })).events[0].seq, 6, 'seqs keep counting after a prune');++ }],++++ ['deliverEvents: signed deliveries to a local endpoint, retried in order, dead after max attempts', async () => {++ const { platform, events } = setup();++ const Database = require('better-sqlite3');++ const inbox = createInbox(new Database(':memory:'));++ inbox.ensureSchema();++ let failNext = 1;++ let secret;++ const handled = [];++ const srv = await stubServer((req, res, body) => {++ const d = parseDelivery(body, req.headers, secret);++ if (!d) return send(res, 401, { error: 'bad signature' });++ if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }++ const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));++ return send(res, 200, { duplicate: r.duplicate });++ });++ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '0' } }); // before the subscription: not delivered++ const sub = await events.subscribe({ topicPattern: 'media.vod.*', endpoint: `${srv.url}/hook`, retryPolicy: { max_attempts: 3 } });++ secret = sub.secret;++ for (let i = 1; i <= 3; i++) platform.publishEvent({ event_type: i === 2 ? 'live.x.y' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });++++ const first = await platform.deliverEvents();++ assert.deepEqual([first.delivered, first.failed, first.dead], [0, 1, 0], 'the first attempt failed: the subscription waits');++ const second = await platform.deliverEvents();++ assert.deepEqual([second.delivered, second.failed], [2, 0]);++ assert.deepEqual(handled, [[2, 2, 'media.vod.ready'], [4, 1, 'media.vod.ready']], 'in seq order, attempt counted, non-matching skipped');++ const req = srv.requests.at(-1);++ assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);++ assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);++ assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);++ assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');++++ failNext = 99;++ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '9' } });++ const outcomes = [];++ for (let i = 0; i < 3; i++) outcomes.push((await platform.deliverEvents()).attempts.map((a) => a.outcome).join());++ assert.deepEqual(outcomes, ['retry', 'retry', 'dead']);++ assert.equal(platform.stats.deliveries.filter((a) => a.outcome === 'dead').length, 1);++++ failNext = 0;++ const worker = platform.startDeliveries({ intervalMs: 5 });++ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '10' } });++ await waitFor(() => handled.length === 3);++ await worker.stop();++ await srv.close();++ }],]);nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++/**++ * openvibe-sdk/jobs against the mock platform's Tools jobs: submit once (Idempotency-Key), follow++ * events across dropped streams and a restart (Last-Event-ID), 204 when finished, cancel, result++ * files as raw Responses, owner scoping, failures, and sandbox refusal.++ */++const assert = require('node:assert/strict');++const { run, stubServer, send } = require('./helpers');++const { createClient } = require('../src/core');++const { createServiceTokenClient } = require('../src/auth');++const { createJobsClient, isTerminal } = require('../src/jobs');++const { createMockPlatform } = require('../src/testing');++++const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';++const OTHER = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TS';++++function setup(jobs = {}, extra = {}) {++ const platform = createMockPlatform({++ jobs: { stepMs: 5, ...jobs },++ apps: {++ [APP]: { env: 'production', secret: 'a', grants: ['tools.job.create', 'tools.job.read', 'tools.job.cancel'] },++ [OTHER]: { env: 'production', secret: 'b', grants: ['tools.job.create', 'tools.job.read'] },++ },++ ...extra,++ });++ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retryDelayMs: 5, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });++ return { platform, jobs: createJobsClient(clientFor(APP, 'a')), other: createJobsClient(clientFor(OTHER, 'b')) };++}++++run([++ ['submit (multipart) -> follow events -> succeeded -> download the result file', async () => {++ const { jobs, platform } = setup({++ handlers: {++ 'img.process': async ({ input, files, progress }) => {++ await progress(25, 'decoding');++ await progress(75, 'encoding');++ return { data: { tool: input.tool }, files: [{ name: files[0].name.replace(/\.\w+$/, '.webp'), mime: 'image/webp', bytes: Buffer.concat([Buffer.from('webp:'), files[0].bytes]) }] };++ },++ },++ });++ const { job, replayed, idempotencyKey } = await jobs.submit({ type: 'img.process', input: { tool: 'convert' }, files: [{ name: 'a.png', data: Buffer.from('PNG') }] });++ assert.match(job.id, /^job_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.equal(job.state, 'queued');++ assert.equal(replayed, false);++ assert.match(idempotencyKey, /^idem_/);++ const call = platform.stats.requests.find((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs'));++ assert.equal(call.url, 'https://openvibe.tools/api/v1/jobs', 'origin from discovery');++ assert.equal(call.headers['idempotency-key'], idempotencyKey);++ assert.match(call.headers['content-type'], /^multipart\/form-data/);++++ const seen = [];++ const done = await jobs.wait(job.id, { onEvent: (e) => seen.push([e.id, e.event, e.job.progress.percent]) });++ assert.deepEqual(seen.map((s) => s[1]), ['job.queued', 'job.running', 'job.progress', 'job.progress', 'job.succeeded']);++ assert.deepEqual(seen.map((s) => s[0]), [1, 2, 3, 4, 5]);++ assert.equal(done.state, 'succeeded');++ assert.ok(isTerminal(done));++ assert.deepEqual(done.result.data, { tool: 'convert' });++ const res = await jobs.file(job.id, 0);++ assert.ok(res instanceof Response);++ assert.equal(res.headers.get('content-type'), 'image/webp');++ assert.equal(Buffer.from(await res.arrayBuffer()).toString(), 'webp:PNG');++ assert.equal([...(await (async () => { const out = []; for await (const e of jobs.events(job.id, { lastEventId: 5 })) out.push(e); return out; })())].length, 0, '204: finished, nothing newer');++ }],++++ ['the same Idempotency-Key gets the same job; a different request under it is a conflict', async () => {++ const { jobs, platform } = setup();++ const a = await jobs.submit({ type: 'doc.render', input: { n: 1 }, idempotencyKey: 'idem_example_0001' });++ const b = await jobs.submit({ type: 'doc.render', input: { n: 1 }, idempotencyKey: 'idem_example_0001' });++ assert.equal(b.job.id, a.job.id);++ assert.equal(b.replayed, true);++ assert.equal(platform.state.jobs.size, 1);++ await assert.rejects(jobs.submit({ type: 'doc.render', input: { n: 2 }, idempotencyKey: 'idem_example_0001' }), { status: 409, code: 'tools.job.idempotency_conflict' });++ const json = platform.stats.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs'))[0];++ assert.match(json.headers['content-type'], /^application\/json/, 'no files: a JSON body');++ await assert.rejects(jobs.submit({ input: {} }), TypeError);++ }],++++ ['events reattach after dropped streams and after a restart, each event once and in order', async () => {++ let release;++ const gate = new Promise((r) => { release = r; });++ const { jobs, platform } = setup({++ handlers: {++ slow: async ({ progress }) => {++ await progress(10, 'a');++ await gate;++ await progress(90, 'b');++ return { data: { ok: true } };++ },++ },++ });++ const { job } = await jobs.submit({ type: 'slow' });++ const first = [];++ for await (const e of jobs.events(job.id, { reconnectDelayMs: 5 })) {++ first.push(e.id);++ if (e.event === 'job.progress') break; // "crash" after saving id 3++ }++ assert.deepEqual(first, [1, 2, 3]);++ const second = [];++ const following = (async () => {++ for await (const e of jobs.events(job.id, { lastEventId: first.at(-1), reconnectDelayMs: 5 })) {++ second.push([e.id, e.event]);++ if (second.length === 1) platform.dropJobStreams(); // the connection drops mid-job++ }++ })();++ setTimeout(release, 20);++ await following;++ assert.deepEqual(second.map((s) => s[0]), [4, 5]);++ assert.deepEqual(second.map((s) => s[1]), ['job.progress', 'job.succeeded']);++ const reconnects = platform.stats.requests.filter((r) => r.url.endsWith('/events')).map((r) => r.headers['last-event-id']);++ assert.ok(reconnects.includes('3') && reconnects.includes('4'), `resumed with Last-Event-ID (${reconnects})`);++ }],++++ ['cancel: queued -> cancelled; finished -> 409; failures carry the error', async () => {++ const { jobs } = setup({ stepMs: 30, handlers: { boom: async () => { throw Object.assign(new Error('bad input'), { code: 'tools.job.invalid_input' }); }, ok: async () => ({}) } });++ const { job } = await jobs.submit({ type: 'ok' });++ const cancelled = await jobs.cancel(job.id);++ assert.equal(cancelled.state, 'cancelled');++ const failed = await jobs.submit({ type: 'boom' });++ const out = await jobs.wait(failed.job.id);++ assert.equal(out.state, 'failed');++ assert.deepEqual(out.error, { code: 'tools.job.invalid_input', detail: 'bad input' });++ await assert.rejects(jobs.cancel(failed.job.id), { status: 409, code: 'tools.job.already_finished' });++ await assert.rejects(jobs.file(failed.job.id, 0), { status: 409, code: 'tools.job.not_ready' });++ await assert.rejects(jobs.submit({ type: 'nope' }), { status: 400, code: 'tools.job.unknown_type' });++ }],++++ ['cancel while running: 202 cancel requested, then cancelled', async () => {++ const { jobs } = setup({ handlers: { long: async ({ progress }) => { for (let i = 1; i <= 5; i++) await progress(i * 20); return {}; } } });++ const { job } = await jobs.submit({ type: 'long' });++ const events = [];++ for await (const e of jobs.events(job.id)) {++ events.push(e.event);++ if (e.event === 'job.running') {++ const c = await jobs.cancel(job.id);++ assert.equal(c.cancel_requested, true);++ assert.equal(c.state, 'running');++ }++ }++ assert.ok(events.includes('job.cancel_requested'));++ assert.equal(events.at(-1), 'job.cancelled');++ assert.equal((await jobs.get(job.id)).state, 'cancelled');++ }],++++ ['jobs are owner-scoped; capabilities and sandbox tokens are checked', async () => {++ const { jobs, other } = setup();++ const { job } = await jobs.submit({ type: 'x' });++ assert.equal(await other.get(job.id), null, 'another app gets 404, as for a job that does not exist');++ await assert.rejects(other.cancel(job.id), { status: 403, code: 'capability.denied' });++ assert.equal((await jobs.get(job.id)).id, job.id);++++ const SANDBOX = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TT';++ const make = (extra) => {++ const p = createMockPlatform({ jobs: true, apps: { [SANDBOX]: { env: 'sandbox', secret: 's', grants: ['tools.job.create'] } }, ...extra });++ return createJobsClient(createClient({ fetch: p.fetch, tokenProvider: createServiceTokenClient({ clientId: SANDBOX, clientSecret: 's', fetch: p.fetch }) }));++ };++ await assert.rejects(make().submit({ type: 'x' }), { status: 401, code: 'token.sandbox_refused' });++ assert.equal((await make({ acceptSandbox: ['openvibe.tools'] }).submit({ type: 'x' })).job.state, 'queued');++ await assert.rejects(createJobsClient(createClient({ fetch: createMockPlatform().fetch, token: 't' })).submit({ type: 'x' }), { status: 404 }, 'jobs are opt-in on the mock');++ }],++++ ['events: a 5xx or a dropped connection reconnects, a 4xx is thrown, the give-up limit holds', async () => {++ let calls = 0;++ const srv = await stubServer((req, res) => {++ calls++;++ if (calls === 1) return send(res, 503, { code: 'x.down' });++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });++ if (calls === 2) { res.write(`id: 1\nevent: job.running\ndata: ${JSON.stringify({ id: 'job_1', state: 'running' })}\n\n`); return res.end(); }++ res.write(`id: 1\nevent: job.running\ndata: {}\n\nid: 2\nevent: job.succeeded\ndata: ${JSON.stringify({ id: 'job_1', state: 'succeeded' })}\n\n`);++ return res.end();++ });++ const jobs = createJobsClient(createClient({ baseUrls: { tools: srv.url }, token: 't' }));++ const got = [];++ for await (const e of jobs.events('job_1', { reconnectDelayMs: 1 })) got.push(e.id);++ assert.deepEqual(got, [1, 2], 'the replayed id 1 is not yielded twice');++ assert.equal(srv.requests[2].headers['last-event-id'], '1');++ await srv.close();++++ const gone = await stubServer((req, res) => send(res, 404, { code: 'tools.job.not_found', status: 404 }));++ await assert.rejects((async () => { for await (const e of createJobsClient(createClient({ baseUrls: { tools: gone.url }, token: 't' })).events('job_x')) void e; })(), { code: 'tools.job.not_found' });++ await gone.close();++ const down = await stubServer((req, res) => send(res, 502, 'bad gateway'));++ await assert.rejects((async () => { for await (const e of createJobsClient(createClient({ baseUrls: { tools: down.url }, token: 't' })).events('job_x', { maxReconnects: 2, reconnectDelayMs: 1 })) void e; })(), { code: 'sdk.network_error' });++ assert.equal(down.requests.length, 3);++ await down.close();++ }],++]);@@@ -22,7 +22,7 @@ run([const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });const info = JSON.parse(out)[0];const files = info.files.map((f) => f.path);-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'esm/core.mjs', 'types/core.d.ts', 'types/contracts.d.ts']) {++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {assert.ok(files.includes(must), `tarball contains ${must}`);}assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');@@@ -32,7 +32,7 @@ run([fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'sdk-consumer', version: '1.0.0', private: true }));execFileSync(npm, ['install', '--offline', '--no-package-lock', path.join(tmp, info.filename)], { cwd: app, env, stdio: 'pipe' });-- const subpaths = Object.keys(pkg.exports).filter((s) => s !== './package.json').map((s) => (s === '.' ? 'openvibe-sdk' : `openvibe-sdk/${s.slice(2)}`));++ const subpaths = Object.keys(pkg.exports).filter((s) => s !== './package.json' && !s.endsWith('.mjs')).map((s) => (s === '.' ? 'openvibe-sdk' : `openvibe-sdk/${s.slice(2)}`));const script = `const assert = require('node:assert/strict');const specs = ${JSON.stringify(subpaths)};@@@ -52,10 +52,16 @@ run([const f = await createMediaClient(client, { app: 'app' }).upload('hi', { filename: 'a.txt' });assert.match(f.key, /-a\\.txt$/);assert.equal(require('openvibe-sdk/package.json').version, ${JSON.stringify(pkg.version)});-- console.log('consumer ok: ' + specs.length + ' subpaths');++ assert.equal(require('openvibe-sdk/core').SDK_VERSION, ${JSON.stringify(pkg.version)});++ const bundle = await import('openvibe-sdk/browser/openvibe-sdk.mjs');++ assert.equal(bundle.SDK_VERSION, ${JSON.stringify(pkg.version)});++ assert.equal(typeof bundle.jobs.createJobsClient, 'function');++ assert.equal(typeof require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'string');++ console.log('consumer ok: ' + specs.length + ' subpaths + the browser bundle');})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });-- assert.match(res, /consumer ok: 12 subpaths/);++ assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));++ assert.equal(subpaths.length, 14);assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * openvibe-sdk/projects against the mock /api/v1/projects: the walkthrough (project -> app with a++ * shown-once secret -> grant -> app token), members and roles, credentials rotate/revoke, grants++ * approve/deny/revoke, allowance, audit; plus the request shapes and no-retry rules on a stub.++ */++const assert = require('node:assert/strict');++const { run, stubServer, send } = require('./helpers');++const { createClient } = require('../src/core');++const { createServiceTokenClient } = require('../src/auth');++const { createProjectsClient } = require('../src/projects');++const { createMockPlatform, DEFAULT_APP_CATALOG } = require('../src/testing');++++function setup(opts) {++ const platform = createMockPlatform({ users: [{ username: 'owner' }, { username: 'dev' }, { username: 'staff', role: 'admin' }, { username: 'stranger' }], ...opts });++ const [owner, dev, staff, stranger] = [...platform.state.users.values()];++ const as = (u) => createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signUserToken(u) }));++ return { platform, owner, dev, staff, stranger, as };++}++++run([++ ['walkthrough: project -> confidential app (secret once) -> grant -> a working app token', async () => {++ const { platform, as, owner } = setup();++ const projects = as(owner);++ assert.deepEqual((await projects.catalog()).map((c) => c.id).sort(), [...DEFAULT_APP_CATALOG].sort());++ const prj = await projects.create({ name: 'My first OpenVibe app' });++ assert.match(prj.id, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.equal(prj.role, 'owner');++ assert.deepEqual(prj.environments, ['sandbox'], 'a new project is sandbox only');++ assert.equal((await projects.list()).length, 1);++++ const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: ['http://localhost:3009/callback'] });++ assert.match(app.id, /^app_/);++ assert.equal(app.client_id, app.id);++ assert.equal(app.credential.shown_once, true);++ const secret = app.credential.client_secret;++ assert.match(secret, /^ovsec_/);++ assert.equal((await projects.apps.get(prj.id, app.id)).credential, undefined, 'never shown again');++ assert.ok(!JSON.stringify(await projects.credentials.list(prj.id, app.id)).includes(secret));++++ const g = await projects.grants.request(prj.id, app.id, 'media.object.upload');++ assert.equal(g.status, 'approved', 'owner + inside the allowance: approved at once');++ assert.equal(g.audience, 'openvibe.media');++ const tokens = createServiceTokenClient({ clientId: app.id, clientSecret: secret, fetch: platform.fetch });++ const info = await tokens.getTokenInfo({ audience: 'openvibe.media' });++ assert.deepEqual(info.scope, ['media.object.upload']);++ assert.equal(info.unverifiedClaims.project_id, prj.id);++ assert.equal(info.unverifiedClaims.env, 'sandbox');++++ // Rotate: the new secret works, the old one keeps working during the overlap; revoke ends it.++ const rotated = await projects.credentials.rotate(prj.id, app.id, { overlapSeconds: 3600 });++ assert.notEqual(rotated.credential.client_secret, secret);++ assert.equal(rotated.previous.length, 1);++ assert.ok(await createServiceTokenClient({ clientId: app.id, clientSecret: rotated.credential.client_secret, fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }));++ const creds = await projects.credentials.list(prj.id, app.id);++ assert.deepEqual(creds.map((c) => c.state).sort(), ['active', 'expiring']);++ const old = creds.find((c) => c.state === 'expiring');++ assert.equal((await projects.credentials.revoke(prj.id, app.id, old.id)).state, 'revoked');++ await assert.rejects(createServiceTokenClient({ clientId: app.id, clientSecret: secret, fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'invalid_client' });++++ // Revoking the grant takes the capability out of new tokens; revoking the app stops tokens.++ await projects.grants.revoke(prj.id, app.id, 'media.object.upload');++ const fresh = createServiceTokenClient({ clientId: app.id, clientSecret: rotated.credential.client_secret, fetch: platform.fetch });++ await assert.rejects(fresh.getToken({ audience: 'openvibe.media' }), { code: 'invalid_scope' });++ assert.ok((await projects.apps.revoke(prj.id, app.id)).revoked_at);++ await assert.rejects(fresh.getToken({ audience: 'openvibe.media' }), { code: 'invalid_client' });++ const actions = [];++ for await (const e of projects.iterateAudit(prj.id, { pageSize: 2 })) actions.push(e.action);++ assert.deepEqual(actions.slice(0, 3), ['app.revoked', 'grant.revoked', 'credential.revoked'], 'newest first, across pages');++ assert.ok(actions.includes('project.created'));++ assert.ok(!JSON.stringify(await projects.audit(prj.id, { limit: 200 })).includes('ovsec_'), 'no secrets in the audit');++ }],++++ ['roles: non-members see 404, developers request, admins approve inside the allowance, staff set it', async () => {++ const { as, owner, dev, staff, stranger } = setup();++ const prj = await as(owner).create({ name: 'team' });++ assert.equal(await as(stranger).get(prj.id), null, 'non-members: 404, existence not disclosed');++ await as(owner).members.add(prj.id, { username: 'dev', role: 'developer' });++ assert.equal((await as(owner).members.list(prj.id)).length, 2);++ const app = await as(dev).apps.create(prj.id, { name: 'web', type: 'public', redirectUris: ['http://localhost:3001/callback'] });++ assert.equal(app.credential, undefined, 'public apps have no secret');++ await assert.rejects(as(dev).apps.create(prj.id, { name: 'prod', environment: 'production' }), { status: 403 });++ const req = await as(dev).grants.request(prj.id, app.id, 'tools.job.read');++ assert.equal(req.status, 'requested', 'a developer request waits');++ await assert.rejects(as(dev).grants.approve(prj.id, app.id, 'tools.job.read'), { status: 403 });++ await assert.rejects(as(dev).grants.request(prj.id, app.id, 'events.event.read'), { status: 403, code: 'grant.not_grantable' });++++ const shrunk = await as(staff).setAllowance(prj.id, ['media.object.read']);++ assert.deepEqual(shrunk.trimmed, [{ app_id: app.id, capability: 'tools.job.read', to: 'denied' }], 'shrinking the allowance denies pending requests outside it');++ await assert.rejects(as(owner).grants.approve(prj.id, app.id, 'tools.job.read'), { status: 403, code: 'grant.beyond_allowance' });++ await as(dev).grants.request(prj.id, app.id, 'media.object.read');++ assert.equal((await as(owner).grants.deny(prj.id, app.id, 'media.object.read')).status, 'denied');++ await as(dev).grants.request(prj.id, app.id, 'media.object.read');++ assert.equal((await as(owner).grants.approve(prj.id, app.id, 'media.object.read')).status, 'approved');++ assert.deepEqual((await as(dev).apps.get(prj.id, app.id)).grants, ['media.object.read']);++ await assert.rejects(as(owner).setAllowance(prj.id, []), { status: 403 }, 'only staff set the allowance');++ await as(staff).setEnvironmentPolicy(prj.id, 'sandbox+production');++ assert.deepEqual((await as(owner).get(prj.id)).environments, ['sandbox', 'production']);++ assert.equal((await as(owner).apps.create(prj.id, { name: 'prod', environment: 'production' })).environment, 'production');++ assert.equal((await as(staff).list({ all: true })).length, 1);++++ await as(dev).members.remove(prj.id, dev.subject_id); // leave++ assert.equal(await as(dev).get(prj.id), null);++ await assert.rejects(as(owner).members.remove(prj.id, owner.subject_id), { code: 'member.owner' });++ await as(owner).archive(prj.id);++ assert.ok((await as(owner).get(prj.id)).archived_at);++ }],++++ ['only user tokens: service and app tokens get 401', async () => {++ const { platform, owner } = setup();++ const svc = createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signServiceToken('live', { audience: 'openvibe.network' }) }));++ await assert.rejects(svc.list(), { status: 401, code: 'auth.invalid' });++ await assert.rejects(createProjectsClient(createClient({ fetch: platform.fetch })).list(), { status: 401, code: 'auth.required' });++ assert.ok(Array.isArray(await createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signUserToken(owner) })).list()));++ }],++++ ['request shapes, and calls that mint secrets or are not deduped are never retried', async () => {++ const srv = await stubServer((req, res) => send(res, 503, { code: 'network.unavailable', status: 503 }));++ const projects = createProjectsClient(createClient({ baseUrls: { network: srv.url }, token: 'user-jwt', retryDelayMs: 1 }));++ await assert.rejects(projects.apps.create('prj_1', { name: 'a', redirectUris: ['https://a.example/cb'] }), { status: 503 });++ await assert.rejects(projects.credentials.rotate('prj_1', 'app_1', { overlapSeconds: 0 }), { status: 503 });++ await assert.rejects(projects.create({ name: 'x' }), { status: 503 });++ assert.equal(srv.requests.length, 3, 'one attempt each');++ const [create, rotate] = srv.requests;++ assert.equal(create.url, '/api/v1/projects/prj_1/apps');++ assert.deepEqual(JSON.parse(create.body), { name: 'a', environment: 'sandbox', type: 'confidential', redirect_uris: ['https://a.example/cb'] });++ assert.equal(create.headers['idempotency-key'], undefined);++ assert.equal(rotate.url, '/api/v1/projects/prj_1/apps/app_1/credentials/rotate');++ assert.deepEqual(JSON.parse(rotate.body), { overlap_seconds: 0 });++ assert.equal(create.headers.authorization, 'Bearer user-jwt');++ await assert.rejects(projects.list(), { status: 503 });++ assert.equal(srv.requests.length, 3 + 3, 'reads are retried (2 retries)');++ await srv.close();++ }],++]);@@@ -2,7 +2,7 @@/** Realtime SSE: resume from Last-Event-ID after a drop, dedupe, gap callback, transports. */const assert = require('node:assert/strict');const { stubServer, run, waitFor } = require('./helpers');--const { subscribe, createRealtimeClient } = require('../src/realtime');++const { subscribe, createRealtimeClient, parseSSE } = require('../src/realtime');const { createClient } = require('../src/core');const { createMockPlatform } = require('../src/testing');@@@ -115,4 +115,34 @@ run([sub.close();await sub.done;}],++++ ['parseSSE: WHATWG fields, comments, multi-line data, CRLF split across chunks, retry, async iterables', async () => {++ const chunks = [': hello\r', '\nretry: 1500\r\nid: 7\r\nevent: job.progress\r\ndata: {"a":', '1}\r\n\r', '\ndata: line one\ndata: line two\n\nevent: ping\n\nid: 9\ndata:no-space\n\n'];++ const body = new ReadableStream({ start(c) { for (const x of chunks) c.enqueue(new TextEncoder().encode(x)); c.close(); } });++ const retries = [];++ const got = [];++ for await (const m of parseSSE(body, { onRetry: (ms) => retries.push(ms) })) got.push(m);++ assert.deepEqual(retries, [1500]);++ assert.deepEqual(got, [++ { event: 'job.progress', data: '{"a":1}', id: '7' },++ { event: 'message', data: 'line one\nline two', id: undefined },++ { event: 'ping', data: '', id: undefined },++ { event: 'message', data: 'no-space', id: '9' },++ ]);++ async function* strings() { yield 'data: a\n'; yield '\ndata: b\n\n'; }++ const fromIterable = [];++ for await (const m of parseSSE(strings())) fromIterable.push(m.data);++ assert.deepEqual(fromIterable, ['a', 'b']);++ await assert.rejects(parseSSE(null).next(), TypeError);++ }],++++ ['parseSSE: breaking out cancels the underlying stream', async () => {++ let cancelled = false;++ const body = new ReadableStream({++ pull(c) { c.enqueue(new TextEncoder().encode('data: x\n\n')); },++ cancel() { cancelled = true; },++ });++ for await (const m of parseSSE(body)) { assert.equal(m.data, 'x'); break; }++ assert.equal(cancelled, true);++ }],]);@@@ -7,6 +7,7 @@const assert = require('node:assert/strict');const fs = require('node:fs');const path = require('node:path');++const { pathToFileURL } = require('node:url');const { run } = require('./helpers');const ROOT = path.join(__dirname, '..');@@@ -33,7 +34,9 @@ run([for (const [sub, entry] of Object.entries(pkg.exports)) {if (sub === './package.json') continue;const names = declared(path.join(ROOT, entry.types));-- const runtime = Object.keys(require(path.join(ROOT, entry.default)));++ const runtime = entry.default.endsWith('.mjs')++ ? Object.keys(await import(pathToFileURL(path.join(ROOT, entry.default)).href)).filter((k) => k !== 'default')++ : Object.keys(require(path.join(ROOT, entry.default)));for (const n of runtime) if (!names.has(n)) missing.push(`${sub}: ${n}`);const browser = entry.browser ? Object.keys(require(path.join(ROOT, entry.browser))) : [];for (const n of browser) if (!names.has(n)) missing.push(`${sub} (browser): ${n}`);nnew file mode 100644@@@ -0,0 +1,25 @@++'use strict';++/** SDK_VERSION, package.json, the lockfile, the CHANGELOG and the bundle agree. */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const { run } = require('./helpers');++++const ROOT = path.join(__dirname, '..');++const pkg = require('../package.json');++++run([++ ['core.SDK_VERSION is the package version', async () => {++ assert.equal(require('../src/core').SDK_VERSION, pkg.version);++ assert.equal(require('..').SDK_VERSION, pkg.version);++ assert.equal(require('../browser.js').SDK_VERSION, pkg.version);++ }],++++ ['the lockfile, the CHANGELOG and the bundle name the same version', async () => {++ const lock = require('../package-lock.json');++ assert.equal(lock.version, pkg.version);++ assert.equal(lock.packages[''].version, pkg.version);++ assert.match(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8'), new RegExp(`^## ${pkg.version.replace(/\./g, '\\.')} \\(`, 'm'));++ assert.match(fs.readFileSync(path.join(ROOT, 'browser/openvibe-sdk.mjs'), 'utf8'), new RegExp(`openvibe-sdk ${pkg.version.replace(/\./g, '\\.')},`));++ }],++]);@@@ -8,14 +8,21 @@ export interface AuthorizeOptions {authorizeUrl?: string;clientId: string;redirectUri: string;++ /** Default 'profile theme' only when no audience is given. Developer apps: capability ids. */scope?: string | string[];++ /** Developer apps: the audience (openvibe.<service>) the code is for; exchange with the same one. */++ audience?: string;state?: string;codeChallenge?: string;codeChallengeMethod?: 'S256';-- /** 'none' = silent SSO; the callback carries error=login_required when there is no session. */++ /** 'none' = silent SSO; the callback carries error=login_required when there is no session. Refused for developer apps (interaction_required). */prompt?: 'none';}export declare function buildAuthorizeUrl(opts: AuthorizeOptions): string;export declare function startAuthorization(opts: Omit<AuthorizeOptions, 'codeChallenge' | 'codeChallengeMethod'> & { verifierLength?: number }): Promise<{ url: string; state: string; codeVerifier: string; codeChallenge: string }>;export declare function readCallback(location: string | URL, opts?: { expectedState?: string }): { code: string; state: string };export declare function base64url(bytes: Uint8Array): string;++/** Header and claims of a JWT decoded WITHOUT verification (display and diagnostics only), or null. */++export declare function decodeUnverified(token: string): { header: Record<string, unknown>; claims: Record<string, any> } | null;++/** The claims of a JWT decoded WITHOUT verification, or null. Never authorize with them. */++export declare function unverifiedClaims(token: string): Record<string, any> | null;@@@ -1,4 +1,4 @@--import type { TokenContext, TokenProvider, FetchLike } from './core';++import type { TokenContext, TokenProvider, FetchLike, ServiceTokenClaims } from './core';export * from './auth-browser';export interface ServiceTokenClientOptions {@@@ -15,8 +15,19 @@ export interface ServiceTokenClientOptions {refreshSkewMs?: number;now?: () => number;}++export interface TokenInfo {++ accessToken: string;++ tokenType: 'Bearer';++ audience: string;++ /** Capability ids the token endpoint granted. */++ scope: string[];++ expiresAt: string | null;++ /** The JWT payload decoded WITHOUT verification: for display and diagnostics only. */++ unverifiedClaims: Record<string, any> | null;++}export interface ServiceTokenClient extends TokenProvider {getToken(ctx?: TokenContext): Promise<string>;++ getTokenInfo(ctx?: TokenContext): Promise<TokenInfo>;authHeaders(ctx?: TokenContext): Promise<{ Authorization: string }>;invalidate(ctx?: TokenContext): void;readonly tokenUrl: string;@@@ -52,8 +63,34 @@ export interface VerifyUserTokenOptions {}export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;++/** A developer app's token (identity.service-token-claims@1 with actor_type app). */++export type AppTokenClaims = ServiceTokenClaims & {++ sub: `app:app_${string}`;++ actor_type: 'app';++ /** [project_id] */++ ns: string[];++ project_id: string;++ env: 'sandbox' | 'production';++ /** usr_… of the person who authorized the app (authorization-code tokens only). */++ on_behalf_of?: string;++};++export interface VerifyAppTokenOptions {++ jwks?: { keys?: object[]; public_key?: string } | string;++ publicKey?: string | object;++ issuer?: string;++ /** Required: the audience your service answers for (openvibe.<service>). */++ audience: string | string[];++ /** Accept env=sandbox tokens (default false: token.sandbox_refused). */++ acceptSandbox?: boolean;++ clockSkewSec?: number;++ now?: number;++ fetch?: FetchLike;++}++export declare function verifyAppToken(token: string, opts: VerifyAppTokenOptions): Promise<AppTokenClaims>;++export interface UserTokenResponse {access_token: string;++ /** Absent for developer-app tokens: sign in again when they expire. */refresh_token?: string;token_type: 'Bearer';expires_in: number;@@@ -61,5 +98,22 @@ export interface UserTokenResponse {user?: Record<string, unknown>;preferences?: Record<string, unknown>;}--export declare function exchangeCode(opts: { code: string; redirectUri: string; codeVerifier?: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;++export interface ExchangeCodeOptions {++ code: string;++ redirectUri: string;++ /** Required for public clients (no clientSecret) and for every developer app. */++ codeVerifier?: string;++ clientId: string;++ /** Confidential clients only; public apps send none. */++ clientSecret?: string;++ /** Developer apps (required for them): the audience the token is for. */++ audience?: string;++ /** Capability ids to narrow what the person authorized. */++ scope?: string | string[];++ network?: string;++ tokenUrl?: string;++ fetch?: FetchLike;++ timeoutMs?: number;++}++export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;nnew file mode 100644@@@ -0,0 +1,26 @@++/** browser/openvibe-sdk.mjs: one self-contained ES module with the browser-safe subpaths (same shape as the package's browser entry). */++import * as auth from './auth-browser';++import * as registry from './registry';++import * as modules from './modules';++import * as realtime from './realtime';++import * as media from './media';++import * as community from './community';++import * as jobs from './jobs';++import * as projects from './projects';++import * as core from './core';++++export * from './core';++export * as auth from './auth-browser';++export * as registry from './registry';++export * as modules from './modules';++export * as realtime from './realtime';++export * as media from './media';++export * as community from './community';++export * as jobs from './jobs';++export * as projects from './projects';++++declare const sdk: typeof core & {++ auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;++ media: typeof media; community: typeof community; jobs: typeof jobs; projects: typeof projects;++};++export default sdk;@@@ -1,4 +1,4 @@--/* Copied verbatim from openvibe-contracts v0.6.0 generated/typescript/index.d.ts (the SDK does not++/* Copied verbatim from openvibe-contracts v0.26.0 generated/typescript/index.d.ts (the SDK does not* require the package at runtime). test/types.test.js checks these against a Contracts checkout. *//* eslint-disable */@@@ -28,11 +28,13 @@ export type SubjectRef =id: string;};--/** [email protected] (owner: network) */++/** [email protected] (owner: network) *//**-- * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.++ * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.*/--export interface ServiceTokenClaims {++export type ServiceTokenClaims = {++ [k: string]: unknown | undefined;++} & {/*** Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().*/@@@ -54,8 +56,20 @@ export interface ServiceTokenClaims {iat: number;exp: number;jti: string;++ /**++ * Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens.++ */++ project_id?: string;++ /**++ * Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production.++ */++ env?: "sandbox" | "production";++ /**++ * The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens.++ */++ on_behalf_of?: string;[k: string]: unknown | undefined;--}++};/** [email protected] (owner: contracts) *//**@@@ -129,7 +143,7 @@ export interface Capability {version: string;owner: string;status: "planned" | "active" | "deprecated" | "retired";-- visibility: "public" | "first-party" | "internal";++ visibility: "public" | "partner" | "first-party" | "internal";description?: string;inputSchema?: string;outputSchema?: string;@@@ -74,6 +74,7 @@ export interface RequestOptions {traceparent?: string;requestId?: string;credentials?: RequestCredentials;++ /** 'response': a 2xx resolves with the raw fetch Response (body unread) as `data` and `response`. */responseType?: 'json' | 'text' | 'arrayBuffer' | 'response';}@@@ -85,6 +86,8 @@ export interface ClientResponse<T = any> {traceId: string;traceparent: string;attempts: number;++ /** Only with responseType 'response': the raw Response (same as data). */++ response?: Response;}export interface Discovery {@@@ -24,7 +24,8 @@ export interface EventsClient {publish(envelope: EventInput, opts?: { traceparent?: string }): Promise<PublishResult>;publish(envelopes: EventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;pull(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;-- iterate(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number; onGap?: (gap: Gap) => void | Promise<void>; onPage?: (page: EventsPage) => void | Promise<void> }): AsyncGenerator<StoredEvent, void, unknown>;++ /** onPage runs after every item of that page was yielded and handled: save page.next_after_seq there. */++ iterate(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number; maxPages?: number; onGap?: (gap: Gap) => void | Promise<void>; onPage?: (page: EventsPage) => void | Promise<void> }): AsyncGenerator<StoredEvent, void, unknown>;get(eventId: string): Promise<StoredEvent | null>;getCheckpoint(topic: string): Promise<{ consumer: string; topic: string; cursor: number; updated_at: string | null }>;setCheckpoint(topic: string, cursor: number): Promise<{ consumer: string; topic: string; cursor: number }>;@@@ -7,3 +7,5 @@ export * as events from './events';export * as realtime from './realtime';export * as media from './media';export * as community from './community';++export * as jobs from './jobs';++export * as projects from './projects';nnew file mode 100644@@@ -0,0 +1,48 @@++import type { OpenVibeClient } from './core';++++export type JobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';++export interface Job {++ id: string;++ object: 'tools.job';++ service: string;++ type: string;++ type_version: number;++ state: JobState;++ progress: { percent: number | null; message: string | null };++ attempts: number;++ max_attempts: number;++ cancel_requested?: boolean;++ created_at?: string | null;++ started_at?: string | null;++ finished_at?: string | null;++ expires_at?: string | null;++ result: { files: Array<{ name: string; mime: string; size: number; url: string; [k: string]: unknown }>; data: Record<string, unknown> } | null;++ error: { code: string; detail?: string; [k: string]: unknown } | null;++ retryable: boolean;++ links: { self: string; events: string; cancel: string | null };++ [field: string]: unknown;++}++export interface JobEvent { id: number | null; event: string; job: Job | null; }++export type JobFile = Blob | { name?: string; data: Blob | ArrayBuffer | ArrayBufferView | string; type?: string };++export interface SubmitJob {++ type: string;++ input?: Record<string, unknown>;++ files?: JobFile | JobFile[];++ /** Same key + same request = same job. Generated when omitted (returned). */++ idempotencyKey?: string;++ signal?: AbortSignal;++}++export interface JobEventsOptions { lastEventId?: number | string; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number; }++export interface JobsClient {++ submit(input: SubmitJob): Promise<{ job: Job; replayed: boolean; idempotencyKey: string }>;++ get(id: string, opts?: { signal?: AbortSignal }): Promise<Job | null>;++ cancel(id: string, opts?: { signal?: AbortSignal }): Promise<Job>;++ events(id: string, opts?: JobEventsOptions): AsyncGenerator<JobEvent, void, unknown>;++ wait(id: string, opts?: JobEventsOptions & { onEvent?: (e: JobEvent) => void | Promise<void> }): Promise<Job | null>;++ /** The raw Response of a result file (body unread). */++ file(id: string, n?: number, opts?: { signal?: AbortSignal; inline?: boolean }): Promise<Response>;++}++/** baseUrl: the satellite that runs the job type (e.g. https://img.openvibe.tools); default the registry's `tools` origin. */++export declare function createJobsClient(client: OpenVibeClient, opts?: { baseUrl?: string; service?: string; audience?: string }): JobsClient;++export declare function isTerminal(job: Pick<Job, 'state'> | null | undefined): boolean;++export declare const TERMINAL_STATES: JobState[];nnew file mode 100644@@@ -0,0 +1,86 @@++import type { OpenVibeClient } from './core';++++export type ProjectRole = 'owner' | 'admin' | 'developer' | 'viewer';++export type EnvironmentPolicy = 'sandbox' | 'sandbox+production';++export interface Project {++ id: string;++ name: string;++ owner: { type: 'user'; id: string } | null;++ role: ProjectRole | null;++ environment_policy: EnvironmentPolicy;++ environments: Array<'sandbox' | 'production'>;++ allowance: string[];++ created_at: string;++ archived_at: string | null;++ counts: { members: number; apps: number };++ [field: string]: unknown;++}++export interface ProjectMember { subject: { type: 'user'; id: string }; username: string | null; display_name: string | null; role: ProjectRole; [field: string]: unknown; }++/** The secret is present only in the response that created it. */++export interface ShownOnceCredential { id: string; client_secret: string; hint: string; shown_once: true; }++export interface DeveloperApp {++ id: string;++ subject: { type: 'app'; id: string };++ project_id: string;++ name: string;++ environment: 'sandbox' | 'production';++ client_id: string;++ client_type: 'confidential' | 'public';++ redirect_uris: string[];++ created_at: string;++ revoked_at: string | null;++ /** Approved capabilities. */++ grants: string[];++ credential?: ShownOnceCredential;++ [field: string]: unknown;++}++export interface Credential { id: string; hint: string; state: 'active' | 'expiring' | 'expired' | 'revoked'; created_at: string; expires_at: string | null; revoked_at: string | null; [field: string]: unknown; }++export interface Grant { app_id: string; capability: string; audience: string; status: 'requested' | 'approved' | 'denied' | 'revoked'; requested_by: string; requested_at: string; decided_by: string | null; decided_at: string | null; }++export interface Quota { capability: string; limit: number; window: 'minute' | 'hour' | 'day' | 'month' | 'total'; unit: string; enforced_by: string | null; updated_at?: string; }++export interface CatalogCapability { id: string; owner: string; audience: string; visibility: string; description: string; resourceConstraints?: string[]; quotaClass?: string; }++export interface AuditEntry { id: number; at: string; actor: string; action: string; target: string; detail: Record<string, unknown>; [field: string]: unknown; }++++export interface ProjectsClient {++ catalog(): Promise<CatalogCapability[]>;++ list(opts?: { all?: boolean }): Promise<Project[]>;++ create(input: { name: string }): Promise<Project>;++ get(project: string): Promise<Project | null>;++ update(project: string, input: { name: string }): Promise<Project>;++ archive(project: string): Promise<Project>;++ setAllowance(project: string, capabilities: string[]): Promise<{ allowance: string[]; trimmed: unknown[] }>;++ setEnvironmentPolicy(project: string, policy: EnvironmentPolicy): Promise<{ environment_policy: EnvironmentPolicy; environments: string[] }>;++ members: {++ list(project: string): Promise<ProjectMember[]>;++ add(project: string, input: { username?: string; subjectId?: string; role: Exclude<ProjectRole, 'owner'> }): Promise<ProjectMember>;++ update(project: string, subject: string, input: { role: Exclude<ProjectRole, 'owner'> }): Promise<ProjectMember>;++ remove(project: string, subject: string): Promise<null>;++ };++ apps: {++ list(project: string): Promise<DeveloperApp[]>;++ get(project: string, app: string): Promise<DeveloperApp | null>;++ create(project: string, input: { name: string; environment?: 'sandbox' | 'production'; type?: 'confidential' | 'public'; redirectUris?: string[] }): Promise<DeveloperApp>;++ update(project: string, app: string, input: { name?: string; redirectUris?: string[] }): Promise<DeveloperApp>;++ revoke(project: string, app: string): Promise<DeveloperApp>;++ };++ credentials: {++ list(project: string, app: string): Promise<Credential[]>;++ rotate(project: string, app: string, opts?: { overlapSeconds?: number }): Promise<{ credential: ShownOnceCredential; previous: Array<{ id: string; expires_at: string }> }>;++ revoke(project: string, app: string, credential: string): Promise<Credential>;++ };++ grants: {++ list(project: string, app: string): Promise<Grant[]>;++ request(project: string, app: string, capability: string): Promise<Grant>;++ approve(project: string, app: string, capability: string): Promise<Grant>;++ deny(project: string, app: string, capability: string): Promise<Grant>;++ revoke(project: string, app: string, capability: string): Promise<Grant>;++ };++ quotas: {++ list(project: string): Promise<Quota[]>;++ set(project: string, capability: string, input: { limit: number; window: Quota['window']; unit: string }): Promise<Quota>;++ delete(project: string, capability: string): Promise<null>;++ };++ audit(project: string, opts?: { before?: number; limit?: number }): Promise<{ entries: AuditEntry[]; next_before: number | null }>;++ iterateAudit(project: string, opts?: { pageSize?: number }): AsyncGenerator<AuditEntry, void, unknown>;++}++/** The client must carry a Network USER access token (token/getToken); service and app tokens get 401. */++export declare function createProjectsClient(client: OpenVibeClient, opts?: { baseUrl?: string }): ProjectsClient;@@@ -31,4 +31,7 @@ export interface RealtimeSubscription {}export declare function subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription;export declare function createRealtimeClient(client: OpenVibeClient, defaults?: Omit<SubscribeOptions, 'client'>): { subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription };++export interface SSEMessage { event: string; data: string; id: string | undefined; }++/** WHATWG event-stream parser over a fetch body (or any async iterable of chunks). */++export declare function parseSSE(body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>, opts?: { onRetry?: (ms: number) => void }): AsyncGenerator<SSEMessage, void, unknown>;export declare const DEFAULT_ORIGIN: string;@@@ -1,34 +1,97 @@import type { FetchLike, EventEnvelope, Capability, ServiceManifest, ModuleNamespace } from './core';++type Origin = 'network' | 'events' | 'media' | 'community' | 'tools';export interface MockGrant { capability: string; audience: string; namespaces?: string[]; }export interface MockUser { id: number | string; subject_id: string; username: string; display_name?: string; role?: string; legacy: Array<{ system: string; type?: string; id: string | number }>; }++export interface MockAppSpec {++ /** app_<ULID>; generated when omitted (addApp). */++ id?: string;++ /** prj_<ULID>; a project is created when omitted or unknown. */++ project?: string;++ name?: string;++ env?: 'sandbox' | 'production';++ environment?: 'sandbox' | 'production';++ type?: 'confidential' | 'public';++ /** Confidential apps: the client secret (generated when omitted). */++ secret?: string;++ redirectUris?: string[];++ /** Approved grants: capability ids (audience openvibe.<owner>) or { capability, audience }. */++ grants?: Array<string | { capability: string; audience?: string }>;++}++export interface MockProjectSpec {++ name?: string;++ owner?: string;++ /** subject -> role. A project with an owner or members lets only them authorize its sandbox apps. */++ members?: Record<string, 'owner' | 'admin' | 'developer' | 'viewer'>;++ /** '*' (default for projects declared here) or capability ids. */++ allowance?: '*' | string[];++ environmentPolicy?: 'sandbox' | 'sandbox+production';++}++export interface MockJobContext {++ input: Record<string, unknown>;++ files: Array<{ name: string; type: string; bytes: Uint8Array }>;++ progress(percent: number, message?: string | null): Promise<void>;++ readonly cancelled: boolean;++}export interface MockPlatformOptions {-- origins?: Partial<Record<'network' | 'events' | 'media' | 'community', string>>;++ origins?: Partial<Record<Origin, string>>;issuer?: string;contractsVersion?: string;clients?: Record<string, { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }>;++ /** Developer apps by id (app_<ULID>). */++ apps?: Record<string, Omit<MockAppSpec, 'id'>>;++ /** Developer projects by id (prj_<ULID>). */++ projects?: Record<string, MockProjectSpec>;++ /** Audiences a sandbox app may get tokens for (Network DEV_SANDBOX_AUDIENCES). Default: any. */++ sandboxAudiences?: string[];++ /** Audiences or capability ids whose mock services accept env=sandbox tokens (or true). Default: none. */++ acceptSandbox?: true | string[];++ /** Allowance of projects created through /api/v1/projects. Default: the whole catalog (Network: empty). */++ defaultAllowance?: string[];users?: Array<Partial<MockUser>>;mediaApps?: Record<string, { apiKey?: string }>;namespaces?: ModuleNamespace[];capabilities?: Capability[];services?: ServiceManifest[];realtimeRetryMs?: number;++ /** Serve Tools jobs at origins.tools. */++ jobs?: boolean | { stepMs?: number; handlers?: Record<string, (ctx: MockJobContext) => Promise<{ data?: Record<string, unknown>; files?: Array<{ name?: string; mime?: string; bytes?: Uint8Array | string; data?: Uint8Array | string }> } | void>> };++ /** fetch used by deliverEvents() (default: the global fetch). */++ deliveryFetch?: FetchLike;}++export interface DeliveryAttempt { subscription_id: string; event_id: string; seq: number; attempt: number; status: number | null; outcome: 'delivered' | 'retry' | 'dead'; error?: string; }export interface MockPlatform {fetch: FetchLike;-- origins: Record<'network' | 'events' | 'media' | 'community', string>;++ origins: Record<Origin, string>;issuer: string;keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;signServiceToken(clientId: string, opts: { audience: string; capabilities?: string[]; namespaces?: string[]; expiresInSec?: number }): string;++ signAppToken(appId: string, opts: { audience: string; capabilities?: string[]; projectId?: string; env?: 'sandbox' | 'production'; onBehalfOf?: string; expiresInSec?: number }): string;addClient(id: string, client: { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }): void;addUser(user?: Partial<MockUser>): MockUser;++ addApp(spec?: MockAppSpec): { id: string; clientId: string; projectId: string; env: 'sandbox' | 'production'; type: 'confidential' | 'public'; secret?: string };++ /** Returns the project id. */++ addProject(spec?: MockProjectSpec & { id?: string }): string;/** What the Network does after the account chooser: returns an authorization code. */-- authorize(opts: { clientId: string; redirectUri: string; subjectId?: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; scope?: string }): string;-- stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }> };-- state: { events: Array<{ seq: number; event: EventEnvelope; publisher: string }>; subscriptions: Map<string, any>; modules: Map<string, any>; files: Map<string, any>; users: Map<string, MockUser>; checkpoints: Map<string, number> };++ authorize(opts: { clientId: string; redirectUri: string; subjectId?: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; scope?: string | string[]; audience?: string }): string;++ /** Who GET /oauth/authorize signs in as, and whether they continue ('allow', default) or decline ('deny'). */++ setAuthorization(opts: { subjectId?: string | null; decision?: 'allow' | 'deny' }): void;++ stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }>; deliveries: DeliveryAttempt[] };++ state: {++ events: Array<{ seq: number; event: EventEnvelope; publisher: string }>; subscriptions: Map<string, any>; modules: Map<string, any>; files: Map<string, any>;++ users: Map<string, MockUser>; checkpoints: Map<string, number>; apps: Map<string, any>; projects: Map<string, any>; jobs: Map<string, any>;++ };publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string): { event_id: string; seq: number; duplicate: boolean };++ /** Retention: drop stored events with seq <= throughSeq; returns how many. */++ pruneEvents(throughSeq: number): number;++ /** Play the Events delivery worker once (signed POSTs to subscription endpoints). */++ deliverEvents(opts?: { fetch?: FetchLike; subscriptionId?: string; timeoutMs?: number }): Promise<{ delivered: number; failed: number; dead: number; attempts: DeliveryAttempt[] }>;++ startDeliveries(opts?: { intervalMs?: number; fetch?: FetchLike; subscriptionId?: string; timeoutMs?: number }): { stop(): Promise<void> };dropRealtime(): void;++ dropJobStreams(): void;}export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;--export declare const DEFAULT_ORIGINS: Record<'network' | 'events' | 'media' | 'community', string>;++export declare const DEFAULT_ORIGINS: Record<Origin, string>;++/** Capability ids a developer app can be granted in openvibe-contracts v0.26.0 (public + active). */++export declare const DEFAULT_APP_CATALOG: string[];
@@ -12,6 +12,6 @@ jobs:
@@ -12,6 +12,6 @@ jobs:
with:
with:
node-version: 22.22.1 # production services run Node 22
node-version: 22.22.1 # production services run Node 22
# types.test.js compares types/contracts.d.ts with the pinned Contracts release
# types.test.js compares types/contracts.d.ts with the pinned Contracts release
- - run: git clone --depth 1 --branch v0.6.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
+ - run: git clone --depth 1 --branch v0.26.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
- run: npm test # includes the pack test (npm pack + install into a temp project)
- run: npm test # includes the pack test (npm pack + install into a temp project)
- run: node scripts/esm.js --check
- run: node scripts/esm.js --check
@@ -3,6 +3,60 @@
@@ -3,6 +3,60 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.3.0 (2026-09-23)
+
+Developer apps (Network developer projects, ADR-014) and the gaps OpenVibe.Examples found.
+Contract types re-copied from openvibe-contracts v0.26.0 (`[email protected]`:
+`project_id`, `env`, `on_behalf_of`); CI checks them against that tag.
+
+- **Auth, developer apps.** `exchangeCode()` sends `audience` and `scope`, supports public clients
+ (no `clientSecret`; the PKCE `codeVerifier` is then required, and always for `app_…` clients)
+ and returns app tokens as they come (no `refresh_token`). New `verifyAppToken(token, { jwks |
+ publicKey, issuer, audience, acceptSandbox })`: RS256, expiry, issuer, the required audience,
+ the claim shape, and `env: sandbox` refused (`token.sandbox_refused`) unless `acceptSandbox`;
+ returns the claims including `project_id`, `env` and `on_behalf_of`. `verifyUserToken()` is
+ unchanged and still refuses app tokens (`token.not_user`).
+- **Authorize URL.** `buildAuthorizeUrl()` / `startAuthorization()` take `audience`; `scope` takes
+ capability ids. **Behaviour change:** the `'profile theme'` default now applies only when no
+ `audience` is given.
+- **Token client.** `createServiceTokenClient().getTokenInfo(ctx)` returns the granted `scope`, the
+ expiry and `unverifiedClaims` (decoded, NOT verified). New `decodeUnverified()` /
+ `unverifiedClaims()` helpers in `openvibe-sdk/auth` (browser build too).
+- **Core.** `client.request({ responseType: 'response' })` now returns the raw fetch `Response`
+ (as `data` and `response`, body unread) instead of `data: null`; error statuses are still read
+ and thrown, the per-attempt timeout covers the headers only, and `signal` still cancels the body.
+ `core.SDK_VERSION` is the package version again (it said 0.1.0 in 0.2.x); a test keeps them equal.
+- **Realtime.** `parseSSE(body, { onRetry })` is exported: an async iterator of
+ `{ event, data, id }` over a fetch body or any async iterable.
+- **Events.** `iterate()` calls `onPage(page)` after every item of that page was yielded and
+ handled (it used to run before the page's items), so saving `next_after_seq` there is
+ crash-safe; a break or throw inside the page leaves the previous cursor. New `maxPages` option.
+ **Behaviour change** for anyone who relied on the old order.
+- **New `openvibe-sdk/jobs`** (browser-safe): OpenVibe.Tools jobs — `submit()` (always with an
+ `Idempotency-Key`; JSON or multipart with files), `get()`, `cancel()`, `events()` (SSE that
+ reattaches with `Last-Event-ID` after drops and restarts, each event once), `wait()`, `file()`
+ (raw Response).
+- **New `openvibe-sdk/projects`** (browser-safe; needs a Network user token): the
+ `/api/v1/projects` API — projects, members, apps, credentials rotate/revoke, grants
+ request/approve/deny/revoke, quotas, audit, the catalog. Calls that mint a secret or that
+ Network does not dedupe are never retried.
+- **Browser bundle.** `browser/openvibe-sdk.mjs` (export `openvibe-sdk/browser/openvibe-sdk.mjs`):
+ one self-contained ES module of the browser-safe subpaths for pages with no build step,
+ generated by `scripts/browser-bundle.js` (no dependencies) from the CommonJS sources, checked
+ in, with a staleness test, and covered by the browser secret scan.
+- **Mock platform.** Developer apps (`apps`, `projects`, `addApp()`, `signAppToken()`):
+ confidential and public app clients, app tokens with `project_id`/`env`/`ns`/`on_behalf_of`,
+ `GET /oauth/authorize` with auto-consent (`setAuthorization()`), audience-bound codes, PKCE
+ required for apps, sandbox membership, `sandboxAudiences`; mock services refuse sandbox tokens
+ unless `acceptSandbox` names the audience or capability; a Media tenant per project; the
+ `/api/v1/projects` API. Events: `pruneEvents()` so pulls and realtime resumes report retention
+ gaps; `deliverEvents()` / `startDeliveries()` play the delivery worker (signed POSTs to a local
+ endpoint, in order, retries, dead after `max_attempts`). Optional Tools jobs (`jobs: true |
+ { stepMs, handlers }`): submit with idempotency replay, get, cancel, SSE with `Last-Event-ID`,
+ 204 when finished, result files, `dropJobStreams()`. Fixed: a realtime resume with a cursor
+ ahead of the stream no longer swallows the next events. Default `contractsVersion` 0.26.0; a
+ `tools` origin. Not included: Chat (no WebSocket mock; OpenVibe.Examples keeps its own).
+
## 0.2.2 (2026-09-23)
## 0.2.2 (2026-09-23)
- No peer dependency on `openvibe-contracts`: the SDK never loads it at runtime, and a peer pinned
- No peer dependency on `openvibe-contracts`: the SDK never loads it at runtime, and a peer pinned
@@ -2,17 +2,17 @@
@@ -2,17 +2,17 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.1.0 (roadmap Wave 2). Runs and is tested against local stub servers and the built-in mock platform. No OpenVibe service or app uses it in production yet.
+**Status:** alpha, v0.3.0 (roadmap Wave 2; developer apps from Wave 20). Tested against local stub servers and the built-in mock platform only, never against the live platform. Several OpenVibe services pin v0.2.2; nothing uses 0.3.0 yet.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.1.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0
```
```
-It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. It does not depend on `openvibe-contracts`: it copies the contract types it uses (built against Contracts v0.6.0+, checked in CI).
+It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts`: it copies the contract types it uses (from Contracts v0.26.0, checked in CI).
## Quick start
## Quick start
@@ -45,7 +45,7 @@ const sub = subscribe(['live.stream.*'], (event, { seq }) => render(event), {
@@ -45,7 +45,7 @@ const sub = subscribe(['live.stream.*'], (event, { seq }) => render(event), {
});
});
```
```
-**The token exchange happens on your server.** Every OpenVibe OAuth client is confidential: it has a client secret, and a secret must never reach a browser. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`.
+**The token exchange happens on your server.** First-party clients and confidential developer apps have a client secret, and a secret must never reach a browser. A public developer app has no secret, but its server should still do the exchange and keep the token in an HttpOnly session. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach (and the browser bundle itself) and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`.
### Server service (service principal)
### Server service (service principal)
@@ -75,38 +75,79 @@ const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.n
@@ -75,38 +75,79 @@ const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.n
claims.subject_id; // usr_…
claims.subject_id; // usr_…
```
```
-### External developer (an app outside the network)
+### External developer (a developer app)
-An app with only a client id, a secret and a capability grant (for example `media.object.upload` for its own namespace) authenticates, finds Media through the registry and uploads a file:
+Developer apps belong to a project on OpenVibe.Network ([developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md)). An app is a principal `app_<ULID>`: `confidential` (has a secret, may use client credentials) or `public` (no secret, authorization code + PKCE only), in `sandbox` or `production`. Its tokens last 5 minutes, carry one audience, the approved capabilities (`cap`), `project_id`, `env` and `ns: [project_id]`, and there are no refresh tokens.
+
+A server app with a secret and a grant (here `media.object.upload`) uploads into its project's Media namespace:
```js
```js
const sdk = require('openvibe-sdk');
const sdk = require('openvibe-sdk');
-const client = sdk.createClient({
- tokenProvider: sdk.auth.createServiceTokenClient({ clientId: 'my-app', clientSecret: process.env.OV_CLIENT_SECRET }),
-});
+const tokens = sdk.auth.createServiceTokenClient({ clientId: process.env.OV_CLIENT_ID, clientSecret: process.env.OV_CLIENT_SECRET });
+const client = sdk.createClient({ tokenProvider: tokens });
await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe
await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe
-const media = sdk.media.createMediaClient(client, { app: 'my-app' });
+const { scope, unverifiedClaims } = await tokens.getTokenInfo({ audience: 'openvibe.media' }); // what Network granted (display only)
+const media = sdk.media.createMediaClient(client, { app: unverifiedClaims.project_id }); // Media tenancy is the project id
const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' });
const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' });
file.public_url; // https://openvibe.media/f/<key>
file.public_url; // https://openvibe.media/f/<key>
```
```
-`test/testing.test.js` runs this flow end to end against `openvibe-sdk/testing`.
+"Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`).
+
+```js
+const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth');
+
+// /login: capability ids as scope, and the audience the token is for.
+const { url, state, codeVerifier } = await startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] });
+// /callback:
+const { code } = readCallback(req.url, { expectedState: state });
+const t = await exchangeCode({ clientId, clientSecret /* omit for a public app */, code, codeVerifier, redirectUri, audience: 'openvibe.media' });
+t.refresh_token; // undefined: when it expires, the person signs in again
+
+// A service receiving app tokens verifies them offline:
+const claims = await verifyAppToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.media' });
+claims.project_id; claims.env; claims.on_behalf_of; // env=sandbox is refused (token.sandbox_refused) unless acceptSandbox: true
+```
+
+Manage projects, apps, secrets and grants with `openvibe-sdk/projects` and a Network user token:
+
+```js
+const projects = require('openvibe-sdk/projects').createProjectsClient(sdk.createClient({ token: userAccessToken }));
+const prj = await projects.create({ name: 'My app' });
+const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential' });
+app.credential.client_secret; // shown ONCE: store it now
+await projects.grants.request(prj.id, app.id, 'media.object.upload');
+```
+
+Run a Tools job and follow it across disconnects and restarts:
+
+```js
+const jobs = require('openvibe-sdk/jobs').createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
+const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' }, files: [{ name: 'a.png', data: bytes }], idempotencyKey });
+for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id); // reconnects with Last-Event-ID
+const res = await jobs.file(job.id, 0); // raw Response
+```
+
+`test/testing.test.js`, `test/apps.test.js`, `test/projects.test.js` and `test/jobs.test.js` run these flows end to end against `openvibe-sdk/testing`. What the live platform allows today (sandbox audiences, allowances, which capabilities are grantable) is in the Network doc above and in OpenVibe.Examples' README.
## API
## API
| Subpath | Where | What |
| Subpath | Where | What |
|---|---|---|
|---|---|---|
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
| `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` |
-| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()`, `verifyUserToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()` |
+| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery` |
| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery` |
-| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)` |
+| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
-| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network, Events and Media on an in-process `fetch` |
+| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()` |
+| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
+| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media and Tools jobs on an in-process `fetch` |
+| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, projects |
Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
@@ -128,7 +169,8 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -128,7 +169,8 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- Legacy `{ error: 'text' }` bodies become `http.<status>`.
- Legacy `{ error: 'text' }` bodies become `http.<status>`.
- Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`.
- Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`.
- **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage.
- **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage.
-- **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. The `iterate()` methods build on it: Events uses `after_seq`, and Media and Community use offsets.
+- **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. Media and Community use offsets. Events `iterate()` walks `after_seq` and calls `onPage(page)` only after every item of that page was handled, so saving `page.next_after_seq` there is crash-safe.
+- **Streams and downloads.** `responseType: 'response'` resolves with the raw fetch `Response` (`data` and `response`, body unread). Error statuses are still read and thrown. The timeout covers the headers only, and `signal` still cancels the body. `parseSSE(res.body)` (from `openvibe-sdk/realtime`) iterates a `text/event-stream`.
### Auth details
### Auth details
@@ -140,27 +182,47 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -140,27 +182,47 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- It rejects service-principal tokens (`token.not_user`).
- It rejects service-principal tokens (`token.not_user`).
It returns the claims, including `subject_id`.
It returns the claims, including `subject_id`.
-- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. See the gaps below for what the Network does with it today.
+- `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts v0.26 `verifyServiceToken()` does: the same signature and time rules, the audience (required), `actor_type: app`, the claim shape (`sub app:app_…`, `cap`, `jti`, `project_id prj_…`, `env`, optional `on_behalf_of usr_…`), and `env: sandbox` refused as `token.sandbox_refused` unless `acceptSandbox: true`. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: test `claims.cap` for the one your route performs.
+- `exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? })`: without `clientSecret` (a public app) the PKCE verifier is required; `app_…` clients must send the verifier and an `audience`. App tokens come back without a `refresh_token`.
+- `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps.
+- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
+- PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app.
### Testing your app
### Testing your app
```js
```js
const { createMockPlatform } = require('openvibe-sdk/testing');
const { createMockPlatform } = require('openvibe-sdk/testing');
const platform = createMockPlatform({
const platform = createMockPlatform({
- clients: { 'my-app': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } },
+ clients: { 'my-service': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } },
+ apps: { 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR': { env: 'production', type: 'confidential', secret: 's', redirectUris: ['http://localhost:3009/callback'], grants: ['media.object.upload'] } },
mediaApps: { 'my-app': {} },
mediaApps: { 'my-app': {} },
users: [{ username: 'ana' }],
users: [{ username: 'ana' }],
+ jobs: true,
});
});
-const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'my-app', clientSecret: 's', fetch: platform.fetch }) });
+const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', clientSecret: 's', fetch: platform.fetch }) });
```
```
-The mock answers at the real public origins with real RS256 tokens and checks audience, capability and namespace. It covers:
+The mock answers at the real public origins with real RS256 tokens and checks audience, capability, namespace and sandbox the way the services do. It covers:
-- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh), the JWKS, the registry, `/api/modules` and `/internal/modules`, and `/internal/identity`.
-- **Events:** publish with `event_id` dedupe, pull, checkpoints, subscriptions, and `/realtime/stream` SSE with `Last-Event-ID`.
+- **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`.
+- **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. Each project gets a Media tenant named after its id.
+- **Sandbox:** mock services refuse `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`.
+- **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries to your local endpoint in order, retry, and mark them dead after `max_attempts`.
- **Media:** the files API.
- **Media:** the files API.
+- **Tools jobs** (`jobs: true | { stepMs, handlers }`): submit with Idempotency-Key replay, get, cancel, SSE with `Last-Event-ID` and `204` when finished, result files; `dropJobStreams()` simulates a dropped connection.
+
+Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent()`, `pruneEvents()`, `deliverEvents()`, `dropRealtime()`, `dropJobStreams()`, and `stats` and `state` for assertions. It is a fake. It has no persistence, its visibility rules are simplified, and it has no Chat (no WebSocket mock).
+
+### Browser without a bundler
+
+```html
+<script type="module">
+ import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs'; // copied or served from node_modules/openvibe-sdk/browser/
+ const { url, state, codeVerifier } = await auth.startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] });
+</script>
+```
-Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, and `stats` and `state` for assertions. It is a fake. It has no persistence and no delivery worker, and its visibility rules are simplified.
+`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `jobs` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.
## Versioning
## Versioning
@@ -171,11 +233,11 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, a
@@ -171,11 +233,11 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, a
## Not wrapped yet (intentionally)
## Not wrapped yet (intentionally)
- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
- **Community comments on other content, the forum and Pulse** are being built in Community right now. Only paste comments are wrapped. The rest is a TODO in `src/community.js` until those APIs settle.
-- **Media v2 objects** (`med_` ids, ADR-006) are being added to Media right now. This release wraps only the v1 files API that exists on Media's `main`, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).
+- **Media v2 objects** (`med_` ids, ADR-006, `/api/v2/:app/objects`) exist on Media but are not wrapped yet. This release wraps only the v1 files API, plus the public URL helpers. VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011).
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
- **Media from the browser:** Media refuses user JWTs on `/api/v1/:app/files`, so a browser uploads through its own app server. That server holds the app key and names the user with `actingUserId`.
-- **PKCE enforcement:** the SDK sends `code_challenge` and `code_verifier`, but the Network's `/oauth/authorize` and `/oauth/token` don't verify them yet. The mock platform does. Security today rests on the client secret, which never leaves the server.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
- **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass.
-- **Chat, Live, Billing, Games and Jobs** from the original charter come when those services publish their capabilities in Contracts.
+- **Chat, Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat has no app principal (`chat.message.send` is `first-party`), so there is no Chat client and no Chat mock.
+- **Tools job types** are not wrapped one by one: `openvibe-sdk/jobs` submits any `type` with its `input` and files; each satellite (img, audio, docs) documents its own types.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
## Development
## Development
@@ -184,6 +246,7 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, a
@@ -184,6 +246,7 @@ Helpers: `signUserToken()`, `authorize()`, `publishEvent()`, `dropRealtime()`, a
npm test # every test/*.test.js: stub servers + mock platform, then npm pack + install
npm test # every test/*.test.js: stub servers + mock platform, then npm pack + install
fnm exec --using=22.22.1 npm test # the Node version production runs
fnm exec --using=22.22.1 npm test # the Node version production runs
node scripts/esm.js # regenerate esm/*.mjs after changing a module's exports
node scripts/esm.js # regenerate esm/*.mjs after changing a module's exports
+node scripts/browser-bundle.js # regenerate browser/openvibe-sdk.mjs after changing a browser-safe file
```
```
Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`.
Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`.
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.1.0",
+ "version": "0.3.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
- "updated": "2026-09-22"
+ "updated": "2026-09-23"
}
}
@@ -13,4 +13,6 @@ module.exports = {
@@ -13,4 +13,6 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ jobs: require('./src/jobs'),
+ projects: require('./src/projects'),
};
};
new file mode 100644
new file mode 100644
@@ -0,0 +1,1714 @@
@@ -0,0 +1,1714 @@
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.0, MIT). Do not edit.
+// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
+// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
+/* eslint-disable */
+const __defs = [
+// ── 0: browser.js ──────────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk (browser entry): core plus the browser-safe families. No server-only code: no
+ * service-token client, no JWT verification, no events publishing, no identity lookups.
+ */
+const core = __load(1);
+
+module.exports = {
+ ...core,
+ auth: __load(8),
+ registry: __load(9),
+ modules: __load(10),
+ realtime: __load(11),
+ media: __load(12),
+ community: __load(13),
+ jobs: __load(14),
+ projects: __load(15),
+};
+},
+// ── 1: src/core/index.js ───────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/** openvibe-sdk/core: the HTTP client, errors, tracing, ids, pagination. Browser-safe. */
+const { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK } = __load(2);
+const { OpenVibeError, isOpenVibeError } = __load(3);
+const { parseTraceparent, startSpan, contextFromHeaders } = __load(4);
+const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = __load(5);
+const { paginate, offsetPager } = __load(7);
+const semver = __load(6);
+
+/** The package version (test/version.test.js keeps it equal to package.json). */
+const SDK_VERSION = '0.3.0';
+
+module.exports = {
+ createClient,
+ OpenVibeError,
+ isOpenVibeError,
+ paginate,
+ offsetPager,
+ parseTraceparent,
+ startSpan,
+ contextFromHeaders,
+ ulid,
+ newEventId,
+ newIdempotencyKey,
+ isActingSubjectId,
+ satisfiesRange: semver.satisfies,
+ compareVersions: semver.compare,
+ CONTRACTS_RANGE,
+ DEFAULT_NETWORK,
+ SDK_VERSION,
+};
+},
+// ── 2: src/core/client.js ──────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * createClient(): the one HTTP client every SDK subpath uses. Browser-safe (global fetch, Web
+ * Crypto, no Node modules).
+ *
+ * - service origins come from explicit `baseUrls` or from the registry (`discover()`); callers
+ * name a service ('media', 'events', …) and a path, never a hard-coded host
+ * - every call has a deadline (`timeoutMs` per attempt, `deadlineMs` for the whole call)
+ * - retries only when repeating is safe: idempotent methods, or a request carrying an
+ * Idempotency-Key (one is generated for mutations when retries > 0 unless the caller opts out
+ * with `idempotencyKey: false`)
+ * - W3C traceparent + X-OpenVibe-Request-Id on every call
+ * - failures are OpenVibeError (RFC 9457 problem details mapped to code/status/detail/requestId/traceId)
+ */
+const { OpenVibeError } = __load(3);
+const { startSpan, contextFromHeaders } = __load(4);
+const { newIdempotencyKey, randomHex } = __load(5);
+const { satisfies } = __load(6);
+
+const DEFAULT_NETWORK = 'https://openvibe.network';
+/** Contract releases this SDK version was built and tested against. */
+const CONTRACTS_RANGE = '>=0.5.0 <1.0.0';
+const IDEMPOTENT = new Set(['GET', 'HEAD', 'OPTIONS', 'PUT', 'DELETE']);
+const RETRY_STATUS = new Set([408, 425, 429, 502, 503, 504]);
+
+const trimSlash = (s) => String(s).replace(/\/+$/, '');
+const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+
+function createClient(options = {}) {
+ const shared = { discovery: null, discoveredAt: 0, inflight: null, warned: false };
+ return build(normalize(options), shared, {});
+}
+
+function normalize(o) {
+ const fetchImpl = o.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
+ if (!fetchImpl) throw new TypeError('openvibe-sdk: no fetch available; pass options.fetch');
+ const sources = [o.token != null, typeof o.getToken === 'function', Boolean(o.tokenProvider)].filter(Boolean).length;
+ if (sources > 1) throw new TypeError('openvibe-sdk: pass only one of token, getToken, tokenProvider');
+ const network = trimSlash(o.network || DEFAULT_NETWORK);
+ let getToken = null;
+ if (typeof o.getToken === 'function') getToken = o.getToken;
+ else if (o.tokenProvider) getToken = (ctx) => o.tokenProvider.getToken(ctx);
+ let invalidateToken = null;
+ if (o.tokenProvider && typeof o.tokenProvider.invalidate === 'function') invalidateToken = (ctx) => o.tokenProvider.invalidate(ctx);
+ else if (typeof o.invalidateToken === 'function') invalidateToken = o.invalidateToken;
+ return Object.freeze({
+ network,
+ discoveryUrl: o.discoveryUrl || `${network}/.well-known/openvibe`,
+ baseUrls: Object.freeze(Object.fromEntries(Object.entries(o.baseUrls || {}).map(([k, v]) => [k, trimSlash(v)]))),
+ audiences: Object.freeze({ ...o.audiences }),
+ fetch: fetchImpl,
+ timeoutMs: o.timeoutMs ?? 10000,
+ deadlineMs: o.deadlineMs ?? 30000,
+ retries: o.retries ?? 2,
+ retryDelayMs: o.retryDelayMs ?? 250,
+ maxRetryDelayMs: o.maxRetryDelayMs ?? 5000,
+ token: o.token ?? null,
+ getToken,
+ invalidateToken,
+ headers: Object.freeze({ ...o.headers }),
+ credentials: o.credentials,
+ discoveryTtlMs: o.discoveryTtlMs ?? 5 * 60 * 1000,
+ autoDiscover: o.autoDiscover !== false,
+ contractsRange: o.contractsRange || CONTRACTS_RANGE,
+ strictContracts: Boolean(o.strictContracts),
+ traceparent: o.traceparent || null,
+ onWarning: typeof o.onWarning === 'function' ? o.onWarning : (msg) => { if (typeof console !== 'undefined') console.warn(`[openvibe-sdk] ${msg}`); },
+ });
+}
+
+function build(cfg, shared, ctx) {
+ const audienceOf = (service) => cfg.audiences[service] || `openvibe.${service}`;
+
+ function cachedDiscovery() {
+ return shared.discovery && Date.now() - shared.discoveredAt < cfg.discoveryTtlMs ? shared.discovery : null;
+ }
+
+ /**
+ * Read the platform descriptor (GET <network>/.well-known/openvibe) and cache it: service
+ * origins, token/JWKS endpoints, and the contracts release the network runs. Concurrent
+ * callers share one request.
+ */
+ async function discover({ force = false } = {}) {
+ const fresh = cachedDiscovery();
+ if (fresh && !force) return fresh;
+ if (!shared.inflight) {
+ shared.inflight = (async () => {
+ const { data } = await request({ url: cfg.discoveryUrl, auth: false });
+ if (!data || typeof data !== 'object' || !Array.isArray(data.services)) {
+ throw new OpenVibeError({ code: 'sdk.bad_response', message: `${cfg.discoveryUrl} is not an OpenVibe platform descriptor` });
+ }
+ const version = (data.contracts && data.contracts.version) || null;
+ const compatible = Boolean(version) && satisfies(version, cfg.contractsRange);
+ const origins = {};
+ for (const s of data.services) if (s && s.id && s.origin) origins[s.id] = trimSlash(s.origin);
+ const out = Object.freeze({
+ issuer: data.issuer || null,
+ tokenEndpoint: data.token_endpoint || null,
+ jwksUri: data.jwks_uri || null,
+ registry: data.registry || null,
+ services: data.services,
+ origins: Object.freeze(origins),
+ contractsVersion: version,
+ contractsRange: cfg.contractsRange,
+ compatible,
+ fetchedAt: new Date().toISOString(),
+ raw: data,
+ });
+ if (!compatible) {
+ const msg = `network runs openvibe-contracts ${version || '(unknown)'}, this SDK supports ${cfg.contractsRange}`;
+ if (cfg.strictContracts) throw new OpenVibeError({ code: 'sdk.incompatible_contracts', message: msg });
+ if (!shared.warned) { shared.warned = true; cfg.onWarning(msg); }
+ }
+ shared.discovery = out;
+ shared.discoveredAt = Date.now();
+ return out;
+ })().finally(() => { shared.inflight = null; });
+ }
+ return shared.inflight;
+ }
+
+ /** Origin for a service: explicit baseUrls win, then the registry. */
+ async function origin(service) {
+ if (cfg.baseUrls[service]) return cfg.baseUrls[service];
+ if (service === 'network') return cfg.network;
+ let d = cachedDiscovery();
+ if (!d && cfg.autoDiscover) d = await discover();
+ if (d && d.origins[service]) return d.origins[service];
+ throw new OpenVibeError({ code: 'sdk.unknown_service', message: `no base URL for service "${service}" (pass baseUrls.${service} or let discover() find it)` });
+ }
+
+ /** Is the service registered and running (alpha or later)? Uses the cached descriptor. */
+ async function supports(service) {
+ const d = await discover();
+ const s = d.services.find((x) => x && x.id === service);
+ return Boolean(s && ['alpha', 'beta', 'stable', 'degraded'].includes(s.status));
+ }
+
+ async function buildUrl({ url, service, baseUrl, path = '', query } = {}) {
+ let u;
+ if (url) u = String(url);
+ else {
+ const base = baseUrl ? trimSlash(baseUrl) : await origin(service || 'network');
+ u = `${base}${path.startsWith('/') ? '' : '/'}${path}`;
+ }
+ if (query) {
+ const qs = new URLSearchParams();
+ for (const [k, v] of Object.entries(query)) {
+ if (v === undefined || v === null || v === false) continue;
+ qs.set(k, v === true ? '1' : Array.isArray(v) ? v.join(',') : String(v));
+ }
+ const s = qs.toString();
+ if (s) u += (u.includes('?') ? '&' : '?') + s;
+ }
+ return u;
+ }
+
+ async function tokenFor(service, audience) {
+ if (cfg.token != null) return cfg.token;
+ if (cfg.getToken) return (await cfg.getToken({ service, audience })) || null;
+ return null;
+ }
+
+ function currentTraceparent() {
+ if (ctx.traceparent) return ctx.traceparent;
+ return typeof cfg.traceparent === 'function' ? cfg.traceparent() : cfg.traceparent;
+ }
+
+ function backoff(attempt) {
+ const base = Math.min(cfg.maxRetryDelayMs, cfg.retryDelayMs * 2 ** (attempt - 1));
+ return Math.round(base / 2 + Math.random() * base / 2);
+ }
+
+ function retryAfterMs(headers) {
+ const v = headers && headers.get('retry-after');
+ if (!v) return null;
+ const s = Number(v);
+ const ms = Number.isFinite(s) ? s * 1000 : Date.parse(v) - Date.now();
+ return Number.isFinite(ms) && ms >= 0 ? Math.min(ms, cfg.maxRetryDelayMs) : null;
+ }
+
+ /**
+ * request({ service | baseUrl | url, path, method, query, json | form | urlencoded | body,
+ * headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,
+ * deadlineMs, signal, traceparent, requestId, responseType })
+ * -> { status, headers, data, requestId, traceId, traceparent, attempts }
+ *
+ * responseType 'response' (streams, downloads): a 2xx resolves with `data` and `response` set
+ * to the raw fetch Response, body unread. The timeout then covers the response headers only,
+ * and `signal` still cancels the body afterwards. An error status is read and thrown as
+ * usual, so the body you get is always a success.
+ */
+ async function request(opts = {}) {
+ const method = String(opts.method || 'GET').toUpperCase();
+ const url = await buildUrl(opts);
+ const retries = Math.max(0, opts.retries ?? cfg.retries);
+ let idem = opts.idempotencyKey;
+ if (idem === undefined && !IDEMPOTENT.has(method) && !opts.idempotent && retries > 0) idem = newIdempotencyKey();
+ if (idem === false || idem === undefined) idem = null;
+ const canRetry = IDEMPOTENT.has(method) || Boolean(idem) || opts.idempotent === true;
+ const span = startSpan(opts.traceparent || currentTraceparent());
+ const requestId = opts.requestId || ctx.requestId || `req_${randomHex(12)}`;
+ const timeoutMs = opts.timeoutMs ?? cfg.timeoutMs;
+ const deadline = Date.now() + (opts.deadlineMs ?? cfg.deadlineMs);
+ const service = opts.service;
+ const audience = opts.audience || (service ? audienceOf(service) : undefined);
+ const meta = { method, url, requestId, traceId: span.traceId };
+
+ let attempt = 0;
+ let reauthed = false;
+ for (;;) {
+ const headers = new Headers({ Accept: 'application/json' });
+ for (const src of [cfg.headers, ctx.headers, opts.headers]) {
+ for (const [k, v] of Object.entries(src || {})) if (v !== undefined && v !== null) headers.set(k, String(v));
+ }
+ headers.set('traceparent', span.traceparent);
+ headers.set('X-OpenVibe-Request-Id', requestId);
+ if (idem) headers.set('Idempotency-Key', idem);
+ if (opts.auth !== false && !headers.has('authorization')) {
+ const t = opts.token !== undefined ? opts.token : await tokenFor(service, audience);
+ if (t) headers.set('Authorization', `Bearer ${t}`);
+ }
+ const body = encodeBody(opts, headers);
+ const remaining = deadline - Date.now();
+ if (remaining <= 0) throw new OpenVibeError({ ...meta, code: 'sdk.deadline_exceeded', message: `${method} ${url.split('?')[0]}: deadline exceeded after ${attempt} attempt(s)` });
+
+ let out;
+ try {
+ out = await attemptOnce(cfg.fetch, url, { method, headers, body, credentials: opts.credentials ?? cfg.credentials, signal: opts.signal, responseType: opts.responseType }, Math.min(timeoutMs, remaining), meta);
+ } catch (err) {
+ if (err.code === 'sdk.aborted' || !canRetry || attempt >= retries) throw err;
+ const wait = backoff(attempt + 1);
+ if (Date.now() + wait >= deadline) throw err;
+ attempt++;
+ await sleep(wait);
+ continue;
+ }
+ const { res, data } = out;
+ if (res.status === 401 && !reauthed && opts.auth !== false && opts.token === undefined && cfg.invalidateToken) {
+ reauthed = true; // a rejected token was never acted on: refresh it and try once more
+ await cfg.invalidateToken({ service, audience });
+ continue;
+ }
+ const retryable = RETRY_STATUS.has(res.status);
+ if (!res.ok && retryable && canRetry && attempt < retries) {
+ const wait = retryAfterMs(res.headers) ?? backoff(attempt + 1);
+ if (Date.now() + wait < deadline) {
+ attempt++;
+ await sleep(wait);
+ continue;
+ }
+ }
+ const rid = res.headers.get('x-openvibe-request-id') || requestId;
+ if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });
+ const result = { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };
+ if (opts.responseType === 'response') result.response = res;
+ return result;
+ }
+ }
+
+ const client = {
+ request,
+ /** request() but resolves to the parsed body only. */
+ async json(opts) { return (await request(opts)).data; },
+ discover,
+ discovery: () => cachedDiscovery(),
+ origin,
+ supports,
+ url: buildUrl,
+ audienceOf,
+ traceparent: currentTraceparent,
+ /** A client that continues an incoming request's trace ({ traceparent, requestId, headers }). */
+ withContext(c = {}) {
+ const next = { ...ctx };
+ if (c.traceparent) next.traceparent = c.traceparent;
+ if (c.requestId) next.requestId = c.requestId;
+ if (c.headers) next.headers = { ...ctx.headers, ...c.headers };
+ return build(cfg, shared, next);
+ },
+ /** withContext() from an incoming request's headers (Node req.headers or a Fetch Headers). */
+ fromRequest(req) {
+ return client.withContext(contextFromHeaders(req && req.headers ? req.headers : req));
+ },
+ options: cfg,
+ };
+ return client;
+}
+
+function encodeBody(opts, headers) {
+ if (opts.json !== undefined) {
+ if (!headers.has('content-type')) headers.set('Content-Type', 'application/json');
+ return JSON.stringify(opts.json);
+ }
+ if (opts.form !== undefined) return opts.form; // FormData: fetch sets the multipart boundary
+ if (opts.urlencoded !== undefined) {
+ headers.set('Content-Type', 'application/x-www-form-urlencoded');
+ const p = opts.urlencoded instanceof URLSearchParams ? opts.urlencoded : new URLSearchParams(Object.entries(opts.urlencoded).filter(([, v]) => v !== undefined && v !== null).map(([k, v]) => [k, String(v)]));
+ return p.toString();
+ }
+ return opts.body;
+}
+
+/** One fetch with its own timeout, covering the body read too. */
+async function attemptOnce(fetchImpl, url, { method, headers, body, credentials, signal, responseType }, ms, meta) {
+ const ctrl = new AbortController();
+ let timedOut = false;
+ const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);
+ const onAbort = () => ctrl.abort();
+ let keepSignal = false;
+ if (signal) {
+ if (signal.aborted) ctrl.abort();
+ else signal.addEventListener('abort', onAbort, { once: true });
+ }
+ try {
+ const init = { method, headers, body, signal: ctrl.signal };
+ if (credentials) init.credentials = credentials;
+ const res = await fetchImpl(url, init);
+ if (responseType === 'response' && res.ok) {
+ keepSignal = true; // the caller reads the body later: its signal must still cancel it
+ return { res, data: res };
+ }
+ return { res, data: await parseBody(res, responseType === 'response' ? undefined : responseType) };
+ } catch (err) {
+ if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });
+ if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });
+ if (err && err.name === 'OpenVibeError') throw err;
+ throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });
+ } finally {
+ clearTimeout(timer);
+ if (signal && !keepSignal) signal.removeEventListener('abort', onAbort);
+ }
+}
+
+async function parseBody(res, responseType) {
+ if (res.status === 204 || res.status === 205 || res.status === 304) return null;
+ if (responseType === 'text') return res.text();
+ if (responseType === 'arrayBuffer') return res.arrayBuffer();
+ const text = await res.text();
+ if (!text) return null;
+ const type = res.headers.get('content-type') || '';
+ if (responseType === 'json' || /[/+]json\b/.test(type)) {
+ try { return JSON.parse(text); } catch { return text; }
+ }
+ return text;
+}
+
+module.exports = { createClient, CONTRACTS_RANGE, DEFAULT_NETWORK, IDEMPOTENT_METHODS: IDEMPOTENT, RETRY_STATUS };
+},
+// ── 3: src/core/errors.js ──────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * OpenVibeError: one error type for every failed call.
+ *
+ * Services answer with RFC 9457 problem details (contract errors.problem@1: type, title, status,
+ * detail, code, request_id, trace_id). Older routes still answer { error: 'text' } and the OAuth
+ * token endpoint answers { error, error_description }; all three become the same shape here, so
+ * callers branch on `err.code` and `err.status` only.
+ *
+ * SDK-side failures use codes in the `sdk.` family: sdk.timeout, sdk.deadline_exceeded,
+ * sdk.aborted, sdk.network_error, sdk.unknown_service, sdk.incompatible_contracts, sdk.bad_response.
+ */
+
+const CODE_RE = /^[a-z][a-z0-9_]*(\.[a-z0-9_]+)*$/;
+
+class OpenVibeError extends Error {
+ constructor({ message, code, status = 0, title, detail, type, requestId, traceId, errors, problem, retryable = false, cause, method, url } = {}) {
+ super(message || detail || title || code || 'OpenVibe request failed', cause ? { cause } : undefined);
+ this.name = 'OpenVibeError';
+ this.code = code || (status ? `http.${status}` : 'sdk.error');
+ this.status = status;
+ if (title) this.title = title;
+ if (detail) this.detail = detail;
+ if (type) this.type = type;
+ this.requestId = requestId || null;
+ this.traceId = traceId || null;
+ if (errors) this.errors = errors;
+ this.problem = problem || null;
+ this.retryable = Boolean(retryable);
+ if (method) this.method = method;
+ if (url) this.url = url;
+ }
+
+ /** Build from an HTTP error response body (problem+json, legacy { error }, OAuth error, or text). */
+ static fromResponse({ status, body, requestId, traceId, method, url, retryable = false }) {
+ const obj = body && typeof body === 'object' && !Array.isArray(body) ? body : null;
+ let code = null;
+ if (obj && typeof obj.code === 'string' && obj.code) code = obj.code;
+ else if (obj && typeof obj.error === 'string' && CODE_RE.test(obj.error) && obj.error.includes('_')) code = obj.error; // OAuth: invalid_client
+ const detail = (obj && (obj.detail || obj.error_description || (typeof obj.error === 'string' ? obj.error : null)))
+ || (typeof body === 'string' && body.trim() ? body.trim().slice(0, 500) : null);
+ const title = obj && typeof obj.title === 'string' ? obj.title : null;
+ return new OpenVibeError({
+ message: `${method || 'GET'} ${stripQuery(url)} -> ${status}${code ? ` ${code}` : ''}${detail ? `: ${detail}` : ''}`,
+ code: code || `http.${status}`,
+ status,
+ title,
+ detail,
+ type: obj && obj.type,
+ requestId: (obj && obj.request_id) || requestId,
+ traceId: (obj && obj.trace_id) || traceId,
+ errors: obj && Array.isArray(obj.errors) ? obj.errors : undefined,
+ problem: obj && typeof obj.code === 'string' && typeof obj.status === 'number' ? obj : null,
+ retryable,
+ method,
+ url,
+ });
+ }
+
+ toJSON() {
+ return {
+ name: this.name, code: this.code, status: this.status, title: this.title, detail: this.detail, type: this.type,
+ request_id: this.requestId, trace_id: this.traceId, errors: this.errors, message: this.message,
+ };
+ }
+}
+
+function stripQuery(url) {
+ return typeof url === 'string' ? url.split('?')[0] : '';
+}
+
+function isOpenVibeError(err) {
+ return err instanceof OpenVibeError || Boolean(err && err.name === 'OpenVibeError' && typeof err.code === 'string');
+}
+
+module.exports = { OpenVibeError, isOpenVibeError };
+},
+// ── 4: src/core/trace.js ───────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * W3C Trace Context (traceparent) propagation, browser-safe.
+ * Every SDK call is one span: it continues the caller's trace when one is given (same trace id,
+ * new span id) and starts a new trace otherwise.
+ */
+const { randomHex } = __load(5);
+
+const TRACEPARENT_RE = /^00-([0-9a-f]{32})-([0-9a-f]{16})-([0-9a-f]{2})$/;
+
+/** Parse a traceparent header; null when absent or malformed (all-zero ids are invalid). */
+function parseTraceparent(value) {
+ const m = typeof value === 'string' && value.trim().toLowerCase().match(TRACEPARENT_RE);
+ if (!m || /^0+$/.test(m[1]) || /^0+$/.test(m[2])) return null;
+ return { traceId: m[1], parentId: m[2], flags: m[3] };
+}
+
+/** A child span of `parent` (a traceparent string), or the root of a new trace. */
+function startSpan(parent) {
+ const p = parseTraceparent(parent);
+ const traceId = p ? p.traceId : randomHex(16);
+ const spanId = randomHex(8);
+ const flags = p ? p.flags : '01';
+ return { traceId, spanId, parentId: p ? p.parentId : null, traceparent: `00-${traceId}-${spanId}-${flags}` };
+}
+
+/**
+ * Context to continue from an incoming request's headers (Node IncomingMessage headers, a Fetch
+ * Headers object, or a plain object): { traceparent, requestId }.
+ */
+function contextFromHeaders(headers) {
+ const get = (k) => {
+ if (!headers) return undefined;
+ if (typeof headers.get === 'function') return headers.get(k) ?? undefined;
+ return headers[k] ?? headers[k.toLowerCase()];
+ };
+ const tp = get('traceparent');
+ const rid = get('x-openvibe-request-id');
+ return {
+ traceparent: parseTraceparent(tp) ? String(tp).trim().toLowerCase() : undefined,
+ requestId: typeof rid === 'string' && /^[A-Za-z0-9._-]{8,128}$/.test(rid) ? rid : undefined,
+ };
+}
+
+module.exports = { parseTraceparent, startSpan, contextFromHeaders };
+},
+// ── 5: src/core/ids.js ─────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * Identifiers, browser-safe (Web Crypto getRandomValues; no Node modules).
+ * ULIDs match openvibe-contracts ids.ulid(): Crockford base32, 48-bit ms time + 80-bit randomness.
+ */
+
+const ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+
+function randomBytes(n) {
+ const c = globalThis.crypto;
+ if (!c || typeof c.getRandomValues !== 'function') throw new Error('openvibe-sdk: Web Crypto (globalThis.crypto) is required');
+ return c.getRandomValues(new Uint8Array(n));
+}
+
+function randomHex(nBytes) {
+ let out = '';
+ for (const b of randomBytes(nBytes)) out += b.toString(16).padStart(2, '0');
+ return out;
+}
+
+function ulid(now = Date.now()) {
+ let time = '';
+ for (let t = now, i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ALPHABET[t % 32] + time;
+ let rand = '';
+ for (const b of randomBytes(16)) rand += ALPHABET[b & 31]; // 256 is a multiple of 32: unbiased
+ return time + rand;
+}
+
+/** evt_<ULID>, the event id format of events.event-envelope@1. */
+function newEventId(now) {
+ return `evt_${ulid(now)}`;
+}
+
+/** A fresh Idempotency-Key value. */
+function newIdempotencyKey() {
+ return `idem_${ulid()}`;
+}
+
+const SUBJECT_RE = { user: /^usr_[0-9A-HJKMNP-TV-Z]{26}$/, guest: /^gst_[0-9A-HJKMNP-TV-Z]{26}$/ };
+
+/** Is this a usr_… or gst_… subject id (the ids a service may act for)? */
+function isActingSubjectId(id) {
+ return typeof id === 'string' && (SUBJECT_RE.user.test(id) || SUBJECT_RE.guest.test(id));
+}
+
+module.exports = { ulid, newEventId, newIdempotencyKey, randomBytes, randomHex, isActingSubjectId };
+},
+// ── 6: src/core/semver.js ──────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * Just enough semver for version negotiation: parse, compare, and ranges made of space-separated
+ * comparators (>=, >, <=, <, =, bare), caret (^1.2.3), tilde (~1.2.3) and `||` alternatives.
+ */
+
+const VERSION_RE = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/;
+
+function parse(v) {
+ const m = typeof v === 'string' && v.trim().match(VERSION_RE);
+ return m ? { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] || null } : null;
+}
+
+function compare(a, b) {
+ const x = typeof a === 'string' ? parse(a) : a;
+ const y = typeof b === 'string' ? parse(b) : b;
+ if (!x || !y) throw new TypeError(`invalid version ${!x ? a : b}`);
+ for (const k of ['major', 'minor', 'patch']) if (x[k] !== y[k]) return x[k] < y[k] ? -1 : 1;
+ if (x.pre === y.pre) return 0;
+ if (!x.pre) return 1;
+ if (!y.pre) return -1;
+ return x.pre < y.pre ? -1 : 1;
+}
+
+function comparators(part) {
+ const out = [];
+ for (const tok of part.trim().split(/\s+/).filter(Boolean)) {
+ const m = tok.match(/^(>=|<=|>|<|=|\^|~)?(.+)$/);
+ const op = m[1] || '=';
+ const v = parse(m[2]);
+ if (!v) throw new TypeError(`invalid range token ${tok}`);
+ if (op === '^') {
+ const upper = v.major > 0 ? `${v.major + 1}.0.0` : v.minor > 0 ? `0.${v.minor + 1}.0` : `0.0.${v.patch + 1}`;
+ out.push(['>=', v], ['<', parse(upper)]);
+ } else if (op === '~') {
+ out.push(['>=', v], ['<', parse(`${v.major}.${v.minor + 1}.0`)]);
+ } else {
+ out.push([op, v]);
+ }
+ }
+ return out;
+}
+
+function satisfies(version, range) {
+ const v = parse(version);
+ if (!v) return false;
+ return String(range).split('||').some((part) => comparators(part).every(([op, c]) => {
+ const r = compare(v, c);
+ return op === '>=' ? r >= 0 : op === '>' ? r > 0 : op === '<=' ? r <= 0 : op === '<' ? r < 0 : r === 0;
+ }));
+}
+
+module.exports = { parse, compare, satisfies };
+},
+// ── 7: src/core/paginate.js ────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * Cursor pagination as an async iterator.
+ *
+ * for await (const item of paginate((cursor) => fetchPage(cursor), { cursor: 0 })) { … }
+ *
+ * `fetchPage(cursor)` returns { items, next }; iteration stops when `next` is null/undefined or
+ * does not move. Offset APIs use `offsetPager()` to produce the same shape.
+ */
+
+async function* paginate(fetchPage, { cursor, maxPages = Infinity, maxItems = Infinity } = {}) {
+ let c = cursor;
+ let pages = 0;
+ let items = 0;
+ while (pages < maxPages) {
+ pages++;
+ const page = await fetchPage(c);
+ for (const item of (page && page.items) || []) {
+ if (items >= maxItems) return;
+ items++;
+ yield item;
+ }
+ if (!page || page.next == null || page.next === c) return;
+ c = page.next;
+ }
+}
+
+/**
+ * For limit/offset endpoints: wraps `load(offset, limit)` -> { items, total? } into a fetchPage.
+ * Stops on a short page, or when `total` is known and reached.
+ */
+function offsetPager(load, { limit = 50 } = {}) {
+ return async (offset = 0) => {
+ const { items, total } = await load(offset, limit);
+ const end = offset + items.length;
+ const more = items.length === limit && (typeof total !== 'number' || end < total);
+ return { items, next: more ? end : null };
+ };
+}
+
+module.exports = { paginate, offsetPager };
+},
+// ── 8: src/auth/browser.js ─────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).
+ *
+ * The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's
+ * session). The code is then exchanged on your server (openvibe-sdk/auth server entry,
+ * exchangeCode()): a confidential client's credentials never reach a browser, and even a public
+ * developer app (no secret) is best served by keeping the token in an HttpOnly server session.
+ * This file holds no credentials and uses Web Crypto only.
+ *
+ * Two kinds of client use this flow:
+ * first-party sign-in scope 'profile theme' (the default); the exchange yields a user token
+ * developer app `audience` (openvibe.<service>) and `scope` = capability ids; the exchange
+ * yields a 5-minute app token acting for the person (no refresh token)
+ */
+const { OpenVibeError } = __load(3);
+const { randomBytes } = __load(5);
+const { DEFAULT_NETWORK } = __load(2);
+
+function base64url(bytes) {
+ let bin = '';
+ for (const b of bytes) bin += String.fromCharCode(b);
+ return btoa(bin).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+}
+
+/** A random code verifier: 43..128 characters of [A-Za-z0-9-._~] (default 64). */
+function createCodeVerifier(length = 64) {
+ if (!Number.isInteger(length) || length < 43 || length > 128) throw new RangeError('PKCE verifier length must be 43..128');
+ return base64url(randomBytes(Math.ceil(length * 3 / 4))).slice(0, length);
+}
+
+/** S256 challenge: BASE64URL(SHA-256(ASCII(verifier))). */
+async function pkceChallenge(verifier) {
+ const subtle = globalThis.crypto && globalThis.crypto.subtle;
+ if (!subtle) throw new Error('openvibe-sdk: Web Crypto subtle digest is required for PKCE');
+ const digest = await subtle.digest('SHA-256', new TextEncoder().encode(String(verifier)));
+ return base64url(new Uint8Array(digest));
+}
+
+/** { codeVerifier, codeChallenge, codeChallengeMethod: 'S256' } */
+async function createPkcePair(length) {
+ const codeVerifier = createCodeVerifier(length);
+ return { codeVerifier, codeChallenge: await pkceChallenge(codeVerifier), codeChallengeMethod: 'S256' };
+}
+
+/** An unguessable `state` value that ties the callback to this browser. */
+function createState() {
+ return base64url(randomBytes(24));
+}
+
+/**
+ * GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state
+ * &code_challenge&code_challenge_method=S256[&audience][&prompt=none]
+ *
+ * `scope` defaults to 'profile theme' (first-party sign-in) only when no `audience` is given. A
+ * developer app passes `audience` and its capability ids as `scope`; without a scope the code may
+ * yield every capability the app holds for that audience. Network refuses prompt=none for apps
+ * (error=interaction_required): a person always chooses to continue.
+ */
+function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope, audience, state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {
+ if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');
+ const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);
+ u.searchParams.set('response_type', 'code');
+ u.searchParams.set('client_id', clientId);
+ u.searchParams.set('redirect_uri', redirectUri);
+ const s = scope === undefined && !audience ? 'profile theme' : scope;
+ const scopeText = Array.isArray(s) ? s.filter(Boolean).join(' ') : s;
+ if (scopeText) u.searchParams.set('scope', scopeText);
+ if (audience) u.searchParams.set('audience', audience);
+ if (state) u.searchParams.set('state', state);
+ if (codeChallenge) {
+ u.searchParams.set('code_challenge', codeChallenge);
+ u.searchParams.set('code_challenge_method', codeChallengeMethod);
+ }
+ if (prompt) u.searchParams.set('prompt', prompt);
+ return u.toString();
+}
+
+/**
+ * Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.
+ * Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.
+ * A developer app passes { audience, scope: [capability ids] } and exchanges with the same audience.
+ */
+async function startAuthorization(opts = {}) {
+ const pair = await createPkcePair(opts.verifierLength);
+ const state = opts.state || createState();
+ const url = buildAuthorizeUrl({ ...opts, state, codeChallenge: pair.codeChallenge, codeChallengeMethod: 'S256' });
+ return { url, state, codeVerifier: pair.codeVerifier, codeChallenge: pair.codeChallenge };
+}
+
+/**
+ * Read the redirect back from the Network: { code, state } or throws OpenVibeError
+ * (oauth.<error> such as oauth.login_required after prompt=none, or oauth.state_mismatch).
+ */
+function readCallback(location, { expectedState } = {}) {
+ const u = new URL(String(location), 'http://localhost');
+ const p = u.searchParams;
+ const state = p.get('state') || '';
+ if (p.get('error')) {
+ throw new OpenVibeError({ code: `oauth.${p.get('error')}`, status: 400, detail: p.get('error_description') || p.get('error'), message: `authorization failed: ${p.get('error')}` });
+ }
+ if (expectedState !== undefined && state !== expectedState) {
+ throw new OpenVibeError({ code: 'oauth.state_mismatch', status: 400, message: 'the callback state does not match the one this browser started with' });
+ }
+ const code = p.get('code');
+ if (!code) throw new OpenVibeError({ code: 'oauth.missing_code', status: 400, message: 'the callback carries no authorization code' });
+ return { code, state };
+}
+
+/**
+ * The header and claims of a JWT, decoded WITHOUT checking its signature, issuer, audience or
+ * expiry: { header, claims } or null when it is not a JWT. For display and logging only (who a
+ * token names, what it may do, when it expires). Never authorize anything with it: a receiver
+ * verifies tokens with verifyUserToken() / verifyAppToken() (server).
+ */
+function decodeUnverified(token) {
+ const parts = typeof token === 'string' ? token.split('.') : [];
+ if (parts.length !== 3) return null;
+ try {
+ const part = (p) => {
+ const b64 = p.replace(/-/g, '+').replace(/_/g, '/');
+ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
+ };
+ const header = part(parts[0]);
+ const claims = part(parts[1]);
+ if (!header || typeof header !== 'object' || !claims || typeof claims !== 'object' || Array.isArray(claims)) return null;
+ return { header, claims };
+ } catch {
+ return null;
+ }
+}
+
+/** decodeUnverified(token).claims, or null. UNVERIFIED: see decodeUnverified(). */
+function unverifiedClaims(token) {
+ const d = decodeUnverified(token);
+ return d ? d.claims : null;
+}
+
+module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url, decodeUnverified, unverifiedClaims };
+},
+// ── 9: src/registry.js ─────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/registry: the ecosystem registry on OpenVibe.Network (/api/v1/registry), built from
+ * openvibe-contracts manifests. Public, cacheable, no token. Browser-safe.
+ *
+ * const registry = createRegistryClient(client);
+ * await registry.services({ status: 'alpha' });
+ * await registry.domain('openvibe.media'); // which service answers for a host
+ */
+const { isOpenVibeError } = __load(3);
+
+function createRegistryClient(client, { baseUrl } = {}) {
+ const get = (path, query) => client.json({ service: 'network', baseUrl, path: `/api/v1/registry${path}`, query, auth: false });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+
+ return {
+ /** The platform descriptor (cached by the client): origins, endpoints, contracts version. */
+ descriptor: (opts) => client.discover(opts),
+ /** Service manifests with polled runtime health. */
+ async services({ status } = {}) { return (await get('/services', { status })).services; },
+ /** One manifest (+ capability_details), or null. */
+ service: (id) => orNull(get(`/services/${encodeURIComponent(id)}`)),
+ async capabilities({ owner } = {}) { return (await get('/capabilities', { owner })).capabilities; },
+ capability: (id) => orNull(get(`/capabilities/${encodeURIComponent(id)}`)),
+ async namespaces() { return (await get('/namespaces')).namespaces; },
+ /** { version, contracts: [catalog entries] } */
+ contracts: () => get('/contracts'),
+ async topics() { return (await get('/topics')).topics; },
+ /** { domain, service } for a host name, or null. */
+ domain: (host) => orNull(get(`/domains/${encodeURIComponent(String(host).toLowerCase())}`)),
+ };
+}
+
+module.exports = { createRegistryClient };
+},
+// ── 10: src/modules.js ──────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/modules: per-subject module records on OpenVibe.Network (contracts
+ * modules.module-record@1). Every write names the revision it read (If-Match); a moved revision
+ * is 412 modules.revision_conflict, so two writers never silently overwrite each other.
+ * update(ns, fn) does the read-modify-write loop for you. Browser-safe.
+ *
+ * user GET/PUT/DELETE /api/modules/:ns, GET /api/modules, GET /api/modules/:ns/public/:subject
+ * service GET/PUT /internal/modules/:ns/:subject (token with network.modules.read / .write for ns)
+ */
+const { isOpenVibeError, OpenVibeError } = __load(3);
+
+const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
+const enc = encodeURIComponent;
+
+function createModulesClient(client, { baseUrl, maxAttempts = 5 } = {}) {
+ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const ifMatch = (revision) => (revision === undefined || revision === null ? undefined : `"${Number(revision)}"`);
+
+ async function readModifyWrite(read, write, fn, attempts) {
+ let last;
+ for (let i = 0; i < attempts; i++) {
+ const rec = await read();
+ const next = await fn(rec ? clone(rec.data) : undefined, rec);
+ if (next === undefined) return rec; // nothing to change
+ try {
+ return await write(next, rec ? rec.revision : 0);
+ } catch (err) {
+ if (!(isOpenVibeError(err) && err.status === 412)) throw err;
+ last = err; // someone else wrote first: read again
+ }
+ }
+ throw last || new OpenVibeError({ code: 'modules.revision_conflict', status: 412, message: 'update gave up' });
+ }
+
+ const mine = {
+ /** Every record of the signed-in subject: { subject, modules, namespaces }. */
+ list: () => call({ path: '/api/modules' }),
+ /** The record, or null when there is none yet. */
+ get: (ns) => orNull(call({ path: `/api/modules/${enc(ns)}` })),
+ /** Replace the data. revision = the one you read (0 for a new record); required for users. */
+ put(ns, data, { revision } = {}) {
+ if (revision === undefined) throw new TypeError('put: pass { revision } (0 for a new record)');
+ return call({ method: 'PUT', path: `/api/modules/${enc(ns)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) } });
+ },
+ /** true when a record was deleted, false when there was none. */
+ async delete(ns) {
+ try { await call({ method: 'DELETE', path: `/api/modules/${enc(ns)}` }); return true; } catch (err) {
+ if (isOpenVibeError(err) && err.status === 404) return false;
+ throw err;
+ }
+ },
+ /** Read, apply fn(data, record) -> newData (undefined = no change), write; retries on 412. */
+ update(ns, fn, { attempts = maxAttempts } = {}) {
+ return readModifyWrite(() => mine.get(ns), (data, rev) => mine.put(ns, data, { revision: rev }), fn, attempts);
+ },
+ /** Anyone: only the namespace's public fields. */
+ publicGet: (ns, subjectId) => orNull(call({ path: `/api/modules/${enc(ns)}/public/${enc(subjectId)}`, auth: false })),
+ };
+
+ const service = {
+ get: (ns, subjectId) => orNull(call({ path: `/internal/modules/${enc(ns)}/${enc(subjectId)}` })),
+ /** The owning service may write unconditionally (no revision); pass one to guard against races. */
+ put: (ns, subjectId, data, { revision } = {}) => call({
+ method: 'PUT', path: `/internal/modules/${enc(ns)}/${enc(subjectId)}`, json: { data }, headers: { 'If-Match': ifMatch(revision) },
+ }),
+ update(ns, subjectId, fn, { attempts = maxAttempts } = {}) {
+ return readModifyWrite(() => service.get(ns, subjectId), (data, rev) => service.put(ns, subjectId, data, { revision: rev }), fn, attempts);
+ },
+ };
+
+ return { ...mine, forSubject: service };
+}
+
+module.exports = { createModulesClient };
+},
+// ── 11: src/realtime.js ─────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/realtime: the browser realtime plane inside OpenVibe.Events (ADR-005), over SSE.
+ *
+ * const sub = subscribe(['live.stream.*'], (event, { seq }) => { … }, {
+ * client, // or url: 'https://events.openvibe.network'
+ * lastEventId: savedSeq, // resume after a reload
+ * onGap: (gap) => refetchState(),
+ * });
+ * sub.close();
+ *
+ * Transport: EventSource when the environment has one and no Bearer token is needed (cookies ride
+ * along with withCredentials); otherwise fetch with a streamed body (Node, or a token). Either
+ * way it resumes from the last seq it saw (Last-Event-ID), skips anything it already delivered,
+ * and reports an `event: gap` (events missed beyond retention or replay limits) through onGap.
+ * Browser-safe.
+ */
+const { OpenVibeError } = __load(3);
+
+const DEFAULT_ORIGIN = 'https://events.openvibe.network';
+const FATAL = new Set([400, 401, 403, 404]);
+const sleep = (ms, signal) => new Promise((resolve) => {
+ const t = setTimeout(resolve, ms);
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
+});
+
+function subscribe(topics, onEvent, opts = {}) {
+ const list = (Array.isArray(topics) ? topics : String(topics).split(',')).map((t) => String(t).trim()).filter(Boolean);
+ if (!list.length) throw new TypeError('subscribe: at least one topic pattern');
+ if (typeof onEvent !== 'function') throw new TypeError('subscribe: onEvent must be a function');
+ const {
+ client, url, baseUrl, lastEventId = null, onGap, onOpen, onError, token, getToken,
+ withCredentials = true, transport = 'auto', reconnectDelayMs = 3000, maxReconnectDelayMs = 30000,
+ } = opts;
+ const fetchImpl = opts.fetch || (typeof globalThis.fetch === 'function' ? globalThis.fetch.bind(globalThis) : null);
+ const ES = opts.EventSource !== undefined ? opts.EventSource : globalThis.EventSource;
+
+ const state = { lastSeq: lastEventId != null && lastEventId !== '' ? Number(lastEventId) : null, closed: false, transport: null, connected: false };
+ const stop = new AbortController();
+ let es = null;
+ let retryMs = reconnectDelayMs;
+ let failures = 0;
+
+ const report = (err) => { if (onError) { try { onError(err); } catch { /* the error hook must not break the loop */ } } };
+
+ function dispatch(type, data, id) {
+ if (type === 'gap') {
+ let gap = null;
+ try { gap = JSON.parse(data); } catch { gap = { reason: 'unknown' }; }
+ if (onGap) { try { onGap(gap); } catch (err) { report(err); } }
+ return;
+ }
+ if (type !== 'message' || !data) return;
+ let msg;
+ try { msg = JSON.parse(data); } catch { report(new OpenVibeError({ code: 'sdk.bad_response', message: 'realtime: undecodable message' })); return; }
+ const seq = Number(id !== undefined && id !== '' ? id : msg && msg.seq);
+ if (Number.isFinite(seq)) {
+ if (state.lastSeq != null && seq <= state.lastSeq) return; // already delivered
+ state.lastSeq = seq;
+ }
+ try { onEvent(msg && msg.event, { seq }); } catch (err) { report(err); }
+ }
+
+ async function streamUrl() {
+ let base = url || baseUrl;
+ if (!base && client) {
+ try { base = await client.origin('events'); } catch { base = DEFAULT_ORIGIN; }
+ }
+ base = String(base || DEFAULT_ORIGIN).replace(/\/+$/, '');
+ const u = new URL(base.endsWith('/realtime/stream') ? base : `${base}/realtime/stream`);
+ u.searchParams.set('topics', list.join(','));
+ return u;
+ }
+
+ // A Bearer token: given here, or the client's own (a service token client, a user JWT).
+ const clientAuth = client && client.options && (client.options.token || client.options.getToken) ? client.options : null;
+ async function bearer() {
+ if (token) return token;
+ const fn = getToken || (clientAuth && clientAuth.getToken);
+ if (fn) return fn({ service: 'events', audience: client ? client.audienceOf('events') : 'openvibe.events' });
+ return clientAuth ? clientAuth.token : null;
+ }
+
+ // ── fetch transport ─────────────────────────────────────
+ async function runFetch() {
+ if (!fetchImpl) throw new TypeError('realtime: no fetch available');
+ const u = await streamUrl();
+ while (!state.closed) {
+ const ctrl = new AbortController();
+ const abort = () => ctrl.abort();
+ stop.signal.addEventListener('abort', abort, { once: true });
+ try {
+ const headers = { Accept: 'text/event-stream', 'Cache-Control': 'no-cache' };
+ if (state.lastSeq != null) headers['Last-Event-ID'] = String(state.lastSeq);
+ const t = await bearer();
+ if (t) headers.Authorization = `Bearer ${t}`;
+ const init = { headers, signal: ctrl.signal, cache: 'no-store' };
+ if (withCredentials) init.credentials = 'include';
+ const res = await fetchImpl(u.toString(), init);
+ if (!res.ok) {
+ const body = await res.text().catch(() => '');
+ let parsed = body;
+ try { parsed = JSON.parse(body); } catch { /* text */ }
+ const err = OpenVibeError.fromResponse({ status: res.status, body: parsed, method: 'GET', url: u.toString(), retryable: !FATAL.has(res.status) });
+ report(err);
+ if (FATAL.has(res.status)) { state.closed = true; return; }
+ } else {
+ failures = 0;
+ state.connected = true;
+ if (onOpen) { try { onOpen(); } catch (err) { report(err); } }
+ await readSse(res.body, dispatch, (ms) => { retryMs = ms; });
+ state.connected = false;
+ }
+ } catch (err) {
+ state.connected = false;
+ if (state.closed) return;
+ report(err && err.name === 'OpenVibeError' ? err : new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `realtime: ${err && err.message}`, cause: err }));
+ } finally {
+ stop.signal.removeEventListener('abort', abort);
+ }
+ if (state.closed) return;
+ failures++;
+ await sleep(Math.min(maxReconnectDelayMs, retryMs * 2 ** Math.max(0, failures - 1)), stop.signal);
+ }
+ }
+
+ // ── EventSource transport ───────────────────────────────
+ async function runEventSource() {
+ const u = await streamUrl();
+ const open = () => {
+ if (state.closed) return;
+ const target = new URL(u.toString());
+ if (state.lastSeq != null) target.searchParams.set('last_event_id', String(state.lastSeq));
+ es = new ES(target.toString(), { withCredentials });
+ es.onopen = () => { failures = 0; state.connected = true; if (onOpen) { try { onOpen(); } catch (err) { report(err); } } };
+ es.onmessage = (m) => dispatch('message', m.data, m.lastEventId);
+ es.addEventListener('gap', (m) => dispatch('gap', m.data));
+ es.onerror = () => {
+ state.connected = false;
+ // CONNECTING (0): the browser reconnects by itself and sends Last-Event-ID.
+ // CLOSED (2): it gave up (HTTP error); reconnect ourselves from the last seq.
+ if (es.readyState === 2 && !state.closed) {
+ report(new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: 'realtime: stream closed' }));
+ failures++;
+ sleep(Math.min(maxReconnectDelayMs, reconnectDelayMs * 2 ** Math.max(0, failures - 1)), stop.signal).then(open);
+ }
+ };
+ };
+ open();
+ }
+
+ const needsHeader = Boolean(token || getToken || clientAuth);
+ const useEs = transport === 'eventsource' || (transport === 'auto' && typeof ES === 'function' && !needsHeader);
+ state.transport = useEs ? 'eventsource' : 'fetch';
+ const done = (useEs ? runEventSource() : runFetch()).catch((err) => { report(err); });
+
+ return {
+ close() {
+ state.closed = true;
+ stop.abort();
+ if (es) { try { es.close(); } catch { /* already closed */ } }
+ },
+ get lastEventId() { return state.lastSeq; },
+ get transport() { return state.transport; },
+ get connected() { return state.connected; },
+ get closed() { return state.closed; },
+ /** Resolves when the fetch loop has ended (after close() or a fatal error). */
+ done,
+ };
+}
+
+/**
+ * parseSSE(body, { onRetry }) -> async iterator of { event, data, id } (WHATWG event-stream rules).
+ *
+ * for await (const { event, data, id } of parseSSE(res.body)) { … }
+ *
+ * `body` is a ReadableStream<Uint8Array> (fetch), or any async iterable of Uint8Array/string chunks
+ * (a Node stream). `event` defaults to 'message'; `id` is the id field of that event (undefined when
+ * it had none); comments are skipped; `retry: <ms>` is passed to onRetry. A line split across chunks,
+ * including a \r\n pair, is reassembled. Breaking out of the loop releases the stream.
+ */
+async function* parseSSE(body, { onRetry } = {}) {
+ if (!body || (typeof body.getReader !== 'function' && typeof body[Symbol.asyncIterator] !== 'function')) {
+ throw new TypeError('parseSSE: pass a ReadableStream or an async iterable body');
+ }
+ const decoder = new TextDecoder();
+ let buf = '';
+ let type = '';
+ let data = [];
+ let id;
+ const out = [];
+ const line = (l) => {
+ if (l === '') {
+ if (data.length || type) out.push({ event: type || 'message', data: data.join('\n'), id });
+ type = ''; data = []; id = undefined;
+ return;
+ }
+ if (l[0] === ':') return;
+ const i = l.indexOf(':');
+ const field = i < 0 ? l : l.slice(0, i);
+ let value = i < 0 ? '' : l.slice(i + 1);
+ if (value[0] === ' ') value = value.slice(1);
+ if (field === 'event') type = value;
+ else if (field === 'data') data.push(value);
+ else if (field === 'id' && !value.includes('\0')) id = value;
+ else if (field === 'retry' && /^\d+$/.test(value) && onRetry) onRetry(Number(value));
+ };
+ const reader = typeof body.getReader === 'function' ? body.getReader() : null;
+ const iter = reader ? null : body[Symbol.asyncIterator]();
+ try {
+ for (;;) {
+ const { value, done } = reader ? await reader.read() : await iter.next();
+ if (done) break;
+ buf += typeof value === 'string' ? value : decoder.decode(value, { stream: true });
+ let m;
+ while ((m = /\r\n|\r|\n/.exec(buf))) {
+ if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunks
+ line(buf.slice(0, m.index));
+ buf = buf.slice(m.index + m[0].length);
+ }
+ while (out.length) yield out.shift();
+ }
+ } finally {
+ if (reader) {
+ try { await reader.cancel(); } catch { /* stream already gone */ }
+ try { reader.releaseLock(); } catch { /* already released */ }
+ } else if (iter && typeof iter.return === 'function') {
+ try { await iter.return(); } catch { /* already closed */ }
+ }
+ }
+}
+
+/** The realtime loop's reader: every parsed event goes to dispatch(type, data, id). */
+async function readSse(body, dispatch, onRetry) {
+ for await (const e of parseSSE(body, { onRetry })) dispatch(e.event, e.data, e.id);
+}
+
+function createRealtimeClient(client, defaults = {}) {
+ return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };
+}
+
+module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };
+},
+// ── 12: src/media.js ────────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
+ *
+ * Credentials: the app's API key (Authorization: Bearer <app key>, server side only; it is a
+ * secret) or a Network service token holding media.object.upload for the app's namespace
+ * (accepted on upload only). Media refuses a user JWT here: a browser uploads through its own
+ * app server, which names the user with X-OV-User-Id (the `actingUserId` option).
+ * Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
+ */
+const { isOpenVibeError } = __load(3);
+const { paginate, offsetPager } = __load(7);
+
+const DEFAULT_PUBLIC_ORIGIN = 'https://openvibe.media';
+const enc = encodeURIComponent;
+
+/** Absolute public URLs for Media objects (no auth unless the item is private). */
+function mediaUrls(origin = DEFAULT_PUBLIC_ORIGIN) {
+ const o = String(origin).replace(/\/+$/, '');
+ return {
+ origin: o,
+ file: (key) => `${o}/f/${enc(key)}`,
+ vod: (id) => `${o}/v/${enc(id)}`,
+ clip: (id) => `${o}/c/${enc(id)}`,
+ thumbnail: (name) => `${o}/t/${enc(name)}`,
+ paste: (slug) => `${o}/p/${enc(slug)}`,
+ pasteRaw: (slug) => `${o}/p/${enc(slug)}/raw`,
+ pasteScreenshot: (slug) => `${o}/p/${enc(slug)}/screenshot`,
+ vodTranscript: (id) => `${o}/v/${enc(id)}/transcript.json`,
+ liveTranscript: (sel, { limit, app } = {}) => withQuery(`${o}/live/${enc(sel)}/transcript.json`, { limit, app }),
+ liveFrame: (sel, { width, app, format } = {}) => withQuery(`${o}/live/${enc(sel)}/frame.jpg`, { w: width, app, format }),
+ /** Make a Media-relative path (e.g. an upload's `/f/<key>`) absolute. */
+ absolute: (path) => (/^https?:\/\//.test(path) ? path : `${o}${String(path).startsWith('/') ? '' : '/'}${path}`),
+ };
+}
+
+function withQuery(u, q) {
+ const qs = new URLSearchParams(Object.entries(q).filter(([, v]) => v != null && v !== '').map(([k, v]) => [k, String(v)])).toString();
+ return qs ? `${u}?${qs}` : u;
+}
+
+function toBlob(file, contentType) {
+ if (typeof Blob !== 'undefined' && file instanceof Blob) return file;
+ if (typeof file === 'string' || file instanceof ArrayBuffer || ArrayBuffer.isView(file)) {
+ return new Blob([file], contentType ? { type: contentType } : undefined);
+ }
+ throw new TypeError('upload: pass a Blob/File, ArrayBuffer, typed array (Buffer) or string');
+}
+
+function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOrigin } = {}) {
+ if (!app) throw new TypeError('createMediaClient: app (the tenant id, e.g. "live") is required');
+ const base = `/api/v1/${enc(app)}`;
+ const urls = mediaUrls(publicOrigin || DEFAULT_PUBLIC_ORIGIN);
+ const call = (opts, perCall = {}) => {
+ const headers = { ...opts.headers };
+ const acting = perCall.actingUserId !== undefined ? perCall.actingUserId : actingUserId;
+ if (acting != null) headers['X-OV-User-Id'] = String(acting);
+ return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
+ };
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const withPublic = (meta) => (meta && meta.url ? { ...meta, public_url: urls.absolute(meta.url) } : meta);
+
+ const files = {
+ /**
+ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.
+ * Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
+ */
+ async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
+ const blob = toBlob(file, contentType);
+ const form = new FormData();
+ if (userId != null) form.append('user_id', String(userId));
+ form.append('file', blob, filename || (file && file.name) || 'file');
+ return withPublic(await call({ method: 'POST', path: `${base}/files`, form, idempotent: true, signal }, { actingUserId: acting }));
+ },
+ /** { files, used_bytes, quota_bytes, limit, offset } */
+ async list({ limit, offset } = {}) {
+ const out = await call({ path: `${base}/files`, query: { limit, offset } });
+ return { ...out, files: (out.files || []).map(withPublic) };
+ },
+ /** Async iterator over every file of the app. */
+ iterate({ pageSize = 100 } = {}) {
+ return paginate(offsetPager(async (offset, limit) => ({ items: (await files.list({ limit, offset })).files }), { limit: pageSize }), { cursor: 0 });
+ },
+ get: async (key) => withPublic(await orNull(call({ path: `${base}/files/${enc(key)}` }))),
+ /** true when deleted, false when there was no such file. */
+ async delete(key, { actingUserId: acting } = {}) {
+ try { await call({ method: 'DELETE', path: `${base}/files/${enc(key)}` }, { actingUserId: acting }); return true; } catch (err) {
+ if (isOpenVibeError(err) && err.status === 404) return false;
+ throw err;
+ }
+ },
+ };
+
+ return { app, files, urls, upload: files.upload };
+}
+
+module.exports = { createMediaClient, mediaUrls, DEFAULT_PUBLIC_ORIGIN };
+},
+// ── 13: src/community.js ────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/community: OpenVibe.Community pastes (/api/pastes). Browser-safe.
+ *
+ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or
+ * a Bearer user JWT) or anonymously. First-party services call with a Network service token
+ * (audience openvibe.community, community.paste.create / .write / .moderate) and say who they
+ * act for:
+ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject (without it a write is anonymous)
+ * origin: 'ai' -> X-OV-Origin: ai (AI output; never attributed to a person,
+ * so no subject is sent with it)
+ * sourceRef: { service, type, id } -> X-OV-Source-Ref (EntityRef, e.g. the stream it came from)
+ * staff: true -> X-OV-Staff: 1 (the acting person is staff; needs .moderate)
+ * Community ignores X-OV-* headers from browsers.
+ *
+ * TODO: comments on other content, the forum and Pulse APIs are being added to Community now and
+ * are not wrapped yet; paste comments are.
+ */
+const { isOpenVibeError } = __load(3);
+const { isActingSubjectId } = __load(5);
+const { paginate, offsetPager } = __load(7);
+
+const enc = encodeURIComponent;
+
+function actingHeaders({ actingSubject, origin, sourceRef, staff } = {}) {
+ const h = {};
+ if (origin !== undefined && origin !== null) {
+ if (origin !== 'ai' && origin !== 'user') throw new TypeError('origin must be "ai" or "user"');
+ h['X-OV-Origin'] = origin;
+ }
+ if (actingSubject && origin !== 'ai') {
+ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
+ h['X-OV-Subject'] = actingSubject;
+ }
+ if (sourceRef) h['X-OV-Source-Ref'] = typeof sourceRef === 'string' ? sourceRef : JSON.stringify(sourceRef);
+ if (staff) h['X-OV-Staff'] = '1';
+ return h;
+}
+
+function createCommunityClient(client, defaults = {}) {
+ const { baseUrl } = defaults;
+ const pick = (o) => ({ actingSubject: o.actingSubject, origin: o.origin, sourceRef: o.sourceRef, staff: o.staff });
+ const base = pick(defaults);
+
+ function call(opts, perCall = {}) {
+ const acting = { ...base };
+ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;
+ return client.json({
+ service: 'community', baseUrl, audience: 'openvibe.community', ...opts,
+ headers: { ...actingHeaders(acting), ...opts.headers }, signal: perCall.signal,
+ });
+ }
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const once = { idempotencyKey: false }; // Community does not dedupe these writes: never repeat them
+
+ const pastes = {
+ /** { pastes, total, limit, offset } — query: limit, offset, type, search, sort, origin, username */
+ list: (query = {}, o = {}) => call({ path: '/api/pastes', query }, o),
+ iterate(query = {}, o = {}) {
+ const limit = query.limit || 50;
+ return paginate(offsetPager(async (offset) => {
+ const page = await pastes.list({ ...query, limit, offset }, o);
+ return { items: page.pastes || [], total: page.total };
+ }, { limit }), { cursor: query.offset || 0 });
+ },
+ /** { paste, liked } or null. noView: true does not count a view. */
+ get: (slug, { noView, ...o } = {}) => orNull(call({ path: `/api/pastes/${enc(slug)}`, query: { no_view: noView ? '1' : undefined } }, o)),
+ /**
+ * Text paste: { title?, content, language?, visibility?, burn_after_read?, is_nsfw?, … }.
+ * Screenshot paste: { screenshot: Blob|File|Buffer, filename?, title?, visibility? } (multipart).
+ * -> { id, slug, url, … }
+ */
+ create(input = {}, o = {}) {
+ if (input.screenshot != null) {
+ const { screenshot, filename, contentType, ...fields } = input;
+ const form = new FormData();
+ for (const [k, v] of Object.entries(fields)) if (v !== undefined && v !== null) form.append(k, typeof v === 'object' ? JSON.stringify(v) : String(v));
+ const blob = typeof Blob !== 'undefined' && screenshot instanceof Blob ? screenshot : new Blob([screenshot], { type: contentType || 'image/png' });
+ form.append('screenshot', blob, filename || (screenshot && screenshot.name) || 'screenshot.png');
+ return call({ method: 'POST', path: '/api/pastes', form, ...once }, o);
+ }
+ return call({ method: 'POST', path: '/api/pastes', json: input, ...once }, o);
+ },
+ /** { paste } — owner or staff. */
+ update: (slug, patch, o = {}) => call({ method: 'PUT', path: `/api/pastes/${enc(slug)}`, json: patch }, o),
+ delete: (slug, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}` }, o),
+ /** A copy of a text paste owned by the caller -> { id, slug, url, … } */
+ fork: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/fork`, json: {}, ...once }, o),
+ /** Toggles the caller's like -> { liked, likes }. */
+ like: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/like`, ...once }, o),
+ /** Counts a copy -> { copies }. */
+ copy: (slug, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/copy`, ...once }, o),
+ versions: (slug, o = {}) => call({ path: `/api/pastes/${enc(slug)}/versions` }, o),
+ byUser: (username, query = {}, o = {}) => call({ path: `/api/pastes/by-user/${enc(username)}`, query }, o),
+ config: (o = {}) => call({ path: '/api/pastes/config' }, o),
+ comments: {
+ /** { comments, total } */
+ list: (slug, query = {}, o = {}) => call({ path: `/api/pastes/${enc(slug)}/comments`, query }, o),
+ /** { content, parent_id? } -> { comment } */
+ create: (slug, input, o = {}) => call({ method: 'POST', path: `/api/pastes/${enc(slug)}/comments`, json: input, ...once }, o),
+ delete: (slug, commentId, o = {}) => call({ method: 'DELETE', path: `/api/pastes/${enc(slug)}/comments/${enc(commentId)}` }, o),
+ },
+ };
+
+ return {
+ pastes,
+ /** The same client acting for someone else: as('usr_…') or as({ actingSubject, origin, sourceRef, staff }). */
+ as(who) {
+ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };
+ return createCommunityClient(client, { ...defaults, ...next });
+ },
+ headers: () => actingHeaders(base),
+ };
+}
+
+module.exports = { createCommunityClient, actingHeaders };
+},
+// ── 14: src/jobs.js ─────────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs
+ * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.
+ *
+ * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
+ * const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },
+ * files: [{ name: 'a.png', data: bytes }], idempotencyKey });
+ * for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops
+ * const done = await jobs.get(job.id);
+ * const res = await jobs.file(job.id, 0); // raw Response: stream or save it
+ *
+ * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,
+ * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same
+ * answer as for a job that does not exist.
+ *
+ * POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);
+ * Idempotency-Key -> 202 new | 200 + Idempotent-Replayed
+ * GET /api/v1/jobs/:id
+ * DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished
+ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;
+ * 204 when the job finished and nothing is newer
+ * GET /api/v1/jobs/:id/files/:n a result file
+ */
+const { OpenVibeError, isOpenVibeError } = __load(3);
+const { newIdempotencyKey } = __load(5);
+const { parseSSE } = __load(11);
+
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const TERMINAL_EVENTS = new Set(['job.succeeded', 'job.failed', 'job.cancelled']);
+const enc = encodeURIComponent;
+const sleep = (ms, signal) => new Promise((resolve) => {
+ const t = setTimeout(resolve, ms);
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
+});
+
+function toBlob(data, type) {
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
+ if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);
+ throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');
+}
+
+/** Is this job finished (succeeded, failed or cancelled)? */
+function isTerminal(job) {
+ return Boolean(job && TERMINAL.has(job.state));
+}
+
+function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {
+ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+
+ /**
+ * submit({ type, input = {}, files, idempotencyKey, signal }) -> { job, replayed, idempotencyKey }
+ *
+ * Always carries an Idempotency-Key (one is generated when you pass none), so retries after a
+ * timeout or a 503 are safe. To survive a crash between submitting and saving the job id, derive
+ * the key from the request (or store it first) and submit again with the same key: Tools answers
+ * with the SAME job (`replayed: true`). A different request under a used key is 409
+ * tools.job.idempotency_conflict.
+ */
+ async function submit({ type, input = {}, files, idempotencyKey, signal } = {}) {
+ if (!type) throw new TypeError('jobs.submit: type is required (e.g. img.process)');
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('jobs.submit: input must be an object');
+ const key = idempotencyKey || newIdempotencyKey();
+ const list = files == null ? [] : Array.isArray(files) ? files : [files];
+ const opts = { method: 'POST', path: '/api/v1/jobs', idempotencyKey: key, signal };
+ if (list.length) {
+ const form = new FormData();
+ form.append('type', String(type));
+ form.append('input', JSON.stringify(input));
+ for (const f of list) {
+ const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;
+ const blob = isBlob ? f : toBlob(f && f.data, f && f.type);
+ form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');
+ }
+ opts.form = form;
+ } else {
+ opts.json = { type: String(type), input };
+ }
+ const res = await call(opts);
+ return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };
+ }
+
+ /** The job, or null when it does not exist or is not yours. */
+ async function get(id, { signal } = {}) {
+ const res = await orNull(call({ path: `/api/v1/jobs/${enc(id)}`, signal }));
+ return res ? res.data : null;
+ }
+
+ /**
+ * Cancel -> the job (state `cancelled`, or `cancel_requested: true` while it is running).
+ * A finished job answers 409 tools.job.already_finished (thrown).
+ */
+ async function cancel(id, { signal } = {}) {
+ return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;
+ }
+
+ /**
+ * events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })
+ * -> async iterator of { id, event, job }
+ *
+ * Follows the job's SSE stream until the job finishes. When the connection drops it reconnects
+ * with Last-Event-ID = the last id it yielded, so every event arrives once and in order; a 204
+ * (finished, nothing newer) ends the iteration. Keep `id` somewhere durable and pass it back as
+ * `lastEventId` to reattach after a restart. 4xx answers (401 after one token refresh, 403,
+ * 404) are thrown; network errors and 5xx reconnect, up to maxReconnects in a row.
+ */
+ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
+ let last = Number(lastEventId) || 0;
+ let retryMs = reconnectDelayMs;
+ let failures = 0;
+ while (!(signal && signal.aborted)) {
+ let res;
+ let delivered = false;
+ try {
+ res = await call({
+ path: `/api/v1/jobs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
+ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(last) },
+ });
+ if (res.status === 204) return;
+ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { retryMs = ms; } })) {
+ let job = null;
+ try { job = JSON.parse(msg.data); } catch { job = null; }
+ const n = Number(msg.id);
+ if (Number.isFinite(n) && msg.id !== undefined && msg.id !== '') {
+ if (n <= last) continue; // already yielded before a reconnect
+ last = n;
+ }
+ delivered = true;
+ failures = 0;
+ yield { id: Number.isFinite(n) ? n : null, event: msg.event, job };
+ if (TERMINAL_EVENTS.has(msg.event) || (job && isTerminal(job) && msg.event !== 'job.progress')) return;
+ }
+ } catch (err) {
+ if (signal && signal.aborted) return;
+ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;
+ if (!isOpenVibeError(err) && !(err && (err.name === 'TypeError' || err.name === 'AbortError'))) throw err;
+ }
+ if (!delivered) failures++;
+ if (failures > maxReconnects) {
+ throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `jobs: the event stream of ${id} failed ${failures} times in a row` });
+ }
+ await sleep(retryMs, signal);
+ }
+ }
+
+ /**
+ * wait(id, { lastEventId, signal, onEvent }) -> the finished job (GET after the terminal event).
+ * onEvent({ id, event, job }) sees every event, e.g. to save `id` and show progress.
+ */
+ async function wait(id, { onEvent, ...opts } = {}) {
+ for await (const e of events(id, opts)) if (onEvent) await onEvent(e);
+ return get(id, { signal: opts.signal });
+ }
+
+ /**
+ * A result file as the raw fetch Response (body unread): stream it, or
+ * `Buffer.from(await res.arrayBuffer())`. 409 tools.job.not_ready until the job succeeded.
+ */
+ async function file(id, n = 0, { signal, inline = false } = {}) {
+ return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;
+ }
+
+ return { submit, get, cancel, events, wait, file };
+}
+
+module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
+},
+// ── 15: src/projects.js ─────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/projects: developer projects on OpenVibe.Network (/api/v1/projects, ADR-014), the
+ * API the developer portal (OpenVibe.Codes) and your own tooling use. Browser-safe code.
+ *
+ * Auth: a Network USER access token (Authorization: Bearer). The API reads no cookies, and service
+ * or app tokens get 401. Errors are problem+json with stable codes (project.not_found,
+ * grant.beyond_allowance, …); a project you are not a member of is 404, never 403.
+ *
+ * const projects = createProjectsClient(createClient({ token: userAccessToken }));
+ * const prj = await projects.create({ name: 'My app' });
+ * const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: [] });
+ * app.credential; // the secret, returned ONCE: store it now
+ * await projects.grants.request(prj.id, app.id, 'media.object.upload');
+ *
+ * Secrets appear only in the responses of apps.create() (confidential apps) and
+ * credentials.rotate(). Those two calls are never retried: a retry would mint a second secret.
+ * Other mutations that Network does not dedupe are not retried either; reads, PUT and DELETE are.
+ */
+const { isOpenVibeError } = __load(3);
+const { paginate } = __load(7);
+
+const enc = encodeURIComponent;
+
+function createProjectsClient(client, { baseUrl } = {}) {
+ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });
+ const once = (opts) => call({ idempotencyKey: false, ...opts }); // never repeated automatically
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const P = (project) => `/api/v1/projects/${enc(project)}`;
+ const A = (project, app) => `${P(project)}/apps/${enc(app)}`;
+
+ const members = {
+ async list(project) { return (await call({ path: `${P(project)}/members` })).members; },
+ /** { username | subjectId, role: 'admin' | 'developer' | 'viewer' } */
+ add: (project, { username, subjectId, role } = {}) => once({ method: 'POST', path: `${P(project)}/members`, json: { username, subject_id: subjectId, role } }),
+ update: (project, subject, { role } = {}) => once({ method: 'PATCH', path: `${P(project)}/members/${enc(subject)}`, json: { role } }),
+ /** Remove a member (or yourself: leave). The owner cannot leave. */
+ remove: (project, subject) => call({ method: 'DELETE', path: `${P(project)}/members/${enc(subject)}` }),
+ };
+
+ const apps = {
+ async list(project) { return (await call({ path: `${P(project)}/apps` })).apps; },
+ get: (project, app) => orNull(call({ path: A(project, app) })),
+ /**
+ * { name, environment: 'sandbox' | 'production', type: 'confidential' | 'public', redirectUris }
+ * -> the app; a confidential app also carries `credential` with its secret, shown once.
+ */
+ create: (project, { name, environment = 'sandbox', type = 'confidential', redirectUris = [] } = {}) => once({
+ method: 'POST', path: `${P(project)}/apps`, json: { name, environment, type, redirect_uris: redirectUris },
+ }),
+ update: (project, app, { name, redirectUris } = {}) => once({ method: 'PATCH', path: A(project, app), json: { name, redirect_uris: redirectUris } }),
+ /** Revoke the app: its credentials and pending codes fail at once; issued tokens end within 5 minutes. */
+ revoke: (project, app) => call({ method: 'DELETE', path: A(project, app) }),
+ };
+
+ const credentials = {
+ /** Metadata only (id, last four characters, state, dates): secrets cannot be read back. */
+ async list(project, app) { return (await call({ path: `${A(project, app)}/credentials` })).credentials; },
+ /** A new secret, returned once. Older secrets keep working for overlapSeconds (Network default 86400). */
+ rotate: (project, app, { overlapSeconds } = {}) => once({ method: 'POST', path: `${A(project, app)}/credentials/rotate`, json: { overlap_seconds: overlapSeconds } }),
+ revoke: (project, app, credential) => once({ method: 'POST', path: `${A(project, app)}/credentials/${enc(credential)}/revoke` }),
+ };
+
+ const grants = {
+ async list(project, app) { return (await call({ path: `${A(project, app)}/grants` })).grants; },
+ /** Request a capability. Owners and admins get it approved at once when it is inside the allowance. */
+ request: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants`, json: { capability } }),
+ approve: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/approve` }),
+ deny: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/deny` }),
+ revoke: (project, app, capability) => call({ method: 'DELETE', path: `${A(project, app)}/grants/${enc(capability)}` }),
+ };
+
+ const quotas = {
+ async list(project) { return (await call({ path: `${P(project)}/quotas` })).quotas; },
+ /** Staff only. { limit, window: minute|hour|day|month|total, unit } */
+ set: (project, capability, { limit, window, unit } = {}) => call({ method: 'PUT', path: `${P(project)}/quotas/${enc(capability)}`, json: { limit, window, unit } }),
+ delete: (project, capability) => call({ method: 'DELETE', path: `${P(project)}/quotas/${enc(capability)}` }),
+ };
+
+ /** One page of the audit, newest first: { entries, next_before }. Admins, owners and staff. */
+ const audit = (project, { before, limit } = {}) => call({ path: `${P(project)}/audit`, query: { before, limit } });
+
+ return {
+ /** Capabilities an app could ever be granted (active + public). */
+ async catalog() { return (await call({ path: '/api/v1/projects/catalog' })).capabilities; },
+ /** Your projects; staff may pass { all: true }. */
+ async list({ all } = {}) { return (await call({ path: '/api/v1/projects', query: { all } })).projects; },
+ /** { name } -> the project; you are its owner. A new project is sandbox only. */
+ create: ({ name } = {}) => once({ method: 'POST', path: '/api/v1/projects', json: { name } }),
+ /** The project with your role, allowance, environments and counts, or null (404 for non-members). */
+ get: (project) => orNull(call({ path: P(project) })),
+ update: (project, { name } = {}) => once({ method: 'PATCH', path: P(project), json: { name } }),
+ /** Irreversible: revokes every app. Owner or staff. */
+ archive: (project) => once({ method: 'POST', path: `${P(project)}/archive` }),
+ /** Staff only: the capabilities this project's apps may hold -> { allowance, trimmed }. */
+ setAllowance: (project, capabilities) => call({ method: 'PUT', path: `${P(project)}/allowance`, json: { capabilities } }),
+ /** Staff only: 'sandbox' | 'sandbox+production'. */
+ setEnvironmentPolicy: (project, environmentPolicy) => call({ method: 'PUT', path: `${P(project)}/environment-policy`, json: { environment_policy: environmentPolicy } }),
+ members,
+ apps,
+ credentials,
+ grants,
+ quotas,
+ audit,
+ /** Every audit entry, newest first, across pages. */
+ iterateAudit: (project, { pageSize = 100 } = {}) => paginate(async (before) => {
+ const page = await audit(project, { before, limit: pageSize });
+ return { items: page.entries || [], next: page.next_before ?? null };
+ }, { cursor: undefined }),
+ };
+}
+
+module.exports = { createProjectsClient };
+},
+];
+const __cache = new Array(__defs.length);
+function __load(i) {
+ if (__cache[i]) return __cache[i].exports;
+ const module = { exports: {} };
+ __cache[i] = module;
+ __defs[i].call(module.exports, module, module.exports);
+ return module.exports;
+}
+const sdk = __load(0);
+
+export const {
+ createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
+ startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
+ satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
+ registry, modules, realtime, media, community, jobs,
+ projects,
+} = sdk;
+export default sdk;
@@ -3,6 +3,6 @@ import cjs from '../src/auth/browser.js';
@@ -3,6 +3,6 @@ import cjs from '../src/auth/browser.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
- readCallback, base64url,
+ readCallback, base64url, decodeUnverified, unverifiedClaims,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -3,6 +3,7 @@ import cjs from '../src/auth/index.js';
@@ -3,6 +3,7 @@ import cjs from '../src/auth/index.js';
export const {
export const {
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization,
- readCallback, base64url, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken,
+ readCallback, base64url, decodeUnverified, unverifiedClaims, createServiceTokenClient, verifyUserToken,
+ verifyAppToken, exchangeCode, refreshUserToken,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -6,6 +6,6 @@ export const {
@@ -6,6 +6,6 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
- community,
+ community, jobs, projects,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/jobs.js. Do not edit.
+import cjs from '../src/jobs.js';
+
+export const {
+ createJobsClient, isTerminal, TERMINAL_STATES,
+} = cjs;
+export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/projects.js. Do not edit.
+import cjs from '../src/projects.js';
+
+export const {
+ createProjectsClient,
+} = cjs;
+export default cjs;
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/realtime.js';
import cjs from '../src/realtime.js';
export const {
export const {
- subscribe, createRealtimeClient, DEFAULT_ORIGIN,
+ subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/testing/index.js';
import cjs from '../src/testing/index.js';
export const {
export const {
- createMockPlatform, DEFAULT_ORIGINS,
+ createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -16,4 +16,6 @@ module.exports = {
@@ -16,4 +16,6 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ jobs: require('./src/jobs'),
+ projects: require('./src/projects'),
};
};
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.2.2",
+ "version": "0.3.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.2.2",
+ "version": "0.3.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.2.2",
- "description": "Supported browser and server clients for the OpenVibe platform: auth, registry discovery, identity, user modules, events, realtime, media and community, with deadlines, safe retries, problem errors and trace propagation.",
+ "version": "0.3.0",
+ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
"browser": "browser.js",
"browser": "browser.js",
@@ -65,12 +65,26 @@
@@ -65,12 +65,26 @@
"import": "./esm/community.mjs",
"import": "./esm/community.mjs",
"default": "./src/community.js"
"default": "./src/community.js"
},
},
+ "./jobs": {
+ "types": "./types/jobs.d.ts",
+ "import": "./esm/jobs.mjs",
+ "default": "./src/jobs.js"
+ },
+ "./projects": {
+ "types": "./types/projects.d.ts",
+ "import": "./esm/projects.mjs",
+ "default": "./src/projects.js"
+ },
"./testing": {
"./testing": {
"types": "./types/testing.d.ts",
"types": "./types/testing.d.ts",
"browser": null,
"browser": null,
"import": "./esm/testing.mjs",
"import": "./esm/testing.mjs",
"default": "./src/testing/index.js"
"default": "./src/testing/index.js"
},
},
+ "./browser/openvibe-sdk.mjs": {
+ "types": "./types/bundle.d.ts",
+ "default": "./browser/openvibe-sdk.mjs"
+ },
"./package.json": "./package.json"
"./package.json": "./package.json"
},
},
"files": [
"files": [
@@ -78,6 +92,7 @@
@@ -78,6 +92,7 @@
"browser.js",
"browser.js",
"src/",
"src/",
"esm/",
"esm/",
+ "browser/",
"types/",
"types/",
"README.md",
"README.md",
"CHANGELOG.md",
"CHANGELOG.md",
@@ -85,7 +100,8 @@
@@ -85,7 +100,8 @@
],
],
"scripts": {
"scripts": {
"test": "node test/run.js",
"test": "node test/run.js",
- "esm": "node scripts/esm.js"
+ "esm": "node scripts/esm.js",
+ "browser-bundle": "node scripts/browser-bundle.js"
},
},
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -98,7 +114,9 @@
@@ -98,7 +114,9 @@
"pkce",
"pkce",
"events",
"events",
"sse",
"sse",
- "media"
+ "media",
+ "jobs",
+ "developer-apps"
],
],
"repository": "github:OpenVibers/OpenVibe.SDK",
"repository": "github:OpenVibers/OpenVibe.SDK",
"license": "MIT",
"license": "MIT",
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK
+ * (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,
+ * community, jobs, projects), for pages with no build step:
+ *
+ * <script type="module">
+ * import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';
+ * </script>
+ *
+ * Generated from the CommonJS sources with no dependencies: each file reachable from browser.js
+ * becomes a function in a small module table, and its relative require() calls become table
+ * lookups. It refuses anything that is not a relative require, a node: import, process.env or a
+ * client secret, so server-only code cannot slip in. Checked in; test/bundle.test.js fails when it
+ * is stale, and test/browser-entries.test.js scans it like the other browser files.
+ *
+ * node scripts/browser-bundle.js write browser/openvibe-sdk.mjs
+ * node scripts/browser-bundle.js --check exit 1 if it differs from the sources
+ */
+const fs = require('fs');
+const path = require('path');
+
+const ROOT = path.join(__dirname, '..');
+const ENTRY = 'browser.js';
+const OUT = path.join(ROOT, 'browser', 'openvibe-sdk.mjs');
+const REQUIRE_RE = /require\(\s*'([^']+)'\s*\)/g;
+const REFUSE = [
+ [/client_secret/, 'mentions client_secret'],
+ [/process\.env/, 'reads process.env'],
+ [/(?:require\s*\(|from\s+|import\s*\()\s*['"]node:/, 'imports a node: module'],
+];
+
+function resolve(fromId, spec) {
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromId), spec));
+ for (const cand of [base, `${base}.js`, `${base}/index.js`]) {
+ const abs = path.join(ROOT, cand);
+ if (fs.existsSync(abs) && fs.statSync(abs).isFile()) return cand;
+ }
+ throw new Error(`${fromId}: cannot resolve ${spec}`);
+}
+
+/** Every module reachable from the entry, in a stable depth-first order: [{ id, source, deps }]. */
+function collect() {
+ const order = [];
+ const seen = new Map();
+ const visit = (id) => {
+ if (seen.has(id)) return;
+ const source = fs.readFileSync(path.join(ROOT, id), 'utf8');
+ for (const [re, why] of REFUSE) if (re.test(source)) throw new Error(`${id} ${why}: not browser-safe, refusing to bundle`);
+ const mod = { id, source, deps: {} };
+ seen.set(id, mod);
+ order.push(mod);
+ for (const [, spec] of source.matchAll(REQUIRE_RE)) {
+ if (!spec.startsWith('.')) throw new Error(`${id} requires "${spec}": only relative SDK files may be bundled`);
+ mod.deps[spec] = resolve(id, spec);
+ visit(mod.deps[spec]);
+ }
+ };
+ visit(ENTRY);
+ return order;
+}
+
+function render() {
+ const version = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')).version;
+ const mods = collect();
+ const index = new Map(mods.map((m, i) => [m.id, i]));
+ const defs = mods.map((m, i) => {
+ const body = m.source.replace(REQUIRE_RE, (_all, spec) => `__load(${index.get(m.deps[spec])})`).replace(/\s+$/, '');
+ return `// ── ${i}: ${m.id} ${'─'.repeat(Math.max(3, 60 - m.id.length))}\nfunction (module, exports) {\n${body}\n}`;
+ });
+ // Load the entry now, the same way Node would, to learn its export names.
+ const entry = require(path.join(ROOT, ENTRY));
+ const names = Object.keys(entry);
+ const namespaces = names.filter((n) => entry[n] && typeof entry[n] === 'object');
+ const lines = [];
+ for (let i = 0; i < names.length; i += 6) lines.push(` ${names.slice(i, i + 6).join(', ')},`);
+ return `// Generated by scripts/browser-bundle.js from ${ENTRY} (openvibe-sdk ${version}, MIT). Do not edit.
+// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
+// (${namespaces.join(', ')}). No server-only code: no service tokens, JWT verification, events, identity.
+/* eslint-disable */
+const __defs = [
+${defs.join(',\n')},
+];
+const __cache = new Array(__defs.length);
+function __load(i) {
+ if (__cache[i]) return __cache[i].exports;
+ const module = { exports: {} };
+ __cache[i] = module;
+ __defs[i].call(module.exports, module, module.exports);
+ return module.exports;
+}
+const sdk = __load(0);
+
+export const {
+${lines.join('\n')}
+} = sdk;
+export default sdk;
+`;
+}
+
+function main() {
+ const check = process.argv.includes('--check');
+ const out = render();
+ const have = fs.existsSync(OUT) ? fs.readFileSync(OUT, 'utf8') : null;
+ if (have === out) return;
+ if (check) {
+ console.error('stale: browser/openvibe-sdk.mjs (run node scripts/browser-bundle.js)');
+ process.exit(1);
+ }
+ fs.mkdirSync(path.dirname(OUT), { recursive: true });
+ fs.writeFileSync(OUT, out);
+}
+
+if (require.main === module) main();
+module.exports = { render, collect, OUT, REFUSE };
@@ -23,6 +23,8 @@ const ENTRIES = {
@@ -23,6 +23,8 @@ const ENTRIES = {
'realtime.mjs': '../src/realtime.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'community.mjs': '../src/community.js',
+ 'jobs.mjs': '../src/jobs.js',
+ 'projects.mjs': '../src/projects.js',
'testing.mjs': '../src/testing/index.js',
'testing.mjs': '../src/testing/index.js',
};
};
@@ -3,9 +3,15 @@
@@ -3,9 +3,15 @@
* openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).
* openvibe-sdk/auth, browser entry: OAuth2 authorization code + PKCE (RFC 7636, S256).
*
*
* The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's
* The browser builds the authorize URL and keeps the verifier (sessionStorage, or your server's
- * session). The code is then exchanged ON YOUR SERVER (openvibe-sdk/auth server entry,
- * exchangeCode()), because every OpenVibe OAuth client is a confidential client: its credentials
- * never reach a browser. This file holds no credentials and uses Web Crypto only.
+ * session). The code is then exchanged on your server (openvibe-sdk/auth server entry,
+ * exchangeCode()): a confidential client's credentials never reach a browser, and even a public
+ * developer app (no secret) is best served by keeping the token in an HttpOnly server session.
+ * This file holds no credentials and uses Web Crypto only.
+ *
+ * Two kinds of client use this flow:
+ * first-party sign-in scope 'profile theme' (the default); the exchange yields a user token
+ * developer app `audience` (openvibe.<service>) and `scope` = capability ids; the exchange
+ * yields a 5-minute app token acting for the person (no refresh token)
*/
*/
const { OpenVibeError } = require('../core/errors');
const { OpenVibeError } = require('../core/errors');
const { randomBytes } = require('../core/ids');
const { randomBytes } = require('../core/ids');
@@ -44,15 +50,23 @@ function createState() {
@@ -44,15 +50,23 @@ function createState() {
/**
/**
* GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state
* GET <network>/oauth/authorize?response_type=code&client_id&redirect_uri&scope&state
- * &code_challenge&code_challenge_method=S256[&prompt=none]
+ * &code_challenge&code_challenge_method=S256[&audience][&prompt=none]
+ *
+ * `scope` defaults to 'profile theme' (first-party sign-in) only when no `audience` is given. A
+ * developer app passes `audience` and its capability ids as `scope`; without a scope the code may
+ * yield every capability the app holds for that audience. Network refuses prompt=none for apps
+ * (error=interaction_required): a person always chooses to continue.
*/
*/
-function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope = 'profile theme', state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {
+function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId, redirectUri, scope, audience, state, codeChallenge, codeChallengeMethod = 'S256', prompt } = {}) {
if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');
if (!clientId || !redirectUri) throw new TypeError('clientId and redirectUri are required');
const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);
const u = new URL(authorizeUrl || `${String(network).replace(/\/+$/, '')}/oauth/authorize`);
u.searchParams.set('response_type', 'code');
u.searchParams.set('response_type', 'code');
u.searchParams.set('client_id', clientId);
u.searchParams.set('client_id', clientId);
u.searchParams.set('redirect_uri', redirectUri);
u.searchParams.set('redirect_uri', redirectUri);
- if (scope) u.searchParams.set('scope', Array.isArray(scope) ? scope.join(' ') : scope);
+ const s = scope === undefined && !audience ? 'profile theme' : scope;
+ const scopeText = Array.isArray(s) ? s.filter(Boolean).join(' ') : s;
+ if (scopeText) u.searchParams.set('scope', scopeText);
+ if (audience) u.searchParams.set('audience', audience);
if (state) u.searchParams.set('state', state);
if (state) u.searchParams.set('state', state);
if (codeChallenge) {
if (codeChallenge) {
u.searchParams.set('code_challenge', codeChallenge);
u.searchParams.set('code_challenge', codeChallenge);
@@ -65,6 +79,7 @@ function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId,
@@ -65,6 +79,7 @@ function buildAuthorizeUrl({ network = DEFAULT_NETWORK, authorizeUrl, clientId,
/**
/**
* Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.
* Everything a sign-in button needs: { url, state, codeVerifier, codeChallenge }.
* Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.
* Store `state` and `codeVerifier` until the callback, then send the code and verifier to your server.
+ * A developer app passes { audience, scope: [capability ids] } and exchanges with the same audience.
*/
*/
async function startAuthorization(opts = {}) {
async function startAuthorization(opts = {}) {
const pair = await createPkcePair(opts.verifierLength);
const pair = await createPkcePair(opts.verifierLength);
@@ -92,4 +107,34 @@ function readCallback(location, { expectedState } = {}) {
@@ -92,4 +107,34 @@ function readCallback(location, { expectedState } = {}) {
return { code, state };
return { code, state };
}
}
-module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url };
+/**
+ * The header and claims of a JWT, decoded WITHOUT checking its signature, issuer, audience or
+ * expiry: { header, claims } or null when it is not a JWT. For display and logging only (who a
+ * token names, what it may do, when it expires). Never authorize anything with it: a receiver
+ * verifies tokens with verifyUserToken() / verifyAppToken() (server).
+ */
+function decodeUnverified(token) {
+ const parts = typeof token === 'string' ? token.split('.') : [];
+ if (parts.length !== 3) return null;
+ try {
+ const part = (p) => {
+ const b64 = p.replace(/-/g, '+').replace(/_/g, '/');
+ const bin = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4));
+ return JSON.parse(new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0))));
+ };
+ const header = part(parts[0]);
+ const claims = part(parts[1]);
+ if (!header || typeof header !== 'object' || !claims || typeof claims !== 'object' || Array.isArray(claims)) return null;
+ return { header, claims };
+ } catch {
+ return null;
+ }
+}
+
+/** decodeUnverified(token).claims, or null. UNVERIFIED: see decodeUnverified(). */
+function unverifiedClaims(token) {
+ const d = decodeUnverified(token);
+ return d ? d.claims : null;
+}
+
+module.exports = { createCodeVerifier, pkceChallenge, createPkcePair, createState, buildAuthorizeUrl, startAuthorization, readCallback, base64url, decodeUnverified, unverifiedClaims };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*/
*/
const browser = require('./browser');
const browser = require('./browser');
const { createServiceTokenClient } = require('./tokens');
const { createServiceTokenClient } = require('./tokens');
-const { verifyUserToken } = require('./jwt');
+const { verifyUserToken, verifyAppToken } = require('./jwt');
const { exchangeCode, refreshUserToken } = require('./oauth');
const { exchangeCode, refreshUserToken } = require('./oauth');
-module.exports = { ...browser, createServiceTokenClient, verifyUserToken, exchangeCode, refreshUserToken };
+module.exports = { ...browser, createServiceTokenClient, verifyUserToken, verifyAppToken, exchangeCode, refreshUserToken };
@@ -1,8 +1,12 @@
@@ -1,8 +1,12 @@
'use strict';
'use strict';
/**
/**
- * Server only. Offline RS256 verification of an OpenVibe.Network user token (the SSO access token:
- * sub, id, subject_id, username, role, …) against the Network JWKS. No JWT library: the rules
- * are the ones written here.
+ * Server only. Offline RS256 verification of OpenVibe.Network tokens against the Network JWKS. No
+ * JWT library: the rules are the ones written here.
+ *
+ * verifyUserToken(token, { jwks }) a person's SSO access token (sub, id, subject_id, username, role, …)
+ * verifyAppToken(token, { jwks, audience, acceptSandbox })
+ * a developer app's token (identity.service-token-claims@1:
+ * sub app:app_…, actor_type app, cap, project_id, env, on_behalf_of?)
*
*
* const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });
* const claims = await verifyUserToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks' });
* claims.subject_id // usr_… (canonical subject; absent on very old tokens)
* claims.subject_id // usr_… (canonical subject; absent on very old tokens)
@@ -10,7 +14,8 @@
@@ -10,7 +14,8 @@
* `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL
* `jwks` is the JWKS document ({ keys: [...] }, Network's shape also carries public_key PEM), a URL
* to fetch it from (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)
* to fetch it from (cached), or pass `publicKey` (PEM or KeyObject). Throws OpenVibeError (401)
* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |
* with a stable code: token.malformed | token.bad_signature | token.expired | token.not_yet_valid |
- * token.wrong_issuer | token.wrong_audience | token.not_user | token.no_key.
+ * token.wrong_issuer | token.wrong_audience | token.not_user | token.not_app | token.invalid_claims |
+ * token.sandbox_refused | token.no_key.
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const { OpenVibeError } = require('../core/errors');
const { OpenVibeError } = require('../core/errors');
@@ -19,8 +24,8 @@ const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g,
@@ -19,8 +24,8 @@ const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g,
const JWKS_TTL_MS = 6 * 60 * 60 * 1000;
const JWKS_TTL_MS = 6 * 60 * 60 * 1000;
const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }
const jwksCache = new Map(); // url -> { at, keys: KeyObject-with-kid[] , inflight }
-function fail(code, detail) {
- return new OpenVibeError({ code, status: 401, detail, message: `user token rejected: ${detail}` });
+function fail(code, detail, kind = 'user') {
+ return new OpenVibeError({ code, status: 401, detail, message: `${kind} token rejected: ${detail}` });
}
}
function keysFromJwks(doc) {
function keysFromJwks(doc) {
@@ -60,19 +65,20 @@ function verifyWith(keys, header, input, sig) {
@@ -60,19 +65,20 @@ function verifyWith(keys, header, input, sig) {
});
});
}
}
-async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch, allowServiceTokens = false } = {}) {
+/** Signature, time and issuer/audience checks shared by both verifiers; returns the claims. */
+async function verifyJwt(token, { jwks, publicKey, issuer, audience, clockSkewSec = 30, now = Date.now(), fetch: fetchImpl = globalThis.fetch } = {}, kind) {
const parts = typeof token === 'string' ? token.split('.') : [];
const parts = typeof token === 'string' ? token.split('.') : [];
- if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT');
+ if (parts.length !== 3 || !parts[2]) throw fail('token.malformed', 'not a signed JWT', kind);
let header, claims;
let header, claims;
- try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { throw fail('token.malformed', 'undecodable'); }
- if (!header || header.alg !== 'RS256') throw fail('token.malformed', `alg ${header && header.alg} not accepted`);
- if (!claims || typeof claims !== 'object') throw fail('token.malformed', 'claims are not an object');
+ try { header = JSON.parse(fromB64url(parts[0])); claims = JSON.parse(fromB64url(parts[1])); } catch { throw fail('token.malformed', 'undecodable', kind); }
+ if (!header || header.alg !== 'RS256') throw fail('token.malformed', `alg ${header && header.alg} not accepted`, kind);
+ if (!claims || typeof claims !== 'object' || Array.isArray(claims)) throw fail('token.malformed', 'claims are not an object', kind);
let keys;
let keys;
if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
if (publicKey) keys = [{ kid: null, key: typeof publicKey === 'string' ? crypto.createPublicKey(publicKey) : publicKey }];
else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);
else if (typeof jwks === 'string') keys = await loadJwks(jwks, fetchImpl, false);
else keys = keysFromJwks(jwks);
else keys = keysFromJwks(jwks);
- if (!keys.length) throw fail('token.no_key', 'no RS256 verification key');
+ if (!keys.length) throw fail('token.no_key', 'no RS256 verification key', kind);
const input = `${parts[0]}.${parts[1]}`;
const input = `${parts[0]}.${parts[1]}`;
const sig = fromB64url(parts[2]);
const sig = fromB64url(parts[2]);
@@ -80,21 +86,60 @@ async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clock
@@ -80,21 +86,60 @@ async function verifyUserToken(token, { jwks, publicKey, issuer, audience, clock
if (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {
if (!good && typeof jwks === 'string' && header.kid && !keys.some((k) => k.kid === header.kid)) {
good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation
good = verifyWith(await loadJwks(jwks, fetchImpl, true), header, input, sig); // key rotation
}
}
- if (!good) throw fail('token.bad_signature', 'signature does not verify');
+ if (!good) throw fail('token.bad_signature', 'signature does not verify', kind);
const t = Math.floor(now / 1000);
const t = Math.floor(now / 1000);
- if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) throw fail('token.expired', 'expired');
- if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) throw fail('token.not_yet_valid', 'not valid yet');
- if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) throw fail('token.not_yet_valid', 'issued in the future');
- if (issuer && claims.iss !== issuer) throw fail('token.wrong_issuer', `issuer ${claims.iss}`);
+ if (typeof claims.exp !== 'number' || claims.exp + clockSkewSec < t) throw fail('token.expired', 'expired', kind);
+ if (typeof claims.nbf === 'number' && claims.nbf - clockSkewSec > t) throw fail('token.not_yet_valid', 'not valid yet', kind);
+ if (typeof claims.iat === 'number' && claims.iat - clockSkewSec > t) throw fail('token.not_yet_valid', 'issued in the future', kind);
+ if (issuer && claims.iss !== issuer) throw fail('token.wrong_issuer', `issuer ${claims.iss}`, kind);
if (audience) {
if (audience) {
const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud != null ? [claims.aud] : [];
const aud = Array.isArray(claims.aud) ? claims.aud : claims.aud != null ? [claims.aud] : [];
const want = Array.isArray(audience) ? audience : [audience];
const want = Array.isArray(audience) ? audience : [audience];
- if (!want.some((a) => aud.includes(a))) throw fail('token.wrong_audience', `not for ${want.join(', ')}`);
+ if (!want.some((a) => aud.includes(a))) throw fail('token.wrong_audience', `not for ${want.join(', ')}`, kind);
}
}
+ return claims;
+}
+
+async function verifyUserToken(token, opts = {}) {
+ const claims = await verifyJwt(token, opts, 'user');
const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));
const principal = ['service', 'app', 'mod'].includes(claims.actor_type) || /^(svc|app|mod):/.test(String(claims.sub || ''));
- if (principal && !allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');
+ if (principal && !opts.allowServiceTokens) throw fail('token.not_user', 'a service principal token is not a user token');
+ return claims;
+}
+
+const APP_SUB_RE = /^app:app_[0-9A-HJKMNP-TV-Z]{26}$/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+/**
+ * verifyAppToken(token, { jwks | publicKey, issuer, audience, acceptSandbox = false })
+ * -> claims { sub: 'app:app_…', actor_type: 'app', aud, cap, ns, project_id, env, on_behalf_of?, iat, exp, jti }
+ *
+ * A developer app's token (Network developer projects, ADR-014), checked the way openvibe-contracts
+ * v0.26 verifyServiceToken() checks it: RS256 signature, expiry, issuer, the ONE audience you
+ * serve (required), the claim shape, and env. `env: sandbox` is refused with
+ * token.sandbox_refused unless you pass acceptSandbox: true, which you should only do when your
+ * service keeps sandbox traffic apart from real data. `on_behalf_of` (usr_…) is present only on
+ * tokens from the authorization-code flow: it names the person who authorized the app.
+ * Capabilities are not checked here: test `claims.cap` for the one your route performs.
+ */
+async function verifyAppToken(token, opts = {}) {
+ if (!opts.audience) throw new TypeError('verifyAppToken: pass the audience your service answers for (openvibe.<service>)');
+ const claims = await verifyJwt(token, opts, 'app');
+ if (claims.actor_type !== 'app' || !/^app:/.test(String(claims.sub || ''))) throw fail('token.not_app', 'not a developer app token', 'app');
+ const bad = [];
+ if (!APP_SUB_RE.test(String(claims.sub))) bad.push('sub');
+ if (!Array.isArray(claims.aud) || !claims.aud.length) bad.push('aud');
+ if (!Array.isArray(claims.cap) || !claims.cap.every((c) => typeof c === 'string')) bad.push('cap');
+ if (claims.ns !== undefined && (!Array.isArray(claims.ns) || !claims.ns.every((n) => typeof n === 'string'))) bad.push('ns');
+ if (typeof claims.jti !== 'string' || !claims.jti) bad.push('jti');
+ if (!PROJECT_RE.test(String(claims.project_id || ''))) bad.push('project_id');
+ if (claims.env !== 'sandbox' && claims.env !== 'production') bad.push('env');
+ if (claims.on_behalf_of !== undefined && !USER_RE.test(String(claims.on_behalf_of))) bad.push('on_behalf_of');
+ if (bad.length) throw fail('token.invalid_claims', `bad or missing claims: ${bad.join(', ')}`, 'app');
+ if (claims.env === 'sandbox' && !opts.acceptSandbox) throw fail('token.sandbox_refused', 'sandbox tokens are not accepted here', 'app');
return claims;
return claims;
}
}
-module.exports = { verifyUserToken, keysFromJwks, _jwksCache: jwksCache };
+module.exports = { verifyUserToken, verifyAppToken, keysFromJwks, _jwksCache: jwksCache };
@@ -1,11 +1,17 @@
@@ -1,11 +1,17 @@
'use strict';
'use strict';
/**
/**
- * Server only. The half of the authorization-code flow that needs the client's credentials:
- * exchanging the code the browser brought back (with its PKCE verifier) and refreshing.
+ * Server only. The half of the authorization-code flow that runs on your server: exchanging the
+ * code the browser brought back (with its PKCE verifier), and refreshing first-party user tokens.
*
*
* POST <network>/oauth/token grant_type=authorization_code, code, redirect_uri, client_id,
* POST <network>/oauth/token grant_type=authorization_code, code, redirect_uri, client_id,
- * client_secret, code_verifier
+ * [client_secret], code_verifier, [audience], [scope]
+ *
+ * First-party client (confidential, no audience)
* -> { access_token, refresh_token, token_type, expires_in, scope, user, preferences }
* -> { access_token, refresh_token, token_type, expires_in, scope, user, preferences }
+ * Developer app (`app_<ULID>`; audience required; PKCE required; confidential apps also send their
+ * secret, public apps send none)
+ * -> { access_token, token_type, expires_in: 300, scope } (no refresh token: sign in again)
+ * The app token names the person in `on_behalf_of`; verify it with verifyAppToken().
*/
*/
const { OpenVibeError } = require('../core/errors');
const { OpenVibeError } = require('../core/errors');
const { DEFAULT_NETWORK } = require('../core/client');
const { DEFAULT_NETWORK } = require('../core/client');
@@ -31,10 +37,20 @@ function tokenUrlOf({ tokenUrl, network = DEFAULT_NETWORK }) {
@@ -31,10 +37,20 @@ function tokenUrlOf({ tokenUrl, network = DEFAULT_NETWORK }) {
return tokenUrl || `${String(network).replace(/\/+$/, '')}/oauth/token`;
return tokenUrl || `${String(network).replace(/\/+$/, '')}/oauth/token`;
}
}
-async function exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {
- if (!code || !redirectUri || !clientId || !clientSecret) throw new TypeError('exchangeCode: code, redirectUri, clientId and clientSecret are required');
+/**
+ * exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret?, audience?, scope? })
+ * A public client (no clientSecret) must send the PKCE codeVerifier; so must every developer app.
+ * `scope` (capability ids) may narrow what the person authorized, never widen it. The response is
+ * returned as the token endpoint sent it: `refresh_token` is absent for app tokens.
+ */
+async function exchangeCode({ code, redirectUri, codeVerifier, clientId, clientSecret, audience, scope, fetch: fetchImpl = globalThis.fetch, timeoutMs = 10000, ...rest } = {}) {
+ if (!code || !redirectUri || !clientId) throw new TypeError('exchangeCode: code, redirectUri and clientId are required');
+ if (!clientSecret && !codeVerifier) throw new TypeError('exchangeCode: a public client (no clientSecret) must send its PKCE codeVerifier');
+ if (/^app_/.test(String(clientId)) && !codeVerifier) throw new TypeError('exchangeCode: developer apps always use PKCE; pass codeVerifier');
+ if (/^app_/.test(String(clientId)) && !audience) throw new TypeError('exchangeCode: developer apps must name the audience (openvibe.<service>) the token is for');
return postToken(tokenUrlOf(rest), {
return postToken(tokenUrlOf(rest), {
- grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret, code_verifier: codeVerifier,
+ grant_type: 'authorization_code', code, redirect_uri: redirectUri, client_id: clientId, client_secret: clientSecret || undefined,
+ code_verifier: codeVerifier, audience: audience || undefined, scope: Array.isArray(scope) ? scope.join(' ') || undefined : scope || undefined,
}, fetchImpl, timeoutMs);
}, fetchImpl, timeoutMs);
}
}
@@ -10,9 +10,15 @@
@@ -10,9 +10,15 @@
* per audience (and scope), refreshed 60 s before expiry; concurrent callers share one request.
* per audience (and scope), refreshed 60 s before expiry; concurrent callers share one request.
* Plug it into createClient({ tokenProvider }) and every call gets a token for the audience of the
* Plug it into createClient({ tokenProvider }) and every call gets a token for the audience of the
* service it calls (openvibe.media, openvibe.events, …).
* service it calls (openvibe.media, openvibe.events, …).
+ *
+ * Developer apps (client id app_<ULID>, confidential) use the same client: their tokens carry
+ * project_id, env and the approved capabilities for that audience. getTokenInfo() shows the
+ * granted `scope` and the token's claims, decoded but NOT verified (for display and diagnostics;
+ * the receiver is the one that verifies).
*/
*/
const { OpenVibeError } = require('../core/errors');
const { OpenVibeError } = require('../core/errors');
const { DEFAULT_NETWORK } = require('../core/client');
const { DEFAULT_NETWORK } = require('../core/client');
+const { unverifiedClaims } = require('./browser');
function createServiceTokenClient({
function createServiceTokenClient({
network = DEFAULT_NETWORK, tokenUrl, clientId, clientSecret, audience, scope,
network = DEFAULT_NETWORK, tokenUrl, clientId, clientSecret, audience, scope,
@@ -47,7 +53,8 @@ function createServiceTokenClient({
@@ -47,7 +53,8 @@ function createServiceTokenClient({
if (!res.ok || !json || !json.access_token) {
if (!res.ok || !json || !json.access_token) {
throw OpenVibeError.fromResponse({ status: res.status, body: json || { error: 'no_token', error_description: 'no access_token in the response' }, method: 'POST', url });
throw OpenVibeError.fromResponse({ status: res.status, body: json || { error: 'no_token', error_description: 'no access_token in the response' }, method: 'POST', url });
}
}
- cache.set(key, { token: json.access_token, exp: now() + (Number(json.expires_in) || 300) * 1000, scope: json.scope });
+ const scopeList = typeof json.scope === 'string' ? json.scope.split(/\s+/).filter(Boolean) : [];
+ cache.set(key, { token: json.access_token, exp: now() + (Number(json.expires_in) || 300) * 1000, scope: scopeList, audience: aud });
return json.access_token;
return json.access_token;
}
}
@@ -63,8 +70,29 @@ function createServiceTokenClient({
@@ -63,8 +70,29 @@ function createServiceTokenClient({
return inflight.get(key);
return inflight.get(key);
}
}
+ /**
+ * getTokenInfo({ audience?, scope? }) -> { accessToken, tokenType, audience, scope, expiresAt,
+ * unverifiedClaims }. `scope` is the capability list the token endpoint granted (it may be
+ * narrower than you asked for when you asked for none). `unverifiedClaims` is the JWT payload
+ * decoded WITHOUT verification: fine for showing project_id, env or cap, never for trusting them.
+ */
+ async function getTokenInfo(ctx = {}) {
+ const aud = ctx.audience || audience;
+ const accessToken = await getToken(ctx);
+ const hit = cache.get(keyOf(aud, scopeFor(aud, ctx.scope))) || {};
+ return {
+ accessToken,
+ tokenType: 'Bearer',
+ audience: aud,
+ scope: hit.scope ? [...hit.scope] : [],
+ expiresAt: hit.exp ? new Date(hit.exp).toISOString() : null,
+ unverifiedClaims: unverifiedClaims(accessToken),
+ };
+ }
+
return {
return {
getToken,
getToken,
+ getTokenInfo,
async authHeaders(ctx) { return { Authorization: `Bearer ${await getToken(ctx)}` }; },
async authHeaders(ctx) { return { Authorization: `Bearer ${await getToken(ctx)}` }; },
/** Drop the cached token (one audience, or all): the next call fetches a new one. */
/** Drop the cached token (one audience, or all): the next call fetches a new one. */
invalidate(ctx = {}) {
invalidate(ctx = {}) {
@@ -184,6 +184,11 @@ function build(cfg, shared, ctx) {
@@ -184,6 +184,11 @@ function build(cfg, shared, ctx) {
* headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,
* headers, token, auth, audience, idempotencyKey, idempotent, retries, timeoutMs,
* deadlineMs, signal, traceparent, requestId, responseType })
* deadlineMs, signal, traceparent, requestId, responseType })
* -> { status, headers, data, requestId, traceId, traceparent, attempts }
* -> { status, headers, data, requestId, traceId, traceparent, attempts }
+ *
+ * responseType 'response' (streams, downloads): a 2xx resolves with `data` and `response` set
+ * to the raw fetch Response, body unread. The timeout then covers the response headers only,
+ * and `signal` still cancels the body afterwards. An error status is read and thrown as
+ * usual, so the body you get is always a success.
*/
*/
async function request(opts = {}) {
async function request(opts = {}) {
const method = String(opts.method || 'GET').toUpperCase();
const method = String(opts.method || 'GET').toUpperCase();
@@ -247,7 +252,9 @@ function build(cfg, shared, ctx) {
@@ -247,7 +252,9 @@ function build(cfg, shared, ctx) {
}
}
const rid = res.headers.get('x-openvibe-request-id') || requestId;
const rid = res.headers.get('x-openvibe-request-id') || requestId;
if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });
if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body: data, requestId: rid, traceId: span.traceId, method, url, retryable });
- return { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };
+ const result = { status: res.status, headers: res.headers, data, requestId: rid, traceId: span.traceId, traceparent: span.traceparent, attempts: attempt + 1 };
+ if (opts.responseType === 'response') result.response = res;
+ return result;
}
}
}
}
@@ -299,6 +306,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
@@ -299,6 +306,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
let timedOut = false;
let timedOut = false;
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);
const timer = setTimeout(() => { timedOut = true; ctrl.abort(); }, ms);
const onAbort = () => ctrl.abort();
const onAbort = () => ctrl.abort();
+ let keepSignal = false;
if (signal) {
if (signal) {
if (signal.aborted) ctrl.abort();
if (signal.aborted) ctrl.abort();
else signal.addEventListener('abort', onAbort, { once: true });
else signal.addEventListener('abort', onAbort, { once: true });
@@ -307,8 +315,11 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
@@ -307,8 +315,11 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
const init = { method, headers, body, signal: ctrl.signal };
const init = { method, headers, body, signal: ctrl.signal };
if (credentials) init.credentials = credentials;
if (credentials) init.credentials = credentials;
const res = await fetchImpl(url, init);
const res = await fetchImpl(url, init);
- if (responseType === 'response') return { res, data: null };
- return { res, data: await parseBody(res, responseType) };
+ if (responseType === 'response' && res.ok) {
+ keepSignal = true; // the caller reads the body later: its signal must still cancel it
+ return { res, data: res };
+ }
+ return { res, data: await parseBody(res, responseType === 'response' ? undefined : responseType) };
} catch (err) {
} catch (err) {
if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });
if (signal && signal.aborted) throw new OpenVibeError({ ...meta, code: 'sdk.aborted', message: `${method} ${url.split('?')[0]}: aborted`, cause: err });
if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });
if (timedOut) throw new OpenVibeError({ ...meta, code: 'sdk.timeout', retryable: true, message: `${method} ${url.split('?')[0]}: no response within ${ms} ms`, cause: err });
@@ -316,7 +327,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
@@ -316,7 +327,7 @@ async function attemptOnce(fetchImpl, url, { method, headers, body, credentials,
throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });
throw new OpenVibeError({ ...meta, code: 'sdk.network_error', retryable: true, message: `${method} ${url.split('?')[0]}: ${err && err.message ? err.message : 'network error'}`, cause: err });
} finally {
} finally {
clearTimeout(timer);
clearTimeout(timer);
- if (signal) signal.removeEventListener('abort', onAbort);
+ if (signal && !keepSignal) signal.removeEventListener('abort', onAbort);
}
}
}
}
@@ -7,7 +7,8 @@ const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = require('./id
@@ -7,7 +7,8 @@ const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = require('./id
const { paginate, offsetPager } = require('./paginate');
const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
-const SDK_VERSION = '0.1.0';
+/** The package version (test/version.test.js keeps it equal to package.json). */
+const SDK_VERSION = '0.3.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -13,7 +13,6 @@ const crypto = require('node:crypto');
@@ -13,7 +13,6 @@ const crypto = require('node:crypto');
const { isOpenVibeError } = require('./core/errors');
const { isOpenVibeError } = require('./core/errors');
const { newEventId } = require('./core/ids');
const { newEventId } = require('./core/ids');
const { startSpan } = require('./core/trace');
const { startSpan } = require('./core/trace');
-const { paginate } = require('./core/paginate');
// ── Webhook signatures ───────────────────────────────────────
// ── Webhook signatures ───────────────────────────────────────
@@ -97,18 +96,28 @@ function createEventsClient(client, { source, baseUrl } = {}) {
@@ -97,18 +96,28 @@ function createEventsClient(client, { source, baseUrl } = {}) {
}
}
/**
/**
- * Async iterator over { seq, event } from afterSeq up to the current head. onGap({ from_seq,
- * to_seq }) is called when retention already pruned part of the range. Keep the last page's
- * next_after_seq (onPage) as your durable cursor: it moves past events that did not match.
+ * Async iterator over { seq, event } from afterSeq up to the current head.
+ *
+ * onGap(gap) called before a page's items when retention already pruned part of the range
+ * ({ from_seq, to_seq }): nothing can replay it, so resync derived state.
+ * onPage(page) called AFTER every item of that page was yielded and handled (your loop body
+ * ran for the page's last item and asked for the next one). Save
+ * page.next_after_seq there as your durable cursor: it also moves past events
+ * that did not match your topics, and a crash (or a break/throw in your loop)
+ * before onPage leaves the cursor on the last page you finished, so nothing is
+ * skipped. Pages with no matching events still call onPage.
*/
*/
- function iterate({ topic, afterSeq = 0, limit, onGap, onPage } = {}) {
- return paginate(async (cursor) => {
+ async function* iterate({ topic, afterSeq = 0, limit, onGap, onPage, maxPages = Infinity } = {}) {
+ let cursor = afterSeq;
+ for (let pages = 0; pages < maxPages; pages++) {
const page = await pull({ topic, afterSeq: cursor, limit });
const page = await pull({ topic, afterSeq: cursor, limit });
if (page.gap && onGap) await onGap(page.gap);
if (page.gap && onGap) await onGap(page.gap);
+ for (const item of page.events || []) yield item;
if (onPage) await onPage(page);
if (onPage) await onPage(page);
- const next = page.next_after_seq < page.latest_seq ? page.next_after_seq : null;
- return { items: page.events, next };
- }, { cursor: afterSeq });
+ const next = page.next_after_seq;
+ if (next == null || next === cursor || !(next < page.latest_seq)) return;
+ cursor = next;
+ }
}
}
const subscriptions = {
const subscriptions = {
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * openvibe-sdk/jobs: asynchronous jobs on OpenVibe.Tools (/api/v1/jobs on each satellite that runs
+ * jobs: img, audio, docs). Browser-safe; the credentials decide who owns a job.
+ *
+ * const jobs = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
+ * const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' },
+ * files: [{ name: 'a.png', data: bytes }], idempotencyKey });
+ * for await (const e of jobs.events(job.id, { lastEventId: saved })) save(e.id); // reattaches after drops
+ * const done = await jobs.get(job.id);
+ * const res = await jobs.file(job.id, 0); // raw Response: stream or save it
+ *
+ * Capabilities (audience openvibe.tools): tools.job.create (submit), tools.job.read (get, events,
+ * file), tools.job.cancel (cancel). Jobs are owner-scoped: another principal gets 404, the same
+ * answer as for a job that does not exist.
+ *
+ * POST /api/v1/jobs JSON { type, input } or multipart (type, input, file…);
+ * Idempotency-Key -> 202 new | 200 + Idempotent-Replayed
+ * GET /api/v1/jobs/:id
+ * DELETE /api/v1/jobs/:id cancel -> 200 cancelled | 202 cancel requested | 409 finished
+ * GET /api/v1/jobs/:id/events SSE with ids; Last-Event-ID replays only later events;
+ * 204 when the job finished and nothing is newer
+ * GET /api/v1/jobs/:id/files/:n a result file
+ */
+const { OpenVibeError, isOpenVibeError } = require('./core/errors');
+const { newIdempotencyKey } = require('./core/ids');
+const { parseSSE } = require('./realtime');
+
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const TERMINAL_EVENTS = new Set(['job.succeeded', 'job.failed', 'job.cancelled']);
+const enc = encodeURIComponent;
+const sleep = (ms, signal) => new Promise((resolve) => {
+ const t = setTimeout(resolve, ms);
+ if (signal) signal.addEventListener('abort', () => { clearTimeout(t); resolve(); }, { once: true });
+});
+
+function toBlob(data, type) {
+ if (typeof Blob !== 'undefined' && data instanceof Blob) return data;
+ if (typeof data === 'string' || data instanceof ArrayBuffer || ArrayBuffer.isView(data)) return new Blob([data], type ? { type } : undefined);
+ throw new TypeError('jobs.submit: a file is a Blob/File, or { name, data: Blob | ArrayBuffer | typed array | string, type? }');
+}
+
+/** Is this job finished (succeeded, failed or cancelled)? */
+function isTerminal(job) {
+ return Boolean(job && TERMINAL.has(job.state));
+}
+
+function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openvibe.tools' } = {}) {
+ const call = (opts) => client.request({ service, baseUrl, audience, ...opts });
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+
+ /**
+ * submit({ type, input = {}, files, idempotencyKey, signal }) -> { job, replayed, idempotencyKey }
+ *
+ * Always carries an Idempotency-Key (one is generated when you pass none), so retries after a
+ * timeout or a 503 are safe. To survive a crash between submitting and saving the job id, derive
+ * the key from the request (or store it first) and submit again with the same key: Tools answers
+ * with the SAME job (`replayed: true`). A different request under a used key is 409
+ * tools.job.idempotency_conflict.
+ */
+ async function submit({ type, input = {}, files, idempotencyKey, signal } = {}) {
+ if (!type) throw new TypeError('jobs.submit: type is required (e.g. img.process)');
+ if (input === null || typeof input !== 'object' || Array.isArray(input)) throw new TypeError('jobs.submit: input must be an object');
+ const key = idempotencyKey || newIdempotencyKey();
+ const list = files == null ? [] : Array.isArray(files) ? files : [files];
+ const opts = { method: 'POST', path: '/api/v1/jobs', idempotencyKey: key, signal };
+ if (list.length) {
+ const form = new FormData();
+ form.append('type', String(type));
+ form.append('input', JSON.stringify(input));
+ for (const f of list) {
+ const isBlob = typeof Blob !== 'undefined' && f instanceof Blob;
+ const blob = isBlob ? f : toBlob(f && f.data, f && f.type);
+ form.append('file', blob, (isBlob ? f.name : f && f.name) || 'file');
+ }
+ opts.form = form;
+ } else {
+ opts.json = { type: String(type), input };
+ }
+ const res = await call(opts);
+ return { job: res.data, replayed: String(res.headers.get('idempotent-replayed') || '') === 'true', idempotencyKey: key };
+ }
+
+ /** The job, or null when it does not exist or is not yours. */
+ async function get(id, { signal } = {}) {
+ const res = await orNull(call({ path: `/api/v1/jobs/${enc(id)}`, signal }));
+ return res ? res.data : null;
+ }
+
+ /**
+ * Cancel -> the job (state `cancelled`, or `cancel_requested: true` while it is running).
+ * A finished job answers 409 tools.job.already_finished (thrown).
+ */
+ async function cancel(id, { signal } = {}) {
+ return (await call({ method: 'DELETE', path: `/api/v1/jobs/${enc(id)}`, signal })).data;
+ }
+
+ /**
+ * events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 })
+ * -> async iterator of { id, event, job }
+ *
+ * Follows the job's SSE stream until the job finishes. When the connection drops it reconnects
+ * with Last-Event-ID = the last id it yielded, so every event arrives once and in order; a 204
+ * (finished, nothing newer) ends the iteration. Keep `id` somewhere durable and pass it back as
+ * `lastEventId` to reattach after a restart. 4xx answers (401 after one token refresh, 403,
+ * 404) are thrown; network errors and 5xx reconnect, up to maxReconnects in a row.
+ */
+ async function* events(id, { lastEventId = 0, signal, maxReconnects = 20, reconnectDelayMs = 1000 } = {}) {
+ let last = Number(lastEventId) || 0;
+ let retryMs = reconnectDelayMs;
+ let failures = 0;
+ while (!(signal && signal.aborted)) {
+ let res;
+ let delivered = false;
+ try {
+ res = await call({
+ path: `/api/v1/jobs/${enc(id)}/events`, responseType: 'response', retries: 0, signal,
+ headers: { Accept: 'text/event-stream', 'Cache-Control': 'no-cache', 'Last-Event-ID': String(last) },
+ });
+ if (res.status === 204) return;
+ for await (const msg of parseSSE(res.data.body, { onRetry: (ms) => { retryMs = ms; } })) {
+ let job = null;
+ try { job = JSON.parse(msg.data); } catch { job = null; }
+ const n = Number(msg.id);
+ if (Number.isFinite(n) && msg.id !== undefined && msg.id !== '') {
+ if (n <= last) continue; // already yielded before a reconnect
+ last = n;
+ }
+ delivered = true;
+ failures = 0;
+ yield { id: Number.isFinite(n) ? n : null, event: msg.event, job };
+ if (TERMINAL_EVENTS.has(msg.event) || (job && isTerminal(job) && msg.event !== 'job.progress')) return;
+ }
+ } catch (err) {
+ if (signal && signal.aborted) return;
+ if (isOpenVibeError(err) && err.status >= 400 && err.status < 500) throw err;
+ if (!isOpenVibeError(err) && !(err && (err.name === 'TypeError' || err.name === 'AbortError'))) throw err;
+ }
+ if (!delivered) failures++;
+ if (failures > maxReconnects) {
+ throw new OpenVibeError({ code: 'sdk.network_error', retryable: true, message: `jobs: the event stream of ${id} failed ${failures} times in a row` });
+ }
+ await sleep(retryMs, signal);
+ }
+ }
+
+ /**
+ * wait(id, { lastEventId, signal, onEvent }) -> the finished job (GET after the terminal event).
+ * onEvent({ id, event, job }) sees every event, e.g. to save `id` and show progress.
+ */
+ async function wait(id, { onEvent, ...opts } = {}) {
+ for await (const e of events(id, opts)) if (onEvent) await onEvent(e);
+ return get(id, { signal: opts.signal });
+ }
+
+ /**
+ * A result file as the raw fetch Response (body unread): stream it, or
+ * `Buffer.from(await res.arrayBuffer())`. 409 tools.job.not_ready until the job succeeded.
+ */
+ async function file(id, n = 0, { signal, inline = false } = {}) {
+ return (await call({ path: `/api/v1/jobs/${enc(id)}/files/${Number(n)}`, query: { inline }, responseType: 'response', signal })).data;
+ }
+
+ return { submit, get, cancel, events, wait, file };
+}
+
+module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -0,0 +1,113 @@
+'use strict';
+/**
+ * openvibe-sdk/projects: developer projects on OpenVibe.Network (/api/v1/projects, ADR-014), the
+ * API the developer portal (OpenVibe.Codes) and your own tooling use. Browser-safe code.
+ *
+ * Auth: a Network USER access token (Authorization: Bearer). The API reads no cookies, and service
+ * or app tokens get 401. Errors are problem+json with stable codes (project.not_found,
+ * grant.beyond_allowance, …); a project you are not a member of is 404, never 403.
+ *
+ * const projects = createProjectsClient(createClient({ token: userAccessToken }));
+ * const prj = await projects.create({ name: 'My app' });
+ * const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: [] });
+ * app.credential; // the secret, returned ONCE: store it now
+ * await projects.grants.request(prj.id, app.id, 'media.object.upload');
+ *
+ * Secrets appear only in the responses of apps.create() (confidential apps) and
+ * credentials.rotate(). Those two calls are never retried: a retry would mint a second secret.
+ * Other mutations that Network does not dedupe are not retried either; reads, PUT and DELETE are.
+ */
+const { isOpenVibeError } = require('./core/errors');
+const { paginate } = require('./core/paginate');
+
+const enc = encodeURIComponent;
+
+function createProjectsClient(client, { baseUrl } = {}) {
+ const call = (opts) => client.json({ service: 'network', baseUrl, audience: 'openvibe.network', ...opts });
+ const once = (opts) => call({ idempotencyKey: false, ...opts }); // never repeated automatically
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const P = (project) => `/api/v1/projects/${enc(project)}`;
+ const A = (project, app) => `${P(project)}/apps/${enc(app)}`;
+
+ const members = {
+ async list(project) { return (await call({ path: `${P(project)}/members` })).members; },
+ /** { username | subjectId, role: 'admin' | 'developer' | 'viewer' } */
+ add: (project, { username, subjectId, role } = {}) => once({ method: 'POST', path: `${P(project)}/members`, json: { username, subject_id: subjectId, role } }),
+ update: (project, subject, { role } = {}) => once({ method: 'PATCH', path: `${P(project)}/members/${enc(subject)}`, json: { role } }),
+ /** Remove a member (or yourself: leave). The owner cannot leave. */
+ remove: (project, subject) => call({ method: 'DELETE', path: `${P(project)}/members/${enc(subject)}` }),
+ };
+
+ const apps = {
+ async list(project) { return (await call({ path: `${P(project)}/apps` })).apps; },
+ get: (project, app) => orNull(call({ path: A(project, app) })),
+ /**
+ * { name, environment: 'sandbox' | 'production', type: 'confidential' | 'public', redirectUris }
+ * -> the app; a confidential app also carries `credential` with its secret, shown once.
+ */
+ create: (project, { name, environment = 'sandbox', type = 'confidential', redirectUris = [] } = {}) => once({
+ method: 'POST', path: `${P(project)}/apps`, json: { name, environment, type, redirect_uris: redirectUris },
+ }),
+ update: (project, app, { name, redirectUris } = {}) => once({ method: 'PATCH', path: A(project, app), json: { name, redirect_uris: redirectUris } }),
+ /** Revoke the app: its credentials and pending codes fail at once; issued tokens end within 5 minutes. */
+ revoke: (project, app) => call({ method: 'DELETE', path: A(project, app) }),
+ };
+
+ const credentials = {
+ /** Metadata only (id, last four characters, state, dates): secrets cannot be read back. */
+ async list(project, app) { return (await call({ path: `${A(project, app)}/credentials` })).credentials; },
+ /** A new secret, returned once. Older secrets keep working for overlapSeconds (Network default 86400). */
+ rotate: (project, app, { overlapSeconds } = {}) => once({ method: 'POST', path: `${A(project, app)}/credentials/rotate`, json: { overlap_seconds: overlapSeconds } }),
+ revoke: (project, app, credential) => once({ method: 'POST', path: `${A(project, app)}/credentials/${enc(credential)}/revoke` }),
+ };
+
+ const grants = {
+ async list(project, app) { return (await call({ path: `${A(project, app)}/grants` })).grants; },
+ /** Request a capability. Owners and admins get it approved at once when it is inside the allowance. */
+ request: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants`, json: { capability } }),
+ approve: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/approve` }),
+ deny: (project, app, capability) => once({ method: 'POST', path: `${A(project, app)}/grants/${enc(capability)}/deny` }),
+ revoke: (project, app, capability) => call({ method: 'DELETE', path: `${A(project, app)}/grants/${enc(capability)}` }),
+ };
+
+ const quotas = {
+ async list(project) { return (await call({ path: `${P(project)}/quotas` })).quotas; },
+ /** Staff only. { limit, window: minute|hour|day|month|total, unit } */
+ set: (project, capability, { limit, window, unit } = {}) => call({ method: 'PUT', path: `${P(project)}/quotas/${enc(capability)}`, json: { limit, window, unit } }),
+ delete: (project, capability) => call({ method: 'DELETE', path: `${P(project)}/quotas/${enc(capability)}` }),
+ };
+
+ /** One page of the audit, newest first: { entries, next_before }. Admins, owners and staff. */
+ const audit = (project, { before, limit } = {}) => call({ path: `${P(project)}/audit`, query: { before, limit } });
+
+ return {
+ /** Capabilities an app could ever be granted (active + public). */
+ async catalog() { return (await call({ path: '/api/v1/projects/catalog' })).capabilities; },
+ /** Your projects; staff may pass { all: true }. */
+ async list({ all } = {}) { return (await call({ path: '/api/v1/projects', query: { all } })).projects; },
+ /** { name } -> the project; you are its owner. A new project is sandbox only. */
+ create: ({ name } = {}) => once({ method: 'POST', path: '/api/v1/projects', json: { name } }),
+ /** The project with your role, allowance, environments and counts, or null (404 for non-members). */
+ get: (project) => orNull(call({ path: P(project) })),
+ update: (project, { name } = {}) => once({ method: 'PATCH', path: P(project), json: { name } }),
+ /** Irreversible: revokes every app. Owner or staff. */
+ archive: (project) => once({ method: 'POST', path: `${P(project)}/archive` }),
+ /** Staff only: the capabilities this project's apps may hold -> { allowance, trimmed }. */
+ setAllowance: (project, capabilities) => call({ method: 'PUT', path: `${P(project)}/allowance`, json: { capabilities } }),
+ /** Staff only: 'sandbox' | 'sandbox+production'. */
+ setEnvironmentPolicy: (project, environmentPolicy) => call({ method: 'PUT', path: `${P(project)}/environment-policy`, json: { environment_policy: environmentPolicy } }),
+ members,
+ apps,
+ credentials,
+ grants,
+ quotas,
+ audit,
+ /** Every audit entry, newest first, across pages. */
+ iterateAudit: (project, { pageSize = 100 } = {}) => paginate(async (before) => {
+ const page = await audit(project, { before, limit: pageSize });
+ return { items: page.entries || [], next: page.next_before ?? null };
+ }, { cursor: undefined }),
+ };
+}
+
+module.exports = { createProjectsClient };
@@ -169,18 +169,29 @@ function subscribe(topics, onEvent, opts = {}) {
@@ -169,18 +169,29 @@ function subscribe(topics, onEvent, opts = {}) {
};
};
}
}
-/** Minimal SSE parser over a ReadableStream<Uint8Array> (WHATWG event-stream rules). */
-async function readSse(body, dispatch, onRetry) {
- if (!body || typeof body.getReader !== 'function') throw new TypeError('realtime: response has no readable body');
- const reader = body.getReader();
+/**
+ * parseSSE(body, { onRetry }) -> async iterator of { event, data, id } (WHATWG event-stream rules).
+ *
+ * for await (const { event, data, id } of parseSSE(res.body)) { … }
+ *
+ * `body` is a ReadableStream<Uint8Array> (fetch), or any async iterable of Uint8Array/string chunks
+ * (a Node stream). `event` defaults to 'message'; `id` is the id field of that event (undefined when
+ * it had none); comments are skipped; `retry: <ms>` is passed to onRetry. A line split across chunks,
+ * including a \r\n pair, is reassembled. Breaking out of the loop releases the stream.
+ */
+async function* parseSSE(body, { onRetry } = {}) {
+ if (!body || (typeof body.getReader !== 'function' && typeof body[Symbol.asyncIterator] !== 'function')) {
+ throw new TypeError('parseSSE: pass a ReadableStream or an async iterable body');
+ }
const decoder = new TextDecoder();
const decoder = new TextDecoder();
let buf = '';
let buf = '';
let type = '';
let type = '';
let data = [];
let data = [];
let id;
let id;
+ const out = [];
const line = (l) => {
const line = (l) => {
if (l === '') {
if (l === '') {
- if (data.length || type) dispatch(type || 'message', data.join('\n'), id);
+ if (data.length || type) out.push({ event: type || 'message', data: data.join('\n'), id });
type = ''; data = []; id = undefined;
type = ''; data = []; id = undefined;
return;
return;
}
}
@@ -192,27 +203,40 @@ async function readSse(body, dispatch, onRetry) {
@@ -192,27 +203,40 @@ async function readSse(body, dispatch, onRetry) {
if (field === 'event') type = value;
if (field === 'event') type = value;
else if (field === 'data') data.push(value);
else if (field === 'data') data.push(value);
else if (field === 'id' && !value.includes('\0')) id = value;
else if (field === 'id' && !value.includes('\0')) id = value;
- else if (field === 'retry' && /^\d+$/.test(value)) onRetry(Number(value));
+ else if (field === 'retry' && /^\d+$/.test(value) && onRetry) onRetry(Number(value));
};
};
+ const reader = typeof body.getReader === 'function' ? body.getReader() : null;
+ const iter = reader ? null : body[Symbol.asyncIterator]();
try {
try {
for (;;) {
for (;;) {
- const { value, done } = await reader.read();
+ const { value, done } = reader ? await reader.read() : await iter.next();
if (done) break;
if (done) break;
- buf += decoder.decode(value, { stream: true });
+ buf += typeof value === 'string' ? value : decoder.decode(value, { stream: true });
let m;
let m;
while ((m = /\r\n|\r|\n/.exec(buf))) {
while ((m = /\r\n|\r|\n/.exec(buf))) {
if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunks
if (m[0] === '\r' && m.index === buf.length - 1) break; // a \r\n may be split across chunks
line(buf.slice(0, m.index));
line(buf.slice(0, m.index));
buf = buf.slice(m.index + m[0].length);
buf = buf.slice(m.index + m[0].length);
}
}
+ while (out.length) yield out.shift();
}
}
} finally {
} finally {
- try { reader.releaseLock(); } catch { /* stream already gone */ }
+ if (reader) {
+ try { await reader.cancel(); } catch { /* stream already gone */ }
+ try { reader.releaseLock(); } catch { /* already released */ }
+ } else if (iter && typeof iter.return === 'function') {
+ try { await iter.return(); } catch { /* already closed */ }
+ }
}
}
}
}
+/** The realtime loop's reader: every parsed event goes to dispatch(type, data, id). */
+async function readSse(body, dispatch, onRetry) {
+ for await (const e of parseSSE(body, { onRetry })) dispatch(e.event, e.data, e.id);
+}
+
function createRealtimeClient(client, defaults = {}) {
function createRealtimeClient(client, defaults = {}) {
return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };
return { subscribe: (topics, onEvent, opts = {}) => subscribe(topics, onEvent, { client, ...defaults, ...opts }) };
}
}
-module.exports = { subscribe, createRealtimeClient, DEFAULT_ORIGIN };
+module.exports = { subscribe, createRealtimeClient, parseSSE, DEFAULT_ORIGIN };
new file mode 100644
new file mode 100644
@@ -0,0 +1,541 @@
@@ -0,0 +1,541 @@
+'use strict';
+/**
+ * Mock developer projects (Network ADR-014): projects, members, apps (app_<ULID> principals),
+ * credentials, grants, the /api/v1/projects API, app tokens from /oauth/token, and the app half of
+ * /oauth/authorize. Node only; part of createMockPlatform().
+ *
+ * Deliberate differences from Network, so tests need no staff steps (each is an option):
+ * - sandbox apps get tokens for any audience (`sandboxAudiences` restricts it like
+ * DEV_SANDBOX_AUDIENCES); receivers still refuse them unless `acceptSandbox` opts them in
+ * - projects created through the API start with every catalog capability in their allowance
+ * (`defaultAllowance: []` matches Network's empty default)
+ * - projects declared in options allow sandbox and production apps and any capability
+ */
+const crypto = require('node:crypto');
+const { ULID_RE, ulid, json, problem, redirect, s256 } = require('./util');
+
+const APP_ID_RE = new RegExp(`^app_${ULID_RE.source.slice(1, -1)}$`);
+const PRJ_ID_RE = new RegExp(`^prj_${ULID_RE.source.slice(1, -1)}$`);
+const PKCE_RE = /^[A-Za-z0-9_-]{43,128}$/;
+const TOKEN_TTL_S = 300;
+const CODE_TTL_MS = 5 * 60 * 1000;
+const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
+const ENV_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
+
+/** Capabilities that are public + active in openvibe-contracts v0.26.0 (grantable to apps). */
+const DEFAULT_APP_CATALOG = [
+ 'media.object.upload', 'media.object.read', 'tools.job.create', 'tools.job.read', 'tools.job.cancel',
+ 'games.mod.read', 'games.world.announce', 'games.prop.place',
+];
+
+function createDeveloper(ctx) {
+ const { opts } = ctx;
+ const projects = new Map();
+ const apps = new Map();
+ const codes = new Map();
+ const sandboxAudiences = opts.sandboxAudiences == null ? null : new Set(opts.sandboxAudiences);
+ const nowIso = () => new Date().toISOString();
+
+ // ── Catalog ─────────────────────────────────────────────
+ function capabilityDef(id) { return (opts.capabilities || []).find((c) => c.id === id) || null; }
+ function audienceOf(cap) {
+ const def = capabilityDef(cap);
+ return `openvibe.${def ? def.owner : String(cap).split('.')[0]}`;
+ }
+ function catalog() {
+ const declared = (opts.capabilities || []).filter((c) => c.visibility === 'public' && (c.status || 'active') === 'active');
+ const list = declared.length ? declared : DEFAULT_APP_CATALOG.map((id) => ({ id, owner: id.split('.')[0], visibility: 'public', description: '' }));
+ return list.map((c) => ({ id: c.id, owner: c.owner, audience: `openvibe.${c.owner}`, visibility: c.visibility, description: c.description || '', resourceConstraints: c.resourceConstraints, quotaClass: c.quotaClass }));
+ }
+ const grantable = (cap) => catalog().some((c) => c.id === cap);
+
+ // ── Model ───────────────────────────────────────────────
+ function audit(project, actor, action, target, detail = {}) {
+ project.audit.push({ id: project.audit.length + 1, at: nowIso(), actor, action, target, detail });
+ }
+
+ function addProject({ id, name, owner, environmentPolicy = 'sandbox+production', allowance = '*', members } = {}) {
+ const pid = id || `prj_${ulid()}`;
+ if (!PRJ_ID_RE.test(pid)) throw new TypeError(`mock platform: project id ${pid} is not prj_<ULID>`);
+ if (projects.has(pid)) return projects.get(pid);
+ const p = {
+ id: pid, name: name || pid, owner: owner || null, environment_policy: environmentPolicy,
+ allowance: allowance === '*' ? '*' : new Set(allowance), members: new Map(), open: !members && !owner,
+ created_at: nowIso(), archived_at: null, quotas: new Map(), audit: [],
+ };
+ if (owner) p.members.set(owner, 'owner');
+ for (const [subject, role] of Object.entries(members || {})) p.members.set(subject, role);
+ projects.set(pid, p);
+ // Media keys tenancy by project id; the real Media needs its operators to create the tenant.
+ if (ctx.mediaApps && !ctx.mediaApps.has(pid)) ctx.mediaApps.set(pid, { apiKey: null });
+ return p;
+ }
+
+ function newCredential(app, secret) {
+ const c = { id: `crd_${ulid()}`, secret: secret || `ovsec_${crypto.randomBytes(32).toString('base64url')}`, created_at: nowIso(), expires_at: null, revoked_at: null };
+ c.hint = c.secret.slice(-4);
+ app.credentials.push(c);
+ return c;
+ }
+ function credentialState(c) {
+ if (c.revoked_at) return 'revoked';
+ if (c.expires_at && Date.parse(c.expires_at) <= Date.now()) return 'expired';
+ return c.expires_at ? 'expiring' : 'active';
+ }
+ const matchSecret = (app, secret) => Boolean(secret) && app.credentials.some((c) => c.secret === secret && ['active', 'expiring'].includes(credentialState(c)));
+
+ /**
+ * addApp({ id?, project?, name?, env|environment = 'sandbox', type = 'confidential', secret?,
+ * redirectUris = [], grants = [capability | { capability, audience }] })
+ * -> { id, clientId, projectId, env, type, secret? } (grants listed here are approved)
+ */
+ function addApp(spec = {}) {
+ const id = spec.id || `app_${ulid()}`;
+ if (!APP_ID_RE.test(id)) throw new TypeError(`mock platform: app id ${id} is not app_<ULID>`);
+ const project = projects.get(spec.project) || addProject({ id: spec.project });
+ const environment = spec.env || spec.environment || 'sandbox';
+ const type = spec.type || 'confidential';
+ if (!['sandbox', 'production'].includes(environment)) throw new TypeError('mock platform: env is sandbox or production');
+ if (!['confidential', 'public'].includes(type)) throw new TypeError('mock platform: type is confidential or public');
+ const app = {
+ id, project_id: project.id, name: spec.name || id, environment, client_type: type, redirect_uris: [...(spec.redirectUris || [])],
+ created_at: nowIso(), revoked_at: null, credentials: [], grants: new Map(),
+ };
+ const cred = type === 'confidential' ? newCredential(app, spec.secret) : null;
+ for (const g of spec.grants || []) {
+ const cap = typeof g === 'string' ? g : g.capability;
+ app.grants.set(cap, { capability: cap, audience: (typeof g === 'object' && g.audience) || audienceOf(cap), status: 'approved', requested_by: 'mock', requested_at: nowIso(), decided_by: 'mock', decided_at: nowIso() });
+ }
+ apps.set(id, app);
+ return { id, clientId: id, projectId: project.id, env: environment, type, secret: cred ? cred.secret : undefined };
+ }
+
+ // opts.projects: { prj_…: { name?, owner?, members?: { usr_…: role }, allowance?: '*' | [], environmentPolicy? } }
+ // A project with an owner or members lets only them authorize its sandbox apps.
+ for (const [id, spec] of Object.entries(opts.projects || {})) addProject({ ...spec, id });
+ for (const [id, spec] of Object.entries(opts.apps || {})) addApp({ ...spec, id });
+
+ // ── Tokens ──────────────────────────────────────────────
+ const oauth = (status, error, description) => json(status, { error, error_description: description }, { 'Cache-Control': 'no-store' });
+
+ function usable(clientId) {
+ const app = apps.get(String(clientId || ''));
+ if (!app || app.revoked_at) return { res: oauth(401, 'invalid_client', 'unknown or revoked app') };
+ const project = projects.get(app.project_id);
+ if (!project || project.archived_at) return { res: oauth(401, 'invalid_client', 'project archived') };
+ if (!ENV_POLICIES[project.environment_policy].includes(app.environment)) return { res: oauth(400, 'unauthorized_client', `${app.environment} apps are not enabled for this project`) };
+ return { app, project };
+ }
+
+ const inAllowance = (project, cap) => project.allowance === '*' || project.allowance.has(cap);
+
+ function mint({ app, project, audience, scope, limit, onBehalfOf }) {
+ const aud = String(audience || '').trim();
+ if (!aud || !/^[a-z0-9.-]+$/.test(aud)) return oauth(400, 'invalid_request', 'audience is required');
+ if (app.environment === 'sandbox' && sandboxAudiences && !sandboxAudiences.has(aud)) return oauth(400, 'invalid_target', `${aud} does not accept sandbox tokens`);
+ const held = [...app.grants.values()].filter((g) => g.status === 'approved' && g.audience === aud && inAllowance(project, g.capability))
+ .map((g) => g.capability).filter((c) => !limit || limit.includes(c)).sort();
+ const wanted = scope ? String(scope).split(/\s+/).filter(Boolean) : null;
+ const missing = wanted ? wanted.filter((w) => !held.includes(w)) : [];
+ if (missing.length) return oauth(400, 'invalid_scope', `not granted: ${missing.join(' ')}`);
+ const cap = wanted || held;
+ if (!cap.length) return oauth(400, 'invalid_scope', `no grants for audience ${aud}`);
+ const now = Math.floor(Date.now() / 1000);
+ const token = ctx.sign({
+ iss: ctx.issuer, sub: `app:${app.id}`, actor_type: 'app', aud: [aud], cap, ns: [project.id], project_id: project.id, env: app.environment,
+ ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}), iat: now, exp: now + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
+ });
+ return json(200, { access_token: token, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: cap.join(' ') }, { 'Cache-Control': 'no-store' });
+ }
+
+ /** /oauth/token for a registered app client id; null when the client id is not one. */
+ function token(p) {
+ if (!apps.has(p.get('client_id') || '')) return null;
+ const found = usable(p.get('client_id'));
+ if (found.res) return found.res;
+ const { app, project } = found;
+ const secret = p.get('client_secret');
+ const authenticate = () => {
+ if (app.client_type === 'public') return secret ? oauth(401, 'invalid_client', 'public apps have no client secret') : null;
+ return matchSecret(app, secret) ? null : oauth(401, 'invalid_client', 'Invalid client credentials');
+ };
+ const grant = p.get('grant_type');
+ if (grant === 'client_credentials') {
+ if (app.client_type !== 'confidential') return oauth(400, 'unauthorized_client', 'public apps use authorization_code with PKCE');
+ return authenticate() || mint({ app, project, audience: p.get('audience'), scope: p.get('scope') });
+ }
+ if (grant === 'authorization_code') {
+ const bad = authenticate();
+ if (bad) return bad;
+ const c = codes.get(p.get('code') || '');
+ if (!c || c.appId !== app.id) return oauth(400, 'invalid_grant', 'Invalid authorization code');
+ if (c.used) return oauth(400, 'invalid_grant', 'Code already used');
+ c.used = true; // single use; a failed check below still burns it
+ if (c.expiresAt < Date.now()) return oauth(400, 'invalid_grant', 'Authorization code expired');
+ if (c.redirectUri !== (p.get('redirect_uri') || '')) return oauth(400, 'invalid_grant', 'Redirect URI mismatch');
+ const v = p.get('code_verifier') || '';
+ if (!PKCE_RE.test(v) || s256(v) !== c.codeChallenge) return oauth(400, 'invalid_grant', 'PKCE verification failed');
+ if (c.audience && c.audience !== p.get('audience')) return oauth(400, 'invalid_grant', `the code was authorized for ${c.audience}`);
+ const authorized = c.scope.length ? c.scope : null;
+ const asked = p.get('scope') ? p.get('scope').split(/\s+/).filter(Boolean) : null;
+ if (authorized && asked && asked.some((s) => !authorized.includes(s))) return oauth(400, 'invalid_scope', 'scope exceeds what the user authorized');
+ return mint({ app, project, audience: p.get('audience'), scope: p.get('scope'), limit: authorized, onBehalfOf: c.subjectId });
+ }
+ return oauth(400, 'unsupported_grant_type', 'apps use client_credentials or authorization_code');
+ }
+
+ // ── Authorization ───────────────────────────────────────
+ const isApp = (clientId) => apps.has(String(clientId || ''));
+
+ /** Issue a code for an app, or throw with { status, error } when Network would refuse. */
+ function issueCode({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope, audience }) {
+ const found = usable(clientId);
+ if (found.res) throw Object.assign(new Error('unknown or unusable app'), { status: 400, error: 'invalid_request' });
+ const { app, project } = found;
+ if (!redirectUri || !app.redirect_uris.includes(String(redirectUri))) throw Object.assign(new Error('Invalid redirect_uri'), { status: 400, error: 'invalid_request' });
+ if (!codeChallenge || codeChallengeMethod !== 'S256' || !PKCE_RE.test(String(codeChallenge))) {
+ throw Object.assign(new Error('apps must send a PKCE code_challenge with code_challenge_method=S256'), { status: 400, error: 'invalid_request' });
+ }
+ const user = ctx.users.get(subjectId) || [...ctx.users.values()][0] || ctx.addUser();
+ if (app.environment === 'sandbox' && !project.open && !project.members.has(user.subject_id)) {
+ throw Object.assign(new Error('sandbox apps can only be authorized by members of their project'), { status: 403, error: 'access_denied', redirectable: true });
+ }
+ const code = crypto.randomBytes(32).toString('hex');
+ const list = (Array.isArray(scope) ? scope : String(scope || '').split(/\s+/)).filter(Boolean);
+ codes.set(code, { appId: app.id, redirectUri: String(redirectUri), subjectId: user.subject_id, codeChallenge: String(codeChallenge), scope: list, audience: audience || null, expiresAt: Date.now() + CODE_TTL_MS, used: false });
+ return code;
+ }
+
+ /** GET /oauth/authorize for an app client id (auto-consent as ctx.authorization.subjectId). */
+ function authorizeRoute(url) {
+ const q = url.searchParams;
+ const clientId = q.get('client_id');
+ const found = usable(clientId);
+ if (found.res) return json(400, { error: 'invalid_request', error_description: 'Unknown client_id' });
+ const redirectUri = q.get('redirect_uri') || '';
+ if (!found.app.redirect_uris.includes(redirectUri)) return json(400, { error: 'invalid_request', error_description: 'Invalid redirect_uri' });
+ const back = (params) => redirect(redirectUri, { ...params, state: q.get('state') });
+ if (q.get('response_type') !== 'code') return back({ error: 'unsupported_response_type' });
+ if (q.get('prompt') === 'none') return back({ error: 'interaction_required', error_description: 'apps need the person to choose to continue' });
+ if (!q.get('code_challenge') || q.get('code_challenge_method') !== 'S256' || !PKCE_RE.test(q.get('code_challenge'))) {
+ return json(400, { error: 'invalid_request', error_description: 'apps must send a PKCE code_challenge with code_challenge_method=S256' });
+ }
+ if (ctx.authorization.decision === 'deny') return back({ error: 'access_denied', error_description: 'the person declined' });
+ try {
+ const code = issueCode({
+ clientId, redirectUri, subjectId: ctx.authorization.subjectId, codeChallenge: q.get('code_challenge'),
+ codeChallengeMethod: q.get('code_challenge_method'), scope: q.get('scope'), audience: q.get('audience'),
+ });
+ return back({ code });
+ } catch (err) {
+ return back({ error: err.error || 'server_error', error_description: err.message });
+ }
+ }
+
+ // ── /api/v1/projects ────────────────────────────────────
+ class DevError extends Error { constructor(status, code, detail) { super(detail); this.status = status; this.code = code; } }
+ const fail = (status, code, detail) => { throw new DevError(status, code, detail); };
+
+ function access(actor, projectId, { need = 'viewer', staffOk = true, allowArchived = false } = {}) {
+ const p = projects.get(projectId);
+ const role = p ? p.members.get(actor.subject) || null : null;
+ if (!p || (!role && !actor.staff)) fail(404, 'project.not_found', 'no such project');
+ if (p.archived_at && !allowArchived) fail(409, 'project.archived', 'project is archived');
+ if (role && RANK[role] >= RANK[need]) return { project: p, role };
+ if (actor.staff && staffOk) return { project: p, role };
+ return fail(403, 'project.forbidden', `needs ${need}`);
+ }
+ function loadApp(project, appId) {
+ const a = apps.get(appId);
+ if (!a || a.project_id !== project.id) fail(404, 'app.not_found', 'no such app');
+ return a;
+ }
+ const manageRole = (env) => (env === 'production' ? 'admin' : 'developer');
+ const allowanceList = (p) => (p.allowance === '*' ? ['*'] : [...p.allowance].sort());
+ function projectView(p, role) {
+ return {
+ id: p.id, name: p.name, owner: p.owner ? { type: 'user', id: p.owner } : null, role: role || null,
+ environment_policy: p.environment_policy, environments: ENV_POLICIES[p.environment_policy], allowance: allowanceList(p),
+ created_at: p.created_at, archived_at: p.archived_at,
+ counts: { members: p.members.size, apps: [...apps.values()].filter((a) => a.project_id === p.id && !a.revoked_at).length },
+ };
+ }
+ function appView(a) {
+ return {
+ id: a.id, subject: { type: 'app', id: a.id }, project_id: a.project_id, name: a.name, environment: a.environment, client_id: a.id,
+ client_type: a.client_type, redirect_uris: [...a.redirect_uris], created_at: a.created_at, revoked_at: a.revoked_at,
+ grants: [...a.grants.values()].filter((g) => g.status === 'approved').map((g) => g.capability).sort(),
+ };
+ }
+ const credentialView = (c) => ({ id: c.id, hint: c.hint, state: credentialState(c), created_at: c.created_at, expires_at: c.expires_at, revoked_at: c.revoked_at });
+ const grantView = (a, g) => ({ app_id: a.id, ...g });
+ function memberView(subject, role) {
+ const u = ctx.users.get(subject);
+ return { subject: { type: 'user', id: subject }, username: u ? u.username : null, display_name: u ? (u.display_name || u.username) : null, role };
+ }
+ const cleanName = (n) => {
+ const s = String(n || '').trim();
+ if (!s || s.length > 80) fail(422, 'project.invalid', 'name is 1..80 characters');
+ return s;
+ };
+
+ function setGrant(p, a, g, actor, status) {
+ const from = g.status;
+ Object.assign(g, { status, decided_by: actor, decided_at: nowIso() });
+ audit(p, actor, `grant.${status}`, `app:${a.id}`, { capability: g.capability, from });
+ }
+
+ async function projectsApi(req, url) {
+ const h = req.headers.get('authorization') || '';
+ if (!h.startsWith('Bearer ')) return problem(401, 'auth.required', 'send Authorization: Bearer <Network access token>');
+ const user = ctx.userOf(req);
+ if (!user) return problem(401, 'auth.invalid', 'not a Network user access token');
+ const u = ctx.users.get(user.subject_id);
+ const actor = { subject: user.subject_id, staff: (u ? u.role : user.role) === 'admin', label: `user:${user.subject_id}` };
+ const path = url.pathname.slice('/api/v1/projects'.length).replace(/\/+$/, '');
+ const seg = path.split('/').filter(Boolean).map(decodeURIComponent);
+ const m = req.method;
+ const body = ['POST', 'PUT', 'PATCH'].includes(m) ? await req.json().catch(() => ({})) || {} : {};
+ const ok = (status, out) => json(status, out, { 'Cache-Control': 'private, no-store' });
+ try {
+ if (!seg.length) {
+ if (m === 'GET') {
+ const all = url.searchParams.get('all') === '1' && actor.staff;
+ return ok(200, { projects: [...projects.values()].filter((p) => all || p.members.has(actor.subject)).map((p) => projectView(p, p.members.get(actor.subject))) });
+ }
+ if (m === 'POST') {
+ const allowance = opts.defaultAllowance === undefined ? catalog().map((c) => c.id) : opts.defaultAllowance;
+ const p = addProject({ name: cleanName(body.name), owner: actor.subject, environmentPolicy: 'sandbox', allowance });
+ audit(p, actor.label, 'project.created', `project:${p.id}`, { name: p.name });
+ return ok(201, projectView(p, 'owner'));
+ }
+ }
+ if (seg[0] === 'catalog' && seg.length === 1 && m === 'GET') return ok(200, { capabilities: catalog() });
+ const pid = seg[0];
+ if (seg.length === 1) {
+ if (m === 'GET') { const { project, role } = access(actor, pid, { allowArchived: true }); return ok(200, projectView(project, role)); }
+ if (m === 'PATCH') { const { project, role } = access(actor, pid, { need: 'admin', staffOk: false }); project.name = cleanName(body.name); return ok(200, projectView(project, role)); }
+ }
+ if (seg.length === 2 && seg[1] === 'archive' && m === 'POST') {
+ const { project, role } = access(actor, pid, { need: 'owner' });
+ project.archived_at = nowIso();
+ for (const a of apps.values()) if (a.project_id === project.id && !a.revoked_at) a.revoked_at = project.archived_at;
+ audit(project, actor.label, 'project.archived', `project:${project.id}`);
+ return ok(200, projectView(project, role));
+ }
+ if (seg.length === 2 && seg[1] === 'allowance' && m === 'PUT') {
+ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');
+ const { project } = access(actor, pid);
+ const wanted = (Array.isArray(body.capabilities) ? body.capabilities : []).map(String);
+ const bad = wanted.find((c) => !grantable(c));
+ if (bad) fail(403, 'grant.not_grantable', `${bad} is not grantable to apps`);
+ project.allowance = new Set(wanted);
+ const trimmed = [];
+ for (const a of apps.values()) {
+ if (a.project_id !== project.id) continue;
+ for (const g of a.grants.values()) {
+ if (wanted.includes(g.capability)) continue;
+ if (g.status === 'approved') { setGrant(project, a, g, actor.label, 'revoked'); trimmed.push({ app_id: a.id, capability: g.capability, to: 'revoked' }); }
+ if (g.status === 'requested') { setGrant(project, a, g, actor.label, 'denied'); trimmed.push({ app_id: a.id, capability: g.capability, to: 'denied' }); }
+ }
+ }
+ return ok(200, { allowance: allowanceList(project), trimmed });
+ }
+ if (seg.length === 2 && seg[1] === 'environment-policy' && m === 'PUT') {
+ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');
+ const { project } = access(actor, pid);
+ if (!ENV_POLICIES[body.environment_policy]) fail(422, 'project.invalid', 'environment_policy is sandbox or sandbox+production');
+ project.environment_policy = body.environment_policy;
+ return ok(200, { environment_policy: project.environment_policy, environments: ENV_POLICIES[project.environment_policy] });
+ }
+ if (seg[1] === 'members') return members(actor, pid, seg, m, body, ok);
+ if (seg[1] === 'apps') return appsRoutes(actor, pid, seg, m, body, ok);
+ if (seg[1] === 'quotas') {
+ const { project } = access(actor, pid, { allowArchived: true });
+ if (seg.length === 2 && m === 'GET') return ok(200, { quotas: [...project.quotas.values()], note: 'quotas are enforced by the service that owns each capability; Network records and exposes them' });
+ if (seg.length === 3 && (m === 'PUT' || m === 'DELETE')) {
+ if (!actor.staff) fail(403, 'project.forbidden', 'staff only');
+ if (m === 'DELETE') { if (!project.quotas.delete(seg[2])) fail(404, 'quota.not_found', 'no such quota'); return new Response(null, { status: 204 }); }
+ const q = { capability: seg[2], limit: Number(body.limit), window: body.window, unit: body.unit, enforced_by: audienceOf(seg[2]), updated_at: nowIso() };
+ project.quotas.set(seg[2], q);
+ return ok(200, q);
+ }
+ }
+ if (seg.length === 2 && seg[1] === 'audit' && m === 'GET') {
+ const { project } = access(actor, pid, { need: 'admin', allowArchived: true });
+ const before = Number(url.searchParams.get('before')) || Infinity;
+ const n = Math.min(200, Math.max(1, Number(url.searchParams.get('limit')) || 50));
+ const rows = project.audit.filter((e) => e.id < before).reverse().slice(0, n);
+ return ok(200, { entries: rows, next_before: rows.length === n ? rows[rows.length - 1].id : null });
+ }
+ return problem(404, 'not_found', 'no such route');
+ } catch (err) {
+ if (err instanceof DevError) return problem(err.status, err.code, err.message);
+ throw err;
+ }
+ }
+
+ function members(actor, pid, seg, m, body, ok) {
+ if (seg.length === 2 && m === 'GET') {
+ const { project } = access(actor, pid, { allowArchived: true });
+ return ok(200, { members: [...project.members].map(([s, r]) => memberView(s, r)) });
+ }
+ if (seg.length === 2 && m === 'POST') {
+ const role = String(body.role || '');
+ if (!['admin', 'developer', 'viewer'].includes(role)) fail(422, 'member.invalid', 'role is admin, developer or viewer');
+ const { project } = access(actor, pid, { need: role === 'admin' ? 'owner' : 'admin', staffOk: false });
+ const u = body.subject_id ? ctx.users.get(body.subject_id) : [...ctx.users.values()].find((x) => x.username === body.username);
+ if (!u) fail(404, 'member.user_not_found', 'no such user');
+ if (project.members.has(u.subject_id)) fail(409, 'member.exists', 'already a member');
+ project.members.set(u.subject_id, role);
+ audit(project, actor.label, 'member.added', `user:${u.subject_id}`, { role });
+ return ok(201, memberView(u.subject_id, role));
+ }
+ if (seg.length === 3) {
+ const { project, role: mine } = access(actor, pid, { allowArchived: m === 'DELETE' });
+ const subject = seg[2];
+ const theirs = project.members.get(subject);
+ if (!theirs) fail(404, 'member.not_found', 'not a member');
+ if (m === 'PATCH') {
+ const role = String(body.role || '');
+ if (!['admin', 'developer', 'viewer'].includes(role)) fail(422, 'member.invalid', 'role is admin, developer or viewer');
+ if (theirs === 'owner') fail(409, 'member.owner', 'ownership cannot be changed here');
+ const need = role === 'admin' || theirs === 'admin' ? 'owner' : 'admin';
+ if (!mine || RANK[mine] < RANK[need]) fail(403, 'project.forbidden', `needs ${need}`);
+ project.members.set(subject, role);
+ return ok(200, memberView(subject, role));
+ }
+ if (m === 'DELETE') {
+ if (theirs === 'owner') fail(409, 'member.owner', 'the owner cannot leave');
+ const self = subject === actor.subject;
+ const need = theirs === 'admin' ? 'owner' : 'admin';
+ if (!self && (!mine || RANK[mine] < RANK[need])) fail(403, 'project.forbidden', `needs ${need}`);
+ project.members.delete(subject);
+ audit(project, actor.label, 'member.removed', `user:${subject}`);
+ return new Response(null, { status: 204 });
+ }
+ }
+ return problem(404, 'not_found', 'no such route');
+ }
+
+ function appsRoutes(actor, pid, seg, m, body, ok) {
+ if (seg.length === 2 && m === 'GET') {
+ const { project } = access(actor, pid, { allowArchived: true });
+ return ok(200, { apps: [...apps.values()].filter((a) => a.project_id === project.id).map(appView) });
+ }
+ if (seg.length === 2 && m === 'POST') {
+ const environment = String(body.environment || 'sandbox');
+ if (!['sandbox', 'production'].includes(environment)) fail(422, 'app.invalid', 'environment is sandbox or production');
+ const { project } = access(actor, pid, { need: manageRole(environment), staffOk: false });
+ if (!ENV_POLICIES[project.environment_policy].includes(environment)) fail(403, 'app.environment_not_allowed', `this project may only have ${ENV_POLICIES[project.environment_policy].join(', ')} apps (staff enable production)`);
+ const type = String(body.type || 'confidential');
+ if (!['confidential', 'public'].includes(type)) fail(422, 'app.invalid', 'type is confidential or public');
+ const redirects = Array.isArray(body.redirect_uris) ? body.redirect_uris.map(String) : [];
+ for (const r of redirects) {
+ let u;
+ try { u = new URL(r); } catch { fail(422, 'app.invalid_redirect', `${r} is not a URL`); }
+ const local = ['localhost', '127.0.0.1', '[::1]'].includes(u.hostname);
+ if (u.hash || u.username || u.password) fail(422, 'app.invalid_redirect', 'no fragments or credentials in redirect URIs');
+ if (u.protocol !== 'https:' && !(local && u.protocol === 'http:' && environment === 'sandbox')) fail(422, 'app.invalid_redirect', 'redirect URIs are https (http://localhost for sandbox apps only)');
+ }
+ if (type === 'public' && !redirects.length) fail(422, 'app.invalid', 'a public app needs at least one redirect URI');
+ const made = addApp({ project: project.id, name: cleanName(body.name), environment, type, redirectUris: redirects });
+ const a = apps.get(made.id);
+ audit(project, actor.label, 'app.created', `app:${a.id}`, { environment, client_type: type });
+ const out = appView(a);
+ if (made.secret) {
+ const c = a.credentials[0];
+ out.credential = { id: c.id, client_secret: c.secret, hint: c.hint, shown_once: true };
+ }
+ return ok(201, out);
+ }
+ const { project, role } = access(actor, pid, { allowArchived: true });
+ const a = loadApp(project, seg[2]);
+ const manage = () => {
+ if (!role || RANK[role] < RANK[manageRole(a.environment)]) fail(403, 'project.forbidden', `needs ${manageRole(a.environment)}`);
+ if (project.archived_at) fail(409, 'project.archived', 'project is archived');
+ };
+ if (seg.length === 3) {
+ if (m === 'GET') return ok(200, appView(a));
+ if (m === 'PATCH') {
+ manage();
+ if (body.name !== undefined) a.name = cleanName(body.name);
+ if (Array.isArray(body.redirect_uris)) a.redirect_uris = body.redirect_uris.map(String);
+ return ok(200, appView(a));
+ }
+ if (m === 'DELETE') {
+ if (!actor.staff) manage();
+ if (!a.revoked_at) { a.revoked_at = nowIso(); audit(project, actor.label, 'app.revoked', `app:${a.id}`); }
+ return ok(200, appView(a));
+ }
+ }
+ if (seg[3] === 'credentials') {
+ if (seg.length === 4 && m === 'GET') return ok(200, { credentials: a.credentials.map(credentialView) });
+ if (seg.length === 5 && seg[4] === 'rotate' && m === 'POST') {
+ manage();
+ if (a.revoked_at) fail(409, 'app.revoked', 'app is revoked');
+ if (a.client_type !== 'confidential') fail(409, 'credential.public_client', 'public apps have no client secret');
+ const overlap = body.overlap_seconds === undefined ? 86400 : Number(body.overlap_seconds);
+ if (!Number.isInteger(overlap) || overlap < 0 || overlap > 7 * 86400) fail(422, 'credential.invalid', 'overlap_seconds is an integer from 0 to 604800');
+ const until = new Date(Date.now() + overlap * 1000).toISOString();
+ const kept = [];
+ for (const c of a.credentials) {
+ if (c.revoked_at || credentialState(c) === 'expired') continue;
+ c.expires_at = c.expires_at && c.expires_at < until ? c.expires_at : until;
+ kept.push({ id: c.id, expires_at: c.expires_at });
+ }
+ const c = newCredential(a);
+ audit(project, actor.label, 'credential.rotated', `app:${a.id}`, { credential_id: c.id, overlap_seconds: overlap });
+ return ok(201, { credential: { id: c.id, client_secret: c.secret, hint: c.hint, shown_once: true }, previous: kept });
+ }
+ if (seg.length === 6 && seg[5] === 'revoke' && m === 'POST') {
+ if (!actor.staff) manage();
+ const c = a.credentials.find((x) => x.id === seg[4]);
+ if (!c) fail(404, 'credential.not_found', 'no such credential');
+ if (!c.revoked_at) { c.revoked_at = nowIso(); audit(project, actor.label, 'credential.revoked', `app:${a.id}`, { credential_id: c.id }); }
+ return ok(200, credentialView(c));
+ }
+ }
+ if (seg[3] === 'grants') {
+ if (seg.length === 4 && m === 'GET') return ok(200, { grants: [...a.grants.values()].map((g) => grantView(a, g)) });
+ if (seg.length === 4 && m === 'POST') {
+ if (!role || RANK[role] < RANK.developer) fail(403, 'project.forbidden', 'needs developer');
+ if (a.revoked_at) fail(409, 'app.revoked', 'app is revoked');
+ const cap = String(body.capability || '');
+ if (!grantable(cap)) fail(403, 'grant.not_grantable', `${cap} is not in the capability catalog for apps`);
+ const existing = a.grants.get(cap);
+ if (existing && ['approved', 'requested'].includes(existing.status)) return ok(201, grantView(a, existing));
+ const now = RANK[role] >= RANK.admin && inAllowance(project, cap);
+ const g = { capability: cap, audience: audienceOf(cap), status: now ? 'approved' : 'requested', requested_by: actor.label, requested_at: nowIso(), decided_by: now ? actor.label : null, decided_at: now ? nowIso() : null };
+ a.grants.set(cap, g);
+ audit(project, actor.label, now ? 'grant.approved' : 'grant.requested', `app:${a.id}`, { capability: cap });
+ return ok(201, grantView(a, g));
+ }
+ const g = a.grants.get(seg[4]);
+ if (seg.length === 6 && ['approve', 'deny'].includes(seg[5]) && m === 'POST') {
+ if (!role || RANK[role] < RANK.admin) fail(403, 'project.forbidden', 'needs admin');
+ if (!g) fail(404, 'grant.not_found', 'no such grant');
+ if (seg[5] === 'approve') {
+ if (!inAllowance(project, g.capability)) fail(403, 'grant.beyond_allowance', `${g.capability} is not in this project's allowance`);
+ if (g.status !== 'approved') setGrant(project, a, g, actor.label, 'approved');
+ } else {
+ if (g.status !== 'requested') fail(409, 'grant.not_pending', `grant is ${g.status}`);
+ setGrant(project, a, g, actor.label, 'denied');
+ }
+ return ok(200, grantView(a, g));
+ }
+ if (seg.length === 5 && m === 'DELETE') {
+ if (!actor.staff && (!role || RANK[role] < RANK.admin)) fail(403, 'project.forbidden', 'needs admin');
+ if (!g) fail(404, 'grant.not_found', 'no such grant');
+ if (g.status !== 'approved') fail(409, 'grant.not_active', `grant is ${g.status}`);
+ setGrant(project, a, g, actor.label, 'revoked');
+ return ok(200, grantView(a, g));
+ }
+ }
+ return problem(404, 'not_found', 'no such route');
+ }
+
+ return { projects, apps, addApp, addProject, token, isApp, issueCode, authorizeRoute, projectsApi, catalog };
+}
+
+module.exports = { createDeveloper, DEFAULT_APP_CATALOG, APP_ID_RE, PRJ_ID_RE };
@@ -5,61 +5,42 @@
@@ -5,61 +5,42 @@
* const { createMockPlatform } = require('openvibe-sdk/testing');
* const { createMockPlatform } = require('openvibe-sdk/testing');
* const platform = createMockPlatform({
* const platform = createMockPlatform({
* clients: { demo: { secret: 's3cret', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['demo'] }] } },
* clients: { demo: { secret: 's3cret', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['demo'] }] } },
+ * apps: { [appId]: { env: 'sandbox', type: 'confidential', secret: 's', grants: ['media.object.upload'] } },
* mediaApps: { demo: {} },
* mediaApps: { demo: {} },
* });
* });
* const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'demo', clientSecret: 's3cret', fetch: platform.fetch }) });
* const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'demo', clientSecret: 's3cret', fetch: platform.fetch }) });
*
*
* `platform.fetch` answers like the real services at their public origins:
* `platform.fetch` answers like the real services at their public origins:
- * Network /.well-known/openvibe, /oauth/token (client_credentials, authorization_code with PKCE,
- * refresh_token), /api/.well-known/jwks, /api/v1/registry/*, /api/modules/*,
- * /internal/modules/*, /internal/identity/*
- * Events /api/v1/events, /api/v1/subscriptions, /api/v1/checkpoints, /realtime/stream (SSE)
- * Media /api/v1/:app/files
+ * Network /.well-known/openvibe, /oauth/authorize (auto-consent), /oauth/token (client_credentials,
+ * authorization_code with PKCE, refresh_token; developer apps), /api/.well-known/jwks,
+ * /api/v1/registry/*, /api/v1/projects/*, /api/modules/*, /internal/modules/*,
+ * /internal/identity/*
+ * Events /api/v1/events (with retention gaps after pruneEvents()), /api/v1/subscriptions,
+ * /api/v1/checkpoints, /realtime/stream (SSE); deliverEvents() plays the delivery worker
+ * Media /api/v1/:app/files (developer apps: the tenant is the project id)
+ * Tools /api/v1/jobs (with { jobs: true })
* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
- * services check them (audience, capability, namespace). It is a fake: no persistence, no
- * delivery worker, simplified visibility rules. It is stricter than the Network on one point: it
- * verifies PKCE code_verifier when the authorization carried a challenge.
+ * services check them (audience, capability, namespace, sandbox refusal). It is a fake: no
+ * persistence, simplified visibility rules, no Chat. Like the Network, it verifies the PKCE
+ * code_verifier whenever the authorization carried a challenge, and requires one from apps.
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
+const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');
+const { createDeveloper, DEFAULT_APP_CATALOG } = require('./developer');
+const { createJobsService } = require('./jobs');
const DEFAULT_ORIGINS = {
const DEFAULT_ORIGINS = {
network: 'https://openvibe.network',
network: 'https://openvibe.network',
events: 'https://events.openvibe.network',
events: 'https://events.openvibe.network',
media: 'https://openvibe.media',
media: 'https://openvibe.media',
community: 'https://openvibe.community',
community: 'https://openvibe.community',
+ tools: 'https://openvibe.tools',
};
};
-const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
-const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
-const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
-
-function ulid() {
- let t = Date.now();
- let time = '';
- for (let i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ULID_ALPHABET[t % 32] + time;
- let rand = '';
- for (const b of crypto.randomBytes(16)) rand += ULID_ALPHABET[b & 31];
- return time + rand;
-}
-
-function topicRegex(pattern) {
- const seg = '[a-z0-9_]+';
- const parts = String(pattern).split('.').map((p) => (p === '*' ? `${seg}(?:\\.${seg})*` : p.replace(/[^a-z0-9_]/g, '')));
- return new RegExp(`^${parts.join('\\.')}$`);
-}
-
-function json(status, body, headers = {}) {
- return new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
-}
-function problem(status, code, detail) {
- return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail, error: detail || code }), {
- status, headers: { 'Content-Type': 'application/problem+json' },
- });
-}
function createMockPlatform(opts = {}) {
function createMockPlatform(opts = {}) {
const origins = { ...DEFAULT_ORIGINS, ...opts.origins };
const origins = { ...DEFAULT_ORIGINS, ...opts.origins };
const issuer = opts.issuer || origins.network;
const issuer = opts.issuer || origins.network;
- const contractsVersion = opts.contractsVersion || '0.6.0';
+ const contractsVersion = opts.contractsVersion || '0.26.0';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const kid = 'mock-1';
const kid = 'mock-1';
const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };
const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };
@@ -70,13 +51,17 @@ function createMockPlatform(opts = {}) {
@@ -70,13 +51,17 @@ function createMockPlatform(opts = {}) {
const codes = new Map();
const codes = new Map();
const refreshTokens = new Map();
const refreshTokens = new Map();
const modules = new Map(); // `${subject}|${ns}` -> record
const modules = new Map(); // `${subject}|${ns}` -> record
- const events = []; // { seq, event, publisher }
+ const events = []; // { seq, event, publisher }, oldest first; pruneEvents() drops the head
+ let lastSeq = 0;
+ const oldestSeq = () => (events.length ? events[0].seq : lastSeq + 1);
const subscriptions = new Map();
const subscriptions = new Map();
const checkpoints = new Map();
const checkpoints = new Map();
const files = new Map(); // `${app}|${key}` -> meta
const files = new Map(); // `${app}|${key}` -> meta
const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
const streams = new Set();
const streams = new Set();
- const stats = { tokenRequests: 0, requests: [] };
+ const stats = { tokenRequests: 0, requests: [], deliveries: [] };
+ const acceptSandbox = opts.acceptSandbox === true ? true : new Set(opts.acceptSandbox || []);
+ const authorization = { subjectId: null, decision: 'allow' };
const namespaces = opts.namespaces || [{ namespace: 'demo.prefs', owner: 'demo', version: 1, writers: ['owner', 'user'], publicFields: ['theme'], quotaBytes: 4096, onOwnerRemoved: 'retain-readonly', schema: { type: 'object' } }];
const namespaces = opts.namespaces || [{ namespace: 'demo.prefs', owner: 'demo', version: 1, writers: ['owner', 'user'], publicFields: ['theme'], quotaBytes: 4096, onOwnerRemoved: 'retain-readonly', schema: { type: 'object' } }];
const capabilities = opts.capabilities || [];
const capabilities = opts.capabilities || [];
@@ -117,13 +102,34 @@ function createMockPlatform(opts = {}) {
@@ -117,13 +102,34 @@ function createMockPlatform(opts = {}) {
return sign({ iss: issuer, sub: `svc:${clientId}`, actor_type: 'service', aud: [audience], cap, ns, iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
return sign({ iss: issuer, sub: `svc:${clientId}`, actor_type: 'service', aud: [audience], cap, ns, iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}` });
}
}
- /** { claims } of a verified caller, or { res } with the error to answer. */
+ /**
+ * An app token signed directly, without the token endpoint (e.g. to test a receiver). A
+ * registered app's project and env are used unless given.
+ */
+ function signAppToken(appId, { audience, capabilities: cap = [], projectId, env, onBehalfOf, expiresInSec = 300 } = {}) {
+ const app = developer.apps.get(appId);
+ const project = projectId || (app && app.project_id) || `prj_${ulid()}`;
+ return sign({
+ iss: issuer, sub: `app:${appId}`, actor_type: 'app', aud: [audience], cap, ns: [project], project_id: project,
+ env: env || (app && app.environment) || 'sandbox', ...(onBehalfOf ? { on_behalf_of: onBehalfOf } : {}),
+ iat: now(), exp: now() + expiresInSec, jti: `tok_${crypto.randomBytes(8).toString('hex')}`,
+ });
+ }
+
+ /**
+ * { claims, service } of a verified service or app principal, or { res } with the error to
+ * answer. App tokens with env=sandbox are refused (401 token.sandbox_refused) unless
+ * `acceptSandbox` lists this audience or capability (or is true).
+ */
function principal(req, audience, capability, namespace) {
function principal(req, audience, capability, namespace) {
const auth = req.headers.get('authorization') || '';
const auth = req.headers.get('authorization') || '';
if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };
if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };
const claims = decode(auth.slice(7));
const claims = decode(auth.slice(7));
- if (!claims || claims.actor_type !== 'service') return { res: problem(401, 'token.bad_signature', 'not a valid service token') };
+ if (!claims || !['service', 'app', 'mod'].includes(claims.actor_type)) return { res: problem(401, 'token.bad_signature', 'not a valid service token') };
if (!(claims.aud || []).includes(audience)) return { res: problem(401, 'token.wrong_audience', `not for ${audience}`) };
if (!(claims.aud || []).includes(audience)) return { res: problem(401, 'token.wrong_audience', `not for ${audience}`) };
+ if (claims.env === 'sandbox' && !(acceptSandbox === true || acceptSandbox.has(audience) || acceptSandbox.has(capability))) {
+ return { res: problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here') };
+ }
const granted = (claims.cap || []).some((c) => c === capability || (c.endsWith('.*') && capability.startsWith(c.slice(0, -1))));
const granted = (claims.cap || []).some((c) => c === capability || (c.endsWith('.*') && capability.startsWith(c.slice(0, -1))));
if (!granted) return { res: problem(403, 'capability.denied', `${capability} not granted`) };
if (!granted) return { res: problem(403, 'capability.denied', `${capability} not granted`) };
if (namespace && claims.ns && claims.ns.length && !claims.ns.some((n) => n === namespace || (n.endsWith('.*') && namespace.startsWith(n.slice(0, -1))))) {
if (namespace && claims.ns && claims.ns.length && !claims.ns.some((n) => n === namespace || (n.endsWith('.*') && namespace.startsWith(n.slice(0, -1))))) {
@@ -137,6 +143,9 @@ function createMockPlatform(opts = {}) {
@@ -137,6 +143,9 @@ function createMockPlatform(opts = {}) {
return claims && !claims.actor_type ? claims : null;
return claims && !claims.actor_type ? claims : null;
}
}
+ const developer = createDeveloper({ opts, issuer, sign, users, addUser, userOf, mediaApps, authorization });
+ const jobsService = createJobsService({ opts, decode, principal });
+
// ── Network ─────────────────────────────────────────────
// ── Network ─────────────────────────────────────────────
function descriptor() {
function descriptor() {
return {
return {
@@ -155,6 +164,8 @@ function createMockPlatform(opts = {}) {
@@ -155,6 +164,8 @@ function createMockPlatform(opts = {}) {
async function tokenEndpoint(req) {
async function tokenEndpoint(req) {
stats.tokenRequests++;
stats.tokenRequests++;
const p = new URLSearchParams(await req.text());
const p = new URLSearchParams(await req.text());
+ const appAnswer = developer.token(p);
+ if (appAnswer) return appAnswer;
const client = clients.get(p.get('client_id') || '');
const client = clients.get(p.get('client_id') || '');
if (!client || client.secret !== p.get('client_secret')) return json(401, { error: 'invalid_client', error_description: 'Invalid client credentials' });
if (!client || client.secret !== p.get('client_secret')) return json(401, { error: 'invalid_client', error_description: 'Invalid client credentials' });
const grant = p.get('grant_type');
const grant = p.get('grant_type');
@@ -195,14 +206,39 @@ function createMockPlatform(opts = {}) {
@@ -195,14 +206,39 @@ function createMockPlatform(opts = {}) {
}
}
}
}
- function authorize({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope = 'profile theme' } = {}) {
+ /**
+ * What the Network does after the account chooser: returns an authorization code. For a
+ * developer app it checks what Network checks (registered redirect URI, S256 challenge,
+ * sandbox membership) and binds the code to `audience` when one is given.
+ */
+ function authorize({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod = 'S256', scope, audience } = {}) {
+ if (developer.isApp(clientId)) return developer.issueCode({ clientId, redirectUri, subjectId, codeChallenge, codeChallengeMethod, scope, audience });
if (codeChallenge && codeChallengeMethod !== 'S256') throw new Error('mock authorize: only S256');
if (codeChallenge && codeChallengeMethod !== 'S256') throw new Error('mock authorize: only S256');
const user = users.get(subjectId) || [...users.values()][0] || addUser();
const user = users.get(subjectId) || [...users.values()][0] || addUser();
const code = crypto.randomBytes(16).toString('hex');
const code = crypto.randomBytes(16).toString('hex');
- codes.set(code, { clientId, redirectUri, subjectId: user.subject_id, codeChallenge, scope, used: false });
+ codes.set(code, { clientId, redirectUri, subjectId: user.subject_id, codeChallenge, scope: scope === undefined ? 'profile theme' : scope, used: false });
return code;
return code;
}
}
+ /** GET /oauth/authorize: consents automatically as setAuthorization()'s person (default: the first user). */
+ function authorizeRoute(url) {
+ const q = url.searchParams;
+ if (developer.isApp(q.get('client_id'))) return developer.authorizeRoute(url);
+ const client = clients.get(q.get('client_id') || '');
+ const redirectUri = q.get('redirect_uri') || '';
+ if (!client) return json(400, { error: 'invalid_request', error_description: 'Unknown client_id' });
+ if (!redirectUri || (client.redirectUris.length && !client.redirectUris.includes(redirectUri))) return json(400, { error: 'invalid_request', error_description: 'Invalid redirect_uri' });
+ const back = (params) => redirect(redirectUri, { ...params, state: q.get('state') });
+ if (q.get('response_type') !== 'code') return back({ error: 'unsupported_response_type' });
+ if (q.get('prompt') === 'none' && !authorization.subjectId && !users.size) return back({ error: 'login_required' });
+ if (authorization.decision === 'deny') return back({ error: 'access_denied', error_description: 'the person declined' });
+ const code = authorize({
+ clientId: client.id, redirectUri, subjectId: authorization.subjectId, codeChallenge: q.get('code_challenge') || undefined,
+ codeChallengeMethod: q.get('code_challenge_method') || 'S256', scope: q.get('scope') || undefined,
+ });
+ return back({ code });
+ }
+
function projection(u) {
function projection(u) {
return u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: null, banned: false } : null;
return u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.id, username: u.username, display_name: u.display_name || u.username, avatar_url: null, banned: false } : null;
}
}
@@ -231,6 +267,8 @@ function createMockPlatform(opts = {}) {
@@ -231,6 +267,8 @@ function createMockPlatform(opts = {}) {
let r;
let r;
if (req.method === 'GET' && path === '/.well-known/openvibe') return json(200, descriptor());
if (req.method === 'GET' && path === '/.well-known/openvibe') return json(200, descriptor());
if (req.method === 'POST' && path === '/oauth/token') return tokenEndpoint(req);
if (req.method === 'POST' && path === '/oauth/token') return tokenEndpoint(req);
+ if (req.method === 'GET' && path === '/oauth/authorize') return authorizeRoute(url);
+ if (path === '/api/v1/projects' || path.startsWith('/api/v1/projects/')) return developer.projectsApi(req, url);
if (req.method === 'GET' && path === '/api/.well-known/jwks') return json(200, jwks);
if (req.method === 'GET' && path === '/api/.well-known/jwks') return json(200, jwks);
if (req.method === 'GET' && path.startsWith('/api/v1/registry')) {
if (req.method === 'GET' && path.startsWith('/api/v1/registry')) {
const sub = path.slice('/api/v1/registry'.length);
const sub = path.slice('/api/v1/registry'.length);
@@ -309,7 +347,7 @@ function createMockPlatform(opts = {}) {
@@ -309,7 +347,7 @@ function createMockPlatform(opts = {}) {
function publishEvent(env, publisher) {
function publishEvent(env, publisher) {
const dup = events.find((e) => e.event.event_id === env.event_id);
const dup = events.find((e) => e.event.event_id === env.event_id);
if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };
if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };
- const stored = { seq: events.length + 1, event: { priority: 'important', visibility: 'internal', ...env }, publisher };
+ const stored = { seq: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher };
events.push(stored);
events.push(stored);
for (const s of streams) s.push(stored);
for (const s of streams) s.push(stored);
return { event_id: env.event_id, seq: stored.seq, duplicate: false };
return { event_id: env.event_id, seq: stored.seq, duplicate: false };
@@ -339,15 +377,21 @@ function createMockPlatform(opts = {}) {
@@ -339,15 +377,21 @@ function createMockPlatform(opts = {}) {
const after = Number(url.searchParams.get('after_seq') || 0);
const after = Number(url.searchParams.get('after_seq') || 0);
const limit = Number(url.searchParams.get('limit') || 100);
const limit = Number(url.searchParams.get('limit') || 100);
const out = [];
const out = [];
- let cursor = after;
+ const page = {};
+ let from = after;
+ if (after < oldestSeq() - 1) { // retention already pruned part of the range
+ page.gap = { from_seq: after + 1, to_seq: oldestSeq() - 1 };
+ from = oldestSeq() - 1;
+ }
+ let cursor = from;
for (const e of events) {
for (const e of events) {
- if (e.seq <= after) continue;
+ if (e.seq <= from) continue;
if (out.length >= limit) break;
if (out.length >= limit) break;
cursor = e.seq;
cursor = e.seq;
if (pats.some((re) => re.test(e.event.event_type))) out.push({ seq: e.seq, event: e.event });
if (pats.some((re) => re.test(e.event.event_type))) out.push({ seq: e.seq, event: e.event });
}
}
- if (out.length < limit) cursor = Math.max(cursor, events.length);
- return json(200, { events: out, next_after_seq: cursor, latest_seq: events.length });
+ if (out.length < limit) cursor = Math.max(cursor, lastSeq);
+ return json(200, { ...page, events: out, next_after_seq: cursor, latest_seq: lastSeq });
}
}
if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {
if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {
const who = principal(req, 'openvibe.events', 'events.event.read');
const who = principal(req, 'openvibe.events', 'events.event.read');
@@ -375,7 +419,11 @@ function createMockPlatform(opts = {}) {
@@ -375,7 +419,11 @@ function createMockPlatform(opts = {}) {
if (!b.topic_pattern || !b.endpoint) return problem(422, 'events.bad_request', 'topic_pattern and endpoint are required');
if (!b.topic_pattern || !b.endpoint) return problem(422, 'events.bad_request', 'topic_pattern and endpoint are required');
const dup = [...subscriptions.values()].find((s) => s.consumer === who.service && s.topic_pattern === b.topic_pattern && s.endpoint === b.endpoint);
const dup = [...subscriptions.values()].find((s) => s.consumer === who.service && s.topic_pattern === b.topic_pattern && s.endpoint === b.endpoint);
if (dup) return problem(409, 'events.subscription_exists', 'already subscribed');
if (dup) return problem(409, 'events.subscription_exists', 'already subscribed');
- const s = { id: `sub_${ulid()}`, consumer: who.service, topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true, retry_policy: b.retry_policy || null, secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}` };
+ const s = {
+ id: `sub_${ulid()}`, consumer: who.service, topic_pattern: b.topic_pattern, endpoint: b.endpoint, enabled: true, retry_policy: b.retry_policy || null,
+ secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`,
+ cursor: lastSeq, attempts: new Map(), // deliverEvents(): events published after the subscription
+ };
subscriptions.set(s.id, s);
subscriptions.set(s.id, s);
return json(201, view(s, true));
return json(201, view(s, true));
}
}
@@ -396,6 +444,7 @@ function createMockPlatform(opts = {}) {
@@ -396,6 +444,7 @@ function createMockPlatform(opts = {}) {
const auth = req.headers.get('authorization') || '';
const auth = req.headers.get('authorization') || '';
const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');
if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');
+ if (claims && claims.env === 'sandbox' && !(acceptSandbox === true || acceptSandbox.has('openvibe.events'))) return problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here');
const viewer = claims ? (claims.actor_type ? { kind: 'service' } : { kind: 'user', subject: claims.subject_id }) : { kind: 'anonymous' };
const viewer = claims ? (claims.actor_type ? { kind: 'service' } : { kind: 'user', subject: claims.subject_id }) : { kind: 'anonymous' };
const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;
const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;
@@ -405,7 +454,7 @@ function createMockPlatform(opts = {}) {
@@ -405,7 +454,7 @@ function createMockPlatform(opts = {}) {
start(controller) {
start(controller) {
const send = (s) => { try { controller.enqueue(enc.encode(s)); } catch { streams.delete(conn); } };
const send = (s) => { try { controller.enqueue(enc.encode(s)); } catch { streams.delete(conn); } };
conn = {
conn = {
- lastSeq: last ?? events.length,
+ lastSeq: last ?? lastSeq,
push(e) {
push(e) {
if (e.seq <= conn.lastSeq || !pats.some((re) => re.test(e.event.event_type)) || !visible(viewer, e)) return;
if (e.seq <= conn.lastSeq || !pats.some((re) => re.test(e.event.event_type)) || !visible(viewer, e)) return;
conn.lastSeq = e.seq;
conn.lastSeq = e.seq;
@@ -415,9 +464,14 @@ function createMockPlatform(opts = {}) {
@@ -415,9 +464,14 @@ function createMockPlatform(opts = {}) {
};
};
send(`retry: ${opts.realtimeRetryMs ?? 50}\n: connected ${viewer.kind}\n\n`);
send(`retry: ${opts.realtimeRetryMs ?? 50}\n: connected ${viewer.kind}\n\n`);
if (last != null) {
if (last != null) {
- if (last > events.length) send(`event: gap\ndata: ${JSON.stringify({ reason: 'cursor_ahead', from_seq: events.length + 1, to_seq: last, latest_seq: events.length })}\n\n`);
+ if (last > lastSeq) {
+ send(`event: gap\ndata: ${JSON.stringify({ reason: 'cursor_ahead', from_seq: lastSeq + 1, to_seq: last, latest_seq: lastSeq })}\n\n`);
+ conn.lastSeq = lastSeq;
+ } else if (last < oldestSeq() - 1) {
+ send(`event: gap\ndata: ${JSON.stringify({ reason: 'retention', from_seq: last + 1, to_seq: oldestSeq() - 1, latest_seq: lastSeq })}\n\n`);
+ }
for (const e of events) conn.push(e);
for (const e of events) conn.push(e);
- conn.lastSeq = Math.max(conn.lastSeq, events.length);
+ conn.lastSeq = Math.max(conn.lastSeq, lastSeq);
}
}
streams.add(conn);
streams.add(conn);
if (req.signal) req.signal.addEventListener('abort', () => conn.close(), { once: true });
if (req.signal) req.signal.addEventListener('abort', () => conn.close(), { once: true });
@@ -427,6 +481,83 @@ function createMockPlatform(opts = {}) {
@@ -427,6 +481,83 @@ function createMockPlatform(opts = {}) {
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' } });
return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-cache' } });
}
}
+ // ── Delivery worker ─────────────────────────────────────
+ /**
+ * deliverEvents({ fetch, subscriptionId, timeoutMs }) plays the Events delivery worker once:
+ * for each enabled subscription, POSTs every event published after it was created (in seq
+ * order, one at a time) to its endpoint as { event, seq }, signed like Events signs deliveries
+ * (X-OpenVibe-Signature: sha256=<HMAC of the raw body with the subscription secret>, plus
+ * X-OpenVibe-Event-Id, -Event-Type, -Seq, -Subscription-Id, -Delivery-Attempt, -Hops,
+ * traceparent). A 2xx moves on; anything else stops that subscription until the next call,
+ * and after retry_policy.max_attempts (default 5) the delivery is dead and skipped.
+ * `fetch` defaults to the global fetch, so the endpoint can be a real local HTTP server.
+ * -> { delivered, failed, dead, attempts: [{ subscription_id, event_id, seq, attempt, status, outcome, error? }] }
+ */
+ async function deliverEvents({ fetch: send = opts.deliveryFetch || globalThis.fetch, subscriptionId, timeoutMs = 5000 } = {}) {
+ const out = { delivered: 0, failed: 0, dead: 0, attempts: [] };
+ for (const sub of subscriptions.values()) {
+ if (!sub.enabled || (subscriptionId && sub.id !== subscriptionId)) continue;
+ const re = topicRegex(sub.topic_pattern);
+ const max = (sub.retry_policy && Number.isInteger(sub.retry_policy.max_attempts) && sub.retry_policy.max_attempts) || 5;
+ for (const e of events) {
+ if (e.seq <= sub.cursor) continue;
+ if (!re.test(e.event.event_type)) { sub.cursor = e.seq; continue; }
+ const attempt = (sub.attempts.get(e.event.event_id) || 0) + 1;
+ sub.attempts.set(e.event.event_id, attempt);
+ const body = JSON.stringify({ event: e.event, seq: e.seq });
+ const trace = /^[0-9a-f]{32}$/.test(e.event.trace_id || '') ? e.event.trace_id : crypto.randomBytes(16).toString('hex');
+ let status = null;
+ let error;
+ try {
+ const res = await send(sub.endpoint, {
+ method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs), body,
+ headers: {
+ 'Content-Type': 'application/json', 'User-Agent': 'OpenVibe.Events/mock',
+ 'X-OpenVibe-Event-Id': e.event.event_id, 'X-OpenVibe-Event-Type': e.event.event_type, 'X-OpenVibe-Seq': String(e.seq),
+ 'X-OpenVibe-Subscription-Id': sub.id, 'X-OpenVibe-Delivery-Attempt': String(attempt), 'X-OpenVibe-Hops': '0',
+ 'X-OpenVibe-Signature': `sha256=${crypto.createHmac('sha256', String(sub.secret)).update(body).digest('hex')}`,
+ traceparent: `00-${trace}-${crypto.randomBytes(8).toString('hex')}-01`,
+ },
+ });
+ status = res.status;
+ try { await (res.body && res.body.cancel()); } catch { /* not needed */ }
+ } catch (err) {
+ error = err && err.name === 'TimeoutError' ? 'timeout' : String((err && err.message) || err);
+ }
+ const ok = status >= 200 && status < 300;
+ const outcome = ok ? 'delivered' : attempt >= max ? 'dead' : 'retry';
+ const rec = { subscription_id: sub.id, event_id: e.event.event_id, seq: e.seq, attempt, status, outcome, ...(error ? { error } : {}) };
+ out.attempts.push(rec);
+ stats.deliveries.push(rec);
+ if (ok) { out.delivered++; sub.cursor = e.seq; continue; }
+ if (outcome === 'dead') { out.dead++; sub.cursor = e.seq; continue; }
+ out.failed++;
+ break; // in order: retry this one next time
+ }
+ }
+ return out;
+ }
+
+ /** deliverEvents() every intervalMs until stop() (resolves after the round in flight). */
+ function startDeliveries({ intervalMs = 20, ...deliverOpts } = {}) {
+ let stopped = false;
+ let timer = null;
+ let current = Promise.resolve();
+ const tick = () => {
+ if (stopped) return;
+ current = deliverEvents(deliverOpts).catch(() => {}).then(() => { if (!stopped) timer = setTimeout(tick, intervalMs); });
+ };
+ tick();
+ return { async stop() { stopped = true; clearTimeout(timer); await current; } };
+ }
+
+ /** Retention: drop every stored event with seq <= throughSeq (pulls and resumes now see a gap). */
+ function pruneEvents(throughSeq) {
+ let n = 0;
+ while (events.length && events[0].seq <= throughSeq) { events.shift(); n++; }
+ return n;
+ }
+
// ── Media files ─────────────────────────────────────────
// ── Media files ─────────────────────────────────────────
async function media(req, url) {
async function media(req, url) {
const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);
const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);
@@ -480,6 +611,7 @@ function createMockPlatform(opts = {}) {
@@ -480,6 +611,7 @@ function createMockPlatform(opts = {}) {
if (origin === new URL(origins.network).origin) return network(req, url);
if (origin === new URL(origins.network).origin) return network(req, url);
if (origin === new URL(origins.events).origin) return eventsService(req, url);
if (origin === new URL(origins.events).origin) return eventsService(req, url);
if (origin === new URL(origins.media).origin) return media(req, url);
if (origin === new URL(origins.media).origin) return media(req, url);
+ if (origin === new URL(origins.tools).origin) return jobsService.handle(req, url);
throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
}
}
@@ -493,8 +625,22 @@ function createMockPlatform(opts = {}) {
@@ -493,8 +625,22 @@ function createMockPlatform(opts = {}) {
addClient,
addClient,
addUser,
addUser,
authorize,
authorize,
+ /** Who /oauth/authorize signs in as ({ subjectId }) and whether they continue or decline ({ decision: 'deny' }). */
+ setAuthorization({ subjectId, decision } = {}) {
+ if (subjectId !== undefined) authorization.subjectId = subjectId;
+ if (decision !== undefined) authorization.decision = decision === 'deny' ? 'deny' : 'allow';
+ },
+ /** A developer app (see createDeveloper): -> { id, clientId, projectId, env, type, secret? } */
+ addApp: (spec) => developer.addApp(spec),
+ addProject: (spec) => developer.addProject(spec).id,
+ signAppToken,
stats,
stats,
- state: { events, subscriptions, modules, files, users, checkpoints },
+ state: { events, subscriptions, modules, files, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },
+ deliverEvents,
+ startDeliveries,
+ pruneEvents,
+ /** End every open Tools job event stream (clients reconnect with Last-Event-ID). */
+ dropJobStreams: () => jobsService.dropStreams(),
/** Store an event as if a producer had published it (for realtime/pull tests). */
/** Store an event as if a producer had published it (for realtime/pull tests). */
publishEvent: (env, publisher = 'svc:mock') => publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher),
publishEvent: (env, publisher = 'svc:mock') => publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher),
/** End every open realtime stream (clients reconnect with Last-Event-ID). */
/** End every open realtime stream (clients reconnect with Last-Event-ID). */
@@ -502,4 +648,4 @@ function createMockPlatform(opts = {}) {
@@ -502,4 +648,4 @@ function createMockPlatform(opts = {}) {
};
};
}
}
-module.exports = { createMockPlatform, DEFAULT_ORIGINS };
+module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG };
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+'use strict';
+/**
+ * Mock OpenVibe.Tools jobs (/api/v1/jobs), enabled with createMockPlatform({ jobs: true | {…} }).
+ * Node only. Plays the documented lifecycle queued -> running -> (progress…) -> succeeded | failed
+ * | cancelled, with owner scoping, Idempotency-Key replay, SSE events with ids and Last-Event-ID
+ * replay, 204 once finished, and result files.
+ *
+ * jobs: {
+ * stepMs: 5, // delay between lifecycle steps
+ * handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },
+ * }
+ * Without a handler for a type, the job echoes: data { echo: input }, files = the uploaded files.
+ * A handler that throws fails the job with { code: err.code || 'tools.job.failed', detail }.
+ */
+const { ulid, json, problem, sha256hex } = require('./util');
+
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
+const CAPS = { create: 'tools.job.create', read: 'tools.job.read', cancel: 'tools.job.cancel' };
+
+function createJobsService(ctx) {
+ const cfg = ctx.opts.jobs && typeof ctx.opts.jobs === 'object' ? ctx.opts.jobs : {};
+ const stepMs = cfg.stepMs ?? 5;
+ const handlers = cfg.handlers || {};
+ const jobs = new Map();
+ const idem = new Map();
+ const streams = new Set();
+ const iso = (t) => (t ? new Date(t).toISOString() : null);
+
+ function view(job) {
+ const live = job.state === 'queued' || job.state === 'running';
+ return {
+ id: job.id, object: 'tools.job', service: 'mock', type: job.type, type_version: 1, state: job.state,
+ progress: { percent: job.percent, message: job.message }, attempts: job.state === 'queued' ? 0 : 1, max_attempts: 1,
+ cancel_requested: job.cancelRequested, created_at: iso(job.createdAt), started_at: iso(job.startedAt), finished_at: iso(job.finishedAt), expires_at: null,
+ 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: job.error, retryable: false,
+ links: { self: `/api/v1/jobs/${job.id}`, events: `/api/v1/jobs/${job.id}/events`, cancel: live ? `/api/v1/jobs/${job.id}` : null },
+ };
+ }
+
+ function emit(job, event) {
+ const e = { seq: job.events.length + 1, event, data: view(job) };
+ job.events.push(e);
+ for (const s of [...streams]) if (s.jobId === job.id) s.push(e);
+ }
+
+ function finish(job, state, fields = {}) {
+ if (TERMINAL.has(job.state)) return;
+ Object.assign(job, { state, finishedAt: Date.now() }, fields);
+ emit(job, `job.${state}`);
+ }
+
+ async function runJob(job) {
+ await new Promise((r) => setTimeout(r, stepMs));
+ if (TERMINAL.has(job.state)) return;
+ Object.assign(job, { state: 'running', startedAt: Date.now() });
+ emit(job, 'job.running');
+ const handler = handlers[job.type] || (async ({ input, files, progress }) => {
+ await progress(50, 'working');
+ return { data: { echo: input }, files: files.map((f) => ({ name: f.name, mime: f.type, bytes: f.bytes })) };
+ });
+ try {
+ const out = await handler({
+ input: job.input,
+ files: job.files,
+ progress: async (percent, message = null) => {
+ await new Promise((r) => setTimeout(r, stepMs));
+ if (TERMINAL.has(job.state)) return;
+ Object.assign(job, { percent, message });
+ emit(job, 'job.progress');
+ },
+ get cancelled() { return job.cancelRequested; },
+ });
+ await new Promise((r) => setTimeout(r, stepMs));
+ if (job.cancelRequested) return finish(job, 'cancelled');
+ const files = ((out && out.files) || []).map((f) => ({ name: f.name || 'result', mime: f.mime || 'application/octet-stream', bytes: Buffer.from(f.bytes || f.data || '') }));
+ return finish(job, 'succeeded', { percent: 100, message: null, result: { data: (out && out.data) || {}, files } });
+ } catch (err) {
+ if (job.cancelRequested) return finish(job, 'cancelled');
+ return finish(job, 'failed', { error: { code: (err && err.code) || 'tools.job.failed', detail: (err && err.message) || 'failed' } });
+ }
+ }
+
+ /** Who owns what this request does: a principal (svc:/app:) or a Network user. */
+ function owner(req, action) {
+ const auth = req.headers.get('authorization') || '';
+ if (!auth.startsWith('Bearer ')) return { res: problem(401, 'token.missing', 'no token') };
+ const claims = ctx.decode(auth.slice(7));
+ if (claims && claims.actor_type) {
+ const who = ctx.principal(req, 'openvibe.tools', CAPS[action]);
+ return who.res ? who : { owner: who.claims.sub };
+ }
+ return claims ? { owner: `user:${claims.subject_id}` } : { res: problem(401, 'token.bad_signature', 'not a valid token') };
+ }
+
+ function stream(req, job, after) {
+ const enc = new TextEncoder();
+ let conn;
+ const body = new ReadableStream({
+ start(controller) {
+ const close = () => { streams.delete(conn); try { controller.close(); } catch { /* closed */ } };
+ conn = {
+ jobId: job.id,
+ last: after,
+ push(e) {
+ if (e.seq <= conn.last) return;
+ conn.last = e.seq;
+ try { controller.enqueue(enc.encode(`id: ${e.seq}\nevent: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)); } catch { streams.delete(conn); return; }
+ if (TERMINAL.has(e.data.state) && e.event !== 'job.progress') setImmediate(close);
+ },
+ close,
+ };
+ controller.enqueue(enc.encode(`retry: ${stepMs}\n\n`));
+ streams.add(conn);
+ for (const e of job.events) conn.push(e);
+ if (req.signal) req.signal.addEventListener('abort', close, { once: true });
+ },
+ cancel() { streams.delete(conn); },
+ });
+ return new Response(body, { status: 200, headers: { 'Content-Type': 'text/event-stream; charset=utf-8', 'Cache-Control': 'no-store' } });
+ }
+
+ async function handle(req, url) {
+ if (!ctx.opts.jobs) return problem(404, 'not_found', 'jobs are not enabled on this mock (createMockPlatform({ jobs: true }))');
+ const path = url.pathname;
+ let m;
+ if (path === '/api/v1/jobs' && req.method === 'POST') {
+ const who = owner(req, 'create');
+ if (who.res) return who.res;
+ let type, input, files = [];
+ const ct = req.headers.get('content-type') || '';
+ if (ct.startsWith('multipart/form-data')) {
+ const form = await req.formData();
+ type = String(form.get('type') || '');
+ try { input = JSON.parse(String(form.get('input') || '{}')); } catch { return problem(400, 'tools.job.invalid', 'input must be JSON'); }
+ for (const f of [...form.getAll('file'), ...form.getAll('files')]) {
+ if (typeof f !== 'string') files.push({ name: f.name, type: f.type || 'application/octet-stream', bytes: Buffer.from(await f.arrayBuffer()) });
+ }
+ } else {
+ const b = await req.json().catch(() => null);
+ if (!b) return problem(400, 'tools.job.invalid', 'send JSON { type, input } or multipart');
+ type = String(b.type || '');
+ input = b.input == null ? {} : b.input;
+ }
+ if (!type) return problem(400, 'tools.job.invalid', 'type is required');
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return problem(400, 'tools.job.invalid', 'input must be a JSON object');
+ if (Object.keys(handlers).length && !handlers[type]) return problem(400, 'tools.job.unknown_type', `no job type ${type}`);
+ const key = req.headers.get('idempotency-key');
+ const hash = sha256hex(JSON.stringify([type, input, files.map((f) => sha256hex(f.bytes))]));
+ if (key) {
+ const prior = idem.get(`${who.owner}|${key}`);
+ if (prior) {
+ if (prior.hash !== hash) return problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
+ return json(200, view(jobs.get(prior.id)), { Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true', 'Cache-Control': 'no-store' });
+ }
+ }
+ const job = {
+ id: `job_${ulid()}`, owner: who.owner, type, input, files, state: 'queued', percent: null, message: null,
+ cancelRequested: false, createdAt: Date.now(), startedAt: null, finishedAt: null, result: null, error: null, events: [],
+ };
+ jobs.set(job.id, job);
+ if (key) idem.set(`${who.owner}|${key}`, { id: job.id, hash });
+ emit(job, 'job.queued');
+ runJob(job);
+ return json(202, view(job), { Location: `/api/v1/jobs/${job.id}`, 'Cache-Control': 'no-store' });
+ }
+ if ((m = path.match(/^\/api\/v1\/jobs\/([^/]+)(\/events|\/files\/(\d+))?$/))) {
+ const action = req.method === 'DELETE' ? 'cancel' : 'read';
+ if (!['GET', 'DELETE'].includes(req.method) || (req.method === 'DELETE' && m[2])) return problem(405, 'method_not_allowed', 'method not allowed');
+ const who = owner(req, action);
+ if (who.res) return who.res;
+ const job = jobs.get(decodeURIComponent(m[1]));
+ if (!job || job.owner !== who.owner) return problem(404, 'tools.job.not_found', 'No such job');
+ if (req.method === 'DELETE') {
+ if (job.state === 'succeeded' || job.state === 'failed') return problem(409, 'tools.job.already_finished', `The job already ${job.state}`);
+ if (job.state === 'queued') finish(job, 'cancelled');
+ else if (job.state === 'running' && !job.cancelRequested) { job.cancelRequested = true; emit(job, 'job.cancel_requested'); }
+ return json(job.state === 'cancelled' ? 200 : 202, view(job), { 'Cache-Control': 'no-store' });
+ }
+ if (!m[2]) return json(200, view(job), { 'Cache-Control': 'no-store' });
+ if (m[3] !== undefined) {
+ if (job.state !== 'succeeded') return problem(409, 'tools.job.not_ready', `The job is ${job.state}`);
+ const f = job.result.files[Number(m[3])];
+ if (!f) return problem(404, 'tools.job.file_not_found', 'No such result file');
+ const kind = ['1', 'true'].includes(url.searchParams.get('inline') || '') ? 'inline' : 'attachment';
+ return new Response(f.bytes, { status: 200, headers: { 'Content-Type': f.mime, 'Content-Disposition': `${kind}; filename="${f.name.replace(/["\\]/g, '_')}"`, 'Cache-Control': 'private, no-store' } });
+ }
+ const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
+ const after = Math.max(0, parseInt(raw, 10) || 0);
+ if (TERMINAL.has(job.state) && !job.events.some((e) => e.seq > after)) return new Response(null, { status: 204, headers: { 'Cache-Control': 'no-store' } });
+ return stream(req, job, after);
+ }
+ return problem(404, 'not_found', 'Not found');
+ }
+
+ return {
+ handle,
+ jobs,
+ /** End every open job event stream (clients reconnect with Last-Event-ID). */
+ dropStreams() { for (const s of [...streams]) s.close(); },
+ };
+}
+
+module.exports = { createJobsService, JOB_CAPS: CAPS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/** Shared helpers of the mock platform (Node only). */
+const crypto = require('node:crypto');
+
+const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
+const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_');
+const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64');
+
+function ulid() {
+ let t = Date.now();
+ let time = '';
+ for (let i = 0; i < 10; i++, t = Math.floor(t / 32)) time = ULID_ALPHABET[t % 32] + time;
+ let rand = '';
+ for (const b of crypto.randomBytes(16)) rand += ULID_ALPHABET[b & 31];
+ return time + rand;
+}
+
+function topicRegex(pattern) {
+ const seg = '[a-z0-9_]+';
+ const parts = String(pattern).split('.').map((p) => (p === '*' ? `${seg}(?:\\.${seg})*` : p.replace(/[^a-z0-9_]/g, '')));
+ return new RegExp(`^${parts.join('\\.')}$`);
+}
+
+function json(status, body, headers = {}) {
+ return new Response(body === null ? null : JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json', ...headers } });
+}
+
+function problem(status, code, detail, extra = {}) {
+ return new Response(JSON.stringify({ type: `https://openvibe.network/problems/${code}`, title: String(status), status, code, detail, error: detail || code, ...extra }), {
+ status, headers: { 'Content-Type': 'application/problem+json' },
+ });
+}
+
+/** A 302 to `target` with query parameters added. */
+function redirect(target, params) {
+ const u = new URL(target);
+ for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== '') u.searchParams.set(k, String(v));
+ return new Response(null, { status: 302, headers: { Location: u.toString(), 'Cache-Control': 'no-store' } });
+}
+
+const sha256hex = (v) => crypto.createHash('sha256').update(v).digest('hex');
+const s256 = (verifier) => b64url(crypto.createHash('sha256').update(String(verifier)).digest());
+
+module.exports = { ULID_RE, b64url, fromB64url, ulid, topicRegex, json, problem, redirect, sha256hex, s256 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,194 @@
@@ -0,0 +1,194 @@
+'use strict';
+/**
+ * Developer apps (Network ADR-014) end to end against the mock platform: authorize URL with an
+ * audience, /oauth/authorize, exchangeCode for public and confidential apps (no refresh token),
+ * verifyAppToken (project_id, env, on_behalf_of, sandbox refusal), client credentials with
+ * getTokenInfo(), and a project-scoped Media upload.
+ */
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const { createClient } = require('../src/core');
+const auth = require('../src/auth');
+const { createMediaClient } = require('../src/media');
+const { createMockPlatform } = require('../src/testing');
+
+const PUBLIC_APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';
+const SERVER_APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TS';
+const PRJ = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';
+const REDIRECT = 'http://localhost:3001/callback';
+
+function setup(extra = {}) {
+ const platform = createMockPlatform({
+ users: [{ username: 'ana' }, { username: 'bo' }],
+ apps: {
+ [PUBLIC_APP]: { project: PRJ, type: 'public', env: 'sandbox', redirectUris: [REDIRECT], grants: ['media.object.read', 'media.object.upload'] },
+ [SERVER_APP]: { project: PRJ, type: 'confidential', env: 'production', secret: 'ovsec_server', redirectUris: [REDIRECT], grants: ['media.object.upload', 'tools.job.create'] },
+ },
+ ...extra,
+ });
+ const [ana, bo] = [...platform.state.users.values()];
+ return { platform, ana, bo };
+}
+
+/** Browser half + Network: returns { code, codeVerifier } after /oauth/authorize redirects back. */
+async function signIn(platform, opts) {
+ const { url, state, codeVerifier } = await auth.startAuthorization({ clientId: PUBLIC_APP, redirectUri: REDIRECT, ...opts });
+ const res = await platform.fetch(url);
+ assert.equal(res.status, 302);
+ const back = res.headers.get('location');
+ return { back, state, codeVerifier, ...(new URL(back).searchParams.get('code') ? auth.readCallback(back, { expectedState: state }) : {}) };
+}
+
+run([
+ ['buildAuthorizeUrl: audience + capability scope for apps, profile theme only for first-party', async () => {
+ const app = new URL(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media', scope: ['media.object.read', 'media.object.upload'], codeChallenge: 'x'.repeat(43) }));
+ assert.equal(app.searchParams.get('audience'), 'openvibe.media');
+ assert.equal(app.searchParams.get('scope'), 'media.object.read media.object.upload');
+ const noScope = new URL(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media' }));
+ assert.equal(noScope.searchParams.get('scope'), null, 'no first-party default when an audience is given');
+ const firstParty = new URL(auth.buildAuthorizeUrl({ clientId: 'live', redirectUri: REDIRECT }));
+ assert.equal(firstParty.searchParams.get('scope'), 'profile theme');
+ assert.equal(firstParty.searchParams.get('audience'), null);
+ }],
+
+ ['public app: /oauth/authorize -> code -> exchange without a secret -> app token for the person', async () => {
+ const { platform, ana } = setup({ acceptSandbox: ['openvibe.media'] });
+ platform.setAuthorization({ subjectId: ana.subject_id });
+ const { code, codeVerifier } = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });
+ const t = await auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code, codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' });
+ assert.equal(t.refresh_token, undefined, 'apps get no refresh token');
+ assert.equal(t.expires_in, 300);
+ assert.equal(t.scope, 'media.object.read', 'the authorized scope limits the token');
+ const claims = await auth.verifyAppToken(t.access_token, { jwks: platform.keys.jwks, issuer: platform.issuer, audience: 'openvibe.media', acceptSandbox: true });
+ assert.equal(claims.sub, `app:${PUBLIC_APP}`);
+ assert.equal(claims.actor_type, 'app');
+ assert.equal(claims.project_id, PRJ);
+ assert.deepEqual(claims.ns, [PRJ]);
+ assert.equal(claims.env, 'sandbox');
+ assert.equal(claims.on_behalf_of, ana.subject_id);
+ assert.deepEqual(claims.cap, ['media.object.read']);
+ const tokenCall = platform.stats.requests.filter((r) => r.url.endsWith('/oauth/token')).at(-1);
+ assert.ok(tokenCall, 'the exchange hit the token endpoint');
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code, codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_grant' }, 'codes work once');
+ }],
+
+ ['exchangeCode refuses to run without what apps need; Network-side refusals come back as codes', async () => {
+ const { platform } = setup();
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: 'c', redirectUri: REDIRECT, audience: 'openvibe.media' }), TypeError, 'public: verifier required');
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: 'c', codeVerifier: 'v'.repeat(43), redirectUri: REDIRECT }), /audience/, 'apps: audience required');
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, clientSecret: 'ovsec_server', code: 'c', redirectUri: REDIRECT, audience: 'openvibe.media' }), /PKCE/, 'apps always use PKCE');
+
+ // The code is bound to the audience it was authorized for.
+ let s = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' }), (e) => e.code === 'invalid_grant' && /openvibe\.media/.test(e.detail));
+ // The exchange may narrow, never widen.
+ s = await signIn(platform, { audience: 'openvibe.media', scope: ['media.object.read'] });
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media', scope: ['media.object.upload'] }), { code: 'invalid_scope' });
+ // A public app that sends a secret, and a wrong verifier (which burns the code).
+ s = await signIn(platform, { audience: 'openvibe.media' });
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, clientSecret: 'nope', code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_client' });
+ s = await signIn(platform, { audience: 'openvibe.media' });
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: auth.createCodeVerifier(), redirectUri: REDIRECT, audience: 'openvibe.media' }), (e) => e.code === 'invalid_grant' && /PKCE/.test(e.detail));
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: PUBLIC_APP, code: s.code, codeVerifier: s.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.media' }), { code: 'invalid_grant' }, 'burnt');
+ }],
+
+ ['/oauth/authorize: prompt=none, declined consent, unknown redirect, missing PKCE, sandbox membership', async () => {
+ const { platform, ana, bo } = setup();
+ const none = await signIn(platform, { audience: 'openvibe.media', prompt: 'none' });
+ assert.throws(() => auth.readCallback(none.back), { code: 'oauth.interaction_required' });
+ platform.setAuthorization({ decision: 'deny' });
+ const denied = await signIn(platform, { audience: 'openvibe.media' });
+ assert.throws(() => auth.readCallback(denied.back), { code: 'oauth.access_denied' });
+ platform.setAuthorization({ decision: 'allow' });
+ const bad = await platform.fetch(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: 'https://evil.example/cb', codeChallenge: 'x'.repeat(43) }));
+ assert.equal(bad.status, 400, 'never redirects to an unregistered URI');
+ const noPkce = await platform.fetch(auth.buildAuthorizeUrl({ clientId: PUBLIC_APP, redirectUri: REDIRECT, audience: 'openvibe.media' }));
+ assert.equal(noPkce.status, 400);
+
+ // A project with members: only they may authorize its sandbox app.
+ const m = createMockPlatform({
+ users: [ana, bo],
+ projects: { [PRJ]: { owner: ana.subject_id } },
+ apps: { [PUBLIC_APP]: { project: PRJ, type: 'public', redirectUris: [REDIRECT], grants: ['media.object.read'] } },
+ });
+ m.setAuthorization({ subjectId: bo.subject_id });
+ const outsider = await signIn(m, { audience: 'openvibe.media' });
+ assert.throws(() => auth.readCallback(outsider.back), { code: 'oauth.access_denied' });
+ m.setAuthorization({ subjectId: ana.subject_id });
+ assert.ok((await signIn(m, { audience: 'openvibe.media' })).code);
+ assert.throws(() => m.authorize({ clientId: PUBLIC_APP, redirectUri: REDIRECT, subjectId: ana.subject_id }), /PKCE/, 'the helper checks PKCE for apps too');
+ }],
+
+ ['confidential app code flow needs its secret too; first-party sign-in keeps its refresh token', async () => {
+ const { platform, bo } = setup();
+ platform.setAuthorization({ subjectId: bo.subject_id });
+ const start = await auth.startAuthorization({ clientId: SERVER_APP, redirectUri: REDIRECT, audience: 'openvibe.tools', scope: 'tools.job.create' });
+ const { code } = auth.readCallback((await platform.fetch(start.url)).headers.get('location'), { expectedState: start.state });
+ await assert.rejects(auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, code, codeVerifier: start.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' }), { code: 'invalid_client' });
+ const start2 = await auth.startAuthorization({ clientId: SERVER_APP, redirectUri: REDIRECT, audience: 'openvibe.tools', scope: 'tools.job.create' });
+ const code2 = auth.readCallback((await platform.fetch(start2.url)).headers.get('location')).code;
+ const t = await auth.exchangeCode({ fetch: platform.fetch, clientId: SERVER_APP, clientSecret: 'ovsec_server', code: code2, codeVerifier: start2.codeVerifier, redirectUri: REDIRECT, audience: 'openvibe.tools' });
+ const claims = await auth.verifyAppToken(t.access_token, { jwks: platform.keys.jwks, audience: 'openvibe.tools' });
+ assert.equal(claims.on_behalf_of, bo.subject_id);
+ assert.equal(claims.env, 'production');
+
+ platform.addClient('live', { secret: 'live-secret', redirectUris: ['https://openvibe.live/cb'] });
+ const fp = await auth.startAuthorization({ clientId: 'live', redirectUri: 'https://openvibe.live/cb' });
+ const back = (await platform.fetch(fp.url)).headers.get('location');
+ const user = await auth.exchangeCode({ fetch: platform.fetch, clientId: 'live', clientSecret: 'live-secret', code: auth.readCallback(back, { expectedState: fp.state }).code, codeVerifier: fp.codeVerifier, redirectUri: 'https://openvibe.live/cb' });
+ assert.ok(user.refresh_token, 'first-party user tokens still refresh');
+ assert.equal((await auth.verifyUserToken(user.access_token, { jwks: platform.keys.jwks })).subject_id, bo.subject_id);
+ }],
+
+ ['verifyAppToken: audience required, user tokens are not app tokens, sandbox refused unless accepted', async () => {
+ const { platform, ana } = setup();
+ const jwks = platform.keys.jwks;
+ const sandbox = platform.signAppToken(PUBLIC_APP, { audience: 'openvibe.media', capabilities: ['media.object.read'] });
+ await assert.rejects(auth.verifyAppToken(sandbox, { jwks }), TypeError);
+ await assert.rejects(auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.media' }), { code: 'token.sandbox_refused', status: 401 });
+ assert.equal((await auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.media', acceptSandbox: true })).env, 'sandbox');
+ await assert.rejects(auth.verifyAppToken(sandbox, { jwks, audience: 'openvibe.tools', acceptSandbox: true }), { code: 'token.wrong_audience' });
+ const prod = platform.signAppToken(SERVER_APP, { audience: 'openvibe.media', capabilities: ['media.object.upload'] });
+ assert.equal((await auth.verifyAppToken(prod, { jwks, audience: 'openvibe.media' })).env, 'production');
+ await assert.rejects(auth.verifyAppToken(platform.signUserToken(ana), { jwks, audience: 'openvibe.media' }), { code: 'token.not_app' });
+ await assert.rejects(auth.verifyAppToken(platform.signServiceToken('live', { audience: 'openvibe.media' }), { jwks, audience: 'openvibe.media' }), { code: 'token.not_app' });
+ await assert.rejects(auth.verifyAppToken(platform.signAppToken(SERVER_APP, { audience: 'openvibe.media', projectId: 'prj_nope' }), { jwks, audience: 'openvibe.media' }), (e) => e.code === 'token.invalid_claims' && /project_id/.test(e.detail));
+ await assert.rejects(auth.verifyUserToken(prod, { jwks }), { code: 'token.not_user' }, 'verifyUserToken stays for people');
+ }],
+
+ ['confidential app: client credentials, getTokenInfo() shows scope and (unverified) claims', async () => {
+ const { platform } = setup();
+ const tokens = auth.createServiceTokenClient({ clientId: SERVER_APP, clientSecret: 'ovsec_server', fetch: platform.fetch });
+ const info = await tokens.getTokenInfo({ audience: 'openvibe.media' });
+ assert.deepEqual(info.scope, ['media.object.upload']);
+ assert.equal(info.audience, 'openvibe.media');
+ assert.equal(info.tokenType, 'Bearer');
+ assert.ok(Date.parse(info.expiresAt) > Date.now());
+ assert.equal(info.unverifiedClaims.project_id, PRJ);
+ assert.equal(info.unverifiedClaims.env, 'production');
+ assert.equal(info.unverifiedClaims.on_behalf_of, undefined);
+ assert.equal(info.accessToken, await tokens.getToken({ audience: 'openvibe.media' }), 'same cached token');
+ assert.deepEqual(auth.unverifiedClaims(info.accessToken), info.unverifiedClaims);
+ assert.equal(auth.decodeUnverified('nope'), null);
+ assert.equal(auth.decodeUnverified(info.accessToken).header.alg, 'RS256');
+ await assert.rejects(tokens.getToken({ audience: 'openvibe.events' }), { code: 'invalid_scope' }, 'no grant for that audience');
+ await assert.rejects(auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 'x', fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'unauthorized_client' }, 'public apps have no client credentials grant');
+ }],
+
+ ['Media: an app uploads into its project namespace; sandbox tokens are refused unless Media opted in', async () => {
+ const { platform } = setup();
+ const client = createClient({ fetch: platform.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: SERVER_APP, clientSecret: 'ovsec_server', fetch: platform.fetch }) });
+ const file = await createMediaClient(client, { app: PRJ }).upload('hello', { filename: 'a.txt' });
+ assert.ok(platform.state.files.has(`${PRJ}|${file.key}`));
+ await assert.rejects(createMediaClient(client, { app: 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ' }).upload('x'), (e) => e.status === 404 || e.code === 'capability.namespace_denied');
+
+ const sandboxApp = platform.addApp({ project: PRJ, env: 'sandbox', grants: ['media.object.upload'] });
+ const sandboxClient = createClient({ fetch: platform.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: sandboxApp.id, clientSecret: sandboxApp.secret, fetch: platform.fetch }) });
+ await assert.rejects(createMediaClient(sandboxClient, { app: PRJ }).upload('x'), { code: 'token.sandbox_refused', status: 401 });
+ const opted = createMockPlatform({ acceptSandbox: ['media.object.upload'], apps: { [PUBLIC_APP]: { project: PRJ, secret: 's', type: 'confidential', grants: ['media.object.upload'] } } });
+ const optedClient = createClient({ fetch: opted.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 's', fetch: opted.fetch }) });
+ assert.ok((await createMediaClient(optedClient, { app: PRJ }).upload('x')).key);
+ const limited = createMockPlatform({ sandboxAudiences: ['openvibe.tools'], apps: { [PUBLIC_APP]: { project: PRJ, secret: 's', type: 'confidential', grants: ['media.object.upload'] } } });
+ await assert.rejects(auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 's', fetch: limited.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'invalid_target' });
+ }],
+]);
@@ -78,7 +78,7 @@ run([
@@ -78,7 +78,7 @@ run([
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
assert.deepEqual(blocked.sort(), ['./events', './identity', './testing']);
assert.deepEqual(blocked.sort(), ['./events', './identity', './testing']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js']) {
+ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
}
}
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/testing/index.js']) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+/**
+ * browser/openvibe-sdk.mjs: up to date with the sources, self-contained (no import or require of
+ * anything), free of server-only code, the same exports as the browser entry, and working.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { pathToFileURL } = require('node:url');
+const { execFileSync } = require('node:child_process');
+const { run } = require('./helpers');
+const { OUT, collect } = require('../scripts/browser-bundle');
+const { createMockPlatform } = require('../src/testing');
+
+const ROOT = path.join(__dirname, '..');
+const load = () => import(pathToFileURL(OUT).href);
+
+run([
+ ['browser/openvibe-sdk.mjs is up to date', async () => {
+ execFileSync(process.execPath, [path.join(ROOT, 'scripts/browser-bundle.js'), '--check'], { stdio: 'pipe' });
+ }],
+
+ ['it is one self-contained module with no server-only code', async () => {
+ const src = fs.readFileSync(OUT, 'utf8');
+ assert.ok(!/\brequire\s*\(/.test(src), 'no require() left');
+ assert.ok(!/^\s*import\s/m.test(src) && !/\bimport\s*\(/.test(src), 'imports nothing');
+ for (const [re, why] of [[/client_secret/, 'client_secret'], [/process\.env/, 'process.env'], [/['"]node:/, 'node: module'], [/function (?:createServiceTokenClient|verifyUserToken|verifyAppToken|exchangeCode|createEventsClient|createIdentityClient|createMockPlatform)\b/, 'server-only API']]) {
+ assert.ok(!re.test(src), `bundle contains ${why}`);
+ }
+ const ids = collect().map((m) => m.id);
+ for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);
+ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);
+ }],
+
+ ['exports match the browser entry', async () => {
+ const esm = await load();
+ const cjs = require('../browser.js');
+ assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());
+ assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());
+ for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'projects']) {
+ assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);
+ }
+ assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');
+ assert.equal(esm.SDK_VERSION, require('../package.json').version);
+ }],
+
+ ['it works: PKCE, an app sign-in URL, and a registry read through its own client', async () => {
+ const sdk = await load();
+ assert.equal(await sdk.auth.pkceChallenge('dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk'), 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM');
+ const { url } = await sdk.auth.startAuthorization({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', redirectUri: 'http://localhost:3001/callback', audience: 'openvibe.media', scope: ['media.object.read'] });
+ const u = new URL(url);
+ assert.equal(u.searchParams.get('audience'), 'openvibe.media');
+ assert.equal(u.searchParams.get('scope'), 'media.object.read');
+ const platform = createMockPlatform();
+ const registry = sdk.registry.createRegistryClient(sdk.createClient({ fetch: platform.fetch }));
+ assert.equal((await registry.domain('openvibe.media')).service.id, 'media');
+ const err = new sdk.OpenVibeError({ code: 'x.y' });
+ assert.ok(sdk.isOpenVibeError(err));
+ }],
+]);
@@ -216,4 +216,43 @@ run([
@@ -216,4 +216,43 @@ run([
for await (const x of paginate(async (c) => ({ items: [c], next: c < 3 ? c + 1 : null }), { cursor: 1 })) cursorPages.push(x);
for await (const x of paginate(async (c) => ({ items: [c], next: c < 3 ? c + 1 : null }), { cursor: 1 })) cursorPages.push(x);
assert.deepEqual(cursorPages, [1, 2, 3]);
assert.deepEqual(cursorPages, [1, 2, 3]);
}],
}],
+
+ ["responseType 'response': the raw Response for streams and downloads; errors still throw", async () => {
+ let busy = 0;
+ const srv = await stubServer((req, res) => {
+ if (req.url === '/busy' && ++busy === 1) return problem(res, 503, 'service.unavailable', 'busy');
+ if (req.url === '/gone') return problem(res, 404, 'thing.not_found', 'no such thing');
+ if (req.url === '/stream') {
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.write('part one;');
+ return setTimeout(() => res.end('part two'), 30);
+ }
+ if (req.url === '/forever') { res.writeHead(200, { 'Content-Type': 'text/plain' }); return res.write('start;'); }
+ return send(res, 200, Buffer.from([1, 2, 3]), { 'Content-Type': 'application/octet-stream' });
+ });
+ const client = createClient({ baseUrls: { x: srv.url }, ...fast });
+ const out = await client.request({ service: 'x', path: '/file', responseType: 'response' });
+ assert.ok(out.data instanceof Response);
+ assert.equal(out.response, out.data);
+ assert.equal(out.status, 200);
+ assert.ok(!out.data.bodyUsed, 'body unread');
+ assert.deepEqual([...new Uint8Array(await out.data.arrayBuffer())], [1, 2, 3]);
+
+ // The per-attempt timeout covers the headers only; the body may take longer.
+ const slow = await client.json({ service: 'x', path: '/stream', responseType: 'response', timeoutMs: 15 });
+ assert.equal(await slow.text(), 'part one;part two');
+
+ const retried = await client.request({ service: 'x', path: '/busy', responseType: 'response' });
+ assert.equal(retried.attempts, 2, 'a 503 is retried before any body is handed out');
+ await assert.rejects(client.request({ service: 'x', path: '/gone', responseType: 'response' }), (e) => e.code === 'thing.not_found' && e.status === 404, 'error bodies are read into OpenVibeError');
+
+ // The caller's signal still cancels the body after the call returned.
+ const ctrl = new AbortController();
+ const endless = await client.json({ service: 'x', path: '/forever', responseType: 'response', signal: ctrl.signal });
+ const reader = endless.body.getReader();
+ assert.equal(new TextDecoder().decode((await reader.read()).value), 'start;');
+ setTimeout(() => ctrl.abort(), 10);
+ await assert.rejects(reader.read());
+ await srv.close();
+ }],
]);
]);
@@ -27,7 +27,7 @@ run([
@@ -27,7 +27,7 @@ run([
['every subpath resolves by package name for require and import', async () => {
['every subpath resolves by package name for require and import', async () => {
const pkg = require('../package.json');
const pkg = require('../package.json');
for (const sub of Object.keys(pkg.exports)) {
for (const sub of Object.keys(pkg.exports)) {
- if (sub === './package.json') continue;
+ if (sub === './package.json' || sub.endsWith('.mjs')) continue; // the browser bundle: test/bundle.test.js
const spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;
const spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;
const cjs = require(spec);
const cjs = require(spec);
const esm = await import(spec);
const esm = await import(spec);
@@ -2,10 +2,11 @@
@@ -2,10 +2,11 @@
/** Events: publish fills the envelope, pull cursor + gaps, subscriptions, webhook signatures. */
/** Events: publish fills the envelope, pull cursor + gaps, subscriptions, webhook signatures. */
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const crypto = require('node:crypto');
-const { stubServer, send, run } = require('./helpers');
+const { stubServer, send, run, waitFor } = require('./helpers');
const { createClient, parseTraceparent } = require('../src/core');
const { createClient, parseTraceparent } = require('../src/core');
const { createServiceTokenClient } = require('../src/auth');
const { createServiceTokenClient } = require('../src/auth');
-const { createEventsClient, verifyDelivery, signDelivery, parseDelivery } = require('../src/events');
+const { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createInbox } = require('../src/events');
+const { subscribe } = require('../src/realtime');
const { createMockPlatform } = require('../src/testing');
const { createMockPlatform } = require('../src/testing');
const actor = { type: 'service', id: 'live' };
const actor = { type: 'service', id: 'live' };
@@ -130,4 +131,99 @@ run([
@@ -130,4 +131,99 @@ run([
assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7, subscriptionId: 'sub_1', attempt: 2 });
assert.deepEqual(d, { event: { event_id: 'evt_1', event_type: 'media.vod.ready' }, seq: 7, subscriptionId: 'sub_1', attempt: 2 });
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
assert.equal(parseDelivery(raw, new Headers({ 'X-OpenVibe-Signature': 'sha256=bad' }), secret), null);
}],
}],
+
+ ['onPage runs only after every item of its page was handled: a crash mid-page keeps the old cursor', async () => {
+ const { platform, events } = setup();
+ for (let i = 1; i <= 6; i++) platform.publishEvent({ event_type: i === 3 ? 'live.other.thing' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
+ const log = [];
+ let saved = 0;
+ const consume = async (failAt) => {
+ for await (const { seq } of events.iterate({ topic: 'media.vod.*', afterSeq: saved, limit: 3, onPage: (p) => { log.push(`page ${p.next_after_seq}`); saved = p.next_after_seq; } })) {
+ if (seq === failAt) throw new Error(`crash at ${seq}`);
+ log.push(`item ${seq}`);
+ }
+ };
+ await assert.rejects(consume(2), /crash at 2/);
+ assert.deepEqual(log, ['item 1']);
+ assert.equal(saved, 0, 'a crash inside the first page saves nothing');
+ log.length = 0;
+ await assert.rejects(consume(5), /crash at 5/);
+ assert.deepEqual(log, ['item 1', 'item 2', 'item 4', 'page 4'], 'page 1 (seq 1-4; 3 did not match) was saved after its last item; page 2 never was');
+ assert.equal(saved, 4);
+ log.length = 0;
+ await consume(null);
+ assert.deepEqual(log, ['item 5', 'item 6', 'page 6'], 'resumed from the saved cursor: nothing skipped');
+ let pages = 0;
+ for await (const e of events.iterate({ topic: 'media.vod.*', limit: 2, onPage: () => { pages++; } })) { void e; break; }
+ assert.equal(pages, 0, 'breaking out before the page is done: no onPage');
+ }],
+
+ ['mock Events retention: pull and realtime report the pruned range as a gap', async () => {
+ const { platform, events } = setup();
+ for (let i = 1; i <= 5; i++) platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) }, visibility: 'public' });
+ assert.equal(platform.pruneEvents(3), 3);
+ const page = await events.pull({ topic: 'media.vod.*', afterSeq: 1 });
+ assert.deepEqual(page.gap, { from_seq: 2, to_seq: 3 });
+ assert.deepEqual(page.events.map((e) => e.seq), [4, 5]);
+ assert.equal((await events.pull({ topic: 'media.vod.*', afterSeq: 3 })).gap, undefined);
+ const gaps = [];
+ const seqs = [];
+ for await (const e of events.iterate({ topic: 'media.vod.*', onGap: (g) => gaps.push(g) })) seqs.push(e.seq);
+ assert.deepEqual(gaps, [{ from_seq: 1, to_seq: 3 }]);
+ assert.deepEqual(seqs, [4, 5]);
+ const rtGaps = [];
+ const got = [];
+ const sub = subscribe('media.vod.*', (ev, { seq }) => got.push(seq), { client: createClient({ fetch: platform.fetch }), fetch: platform.fetch, transport: 'fetch', lastEventId: 1, onGap: (g) => rtGaps.push(g) });
+ await waitFor(() => got.length === 2);
+ sub.close();
+ assert.deepEqual(rtGaps, [{ reason: 'retention', from_seq: 2, to_seq: 3, latest_seq: 5 }]);
+ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '6' } });
+ assert.equal((await events.pull({ afterSeq: 5 })).events[0].seq, 6, 'seqs keep counting after a prune');
+ }],
+
+ ['deliverEvents: signed deliveries to a local endpoint, retried in order, dead after max attempts', async () => {
+ const { platform, events } = setup();
+ const Database = require('better-sqlite3');
+ const inbox = createInbox(new Database(':memory:'));
+ inbox.ensureSchema();
+ let failNext = 1;
+ let secret;
+ const handled = [];
+ const srv = await stubServer((req, res, body) => {
+ const d = parseDelivery(body, req.headers, secret);
+ if (!d) return send(res, 401, { error: 'bad signature' });
+ if (failNext > 0) { failNext--; return send(res, 503, { error: 'busy' }); }
+ const r = inbox.once('webhook', d.event.event_id, () => handled.push([d.seq, d.attempt, req.headers['x-openvibe-event-type']]));
+ return send(res, 200, { duplicate: r.duplicate });
+ });
+ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '0' } }); // before the subscription: not delivered
+ const sub = await events.subscribe({ topicPattern: 'media.vod.*', endpoint: `${srv.url}/hook`, retryPolicy: { max_attempts: 3 } });
+ secret = sub.secret;
+ for (let i = 1; i <= 3; i++) platform.publishEvent({ event_type: i === 2 ? 'live.x.y' : 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: String(i) } });
+
+ const first = await platform.deliverEvents();
+ assert.deepEqual([first.delivered, first.failed, first.dead], [0, 1, 0], 'the first attempt failed: the subscription waits');
+ const second = await platform.deliverEvents();
+ assert.deepEqual([second.delivered, second.failed], [2, 0]);
+ assert.deepEqual(handled, [[2, 2, 'media.vod.ready'], [4, 1, 'media.vod.ready']], 'in seq order, attempt counted, non-matching skipped');
+ const req = srv.requests.at(-1);
+ assert.equal(req.headers['x-openvibe-subscription-id'], sub.id);
+ assert.match(req.headers['x-openvibe-signature'], /^sha256=[0-9a-f]{64}$/);
+ assert.match(req.headers.traceparent, /^00-[0-9a-f]{32}-[0-9a-f]{16}-01$/);
+ assert.equal((await platform.deliverEvents()).attempts.length, 0, 'nothing new');
+
+ failNext = 99;
+ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '9' } });
+ const outcomes = [];
+ for (let i = 0; i < 3; i++) outcomes.push((await platform.deliverEvents()).attempts.map((a) => a.outcome).join());
+ assert.deepEqual(outcomes, ['retry', 'retry', 'dead']);
+ assert.equal(platform.stats.deliveries.filter((a) => a.outcome === 'dead').length, 1);
+
+ failNext = 0;
+ const worker = platform.startDeliveries({ intervalMs: 5 });
+ platform.publishEvent({ event_type: 'media.vod.ready', source: 'media', actor, subject: { type: 'vod', id: '10' } });
+ await waitFor(() => handled.length === 3);
+ await worker.stop();
+ await srv.close();
+ }],
]);
]);
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * openvibe-sdk/jobs against the mock platform's Tools jobs: submit once (Idempotency-Key), follow
+ * events across dropped streams and a restart (Last-Event-ID), 204 when finished, cancel, result
+ * files as raw Responses, owner scoping, failures, and sandbox refusal.
+ */
+const assert = require('node:assert/strict');
+const { run, stubServer, send } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createServiceTokenClient } = require('../src/auth');
+const { createJobsClient, isTerminal } = require('../src/jobs');
+const { createMockPlatform } = require('../src/testing');
+
+const APP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR';
+const OTHER = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TS';
+
+function setup(jobs = {}, extra = {}) {
+ const platform = createMockPlatform({
+ jobs: { stepMs: 5, ...jobs },
+ apps: {
+ [APP]: { env: 'production', secret: 'a', grants: ['tools.job.create', 'tools.job.read', 'tools.job.cancel'] },
+ [OTHER]: { env: 'production', secret: 'b', grants: ['tools.job.create', 'tools.job.read'] },
+ },
+ ...extra,
+ });
+ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retryDelayMs: 5, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });
+ return { platform, jobs: createJobsClient(clientFor(APP, 'a')), other: createJobsClient(clientFor(OTHER, 'b')) };
+}
+
+run([
+ ['submit (multipart) -> follow events -> succeeded -> download the result file', async () => {
+ const { jobs, platform } = setup({
+ handlers: {
+ 'img.process': async ({ input, files, progress }) => {
+ await progress(25, 'decoding');
+ await progress(75, 'encoding');
+ return { data: { tool: input.tool }, files: [{ name: files[0].name.replace(/\.\w+$/, '.webp'), mime: 'image/webp', bytes: Buffer.concat([Buffer.from('webp:'), files[0].bytes]) }] };
+ },
+ },
+ });
+ const { job, replayed, idempotencyKey } = await jobs.submit({ type: 'img.process', input: { tool: 'convert' }, files: [{ name: 'a.png', data: Buffer.from('PNG') }] });
+ assert.match(job.id, /^job_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.equal(job.state, 'queued');
+ assert.equal(replayed, false);
+ assert.match(idempotencyKey, /^idem_/);
+ const call = platform.stats.requests.find((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs'));
+ assert.equal(call.url, 'https://openvibe.tools/api/v1/jobs', 'origin from discovery');
+ assert.equal(call.headers['idempotency-key'], idempotencyKey);
+ assert.match(call.headers['content-type'], /^multipart\/form-data/);
+
+ const seen = [];
+ const done = await jobs.wait(job.id, { onEvent: (e) => seen.push([e.id, e.event, e.job.progress.percent]) });
+ assert.deepEqual(seen.map((s) => s[1]), ['job.queued', 'job.running', 'job.progress', 'job.progress', 'job.succeeded']);
+ assert.deepEqual(seen.map((s) => s[0]), [1, 2, 3, 4, 5]);
+ assert.equal(done.state, 'succeeded');
+ assert.ok(isTerminal(done));
+ assert.deepEqual(done.result.data, { tool: 'convert' });
+ const res = await jobs.file(job.id, 0);
+ assert.ok(res instanceof Response);
+ assert.equal(res.headers.get('content-type'), 'image/webp');
+ assert.equal(Buffer.from(await res.arrayBuffer()).toString(), 'webp:PNG');
+ assert.equal([...(await (async () => { const out = []; for await (const e of jobs.events(job.id, { lastEventId: 5 })) out.push(e); return out; })())].length, 0, '204: finished, nothing newer');
+ }],
+
+ ['the same Idempotency-Key gets the same job; a different request under it is a conflict', async () => {
+ const { jobs, platform } = setup();
+ const a = await jobs.submit({ type: 'doc.render', input: { n: 1 }, idempotencyKey: 'idem_example_0001' });
+ const b = await jobs.submit({ type: 'doc.render', input: { n: 1 }, idempotencyKey: 'idem_example_0001' });
+ assert.equal(b.job.id, a.job.id);
+ assert.equal(b.replayed, true);
+ assert.equal(platform.state.jobs.size, 1);
+ await assert.rejects(jobs.submit({ type: 'doc.render', input: { n: 2 }, idempotencyKey: 'idem_example_0001' }), { status: 409, code: 'tools.job.idempotency_conflict' });
+ const json = platform.stats.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/api/v1/jobs'))[0];
+ assert.match(json.headers['content-type'], /^application\/json/, 'no files: a JSON body');
+ await assert.rejects(jobs.submit({ input: {} }), TypeError);
+ }],
+
+ ['events reattach after dropped streams and after a restart, each event once and in order', async () => {
+ let release;
+ const gate = new Promise((r) => { release = r; });
+ const { jobs, platform } = setup({
+ handlers: {
+ slow: async ({ progress }) => {
+ await progress(10, 'a');
+ await gate;
+ await progress(90, 'b');
+ return { data: { ok: true } };
+ },
+ },
+ });
+ const { job } = await jobs.submit({ type: 'slow' });
+ const first = [];
+ for await (const e of jobs.events(job.id, { reconnectDelayMs: 5 })) {
+ first.push(e.id);
+ if (e.event === 'job.progress') break; // "crash" after saving id 3
+ }
+ assert.deepEqual(first, [1, 2, 3]);
+ const second = [];
+ const following = (async () => {
+ for await (const e of jobs.events(job.id, { lastEventId: first.at(-1), reconnectDelayMs: 5 })) {
+ second.push([e.id, e.event]);
+ if (second.length === 1) platform.dropJobStreams(); // the connection drops mid-job
+ }
+ })();
+ setTimeout(release, 20);
+ await following;
+ assert.deepEqual(second.map((s) => s[0]), [4, 5]);
+ assert.deepEqual(second.map((s) => s[1]), ['job.progress', 'job.succeeded']);
+ const reconnects = platform.stats.requests.filter((r) => r.url.endsWith('/events')).map((r) => r.headers['last-event-id']);
+ assert.ok(reconnects.includes('3') && reconnects.includes('4'), `resumed with Last-Event-ID (${reconnects})`);
+ }],
+
+ ['cancel: queued -> cancelled; finished -> 409; failures carry the error', async () => {
+ const { jobs } = setup({ stepMs: 30, handlers: { boom: async () => { throw Object.assign(new Error('bad input'), { code: 'tools.job.invalid_input' }); }, ok: async () => ({}) } });
+ const { job } = await jobs.submit({ type: 'ok' });
+ const cancelled = await jobs.cancel(job.id);
+ assert.equal(cancelled.state, 'cancelled');
+ const failed = await jobs.submit({ type: 'boom' });
+ const out = await jobs.wait(failed.job.id);
+ assert.equal(out.state, 'failed');
+ assert.deepEqual(out.error, { code: 'tools.job.invalid_input', detail: 'bad input' });
+ await assert.rejects(jobs.cancel(failed.job.id), { status: 409, code: 'tools.job.already_finished' });
+ await assert.rejects(jobs.file(failed.job.id, 0), { status: 409, code: 'tools.job.not_ready' });
+ await assert.rejects(jobs.submit({ type: 'nope' }), { status: 400, code: 'tools.job.unknown_type' });
+ }],
+
+ ['cancel while running: 202 cancel requested, then cancelled', async () => {
+ const { jobs } = setup({ handlers: { long: async ({ progress }) => { for (let i = 1; i <= 5; i++) await progress(i * 20); return {}; } } });
+ const { job } = await jobs.submit({ type: 'long' });
+ const events = [];
+ for await (const e of jobs.events(job.id)) {
+ events.push(e.event);
+ if (e.event === 'job.running') {
+ const c = await jobs.cancel(job.id);
+ assert.equal(c.cancel_requested, true);
+ assert.equal(c.state, 'running');
+ }
+ }
+ assert.ok(events.includes('job.cancel_requested'));
+ assert.equal(events.at(-1), 'job.cancelled');
+ assert.equal((await jobs.get(job.id)).state, 'cancelled');
+ }],
+
+ ['jobs are owner-scoped; capabilities and sandbox tokens are checked', async () => {
+ const { jobs, other } = setup();
+ const { job } = await jobs.submit({ type: 'x' });
+ assert.equal(await other.get(job.id), null, 'another app gets 404, as for a job that does not exist');
+ await assert.rejects(other.cancel(job.id), { status: 403, code: 'capability.denied' });
+ assert.equal((await jobs.get(job.id)).id, job.id);
+
+ const SANDBOX = 'app_01K5WZX7S7Q4D2B8N3M6V1C9TT';
+ const make = (extra) => {
+ const p = createMockPlatform({ jobs: true, apps: { [SANDBOX]: { env: 'sandbox', secret: 's', grants: ['tools.job.create'] } }, ...extra });
+ return createJobsClient(createClient({ fetch: p.fetch, tokenProvider: createServiceTokenClient({ clientId: SANDBOX, clientSecret: 's', fetch: p.fetch }) }));
+ };
+ await assert.rejects(make().submit({ type: 'x' }), { status: 401, code: 'token.sandbox_refused' });
+ assert.equal((await make({ acceptSandbox: ['openvibe.tools'] }).submit({ type: 'x' })).job.state, 'queued');
+ await assert.rejects(createJobsClient(createClient({ fetch: createMockPlatform().fetch, token: 't' })).submit({ type: 'x' }), { status: 404 }, 'jobs are opt-in on the mock');
+ }],
+
+ ['events: a 5xx or a dropped connection reconnects, a 4xx is thrown, the give-up limit holds', async () => {
+ let calls = 0;
+ const srv = await stubServer((req, res) => {
+ calls++;
+ if (calls === 1) return send(res, 503, { code: 'x.down' });
+ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
+ if (calls === 2) { res.write(`id: 1\nevent: job.running\ndata: ${JSON.stringify({ id: 'job_1', state: 'running' })}\n\n`); return res.end(); }
+ res.write(`id: 1\nevent: job.running\ndata: {}\n\nid: 2\nevent: job.succeeded\ndata: ${JSON.stringify({ id: 'job_1', state: 'succeeded' })}\n\n`);
+ return res.end();
+ });
+ const jobs = createJobsClient(createClient({ baseUrls: { tools: srv.url }, token: 't' }));
+ const got = [];
+ for await (const e of jobs.events('job_1', { reconnectDelayMs: 1 })) got.push(e.id);
+ assert.deepEqual(got, [1, 2], 'the replayed id 1 is not yielded twice');
+ assert.equal(srv.requests[2].headers['last-event-id'], '1');
+ await srv.close();
+
+ const gone = await stubServer((req, res) => send(res, 404, { code: 'tools.job.not_found', status: 404 }));
+ await assert.rejects((async () => { for await (const e of createJobsClient(createClient({ baseUrls: { tools: gone.url }, token: 't' })).events('job_x')) void e; })(), { code: 'tools.job.not_found' });
+ await gone.close();
+ const down = await stubServer((req, res) => send(res, 502, 'bad gateway'));
+ await assert.rejects((async () => { for await (const e of createJobsClient(createClient({ baseUrls: { tools: down.url }, token: 't' })).events('job_x', { maxReconnects: 2, reconnectDelayMs: 1 })) void e; })(), { code: 'sdk.network_error' });
+ assert.equal(down.requests.length, 3);
+ await down.close();
+ }],
+]);
@@ -22,7 +22,7 @@ run([
@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
const files = info.files.map((f) => f.path);
- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'esm/core.mjs', 'types/core.d.ts', 'types/contracts.d.ts']) {
+ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/projects.js', 'esm/core.mjs', 'esm/jobs.mjs', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
assert.ok(files.includes(must), `tarball contains ${must}`);
}
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@ -32,7 +32,7 @@ run([
@@ -32,7 +32,7 @@ run([
fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'sdk-consumer', version: '1.0.0', private: true }));
fs.writeFileSync(path.join(app, 'package.json'), JSON.stringify({ name: 'sdk-consumer', version: '1.0.0', private: true }));
execFileSync(npm, ['install', '--offline', '--no-package-lock', path.join(tmp, info.filename)], { cwd: app, env, stdio: 'pipe' });
execFileSync(npm, ['install', '--offline', '--no-package-lock', path.join(tmp, info.filename)], { cwd: app, env, stdio: 'pipe' });
- const subpaths = Object.keys(pkg.exports).filter((s) => s !== './package.json').map((s) => (s === '.' ? 'openvibe-sdk' : `openvibe-sdk/${s.slice(2)}`));
+ const subpaths = Object.keys(pkg.exports).filter((s) => s !== './package.json' && !s.endsWith('.mjs')).map((s) => (s === '.' ? 'openvibe-sdk' : `openvibe-sdk/${s.slice(2)}`));
const script = `
const script = `
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const specs = ${JSON.stringify(subpaths)};
const specs = ${JSON.stringify(subpaths)};
@@ -52,10 +52,16 @@ run([
@@ -52,10 +52,16 @@ run([
const f = await createMediaClient(client, { app: 'app' }).upload('hi', { filename: 'a.txt' });
const f = await createMediaClient(client, { app: 'app' }).upload('hi', { filename: 'a.txt' });
assert.match(f.key, /-a\\.txt$/);
assert.match(f.key, /-a\\.txt$/);
assert.equal(require('openvibe-sdk/package.json').version, ${JSON.stringify(pkg.version)});
assert.equal(require('openvibe-sdk/package.json').version, ${JSON.stringify(pkg.version)});
- console.log('consumer ok: ' + specs.length + ' subpaths');
+ assert.equal(require('openvibe-sdk/core').SDK_VERSION, ${JSON.stringify(pkg.version)});
+ const bundle = await import('openvibe-sdk/browser/openvibe-sdk.mjs');
+ assert.equal(bundle.SDK_VERSION, ${JSON.stringify(pkg.version)});
+ assert.equal(typeof bundle.jobs.createJobsClient, 'function');
+ assert.equal(typeof require.resolve('openvibe-sdk/browser/openvibe-sdk.mjs'), 'string');
+ console.log('consumer ok: ' + specs.length + ' subpaths + the browser bundle');
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
- assert.match(res, /consumer ok: 12 subpaths/);
+ assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
+ assert.equal(subpaths.length, 14);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * openvibe-sdk/projects against the mock /api/v1/projects: the walkthrough (project -> app with a
+ * shown-once secret -> grant -> app token), members and roles, credentials rotate/revoke, grants
+ * approve/deny/revoke, allowance, audit; plus the request shapes and no-retry rules on a stub.
+ */
+const assert = require('node:assert/strict');
+const { run, stubServer, send } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createServiceTokenClient } = require('../src/auth');
+const { createProjectsClient } = require('../src/projects');
+const { createMockPlatform, DEFAULT_APP_CATALOG } = require('../src/testing');
+
+function setup(opts) {
+ const platform = createMockPlatform({ users: [{ username: 'owner' }, { username: 'dev' }, { username: 'staff', role: 'admin' }, { username: 'stranger' }], ...opts });
+ const [owner, dev, staff, stranger] = [...platform.state.users.values()];
+ const as = (u) => createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signUserToken(u) }));
+ return { platform, owner, dev, staff, stranger, as };
+}
+
+run([
+ ['walkthrough: project -> confidential app (secret once) -> grant -> a working app token', async () => {
+ const { platform, as, owner } = setup();
+ const projects = as(owner);
+ assert.deepEqual((await projects.catalog()).map((c) => c.id).sort(), [...DEFAULT_APP_CATALOG].sort());
+ const prj = await projects.create({ name: 'My first OpenVibe app' });
+ assert.match(prj.id, /^prj_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.equal(prj.role, 'owner');
+ assert.deepEqual(prj.environments, ['sandbox'], 'a new project is sandbox only');
+ assert.equal((await projects.list()).length, 1);
+
+ const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential', redirectUris: ['http://localhost:3009/callback'] });
+ assert.match(app.id, /^app_/);
+ assert.equal(app.client_id, app.id);
+ assert.equal(app.credential.shown_once, true);
+ const secret = app.credential.client_secret;
+ assert.match(secret, /^ovsec_/);
+ assert.equal((await projects.apps.get(prj.id, app.id)).credential, undefined, 'never shown again');
+ assert.ok(!JSON.stringify(await projects.credentials.list(prj.id, app.id)).includes(secret));
+
+ const g = await projects.grants.request(prj.id, app.id, 'media.object.upload');
+ assert.equal(g.status, 'approved', 'owner + inside the allowance: approved at once');
+ assert.equal(g.audience, 'openvibe.media');
+ const tokens = createServiceTokenClient({ clientId: app.id, clientSecret: secret, fetch: platform.fetch });
+ const info = await tokens.getTokenInfo({ audience: 'openvibe.media' });
+ assert.deepEqual(info.scope, ['media.object.upload']);
+ assert.equal(info.unverifiedClaims.project_id, prj.id);
+ assert.equal(info.unverifiedClaims.env, 'sandbox');
+
+ // Rotate: the new secret works, the old one keeps working during the overlap; revoke ends it.
+ const rotated = await projects.credentials.rotate(prj.id, app.id, { overlapSeconds: 3600 });
+ assert.notEqual(rotated.credential.client_secret, secret);
+ assert.equal(rotated.previous.length, 1);
+ assert.ok(await createServiceTokenClient({ clientId: app.id, clientSecret: rotated.credential.client_secret, fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }));
+ const creds = await projects.credentials.list(prj.id, app.id);
+ assert.deepEqual(creds.map((c) => c.state).sort(), ['active', 'expiring']);
+ const old = creds.find((c) => c.state === 'expiring');
+ assert.equal((await projects.credentials.revoke(prj.id, app.id, old.id)).state, 'revoked');
+ await assert.rejects(createServiceTokenClient({ clientId: app.id, clientSecret: secret, fetch: platform.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'invalid_client' });
+
+ // Revoking the grant takes the capability out of new tokens; revoking the app stops tokens.
+ await projects.grants.revoke(prj.id, app.id, 'media.object.upload');
+ const fresh = createServiceTokenClient({ clientId: app.id, clientSecret: rotated.credential.client_secret, fetch: platform.fetch });
+ await assert.rejects(fresh.getToken({ audience: 'openvibe.media' }), { code: 'invalid_scope' });
+ assert.ok((await projects.apps.revoke(prj.id, app.id)).revoked_at);
+ await assert.rejects(fresh.getToken({ audience: 'openvibe.media' }), { code: 'invalid_client' });
+ const actions = [];
+ for await (const e of projects.iterateAudit(prj.id, { pageSize: 2 })) actions.push(e.action);
+ assert.deepEqual(actions.slice(0, 3), ['app.revoked', 'grant.revoked', 'credential.revoked'], 'newest first, across pages');
+ assert.ok(actions.includes('project.created'));
+ assert.ok(!JSON.stringify(await projects.audit(prj.id, { limit: 200 })).includes('ovsec_'), 'no secrets in the audit');
+ }],
+
+ ['roles: non-members see 404, developers request, admins approve inside the allowance, staff set it', async () => {
+ const { as, owner, dev, staff, stranger } = setup();
+ const prj = await as(owner).create({ name: 'team' });
+ assert.equal(await as(stranger).get(prj.id), null, 'non-members: 404, existence not disclosed');
+ await as(owner).members.add(prj.id, { username: 'dev', role: 'developer' });
+ assert.equal((await as(owner).members.list(prj.id)).length, 2);
+ const app = await as(dev).apps.create(prj.id, { name: 'web', type: 'public', redirectUris: ['http://localhost:3001/callback'] });
+ assert.equal(app.credential, undefined, 'public apps have no secret');
+ await assert.rejects(as(dev).apps.create(prj.id, { name: 'prod', environment: 'production' }), { status: 403 });
+ const req = await as(dev).grants.request(prj.id, app.id, 'tools.job.read');
+ assert.equal(req.status, 'requested', 'a developer request waits');
+ await assert.rejects(as(dev).grants.approve(prj.id, app.id, 'tools.job.read'), { status: 403 });
+ await assert.rejects(as(dev).grants.request(prj.id, app.id, 'events.event.read'), { status: 403, code: 'grant.not_grantable' });
+
+ const shrunk = await as(staff).setAllowance(prj.id, ['media.object.read']);
+ assert.deepEqual(shrunk.trimmed, [{ app_id: app.id, capability: 'tools.job.read', to: 'denied' }], 'shrinking the allowance denies pending requests outside it');
+ await assert.rejects(as(owner).grants.approve(prj.id, app.id, 'tools.job.read'), { status: 403, code: 'grant.beyond_allowance' });
+ await as(dev).grants.request(prj.id, app.id, 'media.object.read');
+ assert.equal((await as(owner).grants.deny(prj.id, app.id, 'media.object.read')).status, 'denied');
+ await as(dev).grants.request(prj.id, app.id, 'media.object.read');
+ assert.equal((await as(owner).grants.approve(prj.id, app.id, 'media.object.read')).status, 'approved');
+ assert.deepEqual((await as(dev).apps.get(prj.id, app.id)).grants, ['media.object.read']);
+ await assert.rejects(as(owner).setAllowance(prj.id, []), { status: 403 }, 'only staff set the allowance');
+ await as(staff).setEnvironmentPolicy(prj.id, 'sandbox+production');
+ assert.deepEqual((await as(owner).get(prj.id)).environments, ['sandbox', 'production']);
+ assert.equal((await as(owner).apps.create(prj.id, { name: 'prod', environment: 'production' })).environment, 'production');
+ assert.equal((await as(staff).list({ all: true })).length, 1);
+
+ await as(dev).members.remove(prj.id, dev.subject_id); // leave
+ assert.equal(await as(dev).get(prj.id), null);
+ await assert.rejects(as(owner).members.remove(prj.id, owner.subject_id), { code: 'member.owner' });
+ await as(owner).archive(prj.id);
+ assert.ok((await as(owner).get(prj.id)).archived_at);
+ }],
+
+ ['only user tokens: service and app tokens get 401', async () => {
+ const { platform, owner } = setup();
+ const svc = createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signServiceToken('live', { audience: 'openvibe.network' }) }));
+ await assert.rejects(svc.list(), { status: 401, code: 'auth.invalid' });
+ await assert.rejects(createProjectsClient(createClient({ fetch: platform.fetch })).list(), { status: 401, code: 'auth.required' });
+ assert.ok(Array.isArray(await createProjectsClient(createClient({ fetch: platform.fetch, token: platform.signUserToken(owner) })).list()));
+ }],
+
+ ['request shapes, and calls that mint secrets or are not deduped are never retried', async () => {
+ const srv = await stubServer((req, res) => send(res, 503, { code: 'network.unavailable', status: 503 }));
+ const projects = createProjectsClient(createClient({ baseUrls: { network: srv.url }, token: 'user-jwt', retryDelayMs: 1 }));
+ await assert.rejects(projects.apps.create('prj_1', { name: 'a', redirectUris: ['https://a.example/cb'] }), { status: 503 });
+ await assert.rejects(projects.credentials.rotate('prj_1', 'app_1', { overlapSeconds: 0 }), { status: 503 });
+ await assert.rejects(projects.create({ name: 'x' }), { status: 503 });
+ assert.equal(srv.requests.length, 3, 'one attempt each');
+ const [create, rotate] = srv.requests;
+ assert.equal(create.url, '/api/v1/projects/prj_1/apps');
+ assert.deepEqual(JSON.parse(create.body), { name: 'a', environment: 'sandbox', type: 'confidential', redirect_uris: ['https://a.example/cb'] });
+ assert.equal(create.headers['idempotency-key'], undefined);
+ assert.equal(rotate.url, '/api/v1/projects/prj_1/apps/app_1/credentials/rotate');
+ assert.deepEqual(JSON.parse(rotate.body), { overlap_seconds: 0 });
+ assert.equal(create.headers.authorization, 'Bearer user-jwt');
+ await assert.rejects(projects.list(), { status: 503 });
+ assert.equal(srv.requests.length, 3 + 3, 'reads are retried (2 retries)');
+ await srv.close();
+ }],
+]);
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/** Realtime SSE: resume from Last-Event-ID after a drop, dedupe, gap callback, transports. */
/** Realtime SSE: resume from Last-Event-ID after a drop, dedupe, gap callback, transports. */
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const { stubServer, run, waitFor } = require('./helpers');
const { stubServer, run, waitFor } = require('./helpers');
-const { subscribe, createRealtimeClient } = require('../src/realtime');
+const { subscribe, createRealtimeClient, parseSSE } = require('../src/realtime');
const { createClient } = require('../src/core');
const { createClient } = require('../src/core');
const { createMockPlatform } = require('../src/testing');
const { createMockPlatform } = require('../src/testing');
@@ -115,4 +115,34 @@ run([
@@ -115,4 +115,34 @@ run([
sub.close();
sub.close();
await sub.done;
await sub.done;
}],
}],
+
+ ['parseSSE: WHATWG fields, comments, multi-line data, CRLF split across chunks, retry, async iterables', async () => {
+ const chunks = [': hello\r', '\nretry: 1500\r\nid: 7\r\nevent: job.progress\r\ndata: {"a":', '1}\r\n\r', '\ndata: line one\ndata: line two\n\nevent: ping\n\nid: 9\ndata:no-space\n\n'];
+ const body = new ReadableStream({ start(c) { for (const x of chunks) c.enqueue(new TextEncoder().encode(x)); c.close(); } });
+ const retries = [];
+ const got = [];
+ for await (const m of parseSSE(body, { onRetry: (ms) => retries.push(ms) })) got.push(m);
+ assert.deepEqual(retries, [1500]);
+ assert.deepEqual(got, [
+ { event: 'job.progress', data: '{"a":1}', id: '7' },
+ { event: 'message', data: 'line one\nline two', id: undefined },
+ { event: 'ping', data: '', id: undefined },
+ { event: 'message', data: 'no-space', id: '9' },
+ ]);
+ async function* strings() { yield 'data: a\n'; yield '\ndata: b\n\n'; }
+ const fromIterable = [];
+ for await (const m of parseSSE(strings())) fromIterable.push(m.data);
+ assert.deepEqual(fromIterable, ['a', 'b']);
+ await assert.rejects(parseSSE(null).next(), TypeError);
+ }],
+
+ ['parseSSE: breaking out cancels the underlying stream', async () => {
+ let cancelled = false;
+ const body = new ReadableStream({
+ pull(c) { c.enqueue(new TextEncoder().encode('data: x\n\n')); },
+ cancel() { cancelled = true; },
+ });
+ for await (const m of parseSSE(body)) { assert.equal(m.data, 'x'); break; }
+ assert.equal(cancelled, true);
+ }],
]);
]);
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
const assert = require('node:assert/strict');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const fs = require('node:fs');
const path = require('node:path');
const path = require('node:path');
+const { pathToFileURL } = require('node:url');
const { run } = require('./helpers');
const { run } = require('./helpers');
const ROOT = path.join(__dirname, '..');
const ROOT = path.join(__dirname, '..');
@@ -33,7 +34,9 @@ run([
@@ -33,7 +34,9 @@ run([
for (const [sub, entry] of Object.entries(pkg.exports)) {
for (const [sub, entry] of Object.entries(pkg.exports)) {
if (sub === './package.json') continue;
if (sub === './package.json') continue;
const names = declared(path.join(ROOT, entry.types));
const names = declared(path.join(ROOT, entry.types));
- const runtime = Object.keys(require(path.join(ROOT, entry.default)));
+ const runtime = entry.default.endsWith('.mjs')
+ ? Object.keys(await import(pathToFileURL(path.join(ROOT, entry.default)).href)).filter((k) => k !== 'default')
+ : Object.keys(require(path.join(ROOT, entry.default)));
for (const n of runtime) if (!names.has(n)) missing.push(`${sub}: ${n}`);
for (const n of runtime) if (!names.has(n)) missing.push(`${sub}: ${n}`);
const browser = entry.browser ? Object.keys(require(path.join(ROOT, entry.browser))) : [];
const browser = entry.browser ? Object.keys(require(path.join(ROOT, entry.browser))) : [];
for (const n of browser) if (!names.has(n)) missing.push(`${sub} (browser): ${n}`);
for (const n of browser) if (!names.has(n)) missing.push(`${sub} (browser): ${n}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -0,0 +1,25 @@
+'use strict';
+/** SDK_VERSION, package.json, the lockfile, the CHANGELOG and the bundle agree. */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { run } = require('./helpers');
+
+const ROOT = path.join(__dirname, '..');
+const pkg = require('../package.json');
+
+run([
+ ['core.SDK_VERSION is the package version', async () => {
+ assert.equal(require('../src/core').SDK_VERSION, pkg.version);
+ assert.equal(require('..').SDK_VERSION, pkg.version);
+ assert.equal(require('../browser.js').SDK_VERSION, pkg.version);
+ }],
+
+ ['the lockfile, the CHANGELOG and the bundle name the same version', async () => {
+ const lock = require('../package-lock.json');
+ assert.equal(lock.version, pkg.version);
+ assert.equal(lock.packages[''].version, pkg.version);
+ assert.match(fs.readFileSync(path.join(ROOT, 'CHANGELOG.md'), 'utf8'), new RegExp(`^## ${pkg.version.replace(/\./g, '\\.')} \\(`, 'm'));
+ assert.match(fs.readFileSync(path.join(ROOT, 'browser/openvibe-sdk.mjs'), 'utf8'), new RegExp(`openvibe-sdk ${pkg.version.replace(/\./g, '\\.')},`));
+ }],
+]);
@@ -8,14 +8,21 @@ export interface AuthorizeOptions {
@@ -8,14 +8,21 @@ export interface AuthorizeOptions {
authorizeUrl?: string;
authorizeUrl?: string;
clientId: string;
clientId: string;
redirectUri: string;
redirectUri: string;
+ /** Default 'profile theme' only when no audience is given. Developer apps: capability ids. */
scope?: string | string[];
scope?: string | string[];
+ /** Developer apps: the audience (openvibe.<service>) the code is for; exchange with the same one. */
+ audience?: string;
state?: string;
state?: string;
codeChallenge?: string;
codeChallenge?: string;
codeChallengeMethod?: 'S256';
codeChallengeMethod?: 'S256';
- /** 'none' = silent SSO; the callback carries error=login_required when there is no session. */
+ /** 'none' = silent SSO; the callback carries error=login_required when there is no session. Refused for developer apps (interaction_required). */
prompt?: 'none';
prompt?: 'none';
}
}
export declare function buildAuthorizeUrl(opts: AuthorizeOptions): string;
export declare function buildAuthorizeUrl(opts: AuthorizeOptions): string;
export declare function startAuthorization(opts: Omit<AuthorizeOptions, 'codeChallenge' | 'codeChallengeMethod'> & { verifierLength?: number }): Promise<{ url: string; state: string; codeVerifier: string; codeChallenge: string }>;
export declare function startAuthorization(opts: Omit<AuthorizeOptions, 'codeChallenge' | 'codeChallengeMethod'> & { verifierLength?: number }): Promise<{ url: string; state: string; codeVerifier: string; codeChallenge: string }>;
export declare function readCallback(location: string | URL, opts?: { expectedState?: string }): { code: string; state: string };
export declare function readCallback(location: string | URL, opts?: { expectedState?: string }): { code: string; state: string };
export declare function base64url(bytes: Uint8Array): string;
export declare function base64url(bytes: Uint8Array): string;
+/** Header and claims of a JWT decoded WITHOUT verification (display and diagnostics only), or null. */
+export declare function decodeUnverified(token: string): { header: Record<string, unknown>; claims: Record<string, any> } | null;
+/** The claims of a JWT decoded WITHOUT verification, or null. Never authorize with them. */
+export declare function unverifiedClaims(token: string): Record<string, any> | null;
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-import type { TokenContext, TokenProvider, FetchLike } from './core';
+import type { TokenContext, TokenProvider, FetchLike, ServiceTokenClaims } from './core';
export * from './auth-browser';
export * from './auth-browser';
export interface ServiceTokenClientOptions {
export interface ServiceTokenClientOptions {
@@ -15,8 +15,19 @@ export interface ServiceTokenClientOptions {
@@ -15,8 +15,19 @@ export interface ServiceTokenClientOptions {
refreshSkewMs?: number;
refreshSkewMs?: number;
now?: () => number;
now?: () => number;
}
}
+export interface TokenInfo {
+ accessToken: string;
+ tokenType: 'Bearer';
+ audience: string;
+ /** Capability ids the token endpoint granted. */
+ scope: string[];
+ expiresAt: string | null;
+ /** The JWT payload decoded WITHOUT verification: for display and diagnostics only. */
+ unverifiedClaims: Record<string, any> | null;
+}
export interface ServiceTokenClient extends TokenProvider {
export interface ServiceTokenClient extends TokenProvider {
getToken(ctx?: TokenContext): Promise<string>;
getToken(ctx?: TokenContext): Promise<string>;
+ getTokenInfo(ctx?: TokenContext): Promise<TokenInfo>;
authHeaders(ctx?: TokenContext): Promise<{ Authorization: string }>;
authHeaders(ctx?: TokenContext): Promise<{ Authorization: string }>;
invalidate(ctx?: TokenContext): void;
invalidate(ctx?: TokenContext): void;
readonly tokenUrl: string;
readonly tokenUrl: string;
@@ -52,8 +63,34 @@ export interface VerifyUserTokenOptions {
@@ -52,8 +63,34 @@ export interface VerifyUserTokenOptions {
}
}
export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;
export declare function verifyUserToken(token: string, opts: VerifyUserTokenOptions): Promise<UserTokenClaims>;
+/** A developer app's token (identity.service-token-claims@1 with actor_type app). */
+export type AppTokenClaims = ServiceTokenClaims & {
+ sub: `app:app_${string}`;
+ actor_type: 'app';
+ /** [project_id] */
+ ns: string[];
+ project_id: string;
+ env: 'sandbox' | 'production';
+ /** usr_… of the person who authorized the app (authorization-code tokens only). */
+ on_behalf_of?: string;
+};
+export interface VerifyAppTokenOptions {
+ jwks?: { keys?: object[]; public_key?: string } | string;
+ publicKey?: string | object;
+ issuer?: string;
+ /** Required: the audience your service answers for (openvibe.<service>). */
+ audience: string | string[];
+ /** Accept env=sandbox tokens (default false: token.sandbox_refused). */
+ acceptSandbox?: boolean;
+ clockSkewSec?: number;
+ now?: number;
+ fetch?: FetchLike;
+}
+export declare function verifyAppToken(token: string, opts: VerifyAppTokenOptions): Promise<AppTokenClaims>;
+
export interface UserTokenResponse {
export interface UserTokenResponse {
access_token: string;
access_token: string;
+ /** Absent for developer-app tokens: sign in again when they expire. */
refresh_token?: string;
refresh_token?: string;
token_type: 'Bearer';
token_type: 'Bearer';
expires_in: number;
expires_in: number;
@@ -61,5 +98,22 @@ export interface UserTokenResponse {
@@ -61,5 +98,22 @@ export interface UserTokenResponse {
user?: Record<string, unknown>;
user?: Record<string, unknown>;
preferences?: Record<string, unknown>;
preferences?: Record<string, unknown>;
}
}
-export declare function exchangeCode(opts: { code: string; redirectUri: string; codeVerifier?: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
+export interface ExchangeCodeOptions {
+ code: string;
+ redirectUri: string;
+ /** Required for public clients (no clientSecret) and for every developer app. */
+ codeVerifier?: string;
+ clientId: string;
+ /** Confidential clients only; public apps send none. */
+ clientSecret?: string;
+ /** Developer apps (required for them): the audience the token is for. */
+ audience?: string;
+ /** Capability ids to narrow what the person authorized. */
+ scope?: string | string[];
+ network?: string;
+ tokenUrl?: string;
+ fetch?: FetchLike;
+ timeoutMs?: number;
+}
+export declare function exchangeCode(opts: ExchangeCodeOptions): Promise<UserTokenResponse>;
export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
export declare function refreshUserToken(opts: { refreshToken: string; clientId: string; clientSecret: string; network?: string; tokenUrl?: string; fetch?: FetchLike; timeoutMs?: number }): Promise<UserTokenResponse>;
new file mode 100644
new file mode 100644
@@ -0,0 +1,26 @@
@@ -0,0 +1,26 @@
+/** browser/openvibe-sdk.mjs: one self-contained ES module with the browser-safe subpaths (same shape as the package's browser entry). */
+import * as auth from './auth-browser';
+import * as registry from './registry';
+import * as modules from './modules';
+import * as realtime from './realtime';
+import * as media from './media';
+import * as community from './community';
+import * as jobs from './jobs';
+import * as projects from './projects';
+import * as core from './core';
+
+export * from './core';
+export * as auth from './auth-browser';
+export * as registry from './registry';
+export * as modules from './modules';
+export * as realtime from './realtime';
+export * as media from './media';
+export * as community from './community';
+export * as jobs from './jobs';
+export * as projects from './projects';
+
+declare const sdk: typeof core & {
+ auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
+ media: typeof media; community: typeof community; jobs: typeof jobs; projects: typeof projects;
+};
+export default sdk;
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-/* Copied verbatim from openvibe-contracts v0.6.0 generated/typescript/index.d.ts (the SDK does not
+/* Copied verbatim from openvibe-contracts v0.26.0 generated/typescript/index.d.ts (the SDK does not
* require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
* require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
/* eslint-disable */
/* eslint-disable */
@@ -28,11 +28,13 @@ export type SubjectRef =
@@ -28,11 +28,13 @@ export type SubjectRef =
id: string;
id: string;
};
};
-/** [email protected] (owner: network) */
+/** [email protected] (owner: network) */
/**
/**
- * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key.
+ * Claims of a short-lived RS256 client-credentials token issued by OpenVibe.Network to a service or app principal. Replaces X-Internal-Key. App tokens (actor_type app) also carry project_id and env; receivers refuse env=sandbox unless they opted in.
*/
*/
-export interface ServiceTokenClaims {
+export type ServiceTokenClaims = {
+ [k: string]: unknown | undefined;
+} & {
/**
/**
* Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
* Issuer; https://openvibe.network in production. Receivers pass the issuer they expect to verifyServiceToken().
*/
*/
@@ -54,8 +56,20 @@ export interface ServiceTokenClaims {
@@ -54,8 +56,20 @@ export interface ServiceTokenClaims {
iat: number;
iat: number;
exp: number;
exp: number;
jti: string;
jti: string;
+ /**
+ * Developer project of an app principal (ADR-014). Services key tenancy by it. Absent on first-party service tokens.
+ */
+ project_id?: string;
+ /**
+ * Environment of an app principal. A receiver MUST refuse env=sandbox (401 token.sandbox_refused) unless it opted in to sandbox tokens. Absent on first-party service tokens, which are production.
+ */
+ env?: "sandbox" | "production";
+ /**
+ * The person who authorized an app through the authorization-code flow. Absent on client_credentials tokens.
+ */
+ on_behalf_of?: string;
[k: string]: unknown | undefined;
[k: string]: unknown | undefined;
-}
+};
/** [email protected] (owner: contracts) */
/** [email protected] (owner: contracts) */
/**
/**
@@ -129,7 +143,7 @@ export interface Capability {
@@ -129,7 +143,7 @@ export interface Capability {
version: string;
version: string;
owner: string;
owner: string;
status: "planned" | "active" | "deprecated" | "retired";
status: "planned" | "active" | "deprecated" | "retired";
- visibility: "public" | "first-party" | "internal";
+ visibility: "public" | "partner" | "first-party" | "internal";
description?: string;
description?: string;
inputSchema?: string;
inputSchema?: string;
outputSchema?: string;
outputSchema?: string;
@@ -74,6 +74,7 @@ export interface RequestOptions {
@@ -74,6 +74,7 @@ export interface RequestOptions {
traceparent?: string;
traceparent?: string;
requestId?: string;
requestId?: string;
credentials?: RequestCredentials;
credentials?: RequestCredentials;
+ /** 'response': a 2xx resolves with the raw fetch Response (body unread) as `data` and `response`. */
responseType?: 'json' | 'text' | 'arrayBuffer' | 'response';
responseType?: 'json' | 'text' | 'arrayBuffer' | 'response';
}
}
@@ -85,6 +86,8 @@ export interface ClientResponse<T = any> {
@@ -85,6 +86,8 @@ export interface ClientResponse<T = any> {
traceId: string;
traceId: string;
traceparent: string;
traceparent: string;
attempts: number;
attempts: number;
+ /** Only with responseType 'response': the raw Response (same as data). */
+ response?: Response;
}
}
export interface Discovery {
export interface Discovery {
@@ -24,7 +24,8 @@ export interface EventsClient {
@@ -24,7 +24,8 @@ export interface EventsClient {
publish(envelope: EventInput, opts?: { traceparent?: string }): Promise<PublishResult>;
publish(envelope: EventInput, opts?: { traceparent?: string }): Promise<PublishResult>;
publish(envelopes: EventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;
publish(envelopes: EventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;
pull(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;
pull(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;
- iterate(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number; onGap?: (gap: Gap) => void | Promise<void>; onPage?: (page: EventsPage) => void | Promise<void> }): AsyncGenerator<StoredEvent, void, unknown>;
+ /** onPage runs after every item of that page was yielded and handled: save page.next_after_seq there. */
+ iterate(opts?: { topic?: string | string[]; afterSeq?: number; limit?: number; maxPages?: number; onGap?: (gap: Gap) => void | Promise<void>; onPage?: (page: EventsPage) => void | Promise<void> }): AsyncGenerator<StoredEvent, void, unknown>;
get(eventId: string): Promise<StoredEvent | null>;
get(eventId: string): Promise<StoredEvent | null>;
getCheckpoint(topic: string): Promise<{ consumer: string; topic: string; cursor: number; updated_at: string | null }>;
getCheckpoint(topic: string): Promise<{ consumer: string; topic: string; cursor: number; updated_at: string | null }>;
setCheckpoint(topic: string, cursor: number): Promise<{ consumer: string; topic: string; cursor: number }>;
setCheckpoint(topic: string, cursor: number): Promise<{ consumer: string; topic: string; cursor: number }>;
@@ -7,3 +7,5 @@ export * as events from './events';
@@ -7,3 +7,5 @@ export * as events from './events';
export * as realtime from './realtime';
export * as realtime from './realtime';
export * as media from './media';
export * as media from './media';
export * as community from './community';
export * as community from './community';
+export * as jobs from './jobs';
+export * as projects from './projects';
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+import type { OpenVibeClient } from './core';
+
+export type JobState = 'queued' | 'running' | 'succeeded' | 'failed' | 'cancelled';
+export interface Job {
+ id: string;
+ object: 'tools.job';
+ service: string;
+ type: string;
+ type_version: number;
+ state: JobState;
+ progress: { percent: number | null; message: string | null };
+ attempts: number;
+ max_attempts: number;
+ cancel_requested?: boolean;
+ created_at?: string | null;
+ started_at?: string | null;
+ finished_at?: string | null;
+ expires_at?: string | null;
+ result: { files: Array<{ name: string; mime: string; size: number; url: string; [k: string]: unknown }>; data: Record<string, unknown> } | null;
+ error: { code: string; detail?: string; [k: string]: unknown } | null;
+ retryable: boolean;
+ links: { self: string; events: string; cancel: string | null };
+ [field: string]: unknown;
+}
+export interface JobEvent { id: number | null; event: string; job: Job | null; }
+export type JobFile = Blob | { name?: string; data: Blob | ArrayBuffer | ArrayBufferView | string; type?: string };
+export interface SubmitJob {
+ type: string;
+ input?: Record<string, unknown>;
+ files?: JobFile | JobFile[];
+ /** Same key + same request = same job. Generated when omitted (returned). */
+ idempotencyKey?: string;
+ signal?: AbortSignal;
+}
+export interface JobEventsOptions { lastEventId?: number | string; signal?: AbortSignal; maxReconnects?: number; reconnectDelayMs?: number; }
+export interface JobsClient {
+ submit(input: SubmitJob): Promise<{ job: Job; replayed: boolean; idempotencyKey: string }>;
+ get(id: string, opts?: { signal?: AbortSignal }): Promise<Job | null>;
+ cancel(id: string, opts?: { signal?: AbortSignal }): Promise<Job>;
+ events(id: string, opts?: JobEventsOptions): AsyncGenerator<JobEvent, void, unknown>;
+ wait(id: string, opts?: JobEventsOptions & { onEvent?: (e: JobEvent) => void | Promise<void> }): Promise<Job | null>;
+ /** The raw Response of a result file (body unread). */
+ file(id: string, n?: number, opts?: { signal?: AbortSignal; inline?: boolean }): Promise<Response>;
+}
+/** baseUrl: the satellite that runs the job type (e.g. https://img.openvibe.tools); default the registry's `tools` origin. */
+export declare function createJobsClient(client: OpenVibeClient, opts?: { baseUrl?: string; service?: string; audience?: string }): JobsClient;
+export declare function isTerminal(job: Pick<Job, 'state'> | null | undefined): boolean;
+export declare const TERMINAL_STATES: JobState[];
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+import type { OpenVibeClient } from './core';
+
+export type ProjectRole = 'owner' | 'admin' | 'developer' | 'viewer';
+export type EnvironmentPolicy = 'sandbox' | 'sandbox+production';
+export interface Project {
+ id: string;
+ name: string;
+ owner: { type: 'user'; id: string } | null;
+ role: ProjectRole | null;
+ environment_policy: EnvironmentPolicy;
+ environments: Array<'sandbox' | 'production'>;
+ allowance: string[];
+ created_at: string;
+ archived_at: string | null;
+ counts: { members: number; apps: number };
+ [field: string]: unknown;
+}
+export interface ProjectMember { subject: { type: 'user'; id: string }; username: string | null; display_name: string | null; role: ProjectRole; [field: string]: unknown; }
+/** The secret is present only in the response that created it. */
+export interface ShownOnceCredential { id: string; client_secret: string; hint: string; shown_once: true; }
+export interface DeveloperApp {
+ id: string;
+ subject: { type: 'app'; id: string };
+ project_id: string;
+ name: string;
+ environment: 'sandbox' | 'production';
+ client_id: string;
+ client_type: 'confidential' | 'public';
+ redirect_uris: string[];
+ created_at: string;
+ revoked_at: string | null;
+ /** Approved capabilities. */
+ grants: string[];
+ credential?: ShownOnceCredential;
+ [field: string]: unknown;
+}
+export interface Credential { id: string; hint: string; state: 'active' | 'expiring' | 'expired' | 'revoked'; created_at: string; expires_at: string | null; revoked_at: string | null; [field: string]: unknown; }
+export interface Grant { app_id: string; capability: string; audience: string; status: 'requested' | 'approved' | 'denied' | 'revoked'; requested_by: string; requested_at: string; decided_by: string | null; decided_at: string | null; }
+export interface Quota { capability: string; limit: number; window: 'minute' | 'hour' | 'day' | 'month' | 'total'; unit: string; enforced_by: string | null; updated_at?: string; }
+export interface CatalogCapability { id: string; owner: string; audience: string; visibility: string; description: string; resourceConstraints?: string[]; quotaClass?: string; }
+export interface AuditEntry { id: number; at: string; actor: string; action: string; target: string; detail: Record<string, unknown>; [field: string]: unknown; }
+
+export interface ProjectsClient {
+ catalog(): Promise<CatalogCapability[]>;
+ list(opts?: { all?: boolean }): Promise<Project[]>;
+ create(input: { name: string }): Promise<Project>;
+ get(project: string): Promise<Project | null>;
+ update(project: string, input: { name: string }): Promise<Project>;
+ archive(project: string): Promise<Project>;
+ setAllowance(project: string, capabilities: string[]): Promise<{ allowance: string[]; trimmed: unknown[] }>;
+ setEnvironmentPolicy(project: string, policy: EnvironmentPolicy): Promise<{ environment_policy: EnvironmentPolicy; environments: string[] }>;
+ members: {
+ list(project: string): Promise<ProjectMember[]>;
+ add(project: string, input: { username?: string; subjectId?: string; role: Exclude<ProjectRole, 'owner'> }): Promise<ProjectMember>;
+ update(project: string, subject: string, input: { role: Exclude<ProjectRole, 'owner'> }): Promise<ProjectMember>;
+ remove(project: string, subject: string): Promise<null>;
+ };
+ apps: {
+ list(project: string): Promise<DeveloperApp[]>;
+ get(project: string, app: string): Promise<DeveloperApp | null>;
+ create(project: string, input: { name: string; environment?: 'sandbox' | 'production'; type?: 'confidential' | 'public'; redirectUris?: string[] }): Promise<DeveloperApp>;
+ update(project: string, app: string, input: { name?: string; redirectUris?: string[] }): Promise<DeveloperApp>;
+ revoke(project: string, app: string): Promise<DeveloperApp>;
+ };
+ credentials: {
+ list(project: string, app: string): Promise<Credential[]>;
+ rotate(project: string, app: string, opts?: { overlapSeconds?: number }): Promise<{ credential: ShownOnceCredential; previous: Array<{ id: string; expires_at: string }> }>;
+ revoke(project: string, app: string, credential: string): Promise<Credential>;
+ };
+ grants: {
+ list(project: string, app: string): Promise<Grant[]>;
+ request(project: string, app: string, capability: string): Promise<Grant>;
+ approve(project: string, app: string, capability: string): Promise<Grant>;
+ deny(project: string, app: string, capability: string): Promise<Grant>;
+ revoke(project: string, app: string, capability: string): Promise<Grant>;
+ };
+ quotas: {
+ list(project: string): Promise<Quota[]>;
+ set(project: string, capability: string, input: { limit: number; window: Quota['window']; unit: string }): Promise<Quota>;
+ delete(project: string, capability: string): Promise<null>;
+ };
+ audit(project: string, opts?: { before?: number; limit?: number }): Promise<{ entries: AuditEntry[]; next_before: number | null }>;
+ iterateAudit(project: string, opts?: { pageSize?: number }): AsyncGenerator<AuditEntry, void, unknown>;
+}
+/** The client must carry a Network USER access token (token/getToken); service and app tokens get 401. */
+export declare function createProjectsClient(client: OpenVibeClient, opts?: { baseUrl?: string }): ProjectsClient;
@@ -31,4 +31,7 @@ export interface RealtimeSubscription {
@@ -31,4 +31,7 @@ export interface RealtimeSubscription {
}
}
export declare function subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription;
export declare function subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription;
export declare function createRealtimeClient(client: OpenVibeClient, defaults?: Omit<SubscribeOptions, 'client'>): { subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription };
export declare function createRealtimeClient(client: OpenVibeClient, defaults?: Omit<SubscribeOptions, 'client'>): { subscribe(topics: string | string[], onEvent: (event: EventEnvelope, meta: { seq: number }) => void, opts?: SubscribeOptions): RealtimeSubscription };
+export interface SSEMessage { event: string; data: string; id: string | undefined; }
+/** WHATWG event-stream parser over a fetch body (or any async iterable of chunks). */
+export declare function parseSSE(body: ReadableStream<Uint8Array> | AsyncIterable<Uint8Array | string>, opts?: { onRetry?: (ms: number) => void }): AsyncGenerator<SSEMessage, void, unknown>;
export declare const DEFAULT_ORIGIN: string;
export declare const DEFAULT_ORIGIN: string;
@@ -1,34 +1,97 @@
@@ -1,34 +1,97 @@
import type { FetchLike, EventEnvelope, Capability, ServiceManifest, ModuleNamespace } from './core';
import type { FetchLike, EventEnvelope, Capability, ServiceManifest, ModuleNamespace } from './core';
+type Origin = 'network' | 'events' | 'media' | 'community' | 'tools';
export interface MockGrant { capability: string; audience: string; namespaces?: string[]; }
export interface MockGrant { capability: string; audience: string; namespaces?: string[]; }
export interface MockUser { id: number | string; subject_id: string; username: string; display_name?: string; role?: string; legacy: Array<{ system: string; type?: string; id: string | number }>; }
export interface MockUser { id: number | string; subject_id: string; username: string; display_name?: string; role?: string; legacy: Array<{ system: string; type?: string; id: string | number }>; }
+export interface MockAppSpec {
+ /** app_<ULID>; generated when omitted (addApp). */
+ id?: string;
+ /** prj_<ULID>; a project is created when omitted or unknown. */
+ project?: string;
+ name?: string;
+ env?: 'sandbox' | 'production';
+ environment?: 'sandbox' | 'production';
+ type?: 'confidential' | 'public';
+ /** Confidential apps: the client secret (generated when omitted). */
+ secret?: string;
+ redirectUris?: string[];
+ /** Approved grants: capability ids (audience openvibe.<owner>) or { capability, audience }. */
+ grants?: Array<string | { capability: string; audience?: string }>;
+}
+export interface MockProjectSpec {
+ name?: string;
+ owner?: string;
+ /** subject -> role. A project with an owner or members lets only them authorize its sandbox apps. */
+ members?: Record<string, 'owner' | 'admin' | 'developer' | 'viewer'>;
+ /** '*' (default for projects declared here) or capability ids. */
+ allowance?: '*' | string[];
+ environmentPolicy?: 'sandbox' | 'sandbox+production';
+}
+export interface MockJobContext {
+ input: Record<string, unknown>;
+ files: Array<{ name: string; type: string; bytes: Uint8Array }>;
+ progress(percent: number, message?: string | null): Promise<void>;
+ readonly cancelled: boolean;
+}
export interface MockPlatformOptions {
export interface MockPlatformOptions {
- origins?: Partial<Record<'network' | 'events' | 'media' | 'community', string>>;
+ origins?: Partial<Record<Origin, string>>;
issuer?: string;
issuer?: string;
contractsVersion?: string;
contractsVersion?: string;
clients?: Record<string, { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }>;
clients?: Record<string, { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }>;
+ /** Developer apps by id (app_<ULID>). */
+ apps?: Record<string, Omit<MockAppSpec, 'id'>>;
+ /** Developer projects by id (prj_<ULID>). */
+ projects?: Record<string, MockProjectSpec>;
+ /** Audiences a sandbox app may get tokens for (Network DEV_SANDBOX_AUDIENCES). Default: any. */
+ sandboxAudiences?: string[];
+ /** Audiences or capability ids whose mock services accept env=sandbox tokens (or true). Default: none. */
+ acceptSandbox?: true | string[];
+ /** Allowance of projects created through /api/v1/projects. Default: the whole catalog (Network: empty). */
+ defaultAllowance?: string[];
users?: Array<Partial<MockUser>>;
users?: Array<Partial<MockUser>>;
mediaApps?: Record<string, { apiKey?: string }>;
mediaApps?: Record<string, { apiKey?: string }>;
namespaces?: ModuleNamespace[];
namespaces?: ModuleNamespace[];
capabilities?: Capability[];
capabilities?: Capability[];
services?: ServiceManifest[];
services?: ServiceManifest[];
realtimeRetryMs?: number;
realtimeRetryMs?: number;
+ /** Serve Tools jobs at origins.tools. */
+ jobs?: boolean | { stepMs?: number; handlers?: Record<string, (ctx: MockJobContext) => Promise<{ data?: Record<string, unknown>; files?: Array<{ name?: string; mime?: string; bytes?: Uint8Array | string; data?: Uint8Array | string }> } | void>> };
+ /** fetch used by deliverEvents() (default: the global fetch). */
+ deliveryFetch?: FetchLike;
}
}
+export interface DeliveryAttempt { subscription_id: string; event_id: string; seq: number; attempt: number; status: number | null; outcome: 'delivered' | 'retry' | 'dead'; error?: string; }
export interface MockPlatform {
export interface MockPlatform {
fetch: FetchLike;
fetch: FetchLike;
- origins: Record<'network' | 'events' | 'media' | 'community', string>;
+ origins: Record<Origin, string>;
issuer: string;
issuer: string;
keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
signServiceToken(clientId: string, opts: { audience: string; capabilities?: string[]; namespaces?: string[]; expiresInSec?: number }): string;
signServiceToken(clientId: string, opts: { audience: string; capabilities?: string[]; namespaces?: string[]; expiresInSec?: number }): string;
+ signAppToken(appId: string, opts: { audience: string; capabilities?: string[]; projectId?: string; env?: 'sandbox' | 'production'; onBehalfOf?: string; expiresInSec?: number }): string;
addClient(id: string, client: { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }): void;
addClient(id: string, client: { secret: string; grants?: Array<MockGrant | [string, string, string[]?]>; redirectUris?: string[] }): void;
addUser(user?: Partial<MockUser>): MockUser;
addUser(user?: Partial<MockUser>): MockUser;
+ addApp(spec?: MockAppSpec): { id: string; clientId: string; projectId: string; env: 'sandbox' | 'production'; type: 'confidential' | 'public'; secret?: string };
+ /** Returns the project id. */
+ addProject(spec?: MockProjectSpec & { id?: string }): string;
/** What the Network does after the account chooser: returns an authorization code. */
/** What the Network does after the account chooser: returns an authorization code. */
- authorize(opts: { clientId: string; redirectUri: string; subjectId?: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; scope?: string }): string;
- stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }> };
- state: { events: Array<{ seq: number; event: EventEnvelope; publisher: string }>; subscriptions: Map<string, any>; modules: Map<string, any>; files: Map<string, any>; users: Map<string, MockUser>; checkpoints: Map<string, number> };
+ authorize(opts: { clientId: string; redirectUri: string; subjectId?: string; codeChallenge?: string; codeChallengeMethod?: 'S256'; scope?: string | string[]; audience?: string }): string;
+ /** Who GET /oauth/authorize signs in as, and whether they continue ('allow', default) or decline ('deny'). */
+ setAuthorization(opts: { subjectId?: string | null; decision?: 'allow' | 'deny' }): void;
+ stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }>; deliveries: DeliveryAttempt[] };
+ state: {
+ events: Array<{ seq: number; event: EventEnvelope; publisher: string }>; subscriptions: Map<string, any>; modules: Map<string, any>; files: Map<string, any>;
+ users: Map<string, MockUser>; checkpoints: Map<string, number>; apps: Map<string, any>; projects: Map<string, any>; jobs: Map<string, any>;
+ };
publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string): { event_id: string; seq: number; duplicate: boolean };
publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string): { event_id: string; seq: number; duplicate: boolean };
+ /** Retention: drop stored events with seq <= throughSeq; returns how many. */
+ pruneEvents(throughSeq: number): number;
+ /** Play the Events delivery worker once (signed POSTs to subscription endpoints). */
+ deliverEvents(opts?: { fetch?: FetchLike; subscriptionId?: string; timeoutMs?: number }): Promise<{ delivered: number; failed: number; dead: number; attempts: DeliveryAttempt[] }>;
+ startDeliveries(opts?: { intervalMs?: number; fetch?: FetchLike; subscriptionId?: string; timeoutMs?: number }): { stop(): Promise<void> };
dropRealtime(): void;
dropRealtime(): void;
+ dropJobStreams(): void;
}
}
export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;
export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;
-export declare const DEFAULT_ORIGINS: Record<'network' | 'events' | 'media' | 'community', string>;
+export declare const DEFAULT_ORIGINS: Record<Origin, string>;
+/** Capability ids a developer app can be granted in openvibe-contracts v0.26.0 (public + active). */
+export declare const DEFAULT_APP_CATALOG: string[];