openvibers/openvibe.sdk Commits

0.3.0: developer apps and the gaps OpenVibe.Examples found

OP OpenVibers committed 23 Sept 2026 · e788c67008 · 56 files, +4643 −178

- auth: exchangeCode() sends audience/scope, supports public clients (PKCE verifier
  required) and app tokens without refresh_token; verifyAppToken() (project_id, env,
  on_behalf_of, sandbox refused unless acceptSandbox); authorize URL takes audience and
  capability scope, 'profile theme' only without an audience; getTokenInfo() exposes the
  granted scope and unverified claims; decodeUnverified()/unverifiedClaims()
- core: responseType 'response' returns the raw Response; SDK_VERSION = package version
- realtime: parseSSE exported
- events: iterate() calls onPage after the page's items were handled (crash-safe cursor)
- new openvibe-sdk/jobs (Tools jobs: submit with Idempotency-Key, get, cancel, SSE with
  reattach, wait, file) and openvibe-sdk/projects (Network /api/v1/projects)
- browser/openvibe-sdk.mjs: self-contained ES module of the browser-safe subpaths,
  generated by scripts/browser-bundle.js, staleness test, secret scan
- testing: developer apps and projects, /oauth/authorize auto-consent, audience-bound
  codes, sandbox refusal unless opted in, project Media tenants, /api/v1/projects, Events
  retention gaps (pruneEvents), delivery worker (deliverEvents/startDeliveries), optional
  Tools jobs; no Chat mock
