openvibers/openvibe.sdk Commits

openvibe-sdk 0.41.0: the resource index passes owner (#38)

OP OpenVibers committed 10 Oct 2026 · 10ff5d1400 · 11 files, +52 −14

Plan T13: one place for every resource, including a person's own. Their robots, rooms, streams, watches and Actor tasks belong to no project, so the index could list them only by reading every authority's whole index.

- `createResourceIndex().list({ owner })` passes `owner` (a `usr_…`/`agt_…` subject id) to every authority on every page, beside `project` and `kind`.
- `docs/service.md` says what an authority answers:
  - the three filters together, refusing a malformed value with 400;
  - the console filters the merged page too, so an authority without the filter yet never leaks a row; it only walks longer.
- Types, README, changelog, 0.41.0 (version, `SDK_VERSION`, STATUS, browser bundle).

Next: the nine authorities add the filter and Services adds a "Your resources" page.

Test: `test/resources.test.js` checks that `owner` is on both pages of a two-page walk (with the cursor on the second), and absent when none is named. `ov test` is green.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex dc9da9c..778bc8b 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,6 +3,15 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
++## 0.41.0 (2026-10-10)
++
++- **`createResourceIndex().list({ owner })`.** The index passes `owner` (a `usr_…`/`agt_…` subject id) to every
++ authority on every page, beside `project` and `kind`. An authority answers only what that subject owns. A person's
++ own resources (robots, rooms, streams, watches, Actor tasks: none of them in a project) can then be listed without
++ reading every authority's whole index. A console keeps its own filter on the merged page, so an authority that has
++ not added `owner` yet costs a longer walk but never shows another person's row. `docs/service.md` says what an
++ authority answers.
++
## 0.40.1 (2026-10-10)
- **`verifyUserToken` refuses node principals.** A node token (`actor_type: node`, `sub node:nod_…`: a machine paired
ddiff --git a/README.md b/README.md
iindex 51ac521..dca94e1 100644
---- a/README.md
++++ b/README.md
@@@ -181,7 +181,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/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, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), 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?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) |
| `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` |
ddiff --git a/STATUS.json b/STATUS.json
iindex 8d7f743..f7c6dae 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.40.1",
++ "version": "0.41.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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 666ff4c..202ef55 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.40.1, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.41.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.40.1';
++const SDK_VERSION = '0.41.0';
module.exports = {
createClient,
ddiff --git a/docs/service.md b/docs/service.md
iindex fa0c1ae..003011f 100644
---- a/docs/service.md
++++ b/docs/service.md
@@@ -256,6 +256,7 @@ const { createResourceIndex, createResourceClient, resourceName } = require('ope
// 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://openvibe.events'], token: userAccessToken });
const { resources, stale } = await index.list({ project: prj.id, kind: 'media.object' }); // follows each next_cursor
++const mine = await index.list({ owner: 'usr_…' }); // a person's own resources (no project), filtered by each authority
// 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 });
@@@ -265,6 +266,11 @@ const result = await media.control({ action: 'delete', project_id: prj.id,
if (result.state === 'refused' && result.confirmation_required) retryWith(result.confirmation_required.confirmation_id);
```
++An authority answers `?project=`, `?kind=` and `?owner=` (a `usr_…` or `agt_…` subject: only what that subject owns,
++by the column its summaries take `owner` from) together, and refuses a malformed value with 400 rather than ignoring
++it. A console still filters the merged page itself, so an authority that has not added a filter yet never leaks a
++row; it only costs a longer walk.
++
`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
ddiff --git a/package-lock.json b/package-lock.json
iindex 5375fac..885f1cd 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.40.1",
++ "version": "0.41.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.40.1",
++ "version": "0.41.0",
"license": "MIT",
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
ddiff --git a/package.json b/package.json
iindex abbe76e..877e9d2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.40.1",
++ "version": "0.41.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",
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 5e9a1c9..af01e57 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.40.1';
++const SDK_VERSION = '0.41.0';
module.exports = {
createClient,
ddiff --git a/src/resources.js b/src/resources.js
iindex 5ded00a..42123fd 100644
---- a/src/resources.js
++++ b/src/resources.js
@@@ -141,8 +141,9 @@ async function mapPool(items, limit, fn) {
/**
* 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
++ * GET {authority}/api/v1/resources?project=&kind=&owner=&cursor=&limit= with at most `concurrency` requests in
++ * flight (`owner` is a subject id, usr_… or agt_…: an authority answers only the resources that subject owns, so a
++ * person's own resources, which belong to no project, can be listed without reading everyone's), 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.
@@@ -178,10 +179,11 @@ function createResourceIndex({
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 }) {
++ async function page(authority, { project, kind, owner, cursor, limit }) {
const query = new URLSearchParams();
if (project != null) query.set('project', project);
if (kind != null) query.set('kind', kind);
++ if (owner != null) query.set('owner', owner);
if (cursor != null && cursor !== '') query.set('cursor', cursor);
if (limit != null) query.set('limit', String(limit));
const url = `${authority}${INDEX_PATH}${query.toString() ? `?${query}` : ''}`;
@@@ -232,8 +234,8 @@ function createResourceIndex({
* 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 };
++ async function list({ project, kind, owner, limit = pageLimit } = {}) {
++ const filter = { project, kind, owner, limit };
const perAuthority = await mapPool(bases, concurrency, (authority) => walk(authority, filter));
const resources = [];
const stale = [];
ddiff --git a/test/resources.test.js b/test/resources.test.js
iindex 28e9545..9c3931d 100644
---- a/test/resources.test.js
++++ b/test/resources.test.js
@@@ -60,10 +60,25 @@ run([
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');
++ assert.equal(url.searchParams.has('owner'), false, 'no owner named, none sent');
}
} finally { await a.close(); await b.close(); }
}],
++ ['owner is passed to every authority on every page, so a person\'s own resources need no full walk', async () => {
++ const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++ let n = 0;
++ const a = await stubServer((req, res) => send(res, 200, ++n === 1 ? { ...page([summary(MED)]), next_cursor: 'c2' } : page([summary(MED2)])));
++ try {
++ const index = createResourceIndex({ authorities: [a.url], token: 'svc-token' });
++ const { resources } = await index.list({ owner: OWNER });
++ assert.deepEqual(resources.map((r) => r.id), [MED, MED2]);
++ assert.equal(a.requests.length, 2);
++ for (const r of a.requests) assert.equal(new URL(r.url, a.url).searchParams.get('owner'), OWNER);
++ assert.equal(new URL(a.requests[1].url, a.url).searchParams.get('cursor'), 'c2');
++ } finally { await a.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');
ddiff --git a/types/resources.d.ts b/types/resources.d.ts
iindex 5c4b71d..4b7c65f 100644
---- a/types/resources.d.ts
++++ b/types/resources.d.ts
@@@ -68,7 +68,13 @@ export type ResourceIndexOptions = {
/** 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 type ResourceListOptions = {
++ project?: string;
++ kind?: string;
++ /** A subject id (usr_…/agt_…): only what that subject owns. An authority filters on its own owner column. */
++ owner?: string;
++ limit?: number;
++};
export interface ResourceIndex {
readonly authorities: string[];
/** Every resource of every authority, merged in authority order; `stale` lists those not fully read. */