openvibers/openvibe.network Commits

Resource index: an owner filter (a person's own projects and nodes) (#133)

OP OpenVibers committed 10 Oct 2026 · d4048b7d21 · 2 files, +50 −13

Plan T13: a person's own resources in OpenVibe.Services. They belong to no project, so Services could only find them by reading this authority's whole index. openvibe-sdk 0.41.0's resource index now sends `?owner=<subject id>`, and this authority answers it.

- `GET /api/v1/resources?owner=usr_…|agt_…` answers only that subject's resources: the rows whose summary names them as `owner`, filtered in the query itself.
- It combines with `kind` (and `project`), and the keyset cursor pages it like any other filter.
- An owner type this table never stores matches nothing.
- A malformed `owner` (`svc:live`, a short id) is `400 resources.bad_query`, never ignored.

Test: the resource-index test checks:
- one person's rows only, each with them as owner;
- combined with kind/project;
- the other subject type returning nothing;
- a limit=1 walk visiting each of that person's rows exactly once;
- malformed values refused.

Changes

ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
iindex b343f17e..b43875e3 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -2,7 +2,7 @@
/**
* Network's authority resource index (ADR-048 section 3; capability network.resource.read):
*
-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
++ * GET /api/v1/resources[?project=&owner=&kind=&cursor=&limit=] → common.resource-list-result@1
* GET /api/v1/resources/:ovrn → common.resource-summary@1
*
* It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and
@@@ -33,6 +33,7 @@ const NODE_KIND = 'network.node';
const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const OWNER_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@@ -87,28 +88,37 @@ function named(summary) {
/** A stable (kind, id) ordering, so a cursor can be a position in it. */
const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
--/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
++/** The summaries matching the project, owner and kind filters, sorted by (kind, id). */
// Scope: network.resource.read is first-party (resourceConstraints none), so its holder sees every project and
// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
// principal's grants here instead of trusting the query.
--async function collect(db, { project = null, kind = null } = {}) {
++async function collect(db, { project = null, owner = null, kind = null } = {}) {
const out = [];
++ if (owner && !USER_SUBJECT_RE.test(owner)) return out;
if (!kind || kind === PROJECT_KIND) {
-- const rows = project
-- ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').all(project)
-- : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();
++ const rows = project && owner
++ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? AND owner_subject = ? ORDER BY id').all(project, owner)
++ : project
++ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ? ORDER BY id').all(project)
++ : owner
++ ? await db.prepare('SELECT * FROM dev_projects WHERE owner_subject = ? ORDER BY id').all(owner)
++ : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();
for (const p of rows) out.push(named(projectSummary(p)));
}
-- if (!kind || kind === APP_KIND) {
++ if (!owner && (!kind || kind === APP_KIND)) {
const rows = project
? await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project)
: await db.prepare('SELECT * FROM dev_apps ORDER BY id').all();
for (const a of rows) out.push(named(appSummary(a)));
}
if (!kind || kind === NODE_KIND) {
-- const rows = project
-- ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)
-- : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();
++ const rows = project && owner
++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? AND owner_subject = ? ORDER BY id').all(project, owner)
++ : project
++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)
++ : owner
++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE owner_subject = ? ORDER BY id').all(owner)
++ : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();
for (const n of rows) out.push(named(nodeSummary(n)));
}
return out.sort(order);
@@@ -127,6 +137,8 @@ const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id
function filtersOf(query) {
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
++ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;
++ if (owner !== null && (typeof owner !== 'string' || !OWNER_ID_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ id' };
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
@@@ -138,7 +150,7 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
if (!cursor) return { error: 'cursor is not one this index issued' };
}
-- return { project, kind, limit, cursor };
++ return { project, owner, kind, limit, cursor };
}
function router({ guard }) {
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex 14a1b9f1..c1d7b5b4 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -2,7 +2,7 @@
// Network's authority resource index (ADR-048 section 3; capability network.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer
// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn
--// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
++// reads one by its computed ovrn. ?project=&owner=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
// boundary, so a resource of another project is never returned. An ovrn is present exactly when
// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project
// is addressed by its bare prj_ id), never for an app (app_ is excluded from the OVRN id pattern), and
@@@ -103,6 +103,31 @@ const resourceIndex = require('../server/registry/resource-index');
assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');
assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
++ assert.deepStrictEqual(await aIds('?owner='), expected.map((r) => r.id), 'an empty owner leaves the index unfiltered');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), [nodU, prjA], 'an owner sees only its project and user-owned node');
++ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [prjB], 'another owner sees only its own rows');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.node`), [nodU], 'owner combines with kind');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.app`), [], 'apps have no summary owner');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}`), [prjA], 'owner combines with project');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'another project is excluded');
++ assert.deepStrictEqual(await aIds(`?owner=agt_${'0'.repeat(26)}`), [], 'a valid agent id has no matching rows');
++
++ const ownerSeen = [];
++ let ownerCursor = null;
++ for (;;) {
++ const y = await get(`/api/v1/resources?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`, auth);
++ assert.strictEqual(y.status, 200);
++ assert.ok(validate('common.resource-list-result@1', y.body).valid);
++ y.body.resources.forEach((s) => {
++ assert.ok(validate('common.resource-summary@1', s).valid, s.id);
++ assert.strictEqual(s.owner.id, usrA);
++ ownerSeen.push(s.id);
++ });
++ if (y.body.next_cursor === null) break;
++ ownerCursor = y.body.next_cursor;
++ if (ownerSeen.length > 2) assert.fail('the owner cursor chain never ended');
++ }
++ assert.deepStrictEqual(ownerSeen, [nodU, prjA], 'owner paging visits each row exactly once in (kind, id) order');
const seen = [];
let cursor = null;
@@@ -141,7 +166,7 @@ const resourceIndex = require('../server/registry/resource-index');
assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
}
-- const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
++ const bad = [['?project=nope', 'project not a prj_ id'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
for (const [q, why] of bad) {
const y = await get(`/api/v1/resources${q}`, auth);
assert.strictEqual(y.status, 400, why);