- contract types re-copied from openvibe-contracts v0.26.0; CI pins that tag

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex c4db2d5..9199027 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 41d341b..761ec63 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,60 @@
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.
++## 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
ddiff --git a/README.md b/README.md
iindex 510221e..a30d865 100644
---- a/README.md
++++ b/README.md
@@@ -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.n
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
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
--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' });
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
```js
const { 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()`, a
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
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`.
ddiff --git a/STATUS.json b/STATUS.json
iindex a72fe49..6bba392 100644
---- a/STATUS.json
++++ b/STATUS.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"
}
ddiff --git a/browser.js b/browser.js
iindex 9b4c5c1..a2f5dac 100644
---- a/browser.js
++++ b/browser.js
@@@ -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'),
};
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
nnew file mode 100644
iindex 0000000..11d02e0
---- /dev/null
++++ b/browser/openvibe-sdk.mjs
@@@ -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;
ddiff --git a/esm/auth-browser.mjs b/esm/auth-browser.mjs
iindex 059ea99..0660a13 100644
---- a/esm/auth-browser.mjs
++++ b/esm/auth-browser.mjs
@@@ -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;
ddiff --git a/esm/auth.mjs b/esm/auth.mjs
iindex 54809be..4c71eef 100644
---- a/esm/auth.mjs
++++ b/esm/auth.mjs
@@@ -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;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 0c96404..5b279b7 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -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;
ddiff --git a/esm/jobs.mjs b/esm/jobs.mjs
nnew file mode 100644
iindex 0000000..9ad197a
---- /dev/null
++++ b/esm/jobs.mjs
@@@ -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;
ddiff --git a/esm/projects.mjs b/esm/projects.mjs
nnew file mode 100644
iindex 0000000..e1f98bb
---- /dev/null
++++ b/esm/projects.mjs
@@@ -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;
ddiff --git a/esm/realtime.mjs b/esm/realtime.mjs
iindex 67af491..5f47d79 100644
---- a/esm/realtime.mjs
++++ b/esm/realtime.mjs
@@@ -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;
ddiff --git a/esm/testing.mjs b/esm/testing.mjs
iindex 9bbf119..509ceac 100644
---- a/esm/testing.mjs
++++ b/esm/testing.mjs
@@@ -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;
ddiff --git a/index.js b/index.js
iindex f07556f..53828e4 100644
---- a/index.js
++++ b/index.js
@@@ -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'),
};
ddiff --git a/package-lock.json b/package-lock.json
iindex 47cfc66..b3a1249 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 46b6e32..bc53c18 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/browser-bundle.js b/scripts/browser-bundle.js
nnew file mode 100644
iindex 0000000..f280168
---- /dev/null
++++ b/scripts/browser-bundle.js
@@@ -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 };
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex f21a159..c60295d 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
};
ddiff --git a/src/auth/browser.js b/src/auth/browser.js
iindex 7b67b97..e275b12 100644
---- a/src/auth/browser.js
++++ b/src/auth/browser.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 };
ddiff --git a/src/auth/index.js b/src/auth/index.js
iindex 2a036eb..4ae4975 100644
---- a/src/auth/index.js
++++ b/src/auth/index.js
@@@ -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 };
ddiff --git a/src/auth/jwt.js b/src/auth/jwt.js
iindex 6ea36c7..78d0208 100644
---- a/src/auth/jwt.js
++++ b/src/auth/jwt.js
@@@ -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, clock
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
}
-- 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 };
ddiff --git a/src/auth/oauth.js b/src/auth/oauth.js
iindex 4677cb9..f7bb651 100644
---- a/src/auth/oauth.js
++++ b/src/auth/oauth.js
@@@ -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);
}
ddiff --git a/src/auth/tokens.js b/src/auth/tokens.js
iindex 4292d32..d9dc0f5 100644
---- a/src/auth/tokens.js
++++ b/src/auth/tokens.js
@@@ -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 = {}) {
ddiff --git a/src/core/client.js b/src/core/client.js
iindex 1145e85..def97c7 100644
---- a/src/core/client.js
++++ b/src/core/client.js
@@@ -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);
}
}
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 87e9bc0..55e6b52 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -7,7 +7,8 @@ const { ulid, newEventId, newIdempotencyKey, isActingSubjectId } = require('./id
const { 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,
ddiff --git a/src/events.js b/src/events.js
iindex 51b6cb3..24e515c 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -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 = {
ddiff --git a/src/jobs.js b/src/jobs.js
nnew file mode 100644
iindex 0000000..dab9586
---- /dev/null
++++ b/src/jobs.js
@@@ -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] };
ddiff --git a/src/projects.js b/src/projects.js
nnew file mode 100644
iindex 0000000..e9faa08
---- /dev/null
++++ b/src/projects.js
@@@ -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 };
ddiff --git a/src/realtime.js b/src/realtime.js
iindex 0b72b04..7c2bb27 100644
---- a/src/realtime.js
++++ b/src/realtime.js
@@@ -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 chunks
line(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 };
ddiff --git a/src/testing/developer.js b/src/testing/developer.js
nnew file mode 100644
iindex 0000000..6932d0e
---- /dev/null
++++ b/src/testing/developer.js
@@@ -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 };
ddiff --git a/src/testing/index.js b/src/testing/index.js
iindex db722cc..b163686 100644
---- a/src/testing/index.js
++++ b/src/testing/index.js
@@@ -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}` -> meta
const 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 };
ddiff --git a/src/testing/jobs.js b/src/testing/jobs.js
nnew file mode 100644
iindex 0000000..15e86a6
---- /dev/null
++++ b/src/testing/jobs.js
@@@ -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 };
ddiff --git a/src/testing/util.js b/src/testing/util.js
nnew file mode 100644
iindex 0000000..03be33b
---- /dev/null
++++ b/src/testing/util.js
@@@ -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 };
ddiff --git a/test/apps.test.js b/test/apps.test.js
nnew file mode 100644
iindex 0000000..22d65ad
---- /dev/null
++++ b/test/apps.test.js
@@@ -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' });
++ }],
++]);
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 31b095e..b35c1ef 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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']) {
ddiff --git a/test/bundle.test.js b/test/bundle.test.js
nnew file mode 100644
iindex 0000000..703b9b4
---- /dev/null
++++ b/test/bundle.test.js
@@@ -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));
++ }],
++]);
ddiff --git a/test/core.test.js b/test/core.test.js
iindex 66ab377..57e21f6 100644
---- a/test/core.test.js
++++ b/test/core.test.js
@@@ -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();
++ }],
]);
ddiff --git a/test/esm.test.js b/test/esm.test.js
iindex aaa3838..433089f 100644
---- a/test/esm.test.js
++++ b/test/esm.test.js
@@@ -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.js
const spec = sub === '.' ? 'openvibe-sdk' : `openvibe-sdk/${sub.slice(2)}`;
const cjs = require(spec);
const esm = await import(spec);
ddiff --git a/test/events.test.js b/test/events.test.js
iindex be45b3f..f1ab98e 100644
---- a/test/events.test.js
++++ b/test/events.test.js
@@@ -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();
++ }],
]);
ddiff --git a/test/jobs.test.js b/test/jobs.test.js
nnew file mode 100644
iindex 0000000..3b349c7
---- /dev/null
++++ b/test/jobs.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 9d4523d..95942ab 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/test/projects.test.js b/test/projects.test.js
nnew file mode 100644
iindex 0000000..70c494f
---- /dev/null
++++ b/test/projects.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/test/realtime.test.js b/test/realtime.test.js
iindex 2bebd7c..664539e 100644
---- a/test/realtime.test.js
++++ b/test/realtime.test.js
@@@ -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);
++ }],
]);
ddiff --git a/test/types.test.js b/test/types.test.js
iindex 4e8d695..8b2b927 100644
---- a/test/types.test.js
++++ b/test/types.test.js
@@@ -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}`);
ddiff --git a/test/version.test.js b/test/version.test.js
nnew file mode 100644
iindex 0000000..a41062f
---- /dev/null
++++ b/test/version.test.js
@@@ -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, '\\.')},`));
++ }],
++]);
ddiff --git a/types/auth-browser.d.ts b/types/auth-browser.d.ts
iindex d04efc1..95b919d 100644
---- a/types/auth-browser.d.ts
++++ b/types/auth-browser.d.ts
@@@ -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;
ddiff --git a/types/auth.d.ts b/types/auth.d.ts
iindex f33bb3a..c73cbd3 100644
---- a/types/auth.d.ts
++++ b/types/auth.d.ts
@@@ -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>;
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
nnew file mode 100644
iindex 0000000..09557eb
---- /dev/null
++++ b/types/bundle.d.ts
@@@ -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;
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex 3390d3d..eb83844 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -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;
ddiff --git a/types/core.d.ts b/types/core.d.ts
iindex 59791b8..d5c0764 100644
---- a/types/core.d.ts
++++ b/types/core.d.ts
@@@ -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 {
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex 24c3f6b..6a91e8a 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -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 }>;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex b324872..4378cc7 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -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';
ddiff --git a/types/jobs.d.ts b/types/jobs.d.ts
nnew file mode 100644
iindex 0000000..9a0ded0
---- /dev/null
++++ b/types/jobs.d.ts
@@@ -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[];
ddiff --git a/types/projects.d.ts b/types/projects.d.ts
nnew file mode 100644
iindex 0000000..2ce7433
---- /dev/null
++++ b/types/projects.d.ts
@@@ -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;
ddiff --git a/types/realtime.d.ts b/types/realtime.d.ts
iindex 470b3b2..99d9cde 100644
---- a/types/realtime.d.ts
++++ b/types/realtime.d.ts
@@@ -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;
ddiff --git a/types/testing.d.ts b/types/testing.d.ts
iindex 6dfd7a3..14b4a68 100644
---- a/types/testing.d.ts
++++ b/types/testing.d.ts
@@@ -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[];