openvibers/openvibe.sdk Commits
0.3.1: developer-app events and Media sandbox tenants in the mock as production has them
Changes
@@@ -12,6 +12,6 @@ jobs:with:node-version: 22.22.1 # production services run Node 22# types.test.js compares types/contracts.d.ts with the pinned Contracts release-- - run: git clone --depth 1 --branch v0.26.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts++ - run: git clone --depth 1 --branch v0.28.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts- run: npm test # includes the pack test (npm pack + install into a temp project)- run: node scripts/esm.js --check@@@ -3,6 +3,53 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.3.1 (2026-09-23)++++The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or++Media. Contract types re-copied from openvibe-contracts v0.28.0 (adds `codes.app-manifest@1` as++`AppManifest`); CI checks them against that tag.++++- **Events, developer apps.** New `projectKey(projectId)` (`prj_01JAB…` -> `p01jab…`),++ `appSource(appId)` (`app_01JAB…` -> `app-01jab…`) and `createAppEvents(client, { projectId,++ appId, onBehalfOf? })`: the EventsClient calls with project-relative types and patterns++ (`order.shipped` -> `app.<project_key>.order.shipped`, `*` -> `app.<project_key>.*`), the app's++ source, and the app (or the `onBehalfOf` person) as actor and default subject; `pull`/`iterate`++ take `platformTopics` for public first-party events; subscriptions and checkpoints are scoped the++ same way. Types and ESM included.++- **Media.** For a sandbox file (`sandbox: true` from Media) the client now sets `public_url: null`++ and adds `signed_url` (Media's signed, expiring `url`); it used to put the signed URL in++ `public_url`. Media's own fields (`url`, `sandbox`, `url_expires_at`, `app_id`) are passed++ through unchanged. **Behaviour change** for code that read `public_url` of sandbox files.++- **Mock Events** plays OpenVibe.Events' developer-app rules: app tokens judged on++ `events.app.publish | read | subscribe` only (sandbox accepted there); `app.<project_key>.*`++ types, `app-<ulid>` source, actor the app or its `on_behalf_of` user; reads, `get`, checkpoints++ and subscriptions limited to the own project in the token's env plus public first-party events;++ https endpoints that are not loopback, private or local names; events stored with `project_id`++ and `env`, deliveries never cross projects or environments; first-party readers and subscribers++ see no sandbox events and app events only through `app.*`; realtime streams no app events and++ refuses app tokens. Envelopes are checked against the envelope patterns (3+ segment types,++ source syntax), topic patterns against Events' syntax, and first-party services cannot publish++ `app.*`. Not modelled: per-project quotas, revocation, DNS resolution of endpoints.++- **Mock Media** follows Media's tenantAuth: `media.object.upload` uploads and deletes,++ `media.object.read` lists and gets, for app keys, service tokens and app tokens alike (service++ tokens used to be upload-only). A developer app reaches only `/api/v1/<its project_id>/files`;++ its token's env picks the tenant, `prj_…` or `prj_…-sandbox` (created on first use, 1 GB and++ 100 MB, `mediaQuotaMb`), and Media's tenant-tagged keys are used. Sandbox app tokens are accepted++ there without `acceptSandbox`, as in production (**behaviour change**: the mock used to refuse++ them). Sandbox files answer with `sandbox: true`, a signed `url` and `url_expires_at`; the new++ `GET /f/:key` serves files, sandbox ones only with a valid signature (404 otherwise). Projects no++ longer get an API-key tenant in `mediaApps`.++- **Mock Tools** answers `/api/v1/jobs` at `https://img.openvibe.tools`,++ `https://audio.openvibe.tools` and `https://docs.openvibe.tools` as well as `origins.tools`++ (`platform.toolsOrigins`, `DEFAULT_TOOLS_SATELLITES`, option `toolsSatellites`); a job is found++ only on the origin that created it.++- **Mock catalog and descriptor** follow openvibe-contracts v0.28.0: `DEFAULT_APP_CATALOG` is its++ public + active capabilities (adds `events.app.*`, `codes.release.*`, `community.*`, `vip.*`),++ and the default `contractsVersion` is 0.28.0.++- Mock state: `state.events[]` entries carry `project_id` and `env`, `state.mediaTenants` is new,++ checkpoints are stored as `{ cursor, updated_at }`; `publishEvent()` stores an event of publisher++ `app:<id>` as that registered app's (project and env), or takes `{ projectId, env }`.++## 0.3.0 (2026-09-23)Developer apps (Network developer projects, ADR-014) and the gaps OpenVibe.Examples found.@@@ -2,17 +2,17 @@> Supported browser and server clients for the OpenVibe platform.--**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.++**Status:** alpha, v0.3.1 (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; OpenVibe.Examples uses 0.3.0.**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.3.0++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1```--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).++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.28.0, checked in CI).## Quick start@@@ -93,6 +93,21 @@ const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.pfile.public_url; // https://openvibe.media/f/<key>```++The token's `env` picks the tenant: a sandbox app's files go to the project's sandbox tenant (`app_id: '<prj_…>-sandbox'`) and are never served publicly. Media answers them with `sandbox: true`, a signed, expiring `url` and `url_expires_at`; the client adds `signed_url` (the same URL) and sets `public_url` to `null`. Uploading and deleting need `media.object.upload`, listing and getting `media.object.read`.++++App events (`events.app.publish | read | subscribe`) live under the project's own topic `app.<project_key>.*` (`project_key` = `p` + the lowercased project ULID) with `source: app-<lowercased app ULID>`. `createAppEvents()` fills that in, and scopes pulls and subscriptions to the project:++++```js++const { createAppEvents } = require('openvibe-sdk/events');++const events = createAppEvents(client, { projectId: unverifiedClaims.project_id, appId: process.env.OV_CLIENT_ID });++await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } });++// -> app.p01k5….order.shipped, source app-01k5…, actor { type: 'app', id: 'app_…' } (onBehalfOf: the person instead)++const page = await events.pull({ topic: 'order.*', platformTopics: ['live.stream.*'], afterSeq }); // + public first-party events++await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/openvibe' }); // public https only++```++++Events keeps sandbox and production apart: a sandbox app sees and receives only its project's sandbox events (and public first-party ones), a production app only production ones. Realtime (SSE) never streams app events; pull or subscribe. `projectKey(projectId)` and `appSource(appId)` are exported for code that builds envelopes itself.++"Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`).```js@@@ -129,7 +144,7 @@ for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.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.++`test/testing.test.js`, `test/apps.test.js`, `test/app-events.test.js`, `test/mock-services.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@@@ -140,7 +155,7 @@ const res = await jobs.file(job.id, 0);| `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/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` || `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)` |@@@ -182,7 +197,7 @@ 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`.--- `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.++- `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts (v0.26 and later) `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.@@@ -205,13 +220,13 @@ const client = createClient({ fetch: platform.fetch, tokenProvider: createServicThe 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; 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.++- **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. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).++- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `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`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.++- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.++- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: 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).++Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent(envelope, publisher, { projectId, env })` (a registered app's `app:<id>` publisher implies them), `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@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.3.0",++ "version": "0.3.1","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","updated": "2026-09-23"}@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.1, 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 */@@@ -36,7 +36,7 @@ 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';++const SDK_VERSION = '0.3.1';module.exports = {createClient,@@@ -1195,10 +1195,16 @@ function (module, exports) {* 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).++ * secret), a Network service token for the app's namespace, or a developer app token on its++ * project's tenant (`app` = the project id, prj_…). Tokens need media.object.upload to upload and++ * delete, media.object.read to list and get. 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.++ *++ * Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),++ * whose files are never served publicly: Media answers them with `sandbox: true`, a signed,++ * expiring `url` and `url_expires_at`. The client adds `signed_url` (that URL) and sets++ * `public_url` to null for them; other files get `public_url` (the absolute `url`).*/const { isOpenVibeError } = __load(3);const { paginate, offsetPager } = __load(7);@@@ -1250,11 +1256,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOreturn 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 withPublic = (meta) => {++ if (!meta || !meta.url) return meta;++ if (meta.sandbox) return { ...meta, public_url: null, signed_url: urls.absolute(meta.url) };++ return { ...meta, public_url: urls.absolute(meta.url) };++ };const files = {/**-- * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.++ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }++ * (sandbox files: { sandbox: true, url, url_expires_at, signed_url, public_url: null, … }).* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.*/async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {@@@ -3,5 +3,6 @@ import cjs from '../src/events.js';export const {createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,++ projectKey, appSource, createAppEvents,} = cjs;export default cjs;@@@ -2,6 +2,6 @@import cjs from '../src/testing/index.js';export const {-- createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG,++ createMockPlatform, DEFAULT_ORIGINS, DEFAULT_TOOLS_SATELLITES, DEFAULT_APP_CATALOG,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.3.0",++ "version": "0.3.1","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.3.0",++ "version": "0.3.1","license": "MIT","devDependencies": {"better-sqlite3": "^11.10.0"@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.3.0",++ "version": "0.3.1","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",@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.3.0';++const SDK_VERSION = '0.3.1';module.exports = {createClient,@@@ -7,6 +7,10 @@* events.subscription.manage subscriptions.** events.delivery.admin deliveries(), replay()*++ * Developer apps (Network ADR-014) use the same routes with an app token and the capabilities++ * events.app.publish | events.app.read | events.app.subscribe; createAppEvents() fills in what++ * Events requires of them (see below).++ ** Browsers use openvibe-sdk/realtime (SSE) instead.*/const crypto = require('node:crypto');@@@ -155,6 +159,118 @@ function createEventsClient(client, { source, baseUrl } = {}) {};}++// ── Developer apps ───────────────────────────────────────────++//++// OpenVibe.Events rules for an app token (sub app:app_<ULID>, project_id prj_<ULID>, env):++// event_type app.<project_key>.<name…> project_key = 'p' + the project's ULID in lowercase++// source app-<the app's ULID in lowercase>++// actor { type: 'app', id: app_… } or the user the token acts for (on_behalf_of)++// reads own project in the token's env + public first-party events; every topic pattern++// starts with a literal segment and app.* patterns name the own project_key++// webhooks https endpoints on public addresses only++// Realtime (SSE) never streams app events.++++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const PROJECT_ID_RE = new RegExp(`^prj_(${ULID})$`);++const APP_ID_RE = new RegExp(`^(?:app:)?app_(${ULID})$`);++const SEGMENT_RE = /^[a-z0-9_]+$/;++++/** 'prj_01JAB…' -> 'p01jab…' (the second segment of the project's event types); null if not a project id. */++function projectKey(projectId) {++ const m = PROJECT_ID_RE.exec(String(projectId || ''));++ return m ? `p${m[1].toLowerCase()}` : null;++}++++/** 'app_01JAB…' (or 'app:app_01JAB…') -> 'app-01jab…' (the app's event source); null if not an app id. */++function appSource(appId) {++ const m = APP_ID_RE.exec(String(appId || ''));++ return m ? `app-${m[1].toLowerCase()}` : null;++}++++/**++ * An events client scoped to one developer app of one project:++ *++ * const events = createAppEvents(client, { projectId: 'prj_…', appId: 'app_…' });++ * await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload });++ * // -> app.<project_key>.order.shipped, source app-<ulid>, actor { type: 'app', id: 'app_…' }++ * await events.pull({ topic: 'order.*' }) // app.<project_key>.order.*++ * await events.pull({ topic: '*', platformTopics: ['live.stream.*'] }) // + public first-party events++ * await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/ov' })++ *++ * Event types and topic patterns are relative to the project (`order.shipped`, `order.*`, `*`);++ * a full `app.<project_key>.…` name is kept as it is, and a name under another project's key++ * throws. `onBehalfOf` (a usr_… id, the token's on_behalf_of) makes that person the default actor.++ * `subject` defaults to the app. First-party topics go in `platformTopics` (reads only).++ */++function createAppEvents(client, { projectId, appId, onBehalfOf, baseUrl } = {}) {++ const key = projectKey(projectId);++ if (!key) throw new TypeError('createAppEvents: projectId must be prj_<ULID>');++ const source = appSource(appId);++ if (!source) throw new TypeError('createAppEvents: appId must be app_<ULID>');++ const app = String(appId).replace(/^app:/, '');++ const prefix = `app.${key}.`;++ const events = createEventsClient(client, { source, baseUrl });++++ /** A project-relative name or pattern -> the full one. */++ function topic(name) {++ const s = String(name == null ? '' : name).trim();++ if (!s) throw new TypeError('an event type or topic pattern is required');++ if (s === `app.${key}` || s.startsWith(prefix)) return s;++ if (s === 'app' || s.startsWith('app.')) {++ const other = s.split('.')[1];++ if (other && /^p[0-9a-z]{26}$/.test(other)) throw new TypeError(`${s} names another project (this app's types are ${prefix}*)`);++ }++ return `${prefix}${s}`;++ }++ const topics = (t) => (Array.isArray(t) ? t : String(t).split(',')).map((x) => x.trim()).filter(Boolean).map(topic);++ const readTopics = (t, platformTopics = []) => {++ const extra = (Array.isArray(platformTopics) ? platformTopics : [platformTopics]).filter(Boolean).map(String);++ for (const p of extra) if (p === '*' || p.startsWith('*') || p === 'app' || p.startsWith('app.')) throw new TypeError(`platformTopics are first-party patterns with a literal first segment, not ${p}`);++ return [...topics(t), ...extra];++ };++++ function prepare(envelope, opts) {++ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new TypeError('an event envelope must be an object');++ if (envelope.source && envelope.source !== source) throw new TypeError(`source must be ${source}`);++ const type = topic(envelope.event_type);++ if (!type.slice(prefix.length).split('.').every((seg) => SEGMENT_RE.test(seg))) throw new TypeError(`${type}: segments are [a-z0-9_]+`);++ const actor = envelope.actor || (onBehalfOf ? { type: 'user', id: onBehalfOf } : { type: 'app', id: app });++ return events.prepare({ ...envelope, event_type: type, source, actor, subject: envelope.subject || { type: 'app', id: app } }, opts);++ }++++ return {++ projectId,++ appId: app,++ projectKey: key,++ source,++ prefix,++ topic,++ prepare,++ /** Same as EventsClient.publish, with the app's type prefix, source and actor filled in. */++ publish(input, opts) {++ return events.publish(Array.isArray(input) ? input.map((e) => prepare(e)) : prepare(input), opts);++ },++ pull: ({ topic: t = '*', platformTopics, ...rest } = {}) => events.pull({ ...rest, topic: readTopics(t, platformTopics) }),++ iterate: ({ topic: t = '*', platformTopics, ...rest } = {}) => events.iterate({ ...rest, topic: readTopics(t, platformTopics) }),++ get: (eventId) => events.get(eventId),++ getCheckpoint: (t) => events.getCheckpoint(topic(t)),++ setCheckpoint: (t, cursor) => events.setCheckpoint(topic(t), cursor),++ subscriptions: {++ create: ({ topicPattern = '*', ...rest } = {}) => events.subscriptions.create({ ...rest, topicPattern: topic(topicPattern) }),++ list: () => events.subscriptions.list(),++ get: (id) => events.subscriptions.get(id),++ disable: (id) => events.subscriptions.disable(id),++ enable: (id) => events.subscriptions.enable(id),++ },++ subscribe: ({ topicPattern = '*', ...rest } = {}) => events.subscriptions.create({ ...rest, topicPattern: topic(topicPattern) }),++ /** The underlying EventsClient (no scoping). */++ events,++ };++}++const { createOutbox, createInbox } = require('./outbox');--module.exports = { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox };++module.exports = {++ createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,++ projectKey, appSource, createAppEvents,++};@@@ -3,10 +3,16 @@* 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).++ * secret), a Network service token for the app's namespace, or a developer app token on its++ * project's tenant (`app` = the project id, prj_…). Tokens need media.object.upload to upload and++ * delete, media.object.read to list and get. 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.++ *++ * Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),++ * whose files are never served publicly: Media answers them with `sandbox: true`, a signed,++ * expiring `url` and `url_expires_at`. The client adds `signed_url` (that URL) and sets++ * `public_url` to null for them; other files get `public_url` (the absolute `url`).*/const { isOpenVibeError } = require('./core/errors');const { paginate, offsetPager } = require('./core/paginate');@@@ -58,11 +64,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicOreturn 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 withPublic = (meta) => {++ if (!meta || !meta.url) return meta;++ if (meta.sandbox) return { ...meta, public_url: null, signed_url: urls.absolute(meta.url) };++ return { ...meta, public_url: urls.absolute(meta.url) };++ };const files = {/**-- * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.++ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }++ * (sandbox files: { sandbox: true, url, url_expires_at, signed_url, public_url: null, … }).* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.*/async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {nnew file mode 100644@@@ -0,0 +1,109 @@++'use strict';++/**++ * OpenVibe.Events' developer-app rules for the mock platform (Node only), as Events implements++ * them (server/apps.js, server/api/*, server/egress.js; capabilities events.app.publish | read |++ * subscribe in openvibe-contracts v0.28.0):++ *++ * - an app token (sub app:app_<ULID>) is judged on the events.app.* capability of the route only;++ * sandbox app tokens are accepted on these routes (and nowhere else)++ * - publish: event_type app.<project_key>.<name…>, source app-<lowercased app ULID>, actor the++ * app or the user the token acts for; stored with the token's project_id and env++ * - reads (pull, get, checkpoints): the app's project in the token's env + public first-party++ * events; every pattern starts with a literal segment, app.* patterns name the own project_key++ * - subscriptions: the same scope, https endpoints that are not loopback/private/local names++ * - first-party readers and subscribers never see sandbox events, and see app events only++ * through a pattern that starts with `app.`; realtime streams neither++ */++const net = require('node:net');++const { projectKey, appSource } = require('../events');++++const ULID = '[0-9A-HJKMNP-TV-Z]{26}';++const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);++const ENVS = ['sandbox', 'production'];++++/** Events' topic pattern syntax: dot-separated [a-z0-9_]+ or `*` (one or more segments), no `*.*`. */++const PATTERN_RE = /^(\*|[a-z0-9_]+)(\.(\*|[a-z0-9_]+))*$/;++const isValidPattern = (p) => typeof p === 'string' && p.length > 0 && p.length <= 200 && PATTERN_RE.test(p) && !p.includes('*.*');++++/** The app principal for verified claims, or { error }. */++function appPrincipal(claims) {++ const m = APP_SUB_RE.exec(String(claims && claims.sub));++ if (!m) return { error: 'not an app token' };++ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };++ const key = projectKey(claims.project_id);++ if (!key) return { error: 'app token without a project_id' };++ const env = claims.env === undefined ? 'production' : claims.env;++ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };++ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) return { error: 'project_id is not in the token namespaces' };++ return {++ kind: 'app', sub: claims.sub, appId: `app_${m[1]}`, projectId: claims.project_id, projectKey: key,++ source: appSource(`app_${m[1]}`), prefix: `app.${key}.`, env,++ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,++ };++}++++/** null when an app may use this pattern, else the reason. */++function patternScopeError(pattern, app) {++ if (!isValidPattern(pattern)) return 'topic patterns are dot-separated segments of [a-z0-9_] or *';++ const [first, second] = pattern.split('.');++ if (first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';++ if (first === 'app' && second !== app.projectKey) return `app.* patterns must name your project: app.${app.projectKey}.*`;++ return null;++}++++/** `stored` is the mock's { seq, event, project_id, env }. */++function visibleToApp(stored, scope) {++ if (stored.project_id) return stored.project_id === scope.projectId && (stored.env || 'production') === scope.env;++ return stored.event.visibility === 'public' && (stored.env || 'production') === 'production';++}++++function serviceSees(pattern, stored) {++ if ((stored.env || 'production') !== 'production') return false;++ return !(stored.project_id && !pattern.startsWith('app.'));++}++++// ── Endpoints (the syntax half of Events' SSRF guard; the mock does no DNS) ──++++// Events' non-public ranges. Where Events looks inside IPv4-mapped, NAT64 and 6to4 addresses, the++// mock refuses those ranges outright (stricter, never looser).++const BLOCKED = new net.BlockList();++for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],++ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],++ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) BLOCKED.addSubnet(a, p, 'ipv4');++for (const [a, p] of [['::', 128], ['::1', 128], ['::', 96], ['::ffff:0:0', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],++ ['2002::', 16], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) BLOCKED.addSubnet(a, p, 'ipv6');++++function isPublicLiteral(host) {++ const family = net.isIP(host);++ return Boolean(family) && !BLOCKED.check(host, family === 4 ? 'ipv4' : 'ipv6');++}++++/** { ok, url } or { ok: false, reason } for an app subscription endpoint. */++function checkAppEndpoint(endpoint) {++ if (typeof endpoint !== 'string' || endpoint.length > 2048) return { ok: false, reason: 'endpoint must be a URL of at most 2048 characters' };++ let url;++ try { url = new URL(endpoint); } catch { return { ok: false, reason: 'not a URL' }; }++ if (url.protocol !== 'https:') return { ok: false, reason: 'app endpoints must use https' };++ if (url.username || url.password) return { ok: false, reason: 'credentials in the URL are not allowed' };++ let host = url.hostname.toLowerCase();++ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);++ if (host.endsWith('.')) return { ok: false, reason: 'trailing-dot hostnames are not allowed' };++ if (net.isIP(host)) return isPublicLiteral(host) ? { ok: true, url } : { ok: false, reason: `${host} is not a public address` };++ if (!host.includes('.') || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {++ return { ok: false, reason: `${host} is not a public hostname` };++ }++ return { ok: true, url };++}++++/** Events' retry_policy check: { ok, value } or { ok: false, reason }. */++function checkRetryPolicy(p) {++ if (p == null) return { ok: true, value: null };++ if (typeof p !== 'object' || Array.isArray(p)) return { ok: false, reason: 'retry_policy must be an object' };++ for (const k of Object.keys(p)) if (!['max_attempts', 'backoff_ms'].includes(k)) return { ok: false, reason: `unknown retry_policy field ${k}` };++ if (p.max_attempts !== undefined && (!Number.isInteger(p.max_attempts) || p.max_attempts < 1 || p.max_attempts > 20)) return { ok: false, reason: 'max_attempts must be 1..20' };++ if (p.backoff_ms !== undefined && (!Array.isArray(p.backoff_ms) || !p.backoff_ms.length || p.backoff_ms.length > 20++ || !p.backoff_ms.every((n) => Number.isInteger(n) && n >= 0 && n <= 86400000))) return { ok: false, reason: 'backoff_ms must be 1..20 integers between 0 and 86400000' };++ return { ok: true, value: Object.keys(p).length ? p : null };++}++++module.exports = { appPrincipal, patternScopeError, visibleToApp, serviceSees, isValidPattern, checkAppEndpoint, checkRetryPolicy, APP_SUB_RE };@@@ -6,7 +6,8 @@** 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++ * DEV_SANDBOX_AUDIENCES); Media and Events accept them on their app routes as in production,++ * other receivers 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@@@ -22,10 +23,17 @@ 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). */++/**++ * Capabilities that are public + active in openvibe-contracts v0.28.0, the ones Network grants to++ * apps (network.project.manage is public but planned, so not grantable).++ */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',++ 'codes.release.manage', 'codes.release.read', 'community.paste.create', 'community.post.create',++ 'events.app.publish', 'events.app.read', 'events.app.subscribe',++ 'games.mod.read', 'games.prop.place', 'games.world.announce',++ 'media.object.read', 'media.object.upload',++ 'tools.job.cancel', 'tools.job.create', 'tools.job.read',++ 'vip.perk.list', 'vip.plan.list',];function createDeveloper(ctx) {@@@ -66,8 +74,6 @@ function createDeveloper(ctx) {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;}@@@ -16,9 +16,15 @@* /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 })++ * /api/v1/checkpoints, /realtime/stream (SSE); deliverEvents() plays the delivery worker.++ * Developer apps as in Events (events.app.publish|read|subscribe, see ./apps.js):++ * app.<project_key>.* types, source app-<ulid>, actor the app or its on_behalf_of user,++ * reads scoped to the own project + public first-party events, https endpoints,++ * sandbox and production never mixed. Not modelled: app quotas, revocation, DNS checks.++ * Media /api/v1/:app/files and /f/:key. Developer apps reach /api/v1/<project_id>/files only:++ * production tenant prj_…, sandbox tenant prj_…-sandbox whose files have signed URLs only++ * Tools /api/v1/jobs (with { jobs: true }) at origins.tools and the img., audio. and docs.++ * satellites (platform.toolsOrigins)* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real* 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@@@ -26,8 +32,9 @@*/const crypto = require('node:crypto');const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');--const { createDeveloper, DEFAULT_APP_CATALOG } = require('./developer');++const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');const { createJobsService } = require('./jobs');++const appRules = require('./apps');const DEFAULT_ORIGINS = {network: 'https://openvibe.network',@@@ -37,10 +44,14 @@ const DEFAULT_ORIGINS = {tools: 'https://openvibe.tools',};++/** The Tools satellites that run jobs (/api/v1/jobs); the mock answers them too. */++const DEFAULT_TOOLS_SATELLITES = ['https://img.openvibe.tools', 'https://audio.openvibe.tools', 'https://docs.openvibe.tools'];++function createMockPlatform(opts = {}) {const origins = { ...DEFAULT_ORIGINS, ...opts.origins };++ const toolsOrigins = [origins.tools, ...(opts.toolsSatellites || DEFAULT_TOOLS_SATELLITES)].map((o) => new URL(o).origin);const issuer = opts.issuer || origins.network;-- const contractsVersion = opts.contractsVersion || '0.26.0';++ const contractsVersion = opts.contractsVersion || '0.28.0';const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });const kid = 'mock-1';const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };@@@ -56,7 +67,8 @@ function createMockPlatform(opts = {}) {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 files = new Map(); // `${tenant}|${key}` -> meta (tenant: app id, prj_…, prj_…-sandbox)++ const mediaTenants = new Map(); // developer-project tenants, created on first useconst 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: [], deliveries: [] };@@@ -340,42 +352,100 @@ function createMockPlatform(opts = {}) {// ── Events ──────────────────────────────────────────────function visible(viewer, e) {++ if (e.project_id || (e.env || 'production') !== 'production') return false; // never streamedif (e.event.visibility === 'public') return true;if (viewer.kind === 'service') return true;return e.event.visibility === 'subject' && viewer.kind === 'user' && (e.event.actor.id === viewer.subject || (e.event.subject.type === 'user' && e.event.subject.id === viewer.subject));}-- function publishEvent(env, publisher) {++ function publishEvent(env, publisher, { projectId = null, env: environment = 'production' } = {}) {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: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher };++ const stored = { seq: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher, project_id: projectId, env: environment };events.push(stored);for (const s of streams) s.push(stored);return { event_id: env.event_id, seq: stored.seq, duplicate: false };}++ /**++ * Events' appOrService guard: an app token (sub app:…) is judged on `appCap` only (sandbox++ * accepted) and becomes { kind: 'app', … }; anything else must hold `serviceCap`.++ */++ function eventsPrincipal(req, serviceCap, appCap, { requireService = false } = {}) {++ const auth = req.headers.get('authorization') || '';++ const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;++ if (claims && /^app:/.test(String(claims.sub))) {++ if (!(claims.aud || []).includes('openvibe.events')) return { res: problem(401, 'token.wrong_audience', 'not for openvibe.events') };++ const app = appRules.appPrincipal(claims);++ if (app.error) return { res: problem(401, 'token.invalid_claims', app.error) };++ if (!appCap || !hasCap(claims, appCap)) return { res: problem(403, 'capability.denied', appCap ? `${appCap} not granted` : 'app tokens are not accepted on this route') };++ return { principal: app, consumer: app.sub };++ }++ const who = principal(req, 'openvibe.events', serviceCap);++ if (who.res) return who;++ const service = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String(who.claims.sub));++ if (requireService && !service) return { res: problem(403, 'capability.denied', 'only service principals may do this') };++ return { principal: { kind: 'service', sub: who.claims.sub, service: service ? service[1] : null }, consumer: who.service };++ }++ const hasCap = (claims, id) => (claims.cap || []).some((c) => c === id || (c.endsWith('.*') && id.startsWith(c.slice(0, -1))));++ const scopeError = (p, patterns) => {++ if (p.kind !== 'app') return null;++ for (const x of patterns) { const err = appRules.patternScopeError(x, p); if (err) return `${x}: ${err}`; }++ return null;++ };++ /** Does this reader see this stored event (topic match aside)? */++ const readable = (p, stored, pattern) => (p.kind === 'app' ? appRules.visibleToApp(stored, p) : appRules.serviceSees(pattern, stored));++++ function checkPublish(e, p) {++ if (!e || typeof e !== 'object' || Array.isArray(e) || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(e.event_id || '') || typeof e.event_type !== 'string'++ || !/^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/.test(e.event_type) || !e.actor || !e.subject || !e.timestamp || !/^[a-z][a-z0-9-]{1,39}$/.test(String(e.source))) {++ return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');++ }++ if (p.kind === 'app') {++ if (e.source !== p.source) return problem(403, 'events.source_mismatch', `source must be your app's "${p.source}", not "${e.source}"`);++ if (!e.event_type.startsWith(p.prefix)) return problem(403, 'events.type_not_allowed', `an app may publish ${p.prefix}<name> only, not ${e.event_type}`);++ const a = e.actor || {};++ if (!((a.type === 'app' && a.id === p.appId) || (p.onBehalfOf && a.type === 'user' && a.id === p.onBehalfOf))) {++ return problem(403, 'events.actor_mismatch', `actor must be { type: 'app', id: '${p.appId}' }${p.onBehalfOf ? ' or the user the token acts for' : ''}`);++ }++ return null;++ }++ if (e.source !== p.service) return problem(403, 'events.source_mismatch', `source "${e.source}" is not the calling service "${p.service}"`);++ if (e.event_type.startsWith('app.')) return problem(403, 'events.type_not_allowed', `${e.source} may not publish app.* events`);++ return null;++ }++async function eventsService(req, url) {const path = url.pathname;let r;if (path === '/realtime/stream' && req.method === 'GET') return realtimeStream(req, url);if (path === '/api/v1/events' && req.method === 'POST') {-- const who = principal(req, 'openvibe.events', 'events.event.publish');++ const who = eventsPrincipal(req, 'events.event.publish', 'events.app.publish', { requireService: true });if (who.res) return who.res;++ const p = who.principal;const body = await req.json().catch(() => null);-- const batch = body && Array.isArray(body.events) && body.event_id === undefined;++ const batch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;const items = batch ? body.events : [body];-- for (const e of items) {-- if (!e || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(e.event_id || '') || !e.event_type || !e.actor || !e.subject || !e.timestamp) return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');-- if (e.source !== who.service) return problem(403, 'events.source_mismatch', `source "${e.source}" is not the calling service "${who.service}"`);++ if (batch && !items.length) return problem(400, 'events.bad_request', 'events must not be empty');++ for (const [i, e] of items.entries()) {++ const bad = checkPublish(e, p);++ if (bad) return bad;++ if (items.findIndex((x) => x.event_id === e.event_id) !== i) return problem(422, 'events.invalid_envelope', `events[${i}]: event_id repeated within the batch`);}-- const results = items.map((e) => publishEvent(e, who.claims.sub));++ const meta = p.kind === 'app' ? { projectId: p.projectId, env: p.env } : {};++ const results = items.map((e) => publishEvent(e, p.sub, meta));return json(results.some((x) => !x.duplicate) ? 201 : 200, batch ? { results } : results[0]);}if (path === '/api/v1/events' && req.method === 'GET') {-- const who = principal(req, 'openvibe.events', 'events.event.read');++ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');if (who.res) return who.res;-- const pats = (url.searchParams.get('topic') || '*').split(',').map(topicRegex);++ const patterns = (url.searchParams.get('topic') || '*').split(',').map((x) => x.trim()).filter(Boolean);++ if (!patterns.length || patterns.length > 20 || !patterns.every(appRules.isValidPattern)) return problem(400, 'events.bad_topic', 'topic must be 1..20 comma-separated patterns');++ const scopeErr = scopeError(who.principal, patterns);++ if (scopeErr) return problem(403, 'events.topic_not_allowed', scopeErr);const after = Number(url.searchParams.get('after_seq') || 0);const limit = Number(url.searchParams.get('limit') || 100);++ if (!Number.isInteger(after) || after < 0 || !Number.isInteger(limit) || limit < 1 || limit > 1000) return problem(400, 'events.bad_request', 'after_seq must be >= 0 and limit 1..1000');++ const pats = patterns.map((x) => [x, topicRegex(x)]);const out = [];const page = {};let from = after;@@@ -388,50 +458,80 @@ function createMockPlatform(opts = {}) {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 (pats.some(([x, re]) => re.test(e.event.event_type) && readable(who.principal, e, x))) out.push({ seq: e.seq, event: e.event });}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');++ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');if (who.res) return who.res;const e = events.find((x) => x.event.event_id === decodeURIComponent(r[1]));-- return e ? json(200, { seq: e.seq, event: e.event }) : problem(404, 'events.not_found', 'no such event');++ const ok = e && (who.principal.kind === 'app' ? appRules.visibleToApp(e, who.principal) : (e.env || 'production') === 'production');++ return ok ? json(200, { seq: e.seq, event: e.event }) : problem(404, 'events.not_found', 'no such event (or pruned by retention)');}if (path === '/api/v1/checkpoints') {-- const who = principal(req, 'openvibe.events', 'events.event.read');++ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');if (who.res) return who.res;++ const b = req.method === 'GET' ? {} : await req.json().catch(() => ({}));++ const topic = req.method === 'GET' ? url.searchParams.get('topic') || '' : b.topic;++ if (!appRules.isValidPattern(topic)) return problem(400, 'events.bad_request', 'topic (pattern) is required');++ if (req.method !== 'GET' && (!Number.isInteger(b.cursor) || b.cursor < 0)) return problem(400, 'events.bad_request', 'topic (pattern) and cursor (integer >= 0) are required');++ const scopeErr = scopeError(who.principal, [topic]);++ if (scopeErr) return problem(403, 'events.topic_not_allowed', scopeErr);++ const k = `${who.consumer}|${topic}`;if (req.method === 'GET') {-- const topic = url.searchParams.get('topic');-- return json(200, { consumer: who.service, topic, cursor: checkpoints.get(`${who.service}|${topic}`) || 0 });++ const cp = checkpoints.get(k);++ return json(200, { consumer: who.consumer, topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });}-- const b = await req.json().catch(() => ({}));-- checkpoints.set(`${who.service}|${b.topic}`, b.cursor);-- return json(200, { consumer: who.service, topic: b.topic, cursor: b.cursor });++ const cp = { cursor: b.cursor, updated_at: new Date().toISOString() };++ checkpoints.set(k, cp);++ return json(200, { consumer: who.consumer, topic, ...cp });}if (path.startsWith('/api/v1/subscriptions')) {-- const who = principal(req, 'openvibe.events', 'events.subscription.manage');++ const who = eventsPrincipal(req, 'events.subscription.manage', 'events.app.subscribe', { requireService: true });if (who.res) return who.res;-- const view = (s, withSecret) => ({ id: s.id, consumer: s.consumer, topic_pattern: s.topic_pattern, endpoint: s.endpoint, enabled: s.enabled, retry_policy: s.retry_policy, ...(withSecret ? { secret: s.secret } : {}) });++ const app = who.principal.kind === 'app' ? who.principal : null;++ const view = (s, withSecret) => ({++ id: s.id, consumer: s.consumer, topic_pattern: s.topic_pattern, endpoint: s.endpoint, enabled: s.enabled, retry_policy: s.retry_policy,++ created_at: s.created_at, updated_at: s.updated_at, ...(s.project_id ? { project_id: s.project_id, env: s.env } : {}), ...(withSecret ? { secret: s.secret } : {}),++ });if (path === '/api/v1/subscriptions' && req.method === 'POST') {-- const b = await req.json().catch(() => ({}));-- 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 b = await req.json().catch(() => ({})) || {};++ const pattern = b.topic_pattern ?? b.topic;++ if (!appRules.isValidPattern(pattern)) return problem(422, 'events.bad_topic', 'topic_pattern must be dot-separated segments of [a-z0-9_] or *');++ if (app) {++ const err = appRules.patternScopeError(pattern, app);++ if (err) return problem(403, 'events.topic_not_allowed', err);++ }++ let endpoint;++ if (app) {++ const ep = appRules.checkAppEndpoint(b.endpoint);++ if (!ep.ok) return problem(422, 'events.endpoint_not_allowed', ep.reason);++ endpoint = ep.url.toString();++ } else {++ try { endpoint = new URL(b.endpoint).toString(); } catch { return problem(422, 'events.endpoint_not_allowed', 'endpoint must be an http(s) URL'); }++ }++ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) return problem(422, 'events.bad_request', 'secret must be a string of 32..256 characters');++ const rp = appRules.checkRetryPolicy(b.retry_policy);++ if (!rp.ok) return problem(422, 'events.bad_request', rp.reason);++ const dup = [...subscriptions.values()].find((s) => s.consumer === who.consumer && s.topic_pattern === pattern && s.endpoint === endpoint);++ if (dup) return problem(409, 'events.subscription_exists', 'this consumer already subscribes that endpoint to that topic', { subscription_id: dup.id });++ const at = new Date().toISOString();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')}`,++ id: `sub_${ulid()}`, consumer: who.consumer, topic_pattern: pattern, endpoint, enabled: true, retry_policy: rp.value,++ secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`, created_at: at, updated_at: at,++ project_id: app ? app.projectId : null, env: app ? app.env : 'production',cursor: lastSeq, attempts: new Map(), // deliverEvents(): events published after the subscription};subscriptions.set(s.id, s);return json(201, view(s, true));}-- if (path === '/api/v1/subscriptions' && req.method === 'GET') return json(200, { subscriptions: [...subscriptions.values()].filter((s) => s.consumer === who.service).map((s) => view(s)) });++ if (path === '/api/v1/subscriptions' && req.method === 'GET') return json(200, { subscriptions: [...subscriptions.values()].filter((s) => s.consumer === who.consumer).map((s) => view(s)) });if ((r = path.match(/^\/api\/v1\/subscriptions\/([^/]+)(?:\/(enable|disable))?$/))) {const s = subscriptions.get(decodeURIComponent(r[1]));-- if (!s || s.consumer !== who.service) return problem(404, 'events.not_found', 'no such subscription');-- if (r[2] && req.method === 'POST') s.enabled = r[2] === 'enable';++ if (!s || s.consumer !== who.consumer) return problem(404, 'events.not_found', 'no such subscription');++ if (r[2] && req.method === 'POST') { s.enabled = r[2] === 'enable'; s.updated_at = new Date().toISOString(); }return json(200, view(s));}}@@@ -444,8 +544,15 @@ 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' };++ let viewer = { kind: 'anonymous' };++ if (claims && claims.actor_type) {++ // Like Events: principals (apps included) need events.event.read, which apps never hold,++ // and sandbox tokens are refused here.++ const who = principal(req, 'openvibe.events', 'events.event.read');++ if (who.res) return who.res;++ if (/^app:/.test(String(claims.sub)) && claims.env === 'sandbox') return problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here');++ viewer = { kind: 'service' };++ } else if (claims) viewer = { kind: 'user', subject: claims.subject_id };const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;const enc = new TextEncoder();@@@ -501,7 +608,10 @@ function createMockPlatform(opts = {}) {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 wanted = re.test(e.event.event_type) && (sub.project_id++ ? appRules.visibleToApp(e, { projectId: sub.project_id, env: sub.env })++ : appRules.serviceSees(sub.topic_pattern, e));++ if (!wanted) { 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 });@@@ -559,47 +669,126 @@ function createMockPlatform(opts = {}) {}// ── Media files ─────────────────────────────────────────-- async function media(req, url) {-- const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);-- if (!r) return json(404, { error: 'Not found' });-- const appId = decodeURIComponent(r[1]);-- const app = mediaApps.get(appId);-- if (!app) return json(404, { error: 'Unknown app' });++ // Like OpenVibe.Media's tenantAuth + files routes: upload and delete need media.object.upload,++ // list and meta need media.object.read. A developer app reaches only /api/v1/<its project_id>/,++ // where its token's env picks the tenant: production `prj_…`, sandbox `prj_…-sandbox` (created on++ // first use). Sandbox files are never served publicly: their `url` is a signed, expiring++ // /f/<key>?exp=&sig= URL (`sandbox: true`, `url_expires_at`); GET /f/<key> without a valid++ // signature is 404, like a missing file.++ const mediaQuota = { production: 1024, sandbox: 100, ...opts.mediaQuotaMb };++ const mediaSigningKey = crypto.randomBytes(32);++ const signedUrlTtlS = Math.min(3600, Math.max(30, Number(opts.mediaSignedUrlTtlS) || 300));++ const mediaMac = (key, exp) => crypto.createHmac('sha256', mediaSigningKey).update(`getf\nfile:${key}\n${exp}`).digest('base64url');++ const isSandboxTenant = (tenantId) => { const t = mediaTenants.get(tenantId); return Boolean(t && t.env === 'sandbox'); };++ function mediaTenant(projectId, env) {++ const id = env === 'sandbox' ? `${projectId}-sandbox` : projectId;++ if (!mediaTenants.has(id)) mediaTenants.set(id, { id, project_id: projectId, env, quota_bytes: mediaQuota[env] * 1024 * 1024 });++ return mediaTenants.get(id);++ }++ function fileView(f) {++ const sandbox = isSandboxTenant(f.app_id);++ let signed = null;++ if (sandbox) {++ const exp = Math.floor(Date.now() / 1000) + signedUrlTtlS;++ signed = { url: `${origins.media}/f/${encodeURIComponent(f.key)}?exp=${exp}&sig=${mediaMac(f.key, exp)}`, expires_at: new Date(exp * 1000).toISOString() };++ }++ return {++ key: f.key, app_id: f.app_id, user_id: f.user_id, original_name: f.original_name, size: f.size, mime: f.mime, sha256: f.sha256,++ url: signed ? signed.url : `/f/${f.key}`, ...(sandbox ? { sandbox: true, url_expires_at: signed.expires_at } : {}), created_at: f.created_at,++ };++ }++ const fileByKey = (key) => [...files.values()].find((f) => f.key === key) || null;++++ /** { tenant, actingUser } or { res }: the order of Media's tenantAuth. */++ function mediaAuth(req, tenantPath, capability) {const auth = req.headers.get('authorization') || '';-- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';-- const byKey = Boolean(token) && app.apiKey && token === app.apiKey;-- if (!byKey) {-- if (req.method !== 'POST' || r[2]) return json(401, { error: 'Authentication required' });-- const who = principal(req, 'openvibe.media', 'media.object.upload', appId);-- if (who.res) return who.res;++ const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';++ const projectRoute = PRJ_ID_RE.test(tenantPath);++ const claims = token.split('.').length === 3 ? decode(token) : null;++ const svc = claims && claims.actor_type ? claims : null;++ if (svc && svc.env === 'sandbox' && !projectRoute) {++ return { res: problem(401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)') };++ }++ if (svc && !(svc.aud || []).includes('openvibe.media')) return { res: problem(401, 'token.wrong_audience', 'not for openvibe.media') };++ if (svc && (svc.actor_type === 'app' || /^app:/.test(String(svc.sub)))) {++ if (!projectRoute) return { res: problem(403, 'capability.namespace_denied', 'app tokens reach only /<project_id>/ tenants') };++ if (svc.project_id !== tenantPath) return { res: problem(403, 'capability.namespace_denied', `this app token belongs to ${svc.project_id || 'no project'}, not ${tenantPath}`) };++ if (!hasCap(svc, capability)) return { res: problem(403, 'capability.denied', `${capability} not granted`) };++ if (svc.ns && svc.ns.length && !svc.ns.includes(tenantPath)) return { res: problem(403, 'capability.namespace_denied', `namespace ${tenantPath} not granted`) };++ return { tenant: mediaTenant(tenantPath, svc.env === 'sandbox' ? 'sandbox' : 'production'), principal: true };++ }++ if (svc && svc.env === 'sandbox') return { res: problem(401, 'token.sandbox_refused', 'only developer-app sandbox tokens are accepted, on their own project tenant') };++ const app = mediaApps.get(tenantPath);++ if (!app) return { res: json(404, { error: 'Unknown app' }) };++ if (token && app.apiKey && token === app.apiKey) {++ const raw = req.headers.get('x-ov-user-id');++ const n = raw == null || raw === '' ? null : Number(String(raw).trim());++ return { tenant: { id: tenantPath, project_id: null, env: null, quota_bytes: 0 }, actingUser: Number.isInteger(n) && n > 0 ? n : null };++ }++ if (token && [...mediaApps.entries()].some(([id, a]) => id !== tenantPath && a.apiKey && a.apiKey === token)) return { res: json(403, { error: 'API key not valid for this app' }) };++ if (svc) {++ const who = principal(req, 'openvibe.media', capability, tenantPath);++ if (who.res) return who;++ return { tenant: { id: tenantPath, project_id: null, env: null, quota_bytes: 0 }, principal: true };}-- const actingUser = byKey ? req.headers.get('x-ov-user-id') : null;-- const view = (f) => ({ key: f.key, app_id: f.app_id, user_id: f.user_id, original_name: f.original_name, size: f.size, mime: f.mime, sha256: f.sha256, url: `/f/${f.key}`, created_at: f.created_at });-- if (req.method === 'POST' && !r[2]) {++ return { res: json(401, { error: 'Authentication required' }) };++ }++++ async function media(req, url) {++ let r;++ if (req.method === 'GET' && (r = url.pathname.match(/^\/f\/([^/]+)$/))) {++ const f = fileByKey(decodeURIComponent(r[1]));++ if (!f) return json(404, { error: 'Not found' });++ const sandbox = isSandboxTenant(f.app_id);++ if (sandbox) {++ const exp = Number(url.searchParams.get('exp'));++ const sig = Buffer.from(String(url.searchParams.get('sig') || ''));++ const want = Buffer.from(Number.isInteger(exp) ? mediaMac(f.key, exp) : '');++ if (!Number.isInteger(exp) || exp < Math.floor(Date.now() / 1000) || sig.length !== want.length || !crypto.timingSafeEqual(sig, want)) return json(404, { error: 'Not found' });++ }++ return new Response(f.bytes, { status: 200, headers: { 'Content-Type': f.mime, 'X-Robots-Tag': 'noindex', 'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400' } });++ }++ r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);++ const routed = r && (req.method === 'GET' || (req.method === 'POST' && !r[2]) || (req.method === 'DELETE' && r[2]));++ if (!routed) return json(404, { error: 'Not found' });++ const capability = req.method === 'GET' ? 'media.object.read' : 'media.object.upload';++ const who = mediaAuth(req, decodeURIComponent(r[1]), capability);++ if (who.res) return who.res;++ const { tenant } = who;++ const mine = () => [...files.values()].filter((f) => f.app_id === tenant.id);++ const used = () => mine().reduce((n, f) => n + f.size, 0);++ if (req.method === 'POST') {const form = await req.formData();const file = form.get('file');if (!file || typeof file === 'string') return json(400, { error: 'No file uploaded (multipart field: file)' });const buf = Buffer.from(await file.arrayBuffer());++ if (tenant.quota_bytes > 0 && used() + buf.length > tenant.quota_bytes) return json(413, { error: 'App file quota exceeded', quota_bytes: tenant.quota_bytes, used_bytes: used() });const sha256 = crypto.createHash('sha256').update(buf).digest('hex');-- const name = String(file.name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';-- const key = `${sha256.slice(0, 12)}-${name}`;-- const existing = files.get(`${appId}|${key}`);-- if (existing) return json(200, { ...view(existing), deduplicated: true });-- const f = { key, app_id: appId, user_id: actingUser ?? form.get('user_id') ?? null, original_name: file.name, size: buf.length, mime: file.type || 'application/octet-stream', sha256, created_at: new Date().toISOString(), bytes: buf };-- files.set(`${appId}|${key}`, f);-- return json(201, view(f));++ const name = String(file.name || 'file').replace(/^.*[\\/]/, '').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';++ // Keys are global; a project tenant's keys carry a tag of the tenant id, like Media's.++ const tag = tenant.project_id ? `${crypto.createHash('sha256').update(`tenant:${tenant.id}`).digest('hex').slice(0, 8)}-` : '';++ const key = `${sha256.slice(0, 12)}-${tag}${name}`;++ const existing = fileByKey(key);++ if (existing) {++ if (existing.app_id !== tenant.id) return json(409, { error: 'Key conflict — rename the file and retry' });++ return json(200, { ...fileView(existing), deduplicated: true });++ }++ const userId = who.actingUser != null ? who.actingUser : (form.get('user_id') ?? null);++ const f = { key, app_id: tenant.id, user_id: userId, original_name: file.name || name, size: buf.length, mime: file.type || 'application/octet-stream', sha256, created_at: new Date().toISOString(), bytes: buf };++ files.set(`${tenant.id}|${key}`, f);++ return json(201, fileView(f));}if (req.method === 'GET' && !r[2]) {-- const all = [...files.values()].filter((f) => f.app_id === appId);-- const limit = Number(url.searchParams.get('limit') || 100);-- const offset = Number(url.searchParams.get('offset') || 0);-- return json(200, { files: all.slice(offset, offset + limit).map(view), used_bytes: all.reduce((n, f) => n + f.size, 0), quota_bytes: 0, limit, offset });++ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 1, 1), 500);++ const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);++ return json(200, { files: mine().slice(offset, offset + limit).map(fileView), used_bytes: used(), quota_bytes: tenant.quota_bytes, limit, offset });}-- const f = files.get(`${appId}|${decodeURIComponent(r[2] || '')}`);++ const f = files.get(`${tenant.id}|${decodeURIComponent(r[2])}`);if (!f) return json(404, { error: 'File not found' });-- if (req.method === 'GET') return json(200, view(f));-- if (req.method === 'DELETE') { files.delete(`${appId}|${f.key}`); return json(200, { message: 'File deleted' }); }-- return json(404, { error: 'Not found' });++ if (req.method === 'GET') return json(200, fileView(f));++ if (who.actingUser != null && String(f.user_id) !== String(who.actingUser)) return json(403, { error: 'Not authorized to delete this file' });++ files.delete(`${tenant.id}|${f.key}`);++ return json(200, { message: 'File deleted' });}// ── Router ──────────────────────────────────────────────@@@ -611,13 +800,15 @@ 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);++ if (toolsOrigins.includes(origin)) return jobsService.handle(req, url);throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);}return {fetch: fetchImpl,origins,++ /** Every origin that answers /api/v1/jobs: origins.tools and the satellites (img., audio., docs.openvibe.tools). */++ toolsOrigins,issuer,keys: { privateKey, publicKey, jwks },signUserToken,@@@ -635,17 +826,25 @@ function createMockPlatform(opts = {}) {addProject: (spec) => developer.addProject(spec).id,signAppToken,stats,-- state: { events, subscriptions, modules, files, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },++ state: { events, subscriptions, modules, files, mediaTenants, 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),++ /**++ * Store an event as if a producer had published it (for realtime/pull tests). A publisher++ * `app:<id>` of a registered app stores it as that app's event (its project and env);++ * otherwise pass { projectId, env } (env defaults to production).++ */++ publishEvent(env, publisher = 'svc:mock', meta) {++ const app = !meta && /^app:/.test(publisher) ? developer.apps.get(publisher.slice(4)) : null;++ const where = meta || (app ? { projectId: app.project_id, env: app.environment } : undefined);++ return publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher, where);++ },/** End every open realtime stream (clients reconnect with Last-Event-ID). */dropRealtime() { for (const s of [...streams]) s.close(); },};}--module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG };++module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_TOOLS_SATELLITES, DEFAULT_APP_CATALOG };@@@ -9,6 +9,9 @@* stepMs: 5, // delay between lifecycle steps* handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },* }++ * Every Tools origin of the platform answers (origins.tools and the img., audio. and docs.++ * satellites); like the real satellites, each keeps its own jobs: a job id is found only on the++ * origin that created it.* 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 }.*/@@@ -151,18 +154,18 @@ function createJobsService(ctx) {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}`);++ const prior = idem.get(`${url.origin}|${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,++ id: `job_${ulid()}`, origin: url.origin, 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 });++ if (key) idem.set(`${url.origin}|${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' });@@@ -173,7 +176,7 @@ function createJobsService(ctx) {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 (!job || job.owner !== who.owner || job.origin !== url.origin) 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');nnew file mode 100644@@@ -0,0 +1,233 @@++'use strict';++/**++ * Developer-app events: projectKey/appSource/createAppEvents, and the mock platform's Events playing++ * OpenVibe.Events' events.app.* rules (type prefix, source, actor, read and subscription scope, https++ * endpoints, sandbox/production separation, first-party readers, deliveries, realtime).++ */++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const { createClient } = require('../src/core');++const { createServiceTokenClient } = require('../src/auth');++const { createEventsClient, projectKey, appSource, createAppEvents } = require('../src/events');++const { createMockPlatform } = require('../src/testing');++++const P1 = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';++const P2 = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TB';++const A = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T1'; // P1 sandbox++const A2 = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T2'; // P1 production++const B = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T3'; // P2 sandbox++const R = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T4'; // P1 sandbox, read only++const K1 = 'p01k5wzx7s7q4d2b8n3m6v1c9ta';++const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];++++function setup() {++ const platform = createMockPlatform({++ clients: {++ live: { secret: 'live-secret', grants: [['events.event.publish', 'openvibe.events'], ['events.event.read', 'openvibe.events'], ['events.subscription.manage', 'openvibe.events']] },++ },++ apps: {++ [A]: { project: P1, env: 'sandbox', secret: 'a', grants: ALL },++ [A2]: { project: P1, env: 'production', secret: 'a2', grants: ALL },++ [B]: { project: P2, env: 'sandbox', secret: 'b', grants: ALL },++ [R]: { project: P1, env: 'sandbox', secret: 'r', grants: ['events.app.read'] },++ },++ });++ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });++ const live = createEventsClient(clientFor('live', 'live-secret'), { source: 'live' });++ return {++ platform, live, clientFor,++ a: createAppEvents(clientFor(A, 'a'), { projectId: P1, appId: A }),++ a2: createAppEvents(clientFor(A2, 'a2'), { projectId: P1, appId: A2 }),++ b: createAppEvents(clientFor(B, 'b'), { projectId: P2, appId: B }),++ r: createAppEvents(clientFor(R, 'r'), { projectId: P1, appId: R }),++ };++}++const order = { event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } };++const ids = (page) => page.events.map((e) => e.event.event_id);++++run([++ ['projectKey and appSource derive the names Events expects', async () => {++ assert.equal(projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.equal(appSource('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.equal(appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');++ assert.equal(projectKey('prj_bad'), null);++ assert.equal(projectKey('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), null);++ assert.equal(appSource('svc:live'), null);++ assert.equal(appSource('app_01jab2c3d4e5f6g7h8j9k0mnpq'), null, 'ids are upper-case ULIDs');++ }],++++ ['createAppEvents: ids checked, project-relative names and patterns', async () => {++ const client = createClient({ fetch: () => { throw new Error('no calls'); } });++ assert.throws(() => createAppEvents(client, { projectId: 'prj_x', appId: A }), TypeError);++ assert.throws(() => createAppEvents(client, { projectId: P1, appId: 'svc:live' }), TypeError);++ const ev = createAppEvents(client, { projectId: P1, appId: A });++ assert.equal(ev.projectKey, K1);++ assert.equal(ev.source, appSource(A));++ assert.equal(ev.prefix, `app.${K1}.`);++ assert.equal(ev.topic('order.shipped'), `app.${K1}.order.shipped`);++ assert.equal(ev.topic('*'), `app.${K1}.*`);++ assert.equal(ev.topic(`app.${K1}.order.*`), `app.${K1}.order.*`, 'full names are kept');++ assert.throws(() => ev.topic('app.p01k5wzx7s7q4d2b8n3m6v1c9tb.x'), /another project/);++ const e = ev.prepare({ ...order });++ assert.equal(e.event_type, `app.${K1}.order.shipped`);++ assert.equal(e.source, appSource(A));++ assert.deepEqual(e.actor, { type: 'app', id: A });++ assert.match(e.event_id, /^evt_/);++ assert.deepEqual(ev.prepare({ event_type: 'ping' }).subject, { type: 'app', id: A }, 'subject defaults to the app');++ const user = createAppEvents(client, { projectId: P1, appId: A, onBehalfOf: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' });++ assert.deepEqual(user.prepare({ ...order }).actor, { type: 'user', id: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' });++ assert.throws(() => ev.prepare({ ...order, source: 'live' }), TypeError);++ assert.throws(() => ev.prepare({ ...order, event_type: 'Order.Shipped' }), TypeError);++ assert.throws(() => ev.pull({ platformTopics: ['*'] }), TypeError);++ assert.throws(() => ev.pull({ platformTopics: [`app.${K1}.*`] }), TypeError);++ }],++++ ['publish: the app prefix, source and actor; everything else is refused like Events does', async () => {++ const { platform, a, clientFor } = setup();++ const out = await a.publish({ ...order });++ assert.equal(out.duplicate, false);++ const stored = platform.state.events.at(-1);++ assert.equal(stored.event.event_type, `app.${K1}.order.shipped`);++ assert.equal(stored.event.source, appSource(A));++ assert.deepEqual(stored.event.actor, { type: 'app', id: A });++ assert.equal(stored.project_id, P1);++ assert.equal(stored.env, 'sandbox');++ assert.equal(stored.publisher, `app:${A}`);++ assert.equal((await a.publish({ ...order, event_id: stored.event.event_id })).duplicate, true, 'safe to retry');++ assert.equal((await a.publish([{ event_type: 'x.one' }, { event_type: 'x.two' }])).results.length, 2);++++ const raw = a.events; // unscoped: nothing is filled in beyond the source++ const env = (over) => ({ event_type: `app.${K1}.order.shipped`, actor: { type: 'app', id: A }, subject: { type: 'order', id: 'o1' }, ...over });++ await assert.rejects(raw.publish(env({ event_type: 'app.p01k5wzx7s7q4d2b8n3m6v1c9tb.order.shipped' })), { status: 403, code: 'events.type_not_allowed' });++ await assert.rejects(raw.publish(env({ event_type: 'live.stream.started' })), { status: 403, code: 'events.type_not_allowed' });++ await assert.rejects(raw.publish(env({ source: 'live' })), { status: 403, code: 'events.source_mismatch' });++ await assert.rejects(raw.publish(env({ source: appSource(B) })), { status: 403, code: 'events.source_mismatch' });++ await assert.rejects(raw.publish(env({ actor: { type: 'app', id: B } })), { status: 403, code: 'events.actor_mismatch' });++ await assert.rejects(raw.publish(env({ actor: { type: 'user', id: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' } })), { status: 403, code: 'events.actor_mismatch' });++++ // The user the token acts for may be the actor.++ const usr = 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU';++ const obo = createClient({ fetch: platform.fetch, token: platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.app.publish'], onBehalfOf: usr }) });++ assert.equal((await createAppEvents(obo, { projectId: P1, appId: A, onBehalfOf: usr }).publish({ ...order })).duplicate, false);++++ // events.app.publish is required; first-party services never publish into app.*.++ const r = createAppEvents(clientFor(R, 'r'), { projectId: P1, appId: R });++ await assert.rejects(r.publish({ ...order }), { status: 403, code: 'capability.denied' });++ const live = createEventsClient(clientFor('live', 'live-secret'), { source: 'live' });++ await assert.rejects(live.publish({ event_type: `app.${K1}.order.shipped`, actor: { type: 'service', id: 'live' }, subject: { type: 'x', id: '1' } }), { status: 403, code: 'events.type_not_allowed' });++ // A first-party capability in an app token is never honoured.++ const sneaky = createClient({ fetch: platform.fetch, token: platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.event.publish', 'events.event.read'] }) });++ await assert.rejects(createEventsClient(sneaky).pull({ topic: `app.${K1}.*` }), { status: 403, code: 'capability.denied' });++ }],++++ ['reads: own project in the same env, public first-party events, never another project', async () => {++ const { platform, a, a2, b, r, live } = setup();++ const sbx = await a.publish({ ...order });++ const prod = await a2.publish({ ...order });++ const other = await b.publish({ ...order });++ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });++ platform.publishEvent({ event_type: 'live.stream.ended', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' } });++++ assert.deepEqual(ids(await r.pull()), [sbx.event_id], 'another app of the project, same env');++ assert.deepEqual(ids(await a.pull({ topic: 'order.*' })), [sbx.event_id], 'a sandbox app never sees production events');++ assert.deepEqual(ids(await a2.pull()), [prod.event_id], 'a production app never sees sandbox events');++ assert.deepEqual(ids(await b.pull()), [other.event_id]);++ assert.deepEqual(ids(await a.pull({ platformTopics: ['live.*'] })), [sbx.event_id, pub.event_id], 'public first-party only, never internal');++ await assert.rejects(b.events.pull({ topic: `app.${K1}.*` }), { status: 403, code: 'events.topic_not_allowed' });++ await assert.rejects(b.events.pull({ topic: '*' }), { status: 403, code: 'events.topic_not_allowed' });++ await assert.rejects(b.events.pull({ topic: '*.shipped' }), { status: 403, code: 'events.topic_not_allowed' });++ await assert.rejects(b.events.pull({ topic: 'Bad Topic' }), { status: 400, code: 'events.bad_topic' });++ assert.equal((await a.get(sbx.event_id)).event.event_id, sbx.event_id);++ assert.equal(await b.get(sbx.event_id), null, 'another project\'s event does not exist for this app');++ assert.equal(await a2.get(sbx.event_id), null);++ const seen = [];++ for await (const item of a.iterate({ topic: '*', platformTopics: ['live.stream.*'] })) seen.push(item.event.event_id);++ assert.deepEqual(seen, [sbx.event_id, pub.event_id]);++++ // First-party readers: never sandbox; app events only through an app.* pattern.++ assert.ok(!(await live.pull({ topic: '*' })).events.some((e) => e.event.event_type.startsWith('app.')));++ assert.deepEqual(ids(await live.pull({ topic: 'app.*' })), [prod.event_id]);++ assert.equal(await live.get(sbx.event_id), null);++ assert.equal((await live.get(prod.event_id)).event.event_id, prod.event_id);++++ // Checkpoints are per app and scoped the same way.++ const cp = await a.setCheckpoint('*', 5);++ assert.equal(cp.consumer, `app:${A}`);++ assert.equal(cp.topic, `app.${K1}.*`);++ assert.equal((await a.getCheckpoint('*')).cursor, 5);++ assert.equal((await r.getCheckpoint('*')).cursor, 0, 'another app has its own');++ await assert.rejects(b.events.getCheckpoint(`app.${K1}.*`), { status: 403, code: 'events.topic_not_allowed' });++ }],++++ ['subscriptions: own topics, public https endpoints; deliveries keep projects and environments apart', async () => {++ const { platform, a, a2, b, r, live } = setup();++ const sa = await a.subscribe({ endpoint: 'https://hooks.example.com/a' });++ assert.equal(sa.topic_pattern, `app.${K1}.*`);++ assert.equal(sa.consumer, `app:${A}`);++ assert.equal(sa.project_id, P1);++ assert.equal(sa.env, 'sandbox');++ assert.match(sa.secret, /^whsec_/);++ await a.subscriptions.create({ topicPattern: 'order.*', endpoint: 'https://hooks.example.com/orders', secret: 'x'.repeat(32) });++ assert.equal((await a.subscriptions.create({ topicPattern: 'live.*', endpoint: 'https://hooks.example.com/nope' })).topic_pattern, `app.${K1}.live.*`, 'relative: inside the project');++ assert.equal((await a.events.subscriptions.create({ topicPattern: 'live.*', endpoint: 'https://hooks.example.com/public' })).topic_pattern, 'live.*', 'a first-party pattern (public events only)');++ for (const endpoint of ['http://hooks.example.com/x', 'https://localhost/x', 'https://127.0.0.1/x', 'https://10.0.0.1/x', 'https://intranet/x', 'https://user:[email protected]/x', 'https://hooks.example.com./x', 'https://[::1]/x']) {++ await assert.rejects(a.subscribe({ endpoint }), { status: 422, code: 'events.endpoint_not_allowed' }, endpoint);++ }++ await assert.rejects(a.events.subscriptions.create({ topicPattern: '*', endpoint: 'https://hooks.example.com/x' }), { status: 403, code: 'events.topic_not_allowed' });++ await assert.rejects(a.subscribe({ endpoint: 'https://hooks.example.com/a' }), { status: 409, code: 'events.subscription_exists' });++ await assert.rejects(a.subscribe({ endpoint: 'https://hooks.example.com/s', secret: 'short' }), { status: 422 });++ await assert.rejects(r.subscribe({ endpoint: 'https://hooks.example.com/r' }), { status: 403, code: 'capability.denied' });++ await a2.subscribe({ endpoint: 'https://hooks.example.com/prod' });++ await b.subscribe({ endpoint: 'https://hooks.example.com/b' });++ await live.subscribe({ topicPattern: 'app.*', endpoint: 'https://live.openvibe.live/hook' });++ await live.subscribe({ topicPattern: 'live.*', endpoint: 'https://live.openvibe.live/svc-live' });++++ assert.equal((await a.subscriptions.list()).length, 4);++ assert.equal((await b.subscriptions.list()).length, 1);++ assert.equal(await b.subscriptions.get(sa.id), null);++ assert.equal(await a2.subscriptions.get(sa.id), null, 'another app of the project cannot see it');++ await assert.rejects(a2.subscriptions.disable(sa.id), { status: 404 });++++ const e1 = await a.publish({ ...order });++ const e2 = await a2.publish({ ...order });++ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });++ const calls = [];++ const round = await platform.deliverEvents({ fetch: async (url, init) => { calls.push({ path: new URL(url).pathname, id: init.headers['X-OpenVibe-Event-Id'] }); return new Response(null, { status: 204 }); } });++ assert.equal(round.failed, 0);++ const at = (p) => calls.filter((c) => c.path === p).map((c) => c.id);++ assert.deepEqual(at('/a'), [e1.event_id], 'own sandbox event only');++ assert.deepEqual(at('/orders'), [e1.event_id]);++ assert.deepEqual(at('/prod'), [e2.event_id], 'a sandbox event never reaches a production subscription');++ assert.deepEqual(at('/b'), [], 'never another project');++ assert.deepEqual(at('/hook'), [e2.event_id], 'first-party app.* subscribers: production app events only');++ assert.deepEqual(at('/svc-live'), [pub.event_id]);++ assert.deepEqual(at('/public'), [pub.event_id], 'an app gets public first-party events');++ assert.deepEqual(at('/nope'), []);++ }],++++ ['realtime never streams app events; app tokens are refused there', async () => {++ const { platform, a, a2 } = setup();++ const url = `${platform.origins.events}/realtime/stream?topics=*`;++ const sbx = platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.app.read'] });++ const prod = platform.signAppToken(A2, { audience: 'openvibe.events', capabilities: ['events.app.read'] });++ const refused = await platform.fetch(url, { headers: { authorization: `Bearer ${sbx}` } });++ assert.equal(refused.status, 401);++ assert.equal((await refused.json()).code, 'token.sandbox_refused');++ const denied = await platform.fetch(url, { headers: { authorization: `Bearer ${prod}` } });++ assert.equal(denied.status, 403, 'apps never hold events.event.read');++ assert.equal((await denied.json()).code, 'capability.denied');++++ const ctl = new AbortController();++ const res = await platform.fetch(url, { signal: ctl.signal });++ const reader = res.body.getReader();++ await a.publish({ ...order });++ await a2.publish({ ...order });++ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });++ let text = '';++ while (!text.includes('data: ')) text += new TextDecoder().decode((await reader.read()).value);++ const first = JSON.parse(text.split('data: ')[1].split('\n')[0]);++ assert.equal(first.event.event_id, pub.event_id, 'the first thing streamed is the public first-party event');++ ctl.abort();++ platform.dropRealtime();++ }],++]);@@@ -175,19 +175,33 @@ run([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 () => {++ ['Media: an app uploads into its project namespace; production and sandbox tenants stay apart', 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');++ assert.equal(file.app_id, PRJ);++ assert.equal(file.sandbox, undefined);++ assert.equal(file.url, `/f/${file.key}`);++ assert.equal(file.public_url, `https://openvibe.media/f/${file.key}`);++ assert.equal(await (await platform.fetch(file.public_url)).text(), 'hello', 'production files are public');++ await assert.rejects(createMediaClient(client, { app: 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ' }).upload('x'), { status: 403, code: 'capability.namespace_denied' });++ // Media accepts sandbox app tokens on the project's own tenant (and nowhere else).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 sbx = await createMediaClient(sandboxClient, { app: PRJ }).upload('hello', { filename: 'a.txt' });++ assert.equal(sbx.app_id, `${PRJ}-sandbox`);++ assert.equal(sbx.sandbox, true);++ assert.notEqual(sbx.key, file.key, 'tenant-tagged keys never collide across tenants');++ assert.match(sbx.url, /^https:\/\/openvibe\.media\/f\/[^?]+\?exp=\d+&sig=[\w-]+$/);++ assert.ok(Date.parse(sbx.url_expires_at) > Date.now());++ assert.equal(sbx.public_url, null);++ assert.equal(sbx.signed_url, sbx.url);++ assert.equal((await platform.fetch(`https://openvibe.media/f/${sbx.key}`)).status, 404, 'never served without a signature');++ assert.equal((await platform.fetch(sbx.url.replace(/sig=[\w-]+/, 'sig=forged'))).status, 404);++ assert.equal(await (await platform.fetch(sbx.signed_url)).text(), 'hello');++ await assert.rejects(createMediaClient(sandboxClient, { app: 'demo' }).upload('x'), { status: 401, code: 'token.sandbox_refused' });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' });}],nnew file mode 100644@@@ -0,0 +1,127 @@++'use strict';++/**++ * Mock platform services as production answers them: Media's developer-project tenants (read vs++ * upload capabilities, sandbox tenant apart from production, signed-only sandbox files, quotas,++ * first-party tenants), and Tools jobs on the img., audio. and docs. satellites.++ */++const assert = require('node:assert/strict');++const { run } = require('./helpers');++const { createClient } = require('../src/core');++const { createServiceTokenClient } = require('../src/auth');++const { createMediaClient } = require('../src/media');++const { createJobsClient } = require('../src/jobs');++const { createMockPlatform, DEFAULT_TOOLS_SATELLITES } = require('../src/testing');++++const PRJ = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';++const UP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T1'; // production, upload only++const RD = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T2'; // production, read only++const SBX = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T3'; // sandbox, read + upload++++function setup(extra = {}) {++ const platform = createMockPlatform({++ mediaApps: { demo: { apiKey: 'demo-key' }, other: { apiKey: 'other-key' } },++ clients: { svc: { secret: 's', grants: [{ capability: 'media.object.read', audience: 'openvibe.media', namespaces: ['demo'] }] } },++ apps: {++ [UP]: { project: PRJ, env: 'production', secret: 'up', grants: ['media.object.upload'] },++ [RD]: { project: PRJ, env: 'production', secret: 'rd', grants: ['media.object.read'] },++ [SBX]: { project: PRJ, env: 'sandbox', secret: 'sbx', grants: ['media.object.read', 'media.object.upload'] },++ },++ ...extra,++ });++ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });++ const mediaFor = (id, secret, app = PRJ) => createMediaClient(clientFor(id, secret), { app });++ return { platform, clientFor, mediaFor };++}++++run([++ ['Media: media.object.read lists and gets, media.object.upload uploads and deletes', async () => {++ const { platform, mediaFor } = setup();++ const up = mediaFor(UP, 'up');++ const rd = mediaFor(RD, 'rd');++ const f = await up.upload('hello', { filename: 'a.txt' });++ await assert.rejects(up.files.list(), { status: 403, code: 'capability.denied' });++ await assert.rejects(up.files.get(f.key), { status: 403, code: 'capability.denied' });++ const list = await rd.files.list();++ assert.deepEqual(list.files.map((x) => x.key), [f.key]);++ assert.equal(list.quota_bytes, 1024 * 1024 * 1024, 'production project tenant: 1 GB');++ assert.equal(list.used_bytes, 5);++ assert.equal((await rd.files.get(f.key)).sha256, f.sha256);++ assert.equal(await rd.files.get('nope'), null);++ await assert.rejects(rd.upload('x'), { status: 403, code: 'capability.denied' });++ await assert.rejects(rd.files.delete(f.key), { status: 403, code: 'capability.denied' });++ assert.equal(await up.files.delete(f.key), true);++ assert.equal(await up.files.delete(f.key), false);++ assert.equal((await rd.files.list()).files.length, 0);++ assert.equal((await platform.fetch(f.public_url)).status, 404, 'gone');++ }],++++ ['Media: the sandbox tenant is <project>-sandbox, apart from production, and signed URLs only', async () => {++ const { platform, mediaFor } = setup();++ const prodFile = await mediaFor(UP, 'up').upload('prod', { filename: 'p.txt' });++ const sbx = mediaFor(SBX, 'sbx');++ const f = await sbx.upload('secret bytes', { filename: 'img.png', contentType: 'image/png' });++ assert.equal(f.app_id, `${PRJ}-sandbox`);++ assert.equal(f.sandbox, true);++ assert.equal(f.public_url, null);++ assert.equal(f.signed_url, f.url);++ assert.ok(platform.state.files.has(`${PRJ}-sandbox|${f.key}`));++ assert.equal(platform.state.mediaTenants.get(`${PRJ}-sandbox`).env, 'sandbox');++++ const list = await sbx.files.list();++ assert.deepEqual(list.files.map((x) => x.key), [f.key], 'the sandbox app sees its tenant only');++ assert.equal(list.quota_bytes, 100 * 1024 * 1024, 'sandbox tenant: 100 MB');++ assert.equal(list.files[0].sandbox, true);++ assert.match(list.files[0].url, /\?exp=\d+&sig=/, 'every answer carries a fresh signed URL');++ assert.equal(await sbx.files.get(prodFile.key), null, 'production files do not exist for a sandbox app');++ assert.deepEqual((await mediaFor(RD, 'rd').files.list()).files.map((x) => x.key), [prodFile.key]);++++ const served = await platform.fetch(f.signed_url);++ assert.equal(served.status, 200);++ assert.equal(served.headers.get('content-type'), 'image/png');++ assert.equal(served.headers.get('cache-control'), 'private, no-store');++ assert.equal(await served.text(), 'secret bytes');++ assert.equal((await platform.fetch(`${platform.origins.media}/f/${f.key}`)).status, 404);++ const u = new URL(f.signed_url);++ u.searchParams.set('exp', String(Math.floor(Date.now() / 1000) - 10));++ assert.equal((await platform.fetch(u)).status, 404, 'an expired (or re-signed) URL is refused');++ assert.equal((await platform.fetch(`${platform.origins.media}/f/${prodFile.key}`)).headers.get('cache-control'), 'public, max-age=86400');++ assert.equal(await sbx.files.delete(f.key), true);++ }],++++ ['Media: tenant rules for app tokens, service tokens and app keys; quotas', async () => {++ const { platform, clientFor, mediaFor } = setup({ mediaQuotaMb: { sandbox: 0.00001 } });++ await assert.rejects(mediaFor(UP, 'up', 'demo').upload('x'), { status: 403, code: 'capability.namespace_denied' }, 'apps reach /<project_id>/ only');++ await assert.rejects(mediaFor(UP, 'up', 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ').upload('x'), { status: 403, code: 'capability.namespace_denied' });++ await assert.rejects(mediaFor(SBX, 'sbx', 'demo').files.list(), { status: 401, code: 'token.sandbox_refused' });++ await assert.rejects(mediaFor(SBX, 'sbx').upload('more than ten bytes'), { status: 413 });++ assert.ok((await mediaFor(SBX, 'sbx').upload('tiny')).key);++++ const key = createMediaClient(createClient({ fetch: platform.fetch }), { app: 'demo', apiKey: 'demo-key', actingUserId: 7 });++ const mine = await key.upload('by seven');++ assert.equal(mine.user_id, 7);++ await assert.rejects(key.files.delete(mine.key, { actingUserId: 8 }), { status: 403 }, 'an acting user deletes only their files');++ await assert.rejects(createMediaClient(createClient({ fetch: platform.fetch }), { app: 'demo', apiKey: 'other-key' }).files.list(), { status: 403 });++ await assert.rejects(createMediaClient(createClient({ fetch: platform.fetch }), { app: PRJ, apiKey: 'demo-key' }).files.list(), { status: 404 }, 'project tenants are reached with app tokens only');++++ const svc = createMediaClient(clientFor('svc', 's'), { app: 'demo' });++ assert.equal((await svc.files.list()).files.length, 1, 'a service token with media.object.read lists');++ await assert.rejects(svc.upload('x'), { status: 403, code: 'capability.denied' });++ await assert.rejects(createMediaClient(clientFor('svc', 's'), { app: 'other' }).files.list(), { status: 403, code: 'capability.namespace_denied' });++ }],++++ ['Tools: the img., audio. and docs. satellites answer /api/v1/jobs, each with its own jobs', async () => {++ const platform = createMockPlatform({ jobs: { stepMs: 1 }, clients: { svc: { secret: 's', grants: [['tools.job.create', 'openvibe.tools'], ['tools.job.read', 'openvibe.tools']] } } });++ assert.deepEqual(platform.toolsOrigins, ['https://openvibe.tools', ...DEFAULT_TOOLS_SATELLITES]);++ const client = createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: 'svc', clientSecret: 's', fetch: platform.fetch }) });++ const img = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });++ const audio = createJobsClient(client, { baseUrl: 'https://audio.openvibe.tools' });++ const docs = createJobsClient(client, { baseUrl: 'https://docs.openvibe.tools' });++ const { job } = await img.submit({ type: 'img.process', input: { format: 'webp' } });++ assert.equal((await img.wait(job.id)).state, 'succeeded');++ assert.equal(await audio.get(job.id), null, 'a job lives on the satellite that created it');++ assert.ok((await docs.submit({ type: 'docs.convert', input: {} })).job.id);++ await assert.rejects(platform.fetch('https://video.openvibe.tools/api/v1/jobs'), TypeError);++ const only = createMockPlatform({ jobs: true, toolsSatellites: [] });++ assert.deepEqual(only.toolsOrigins, ['https://openvibe.tools']);++ }],++]);@@@ -33,7 +33,7 @@ run([const other = sdk.media.createMediaClient(client, { app: 'someone-else' });await assert.rejects(other.upload('x'), (err) => err.status === 404 || err.code === 'capability.namespace_denied');-- await assert.rejects(media.files.list(), { status: 401 }, 'a service token only uploads; listing needs the app key');++ await assert.rejects(media.files.list(), { status: 403, code: 'capability.denied' }, 'listing needs media.object.read');}],['user modules with a Network user token', async () => {@@@ -1,4 +1,4 @@--/* Copied verbatim from openvibe-contracts v0.26.0 generated/typescript/index.d.ts (the SDK does not++/* Copied verbatim from openvibe-contracts v0.28.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 */@@@ -236,3 +236,76 @@ export interface ModuleRecord {*/updated_by?: string;}++++/** [email protected] (owner: codes) */++/**++ * PROPOSAL (OpenVibe.Codes, roadmap Wave 20): an app release as the platform knows it. The app is a Network developer app (ADR-014): its id is the Network app id, its capabilities are only a request (the grants in Network are the authority), and trust tiers are metadata that never change a grant check. OpenVibe.Codes validates with this schema, loaded into openvibe-contracts' validator, until Contracts publishes it as codes.app-manifest@1.++ */++export interface AppManifest {++ /**++ * The Network app id (its client_id). Its principal subject is app:<id>.++ */++ id: string;++ name: string;++ /**++ * Semantic version of this release.++ */++ version: string;++ description?: string;++ publisher: SubjectRef;++ /**++ * The Network project that owns the app. Tenancy in other services is keyed by it (ADR-014).++ */++ project_id: string;++ /**++ * The app's environment. Apps never change environment.++ */++ environment?: "sandbox" | "production";++ /**++ * Capability ids the app asks for (3+ segments). Only active public (or partner, by staff allowance) capabilities can ever be granted to apps.++ *++ * @maxItems 64++ */++ capabilities: string[];++ events?: {++ /**++ * Event types (or a trailing .* family) the app subscribes to.++ *++ * @maxItems 64++ */++ consumes?: string[];++ };++ /**++ * Informational copy of the redirect URIs registered in Network (Network's list is the authority).++ *++ * @maxItems 10++ */++ redirect_uris?:++ | []++ | [string]++ | [string, string]++ | [string, string, string]++ | [string, string, string, string]++ | [string, string, string, string, string]++ | [string, string, string, string, string, string]++ | [string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string]++ | [string, string, string, string, string, string, string, string, string, string];++ homepage?: string;++ repository?: string;++ /**++ * SPDX license expression.++ */++ license?: string;++ compatibility: {++ /**++ * Semver range of openvibe-contracts releases it was built against.++ */++ contracts: string;++ /**++ * Semver range of openvibe-sdk releases it uses.++ */++ sdk?: string;++ };++}@@@ -1,5 +1,5 @@import type { Problem } from './contracts';--export type { SubjectRef, ServiceTokenClaims, EntityRef, Problem, ServiceManifest, Capability, EventEnvelope, ModuleNamespace, ModuleRecord } from './contracts';++export type { SubjectRef, ServiceTokenClaims, EntityRef, Problem, ServiceManifest, Capability, EventEnvelope, ModuleNamespace, ModuleRecord, AppManifest } from './contracts';export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;@@@ -42,6 +42,52 @@ export interface EventsClient {}export declare function createEventsClient(client: OpenVibeClient, opts?: { source?: string; baseUrl?: string }): EventsClient;++/** 'prj_01JAB…' -> 'p01jab…' (the second segment of a project's event types); null if not prj_<ULID>. */++export declare function projectKey(projectId: string): string | null;++/** 'app_01JAB…' or 'app:app_01JAB…' -> 'app-01jab…' (the app's event source); null if not an app id. */++export declare function appSource(appId: string): string | null;++++/** An app event: event_type may be project-relative ('order.shipped'); actor and subject default to the app. */++export type AppEventInput = Omit<EventInput, 'actor' | 'subject'> & Partial<Pick<EventEnvelope, 'actor' | 'subject'>>;++export interface AppEventsClient {++ projectId: string;++ /** app_<ULID> */++ appId: string;++ /** p<lowercased project ULID> */++ projectKey: string;++ /** app-<lowercased app ULID> */++ source: string;++ /** app.<projectKey>. */++ prefix: string;++ /** A project-relative type or pattern -> the full one ('order.*' -> 'app.<key>.order.*'; '*' -> 'app.<key>.*'). */++ topic(name: string): string;++ prepare(envelope: AppEventInput, opts?: { traceId?: string; now?: number }): EventEnvelope;++ publish(envelope: AppEventInput, opts?: { traceparent?: string }): Promise<PublishResult>;++ publish(envelopes: AppEventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;++ /** topic is project-relative (default '*'); platformTopics are first-party patterns (public events only). */++ pull(opts?: { topic?: string | string[]; platformTopics?: string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;++ iterate(opts?: { topic?: string | string[]; platformTopics?: 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 }>;++ subscriptions: {++ /** topicPattern is project-relative (default '*'); the endpoint must be public https. */++ create(input: Partial<CreateSubscription> & { endpoint: string }): Promise<Subscription>;++ list(): Promise<Subscription[]>;++ get(id: string): Promise<Subscription | null>;++ disable(id: string): Promise<Subscription>;++ enable(id: string): Promise<Subscription>;++ };++ subscribe(input: Partial<CreateSubscription> & { endpoint: string }): Promise<Subscription>;++ /** The unscoped client underneath. */++ events: EventsClient;++}++/**++ * Events for one developer app of one project (token: events.app.publish | read | subscribe).++ * onBehalfOf (usr_…, the token's on_behalf_of) makes that person the default actor.++ */++export declare function createAppEvents(client: OpenVibeClient, opts: { projectId: string; appId: string; onBehalfOf?: string; baseUrl?: string }): AppEventsClient;++type RawBody = string | Uint8Array | ArrayBufferView;export declare function signDelivery(rawBody: RawBody, secret: string): string;export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;@@@ -2,16 +2,23 @@ import type { OpenVibeClient } from './core';export interface MediaFile {key: string;++ /** The tenant: the app id, a project id (prj_…) or its sandbox tenant (prj_…-sandbox). */app_id: string;user_id: string | number | null;original_name: string;size: number;mime: string;sha256: string;-- /** Media-relative, e.g. /f/<key> */++ /** Media-relative, e.g. /f/<key>; for a sandbox file an absolute signed, expiring URL. */url: string;-- /** Absolute, added by the SDK. */-- public_url: string;++ /** Absolute public URL, added by the SDK; null for sandbox files (never served publicly). */++ public_url: string | null;++ /** Sandbox files only (Media): the tenant is a developer project's sandbox. */++ sandbox?: true;++ /** Sandbox files only (Media): when `url` stops working. */++ url_expires_at?: string;++ /** Sandbox files only, added by the SDK: the signed `url`. */++ signed_url?: string;created_at: string;deduplicated?: boolean;}@@@ -45,6 +52,10 @@ export interface MediaClient {};upload: MediaClient['files']['upload'];}--/** apiKey: the app's Media API key (server only). Without it the client's token (a service token with media.object.upload) is used. */++/**++ * apiKey: the app's Media API key (server only). Without it the client's token is used: a service++ * token, or a developer app token with `app` = its project id (media.object.upload to upload and++ * delete, media.object.read to list and get).++ */export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;export declare const DEFAULT_PUBLIC_ORIGIN: string;@@@ -44,7 +44,11 @@ export interface MockPlatformOptions {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. */++ /**++ * Audiences or capability ids whose mock services accept env=sandbox tokens (or true). Default:++ * none. Media (on /api/v1/<project_id>/files) and Events (on the events.app.* routes) accept++ * sandbox app tokens regardless, as in production.++ */acceptSandbox?: true | string[];/** Allowance of projects created through /api/v1/projects. Default: the whole catalog (Network: empty). */defaultAllowance?: string[];@@@ -54,7 +58,13 @@ export interface MockPlatformOptions {capabilities?: Capability[];services?: ServiceManifest[];realtimeRetryMs?: number;-- /** Serve Tools jobs at origins.tools. */++ /** Media quotas of developer-project tenants in MB (Media's defaults: production 1024, sandbox 100). */++ mediaQuotaMb?: { production?: number; sandbox?: number };++ /** Lifetime of signed sandbox file URLs, 30..3600 s (default 300, like Media). */++ mediaSignedUrlTtlS?: number;++ /** Tools satellites that also answer /api/v1/jobs. Default: DEFAULT_TOOLS_SATELLITES. */++ toolsSatellites?: string[];++ /** Serve Tools jobs at origins.tools and the satellites (each keeps its own jobs). */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;@@@ -63,6 +73,8 @@ export interface DeliveryAttempt { subscription_id: string; event_id: string; seexport interface MockPlatform {fetch: FetchLike;origins: Record<Origin, string>;++ /** Every origin that answers /api/v1/jobs: origins.tools and the satellites. */++ toolsOrigins: 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;@@@ -79,10 +91,19 @@ export interface MockPlatform {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>;++ /** project_id/env are set for developer-app events (null/'production' for first-party ones). */++ events: Array<{ seq: number; event: EventEnvelope; publisher: string; project_id: string | null; env: 'sandbox' | 'production' }>;++ subscriptions: Map<string, any>; modules: Map<string, any>;++ /** `${tenant}|${key}`; tenant is a Media app id, prj_… or prj_…-sandbox. */++ files: Map<string, any>;++ mediaTenants: Map<string, { id: string; project_id: string; env: 'sandbox' | 'production'; quota_bytes: number }>;++ users: Map<string, MockUser>; checkpoints: Map<string, { cursor: number; updated_at: string }>; 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 };++ /**++ * Store an event as if published. A publisher `app:<id>` of a registered app stores it as that++ * app's event (its project and env); or pass { projectId, env }.++ */++ publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string, meta?: { projectId?: string; env?: 'sandbox' | 'production' }): { 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). */@@@ -93,5 +114,7 @@ export interface MockPlatform {}export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;export declare const DEFAULT_ORIGINS: Record<Origin, string>;--/** Capability ids a developer app can be granted in openvibe-contracts v0.26.0 (public + active). */++/** https://img.openvibe.tools, https://audio.openvibe.tools, https://docs.openvibe.tools */++export declare const DEFAULT_TOOLS_SATELLITES: string[];++/** Capability ids a developer app can be granted in openvibe-contracts v0.28.0 (public + active). */export declare const DEFAULT_APP_CATALOG: string[];
@@ -12,6 +12,6 @@ jobs:
@@ -12,6 +12,6 @@ jobs:
with:
with:
node-version: 22.22.1 # production services run Node 22
node-version: 22.22.1 # production services run Node 22
# types.test.js compares types/contracts.d.ts with the pinned Contracts release
# types.test.js compares types/contracts.d.ts with the pinned Contracts release
- - run: git clone --depth 1 --branch v0.26.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
+ - run: git clone --depth 1 --branch v0.28.0 https://github.com/OpenVibers/OpenVibe.Contracts.git ../OpenVibe.Contracts
- run: npm test # includes the pack test (npm pack + install into a temp project)
- run: npm test # includes the pack test (npm pack + install into a temp project)
- run: node scripts/esm.js --check
- run: node scripts/esm.js --check
@@ -3,6 +3,53 @@
@@ -3,6 +3,53 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.3.1 (2026-09-23)
+
+The remaining gaps OpenVibe.Examples found, so its examples need no local stand-ins for Events or
+Media. Contract types re-copied from openvibe-contracts v0.28.0 (adds `codes.app-manifest@1` as
+`AppManifest`); CI checks them against that tag.
+
+- **Events, developer apps.** New `projectKey(projectId)` (`prj_01JAB…` -> `p01jab…`),
+ `appSource(appId)` (`app_01JAB…` -> `app-01jab…`) and `createAppEvents(client, { projectId,
+ appId, onBehalfOf? })`: the EventsClient calls with project-relative types and patterns
+ (`order.shipped` -> `app.<project_key>.order.shipped`, `*` -> `app.<project_key>.*`), the app's
+ source, and the app (or the `onBehalfOf` person) as actor and default subject; `pull`/`iterate`
+ take `platformTopics` for public first-party events; subscriptions and checkpoints are scoped the
+ same way. Types and ESM included.
+- **Media.** For a sandbox file (`sandbox: true` from Media) the client now sets `public_url: null`
+ and adds `signed_url` (Media's signed, expiring `url`); it used to put the signed URL in
+ `public_url`. Media's own fields (`url`, `sandbox`, `url_expires_at`, `app_id`) are passed
+ through unchanged. **Behaviour change** for code that read `public_url` of sandbox files.
+- **Mock Events** plays OpenVibe.Events' developer-app rules: app tokens judged on
+ `events.app.publish | read | subscribe` only (sandbox accepted there); `app.<project_key>.*`
+ types, `app-<ulid>` source, actor the app or its `on_behalf_of` user; reads, `get`, checkpoints
+ and subscriptions limited to the own project in the token's env plus public first-party events;
+ https endpoints that are not loopback, private or local names; events stored with `project_id`
+ and `env`, deliveries never cross projects or environments; first-party readers and subscribers
+ see no sandbox events and app events only through `app.*`; realtime streams no app events and
+ refuses app tokens. Envelopes are checked against the envelope patterns (3+ segment types,
+ source syntax), topic patterns against Events' syntax, and first-party services cannot publish
+ `app.*`. Not modelled: per-project quotas, revocation, DNS resolution of endpoints.
+- **Mock Media** follows Media's tenantAuth: `media.object.upload` uploads and deletes,
+ `media.object.read` lists and gets, for app keys, service tokens and app tokens alike (service
+ tokens used to be upload-only). A developer app reaches only `/api/v1/<its project_id>/files`;
+ its token's env picks the tenant, `prj_…` or `prj_…-sandbox` (created on first use, 1 GB and
+ 100 MB, `mediaQuotaMb`), and Media's tenant-tagged keys are used. Sandbox app tokens are accepted
+ there without `acceptSandbox`, as in production (**behaviour change**: the mock used to refuse
+ them). Sandbox files answer with `sandbox: true`, a signed `url` and `url_expires_at`; the new
+ `GET /f/:key` serves files, sandbox ones only with a valid signature (404 otherwise). Projects no
+ longer get an API-key tenant in `mediaApps`.
+- **Mock Tools** answers `/api/v1/jobs` at `https://img.openvibe.tools`,
+ `https://audio.openvibe.tools` and `https://docs.openvibe.tools` as well as `origins.tools`
+ (`platform.toolsOrigins`, `DEFAULT_TOOLS_SATELLITES`, option `toolsSatellites`); a job is found
+ only on the origin that created it.
+- **Mock catalog and descriptor** follow openvibe-contracts v0.28.0: `DEFAULT_APP_CATALOG` is its
+ public + active capabilities (adds `events.app.*`, `codes.release.*`, `community.*`, `vip.*`),
+ and the default `contractsVersion` is 0.28.0.
+- Mock state: `state.events[]` entries carry `project_id` and `env`, `state.mediaTenants` is new,
+ checkpoints are stored as `{ cursor, updated_at }`; `publishEvent()` stores an event of publisher
+ `app:<id>` as that registered app's (project and env), or takes `{ projectId, env }`.
+
## 0.3.0 (2026-09-23)
## 0.3.0 (2026-09-23)
Developer apps (Network developer projects, ADR-014) and the gaps OpenVibe.Examples found.
Developer apps (Network developer projects, ADR-014) and the gaps OpenVibe.Examples found.
@@ -2,17 +2,17 @@
@@ -2,17 +2,17 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.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.
+**Status:** alpha, v0.3.1 (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; OpenVibe.Examples uses 0.3.0.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.0
+npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.3.1
```
```
-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).
+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.28.0, checked in CI).
## Quick start
## Quick start
@@ -93,6 +93,21 @@ const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.p
@@ -93,6 +93,21 @@ const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.p
file.public_url; // https://openvibe.media/f/<key>
file.public_url; // https://openvibe.media/f/<key>
```
```
+The token's `env` picks the tenant: a sandbox app's files go to the project's sandbox tenant (`app_id: '<prj_…>-sandbox'`) and are never served publicly. Media answers them with `sandbox: true`, a signed, expiring `url` and `url_expires_at`; the client adds `signed_url` (the same URL) and sets `public_url` to `null`. Uploading and deleting need `media.object.upload`, listing and getting `media.object.read`.
+
+App events (`events.app.publish | read | subscribe`) live under the project's own topic `app.<project_key>.*` (`project_key` = `p` + the lowercased project ULID) with `source: app-<lowercased app ULID>`. `createAppEvents()` fills that in, and scopes pulls and subscriptions to the project:
+
+```js
+const { createAppEvents } = require('openvibe-sdk/events');
+const events = createAppEvents(client, { projectId: unverifiedClaims.project_id, appId: process.env.OV_CLIENT_ID });
+await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } });
+// -> app.p01k5….order.shipped, source app-01k5…, actor { type: 'app', id: 'app_…' } (onBehalfOf: the person instead)
+const page = await events.pull({ topic: 'order.*', platformTopics: ['live.stream.*'], afterSeq }); // + public first-party events
+await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/openvibe' }); // public https only
+```
+
+Events keeps sandbox and production apart: a sandbox app sees and receives only its project's sandbox events (and public first-party ones), a production app only production ones. Realtime (SSE) never streams app events; pull or subscribe. `projectKey(projectId)` and `appSource(appId)` are exported for code that builds envelopes itself.
+
"Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`).
"Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`).
```js
```js
@@ -129,7 +144,7 @@ for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id);
@@ -129,7 +144,7 @@ for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id);
const res = await jobs.file(job.id, 0); // raw Response
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.
+`test/testing.test.js`, `test/apps.test.js`, `test/app-events.test.js`, `test/mock-services.test.js`, `test/projects.test.js` and `test/jobs.test.js` run these flows end to end against `openvibe-sdk/testing`. What the live platform allows today (sandbox audiences, allowances, which capabilities are grantable) is in the Network doc above and in OpenVibe.Examples' README.
## API
## API
@@ -140,7 +155,7 @@ const res = await jobs.file(job.id, 0);
@@ -140,7 +155,7 @@ const res = await jobs.file(job.id, 0);
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
-| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery` |
+| `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `verifyDelivery`, `signDelivery`, `parseDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
@@ -182,7 +197,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
@@ -182,7 +197,7 @@ Server-only subpaths are declared `"browser": null` in the exports map, so a bun
- It rejects service-principal tokens (`token.not_user`).
- It rejects service-principal tokens (`token.not_user`).
It returns the claims, including `subject_id`.
It returns the claims, including `subject_id`.
-- `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.
+- `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts (v0.26 and later) `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`.
- `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.
- `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.
- `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization.
@@ -205,13 +220,13 @@ const client = createClient({ fetch: platform.fetch, tokenProvider: createServic
@@ -205,13 +220,13 @@ const client = createClient({ fetch: platform.fetch, tokenProvider: createServic
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:
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; 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`.
- **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.
+- **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. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included).
+- **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `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`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server.
+- **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature.
+- **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides), each with its own jobs: 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).
+Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent(envelope, publisher, { projectId, env })` (a registered app's `app:<id>` publisher implies them), `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
### Browser without a bundler
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.3.0",
+ "version": "0.3.1",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"updated": "2026-09-23"
"updated": "2026-09-23"
}
}
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.3.1, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// 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.
// (auth, registry, modules, realtime, media, community, jobs, projects). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
@@ -36,7 +36,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.3.0';
+const SDK_VERSION = '0.3.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1195,10 +1195,16 @@ function (module, exports) {
@@ -1195,10 +1195,16 @@ function (module, exports) {
* openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
* 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
* 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).
+ * secret), a Network service token for the app's namespace, or a developer app token on its
+ * project's tenant (`app` = the project id, prj_…). Tokens need media.object.upload to upload and
+ * delete, media.object.read to list and get. 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.
* Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
+ *
+ * Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),
+ * whose files are never served publicly: Media answers them with `sandbox: true`, a signed,
+ * expiring `url` and `url_expires_at`. The client adds `signed_url` (that URL) and sets
+ * `public_url` to null for them; other files get `public_url` (the absolute `url`).
*/
*/
const { isOpenVibeError } = __load(3);
const { isOpenVibeError } = __load(3);
const { paginate, offsetPager } = __load(7);
const { paginate, offsetPager } = __load(7);
@@ -1250,11 +1256,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
@@ -1250,11 +1256,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
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 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 withPublic = (meta) => {
+ if (!meta || !meta.url) return meta;
+ if (meta.sandbox) return { ...meta, public_url: null, signed_url: urls.absolute(meta.url) };
+ return { ...meta, public_url: urls.absolute(meta.url) };
+ };
const files = {
const files = {
/**
/**
- * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.
+ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }
+ * (sandbox files: { sandbox: true, url, url_expires_at, signed_url, public_url: null, … }).
* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
*/
*/
async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
@@ -3,5 +3,6 @@ import cjs from '../src/events.js';
@@ -3,5 +3,6 @@ import cjs from '../src/events.js';
export const {
export const {
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
+ projectKey, appSource, createAppEvents,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/testing/index.js';
import cjs from '../src/testing/index.js';
export const {
export const {
- createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG,
+ createMockPlatform, DEFAULT_ORIGINS, DEFAULT_TOOLS_SATELLITES, DEFAULT_APP_CATALOG,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.0",
+ "version": "0.3.1",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.0",
+ "version": "0.3.1",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"better-sqlite3": "^11.10.0"
"better-sqlite3": "^11.10.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.3.0",
+ "version": "0.3.1",
"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.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Tools jobs and developer projects, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.3.0';
+const SDK_VERSION = '0.3.1';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -7,6 +7,10 @@
@@ -7,6 +7,10 @@
* events.subscription.manage subscriptions.*
* events.subscription.manage subscriptions.*
* events.delivery.admin deliveries(), replay()
* events.delivery.admin deliveries(), replay()
*
*
+ * Developer apps (Network ADR-014) use the same routes with an app token and the capabilities
+ * events.app.publish | events.app.read | events.app.subscribe; createAppEvents() fills in what
+ * Events requires of them (see below).
+ *
* Browsers use openvibe-sdk/realtime (SSE) instead.
* Browsers use openvibe-sdk/realtime (SSE) instead.
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
@@ -155,6 +159,118 @@ function createEventsClient(client, { source, baseUrl } = {}) {
@@ -155,6 +159,118 @@ function createEventsClient(client, { source, baseUrl } = {}) {
};
};
}
}
+// ── Developer apps ───────────────────────────────────────────
+//
+// OpenVibe.Events rules for an app token (sub app:app_<ULID>, project_id prj_<ULID>, env):
+// event_type app.<project_key>.<name…> project_key = 'p' + the project's ULID in lowercase
+// source app-<the app's ULID in lowercase>
+// actor { type: 'app', id: app_… } or the user the token acts for (on_behalf_of)
+// reads own project in the token's env + public first-party events; every topic pattern
+// starts with a literal segment and app.* patterns name the own project_key
+// webhooks https endpoints on public addresses only
+// Realtime (SSE) never streams app events.
+
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const PROJECT_ID_RE = new RegExp(`^prj_(${ULID})$`);
+const APP_ID_RE = new RegExp(`^(?:app:)?app_(${ULID})$`);
+const SEGMENT_RE = /^[a-z0-9_]+$/;
+
+/** 'prj_01JAB…' -> 'p01jab…' (the second segment of the project's event types); null if not a project id. */
+function projectKey(projectId) {
+ const m = PROJECT_ID_RE.exec(String(projectId || ''));
+ return m ? `p${m[1].toLowerCase()}` : null;
+}
+
+/** 'app_01JAB…' (or 'app:app_01JAB…') -> 'app-01jab…' (the app's event source); null if not an app id. */
+function appSource(appId) {
+ const m = APP_ID_RE.exec(String(appId || ''));
+ return m ? `app-${m[1].toLowerCase()}` : null;
+}
+
+/**
+ * An events client scoped to one developer app of one project:
+ *
+ * const events = createAppEvents(client, { projectId: 'prj_…', appId: 'app_…' });
+ * await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload });
+ * // -> app.<project_key>.order.shipped, source app-<ulid>, actor { type: 'app', id: 'app_…' }
+ * await events.pull({ topic: 'order.*' }) // app.<project_key>.order.*
+ * await events.pull({ topic: '*', platformTopics: ['live.stream.*'] }) // + public first-party events
+ * await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/ov' })
+ *
+ * Event types and topic patterns are relative to the project (`order.shipped`, `order.*`, `*`);
+ * a full `app.<project_key>.…` name is kept as it is, and a name under another project's key
+ * throws. `onBehalfOf` (a usr_… id, the token's on_behalf_of) makes that person the default actor.
+ * `subject` defaults to the app. First-party topics go in `platformTopics` (reads only).
+ */
+function createAppEvents(client, { projectId, appId, onBehalfOf, baseUrl } = {}) {
+ const key = projectKey(projectId);
+ if (!key) throw new TypeError('createAppEvents: projectId must be prj_<ULID>');
+ const source = appSource(appId);
+ if (!source) throw new TypeError('createAppEvents: appId must be app_<ULID>');
+ const app = String(appId).replace(/^app:/, '');
+ const prefix = `app.${key}.`;
+ const events = createEventsClient(client, { source, baseUrl });
+
+ /** A project-relative name or pattern -> the full one. */
+ function topic(name) {
+ const s = String(name == null ? '' : name).trim();
+ if (!s) throw new TypeError('an event type or topic pattern is required');
+ if (s === `app.${key}` || s.startsWith(prefix)) return s;
+ if (s === 'app' || s.startsWith('app.')) {
+ const other = s.split('.')[1];
+ if (other && /^p[0-9a-z]{26}$/.test(other)) throw new TypeError(`${s} names another project (this app's types are ${prefix}*)`);
+ }
+ return `${prefix}${s}`;
+ }
+ const topics = (t) => (Array.isArray(t) ? t : String(t).split(',')).map((x) => x.trim()).filter(Boolean).map(topic);
+ const readTopics = (t, platformTopics = []) => {
+ const extra = (Array.isArray(platformTopics) ? platformTopics : [platformTopics]).filter(Boolean).map(String);
+ for (const p of extra) if (p === '*' || p.startsWith('*') || p === 'app' || p.startsWith('app.')) throw new TypeError(`platformTopics are first-party patterns with a literal first segment, not ${p}`);
+ return [...topics(t), ...extra];
+ };
+
+ function prepare(envelope, opts) {
+ if (!envelope || typeof envelope !== 'object' || Array.isArray(envelope)) throw new TypeError('an event envelope must be an object');
+ if (envelope.source && envelope.source !== source) throw new TypeError(`source must be ${source}`);
+ const type = topic(envelope.event_type);
+ if (!type.slice(prefix.length).split('.').every((seg) => SEGMENT_RE.test(seg))) throw new TypeError(`${type}: segments are [a-z0-9_]+`);
+ const actor = envelope.actor || (onBehalfOf ? { type: 'user', id: onBehalfOf } : { type: 'app', id: app });
+ return events.prepare({ ...envelope, event_type: type, source, actor, subject: envelope.subject || { type: 'app', id: app } }, opts);
+ }
+
+ return {
+ projectId,
+ appId: app,
+ projectKey: key,
+ source,
+ prefix,
+ topic,
+ prepare,
+ /** Same as EventsClient.publish, with the app's type prefix, source and actor filled in. */
+ publish(input, opts) {
+ return events.publish(Array.isArray(input) ? input.map((e) => prepare(e)) : prepare(input), opts);
+ },
+ pull: ({ topic: t = '*', platformTopics, ...rest } = {}) => events.pull({ ...rest, topic: readTopics(t, platformTopics) }),
+ iterate: ({ topic: t = '*', platformTopics, ...rest } = {}) => events.iterate({ ...rest, topic: readTopics(t, platformTopics) }),
+ get: (eventId) => events.get(eventId),
+ getCheckpoint: (t) => events.getCheckpoint(topic(t)),
+ setCheckpoint: (t, cursor) => events.setCheckpoint(topic(t), cursor),
+ subscriptions: {
+ create: ({ topicPattern = '*', ...rest } = {}) => events.subscriptions.create({ ...rest, topicPattern: topic(topicPattern) }),
+ list: () => events.subscriptions.list(),
+ get: (id) => events.subscriptions.get(id),
+ disable: (id) => events.subscriptions.disable(id),
+ enable: (id) => events.subscriptions.enable(id),
+ },
+ subscribe: ({ topicPattern = '*', ...rest } = {}) => events.subscriptions.create({ ...rest, topicPattern: topic(topicPattern) }),
+ /** The underlying EventsClient (no scoping). */
+ events,
+ };
+}
+
const { createOutbox, createInbox } = require('./outbox');
const { createOutbox, createInbox } = require('./outbox');
-module.exports = { createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox };
+module.exports = {
+ createEventsClient, verifyDelivery, signDelivery, parseDelivery, createOutbox, createInbox,
+ projectKey, appSource, createAppEvents,
+};
@@ -3,10 +3,16 @@
@@ -3,10 +3,16 @@
* openvibe-sdk/media: OpenVibe.Media files (Media API v1, /api/v1/:app/files) and public URL helpers.
* 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
* 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).
+ * secret), a Network service token for the app's namespace, or a developer app token on its
+ * project's tenant (`app` = the project id, prj_…). Tokens need media.object.upload to upload and
+ * delete, media.object.read to list and get. 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.
* Browser-safe code (FormData/Blob), but the credentials it needs belong on a server.
+ *
+ * Sandbox app tokens land in the project's sandbox tenant (`<prj_…>-sandbox`, shown as `app_id`),
+ * whose files are never served publicly: Media answers them with `sandbox: true`, a signed,
+ * expiring `url` and `url_expires_at`. The client adds `signed_url` (that URL) and sets
+ * `public_url` to null for them; other files get `public_url` (the absolute `url`).
*/
*/
const { isOpenVibeError } = require('./core/errors');
const { isOpenVibeError } = require('./core/errors');
const { paginate, offsetPager } = require('./core/paginate');
const { paginate, offsetPager } = require('./core/paginate');
@@ -58,11 +64,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
@@ -58,11 +64,16 @@ function createMediaClient(client, { app, apiKey, actingUserId, baseUrl, publicO
return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
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 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 withPublic = (meta) => {
+ if (!meta || !meta.url) return meta;
+ if (meta.sandbox) return { ...meta, public_url: null, signed_url: urls.absolute(meta.url) };
+ return { ...meta, public_url: urls.absolute(meta.url) };
+ };
const files = {
const files = {
/**
/**
- * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }.
+ * Multipart upload (field `file`) -> { key, url, public_url, size, mime, sha256, … }
+ * (sandbox files: { sandbox: true, url, url_expires_at, signed_url, public_url: null, … }).
* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
* Keys are content-addressed, so a repeat upload is deduplicated; retries are safe.
*/
*/
async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
async upload(file, { filename, contentType, userId, actingUserId: acting, signal } = {}) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * OpenVibe.Events' developer-app rules for the mock platform (Node only), as Events implements
+ * them (server/apps.js, server/api/*, server/egress.js; capabilities events.app.publish | read |
+ * subscribe in openvibe-contracts v0.28.0):
+ *
+ * - an app token (sub app:app_<ULID>) is judged on the events.app.* capability of the route only;
+ * sandbox app tokens are accepted on these routes (and nowhere else)
+ * - publish: event_type app.<project_key>.<name…>, source app-<lowercased app ULID>, actor the
+ * app or the user the token acts for; stored with the token's project_id and env
+ * - reads (pull, get, checkpoints): the app's project in the token's env + public first-party
+ * events; every pattern starts with a literal segment, app.* patterns name the own project_key
+ * - subscriptions: the same scope, https endpoints that are not loopback/private/local names
+ * - first-party readers and subscribers never see sandbox events, and see app events only
+ * through a pattern that starts with `app.`; realtime streams neither
+ */
+const net = require('node:net');
+const { projectKey, appSource } = require('../events');
+
+const ULID = '[0-9A-HJKMNP-TV-Z]{26}';
+const APP_SUB_RE = new RegExp(`^app:app_(${ULID})$`);
+const ENVS = ['sandbox', 'production'];
+
+/** Events' topic pattern syntax: dot-separated [a-z0-9_]+ or `*` (one or more segments), no `*.*`. */
+const PATTERN_RE = /^(\*|[a-z0-9_]+)(\.(\*|[a-z0-9_]+))*$/;
+const isValidPattern = (p) => typeof p === 'string' && p.length > 0 && p.length <= 200 && PATTERN_RE.test(p) && !p.includes('*.*');
+
+/** The app principal for verified claims, or { error }. */
+function appPrincipal(claims) {
+ const m = APP_SUB_RE.exec(String(claims && claims.sub));
+ if (!m) return { error: 'not an app token' };
+ if (claims.actor_type && claims.actor_type !== 'app') return { error: 'actor_type must be app' };
+ const key = projectKey(claims.project_id);
+ if (!key) return { error: 'app token without a project_id' };
+ const env = claims.env === undefined ? 'production' : claims.env;
+ if (!ENVS.includes(env)) return { error: `unknown env ${claims.env}` };
+ if (Array.isArray(claims.ns) && claims.ns.length && !claims.ns.includes(claims.project_id)) return { error: 'project_id is not in the token namespaces' };
+ return {
+ kind: 'app', sub: claims.sub, appId: `app_${m[1]}`, projectId: claims.project_id, projectKey: key,
+ source: appSource(`app_${m[1]}`), prefix: `app.${key}.`, env,
+ onBehalfOf: typeof claims.on_behalf_of === 'string' ? claims.on_behalf_of : null,
+ };
+}
+
+/** null when an app may use this pattern, else the reason. */
+function patternScopeError(pattern, app) {
+ if (!isValidPattern(pattern)) return 'topic patterns are dot-separated segments of [a-z0-9_] or *';
+ const [first, second] = pattern.split('.');
+ if (first === '*') return 'an app topic pattern must start with a literal segment (e.g. app.<project_key>.* or live.*)';
+ if (first === 'app' && second !== app.projectKey) return `app.* patterns must name your project: app.${app.projectKey}.*`;
+ return null;
+}
+
+/** `stored` is the mock's { seq, event, project_id, env }. */
+function visibleToApp(stored, scope) {
+ if (stored.project_id) return stored.project_id === scope.projectId && (stored.env || 'production') === scope.env;
+ return stored.event.visibility === 'public' && (stored.env || 'production') === 'production';
+}
+
+function serviceSees(pattern, stored) {
+ if ((stored.env || 'production') !== 'production') return false;
+ return !(stored.project_id && !pattern.startsWith('app.'));
+}
+
+// ── Endpoints (the syntax half of Events' SSRF guard; the mock does no DNS) ──
+
+// Events' non-public ranges. Where Events looks inside IPv4-mapped, NAT64 and 6to4 addresses, the
+// mock refuses those ranges outright (stricter, never looser).
+const BLOCKED = new net.BlockList();
+for (const [a, p] of [['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8], ['169.254.0.0', 16], ['172.16.0.0', 12],
+ ['192.0.0.0', 24], ['192.0.2.0', 24], ['192.88.99.0', 24], ['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
+ ['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4]]) BLOCKED.addSubnet(a, p, 'ipv4');
+for (const [a, p] of [['::', 128], ['::1', 128], ['::', 96], ['::ffff:0:0', 96], ['100::', 64], ['2001::', 23], ['2001:db8::', 32],
+ ['2002::', 16], ['64:ff9b::', 96], ['fc00::', 7], ['fe80::', 10], ['fec0::', 10], ['ff00::', 8]]) BLOCKED.addSubnet(a, p, 'ipv6');
+
+function isPublicLiteral(host) {
+ const family = net.isIP(host);
+ return Boolean(family) && !BLOCKED.check(host, family === 4 ? 'ipv4' : 'ipv6');
+}
+
+/** { ok, url } or { ok: false, reason } for an app subscription endpoint. */
+function checkAppEndpoint(endpoint) {
+ if (typeof endpoint !== 'string' || endpoint.length > 2048) return { ok: false, reason: 'endpoint must be a URL of at most 2048 characters' };
+ let url;
+ try { url = new URL(endpoint); } catch { return { ok: false, reason: 'not a URL' }; }
+ if (url.protocol !== 'https:') return { ok: false, reason: 'app endpoints must use https' };
+ if (url.username || url.password) return { ok: false, reason: 'credentials in the URL are not allowed' };
+ let host = url.hostname.toLowerCase();
+ if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
+ if (host.endsWith('.')) return { ok: false, reason: 'trailing-dot hostnames are not allowed' };
+ if (net.isIP(host)) return isPublicLiteral(host) ? { ok: true, url } : { ok: false, reason: `${host} is not a public address` };
+ if (!host.includes('.') || host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local') || host.endsWith('.internal')) {
+ return { ok: false, reason: `${host} is not a public hostname` };
+ }
+ return { ok: true, url };
+}
+
+/** Events' retry_policy check: { ok, value } or { ok: false, reason }. */
+function checkRetryPolicy(p) {
+ if (p == null) return { ok: true, value: null };
+ if (typeof p !== 'object' || Array.isArray(p)) return { ok: false, reason: 'retry_policy must be an object' };
+ for (const k of Object.keys(p)) if (!['max_attempts', 'backoff_ms'].includes(k)) return { ok: false, reason: `unknown retry_policy field ${k}` };
+ if (p.max_attempts !== undefined && (!Number.isInteger(p.max_attempts) || p.max_attempts < 1 || p.max_attempts > 20)) return { ok: false, reason: 'max_attempts must be 1..20' };
+ if (p.backoff_ms !== undefined && (!Array.isArray(p.backoff_ms) || !p.backoff_ms.length || p.backoff_ms.length > 20
+ || !p.backoff_ms.every((n) => Number.isInteger(n) && n >= 0 && n <= 86400000))) return { ok: false, reason: 'backoff_ms must be 1..20 integers between 0 and 86400000' };
+ return { ok: true, value: Object.keys(p).length ? p : null };
+}
+
+module.exports = { appPrincipal, patternScopeError, visibleToApp, serviceSees, isValidPattern, checkAppEndpoint, checkRetryPolicy, APP_SUB_RE };
@@ -6,7 +6,8 @@
@@ -6,7 +6,8 @@
*
*
* Deliberate differences from Network, so tests need no staff steps (each is an option):
* 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
* - sandbox apps get tokens for any audience (`sandboxAudiences` restricts it like
- * DEV_SANDBOX_AUDIENCES); receivers still refuse them unless `acceptSandbox` opts them in
+ * DEV_SANDBOX_AUDIENCES); Media and Events accept them on their app routes as in production,
+ * other receivers refuse them unless `acceptSandbox` opts them in
* - projects created through the API start with every catalog capability in their allowance
* - projects created through the API start with every catalog capability in their allowance
* (`defaultAllowance: []` matches Network's empty default)
* (`defaultAllowance: []` matches Network's empty default)
* - projects declared in options allow sandbox and production apps and any capability
* - projects declared in options allow sandbox and production apps and any capability
@@ -22,10 +23,17 @@ const CODE_TTL_MS = 5 * 60 * 1000;
@@ -22,10 +23,17 @@ const CODE_TTL_MS = 5 * 60 * 1000;
const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
const RANK = { viewer: 1, developer: 2, admin: 3, owner: 4 };
const ENV_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
const ENV_POLICIES = { sandbox: ['sandbox'], 'sandbox+production': ['sandbox', 'production'] };
-/** Capabilities that are public + active in openvibe-contracts v0.26.0 (grantable to apps). */
+/**
+ * Capabilities that are public + active in openvibe-contracts v0.28.0, the ones Network grants to
+ * apps (network.project.manage is public but planned, so not grantable).
+ */
const DEFAULT_APP_CATALOG = [
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',
+ 'codes.release.manage', 'codes.release.read', 'community.paste.create', 'community.post.create',
+ 'events.app.publish', 'events.app.read', 'events.app.subscribe',
+ 'games.mod.read', 'games.prop.place', 'games.world.announce',
+ 'media.object.read', 'media.object.upload',
+ 'tools.job.cancel', 'tools.job.create', 'tools.job.read',
+ 'vip.perk.list', 'vip.plan.list',
];
];
function createDeveloper(ctx) {
function createDeveloper(ctx) {
@@ -66,8 +74,6 @@ function createDeveloper(ctx) {
@@ -66,8 +74,6 @@ function createDeveloper(ctx) {
if (owner) p.members.set(owner, 'owner');
if (owner) p.members.set(owner, 'owner');
for (const [subject, role] of Object.entries(members || {})) p.members.set(subject, role);
for (const [subject, role] of Object.entries(members || {})) p.members.set(subject, role);
projects.set(pid, p);
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;
return p;
}
}
@@ -16,9 +16,15 @@
@@ -16,9 +16,15 @@
* /api/v1/registry/*, /api/v1/projects/*, /api/modules/*, /internal/modules/*,
* /api/v1/registry/*, /api/v1/projects/*, /api/modules/*, /internal/modules/*,
* /internal/identity/*
* /internal/identity/*
* Events /api/v1/events (with retention gaps after pruneEvents()), /api/v1/subscriptions,
* 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 })
+ * /api/v1/checkpoints, /realtime/stream (SSE); deliverEvents() plays the delivery worker.
+ * Developer apps as in Events (events.app.publish|read|subscribe, see ./apps.js):
+ * app.<project_key>.* types, source app-<ulid>, actor the app or its on_behalf_of user,
+ * reads scoped to the own project + public first-party events, https endpoints,
+ * sandbox and production never mixed. Not modelled: app quotas, revocation, DNS checks.
+ * Media /api/v1/:app/files and /f/:key. Developer apps reach /api/v1/<project_id>/files only:
+ * production tenant prj_…, sandbox tenant prj_…-sandbox whose files have signed URLs only
+ * Tools /api/v1/jobs (with { jobs: true }) at origins.tools and the img., audio. and docs.
+ * satellites (platform.toolsOrigins)
* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
* Tokens are real RS256 JWTs signed with a key generated per platform, checked the way the real
* services check them (audience, capability, namespace, sandbox refusal). It is a fake: no
* 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
* persistence, simplified visibility rules, no Chat. Like the Network, it verifies the PKCE
@@ -26,8 +32,9 @@
@@ -26,8 +32,9 @@
*/
*/
const crypto = require('node:crypto');
const crypto = require('node:crypto');
const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');
const { b64url, fromB64url, ulid, topicRegex, json, problem, redirect } = require('./util');
-const { createDeveloper, DEFAULT_APP_CATALOG } = require('./developer');
+const { createDeveloper, DEFAULT_APP_CATALOG, PRJ_ID_RE } = require('./developer');
const { createJobsService } = require('./jobs');
const { createJobsService } = require('./jobs');
+const appRules = require('./apps');
const DEFAULT_ORIGINS = {
const DEFAULT_ORIGINS = {
network: 'https://openvibe.network',
network: 'https://openvibe.network',
@@ -37,10 +44,14 @@ const DEFAULT_ORIGINS = {
@@ -37,10 +44,14 @@ const DEFAULT_ORIGINS = {
tools: 'https://openvibe.tools',
tools: 'https://openvibe.tools',
};
};
+/** The Tools satellites that run jobs (/api/v1/jobs); the mock answers them too. */
+const DEFAULT_TOOLS_SATELLITES = ['https://img.openvibe.tools', 'https://audio.openvibe.tools', 'https://docs.openvibe.tools'];
+
function createMockPlatform(opts = {}) {
function createMockPlatform(opts = {}) {
const origins = { ...DEFAULT_ORIGINS, ...opts.origins };
const origins = { ...DEFAULT_ORIGINS, ...opts.origins };
+ const toolsOrigins = [origins.tools, ...(opts.toolsSatellites || DEFAULT_TOOLS_SATELLITES)].map((o) => new URL(o).origin);
const issuer = opts.issuer || origins.network;
const issuer = opts.issuer || origins.network;
- const contractsVersion = opts.contractsVersion || '0.26.0';
+ const contractsVersion = opts.contractsVersion || '0.28.0';
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const kid = 'mock-1';
const kid = 'mock-1';
const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };
const jwk = { ...publicKey.export({ format: 'jwk' }), use: 'sig', alg: 'RS256', kid };
@@ -56,7 +67,8 @@ function createMockPlatform(opts = {}) {
@@ -56,7 +67,8 @@ function createMockPlatform(opts = {}) {
const oldestSeq = () => (events.length ? events[0].seq : lastSeq + 1);
const oldestSeq = () => (events.length ? events[0].seq : lastSeq + 1);
const subscriptions = new Map();
const subscriptions = new Map();
const checkpoints = new Map();
const checkpoints = new Map();
- const files = new Map(); // `${app}|${key}` -> meta
+ const files = new Map(); // `${tenant}|${key}` -> meta (tenant: app id, prj_…, prj_…-sandbox)
+ const mediaTenants = new Map(); // developer-project tenants, created on first use
const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
const streams = new Set();
const streams = new Set();
const stats = { tokenRequests: 0, requests: [], deliveries: [] };
const stats = { tokenRequests: 0, requests: [], deliveries: [] };
@@ -340,42 +352,100 @@ function createMockPlatform(opts = {}) {
@@ -340,42 +352,100 @@ function createMockPlatform(opts = {}) {
// ── Events ──────────────────────────────────────────────
// ── Events ──────────────────────────────────────────────
function visible(viewer, e) {
function visible(viewer, e) {
+ if (e.project_id || (e.env || 'production') !== 'production') return false; // never streamed
if (e.event.visibility === 'public') return true;
if (e.event.visibility === 'public') return true;
if (viewer.kind === 'service') return true;
if (viewer.kind === 'service') return true;
return e.event.visibility === 'subject' && viewer.kind === 'user' && (e.event.actor.id === viewer.subject || (e.event.subject.type === 'user' && e.event.subject.id === viewer.subject));
return e.event.visibility === 'subject' && viewer.kind === 'user' && (e.event.actor.id === viewer.subject || (e.event.subject.type === 'user' && e.event.subject.id === viewer.subject));
}
}
- function publishEvent(env, publisher) {
+ function publishEvent(env, publisher, { projectId = null, env: environment = 'production' } = {}) {
const dup = events.find((e) => e.event.event_id === env.event_id);
const dup = events.find((e) => e.event.event_id === env.event_id);
if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };
if (dup) return { event_id: env.event_id, seq: dup.seq, duplicate: true };
- const stored = { seq: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher };
+ const stored = { seq: ++lastSeq, event: { priority: 'important', visibility: 'internal', ...env }, publisher, project_id: projectId, env: environment };
events.push(stored);
events.push(stored);
for (const s of streams) s.push(stored);
for (const s of streams) s.push(stored);
return { event_id: env.event_id, seq: stored.seq, duplicate: false };
return { event_id: env.event_id, seq: stored.seq, duplicate: false };
}
}
+ /**
+ * Events' appOrService guard: an app token (sub app:…) is judged on `appCap` only (sandbox
+ * accepted) and becomes { kind: 'app', … }; anything else must hold `serviceCap`.
+ */
+ function eventsPrincipal(req, serviceCap, appCap, { requireService = false } = {}) {
+ const auth = req.headers.get('authorization') || '';
+ const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
+ if (claims && /^app:/.test(String(claims.sub))) {
+ if (!(claims.aud || []).includes('openvibe.events')) return { res: problem(401, 'token.wrong_audience', 'not for openvibe.events') };
+ const app = appRules.appPrincipal(claims);
+ if (app.error) return { res: problem(401, 'token.invalid_claims', app.error) };
+ if (!appCap || !hasCap(claims, appCap)) return { res: problem(403, 'capability.denied', appCap ? `${appCap} not granted` : 'app tokens are not accepted on this route') };
+ return { principal: app, consumer: app.sub };
+ }
+ const who = principal(req, 'openvibe.events', serviceCap);
+ if (who.res) return who;
+ const service = /^svc:([a-z][a-z0-9-]{1,39})$/.exec(String(who.claims.sub));
+ if (requireService && !service) return { res: problem(403, 'capability.denied', 'only service principals may do this') };
+ return { principal: { kind: 'service', sub: who.claims.sub, service: service ? service[1] : null }, consumer: who.service };
+ }
+ const hasCap = (claims, id) => (claims.cap || []).some((c) => c === id || (c.endsWith('.*') && id.startsWith(c.slice(0, -1))));
+ const scopeError = (p, patterns) => {
+ if (p.kind !== 'app') return null;
+ for (const x of patterns) { const err = appRules.patternScopeError(x, p); if (err) return `${x}: ${err}`; }
+ return null;
+ };
+ /** Does this reader see this stored event (topic match aside)? */
+ const readable = (p, stored, pattern) => (p.kind === 'app' ? appRules.visibleToApp(stored, p) : appRules.serviceSees(pattern, stored));
+
+ function checkPublish(e, p) {
+ if (!e || typeof e !== 'object' || Array.isArray(e) || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(e.event_id || '') || typeof e.event_type !== 'string'
+ || !/^[a-z][a-z0-9_]*(\.[a-z0-9_]+){2,}$/.test(e.event_type) || !e.actor || !e.subject || !e.timestamp || !/^[a-z][a-z0-9-]{1,39}$/.test(String(e.source))) {
+ return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');
+ }
+ if (p.kind === 'app') {
+ if (e.source !== p.source) return problem(403, 'events.source_mismatch', `source must be your app's "${p.source}", not "${e.source}"`);
+ if (!e.event_type.startsWith(p.prefix)) return problem(403, 'events.type_not_allowed', `an app may publish ${p.prefix}<name> only, not ${e.event_type}`);
+ const a = e.actor || {};
+ if (!((a.type === 'app' && a.id === p.appId) || (p.onBehalfOf && a.type === 'user' && a.id === p.onBehalfOf))) {
+ return problem(403, 'events.actor_mismatch', `actor must be { type: 'app', id: '${p.appId}' }${p.onBehalfOf ? ' or the user the token acts for' : ''}`);
+ }
+ return null;
+ }
+ if (e.source !== p.service) return problem(403, 'events.source_mismatch', `source "${e.source}" is not the calling service "${p.service}"`);
+ if (e.event_type.startsWith('app.')) return problem(403, 'events.type_not_allowed', `${e.source} may not publish app.* events`);
+ return null;
+ }
+
async function eventsService(req, url) {
async function eventsService(req, url) {
const path = url.pathname;
const path = url.pathname;
let r;
let r;
if (path === '/realtime/stream' && req.method === 'GET') return realtimeStream(req, url);
if (path === '/realtime/stream' && req.method === 'GET') return realtimeStream(req, url);
if (path === '/api/v1/events' && req.method === 'POST') {
if (path === '/api/v1/events' && req.method === 'POST') {
- const who = principal(req, 'openvibe.events', 'events.event.publish');
+ const who = eventsPrincipal(req, 'events.event.publish', 'events.app.publish', { requireService: true });
if (who.res) return who.res;
if (who.res) return who.res;
+ const p = who.principal;
const body = await req.json().catch(() => null);
const body = await req.json().catch(() => null);
- const batch = body && Array.isArray(body.events) && body.event_id === undefined;
+ const batch = body && typeof body === 'object' && !Array.isArray(body) && Array.isArray(body.events) && body.event_id === undefined;
const items = batch ? body.events : [body];
const items = batch ? body.events : [body];
- for (const e of items) {
- if (!e || !/^evt_[0-9A-HJKMNP-TV-Z]{26}$/.test(e.event_id || '') || !e.event_type || !e.actor || !e.subject || !e.timestamp) return problem(422, 'events.invalid_envelope', 'envelope does not match events.event-envelope@1');
- if (e.source !== who.service) return problem(403, 'events.source_mismatch', `source "${e.source}" is not the calling service "${who.service}"`);
+ if (batch && !items.length) return problem(400, 'events.bad_request', 'events must not be empty');
+ for (const [i, e] of items.entries()) {
+ const bad = checkPublish(e, p);
+ if (bad) return bad;
+ if (items.findIndex((x) => x.event_id === e.event_id) !== i) return problem(422, 'events.invalid_envelope', `events[${i}]: event_id repeated within the batch`);
}
}
- const results = items.map((e) => publishEvent(e, who.claims.sub));
+ const meta = p.kind === 'app' ? { projectId: p.projectId, env: p.env } : {};
+ const results = items.map((e) => publishEvent(e, p.sub, meta));
return json(results.some((x) => !x.duplicate) ? 201 : 200, batch ? { results } : results[0]);
return json(results.some((x) => !x.duplicate) ? 201 : 200, batch ? { results } : results[0]);
}
}
if (path === '/api/v1/events' && req.method === 'GET') {
if (path === '/api/v1/events' && req.method === 'GET') {
- const who = principal(req, 'openvibe.events', 'events.event.read');
+ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');
if (who.res) return who.res;
if (who.res) return who.res;
- const pats = (url.searchParams.get('topic') || '*').split(',').map(topicRegex);
+ const patterns = (url.searchParams.get('topic') || '*').split(',').map((x) => x.trim()).filter(Boolean);
+ if (!patterns.length || patterns.length > 20 || !patterns.every(appRules.isValidPattern)) return problem(400, 'events.bad_topic', 'topic must be 1..20 comma-separated patterns');
+ const scopeErr = scopeError(who.principal, patterns);
+ if (scopeErr) return problem(403, 'events.topic_not_allowed', scopeErr);
const after = Number(url.searchParams.get('after_seq') || 0);
const after = Number(url.searchParams.get('after_seq') || 0);
const limit = Number(url.searchParams.get('limit') || 100);
const limit = Number(url.searchParams.get('limit') || 100);
+ if (!Number.isInteger(after) || after < 0 || !Number.isInteger(limit) || limit < 1 || limit > 1000) return problem(400, 'events.bad_request', 'after_seq must be >= 0 and limit 1..1000');
+ const pats = patterns.map((x) => [x, topicRegex(x)]);
const out = [];
const out = [];
const page = {};
const page = {};
let from = after;
let from = after;
@@ -388,50 +458,80 @@ function createMockPlatform(opts = {}) {
@@ -388,50 +458,80 @@ function createMockPlatform(opts = {}) {
if (e.seq <= from) continue;
if (e.seq <= from) continue;
if (out.length >= limit) break;
if (out.length >= limit) break;
cursor = e.seq;
cursor = e.seq;
- if (pats.some((re) => re.test(e.event.event_type))) out.push({ seq: e.seq, event: e.event });
+ if (pats.some(([x, re]) => re.test(e.event.event_type) && readable(who.principal, e, x))) out.push({ seq: e.seq, event: e.event });
}
}
if (out.length < limit) cursor = Math.max(cursor, lastSeq);
if (out.length < limit) cursor = Math.max(cursor, lastSeq);
return json(200, { ...page, events: out, next_after_seq: cursor, latest_seq: lastSeq });
return json(200, { ...page, events: out, next_after_seq: cursor, latest_seq: lastSeq });
}
}
if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {
if ((r = path.match(/^\/api\/v1\/events\/([^/]+)$/)) && req.method === 'GET') {
- const who = principal(req, 'openvibe.events', 'events.event.read');
+ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');
if (who.res) return who.res;
if (who.res) return who.res;
const e = events.find((x) => x.event.event_id === decodeURIComponent(r[1]));
const e = events.find((x) => x.event.event_id === decodeURIComponent(r[1]));
- return e ? json(200, { seq: e.seq, event: e.event }) : problem(404, 'events.not_found', 'no such event');
+ const ok = e && (who.principal.kind === 'app' ? appRules.visibleToApp(e, who.principal) : (e.env || 'production') === 'production');
+ return ok ? json(200, { seq: e.seq, event: e.event }) : problem(404, 'events.not_found', 'no such event (or pruned by retention)');
}
}
if (path === '/api/v1/checkpoints') {
if (path === '/api/v1/checkpoints') {
- const who = principal(req, 'openvibe.events', 'events.event.read');
+ const who = eventsPrincipal(req, 'events.event.read', 'events.app.read');
if (who.res) return who.res;
if (who.res) return who.res;
+ const b = req.method === 'GET' ? {} : await req.json().catch(() => ({}));
+ const topic = req.method === 'GET' ? url.searchParams.get('topic') || '' : b.topic;
+ if (!appRules.isValidPattern(topic)) return problem(400, 'events.bad_request', 'topic (pattern) is required');
+ if (req.method !== 'GET' && (!Number.isInteger(b.cursor) || b.cursor < 0)) return problem(400, 'events.bad_request', 'topic (pattern) and cursor (integer >= 0) are required');
+ const scopeErr = scopeError(who.principal, [topic]);
+ if (scopeErr) return problem(403, 'events.topic_not_allowed', scopeErr);
+ const k = `${who.consumer}|${topic}`;
if (req.method === 'GET') {
if (req.method === 'GET') {
- const topic = url.searchParams.get('topic');
- return json(200, { consumer: who.service, topic, cursor: checkpoints.get(`${who.service}|${topic}`) || 0 });
+ const cp = checkpoints.get(k);
+ return json(200, { consumer: who.consumer, topic, cursor: cp ? cp.cursor : 0, updated_at: cp ? cp.updated_at : null });
}
}
- const b = await req.json().catch(() => ({}));
- checkpoints.set(`${who.service}|${b.topic}`, b.cursor);
- return json(200, { consumer: who.service, topic: b.topic, cursor: b.cursor });
+ const cp = { cursor: b.cursor, updated_at: new Date().toISOString() };
+ checkpoints.set(k, cp);
+ return json(200, { consumer: who.consumer, topic, ...cp });
}
}
if (path.startsWith('/api/v1/subscriptions')) {
if (path.startsWith('/api/v1/subscriptions')) {
- const who = principal(req, 'openvibe.events', 'events.subscription.manage');
+ const who = eventsPrincipal(req, 'events.subscription.manage', 'events.app.subscribe', { requireService: true });
if (who.res) return who.res;
if (who.res) return who.res;
- const view = (s, withSecret) => ({ id: s.id, consumer: s.consumer, topic_pattern: s.topic_pattern, endpoint: s.endpoint, enabled: s.enabled, retry_policy: s.retry_policy, ...(withSecret ? { secret: s.secret } : {}) });
+ const app = who.principal.kind === 'app' ? who.principal : null;
+ const view = (s, withSecret) => ({
+ id: s.id, consumer: s.consumer, topic_pattern: s.topic_pattern, endpoint: s.endpoint, enabled: s.enabled, retry_policy: s.retry_policy,
+ created_at: s.created_at, updated_at: s.updated_at, ...(s.project_id ? { project_id: s.project_id, env: s.env } : {}), ...(withSecret ? { secret: s.secret } : {}),
+ });
if (path === '/api/v1/subscriptions' && req.method === 'POST') {
if (path === '/api/v1/subscriptions' && req.method === 'POST') {
- const b = await req.json().catch(() => ({}));
- 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 b = await req.json().catch(() => ({})) || {};
+ const pattern = b.topic_pattern ?? b.topic;
+ if (!appRules.isValidPattern(pattern)) return problem(422, 'events.bad_topic', 'topic_pattern must be dot-separated segments of [a-z0-9_] or *');
+ if (app) {
+ const err = appRules.patternScopeError(pattern, app);
+ if (err) return problem(403, 'events.topic_not_allowed', err);
+ }
+ let endpoint;
+ if (app) {
+ const ep = appRules.checkAppEndpoint(b.endpoint);
+ if (!ep.ok) return problem(422, 'events.endpoint_not_allowed', ep.reason);
+ endpoint = ep.url.toString();
+ } else {
+ try { endpoint = new URL(b.endpoint).toString(); } catch { return problem(422, 'events.endpoint_not_allowed', 'endpoint must be an http(s) URL'); }
+ }
+ if (b.secret !== undefined && (typeof b.secret !== 'string' || b.secret.length < 32 || b.secret.length > 256)) return problem(422, 'events.bad_request', 'secret must be a string of 32..256 characters');
+ const rp = appRules.checkRetryPolicy(b.retry_policy);
+ if (!rp.ok) return problem(422, 'events.bad_request', rp.reason);
+ const dup = [...subscriptions.values()].find((s) => s.consumer === who.consumer && s.topic_pattern === pattern && s.endpoint === endpoint);
+ if (dup) return problem(409, 'events.subscription_exists', 'this consumer already subscribes that endpoint to that topic', { subscription_id: dup.id });
+ const at = new Date().toISOString();
const s = {
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')}`,
+ id: `sub_${ulid()}`, consumer: who.consumer, topic_pattern: pattern, endpoint, enabled: true, retry_policy: rp.value,
+ secret: b.secret || `whsec_${crypto.randomBytes(32).toString('hex')}`, created_at: at, updated_at: at,
+ project_id: app ? app.projectId : null, env: app ? app.env : 'production',
cursor: lastSeq, attempts: new Map(), // deliverEvents(): events published after the subscription
cursor: lastSeq, attempts: new Map(), // deliverEvents(): events published after the subscription
};
};
subscriptions.set(s.id, s);
subscriptions.set(s.id, s);
return json(201, view(s, true));
return json(201, view(s, true));
}
}
- if (path === '/api/v1/subscriptions' && req.method === 'GET') return json(200, { subscriptions: [...subscriptions.values()].filter((s) => s.consumer === who.service).map((s) => view(s)) });
+ if (path === '/api/v1/subscriptions' && req.method === 'GET') return json(200, { subscriptions: [...subscriptions.values()].filter((s) => s.consumer === who.consumer).map((s) => view(s)) });
if ((r = path.match(/^\/api\/v1\/subscriptions\/([^/]+)(?:\/(enable|disable))?$/))) {
if ((r = path.match(/^\/api\/v1\/subscriptions\/([^/]+)(?:\/(enable|disable))?$/))) {
const s = subscriptions.get(decodeURIComponent(r[1]));
const s = subscriptions.get(decodeURIComponent(r[1]));
- if (!s || s.consumer !== who.service) return problem(404, 'events.not_found', 'no such subscription');
- if (r[2] && req.method === 'POST') s.enabled = r[2] === 'enable';
+ if (!s || s.consumer !== who.consumer) return problem(404, 'events.not_found', 'no such subscription');
+ if (r[2] && req.method === 'POST') { s.enabled = r[2] === 'enable'; s.updated_at = new Date().toISOString(); }
return json(200, view(s));
return json(200, view(s));
}
}
}
}
@@ -444,8 +544,15 @@ function createMockPlatform(opts = {}) {
@@ -444,8 +544,15 @@ function createMockPlatform(opts = {}) {
const auth = req.headers.get('authorization') || '';
const auth = req.headers.get('authorization') || '';
const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
const claims = auth.startsWith('Bearer ') ? decode(auth.slice(7)) : null;
if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');
if (auth.startsWith('Bearer ') && !claims) return problem(401, 'token.bad_signature', 'bad token');
- if (claims && claims.env === 'sandbox' && !(acceptSandbox === true || acceptSandbox.has('openvibe.events'))) return problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here');
- const viewer = claims ? (claims.actor_type ? { kind: 'service' } : { kind: 'user', subject: claims.subject_id }) : { kind: 'anonymous' };
+ let viewer = { kind: 'anonymous' };
+ if (claims && claims.actor_type) {
+ // Like Events: principals (apps included) need events.event.read, which apps never hold,
+ // and sandbox tokens are refused here.
+ const who = principal(req, 'openvibe.events', 'events.event.read');
+ if (who.res) return who.res;
+ if (/^app:/.test(String(claims.sub)) && claims.env === 'sandbox') return problem(401, 'token.sandbox_refused', 'sandbox tokens are not accepted here');
+ viewer = { kind: 'service' };
+ } else if (claims) viewer = { kind: 'user', subject: claims.subject_id };
const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
const raw = req.headers.get('last-event-id') ?? url.searchParams.get('last_event_id');
const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;
const last = raw != null && /^\d+$/.test(raw) ? Number(raw) : null;
const enc = new TextEncoder();
const enc = new TextEncoder();
@@ -501,7 +608,10 @@ function createMockPlatform(opts = {}) {
@@ -501,7 +608,10 @@ function createMockPlatform(opts = {}) {
const max = (sub.retry_policy && Number.isInteger(sub.retry_policy.max_attempts) && sub.retry_policy.max_attempts) || 5;
const max = (sub.retry_policy && Number.isInteger(sub.retry_policy.max_attempts) && sub.retry_policy.max_attempts) || 5;
for (const e of events) {
for (const e of events) {
if (e.seq <= sub.cursor) continue;
if (e.seq <= sub.cursor) continue;
- if (!re.test(e.event.event_type)) { sub.cursor = e.seq; continue; }
+ const wanted = re.test(e.event.event_type) && (sub.project_id
+ ? appRules.visibleToApp(e, { projectId: sub.project_id, env: sub.env })
+ : appRules.serviceSees(sub.topic_pattern, e));
+ if (!wanted) { sub.cursor = e.seq; continue; }
const attempt = (sub.attempts.get(e.event.event_id) || 0) + 1;
const attempt = (sub.attempts.get(e.event.event_id) || 0) + 1;
sub.attempts.set(e.event.event_id, attempt);
sub.attempts.set(e.event.event_id, attempt);
const body = JSON.stringify({ event: e.event, seq: e.seq });
const body = JSON.stringify({ event: e.event, seq: e.seq });
@@ -559,47 +669,126 @@ function createMockPlatform(opts = {}) {
@@ -559,47 +669,126 @@ function createMockPlatform(opts = {}) {
}
}
// ── Media files ─────────────────────────────────────────
// ── Media files ─────────────────────────────────────────
- async function media(req, url) {
- const r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);
- if (!r) return json(404, { error: 'Not found' });
- const appId = decodeURIComponent(r[1]);
- const app = mediaApps.get(appId);
- if (!app) return json(404, { error: 'Unknown app' });
+ // Like OpenVibe.Media's tenantAuth + files routes: upload and delete need media.object.upload,
+ // list and meta need media.object.read. A developer app reaches only /api/v1/<its project_id>/,
+ // where its token's env picks the tenant: production `prj_…`, sandbox `prj_…-sandbox` (created on
+ // first use). Sandbox files are never served publicly: their `url` is a signed, expiring
+ // /f/<key>?exp=&sig= URL (`sandbox: true`, `url_expires_at`); GET /f/<key> without a valid
+ // signature is 404, like a missing file.
+ const mediaQuota = { production: 1024, sandbox: 100, ...opts.mediaQuotaMb };
+ const mediaSigningKey = crypto.randomBytes(32);
+ const signedUrlTtlS = Math.min(3600, Math.max(30, Number(opts.mediaSignedUrlTtlS) || 300));
+ const mediaMac = (key, exp) => crypto.createHmac('sha256', mediaSigningKey).update(`getf\nfile:${key}\n${exp}`).digest('base64url');
+ const isSandboxTenant = (tenantId) => { const t = mediaTenants.get(tenantId); return Boolean(t && t.env === 'sandbox'); };
+ function mediaTenant(projectId, env) {
+ const id = env === 'sandbox' ? `${projectId}-sandbox` : projectId;
+ if (!mediaTenants.has(id)) mediaTenants.set(id, { id, project_id: projectId, env, quota_bytes: mediaQuota[env] * 1024 * 1024 });
+ return mediaTenants.get(id);
+ }
+ function fileView(f) {
+ const sandbox = isSandboxTenant(f.app_id);
+ let signed = null;
+ if (sandbox) {
+ const exp = Math.floor(Date.now() / 1000) + signedUrlTtlS;
+ signed = { url: `${origins.media}/f/${encodeURIComponent(f.key)}?exp=${exp}&sig=${mediaMac(f.key, exp)}`, expires_at: new Date(exp * 1000).toISOString() };
+ }
+ return {
+ key: f.key, app_id: f.app_id, user_id: f.user_id, original_name: f.original_name, size: f.size, mime: f.mime, sha256: f.sha256,
+ url: signed ? signed.url : `/f/${f.key}`, ...(sandbox ? { sandbox: true, url_expires_at: signed.expires_at } : {}), created_at: f.created_at,
+ };
+ }
+ const fileByKey = (key) => [...files.values()].find((f) => f.key === key) || null;
+
+ /** { tenant, actingUser } or { res }: the order of Media's tenantAuth. */
+ function mediaAuth(req, tenantPath, capability) {
const auth = req.headers.get('authorization') || '';
const auth = req.headers.get('authorization') || '';
- const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
- const byKey = Boolean(token) && app.apiKey && token === app.apiKey;
- if (!byKey) {
- if (req.method !== 'POST' || r[2]) return json(401, { error: 'Authentication required' });
- const who = principal(req, 'openvibe.media', 'media.object.upload', appId);
- if (who.res) return who.res;
+ const token = auth.startsWith('Bearer ') ? auth.slice(7).trim() : '';
+ const projectRoute = PRJ_ID_RE.test(tenantPath);
+ const claims = token.split('.').length === 3 ? decode(token) : null;
+ const svc = claims && claims.actor_type ? claims : null;
+ if (svc && svc.env === 'sandbox' && !projectRoute) {
+ return { res: problem(401, 'token.sandbox_refused', 'sandbox tokens are accepted only on developer-project tenant routes (/api/v1/<project_id>/files, /api/v2/<project_id>/objects)') };
+ }
+ if (svc && !(svc.aud || []).includes('openvibe.media')) return { res: problem(401, 'token.wrong_audience', 'not for openvibe.media') };
+ if (svc && (svc.actor_type === 'app' || /^app:/.test(String(svc.sub)))) {
+ if (!projectRoute) return { res: problem(403, 'capability.namespace_denied', 'app tokens reach only /<project_id>/ tenants') };
+ if (svc.project_id !== tenantPath) return { res: problem(403, 'capability.namespace_denied', `this app token belongs to ${svc.project_id || 'no project'}, not ${tenantPath}`) };
+ if (!hasCap(svc, capability)) return { res: problem(403, 'capability.denied', `${capability} not granted`) };
+ if (svc.ns && svc.ns.length && !svc.ns.includes(tenantPath)) return { res: problem(403, 'capability.namespace_denied', `namespace ${tenantPath} not granted`) };
+ return { tenant: mediaTenant(tenantPath, svc.env === 'sandbox' ? 'sandbox' : 'production'), principal: true };
+ }
+ if (svc && svc.env === 'sandbox') return { res: problem(401, 'token.sandbox_refused', 'only developer-app sandbox tokens are accepted, on their own project tenant') };
+ const app = mediaApps.get(tenantPath);
+ if (!app) return { res: json(404, { error: 'Unknown app' }) };
+ if (token && app.apiKey && token === app.apiKey) {
+ const raw = req.headers.get('x-ov-user-id');
+ const n = raw == null || raw === '' ? null : Number(String(raw).trim());
+ return { tenant: { id: tenantPath, project_id: null, env: null, quota_bytes: 0 }, actingUser: Number.isInteger(n) && n > 0 ? n : null };
+ }
+ if (token && [...mediaApps.entries()].some(([id, a]) => id !== tenantPath && a.apiKey && a.apiKey === token)) return { res: json(403, { error: 'API key not valid for this app' }) };
+ if (svc) {
+ const who = principal(req, 'openvibe.media', capability, tenantPath);
+ if (who.res) return who;
+ return { tenant: { id: tenantPath, project_id: null, env: null, quota_bytes: 0 }, principal: true };
}
}
- const actingUser = byKey ? req.headers.get('x-ov-user-id') : null;
- const view = (f) => ({ key: f.key, app_id: f.app_id, user_id: f.user_id, original_name: f.original_name, size: f.size, mime: f.mime, sha256: f.sha256, url: `/f/${f.key}`, created_at: f.created_at });
- if (req.method === 'POST' && !r[2]) {
+ return { res: json(401, { error: 'Authentication required' }) };
+ }
+
+ async function media(req, url) {
+ let r;
+ if (req.method === 'GET' && (r = url.pathname.match(/^\/f\/([^/]+)$/))) {
+ const f = fileByKey(decodeURIComponent(r[1]));
+ if (!f) return json(404, { error: 'Not found' });
+ const sandbox = isSandboxTenant(f.app_id);
+ if (sandbox) {
+ const exp = Number(url.searchParams.get('exp'));
+ const sig = Buffer.from(String(url.searchParams.get('sig') || ''));
+ const want = Buffer.from(Number.isInteger(exp) ? mediaMac(f.key, exp) : '');
+ if (!Number.isInteger(exp) || exp < Math.floor(Date.now() / 1000) || sig.length !== want.length || !crypto.timingSafeEqual(sig, want)) return json(404, { error: 'Not found' });
+ }
+ return new Response(f.bytes, { status: 200, headers: { 'Content-Type': f.mime, 'X-Robots-Tag': 'noindex', 'Cache-Control': sandbox ? 'private, no-store' : 'public, max-age=86400' } });
+ }
+ r = url.pathname.match(/^\/api\/v1\/([^/]+)\/files(?:\/([^/]+))?$/);
+ const routed = r && (req.method === 'GET' || (req.method === 'POST' && !r[2]) || (req.method === 'DELETE' && r[2]));
+ if (!routed) return json(404, { error: 'Not found' });
+ const capability = req.method === 'GET' ? 'media.object.read' : 'media.object.upload';
+ const who = mediaAuth(req, decodeURIComponent(r[1]), capability);
+ if (who.res) return who.res;
+ const { tenant } = who;
+ const mine = () => [...files.values()].filter((f) => f.app_id === tenant.id);
+ const used = () => mine().reduce((n, f) => n + f.size, 0);
+ if (req.method === 'POST') {
const form = await req.formData();
const form = await req.formData();
const file = form.get('file');
const file = form.get('file');
if (!file || typeof file === 'string') return json(400, { error: 'No file uploaded (multipart field: file)' });
if (!file || typeof file === 'string') return json(400, { error: 'No file uploaded (multipart field: file)' });
const buf = Buffer.from(await file.arrayBuffer());
const buf = Buffer.from(await file.arrayBuffer());
+ if (tenant.quota_bytes > 0 && used() + buf.length > tenant.quota_bytes) return json(413, { error: 'App file quota exceeded', quota_bytes: tenant.quota_bytes, used_bytes: used() });
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
const sha256 = crypto.createHash('sha256').update(buf).digest('hex');
- const name = String(file.name || 'file').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';
- const key = `${sha256.slice(0, 12)}-${name}`;
- const existing = files.get(`${appId}|${key}`);
- if (existing) return json(200, { ...view(existing), deduplicated: true });
- const f = { key, app_id: appId, user_id: actingUser ?? form.get('user_id') ?? null, original_name: file.name, size: buf.length, mime: file.type || 'application/octet-stream', sha256, created_at: new Date().toISOString(), bytes: buf };
- files.set(`${appId}|${key}`, f);
- return json(201, view(f));
+ const name = String(file.name || 'file').replace(/^.*[\\/]/, '').replace(/[^a-zA-Z0-9._-]/g, '_').slice(0, 120) || 'file';
+ // Keys are global; a project tenant's keys carry a tag of the tenant id, like Media's.
+ const tag = tenant.project_id ? `${crypto.createHash('sha256').update(`tenant:${tenant.id}`).digest('hex').slice(0, 8)}-` : '';
+ const key = `${sha256.slice(0, 12)}-${tag}${name}`;
+ const existing = fileByKey(key);
+ if (existing) {
+ if (existing.app_id !== tenant.id) return json(409, { error: 'Key conflict — rename the file and retry' });
+ return json(200, { ...fileView(existing), deduplicated: true });
+ }
+ const userId = who.actingUser != null ? who.actingUser : (form.get('user_id') ?? null);
+ const f = { key, app_id: tenant.id, user_id: userId, original_name: file.name || name, size: buf.length, mime: file.type || 'application/octet-stream', sha256, created_at: new Date().toISOString(), bytes: buf };
+ files.set(`${tenant.id}|${key}`, f);
+ return json(201, fileView(f));
}
}
if (req.method === 'GET' && !r[2]) {
if (req.method === 'GET' && !r[2]) {
- const all = [...files.values()].filter((f) => f.app_id === appId);
- const limit = Number(url.searchParams.get('limit') || 100);
- const offset = Number(url.searchParams.get('offset') || 0);
- return json(200, { files: all.slice(offset, offset + limit).map(view), used_bytes: all.reduce((n, f) => n + f.size, 0), quota_bytes: 0, limit, offset });
+ const limit = Math.min(Math.max(parseInt(url.searchParams.get('limit') || '100', 10) || 1, 1), 500);
+ const offset = Math.max(parseInt(url.searchParams.get('offset') || '0', 10) || 0, 0);
+ return json(200, { files: mine().slice(offset, offset + limit).map(fileView), used_bytes: used(), quota_bytes: tenant.quota_bytes, limit, offset });
}
}
- const f = files.get(`${appId}|${decodeURIComponent(r[2] || '')}`);
+ const f = files.get(`${tenant.id}|${decodeURIComponent(r[2])}`);
if (!f) return json(404, { error: 'File not found' });
if (!f) return json(404, { error: 'File not found' });
- if (req.method === 'GET') return json(200, view(f));
- if (req.method === 'DELETE') { files.delete(`${appId}|${f.key}`); return json(200, { message: 'File deleted' }); }
- return json(404, { error: 'Not found' });
+ if (req.method === 'GET') return json(200, fileView(f));
+ if (who.actingUser != null && String(f.user_id) !== String(who.actingUser)) return json(403, { error: 'Not authorized to delete this file' });
+ files.delete(`${tenant.id}|${f.key}`);
+ return json(200, { message: 'File deleted' });
}
}
// ── Router ──────────────────────────────────────────────
// ── Router ──────────────────────────────────────────────
@@ -611,13 +800,15 @@ function createMockPlatform(opts = {}) {
@@ -611,13 +800,15 @@ function createMockPlatform(opts = {}) {
if (origin === new URL(origins.network).origin) return network(req, url);
if (origin === new URL(origins.network).origin) return network(req, url);
if (origin === new URL(origins.events).origin) return eventsService(req, url);
if (origin === new URL(origins.events).origin) return eventsService(req, url);
if (origin === new URL(origins.media).origin) return media(req, url);
if (origin === new URL(origins.media).origin) return media(req, url);
- if (origin === new URL(origins.tools).origin) return jobsService.handle(req, url);
+ if (toolsOrigins.includes(origin)) return jobsService.handle(req, url);
throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
throw new TypeError(`mock platform: no service at ${origin} (fetch failed)`);
}
}
return {
return {
fetch: fetchImpl,
fetch: fetchImpl,
origins,
origins,
+ /** Every origin that answers /api/v1/jobs: origins.tools and the satellites (img., audio., docs.openvibe.tools). */
+ toolsOrigins,
issuer,
issuer,
keys: { privateKey, publicKey, jwks },
keys: { privateKey, publicKey, jwks },
signUserToken,
signUserToken,
@@ -635,17 +826,25 @@ function createMockPlatform(opts = {}) {
@@ -635,17 +826,25 @@ function createMockPlatform(opts = {}) {
addProject: (spec) => developer.addProject(spec).id,
addProject: (spec) => developer.addProject(spec).id,
signAppToken,
signAppToken,
stats,
stats,
- state: { events, subscriptions, modules, files, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },
+ state: { events, subscriptions, modules, files, mediaTenants, users, checkpoints, apps: developer.apps, projects: developer.projects, jobs: jobsService.jobs },
deliverEvents,
deliverEvents,
startDeliveries,
startDeliveries,
pruneEvents,
pruneEvents,
/** End every open Tools job event stream (clients reconnect with Last-Event-ID). */
/** End every open Tools job event stream (clients reconnect with Last-Event-ID). */
dropJobStreams: () => jobsService.dropStreams(),
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),
+ /**
+ * Store an event as if a producer had published it (for realtime/pull tests). A publisher
+ * `app:<id>` of a registered app stores it as that app's event (its project and env);
+ * otherwise pass { projectId, env } (env defaults to production).
+ */
+ publishEvent(env, publisher = 'svc:mock', meta) {
+ const app = !meta && /^app:/.test(publisher) ? developer.apps.get(publisher.slice(4)) : null;
+ const where = meta || (app ? { projectId: app.project_id, env: app.environment } : undefined);
+ return publishEvent({ event_id: `evt_${ulid()}`, version: 1, timestamp: new Date().toISOString(), payload: {}, ...env }, publisher, where);
+ },
/** End every open realtime stream (clients reconnect with Last-Event-ID). */
/** End every open realtime stream (clients reconnect with Last-Event-ID). */
dropRealtime() { for (const s of [...streams]) s.close(); },
dropRealtime() { for (const s of [...streams]) s.close(); },
};
};
}
}
-module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG };
+module.exports = { createMockPlatform, DEFAULT_ORIGINS, DEFAULT_TOOLS_SATELLITES, DEFAULT_APP_CATALOG };
@@ -9,6 +9,9 @@
@@ -9,6 +9,9 @@
* stepMs: 5, // delay between lifecycle steps
* stepMs: 5, // delay between lifecycle steps
* handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },
* handlers: { 'img.process': async ({ input, files, progress, cancelled }) => ({ data, files: [{ name, mime, bytes }] }) },
* }
* }
+ * Every Tools origin of the platform answers (origins.tools and the img., audio. and docs.
+ * satellites); like the real satellites, each keeps its own jobs: a job id is found only on the
+ * origin that created it.
* Without a handler for a type, the job echoes: data { echo: input }, files = the uploaded files.
* 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 }.
* A handler that throws fails the job with { code: err.code || 'tools.job.failed', detail }.
*/
*/
@@ -151,18 +154,18 @@ function createJobsService(ctx) {
@@ -151,18 +154,18 @@ function createJobsService(ctx) {
const key = req.headers.get('idempotency-key');
const key = req.headers.get('idempotency-key');
const hash = sha256hex(JSON.stringify([type, input, files.map((f) => sha256hex(f.bytes))]));
const hash = sha256hex(JSON.stringify([type, input, files.map((f) => sha256hex(f.bytes))]));
if (key) {
if (key) {
- const prior = idem.get(`${who.owner}|${key}`);
+ const prior = idem.get(`${url.origin}|${who.owner}|${key}`);
if (prior) {
if (prior) {
if (prior.hash !== hash) return problem(409, 'tools.job.idempotency_conflict', 'This Idempotency-Key was used for a different request');
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' });
return json(200, view(jobs.get(prior.id)), { Location: `/api/v1/jobs/${prior.id}`, 'Idempotent-Replayed': 'true', 'Cache-Control': 'no-store' });
}
}
}
}
const job = {
const job = {
- id: `job_${ulid()}`, owner: who.owner, type, input, files, state: 'queued', percent: null, message: null,
+ id: `job_${ulid()}`, origin: url.origin, 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: [],
cancelRequested: false, createdAt: Date.now(), startedAt: null, finishedAt: null, result: null, error: null, events: [],
};
};
jobs.set(job.id, job);
jobs.set(job.id, job);
- if (key) idem.set(`${who.owner}|${key}`, { id: job.id, hash });
+ if (key) idem.set(`${url.origin}|${who.owner}|${key}`, { id: job.id, hash });
emit(job, 'job.queued');
emit(job, 'job.queued');
runJob(job);
runJob(job);
return json(202, view(job), { Location: `/api/v1/jobs/${job.id}`, 'Cache-Control': 'no-store' });
return json(202, view(job), { Location: `/api/v1/jobs/${job.id}`, 'Cache-Control': 'no-store' });
@@ -173,7 +176,7 @@ function createJobsService(ctx) {
@@ -173,7 +176,7 @@ function createJobsService(ctx) {
const who = owner(req, action);
const who = owner(req, action);
if (who.res) return who.res;
if (who.res) return who.res;
const job = jobs.get(decodeURIComponent(m[1]));
const job = jobs.get(decodeURIComponent(m[1]));
- if (!job || job.owner !== who.owner) return problem(404, 'tools.job.not_found', 'No such job');
+ if (!job || job.owner !== who.owner || job.origin !== url.origin) return problem(404, 'tools.job.not_found', 'No such job');
if (req.method === 'DELETE') {
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 === 'succeeded' || job.state === 'failed') return problem(409, 'tools.job.already_finished', `The job already ${job.state}`);
if (job.state === 'queued') finish(job, 'cancelled');
if (job.state === 'queued') finish(job, 'cancelled');
new file mode 100644
new file mode 100644
@@ -0,0 +1,233 @@
@@ -0,0 +1,233 @@
+'use strict';
+/**
+ * Developer-app events: projectKey/appSource/createAppEvents, and the mock platform's Events playing
+ * OpenVibe.Events' events.app.* rules (type prefix, source, actor, read and subscription scope, https
+ * endpoints, sandbox/production separation, first-party readers, deliveries, realtime).
+ */
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createServiceTokenClient } = require('../src/auth');
+const { createEventsClient, projectKey, appSource, createAppEvents } = require('../src/events');
+const { createMockPlatform } = require('../src/testing');
+
+const P1 = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';
+const P2 = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TB';
+const A = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T1'; // P1 sandbox
+const A2 = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T2'; // P1 production
+const B = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T3'; // P2 sandbox
+const R = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T4'; // P1 sandbox, read only
+const K1 = 'p01k5wzx7s7q4d2b8n3m6v1c9ta';
+const ALL = ['events.app.publish', 'events.app.read', 'events.app.subscribe'];
+
+function setup() {
+ const platform = createMockPlatform({
+ clients: {
+ live: { secret: 'live-secret', grants: [['events.event.publish', 'openvibe.events'], ['events.event.read', 'openvibe.events'], ['events.subscription.manage', 'openvibe.events']] },
+ },
+ apps: {
+ [A]: { project: P1, env: 'sandbox', secret: 'a', grants: ALL },
+ [A2]: { project: P1, env: 'production', secret: 'a2', grants: ALL },
+ [B]: { project: P2, env: 'sandbox', secret: 'b', grants: ALL },
+ [R]: { project: P1, env: 'sandbox', secret: 'r', grants: ['events.app.read'] },
+ },
+ });
+ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });
+ const live = createEventsClient(clientFor('live', 'live-secret'), { source: 'live' });
+ return {
+ platform, live, clientFor,
+ a: createAppEvents(clientFor(A, 'a'), { projectId: P1, appId: A }),
+ a2: createAppEvents(clientFor(A2, 'a2'), { projectId: P1, appId: A2 }),
+ b: createAppEvents(clientFor(B, 'b'), { projectId: P2, appId: B }),
+ r: createAppEvents(clientFor(R, 'r'), { projectId: P1, appId: R }),
+ };
+}
+const order = { event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } };
+const ids = (page) => page.events.map((e) => e.event.event_id);
+
+run([
+ ['projectKey and appSource derive the names Events expects', async () => {
+ assert.equal(projectKey('prj_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'p01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.equal(appSource('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.equal(appSource('app:app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), 'app-01jab2c3d4e5f6g7h8j9k0mnpq');
+ assert.equal(projectKey('prj_bad'), null);
+ assert.equal(projectKey('app_01JAB2C3D4E5F6G7H8J9K0MNPQ'), null);
+ assert.equal(appSource('svc:live'), null);
+ assert.equal(appSource('app_01jab2c3d4e5f6g7h8j9k0mnpq'), null, 'ids are upper-case ULIDs');
+ }],
+
+ ['createAppEvents: ids checked, project-relative names and patterns', async () => {
+ const client = createClient({ fetch: () => { throw new Error('no calls'); } });
+ assert.throws(() => createAppEvents(client, { projectId: 'prj_x', appId: A }), TypeError);
+ assert.throws(() => createAppEvents(client, { projectId: P1, appId: 'svc:live' }), TypeError);
+ const ev = createAppEvents(client, { projectId: P1, appId: A });
+ assert.equal(ev.projectKey, K1);
+ assert.equal(ev.source, appSource(A));
+ assert.equal(ev.prefix, `app.${K1}.`);
+ assert.equal(ev.topic('order.shipped'), `app.${K1}.order.shipped`);
+ assert.equal(ev.topic('*'), `app.${K1}.*`);
+ assert.equal(ev.topic(`app.${K1}.order.*`), `app.${K1}.order.*`, 'full names are kept');
+ assert.throws(() => ev.topic('app.p01k5wzx7s7q4d2b8n3m6v1c9tb.x'), /another project/);
+ const e = ev.prepare({ ...order });
+ assert.equal(e.event_type, `app.${K1}.order.shipped`);
+ assert.equal(e.source, appSource(A));
+ assert.deepEqual(e.actor, { type: 'app', id: A });
+ assert.match(e.event_id, /^evt_/);
+ assert.deepEqual(ev.prepare({ event_type: 'ping' }).subject, { type: 'app', id: A }, 'subject defaults to the app');
+ const user = createAppEvents(client, { projectId: P1, appId: A, onBehalfOf: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' });
+ assert.deepEqual(user.prepare({ ...order }).actor, { type: 'user', id: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' });
+ assert.throws(() => ev.prepare({ ...order, source: 'live' }), TypeError);
+ assert.throws(() => ev.prepare({ ...order, event_type: 'Order.Shipped' }), TypeError);
+ assert.throws(() => ev.pull({ platformTopics: ['*'] }), TypeError);
+ assert.throws(() => ev.pull({ platformTopics: [`app.${K1}.*`] }), TypeError);
+ }],
+
+ ['publish: the app prefix, source and actor; everything else is refused like Events does', async () => {
+ const { platform, a, clientFor } = setup();
+ const out = await a.publish({ ...order });
+ assert.equal(out.duplicate, false);
+ const stored = platform.state.events.at(-1);
+ assert.equal(stored.event.event_type, `app.${K1}.order.shipped`);
+ assert.equal(stored.event.source, appSource(A));
+ assert.deepEqual(stored.event.actor, { type: 'app', id: A });
+ assert.equal(stored.project_id, P1);
+ assert.equal(stored.env, 'sandbox');
+ assert.equal(stored.publisher, `app:${A}`);
+ assert.equal((await a.publish({ ...order, event_id: stored.event.event_id })).duplicate, true, 'safe to retry');
+ assert.equal((await a.publish([{ event_type: 'x.one' }, { event_type: 'x.two' }])).results.length, 2);
+
+ const raw = a.events; // unscoped: nothing is filled in beyond the source
+ const env = (over) => ({ event_type: `app.${K1}.order.shipped`, actor: { type: 'app', id: A }, subject: { type: 'order', id: 'o1' }, ...over });
+ await assert.rejects(raw.publish(env({ event_type: 'app.p01k5wzx7s7q4d2b8n3m6v1c9tb.order.shipped' })), { status: 403, code: 'events.type_not_allowed' });
+ await assert.rejects(raw.publish(env({ event_type: 'live.stream.started' })), { status: 403, code: 'events.type_not_allowed' });
+ await assert.rejects(raw.publish(env({ source: 'live' })), { status: 403, code: 'events.source_mismatch' });
+ await assert.rejects(raw.publish(env({ source: appSource(B) })), { status: 403, code: 'events.source_mismatch' });
+ await assert.rejects(raw.publish(env({ actor: { type: 'app', id: B } })), { status: 403, code: 'events.actor_mismatch' });
+ await assert.rejects(raw.publish(env({ actor: { type: 'user', id: 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU' } })), { status: 403, code: 'events.actor_mismatch' });
+
+ // The user the token acts for may be the actor.
+ const usr = 'usr_01K5WZX7S7Q4D2B8N3M6V1C9TU';
+ const obo = createClient({ fetch: platform.fetch, token: platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.app.publish'], onBehalfOf: usr }) });
+ assert.equal((await createAppEvents(obo, { projectId: P1, appId: A, onBehalfOf: usr }).publish({ ...order })).duplicate, false);
+
+ // events.app.publish is required; first-party services never publish into app.*.
+ const r = createAppEvents(clientFor(R, 'r'), { projectId: P1, appId: R });
+ await assert.rejects(r.publish({ ...order }), { status: 403, code: 'capability.denied' });
+ const live = createEventsClient(clientFor('live', 'live-secret'), { source: 'live' });
+ await assert.rejects(live.publish({ event_type: `app.${K1}.order.shipped`, actor: { type: 'service', id: 'live' }, subject: { type: 'x', id: '1' } }), { status: 403, code: 'events.type_not_allowed' });
+ // A first-party capability in an app token is never honoured.
+ const sneaky = createClient({ fetch: platform.fetch, token: platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.event.publish', 'events.event.read'] }) });
+ await assert.rejects(createEventsClient(sneaky).pull({ topic: `app.${K1}.*` }), { status: 403, code: 'capability.denied' });
+ }],
+
+ ['reads: own project in the same env, public first-party events, never another project', async () => {
+ const { platform, a, a2, b, r, live } = setup();
+ const sbx = await a.publish({ ...order });
+ const prod = await a2.publish({ ...order });
+ const other = await b.publish({ ...order });
+ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });
+ platform.publishEvent({ event_type: 'live.stream.ended', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' } });
+
+ assert.deepEqual(ids(await r.pull()), [sbx.event_id], 'another app of the project, same env');
+ assert.deepEqual(ids(await a.pull({ topic: 'order.*' })), [sbx.event_id], 'a sandbox app never sees production events');
+ assert.deepEqual(ids(await a2.pull()), [prod.event_id], 'a production app never sees sandbox events');
+ assert.deepEqual(ids(await b.pull()), [other.event_id]);
+ assert.deepEqual(ids(await a.pull({ platformTopics: ['live.*'] })), [sbx.event_id, pub.event_id], 'public first-party only, never internal');
+ await assert.rejects(b.events.pull({ topic: `app.${K1}.*` }), { status: 403, code: 'events.topic_not_allowed' });
+ await assert.rejects(b.events.pull({ topic: '*' }), { status: 403, code: 'events.topic_not_allowed' });
+ await assert.rejects(b.events.pull({ topic: '*.shipped' }), { status: 403, code: 'events.topic_not_allowed' });
+ await assert.rejects(b.events.pull({ topic: 'Bad Topic' }), { status: 400, code: 'events.bad_topic' });
+ assert.equal((await a.get(sbx.event_id)).event.event_id, sbx.event_id);
+ assert.equal(await b.get(sbx.event_id), null, 'another project\'s event does not exist for this app');
+ assert.equal(await a2.get(sbx.event_id), null);
+ const seen = [];
+ for await (const item of a.iterate({ topic: '*', platformTopics: ['live.stream.*'] })) seen.push(item.event.event_id);
+ assert.deepEqual(seen, [sbx.event_id, pub.event_id]);
+
+ // First-party readers: never sandbox; app events only through an app.* pattern.
+ assert.ok(!(await live.pull({ topic: '*' })).events.some((e) => e.event.event_type.startsWith('app.')));
+ assert.deepEqual(ids(await live.pull({ topic: 'app.*' })), [prod.event_id]);
+ assert.equal(await live.get(sbx.event_id), null);
+ assert.equal((await live.get(prod.event_id)).event.event_id, prod.event_id);
+
+ // Checkpoints are per app and scoped the same way.
+ const cp = await a.setCheckpoint('*', 5);
+ assert.equal(cp.consumer, `app:${A}`);
+ assert.equal(cp.topic, `app.${K1}.*`);
+ assert.equal((await a.getCheckpoint('*')).cursor, 5);
+ assert.equal((await r.getCheckpoint('*')).cursor, 0, 'another app has its own');
+ await assert.rejects(b.events.getCheckpoint(`app.${K1}.*`), { status: 403, code: 'events.topic_not_allowed' });
+ }],
+
+ ['subscriptions: own topics, public https endpoints; deliveries keep projects and environments apart', async () => {
+ const { platform, a, a2, b, r, live } = setup();
+ const sa = await a.subscribe({ endpoint: 'https://hooks.example.com/a' });
+ assert.equal(sa.topic_pattern, `app.${K1}.*`);
+ assert.equal(sa.consumer, `app:${A}`);
+ assert.equal(sa.project_id, P1);
+ assert.equal(sa.env, 'sandbox');
+ assert.match(sa.secret, /^whsec_/);
+ await a.subscriptions.create({ topicPattern: 'order.*', endpoint: 'https://hooks.example.com/orders', secret: 'x'.repeat(32) });
+ assert.equal((await a.subscriptions.create({ topicPattern: 'live.*', endpoint: 'https://hooks.example.com/nope' })).topic_pattern, `app.${K1}.live.*`, 'relative: inside the project');
+ assert.equal((await a.events.subscriptions.create({ topicPattern: 'live.*', endpoint: 'https://hooks.example.com/public' })).topic_pattern, 'live.*', 'a first-party pattern (public events only)');
+ for (const endpoint of ['http://hooks.example.com/x', 'https://localhost/x', 'https://127.0.0.1/x', 'https://10.0.0.1/x', 'https://intranet/x', 'https://user:[email protected]/x', 'https://hooks.example.com./x', 'https://[::1]/x']) {
+ await assert.rejects(a.subscribe({ endpoint }), { status: 422, code: 'events.endpoint_not_allowed' }, endpoint);
+ }
+ await assert.rejects(a.events.subscriptions.create({ topicPattern: '*', endpoint: 'https://hooks.example.com/x' }), { status: 403, code: 'events.topic_not_allowed' });
+ await assert.rejects(a.subscribe({ endpoint: 'https://hooks.example.com/a' }), { status: 409, code: 'events.subscription_exists' });
+ await assert.rejects(a.subscribe({ endpoint: 'https://hooks.example.com/s', secret: 'short' }), { status: 422 });
+ await assert.rejects(r.subscribe({ endpoint: 'https://hooks.example.com/r' }), { status: 403, code: 'capability.denied' });
+ await a2.subscribe({ endpoint: 'https://hooks.example.com/prod' });
+ await b.subscribe({ endpoint: 'https://hooks.example.com/b' });
+ await live.subscribe({ topicPattern: 'app.*', endpoint: 'https://live.openvibe.live/hook' });
+ await live.subscribe({ topicPattern: 'live.*', endpoint: 'https://live.openvibe.live/svc-live' });
+
+ assert.equal((await a.subscriptions.list()).length, 4);
+ assert.equal((await b.subscriptions.list()).length, 1);
+ assert.equal(await b.subscriptions.get(sa.id), null);
+ assert.equal(await a2.subscriptions.get(sa.id), null, 'another app of the project cannot see it');
+ await assert.rejects(a2.subscriptions.disable(sa.id), { status: 404 });
+
+ const e1 = await a.publish({ ...order });
+ const e2 = await a2.publish({ ...order });
+ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });
+ const calls = [];
+ const round = await platform.deliverEvents({ fetch: async (url, init) => { calls.push({ path: new URL(url).pathname, id: init.headers['X-OpenVibe-Event-Id'] }); return new Response(null, { status: 204 }); } });
+ assert.equal(round.failed, 0);
+ const at = (p) => calls.filter((c) => c.path === p).map((c) => c.id);
+ assert.deepEqual(at('/a'), [e1.event_id], 'own sandbox event only');
+ assert.deepEqual(at('/orders'), [e1.event_id]);
+ assert.deepEqual(at('/prod'), [e2.event_id], 'a sandbox event never reaches a production subscription');
+ assert.deepEqual(at('/b'), [], 'never another project');
+ assert.deepEqual(at('/hook'), [e2.event_id], 'first-party app.* subscribers: production app events only');
+ assert.deepEqual(at('/svc-live'), [pub.event_id]);
+ assert.deepEqual(at('/public'), [pub.event_id], 'an app gets public first-party events');
+ assert.deepEqual(at('/nope'), []);
+ }],
+
+ ['realtime never streams app events; app tokens are refused there', async () => {
+ const { platform, a, a2 } = setup();
+ const url = `${platform.origins.events}/realtime/stream?topics=*`;
+ const sbx = platform.signAppToken(A, { audience: 'openvibe.events', capabilities: ['events.app.read'] });
+ const prod = platform.signAppToken(A2, { audience: 'openvibe.events', capabilities: ['events.app.read'] });
+ const refused = await platform.fetch(url, { headers: { authorization: `Bearer ${sbx}` } });
+ assert.equal(refused.status, 401);
+ assert.equal((await refused.json()).code, 'token.sandbox_refused');
+ const denied = await platform.fetch(url, { headers: { authorization: `Bearer ${prod}` } });
+ assert.equal(denied.status, 403, 'apps never hold events.event.read');
+ assert.equal((await denied.json()).code, 'capability.denied');
+
+ const ctl = new AbortController();
+ const res = await platform.fetch(url, { signal: ctl.signal });
+ const reader = res.body.getReader();
+ await a.publish({ ...order });
+ await a2.publish({ ...order });
+ const pub = platform.publishEvent({ event_type: 'live.stream.started', source: 'live', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '1' }, visibility: 'public' });
+ let text = '';
+ while (!text.includes('data: ')) text += new TextDecoder().decode((await reader.read()).value);
+ const first = JSON.parse(text.split('data: ')[1].split('\n')[0]);
+ assert.equal(first.event.event_id, pub.event_id, 'the first thing streamed is the public first-party event');
+ ctl.abort();
+ platform.dropRealtime();
+ }],
+]);
@@ -175,19 +175,33 @@ run([
@@ -175,19 +175,33 @@ run([
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');
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 () => {
+ ['Media: an app uploads into its project namespace; production and sandbox tenants stay apart', async () => {
const { platform } = setup();
const { platform } = setup();
const client = createClient({ fetch: platform.fetch, tokenProvider: auth.createServiceTokenClient({ clientId: SERVER_APP, clientSecret: 'ovsec_server', fetch: platform.fetch }) });
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' });
const file = await createMediaClient(client, { app: PRJ }).upload('hello', { filename: 'a.txt' });
assert.ok(platform.state.files.has(`${PRJ}|${file.key}`));
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');
+ assert.equal(file.app_id, PRJ);
+ assert.equal(file.sandbox, undefined);
+ assert.equal(file.url, `/f/${file.key}`);
+ assert.equal(file.public_url, `https://openvibe.media/f/${file.key}`);
+ assert.equal(await (await platform.fetch(file.public_url)).text(), 'hello', 'production files are public');
+ await assert.rejects(createMediaClient(client, { app: 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ' }).upload('x'), { status: 403, code: 'capability.namespace_denied' });
+ // Media accepts sandbox app tokens on the project's own tenant (and nowhere else).
const sandboxApp = platform.addApp({ project: PRJ, env: 'sandbox', grants: ['media.object.upload'] });
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 }) });
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 sbx = await createMediaClient(sandboxClient, { app: PRJ }).upload('hello', { filename: 'a.txt' });
+ assert.equal(sbx.app_id, `${PRJ}-sandbox`);
+ assert.equal(sbx.sandbox, true);
+ assert.notEqual(sbx.key, file.key, 'tenant-tagged keys never collide across tenants');
+ assert.match(sbx.url, /^https:\/\/openvibe\.media\/f\/[^?]+\?exp=\d+&sig=[\w-]+$/);
+ assert.ok(Date.parse(sbx.url_expires_at) > Date.now());
+ assert.equal(sbx.public_url, null);
+ assert.equal(sbx.signed_url, sbx.url);
+ assert.equal((await platform.fetch(`https://openvibe.media/f/${sbx.key}`)).status, 404, 'never served without a signature');
+ assert.equal((await platform.fetch(sbx.url.replace(/sig=[\w-]+/, 'sig=forged'))).status, 404);
+ assert.equal(await (await platform.fetch(sbx.signed_url)).text(), 'hello');
+ await assert.rejects(createMediaClient(sandboxClient, { app: 'demo' }).upload('x'), { status: 401, code: 'token.sandbox_refused' });
const limited = createMockPlatform({ sandboxAudiences: ['openvibe.tools'], apps: { [PUBLIC_APP]: { project: PRJ, secret: 's', type: 'confidential', grants: ['media.object.upload'] } } });
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' });
await assert.rejects(auth.createServiceTokenClient({ clientId: PUBLIC_APP, clientSecret: 's', fetch: limited.fetch }).getToken({ audience: 'openvibe.media' }), { code: 'invalid_target' });
}],
}],
new file mode 100644
new file mode 100644
@@ -0,0 +1,127 @@
@@ -0,0 +1,127 @@
+'use strict';
+/**
+ * Mock platform services as production answers them: Media's developer-project tenants (read vs
+ * upload capabilities, sandbox tenant apart from production, signed-only sandbox files, quotas,
+ * first-party tenants), and Tools jobs on the img., audio. and docs. satellites.
+ */
+const assert = require('node:assert/strict');
+const { run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createServiceTokenClient } = require('../src/auth');
+const { createMediaClient } = require('../src/media');
+const { createJobsClient } = require('../src/jobs');
+const { createMockPlatform, DEFAULT_TOOLS_SATELLITES } = require('../src/testing');
+
+const PRJ = 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TA';
+const UP = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T1'; // production, upload only
+const RD = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T2'; // production, read only
+const SBX = 'app_01K5WZX7S7Q4D2B8N3M6V1C9T3'; // sandbox, read + upload
+
+function setup(extra = {}) {
+ const platform = createMockPlatform({
+ mediaApps: { demo: { apiKey: 'demo-key' }, other: { apiKey: 'other-key' } },
+ clients: { svc: { secret: 's', grants: [{ capability: 'media.object.read', audience: 'openvibe.media', namespaces: ['demo'] }] } },
+ apps: {
+ [UP]: { project: PRJ, env: 'production', secret: 'up', grants: ['media.object.upload'] },
+ [RD]: { project: PRJ, env: 'production', secret: 'rd', grants: ['media.object.read'] },
+ [SBX]: { project: PRJ, env: 'sandbox', secret: 'sbx', grants: ['media.object.read', 'media.object.upload'] },
+ },
+ ...extra,
+ });
+ const clientFor = (id, secret) => createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: id, clientSecret: secret, fetch: platform.fetch }) });
+ const mediaFor = (id, secret, app = PRJ) => createMediaClient(clientFor(id, secret), { app });
+ return { platform, clientFor, mediaFor };
+}
+
+run([
+ ['Media: media.object.read lists and gets, media.object.upload uploads and deletes', async () => {
+ const { platform, mediaFor } = setup();
+ const up = mediaFor(UP, 'up');
+ const rd = mediaFor(RD, 'rd');
+ const f = await up.upload('hello', { filename: 'a.txt' });
+ await assert.rejects(up.files.list(), { status: 403, code: 'capability.denied' });
+ await assert.rejects(up.files.get(f.key), { status: 403, code: 'capability.denied' });
+ const list = await rd.files.list();
+ assert.deepEqual(list.files.map((x) => x.key), [f.key]);
+ assert.equal(list.quota_bytes, 1024 * 1024 * 1024, 'production project tenant: 1 GB');
+ assert.equal(list.used_bytes, 5);
+ assert.equal((await rd.files.get(f.key)).sha256, f.sha256);
+ assert.equal(await rd.files.get('nope'), null);
+ await assert.rejects(rd.upload('x'), { status: 403, code: 'capability.denied' });
+ await assert.rejects(rd.files.delete(f.key), { status: 403, code: 'capability.denied' });
+ assert.equal(await up.files.delete(f.key), true);
+ assert.equal(await up.files.delete(f.key), false);
+ assert.equal((await rd.files.list()).files.length, 0);
+ assert.equal((await platform.fetch(f.public_url)).status, 404, 'gone');
+ }],
+
+ ['Media: the sandbox tenant is <project>-sandbox, apart from production, and signed URLs only', async () => {
+ const { platform, mediaFor } = setup();
+ const prodFile = await mediaFor(UP, 'up').upload('prod', { filename: 'p.txt' });
+ const sbx = mediaFor(SBX, 'sbx');
+ const f = await sbx.upload('secret bytes', { filename: 'img.png', contentType: 'image/png' });
+ assert.equal(f.app_id, `${PRJ}-sandbox`);
+ assert.equal(f.sandbox, true);
+ assert.equal(f.public_url, null);
+ assert.equal(f.signed_url, f.url);
+ assert.ok(platform.state.files.has(`${PRJ}-sandbox|${f.key}`));
+ assert.equal(platform.state.mediaTenants.get(`${PRJ}-sandbox`).env, 'sandbox');
+
+ const list = await sbx.files.list();
+ assert.deepEqual(list.files.map((x) => x.key), [f.key], 'the sandbox app sees its tenant only');
+ assert.equal(list.quota_bytes, 100 * 1024 * 1024, 'sandbox tenant: 100 MB');
+ assert.equal(list.files[0].sandbox, true);
+ assert.match(list.files[0].url, /\?exp=\d+&sig=/, 'every answer carries a fresh signed URL');
+ assert.equal(await sbx.files.get(prodFile.key), null, 'production files do not exist for a sandbox app');
+ assert.deepEqual((await mediaFor(RD, 'rd').files.list()).files.map((x) => x.key), [prodFile.key]);
+
+ const served = await platform.fetch(f.signed_url);
+ assert.equal(served.status, 200);
+ assert.equal(served.headers.get('content-type'), 'image/png');
+ assert.equal(served.headers.get('cache-control'), 'private, no-store');
+ assert.equal(await served.text(), 'secret bytes');
+ assert.equal((await platform.fetch(`${platform.origins.media}/f/${f.key}`)).status, 404);
+ const u = new URL(f.signed_url);
+ u.searchParams.set('exp', String(Math.floor(Date.now() / 1000) - 10));
+ assert.equal((await platform.fetch(u)).status, 404, 'an expired (or re-signed) URL is refused');
+ assert.equal((await platform.fetch(`${platform.origins.media}/f/${prodFile.key}`)).headers.get('cache-control'), 'public, max-age=86400');
+ assert.equal(await sbx.files.delete(f.key), true);
+ }],
+
+ ['Media: tenant rules for app tokens, service tokens and app keys; quotas', async () => {
+ const { platform, clientFor, mediaFor } = setup({ mediaQuotaMb: { sandbox: 0.00001 } });
+ await assert.rejects(mediaFor(UP, 'up', 'demo').upload('x'), { status: 403, code: 'capability.namespace_denied' }, 'apps reach /<project_id>/ only');
+ await assert.rejects(mediaFor(UP, 'up', 'prj_01K5WZX7S7Q4D2B8N3M6V1C9TZ').upload('x'), { status: 403, code: 'capability.namespace_denied' });
+ await assert.rejects(mediaFor(SBX, 'sbx', 'demo').files.list(), { status: 401, code: 'token.sandbox_refused' });
+ await assert.rejects(mediaFor(SBX, 'sbx').upload('more than ten bytes'), { status: 413 });
+ assert.ok((await mediaFor(SBX, 'sbx').upload('tiny')).key);
+
+ const key = createMediaClient(createClient({ fetch: platform.fetch }), { app: 'demo', apiKey: 'demo-key', actingUserId: 7 });
+ const mine = await key.upload('by seven');
+ assert.equal(mine.user_id, 7);
+ await assert.rejects(key.files.delete(mine.key, { actingUserId: 8 }), { status: 403 }, 'an acting user deletes only their files');
+ await assert.rejects(createMediaClient(createClient({ fetch: platform.fetch }), { app: 'demo', apiKey: 'other-key' }).files.list(), { status: 403 });
+ await assert.rejects(createMediaClient(createClient({ fetch: platform.fetch }), { app: PRJ, apiKey: 'demo-key' }).files.list(), { status: 404 }, 'project tenants are reached with app tokens only');
+
+ const svc = createMediaClient(clientFor('svc', 's'), { app: 'demo' });
+ assert.equal((await svc.files.list()).files.length, 1, 'a service token with media.object.read lists');
+ await assert.rejects(svc.upload('x'), { status: 403, code: 'capability.denied' });
+ await assert.rejects(createMediaClient(clientFor('svc', 's'), { app: 'other' }).files.list(), { status: 403, code: 'capability.namespace_denied' });
+ }],
+
+ ['Tools: the img., audio. and docs. satellites answer /api/v1/jobs, each with its own jobs', async () => {
+ const platform = createMockPlatform({ jobs: { stepMs: 1 }, clients: { svc: { secret: 's', grants: [['tools.job.create', 'openvibe.tools'], ['tools.job.read', 'openvibe.tools']] } } });
+ assert.deepEqual(platform.toolsOrigins, ['https://openvibe.tools', ...DEFAULT_TOOLS_SATELLITES]);
+ const client = createClient({ fetch: platform.fetch, retries: 0, tokenProvider: createServiceTokenClient({ clientId: 'svc', clientSecret: 's', fetch: platform.fetch }) });
+ const img = createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' });
+ const audio = createJobsClient(client, { baseUrl: 'https://audio.openvibe.tools' });
+ const docs = createJobsClient(client, { baseUrl: 'https://docs.openvibe.tools' });
+ const { job } = await img.submit({ type: 'img.process', input: { format: 'webp' } });
+ assert.equal((await img.wait(job.id)).state, 'succeeded');
+ assert.equal(await audio.get(job.id), null, 'a job lives on the satellite that created it');
+ assert.ok((await docs.submit({ type: 'docs.convert', input: {} })).job.id);
+ await assert.rejects(platform.fetch('https://video.openvibe.tools/api/v1/jobs'), TypeError);
+ const only = createMockPlatform({ jobs: true, toolsSatellites: [] });
+ assert.deepEqual(only.toolsOrigins, ['https://openvibe.tools']);
+ }],
+]);
@@ -33,7 +33,7 @@ run([
@@ -33,7 +33,7 @@ run([
const other = sdk.media.createMediaClient(client, { app: 'someone-else' });
const other = sdk.media.createMediaClient(client, { app: 'someone-else' });
await assert.rejects(other.upload('x'), (err) => err.status === 404 || err.code === 'capability.namespace_denied');
await assert.rejects(other.upload('x'), (err) => err.status === 404 || err.code === 'capability.namespace_denied');
- await assert.rejects(media.files.list(), { status: 401 }, 'a service token only uploads; listing needs the app key');
+ await assert.rejects(media.files.list(), { status: 403, code: 'capability.denied' }, 'listing needs media.object.read');
}],
}],
['user modules with a Network user token', async () => {
['user modules with a Network user token', async () => {
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-/* Copied verbatim from openvibe-contracts v0.26.0 generated/typescript/index.d.ts (the SDK does not
+/* Copied verbatim from openvibe-contracts v0.28.0 generated/typescript/index.d.ts (the SDK does not
* require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
* require the package at runtime). test/types.test.js checks these against a Contracts checkout. */
/* eslint-disable */
/* eslint-disable */
@@ -236,3 +236,76 @@ export interface ModuleRecord {
@@ -236,3 +236,76 @@ export interface ModuleRecord {
*/
*/
updated_by?: string;
updated_by?: string;
}
}
+
+/** [email protected] (owner: codes) */
+/**
+ * PROPOSAL (OpenVibe.Codes, roadmap Wave 20): an app release as the platform knows it. The app is a Network developer app (ADR-014): its id is the Network app id, its capabilities are only a request (the grants in Network are the authority), and trust tiers are metadata that never change a grant check. OpenVibe.Codes validates with this schema, loaded into openvibe-contracts' validator, until Contracts publishes it as codes.app-manifest@1.
+ */
+export interface AppManifest {
+ /**
+ * The Network app id (its client_id). Its principal subject is app:<id>.
+ */
+ id: string;
+ name: string;
+ /**
+ * Semantic version of this release.
+ */
+ version: string;
+ description?: string;
+ publisher: SubjectRef;
+ /**
+ * The Network project that owns the app. Tenancy in other services is keyed by it (ADR-014).
+ */
+ project_id: string;
+ /**
+ * The app's environment. Apps never change environment.
+ */
+ environment?: "sandbox" | "production";
+ /**
+ * Capability ids the app asks for (3+ segments). Only active public (or partner, by staff allowance) capabilities can ever be granted to apps.
+ *
+ * @maxItems 64
+ */
+ capabilities: string[];
+ events?: {
+ /**
+ * Event types (or a trailing .* family) the app subscribes to.
+ *
+ * @maxItems 64
+ */
+ consumes?: string[];
+ };
+ /**
+ * Informational copy of the redirect URIs registered in Network (Network's list is the authority).
+ *
+ * @maxItems 10
+ */
+ redirect_uris?:
+ | []
+ | [string]
+ | [string, string]
+ | [string, string, string]
+ | [string, string, string, string]
+ | [string, string, string, string, string]
+ | [string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string]
+ | [string, string, string, string, string, string, string, string, string, string];
+ homepage?: string;
+ repository?: string;
+ /**
+ * SPDX license expression.
+ */
+ license?: string;
+ compatibility: {
+ /**
+ * Semver range of openvibe-contracts releases it was built against.
+ */
+ contracts: string;
+ /**
+ * Semver range of openvibe-sdk releases it uses.
+ */
+ sdk?: string;
+ };
+}
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
import type { Problem } from './contracts';
import type { Problem } from './contracts';
-export type { SubjectRef, ServiceTokenClaims, EntityRef, Problem, ServiceManifest, Capability, EventEnvelope, ModuleNamespace, ModuleRecord } from './contracts';
+export type { SubjectRef, ServiceTokenClaims, EntityRef, Problem, ServiceManifest, Capability, EventEnvelope, ModuleNamespace, ModuleRecord, AppManifest } from './contracts';
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
@@ -42,6 +42,52 @@ export interface EventsClient {
@@ -42,6 +42,52 @@ export interface EventsClient {
}
}
export declare function createEventsClient(client: OpenVibeClient, opts?: { source?: string; baseUrl?: string }): EventsClient;
export declare function createEventsClient(client: OpenVibeClient, opts?: { source?: string; baseUrl?: string }): EventsClient;
+/** 'prj_01JAB…' -> 'p01jab…' (the second segment of a project's event types); null if not prj_<ULID>. */
+export declare function projectKey(projectId: string): string | null;
+/** 'app_01JAB…' or 'app:app_01JAB…' -> 'app-01jab…' (the app's event source); null if not an app id. */
+export declare function appSource(appId: string): string | null;
+
+/** An app event: event_type may be project-relative ('order.shipped'); actor and subject default to the app. */
+export type AppEventInput = Omit<EventInput, 'actor' | 'subject'> & Partial<Pick<EventEnvelope, 'actor' | 'subject'>>;
+export interface AppEventsClient {
+ projectId: string;
+ /** app_<ULID> */
+ appId: string;
+ /** p<lowercased project ULID> */
+ projectKey: string;
+ /** app-<lowercased app ULID> */
+ source: string;
+ /** app.<projectKey>. */
+ prefix: string;
+ /** A project-relative type or pattern -> the full one ('order.*' -> 'app.<key>.order.*'; '*' -> 'app.<key>.*'). */
+ topic(name: string): string;
+ prepare(envelope: AppEventInput, opts?: { traceId?: string; now?: number }): EventEnvelope;
+ publish(envelope: AppEventInput, opts?: { traceparent?: string }): Promise<PublishResult>;
+ publish(envelopes: AppEventInput[], opts?: { traceparent?: string }): Promise<{ results: PublishResult[] }>;
+ /** topic is project-relative (default '*'); platformTopics are first-party patterns (public events only). */
+ pull(opts?: { topic?: string | string[]; platformTopics?: string[]; afterSeq?: number; limit?: number }): Promise<EventsPage>;
+ iterate(opts?: { topic?: string | string[]; platformTopics?: 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 }>;
+ subscriptions: {
+ /** topicPattern is project-relative (default '*'); the endpoint must be public https. */
+ create(input: Partial<CreateSubscription> & { endpoint: string }): Promise<Subscription>;
+ list(): Promise<Subscription[]>;
+ get(id: string): Promise<Subscription | null>;
+ disable(id: string): Promise<Subscription>;
+ enable(id: string): Promise<Subscription>;
+ };
+ subscribe(input: Partial<CreateSubscription> & { endpoint: string }): Promise<Subscription>;
+ /** The unscoped client underneath. */
+ events: EventsClient;
+}
+/**
+ * Events for one developer app of one project (token: events.app.publish | read | subscribe).
+ * onBehalfOf (usr_…, the token's on_behalf_of) makes that person the default actor.
+ */
+export declare function createAppEvents(client: OpenVibeClient, opts: { projectId: string; appId: string; onBehalfOf?: string; baseUrl?: string }): AppEventsClient;
+
type RawBody = string | Uint8Array | ArrayBufferView;
type RawBody = string | Uint8Array | ArrayBufferView;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function signDelivery(rawBody: RawBody, secret: string): string;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
export declare function verifyDelivery(rawBody: RawBody, signatureHeader: string | undefined | null, secret: string): boolean;
@@ -2,16 +2,23 @@ import type { OpenVibeClient } from './core';
@@ -2,16 +2,23 @@ import type { OpenVibeClient } from './core';
export interface MediaFile {
export interface MediaFile {
key: string;
key: string;
+ /** The tenant: the app id, a project id (prj_…) or its sandbox tenant (prj_…-sandbox). */
app_id: string;
app_id: string;
user_id: string | number | null;
user_id: string | number | null;
original_name: string;
original_name: string;
size: number;
size: number;
mime: string;
mime: string;
sha256: string;
sha256: string;
- /** Media-relative, e.g. /f/<key> */
+ /** Media-relative, e.g. /f/<key>; for a sandbox file an absolute signed, expiring URL. */
url: string;
url: string;
- /** Absolute, added by the SDK. */
- public_url: string;
+ /** Absolute public URL, added by the SDK; null for sandbox files (never served publicly). */
+ public_url: string | null;
+ /** Sandbox files only (Media): the tenant is a developer project's sandbox. */
+ sandbox?: true;
+ /** Sandbox files only (Media): when `url` stops working. */
+ url_expires_at?: string;
+ /** Sandbox files only, added by the SDK: the signed `url`. */
+ signed_url?: string;
created_at: string;
created_at: string;
deduplicated?: boolean;
deduplicated?: boolean;
}
}
@@ -45,6 +52,10 @@ export interface MediaClient {
@@ -45,6 +52,10 @@ export interface MediaClient {
};
};
upload: MediaClient['files']['upload'];
upload: MediaClient['files']['upload'];
}
}
-/** apiKey: the app's Media API key (server only). Without it the client's token (a service token with media.object.upload) is used. */
+/**
+ * apiKey: the app's Media API key (server only). Without it the client's token is used: a service
+ * token, or a developer app token with `app` = its project id (media.object.upload to upload and
+ * delete, media.object.read to list and get).
+ */
export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;
export declare function createMediaClient(client: OpenVibeClient, opts: { app: string; apiKey?: string; actingUserId?: string | number; baseUrl?: string; publicOrigin?: string }): MediaClient;
export declare const DEFAULT_PUBLIC_ORIGIN: string;
export declare const DEFAULT_PUBLIC_ORIGIN: string;
@@ -44,7 +44,11 @@ export interface MockPlatformOptions {
@@ -44,7 +44,11 @@ export interface MockPlatformOptions {
projects?: Record<string, MockProjectSpec>;
projects?: Record<string, MockProjectSpec>;
/** Audiences a sandbox app may get tokens for (Network DEV_SANDBOX_AUDIENCES). Default: any. */
/** Audiences a sandbox app may get tokens for (Network DEV_SANDBOX_AUDIENCES). Default: any. */
sandboxAudiences?: string[];
sandboxAudiences?: string[];
- /** Audiences or capability ids whose mock services accept env=sandbox tokens (or true). Default: none. */
+ /**
+ * Audiences or capability ids whose mock services accept env=sandbox tokens (or true). Default:
+ * none. Media (on /api/v1/<project_id>/files) and Events (on the events.app.* routes) accept
+ * sandbox app tokens regardless, as in production.
+ */
acceptSandbox?: true | string[];
acceptSandbox?: true | string[];
/** Allowance of projects created through /api/v1/projects. Default: the whole catalog (Network: empty). */
/** Allowance of projects created through /api/v1/projects. Default: the whole catalog (Network: empty). */
defaultAllowance?: string[];
defaultAllowance?: string[];
@@ -54,7 +58,13 @@ export interface MockPlatformOptions {
@@ -54,7 +58,13 @@ export interface MockPlatformOptions {
capabilities?: Capability[];
capabilities?: Capability[];
services?: ServiceManifest[];
services?: ServiceManifest[];
realtimeRetryMs?: number;
realtimeRetryMs?: number;
- /** Serve Tools jobs at origins.tools. */
+ /** Media quotas of developer-project tenants in MB (Media's defaults: production 1024, sandbox 100). */
+ mediaQuotaMb?: { production?: number; sandbox?: number };
+ /** Lifetime of signed sandbox file URLs, 30..3600 s (default 300, like Media). */
+ mediaSignedUrlTtlS?: number;
+ /** Tools satellites that also answer /api/v1/jobs. Default: DEFAULT_TOOLS_SATELLITES. */
+ toolsSatellites?: string[];
+ /** Serve Tools jobs at origins.tools and the satellites (each keeps its own jobs). */
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>> };
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). */
/** fetch used by deliverEvents() (default: the global fetch). */
deliveryFetch?: FetchLike;
deliveryFetch?: FetchLike;
@@ -63,6 +73,8 @@ export interface DeliveryAttempt { subscription_id: string; event_id: string; se
@@ -63,6 +73,8 @@ export interface DeliveryAttempt { subscription_id: string; event_id: string; se
export interface MockPlatform {
export interface MockPlatform {
fetch: FetchLike;
fetch: FetchLike;
origins: Record<Origin, string>;
origins: Record<Origin, string>;
+ /** Every origin that answers /api/v1/jobs: origins.tools and the satellites. */
+ toolsOrigins: string[];
issuer: string;
issuer: string;
keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
keys: { privateKey: object; publicKey: object; jwks: { keys: object[]; public_key: string; algorithm: 'RS256' } };
signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
signUserToken(user: MockUser | string, opts?: { expiresInSec?: number; audience?: string[] }): string;
@@ -79,10 +91,19 @@ export interface MockPlatform {
@@ -79,10 +91,19 @@ export interface MockPlatform {
setAuthorization(opts: { subjectId?: string | null; decision?: 'allow' | 'deny' }): void;
setAuthorization(opts: { subjectId?: string | null; decision?: 'allow' | 'deny' }): void;
stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }>; deliveries: DeliveryAttempt[] };
stats: { tokenRequests: number; requests: Array<{ method: string; url: string; headers: Record<string, string> }>; deliveries: DeliveryAttempt[] };
state: {
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>;
+ /** project_id/env are set for developer-app events (null/'production' for first-party ones). */
+ events: Array<{ seq: number; event: EventEnvelope; publisher: string; project_id: string | null; env: 'sandbox' | 'production' }>;
+ subscriptions: Map<string, any>; modules: Map<string, any>;
+ /** `${tenant}|${key}`; tenant is a Media app id, prj_… or prj_…-sandbox. */
+ files: Map<string, any>;
+ mediaTenants: Map<string, { id: string; project_id: string; env: 'sandbox' | 'production'; quota_bytes: number }>;
+ users: Map<string, MockUser>; checkpoints: Map<string, { cursor: number; updated_at: string }>; 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 };
+ /**
+ * Store an event as if published. A publisher `app:<id>` of a registered app stores it as that
+ * app's event (its project and env); or pass { projectId, env }.
+ */
+ publishEvent(envelope: Partial<EventEnvelope> & Pick<EventEnvelope, 'event_type' | 'source' | 'actor' | 'subject'>, publisher?: string, meta?: { projectId?: string; env?: 'sandbox' | 'production' }): { event_id: string; seq: number; duplicate: boolean };
/** Retention: drop stored events with seq <= throughSeq; returns how many. */
/** Retention: drop stored events with seq <= throughSeq; returns how many. */
pruneEvents(throughSeq: number): number;
pruneEvents(throughSeq: number): number;
/** Play the Events delivery worker once (signed POSTs to subscription endpoints). */
/** Play the Events delivery worker once (signed POSTs to subscription endpoints). */
@@ -93,5 +114,7 @@ export interface MockPlatform {
@@ -93,5 +114,7 @@ export interface MockPlatform {
}
}
export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;
export declare function createMockPlatform(opts?: MockPlatformOptions): MockPlatform;
export declare const DEFAULT_ORIGINS: Record<Origin, 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). */
+/** https://img.openvibe.tools, https://audio.openvibe.tools, https://docs.openvibe.tools */
+export declare const DEFAULT_TOOLS_SATELLITES: string[];
+/** Capability ids a developer app can be granted in openvibe-contracts v0.28.0 (public + active). */
export declare const DEFAULT_APP_CATALOG: string[];
export declare const DEFAULT_APP_CATALOG: string[];