openvibers/openvibe.sdk Commits
openvibe-sdk/resources: OVRN names, a resource index over authorities and resource control; release 0.34.0 (#18)
Changes
@@@ -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()`@@@ -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` |@@@ -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"}@@@ -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,@@@ -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 isalready 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.@@@ -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;nnew file mode 100644@@@ -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;@@@ -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'),@@@ -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",@@@ -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,@@@ -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',@@@ -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,nnew file mode 100644@@@ -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,++};@@@ -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`);@@@ -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 });nnew file mode 100644@@@ -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);++ }],++]);@@@ -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';nnew file mode 100644@@@ -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;
@@ -7,6 +7,32 @@ release may change an API and says so here.
@@ -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)
## 0.33.0 (2026-10-05)
- **`openvibe-sdk/events` surfaces the opaque `after`/`next_cursor` pull cursors (ADR-042 decision 7):** `pull()`
- **`openvibe-sdk/events` surfaces the opaque `after`/`next_cursor` pull cursors (ADR-042 decision 7):** `pull()`
@@ -2,14 +2,14 @@
@@ -2,14 +2,14 @@
> Supported browser and server clients for the OpenVibe platform.
> 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.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
**If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here.
```bash
```bash
-npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.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).
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
@@ -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/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/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/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/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/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` |
| `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` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -27,7 +27,8 @@
@@ -27,7 +27,8 @@
"commerce (openvibe-sdk/commerce): the shared Billing client for Tips and VIP, with intentKey and receipt",
"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)",
"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",
"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"
"updated": "2026-10-05"
}
}
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.33.0';
+const SDK_VERSION = '0.34.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -241,3 +241,34 @@ alone. `parser: express.json({ limit })` keeps Express's parser and maps its err
@@ -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
`jsonErrors()` is opt-in: adopting it decides which paths answer JSON and which text, so mount it only where that is
already the behaviour.
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.
@@ -7,7 +7,7 @@ export const {
@@ -7,7 +7,7 @@ export const {
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
community, bot, search, chat, ai, jobs,
community, bot, search, chat, ai, jobs,
- tools, projects, vip, openre, geo, usage,
- commerce, telemetry,
+ tools, projects, resources, vip, openre, geo,
+ usage, commerce, telemetry,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/resources.js. Do not edit.
+import cjs from '../src/resources.js';
+
+export const {
+ parseResourceName, resourceName, resourceNameOf, RESOURCE_KINDS, createResourceIndex, createResourceClient,
+} = cjs;
+export default cjs;
@@ -23,6 +23,7 @@ module.exports = {
@@ -23,6 +23,7 @@ module.exports = {
jobs: require('./src/jobs'),
jobs: require('./src/jobs'),
tools: require('./src/tools'),
tools: require('./src/tools'),
projects: require('./src/projects'),
projects: require('./src/projects'),
+ resources: require('./src/resources'),
vip: require('./src/vip'),
vip: require('./src/vip'),
openre: require('./src/openre'),
openre: require('./src/openre'),
geo: require('./src/geo'),
geo: require('./src/geo'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.33.0",
+ "version": "0.34.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.33.0",
+ "version": "0.34.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -105,6 +105,12 @@
@@ -105,6 +105,12 @@
"import": "./esm/usage.mjs",
"import": "./esm/usage.mjs",
"default": "./src/usage.js"
"default": "./src/usage.js"
},
},
+ "./resources": {
+ "types": "./types/resources.d.ts",
+ "browser": null,
+ "import": "./esm/resources.mjs",
+ "default": "./src/resources.js"
+ },
"./telemetry": {
"./telemetry": {
"types": "./types/telemetry.d.ts",
"types": "./types/telemetry.d.ts",
"browser": null,
"browser": null,
@@ -30,6 +30,7 @@ const ENTRIES = {
@@ -30,6 +30,7 @@ const ENTRIES = {
'govern.mjs': '../src/govern.js',
'govern.mjs': '../src/govern.js',
'placement.mjs': '../src/placement.js',
'placement.mjs': '../src/placement.js',
'usage.mjs': '../src/usage.js',
'usage.mjs': '../src/usage.js',
+ 'resources.mjs': '../src/resources.js',
'telemetry.mjs': '../src/telemetry.js',
'telemetry.mjs': '../src/telemetry.js',
'cost.mjs': '../src/cost.js',
'cost.mjs': '../src/cost.js',
'runtime.mjs': '../src/runtime.js',
'runtime.mjs': '../src/runtime.js',
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.33.0';
+const SDK_VERSION = '0.34.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,383 @@
@@ -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,
+};
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
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));
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']) {
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`);
assert.ok(rel.includes(must), `${must} is scanned`);
@@ -22,7 +22,7 @@ run([
@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
const files = info.files.map((f) => f.path);
- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', '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.includes(must), `tarball contains ${must}`);
}
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
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');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,308 @@
@@ -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);
+ }],
+]);
@@ -14,6 +14,7 @@ export * as ai from './ai';
@@ -14,6 +14,7 @@ export * as ai from './ai';
export * as jobs from './jobs';
export * as jobs from './jobs';
export * as tools from './tools';
export * as tools from './tools';
export * as projects from './projects';
export * as projects from './projects';
+export * as resources from './resources';
export * as vip from './vip';
export * as vip from './vip';
export * as openre from './openre';
export * as openre from './openre';
export * as geo from './geo';
export * as geo from './geo';
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -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;