openvibers/openvibe.events Commits

Resource index: an owner filter (a person's own subscriptions) (#54)

OP OpenVibers committed 10 Oct 2026 · 3f1e7a525d · 3 files, +67 −16

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/api/resources.js b/server/api/resources.js
iindex 8f6bad8..612c2b2 100644
---- a/server/api/resources.js
++++ b/server/api/resources.js
@@@ -2,7 +2,7 @@
/**
* The authority resource index (ADR-048 section 3; capability events.resource.read):
*
-- * GET /api/v1/resources[?project=&kind=&cursor=&limit=] -> common.resource-list-result@1
++ * GET /api/v1/resources[?project=&kind=&owner=&cursor=&limit=] -> common.resource-list-result@1
* GET /api/v1/resources/:ovrn -> common.resource-summary@1
*
* It pages the resources OpenVibe.Events owns — today its subscriptions (sub_), the kind
@@@ -15,7 +15,8 @@
* answer — the project-less ones are not its rows and are never mixed in. Without it the first-party caller
* (the capability is first-party, resourceConstraints none) sees every subscription, which is what an
* authority-wide fan-out needs; a subscription without a project (a service consumer's, first-party) is
-- * exactly the row only that caller sees.
++ * exactly the row only that caller sees. `?owner=usr_…` narrows the result to subscriptions whose consumer
++ * is that raw user subject; this index does not emit agent owners.
*
* OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
* formatter, so it is present exactly when the subscription is a nameable resource — a project-scoped one is
@@@ -34,6 +35,7 @@ const SERVICE = 'events';
const SUBSCRIPTION_KIND = 'events.subscription';
const KINDS = [SUBSCRIPTION_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUBJECT_ID_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@@ -73,16 +75,17 @@ function named(summary) {
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 filters: `project` scopes tenancy, `kind` picks one kind, `owner` picks a
++ * person whose subject is stored as the subscription consumer. Sorted by (kind, id).
*
* Scope: events.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(store, { project = null, kind = null } = {}) {
++async function collect(store, { project = null, kind = null, owner = null } = {}) {
const out = [];
-- if (!kind || kind === SUBSCRIPTION_KIND) {
-- const rows = project ? await store.listProjectSubscriptions(project) : await store.listSubscriptions();
++ if ((!kind || kind === SUBSCRIPTION_KIND) && (!owner || USER_SUBJECT_RE.test(owner))) {
++ const rows = project ? await store.listProjectSubscriptions(project, owner) : await store.listSubscriptions(owner);
for (const s of rows) out.push(named(subscriptionSummary(s)));
}
return out.sort(order);
@@@ -101,6 +104,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' || !SUBJECT_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 !== '') {
@@@ -112,7 +117,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, kind, owner, limit, cursor };
}
function resourcesRouter({ store, auth }) {
ddiff --git a/server/store.js b/server/store.js
iindex 9a14128..30a8bb8 100644
---- a/server/store.js
++++ b/server/store.js
@@@ -499,9 +499,12 @@ function createStore(db, { clock = { now: () => Date.now() }, maxHops = 8, usage
/**
* Every subscription of one project, for the authority resource index (server/api/resources.js): `?project=prj_…`
* is its tenancy boundary, so a row of another project — or a project-less first-party one — is never returned.
++ * The optional consumer is the raw user subject used as the resource summary's owner id.
*/
-- async function listProjectSubscriptions(projectId) {
-- return await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);
++ async function listProjectSubscriptions(projectId, consumer = null) {
++ return consumer
++ ? await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? AND consumer = ? ORDER BY id').all(projectId, consumer)
++ : await db.prepare('SELECT * FROM subscriptions WHERE project_id = ? ORDER BY id').all(projectId);
}
/** Rotate a subscription's secret: the old one keeps signing (next to the new) until overlapMs from now. */
async function rotateSubscriptionSecret(id, secret, overlapMs) {
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex 1dc418c..be99488 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -2,7 +2,7 @@
// Events' authority resource index (ADR-048 section 3; capability events.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Events owns - its subscriptions
// (sub_, kind events.subscription) - and GET /api/v1/resources/:ovrn reads one by its computed ovrn.
--// ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of
++// ?project=&kind=&owner=&cursor=&limit= are honoured; ?project= is the tenancy boundary, so a subscription of
// another project - or a project-less one - is never returned under it, while the unscoped first-party
// caller sees everything. An ovrn is present exactly when openvibe-contracts' contracts.resources.nameOf
// composes one: a project-scoped subscription is ovrn:events:<prj_…>:subscription/sub_…, a project-less
@@@ -24,14 +24,18 @@ const sandbox = appToken({ env: 'sandbox', cap: ['events.resource.read'] });
const prjA = `prj_${ids.ulid()}`;
const prjB = `prj_${ids.ulid()}`;
const usrA = ids.newId('user');
++const usrB = ids.newId('user');
const appA = ids.newId('app');
const subSvc = ids.newId('subscription'); // first-party: a service consumer, no project (no name)
const subUsr = ids.newId('subscription'); // a person's: the consumer is a usr_ subject, no project
++const subUsr2 = ids.newId('subscription'); // another subscription for the same person
++const subOther = ids.newId('subscription'); // another person's subscription
const subA1 = ids.newId('subscription'); // project A
const subA2 = ids.newId('subscription'); // project A, later disabled
++const subUsrProject = ids.newId('subscription'); // project A, person-owned
const subB = ids.newId('subscription'); // project B
// Sorted by (kind, id): one kind, so by id (ULID randomness, not creation order).
--const expected = [subSvc, subUsr, subA1, subA2, subB].sort();
++const expected = [subSvc, subUsr, subUsr2, subOther, subA1, subA2, subUsrProject, subB].sort();
const ovrnOfSub = (project, id) => `ovrn:events:${project}:subscription/${id}`;
const get = (p, token) => request(h.base, 'GET', p, { token });
@@@ -45,8 +49,11 @@ t('boot + fixtures: first-party, person-owned and two projects\' subscriptions',
});
await add(subSvc, 'svc:live', 'live.*');
await add(subUsr, usrA, 'live.vod.transcoded');
++ await add(subUsr2, usrA, 'live.stream.started');
++ await add(subOther, usrB, 'media.*');
await add(subA1, `app:${appA}`, 'live.vod.*', prjA);
await add(subA2, `app:${appA}`, 'media.*', prjA);
++ await add(subUsrProject, usrA, 'games.*', prjA);
await add(subB, `app:${ids.newId('app')}`, 'games.*', prjB);
await h.store.setSubscriptionEnabled(subA2, false);
});
@@@ -105,8 +112,8 @@ t('each summary maps its own row: project_id, name, state, owner, and an ovrn on
assert.strictEqual(byId.get(subA1).ovrn, ovrnOfSub(prjA, subA1));
assert.strictEqual(byId.get(subB).ovrn, ovrnOfSub(prjB, subB));
assert.deepStrictEqual(resources.parse(byId.get(subA1).ovrn), { service: 'events', project_id: prjA, type: 'subscription', id: subA1 });
-- [subSvc, subUsr].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
-- [subSvc, subUsr].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));
++ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
++ [subSvc, subUsr, subUsr2, subOther].forEach((id) => assert.ok(!('project_id' in byId.get(id)), `${id}: a first-party subscription is not a project's`));
assert.deepStrictEqual(byId.get(subUsr).owner, { type: 'user', id: usrA }, 'a person-owned subscription names its owner');
assert.ok(!('owner' in byId.get(subSvc)), 'a service consumer is not an owner');
});
@@@ -118,9 +125,9 @@ t('?project= is the tenancy boundary: only that project’s rows, never another
assert.ok(validate('common.resource-list-result@1', y.body).valid);
return y.body.resources.map((r) => r.id);
};
-- assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2].sort(), 'project A: never project B, never the project-less ones');
++ assert.deepStrictEqual(await ids1(`?project=${prjA}`), [subA1, subA2, subUsrProject].sort(), 'project A: never project B, never the project-less ones');
assert.deepStrictEqual(await ids1(`?project=${prjB}`), [subB], 'project B is never mixed in');
-- assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2].sort(), 'kind narrows within the project');
++ assert.deepStrictEqual(await ids1(`?project=${prjA}&kind=events.subscription`), [subA1, subA2, subUsrProject].sort(), 'kind narrows within the project');
assert.deepStrictEqual(await ids1(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
});
@@@ -132,6 +139,42 @@ t('?kind= picks a kind; an unknown one (events.queue, events.unknown) is an empt
assert.deepStrictEqual(await list('events.unknown'), []);
});
++t('?owner= selects one person, combines with kind and project, and pages that person once', async () => {
++ const owned = [subUsr, subUsr2, subUsrProject].sort();
++ const page = async (query) => {
++ const y = await get(`/api/v1/resources?${query}`, reader);
++ assert.strictEqual(y.status, 200, y.text);
++ const check = validate('common.resource-list-result@1', y.body);
++ assert.ok(check.valid, JSON.stringify(check.errors));
++ return y.body;
++ };
++ assert.deepStrictEqual((await page(`owner=${usrA}`)).resources.map((r) => r.id), owned);
++ assert.deepStrictEqual((await page(`owner=${usrB}`)).resources.map((r) => r.id), [subOther]);
++ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.subscription`)).resources.map((r) => r.id), owned);
++ assert.deepStrictEqual((await page(`owner=${usrA}&kind=events.queue`)).resources, []);
++ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}`)).resources.map((r) => r.id), [subUsrProject]);
++ assert.deepStrictEqual((await page(`owner=${usrA}&project=${prjA}&kind=events.subscription`)).resources.map((r) => r.id), [subUsrProject]);
++ assert.deepStrictEqual((await page(`owner=${usrB}&project=${prjA}`)).resources, []);
++ assert.deepStrictEqual((await page(`owner=agt_${ids.ulid()}`)).resources, [], 'this index emits only user owners');
++ assert.deepStrictEqual((await page('owner=')).resources.map((r) => r.id), expected, 'empty owner does not filter');
++
++ const seen = [];
++ let cursor = null;
++ for (let pages = 0; ; pages++) {
++ const query = `owner=${usrA}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`;
++ const y = await page(query);
++ assert.ok(y.resources.length <= 1);
++ for (const r of y.resources) {
++ assert.deepStrictEqual(r.owner, { type: 'user', id: usrA });
++ seen.push(r.id);
++ }
++ if (y.next_cursor === null) break;
++ cursor = y.next_cursor;
++ assert.ok(pages < owned.length, 'the owner cursor chain never ended');
++ }
++ assert.deepStrictEqual(seen, owned, 'every owned resource appears exactly once in order');
++});
++
t('the cursor pages the whole index once, in order, with no duplicates', async () => {
const seen = [];
let cursor = null;
@@@ -181,7 +224,7 @@ t('GET /:ovrn reads the same summary the list answers, and only for a name it co
});
t('a bad query is 400 resources.bad_query', async () => {
-- const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project 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']];
++ const bad = [['?project=nope', 'project not a prj_ id'], ['?project=prj_short', 'project too short'], ['?owner=svc:live', 'owner not a subject id'], ['?owner=usr_short', 'owner too short'], ['?owner[]=x', 'owner not a scalar'], ['?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}`, reader);
assert.strictEqual(y.status, 400, `${why}: ${y.text}`);