openvibers/openvibe.media Commits

Resource index: an owner filter (a person's own objects) (#80)

OP OpenVibers committed 10 Oct 2026 · 67cb4984cc · 2 files, +49 −12

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 8a0de34..e39228b 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -2,7 +2,7 @@
/**
* Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.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.Media owns — its media objects (med_<ULID>, media_objects) — as
@@@ -31,6 +31,7 @@ const OBJECT_KIND = 'media.object';
const KINDS = [OBJECT_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_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@@ -78,12 +79,16 @@ const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.i
const SELECT_OBJECT = `SELECT o.*, a.project_id AS project FROM media_objects o
LEFT JOIN apps a ON a.app_id = o.app_id`;
--/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
--async function collect(db, { project = null, kind = null } = {}) {
++/** The summaries matching the filters: `project` scopes tenancy, `owner` scopes subjects, `kind` picks one kind. Sorted by (kind, id). */
++async function collect(db, { project = null, owner = null, kind = null } = {}) {
if (kind && kind !== OBJECT_KIND) return [];
-- const rows = project
-- ? await db.all(`${SELECT_OBJECT} WHERE a.project_id = ? ORDER BY o.id`, [project])
-- : await db.all(`${SELECT_OBJECT} ORDER BY o.id`);
++ if (owner && !USER_SUBJECT_RE.test(owner)) return [];
++ const conditions = [];
++ const params = [];
++ if (project) { conditions.push('a.project_id = ?'); params.push(project); }
++ if (owner) { conditions.push('o.owner_subject = ?'); params.push(owner); }
++ const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
++ const rows = await db.all(`${SELECT_OBJECT}${where} ORDER BY o.id`, params);
return rows.map((o) => named(objectSummary(o))).sort(order);
}
@@@ -100,6 +105,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_SUBJECT_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 !== '') {
@@@ -111,7 +118,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, db = require('../db/database') }) {
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex f90a204..d2c3016 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -2,7 +2,7 @@
// Media's authority resource index (ADR-048 §3, plan T13 step 8; capability media.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Media owns — its media objects
// (med_, media_objects) — and GET /api/v1/resources/:ovrn reads one by its computed ovrn. Media lists
--// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&kind=&cursor=&limit=
++// objects alone; v1 vods/clips/files are projections and are never listed. ?project=&owner=&kind=&cursor=&limit=
// are honoured; ?project= is the tenancy boundary (the tenant's apps.project_id), so a resource of another
// project is never returned. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// composes one: only when the object's tenant is a developer project (a first-party tenant has no project).
@@@ -37,7 +37,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
// ── Fixtures: two developer projects (A production + sandbox, B) and the first-party tenant `live` ──
const prjA = ids.newId('project'); const prjB = ids.newId('project');
-- const usrA = ids.newId('user');
++ const usrA = ids.newId('user'); const usrB = ids.newId('user');
const at = '2026-10-01 00:00:00'; // Media's stored shape; the index renders it RFC 3339
const objA1 = ids.newId('media', 1700000001000); // project A, production, ready, user-owned
const objA2 = ids.newId('media', 1700000002000); // project A, sandbox, uploading
@@@ -51,9 +51,9 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
const INSERT_OBJECT = "INSERT INTO media_objects (id, app_id, namespace, kind, owner_subject, visibility, lifecycle_status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'private', ?, ?, ?)";
const insertObject = (id, app, kind, owner, lifecycle) => db.run(INSERT_OBJECT, [id, app, app, kind, owner, lifecycle, at, at]);
await insertObject(objA1, prjA, 'vod', usrA, 'ready');
-- await insertObject(objA2, `${prjA}-sandbox`, 'vod', null, 'uploading');
++ await insertObject(objA2, `${prjA}-sandbox`, 'vod', usrA, 'uploading');
await insertObject(objA3, prjA, 'clip', null, 'deleted');
-- await insertObject(objB1, prjB, 'file', null, 'ready');
++ await insertObject(objB1, prjB, 'file', usrB, 'ready');
await insertObject(objL1, 'live', 'file', null, 'ready');
await insertObject(objL2, 'live', 'file', usrA, 'ready');
@@@ -89,6 +89,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.strictEqual(byId.get(objA2).project_id, prjA, 'the sandbox tenant of project A is project A');
assert.strictEqual(byId.get(objA1).ovrn, ovrnOf(prjA, objA1), 'a project object is named');
assert.strictEqual(byId.get(objA1).owner.id, usrA, 'a user-owned object names its owner');
++ assert.strictEqual(byId.get(objB1).owner.id, usrB, 'another user-owned object names its own owner');
assert.strictEqual(byId.get(objA1).state, 'ready');
assert.strictEqual(byId.get(objA2).state, 'uploading');
assert.strictEqual(byId.get(objA3).state, 'deleted', 'the state follows lifecycle_status');
@@@ -114,6 +115,35 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual((await aIds(`?project=${prjA}&limit=1000`)).sort(), [objA1, objA2, objA3].sort(), 'a large limit still pages one result');
++ // ── ?owner= scopes the summary owner across tenants and combines with the other filters ──
++ const ownedByA = [objA1, objA2, objL2].sort();
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}`), ownedByA, 'only this subject, including first-party rows');
++ assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [objB1], 'another subject has only its own rows');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=media.object`), ownedByA, 'owner and kind combine');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}&kind=media.object`), [objA1, objA2].sort(), 'owner, project and kind combine');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'owner excludes another project');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA.replace(/^usr_/, 'agt_')}`), [], 'an agent has no user-owned summaries');
++ assert.deepStrictEqual(await aIds('?owner='), idOrder, 'an empty owner leaves the index unfiltered');
++
++ const ownedPages = [];
++ let ownerCursor = null;
++ for (;;) {
++ const q = `?owner=${usrA}&limit=1${ownerCursor ? `&cursor=${encodeURIComponent(ownerCursor)}` : ''}`;
++ const y = await get(`/api/v1/resources${q}`, authHeaders);
++ assert.strictEqual(y.status, 200, q);
++ assert.ok(validate('common.resource-list-result@1', y.body).valid, q);
++ y.body.resources.forEach((s) => {
++ assert.ok(validate('common.resource-summary@1', s).valid, s.id);
++ assert.strictEqual(s.owner.id, usrA);
++ });
++ ownedPages.push(...y.body.resources.map((s) => s.id));
++ if (y.body.next_cursor === null) break;
++ assert.ok(y.body.next_cursor && y.body.next_cursor !== ownerCursor);
++ ownerCursor = y.body.next_cursor;
++ if (ownedPages.length > ownedByA.length) assert.fail('the owner cursor chain never ended');
++ }
++ assert.deepStrictEqual(ownedPages, ownedByA, 'owner paging visits each row exactly once in order');
++
// ── Keyset paging: the whole index in pages of two, no duplicates, none skipped, order preserved ──
const seen = [];
let cursor = null;
@@@ -156,7 +186,7 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOf(prjA, objA1))}`)).status, 401, 'a read without a token is 401 too');
// ── A bad query is 400 resources.bad_query (an unknown kind stays an empty page) ──
-- 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 is not a subject id'], ['?owner=usr_short', 'owner is too short'], [`?owner=${usrA}&owner=${usrB}`, 'multiple owners are not a subject 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']];
for (const [q, why] of bad) {
const y = await get(`/api/v1/resources${q}`, authHeaders);
assert.strictEqual(y.status, 400, why);