openvibers/openvibe.sdk Commits

0.3.1: developer-app events and Media sandbox tenants in the mock as production has them

OP OpenVibers committed 23 Sept 2026 · 04f0deeeef · 25 files, +1180 −135

- openvibe-sdk/events: projectKey(), appSource() and createAppEvents(client, { projectId, appId })
  (project-relative types and patterns, app source and actor, platformTopics for public events)
- mock Events plays OpenVibe.Events' events.app.publish|read|subscribe rules: app.<project_key>.*
  types, app-<ulid> source, actor the app or its on_behalf_of user, reads and subscriptions scoped
  to the own project and env plus public first-party events, https endpoints, sandbox and
  production never mixed, no app events on realtime
- mock Media follows Media's tenantAuth: media.object.read lists/gets, media.object.upload
  uploads/deletes; sandbox tenant <prj>-sandbox with signed-only files (sandbox, url,
  url_expires_at) and GET /f/:key; the media client sets public_url null and adds signed_url
  for sandbox files
- mock Tools answers the img., audio. and docs.openvibe.tools satellites
- contract types from openvibe-contracts v0.28.0 (AppManifest), CI clone, mock catalog and
  default contractsVersion on v0.28.0

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 9199027..8c99068 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -12,6 +12,6 @@ jobs:
with:
node-version: 22.22.1 # production services run Node 22
# types.test.js compares types/contracts.d.ts with the pinned Contracts release
-- - run: git clone --depth 1 --branch v0.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
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 761ec63..0405e97 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,53 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.3.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.
ddiff --git a/README.md b/README.md
iindex a30d865..7138bbb 100644
---- a/README.md
++++ b/README.md
@@@ -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.p
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_…`).
```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: 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:
- **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
ddiff --git a/STATUS.json b/STATUS.json
iindex 6bba392..20ef66a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 11d02e0..ea76a03 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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, publicO
return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
};
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
-- const withPublic = (meta) => (meta && meta.url ? { ...meta, public_url: urls.absolute(meta.url) } : meta);
++ const 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 } = {}) {
ddiff --git a/esm/events.mjs b/esm/events.mjs
iindex 0a433d7..c8e0280 100644
---- a/esm/events.mjs
++++ b/esm/events.mjs
@@@ -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;
ddiff --git a/esm/testing.mjs b/esm/testing.mjs
iindex 509ceac..c91caf1 100644
---- a/esm/testing.mjs
++++ b/esm/testing.mjs
@@@ -2,6 +2,6 @@
import cjs from '../src/testing/index.js';
export const {
-- createMockPlatform, DEFAULT_ORIGINS, DEFAULT_APP_CATALOG,
++ createMockPlatform, DEFAULT_ORIGINS, DEFAULT_TOOLS_SATELLITES, DEFAULT_APP_CATALOG,
} = cjs;
export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex b3a1249..b6ee595 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex bc53c18..99d6b07 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 55e6b52..3630586 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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,
ddiff --git a/src/events.js b/src/events.js
iindex 24e515c..124baaa 100644
---- a/src/events.js
++++ b/src/events.js
@@@ -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,
++};
ddiff --git a/src/media.js b/src/media.js
iindex 52a12d3..946604a 100644
---- a/src/media.js
++++ b/src/media.js
@@@ -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, publicO
return client.json({ service: 'media', baseUrl, audience: 'openvibe.media', ...(apiKey ? { token: apiKey } : {}), ...opts, headers });
};
const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
-- const withPublic = (meta) => (meta && meta.url ? { ...meta, public_url: urls.absolute(meta.url) } : meta);
++ const 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 } = {}) {
ddiff --git a/src/testing/apps.js b/src/testing/apps.js
nnew file mode 100644
iindex 0000000..c7352fc
---- /dev/null
++++ b/src/testing/apps.js
@@@ -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 };
ddiff --git a/src/testing/developer.js b/src/testing/developer.js
iindex 6932d0e..68733fe 100644
---- a/src/testing/developer.js
++++ b/src/testing/developer.js
@@@ -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;
}
ddiff --git a/src/testing/index.js b/src/testing/index.js
iindex b163686..0d0120b 100644
---- a/src/testing/index.js
++++ b/src/testing/index.js
@@@ -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 use
const mediaApps = new Map(Object.entries(opts.mediaApps || {}).map(([id, a]) => [id, { apiKey: (a && a.apiKey) || null }]));
const streams = new Set();
const stats = { tokenRequests: 0, requests: [], deliveries: [] };
@@@ -340,42 +352,100 @@ function createMockPlatform(opts = {}) {
// ── Events ──────────────────────────────────────────────
function visible(viewer, e) {
++ if (e.project_id || (e.env || 'production') !== 'production') return false; // never streamed
if (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 };
ddiff --git a/src/testing/jobs.js b/src/testing/jobs.js
iindex 15e86a6..cf0cccd 100644
---- a/src/testing/jobs.js
++++ b/src/testing/jobs.js
@@@ -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');
ddiff --git a/test/app-events.test.js b/test/app-events.test.js
nnew file mode 100644
iindex 0000000..3ab214e
---- /dev/null
++++ b/test/app-events.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/test/apps.test.js b/test/apps.test.js
iindex 22d65ad..90f87bd 100644
---- a/test/apps.test.js
++++ b/test/apps.test.js
@@@ -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' });
}],
ddiff --git a/test/mock-services.test.js b/test/mock-services.test.js
nnew file mode 100644
iindex 0000000..8aa17f0
---- /dev/null
++++ b/test/mock-services.test.js
@@@ -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']);
++ }],
++]);
ddiff --git a/test/testing.test.js b/test/testing.test.js
iindex 28c0ffd..16e51f2 100644
---- a/test/testing.test.js
++++ b/test/testing.test.js
@@@ -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 () => {
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex eb83844..c6ac852 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -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;
++ };
++}
ddiff --git a/types/core.d.ts b/types/core.d.ts
iindex d5c0764..9ed718e 100644
---- a/types/core.d.ts
++++ b/types/core.d.ts
@@@ -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>;
ddiff --git a/types/events.d.ts b/types/events.d.ts
iindex 6a91e8a..1dcd46f 100644
---- a/types/events.d.ts
++++ b/types/events.d.ts
@@@ -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;
ddiff --git a/types/media.d.ts b/types/media.d.ts
iindex dd3a926..052a67f 100644
---- a/types/media.d.ts
++++ b/types/media.d.ts
@@@ -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;
ddiff --git a/types/testing.d.ts b/types/testing.d.ts
iindex 14b4a68..89574bd 100644
---- a/types/testing.d.ts
++++ b/types/testing.d.ts
@@@ -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; se
export 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[];