openvibers/openvibe.sdk Commits

openvibe-sdk/resources: OVRN names, a resource index over authorities and resource control; release 0.34.0 (#18)

OP OpenVibers committed 5 Oct 2026 · 77fcfca70d · 18 files, +887 −15

* openvibe-sdk/resources: OVRN names, a resource index over authorities and resource control; release 0.34.0

**Changes (file:line)**
- `src/resources.js:1` — new `openvibe-sdk/resources`: `parseResourceName`/`resourceName`/`resourceNameOf` (delegate to `contracts.resources`, lazily required), `RESOURCE_KINDS:93`, `createResourceIndex:157` (fan-out GET `/api/v1/resources?project=&kind=&cursor=&limit=`, concurrency pool, per-authority `next_cursor` walk, merged pages, failed authority reported in `stale`), `createResourceClient:276` (POST `common.resource-control-request@1` to `/api/v1/resources/control`, generated/stable idempotency key, transient retries, returns refused results).
- `index.js:26` — `resources: require('./src/resources')`.
- `package.json:3` — `0.34.0`; `package.json:108` — `"./resources"` export with `"browser": null` beside `./usage`.
- `package-lock.json:3,9` — `0.34.0`.
- `src/core/index.js:11` — `SDK_VERSION = '0.34.0'`.
- `scripts/esm.js:33` — `'resources.mjs'` entry.
- `esm/resources.mjs` (new, generated), `esm/index.mjs:10` (regenerated).
- `types/resources.d.ts:1` (new) — declares all six runtime exports.
- `types/index.d.ts:17` — `export * as resources`.
- `README.md:5,12` — status/install `0.34.0`; `README.md:182` — API-table row.
- `STATUS.json:6` — `0.34.0`.
- `CHANGELOG.md:10` — new `## 0.34.0 (2026-10-05)` entry.
- `browser/openvibe-sdk.mjs:1` — regenerated (`node scripts/browser-bundle.js`; version only, `resources` is server-only).
- `docs/service.md:245` — new "The resource index and the control plane" section.
- `test/resources.test.js:1` (new) — compose/reject OVRN + catalog, merge two authorities, cursor walk, stale-on-500 (keeps pages read, reports `{authority,status,code,error}`), control posts/retries-same-key/refused, invalid request never sent.
- `test/pack.test.js:25,69` — add resources files to tarball list, subpath count 41 → 42.
- `test/browser-entries.test.js:79` — add `'./resources'` to the `browser: null` blocked list.

**Tests** — `ov test test/resources.test.js test/esm.test.js test/browser-entries.test.js test/pack.test.js test/types.test.js` → **5 passed in 3 s**. Also ran the release bookkeeping tests my version bump/bundle touch: `ov test test/version.test.js test/bundle.test.js` → **2 passed in 1 s**. Not committed or pushed.

* Resource index: bounded paging (repeat cursors, maxPages, maxMs, empty cursor); control returns refused/failed bodies, carries the idempotency key on errors, validates strictly; STATUS lists the module (review)

All six findings are fixed, each with a test in `test/resources.test.js`. No commit or push.

**Findings**

1. **Unbounded paging** — `walk()` now keeps a `Set` of seen cursors, throws `sdk.bad_response` ("cursor repeated") on a repeat/echo, adds `maxPages` (default 1000, `sdk.bad_response` "did not end after N pages") and an optional per-authority `maxMs` deadline (`sdk.timeout`); each keeps pages read and reports the authority stale (`src/resources.js:200-229`, opts `:170`, validation `:176-177`, constant `:105`). Tests: two-cursor cycle, endless new cursors at `maxPages: 3`, `maxMs` deadline (`test/resources.test.js:158,176,190`).

2. **Refused/failed on non-2xx thrown** — before throwing, a non-retryable non-2xx whose body is refused/failed with a `problem` or `confirmation_required` is returned (`src/resources.js:263-267,357-361`); a 5xx still retries first, and a plain 403/5xx still throws. Tests: 409 refused body returned, 503 refused body retries then throws, 403 without a result still throws (`test/resources.test.js:213,227,242`).

3. **Idempotency key lost on throw** — every error thrown from `control()` (network/timeout, HTTP, bad response) now carries `err.idempotencyKey` (`src/resources.js:349,360,366`), documented at `:301-311`. `idempotency_key` is now optional and documented in `types/resources.d.ts:31-32,92`. Tests: generated key returned to caller and matched to what the authority saw (`test/resources.test.js:254`).

4. **Validation degrading to weak check** — `checkControlRequest` catches only `MODULE_NOT_FOUND` around the `require`; a real throw from `contracts.resources.checkControlRequest` propagates, and the shape check (`shapeCheckControlRequest`) runs only when the module is absent (`src/resources.js:269-295`). Tests: a patched validator throw propagates with no request sent; the project-tenancy rule rejects a cross-project resource (`test/resources.test.js:270,288`).

5. **STATUS.json missing module** — added the `resources (openvibe-sdk/resources)` capability entry (`STATUS.json:31`); test asserts it and that `version` matches `package.json` (`test/resources.test.js:300`).

6. **Empty cursor passed on** — `next === ''` ends the walk, and `page()` never sends `cursor=` for an empty string (`src/resources.js:218,185`); test asserts one request and no stale (`test/resources.test.js:202`).

Docs kept accurate: CHANGELOG 0.34.0 entry (index bounds, non-2xx refused/failed, `idempotencyKey` on thrown errors) and the README option list (`maxPages`, `maxMs`). No export change, so `esm/resources.mjs` needed no regeneration. Fixture keys are plain words (`conflict-refused-key`, `busy-refused-key`, `forbidden-test-key`, `validator-test-key`, `tenancy-test-key`).

**Tests** — `ov test test/resources.test.js test/types.test.js test/pack.test.js test/esm.test.js`: 4 passed in 2 s (esm 0.3 s, pack 1.7 s, resources 0.7 s, types 0.1 s).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1d30a64..7a6071b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -7,6 +7,32 @@ release may change an API and says so here.
++## 0.34.0 (2026-10-05)
++
++- **`openvibe-sdk/resources` (new): the resource index and the control plane (ADR-048, plan T13 step 2).**
++ `parseResourceName`/`resourceName`/`resourceNameOf` reuse the pinned openvibe-contracts helpers
++ (`contracts.resources`, required lazily) for the one resource name, OVRN
++ `ovrn:<service>:<project_id>:<type>/<id>`. `createResourceIndex({ authorities, token, fetch, pageLimit,
++ concurrency, timeoutMs, maxPages, maxMs })` fans out `GET
++ {authority}/api/v1/resources?project=&kind=&cursor=&limit=`, follows each authority opaque
++ `next_cursor` and merges the `common.resource-list-result@1` pages in authority order; an authority
++ that fails (network, timeout, non-2xx, malformed page) never fails the call — its pages read so far are
++ kept and it is reported in `stale`, so a read model can be rebuilt. A cursor chain that would not end
++ is bounded — a repeated cursor (a cycle or an echoed cursor) or an empty `next_cursor` ends the walk,
++ `maxPages` caps the pages read and an optional `maxMs` deadline caps the time — and the authority is
++ reported `stale` (`sdk.bad_response`/`sdk.timeout`) rather than hanging `list()`.
++ `createResourceClient({ origin, token })` POSTs a `common.resource-control-request@1` to
++ `/api/v1/resources/control` and returns the authority `common.resource-control-result@1`
++ (`done`/`pending`/`refused`/`failed`, a refused one carrying its problem or `confirmation_required`);
++ a refused/failed result is returned even when it arrives with a non-retryable non-2xx (403/409/422), so
++ a confirmation gate is not thrown away. An idempotency key is generated when the caller omits it and a
++ transient failure (network, timeout, 408/425/429/5xx) is retried with the same key and body, so an
++ action is never applied twice; an error thrown after the retries carries that `idempotencyKey`, so the
++ caller's own retry reuses it instead of duplicating a sensitive action.
++ `RESOURCE_KINDS` lists the kinds whose three-letter id prefix ADR-048 has chosen
++ (`media.object`/`med`, `watch.watch`/`wch`); the proposed (`act`, `run`, `zon`) and unchosen
++ (`codes.repo`, `events.queue`, `events.subscription`) kinds are deliberately absent until step 8.
++
## 0.33.0 (2026-10-05)
- **`openvibe-sdk/events` surfaces the opaque `after`/`next_cursor` pull cursors (ADR-042 decision 7):** `pull()`
ddiff --git a/README.md b/README.md
iindex 1da0364..288c11d 100644
---- a/README.md
++++ b/README.md
@@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.33.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.34.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**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.33.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. 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` at runtime: it copies the contract types it uses (from Contracts v0.86.0, a devDependency the tests check them against).
@@@ -179,6 +179,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade |
| `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()` |
| `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` |
++| `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, limit})` -> `{resources, stale}`, fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) |
| `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) |
| `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRe.Stream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 2d2eaca..7a0eac8 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.33.0",
++ "version": "0.34.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@@ -27,7 +27,8 @@
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"events: the PostgreSQL outbox (lease-claimed, several relays per table) and inbox (handler in its transaction)",
"usage: createUsageReporter (openvibe-sdk/usage), the shared platform.usage-sample@1 reporter — validated readings queued idempotency-keyed in an outbox and relayed to Billing with retry, never dropped, refused readings kept",
-- "the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js"
++ "the HTTP telemetry kit (openvibe-sdk/service): the platform.telemetry-sample@1 middleware aggregated per route|method|status_class per flush, with active-requests, p95 and event-loop-lag gauges, lifted from Network's server/telemetry.js and server/observability.js",
++ "resources (openvibe-sdk/resources): the ADR-048 resource index fan-out and the idempotent control client"
],
"updated": "2026-10-05"
}
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex f35c044..fcd6ead 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.33.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.34.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -42,7 +42,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.33.0';
++const SDK_VERSION = '0.34.0';
module.exports = {
createClient,
ddiff --git a/docs/service.md b/docs/service.md
iindex 560f12c..23ea952 100644
---- a/docs/service.md
++++ b/docs/service.md
@@@ -241,3 +241,34 @@ alone. `parser: express.json({ limit })` keeps Express's parser and maps its err
`jsonErrors()` is opt-in: adopting it decides which paths answer JSON and which text, so mount it only where that is
already the behaviour.
++
++## The resource index and the control plane
++
++ADR-048 makes every service that owns resources answer `GET /api/v1/resources` with a
++`common.resource-list-result@1` page of `common.resource-summary@1`, and every change a call to
++`POST /api/v1/resources/control` with a `common.resource-control-request@1` that it answers with a
++`common.resource-control-result@1`. `openvibe-sdk/resources` carries both sides; it is server-only (a
++control plane carries a user or service token and is never shipped to a page).
++
++```js
++const { createResourceIndex, createResourceClient, resourceName } = require('openvibe-sdk/resources');
++
++// A console reads several authorities and merges their index pages; a failing authority is reported, not fatal.
++const index = createResourceIndex({ authorities: ['https://media.openvibe.network', 'https://events.openvibe.network'], token: userAccessToken });
++const { resources, stale } = await index.list({ project: prj.id, kind: 'media.object' }); // follows each next_cursor
++
++// A change is a control call to the authority that owns the resource; it decides, we only show the answer.
++const media = createResourceClient({ origin: 'https://media.openvibe.network', token: userAccessToken });
++const result = await media.control({ action: 'delete', project_id: prj.id,
++ resource: resourceName({ service: 'media', project_id: prj.id, type: 'object', id: objectId }),
++ idempotency_key: 'idem_…' }); // done | pending | refused | failed
++if (result.state === 'refused' && result.confirmation_required) retryWith(result.confirmation_required.confirmation_id);
++```
++
++`parseResourceName`, `resourceName` and `resourceNameOf` reuse the pinned openvibe-contracts
++`contracts.resources` helpers (required lazily, only by these three), so no service splits an OVRN on
++`:`. `RESOURCE_KINDS` names only the kinds whose three-letter id prefix ADR-048 has chosen
++(`media.object`/`med`, `watch.watch`/`wch`); `codes.repo`, `events.queue` and `events.subscription` are
++unchosen and `act`, `run` and `zon` are only proposed. The control client generates an idempotency key
++when the caller omits one and retries a transient failure (network, timeout, 408/425/429/5xx) with the
++same key and body, so an authority applies an action at most once.
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 4ad01c9..f978db8 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -7,7 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
-- tools, projects, vip, openre, geo, usage,
-- commerce, telemetry,
++ tools, projects, resources, vip, openre, geo,
++ usage, commerce, telemetry,
} = cjs;
export default cjs;
ddiff --git a/esm/resources.mjs b/esm/resources.mjs
nnew file mode 100644
iindex 0000000..6ecea00
---- /dev/null
++++ b/esm/resources.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/resources.js. Do not edit.
++import cjs from '../src/resources.js';
++
++export const {
++ parseResourceName, resourceName, resourceNameOf, RESOURCE_KINDS, createResourceIndex, createResourceClient,
++} = cjs;
++export default cjs;
ddiff --git a/index.js b/index.js
iindex 582c39e..88de73b 100644
---- a/index.js
++++ b/index.js
@@@ -23,6 +23,7 @@ module.exports = {
jobs: require('./src/jobs'),
tools: require('./src/tools'),
projects: require('./src/projects'),
++ resources: require('./src/resources'),
vip: require('./src/vip'),
openre: require('./src/openre'),
geo: require('./src/geo'),
ddiff --git a/package-lock.json b/package-lock.json
iindex f86c870..b9e8f37 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.33.0",
++ "version": "0.34.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.33.0",
++ "version": "0.34.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex a052b85..f0a67a7 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.33.0",
++ "version": "0.34.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -105,6 +105,12 @@
"import": "./esm/usage.mjs",
"default": "./src/usage.js"
},
++ "./resources": {
++ "types": "./types/resources.d.ts",
++ "browser": null,
++ "import": "./esm/resources.mjs",
++ "default": "./src/resources.js"
++ },
"./telemetry": {
"types": "./types/telemetry.d.ts",
"browser": null,
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex f94355e..edad84b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -30,6 +30,7 @@ const ENTRIES = {
'govern.mjs': '../src/govern.js',
'placement.mjs': '../src/placement.js',
'usage.mjs': '../src/usage.js',
++ 'resources.mjs': '../src/resources.js',
'telemetry.mjs': '../src/telemetry.js',
'cost.mjs': '../src/cost.js',
'runtime.mjs': '../src/runtime.js',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 71ae1cc..b3cbf52 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.33.0';
++const SDK_VERSION = '0.34.0';
module.exports = {
createClient,
ddiff --git a/src/resources.js b/src/resources.js
nnew file mode 100644
iindex 0000000..5ded00a
---- /dev/null
++++ b/src/resources.js
@@@ -0,0 +1,383 @@
++'use strict';
++/**
++ * openvibe-sdk/resources — the resource index and the control plane (ADR-048, plan T13 step 2).
++ *
++ * Every service that owns resources answers GET /api/v1/resources with common.resource-list-result@1
++ * pages of common.resource-summary@1 (ADR-048: the index owns that path). A console or operator reads
++ * one authority directly, or fans out over several with createResourceIndex, and changes one resource by
++ * sending a common.resource-control-request@1 to its authority's control API with createResourceClient.
++ * The SDK never writes another service's rows: create, update, delete, start, stop, suspend, resume,
++ * resize, rotate, pair, grant, revoke and archive are all control calls the owning authority decides
++ * (it checks the caller's capability, applies, and answers common.resource-control-result@1).
++ *
++ * The authorities are the services that own resources (ADR-048: Actor, Codes, Events, Media, Run,
++ * Watch and Zone); OpenVibe.Network's T2 offer registry currently answers the same public
++ * /api/v1/resources path and moves to /api/v1/offers in Network's release.
++ *
++ * OVRN is the one cross-service resource name, ovrn:<service>:<project_id>:<type>/<id>
++ * (common.resource-name@1). parseResourceName/resourceName/resourceNameOf reuse openvibe-contracts'
++ * contracts.resources helpers (required lazily, only by these three) so this package, like every
++ * service, has one parser and one formatter and never splits the name on ':'.
++ *
++ * const index = createResourceIndex({ authorities: ['https://media.openvibe.network', …], token });
++ * const { resources, stale } = await index.list({ project: prj.id, kind: 'media.object' });
++ * const media = createResourceClient({ origin: 'https://media.openvibe.network', token });
++ * const result = await media.control({ action: 'delete', project_id: prj.id,
++ * resource: 'ovrn:media:prj_…:object/med_…', idempotency_key: '…' });
++ * result.state; // done | pending | refused | failed
++ *
++ * Server-only (browser: null): the control plane carries a user or service token and is never shipped
++ * to a page.
++ */
++const { OpenVibeError } = require('./core/errors');
++const { newIdempotencyKey } = require('./core/ids');
++
++const trimSlash = (s) => String(s).replace(/\/+$/, '');
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++/** A GET/POST timeout signal, or undefined when none is wanted. */
++const timeoutSignal = (ms) => (ms > 0 ? AbortSignal.timeout(ms) : undefined);
++
++/** A retryable HTTP status: pressure or a server-side fault, never the request itself. */
++const isRetryableStatus = (status) => status === 408 || status === 425 || status === 429 || status >= 500;
++
++/** The bearer header for a token that is a string, a getter or a token client; no header when there is none. */
++function tokenGetter(token) {
++ if (typeof token === 'function') return token;
++ if (token && typeof token.getToken === 'function') return () => token.getToken();
++ if (typeof token === 'string' && token) return () => token;
++ return () => null;
++}
++async function authHeaders(token) {
++ const value = await tokenGetter(token)();
++ return value ? { Authorization: `Bearer ${value}` } : {};
++}
++
++/** A fetch rejection as an OpenVibeError: sdk.timeout when the deadline aborted it, else sdk.network_error. */
++function networkError(cause, method, url) {
++ const aborted = Boolean(cause) && (cause.name === 'AbortError' || cause.name === 'TimeoutError');
++ return new OpenVibeError({
++ code: aborted ? 'sdk.timeout' : 'sdk.network_error',
++ message: `${method} ${url} failed: ${(cause && cause.message) || 'no response'}`,
++ cause, method, url,
++ });
++}
++
++// ── OVRN: the one resource name (common.resource-name@1) ─────────────────────────────────────────
++
++/** openvibe-contracts' contracts.resources (lib/resources.js), required lazily, only by the name helpers. */
++function contractResources() {
++ try { return require('openvibe-contracts').resources; }
++ catch {
++ throw new Error('openvibe-sdk/resources: openvibe-contracts is required for the resource-name helpers (contracts.resources.parse/format/nameOf)');
++ }
++}
++
++/** ovrn:<service>:<project_id>:<type>/<id> -> its four segments, or null when it is not a resource name. */
++function parseResourceName(name) { return contractResources().parse(name); }
++
++/** The four segments -> an OVRN; throws when they do not make a valid one (id prefix, project, type). */
++function resourceName(parts) { return contractResources().format(parts); }
++
++/** The OVRN of a common.resource-summary@1 (its kind's second half is the type), or null when it has none. */
++function resourceNameOf(summary) { return contractResources().nameOf(summary); }
++
++/**
++ * The resource-kind catalog of ADR-048, with each kind's three-letter id prefix. Only the kinds whose
++ * prefix is chosen in the pinned openvibe-contracts (ids.PREFIX: media -> med, watch -> wch) are here.
++ * The ADR's other kinds are deliberately absent until the step-8 sweep settles them: codes.repo,
++ * events.queue and events.subscription have no prefix at all, and actor.actor (act), run.sandbox (run;
++ * it collides with the existing run_ AI run ids) and zone.object-zone (zon) are only proposed. No
++ * contract schema carries this catalog — it is prose in ADR-048 — so this is not read from contracts.
++ */
++const RESOURCE_KINDS = Object.freeze([
++ Object.freeze({ kind: 'media.object', service: 'media', type: 'object', prefix: 'med' }),
++ Object.freeze({ kind: 'watch.watch', service: 'watch', type: 'watch', prefix: 'wch' }),
++]);
++
++// ── the resource index: fan out over authorities and merge their pages ───────────────────────────
++
++const INDEX_PATH = '/api/v1/resources';
++const DEFAULT_PAGE_LIMIT = 100;
++const DEFAULT_CONCURRENCY = 4;
++const DEFAULT_TIMEOUT_MS = 10000;
++/** The most pages one authority's cursor chain is followed for before it is reported stale. */
++const DEFAULT_MAX_PAGES = 1000;
++
++/** An authority's failure, reported instead of thrown so one authority cannot fail the whole index. */
++function staleOf(authority, err) {
++ return {
++ authority,
++ status: (err && err.status) || 0,
++ code: (err && err.code) || 'sdk.error',
++ error: (err && err.message) || String(err),
++ };
++}
++
++/** A structurally valid common.resource-list-result@1 page. */
++function isListResult(body) {
++ return Boolean(body) && typeof body === 'object' && Array.isArray(body.resources)
++ && (body.next_cursor === null || typeof body.next_cursor === 'string');
++}
++
++/** Run fn over items with at most `limit` in flight; results keep the items' order. */
++async function mapPool(items, limit, fn) {
++ const results = new Array(items.length);
++ const size = Math.max(1, Math.min(limit, items.length));
++ let next = 0;
++ const workers = [];
++ for (let w = 0; w < size; w++) {
++ workers.push((async () => {
++ for (;;) {
++ const i = next++;
++ if (i >= items.length) return;
++ results[i] = await fn(items[i], i);
++ }
++ })());
++ }
++ await Promise.all(workers);
++ return results;
++}
++
++/**
++ * The read side of the control plane: one index for several authorities. `list()` fans out
++ * GET {authority}/api/v1/resources?project=&kind=&cursor=&limit= with at most `concurrency` requests in
++ * flight, follows each authority's opaque next_cursor to the end, and merges the pages in authority
++ * order. An authority that fails (network, timeout, non-2xx, malformed page) never fails the call: its
++ * pages read so far are kept and it is reported in `stale` (`{ authority, status, code, error }`), so a
++ * caller can rebuild its read model and try that authority again.
++ *
++ * A cursor chain that never ends is bounded three ways, so one bad authority cannot hang `list()`:
++ * a repeated cursor (a cycle, or the same cursor echoed back) and an empty-string `next_cursor` end the
++ * walk, `maxPages` caps the pages read (`sdk.bad_response`), and an optional `maxMs` per-authority
++ * deadline reports a walk that outran its budget (`sdk.timeout`). Each bound keeps the pages read so
++ * far and reports the authority stale rather than failing the whole index.
++ *
++ * @param {object} o
++ * @param {string[]} o.authorities base URLs of the services whose index to read
++ * @param {string|Function|{getToken(): Promise<string>}} [o.token] a bearer token, a getter, or a token client
++ * @param {Function} [o.fetch] fetch for the requests (default globalThis.fetch)
++ * @param {number} [o.pageLimit] the per-request `limit` (default 100)
++ * @param {number} [o.concurrency] the most requests in flight at once (default 4)
++ * @param {number} [o.timeoutMs] the per-request timeout (default 10000)
++ * @param {number} [o.maxPages] the most pages per authority before it is reported stale (default 1000)
++ * @param {number} [o.maxMs] an optional per-authority deadline; a walk past it is reported stale
++ * @returns {{ authorities: string[], list(opts?): Promise<{resources, stale}>, iterate(opts?): AsyncGenerator }}
++ */
++function createResourceIndex({
++ authorities, token, fetch: fetchImpl = globalThis.fetch,
++ pageLimit = DEFAULT_PAGE_LIMIT, concurrency = DEFAULT_CONCURRENCY, timeoutMs = DEFAULT_TIMEOUT_MS,
++ maxPages = DEFAULT_MAX_PAGES, maxMs = null,
++} = {}) {
++ if (!Array.isArray(authorities) || authorities.length === 0) throw new TypeError('createResourceIndex: authorities must be a non-empty array of base URLs');
++ if (typeof fetchImpl !== 'function') throw new TypeError('createResourceIndex: a fetch implementation is required');
++ if (!Number.isInteger(concurrency) || concurrency < 1) throw new TypeError('createResourceIndex: concurrency must be a positive integer');
++ if (pageLimit != null && (!Number.isInteger(pageLimit) || pageLimit < 1)) throw new TypeError('createResourceIndex: pageLimit must be a positive integer');
++ if (!Number.isInteger(maxPages) || maxPages < 1) throw new TypeError('createResourceIndex: maxPages must be a positive integer');
++ if (maxMs != null && (!Number.isFinite(maxMs) || maxMs <= 0)) throw new TypeError('createResourceIndex: maxMs must be a positive number of milliseconds');
++ const bases = authorities.map((a) => trimSlash(a));
++
++ /** One page of an authority's index; a non-2xx or a non-page body throws an OpenVibeError. */
++ async function page(authority, { project, kind, cursor, limit }) {
++ const query = new URLSearchParams();
++ if (project != null) query.set('project', project);
++ if (kind != null) query.set('kind', kind);
++ if (cursor != null && cursor !== '') query.set('cursor', cursor);
++ if (limit != null) query.set('limit', String(limit));
++ const url = `${authority}${INDEX_PATH}${query.toString() ? `?${query}` : ''}`;
++ let res;
++ try {
++ res = await fetchImpl(url, { headers: { Accept: 'application/json', ...(await authHeaders(token)) }, signal: timeoutSignal(timeoutMs) });
++ } catch (err) {
++ throw networkError(err, 'GET', url);
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok) throw OpenVibeError.fromResponse({ status: res.status, body, method: 'GET', url, retryable: isRetryableStatus(res.status) });
++ if (!isListResult(body)) throw new OpenVibeError({ code: 'sdk.bad_response', message: `GET ${url} is not a common.resource-list-result@1 page`, method: 'GET', url });
++ return body;
++ }
++
++ /** Walk one authority's cursor chain, keeping its pages and reporting a failure as stale. */
++ async function walk(authority, filter) {
++ const resources = [];
++ const seen = new Set();
++ const startedAt = Date.now();
++ const where = `${authority}${INDEX_PATH}`;
++ let cursor;
++ try {
++ for (let pages = 0; ; pages++) {
++ if (pages >= maxPages) {
++ throw new OpenVibeError({ code: 'sdk.bad_response', message: `GET ${where} did not end after ${maxPages} pages`, method: 'GET', url: where });
++ }
++ if (maxMs != null && Date.now() - startedAt > maxMs) {
++ throw new OpenVibeError({ code: 'sdk.timeout', message: `GET ${where} exceeded maxMs (${maxMs} ms)`, method: 'GET', url: where });
++ }
++ const body = await page(authority, { ...filter, cursor });
++ resources.push(...body.resources);
++ const next = body.next_cursor;
++ if (next == null || next === '') break; // null or empty: the end
++ if (next === cursor || seen.has(next)) {
++ throw new OpenVibeError({ code: 'sdk.bad_response', message: `GET ${where} cursor repeated (${next})`, method: 'GET', url: where });
++ }
++ seen.add(next);
++ cursor = next;
++ }
++ return { resources, stale: null };
++ } catch (err) {
++ return { resources, stale: staleOf(authority, err) };
++ }
++ }
++
++ /**
++ * Every resource of every authority, merged in authority order. `{ resources, stale }`; `stale`
++ * lists the authorities this call could not read to the end.
++ */
++ async function list({ project, kind, limit = pageLimit } = {}) {
++ const filter = { project, kind, limit };
++ const perAuthority = await mapPool(bases, concurrency, (authority) => walk(authority, filter));
++ const resources = [];
++ const stale = [];
++ for (const r of perAuthority) {
++ resources.push(...r.resources);
++ if (r.stale) stale.push(r.stale);
++ }
++ return { resources, stale };
++ }
++
++ /** The same resources as list(), one at a time. A stale authority contributes what it answered. */
++ async function* iterate(opts) {
++ const { resources } = await list(opts);
++ yield* resources;
++ }
++
++ return { authorities: bases, list, iterate };
++}
++
++// ── the control side: one control call to the authority that owns a resource ─────────────────────
++
++const CONTROL_PATH = '/api/v1/resources/control';
++const DEFAULT_RETRIES = 2;
++const DEFAULT_RETRY_DELAY_MS = 250;
++
++/** A refused/failed common.resource-control-result@1: a body the authority answered with, not an error. */
++function isRefusedOrFailedResult(body) {
++ return Boolean(body) && typeof body === 'object'
++ && (body.state === 'refused' || body.state === 'failed')
++ && (body.problem != null || body.confirmation_required != null);
++}
++
++/** The minimal request shape check, used only when openvibe-contracts is not installed. */
++function shapeCheckControlRequest(request) {
++ const errors = [];
++ if (typeof request.action !== 'string' || !request.action) errors.push({ path: '/action', message: 'required' });
++ if (typeof request.project_id !== 'string' || !request.project_id) errors.push({ path: '/project_id', message: 'required' });
++ if (typeof request.idempotency_key !== 'string' || request.idempotency_key.length < 8) errors.push({ path: '/idempotency_key', message: 'required, 8-200 characters' });
++ const names = (request.resource === undefined ? 0 : 1) + (request.resource_kind === undefined ? 0 : 1);
++ if (names !== 1) errors.push({ path: '/resource', message: 'exactly one of resource or resource_kind' });
++ return { valid: errors.length === 0, errors };
++}
++
++/**
++ * A common.resource-control-request@1, checked with contracts.resources.checkControlRequest when
++ * openvibe-contracts is installed. Only a missing module falls back to the local shape check (create
++ * names resource_kind; every other action names resource): any real error the contracts helper throws
++ * propagates, so the OVRN parse and the project-tenancy rule are never silently skipped.
++ */
++function checkControlRequest(request) {
++ let contracts;
++ try {
++ contracts = require('openvibe-contracts');
++ } catch (err) {
++ if (!err || err.code !== 'MODULE_NOT_FOUND') throw err;
++ return shapeCheckControlRequest(request);
++ }
++ return contracts.resources.checkControlRequest(request);
++}
++
++/**
++ * The write side of the control plane: POST a common.resource-control-request@1 to
++ * {origin}/api/v1/resources/control and return the authority's common.resource-control-result@1.
++ *
++ * A request carries an idempotency_key (one is generated when the caller omits it, `idem_…`); the
++ * authority stores the first answer per (caller, project, key), so a transient failure (network,
++ * timeout, 408/425/429/5xx) is retried with the SAME key and the same body, up to `retries` times, and
++ * an action is never applied twice. `state` is the outcome, not the resource's state: `done`, `pending`,
++ * `refused` (carrying a `problem` or `confirmation_required`) or `failed` — all four are returned when
++ * the authority sends them with a 2xx, and a refused/failed result with a non-retryable non-2xx (403,
++ * 409, 422 …) is returned too, so the caller reads `confirmation_required.confirmation_id` and repeats
++ * the request with it once the owner approves. Any other non-2xx answer is an OpenVibeError
++ * (problem+json -> code/status); a thrown error carries the request's `idempotencyKey`, so a caller that
++ * retries a sensitive action after a timeout or a 5xx reuses the key the authority already saw instead
++ * of generating a fresh one that could duplicate the action.
++ *
++ * @param {object} o
++ * @param {string} o.origin the authority's base URL
++ * @param {string|Function|{getToken(): Promise<string>}} [o.token] a bearer token, a getter, or a token client
++ * @param {Function} [o.fetch] fetch for the POST (default globalThis.fetch)
++ * @param {number} [o.timeoutMs] the per-attempt timeout (default 10000)
++ * @param {number} [o.retries] retries after the first attempt for a transient failure (default 2)
++ * @param {number} [o.retryDelayMs] the base backoff between attempts (default 250; grows linearly)
++ * @returns {{ path: string, control(request, opts?): Promise<object> }}
++ */
++function createResourceClient({
++ origin, token, fetch: fetchImpl = globalThis.fetch,
++ timeoutMs = DEFAULT_TIMEOUT_MS, retries = DEFAULT_RETRIES, retryDelayMs = DEFAULT_RETRY_DELAY_MS,
++} = {}) {
++ if (typeof fetchImpl !== 'function') throw new TypeError('createResourceClient: a fetch implementation is required');
++ const base = trimSlash(origin || '');
++ if (!base) throw new TypeError('createResourceClient: origin is required');
++ if (!Number.isInteger(retries) || retries < 0) throw new TypeError('createResourceClient: retries must be a non-negative integer');
++
++ async function control(request, { retries: attempts = retries } = {}) {
++ if (!request || typeof request !== 'object') throw new TypeError('resourceClient.control: a common.resource-control-request@1 is required');
++ const body = { ...request };
++ if (body.idempotency_key == null) body.idempotency_key = newIdempotencyKey();
++ const check = checkControlRequest(body);
++ if (!check.valid) throw new Error(`not a valid common.resource-control-request@1: ${JSON.stringify(check.errors)}`);
++ const url = `${base}${CONTROL_PATH}`;
++ for (let attempt = 0; ; attempt++) {
++ let res;
++ try {
++ res = await fetchImpl(url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await authHeaders(token)) },
++ body: JSON.stringify(body),
++ signal: timeoutSignal(timeoutMs),
++ });
++ } catch (err) {
++ const netErr = networkError(err, 'POST', url);
++ netErr.idempotencyKey = body.idempotency_key;
++ if (attempt < attempts) { await sleep(retryDelayMs * (attempt + 1)); continue; }
++ throw netErr;
++ }
++ const data = await res.json().catch(() => null);
++ if (!res.ok) {
++ const retryable = isRetryableStatus(res.status);
++ // ADR-048 does not fix the status for refused/failed: a non-retryable non-2xx may carry
++ // the answer, so return it instead of throwing it away. A retryable status retries first.
++ if (!retryable && isRefusedOrFailedResult(data)) return data;
++ const err = OpenVibeError.fromResponse({ status: res.status, body: data, method: 'POST', url, retryable });
++ err.idempotencyKey = body.idempotency_key;
++ if (attempt < attempts && retryable) { await sleep(retryDelayMs * (attempt + 1)); continue; }
++ throw err;
++ }
++ if (!data || typeof data !== 'object' || typeof data.state !== 'string') {
++ const err = new OpenVibeError({ code: 'sdk.bad_response', message: `POST ${url} is not a common.resource-control-result@1`, method: 'POST', url });
++ err.idempotencyKey = body.idempotency_key;
++ throw err;
++ }
++ return data;
++ }
++ }
++
++ return { path: CONTROL_PATH, control };
++}
++
++module.exports = {
++ parseResourceName,
++ resourceName,
++ resourceNameOf,
++ RESOURCE_KINDS,
++ createResourceIndex,
++ createResourceClient,
++};
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 8fa5636..1863619 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
++ assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex 416fbd5..46a74ac 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'src/resources.js', 'esm/resources.mjs', 'types/resources.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
-- assert.equal(subpaths.length, 41);
++ assert.equal(subpaths.length, 42);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
ddiff --git a/test/resources.test.js b/test/resources.test.js
nnew file mode 100644
iindex 0000000..28e9545
---- /dev/null
++++ b/test/resources.test.js
@@@ -0,0 +1,308 @@
++'use strict';
++/**
++ * openvibe-sdk/resources: OVRN names (contracts.resources helpers), the resource index fanning out over
++ * authorities and merging common.resource-list-result@1 pages (cursor, stale authority), and the control
++ * client posting common.resource-control-request@1 to /api/v1/resources/control (posts, retries, refused).
++ */
++const assert = require('node:assert/strict');
++const { run, stubServer, send, problem } = require('./helpers');
++const {
++ parseResourceName, resourceName, resourceNameOf, RESOURCE_KINDS,
++ createResourceIndex, createResourceClient,
++} = require('../src/resources');
++
++const PRJ = 'prj_01J0000000000000000000000A';
++const PRJ2 = 'prj_01J0000000000000000000000B';
++const MED = 'med_01K0000000000000000000000A';
++const MED2 = 'med_01K0000000000000000000000B';
++const WCH = 'wch_01K0000000000000000000000C';
++
++const summary = (id, { kind = 'media.object', project_id = PRJ, service = 'media' } = {}) =>
++ ({ id, kind, service, project_id, state: 'active', created_at: '2026-10-05T00:00:00Z' });
++const page = (resources, next_cursor = null) => ({ resources, next_cursor });
++
++run([
++ ['OVRN: compose from a summary, parse back, and reject what is not a resource name', async () => {
++ const name = resourceName({ service: 'media', project_id: PRJ, type: 'object', id: MED });
++ assert.equal(name, `ovrn:media:${PRJ}:object/${MED}`);
++ assert.deepEqual(parseResourceName(name), { service: 'media', project_id: PRJ, type: 'object', id: MED });
++ assert.equal(resourceNameOf(summary(MED)), name, 'composed from kind + service + project_id + id');
++ assert.equal(resourceNameOf(summary(WCH, { kind: 'watch.watch', service: 'watch' })), `ovrn:watch:${PRJ}:watch/${WCH}`);
++ assert.equal(resourceNameOf(summary(MED, { kind: 'watch.watch' })), null, 'kind must belong to the summary service');
++
++ assert.equal(parseResourceName('not-a-name'), null);
++ assert.equal(parseResourceName(`ovrn:media:${PRJ}:object/usr_01K0000000000000000000000A`), null, 'a person is never a resource');
++ assert.equal(parseResourceName(`ovrn:media:prj_not-a-ulid:object/${MED}`), null);
++ assert.equal(resourceNameOf({ id: MED, service: 'media' }), null, 'no kind: nothing to compose');
++ assert.throws(() => resourceName({ service: 'media', project_id: PRJ, type: 'object', id: 'usr_01K0000000000000000000000A' }), TypeError);
++
++ assert.deepEqual(RESOURCE_KINDS, [
++ { kind: 'media.object', service: 'media', type: 'object', prefix: 'med' },
++ { kind: 'watch.watch', service: 'watch', type: 'watch', prefix: 'wch' },
++ ], 'only the chosen prefixes; act/run/zon are proposed and events.queue/subscription and codes.repo unchosen');
++ }],
++
++ ['the index fans out over two authorities and merges their pages, with the filters on the query', async () => {
++ const a = await stubServer((req, res) => send(res, 200, page([summary(MED)])));
++ const b = await stubServer((req, res) => send(res, 200, page([summary(MED2)])));
++ try {
++ const index = createResourceIndex({ authorities: [a.url, b.url], token: 'svc-token', pageLimit: 25 });
++ const { resources, stale } = await index.list({ project: PRJ, kind: 'media.object' });
++ assert.deepEqual(resources.map((r) => r.id), [MED, MED2], 'authority order, each page merged');
++ assert.deepEqual(stale, []);
++ for (const srv of [a, b]) {
++ assert.equal(srv.requests.length, 1);
++ assert.equal(srv.requests[0].method, 'GET');
++ assert.equal(srv.requests[0].headers.authorization, 'Bearer svc-token');
++ const url = new URL(srv.requests[0].url, srv.url);
++ assert.equal(url.pathname, '/api/v1/resources');
++ assert.equal(url.searchParams.get('project'), PRJ);
++ assert.equal(url.searchParams.get('kind'), 'media.object');
++ assert.equal(url.searchParams.get('limit'), '25');
++ assert.equal(url.searchParams.has('cursor'), false, 'the first page names no cursor');
++ }
++ } finally { await a.close(); await b.close(); }
++ }],
++
++ ['the index follows each authority next_cursor to the end', async () => {
++ const srv = await stubServer((req, res) => {
++ const cursor = new URL(req.url, 'http://x').searchParams.get('cursor');
++ if (!cursor) return send(res, 200, page([summary(MED)], 'c2'));
++ assert.equal(cursor, 'c2');
++ return send(res, 200, page([summary(MED2)], null));
++ });
++ try {
++ const index = createResourceIndex({ authorities: [srv.url] });
++ const { resources } = await index.list({ project: PRJ });
++ assert.deepEqual(resources.map((r) => r.id), [MED, MED2]);
++ assert.equal(srv.requests.length, 2);
++ assert.equal(new URL(srv.requests[1].url, srv.url).searchParams.get('cursor'), 'c2');
++ } finally { await srv.close(); }
++ }],
++
++ ['one authority answering 500 is reported stale; the others still merge', async () => {
++ const bad = await stubServer((req, res) => {
++ const cursor = new URL(req.url, 'http://x').searchParams.get('cursor');
++ if (!cursor) return send(res, 200, page([summary(MED)], 'c2')); // page 1 read, then it fails
++ return problem(res, 500, 'internal.error', 'boom');
++ });
++ const ok = await stubServer((req, res) => send(res, 200, page([summary(MED2)])));
++ try {
++ const index = createResourceIndex({ authorities: [bad.url, ok.url] });
++ const { resources, stale } = await index.list({ project: PRJ });
++ assert.deepEqual(resources.map((r) => r.id).sort(), [MED, MED2].sort(), 'the pages read are kept; the other authority merges');
++ assert.equal(stale.length, 1);
++ assert.equal(stale[0].authority, bad.url);
++ assert.equal(stale[0].status, 500);
++ assert.equal(stale[0].code, 'internal.error');
++ assert.match(stale[0].error, /500/);
++ } finally { await bad.close(); await ok.close(); }
++ }],
++
++ ['control posts a common.resource-control-request@1 as a bearer of the caller', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, { action: 'delete', resource: `ovrn:media:${PRJ}:object/${MED}`, state: 'done', at: '2026-10-05T00:00:00Z' }));
++ try {
++ const client = createResourceClient({ origin: srv.url, token: 'user-jwt' });
++ const result = await client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}` });
++ assert.equal(result.state, 'done');
++ assert.equal(srv.requests.length, 1);
++ assert.equal(srv.requests[0].method, 'POST');
++ assert.equal(srv.requests[0].headers.authorization, 'Bearer user-jwt');
++ const body = JSON.parse(srv.requests[0].body.toString());
++ assert.equal(body.action, 'delete');
++ assert.equal(body.project_id, PRJ);
++ assert.match(body.idempotency_key, /^idem_[0-9A-HJKMNP-TV-Z]{26}$/, 'generated when the caller omits it');
++ assert.equal(new URL(srv.requests[0].url, srv.url).pathname, '/api/v1/resources/control');
++ assert.equal(client.path, '/api/v1/resources/control');
++
++ await assert.rejects(
++ client.control({ action: 'create', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}` }),
++ /not a valid common.resource-control-request@1/,
++ 'create names resource_kind, never resource',
++ );
++ assert.equal(srv.requests.length, 1, 'an invalid request never reaches the authority');
++ } finally { await srv.close(); }
++ }],
++
++ ['control retries a transient answer with the same idempotency key and body', async () => {
++ const seen = [];
++ const srv = await stubServer((req, res, body) => {
++ seen.push(JSON.parse(body.toString()));
++ if (seen.length === 1) return problem(res, 503, 'service.unavailable', 'try again');
++ return send(res, 200, { action: 'rotate', state: 'done', at: '2026-10-05T00:00:00Z' });
++ });
++ try {
++ const client = createResourceClient({ origin: srv.url, token: 'svc', retryDelayMs: 1 });
++ const result = await client.control({ action: 'rotate', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'retry-test-given-key' });
++ assert.equal(result.state, 'done');
++ assert.equal(seen.length, 2);
++ assert.deepEqual(seen[0], seen[1], 'a retry repeats the exact request, so the authority applies it once');
++ assert.equal(seen[0].idempotency_key, 'retry-test-given-key');
++ } finally { await srv.close(); }
++ }],
++
++ ['control returns a refused result (confirmation gate) instead of throwing', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, {
++ action: 'delete', state: 'refused', at: '2026-10-05T00:00:00Z',
++ confirmation_required: { confirmation_id: 'cnf_01K0000000000000000000000A', reason: 'the owner must approve a delete' },
++ }));
++ try {
++ const client = createResourceClient({ origin: srv.url });
++ const result = await client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'approve-test-key' });
++ assert.equal(result.state, 'refused');
++ assert.equal(result.confirmation_required.confirmation_id, 'cnf_01K0000000000000000000000A');
++ assert.equal(srv.requests[0].headers.authorization, undefined, 'no token: no header');
++ } finally { await srv.close(); }
++ }],
++
++ ['the index stops and reports stale when an authority repeats a cursor (a cycle)', async () => {
++ const srv = await stubServer((req, res) => {
++ const cursor = new URL(req.url, 'http://x').searchParams.get('cursor');
++ const next = cursor === 'A' ? 'B' : 'A'; // A -> B -> A never ends
++ return send(res, 200, page([summary(MED)], next));
++ });
++ try {
++ const index = createResourceIndex({ authorities: [srv.url] });
++ const { resources, stale } = await index.list({ project: PRJ });
++ assert.equal(stale.length, 1);
++ assert.equal(stale[0].authority, srv.url);
++ assert.equal(stale[0].code, 'sdk.bad_response');
++ assert.match(stale[0].error, /cursor repeated/);
++ assert.deepEqual(resources.map((r) => r.id), [MED, MED, MED], 'the pages read are kept');
++ assert.equal(srv.requests.length, 3, 'page 1 (no cursor), A, then B repeats A');
++ } finally { await srv.close(); }
++ }],
++
++ ['the index stops an endless cursor stream at maxPages and reports stale', async () => {
++ let n = 0;
++ const srv = await stubServer((req, res) => send(res, 200, page([summary(MED)], `cursor-${++n}`)));
++ try {
++ const index = createResourceIndex({ authorities: [srv.url], maxPages: 3 });
++ const { resources, stale } = await index.list({ project: PRJ });
++ assert.equal(srv.requests.length, 3, 'maxPages bounds the walk');
++ assert.equal(resources.length, 3, 'the pages read are kept');
++ assert.equal(stale.length, 1);
++ assert.equal(stale[0].code, 'sdk.bad_response');
++ assert.match(stale[0].error, /did not end after 3 pages/);
++ } finally { await srv.close(); }
++ }],
++
++ ['the index reports a walk past its maxMs deadline as stale', async () => {
++ const srv = await stubServer((req, res) => setTimeout(() => send(res, 200, page([summary(MED)], 'more')), 30));
++ try {
++ const index = createResourceIndex({ authorities: [srv.url], maxMs: 15 });
++ const { resources, stale } = await index.list({ project: PRJ });
++ assert.equal(resources.length, 1, 'the first page is kept');
++ assert.equal(stale.length, 1);
++ assert.equal(stale[0].code, 'sdk.timeout');
++ assert.match(stale[0].error, /maxMs/);
++ } finally { await srv.close(); }
++ }],
++
++ ['an empty next_cursor ends the walk (no cursor= request)', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, page([summary(MED)], '')));
++ try {
++ const index = createResourceIndex({ authorities: [srv.url] });
++ const { resources, stale } = await index.list({ project: PRJ });
++ assert.deepEqual(resources.map((r) => r.id), [MED]);
++ assert.deepEqual(stale, []);
++ assert.equal(srv.requests.length, 1, 'an empty cursor is the end, not a cursor to follow');
++ } finally { await srv.close(); }
++ }],
++
++ ['control returns a refused result sent with a non-retryable non-2xx status', async () => {
++ const srv = await stubServer((req, res) => problem(res, 409, 'resource.confirmation_required', 'needs approval', {
++ action: 'delete', state: 'refused', at: '2026-10-05T00:00:00Z',
++ confirmation_required: { confirmation_id: 'cnf_01K0000000000000000000000A', reason: 'the owner must approve a delete' },
++ }));
++ try {
++ const client = createResourceClient({ origin: srv.url, retryDelayMs: 1 });
++ const result = await client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'conflict-refused-key' });
++ assert.equal(result.state, 'refused');
++ assert.equal(result.confirmation_required.confirmation_id, 'cnf_01K0000000000000000000000A');
++ assert.equal(srv.requests.length, 1, 'a non-retryable status is not retried');
++ } finally { await srv.close(); }
++ }],
++
++ ['a 5xx refused body still retries first, then throws with the idempotency key', async () => {
++ const srv = await stubServer((req, res) => problem(res, 503, 'service.unavailable', 'try again', {
++ action: 'delete', state: 'refused', at: '2026-10-05T00:00:00Z', problem: { detail: 'busy' },
++ }));
++ try {
++ const client = createResourceClient({ origin: srv.url, retries: 1, retryDelayMs: 1 });
++ const err = await client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'busy-refused-key' })
++ .then(() => null, (e) => e);
++ assert.ok(err, 'a retryable status still throws after the retries');
++ assert.equal(srv.requests.length, 2);
++ assert.equal(err.idempotencyKey, 'busy-refused-key');
++ assert.equal(JSON.parse(srv.requests[1].body.toString()).idempotency_key, 'busy-refused-key');
++ } finally { await srv.close(); }
++ }],
++
++ ['a non-retryable non-2xx without a refused/failed body is still thrown', async () => {
++ const srv = await stubServer((req, res) => problem(res, 403, 'authorization.denied', 'no capability'));
++ try {
++ const client = createResourceClient({ origin: srv.url });
++ await assert.rejects(
++ client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'forbidden-test-key' }),
++ (err) => err.code === 'authorization.denied' && err.status === 403,
++ );
++ assert.equal(srv.requests.length, 1);
++ } finally { await srv.close(); }
++ }],
++
++ ['a thrown control error carries the generated idempotency key', async () => {
++ let sawKey;
++ const srv = await stubServer((req, res, body) => {
++ sawKey = JSON.parse(body.toString()).idempotency_key;
++ return problem(res, 500, 'internal.error', 'boom');
++ });
++ try {
++ const client = createResourceClient({ origin: srv.url, retries: 0 });
++ const err = await client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}` })
++ .then(() => null, (e) => e);
++ assert.ok(err);
++ assert.match(err.idempotencyKey, /^idem_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.equal(err.idempotencyKey, sawKey, 'the key the authority saw is returned to the caller');
++ } finally { await srv.close(); }
++ }],
++
++ ['a real error from the contracts validator propagates instead of the weak shape check', async () => {
++ const contracts = require(require.resolve('openvibe-contracts'));
++ const original = contracts.resources.checkControlRequest;
++ const srv = await stubServer((req, res) => send(res, 200, { action: 'delete', state: 'done', at: '2026-10-05T00:00:00Z' }));
++ try {
++ contracts.resources.checkControlRequest = () => { throw new Error('contracts validator exploded'); };
++ const client = createResourceClient({ origin: srv.url });
++ await assert.rejects(
++ client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ}:object/${MED}`, idempotency_key: 'validator-test-key' }),
++ /contracts validator exploded/,
++ );
++ assert.equal(srv.requests.length, 0, 'a validator error never degrades into sending the request');
++ } finally {
++ contracts.resources.checkControlRequest = original;
++ await srv.close();
++ }
++ }],
++
++ ['control enforces the project-tenancy rule through the contracts validator', async () => {
++ const srv = await stubServer((req, res) => send(res, 200, { action: 'delete', state: 'done', at: '2026-10-05T00:00:00Z' }));
++ try {
++ const client = createResourceClient({ origin: srv.url });
++ await assert.rejects(
++ client.control({ action: 'delete', project_id: PRJ, resource: `ovrn:media:${PRJ2}:object/${MED}`, idempotency_key: 'tenancy-test-key' }),
++ /not a valid common.resource-control-request@1/,
++ );
++ assert.equal(srv.requests.length, 0, 'a cross-project resource is never sent');
++ } finally { await srv.close(); }
++ }],
++
++ ['STATUS.json lists the resources module among the capabilities', async () => {
++ const status = require('../STATUS.json');
++ assert.ok(
++ status.features.some((f) => /openvibe-sdk\/resources/.test(f)),
++ 'the capability list names openvibe-sdk/resources',
++ );
++ assert.equal(status.version, require('../package.json').version);
++ }],
++]);
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex e4e9569..91ca0f0 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -14,6 +14,7 @@ export * as ai from './ai';
export * as jobs from './jobs';
export * as tools from './tools';
export * as projects from './projects';
++export * as resources from './resources';
export * as vip from './vip';
export * as openre from './openre';
export * as geo from './geo';
ddiff --git a/types/resources.d.ts b/types/resources.d.ts
nnew file mode 100644
iindex 0000000..5c4b71d
---- /dev/null
++++ b/types/resources.d.ts
@@@ -0,0 +1,106 @@
++/** common.resource-name@1 (OVRN): the four segments of ovrn:<service>:<project_id>:<type>/<id>. */
++export type ResourceNameParts = { service: string; project_id: string; type: string; id: string };
++/** A common.resource-summary@1: one resource of any service, as a resource index lists it. */
++export interface ResourceSummary {
++ id: string;
++ /** <service>.<type>, e.g. 'media.object', 'watch.watch'. */
++ kind: string;
++ service: string;
++ project_id?: string;
++ /** The OVRN when the authority includes it; resourceNameOf() composes one from kind/service/id anyway. */
++ ovrn?: string;
++ name?: string;
++ state: string;
++ created_at: string;
++ updated_at?: string;
++ owner?: unknown;
++ usage?: Record<string, number>;
++ links?: { self?: string; console?: string; docs?: string };
++ [field: string]: unknown;
++}
++/** A common.resource-list-result@1 page: a page's resources and the opaque next_cursor (null at the end). */
++export interface ResourceListResult { resources: ResourceSummary[]; next_cursor: string | null; }
++/** A resource kind whose three-letter id prefix ADR-048 has chosen. */
++export interface ResourceKind { kind: string; service: string; type: string; prefix: string; }
++export type ResourceControlAction = 'create' | 'update' | 'delete' | 'start' | 'stop' | 'suspend' | 'resume'
++ | 'resize' | 'rotate' | 'pair' | 'grant' | 'revoke' | 'archive';
++/** A common.resource-control-request@1: create names resource_kind, every other action names resource. */
++export interface ResourceControlRequest {
++ action: ResourceControlAction;
++ project_id: string;
++ /** Generated when omitted; a thrown OpenVibeError carries it back as `idempotencyKey`, so a retry reuses it. */
++ idempotency_key?: string;
++ resource?: string;
++ resource_kind?: string;
++ params?: Record<string, unknown>;
++ on_behalf_of?: unknown;
++ confirmation_id?: string | null;
++ dry_run?: boolean;
++ trace_id?: string;
++}
++/** A common.resource-control-result@1: the authority's outcome (refused/failed carry a problem). */
++export interface ResourceControlResult {
++ action: string;
++ state: 'done' | 'pending' | 'refused' | 'failed';
++ at: string;
++ resource?: string;
++ result?: Record<string, unknown>;
++ confirmation_required?: { confirmation_id: string; reason: string };
++ problem?: unknown;
++ [field: string]: unknown;
++}
++/** An authority the index could not read to the end; its pages read so far are still returned. */
++export interface StaleAuthority { authority: string; status: number; code: string; error: string; }
++/** A bearer token, a getter, or anything with getToken() (openvibe-sdk/auth's createServiceTokenClient). */
++export type ResourceToken = string | (() => string | Promise<string>) | { getToken(ctx?: { audience?: string }): Promise<string> };
++export type ResourceIndexOptions = {
++ authorities: string[];
++ token?: ResourceToken | null;
++ fetch?: import('./core').FetchLike;
++ /** The per-request `limit` (default 100). */
++ pageLimit?: number;
++ /** The most requests in flight at once (default 4). */
++ concurrency?: number;
++ /** The per-request timeout (default 10000). */
++ timeoutMs?: number;
++ /** The most pages per authority before it is reported stale (default 1000). */
++ maxPages?: number;
++ /** An optional per-authority deadline in milliseconds; a walk past it is reported stale. */
++ maxMs?: number;
++};
++export type ResourceListOptions = { project?: string; kind?: string; limit?: number };
++export interface ResourceIndex {
++ readonly authorities: string[];
++ /** Every resource of every authority, merged in authority order; `stale` lists those not fully read. */
++ list(opts?: ResourceListOptions): Promise<{ resources: ResourceSummary[]; stale: StaleAuthority[] }>;
++ /** The same resources, one at a time. */
++ iterate(opts?: ResourceListOptions): AsyncGenerator<ResourceSummary, void, unknown>;
++}
++export type ResourceClientOptions = {
++ origin: string;
++ token?: ResourceToken | null;
++ fetch?: import('./core').FetchLike;
++ /** The per-attempt timeout (default 10000). */
++ timeoutMs?: number;
++ /** Retries after the first attempt for a transient failure (default 2). */
++ retries?: number;
++ /** The base backoff between attempts (default 250; grows linearly). */
++ retryDelayMs?: number;
++};
++export interface ResourceClient {
++ readonly path: string;
++ /** A thrown OpenVibeError carries `idempotencyKey` (the request's key) so a retry reuses it. */
++ control(request: ResourceControlRequest, opts?: { retries?: number }): Promise<ResourceControlResult>;
++}
++/** The chosen resource kinds and their three-letter id prefixes (ADR-048); proposed/unchosen kinds are absent. */
++export declare const RESOURCE_KINDS: readonly ResourceKind[];
++/** ovrn:… -> its four segments, or null (contracts.resources.parse). */
++export declare function parseResourceName(name: string): ResourceNameParts | null;
++/** The four segments -> an OVRN; throws when they do not make one (contracts.resources.format). */
++export declare function resourceName(parts: ResourceNameParts): string;
++/** A summary's OVRN, or null when its kind/id do not compose one (contracts.resources.nameOf). */
++export declare function resourceNameOf(summary: Pick<ResourceSummary, 'kind' | 'service' | 'project_id' | 'id'>): string | null;
++/** One index over several authorities: fan out, follow each next_cursor, merge the pages. */
++export declare function createResourceIndex(opts: ResourceIndexOptions): ResourceIndex;
++/** POST a common.resource-control-request@1 to {origin}/api/v1/resources/control. */
++export declare function createResourceClient(opts: ResourceClientOptions): ResourceClient;