openvibers/openrestream Commits

Resource index: an owner filter (a person's own streams) (#49)

OP OpenVibers committed 10 Oct 2026 · 469a7f18ef · 2 files, +51 −5

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 1c84200..1d190dd 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -1,5 +1,6 @@
'use strict';
--/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned. */
++/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned.
++ * GET /api/v1/resources accepts project, kind, owner, cursor, and limit query parameters. */
const express = require('express');
const contracts = require('openvibe-contracts');
@@@ -7,6 +8,7 @@ const SERVICE = 'openre';
const KIND = 'openre.stream';
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;
@@@ -36,6 +38,8 @@ function decodeCursor(raw) {
function filtersOf(query) {
const project = query.project === undefined || query.project === '' ? null : query.project;
if (project !== null && (typeof project !== 'string' || !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 !== '') {
@@@ -47,7 +51,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({ db, guard }) {
@@@ -67,7 +71,8 @@ function router({ db, guard }) {
const f = filtersOf(req.query);
if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
-- const rows = await db.prepare("SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ? ORDER BY id LIMIT ?").all(f.cursor || '', f.limit + 1);
++ const ownerClause = f.owner ? ' AND owner_subject = ?' : '';
++ const rows = await db.prepare(`SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ?${ownerClause} ORDER BY id LIMIT ?`).all(f.cursor || '', ...(f.owner ? [f.owner] : []), f.limit + 1);
const resources = rows.slice(0, f.limit).map(streamSummary);
const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 1]) : null;
return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex b25cb05..76b76fc 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -9,6 +9,7 @@ const t = suite('resource-index');
const reader = serviceToken('services', ['openre.resource.read']);
let api;
let ids;
++let ownerIds;
const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });
@@@ -22,15 +23,20 @@ t('summary and query functions keep the contract shape without a socket', () =>
assert.ok(contracts.validate('common.resource-summary@1', summary).valid);
assert.strictEqual(contracts.resources.nameOf(summary), null);
assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');
-- assert.deepStrictEqual(filtersOf({}), { project: null, kind: null, limit: 100, cursor: null });
++ assert.strictEqual(filtersOf({ owner: OWNER }).owner, OWNER);
++ assert.strictEqual(filtersOf({ owner: 'agt_01J0000000000000000000000A' }).owner, 'agt_01J0000000000000000000000A');
++ assert.strictEqual(filtersOf({ owner: '' }).owner, null);
++ assert.deepStrictEqual(filtersOf({}), { project: null, owner: null, kind: null, limit: 100, cursor: null });
});
t('boot and list schema, ordered rows, and safe summary fields', async () => {
api = await bootApi();
const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;
const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;
++ const c = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Third stream' })).definition;
const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;
-- ids = [a.id, b.id].sort();
++ ids = [a.id, b.id, c.id].sort();
++ ownerIds = [a.id, c.id].sort();
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);
const page = await get('');
@@@ -69,6 +75,41 @@ t('cursor visits each row exactly once and filters follow person ownership', asy
assert.strictEqual((await get('?limit=1001')).status, 400);
});
++t('owner filters exact subjects across kinds and cursor pages', async () => {
++ const page = await get(`?owner=${OWNER}`);
++ assert.strictEqual(page.status, 200, page.text);
++ const validation = contracts.validate('common.resource-list-result@1', page.body);
++ assert.ok(validation.valid, JSON.stringify(validation.errors));
++ assert.deepStrictEqual(page.body.resources.map((s) => s.id), ownerIds);
++ assert.ok(page.body.resources.every((s) => s.owner.id === OWNER));
++ assert.strictEqual(page.body.next_cursor, null);
++
++ const withKind = await get(`?owner=${OWNER}&kind=openre.stream`);
++ assert.deepStrictEqual(withKind.body.resources.map((s) => s.id), ownerIds);
++ assert.deepStrictEqual((await get(`?owner=${OWNER}&kind=unknown.kind`)).body, { resources: [], next_cursor: null });
++ assert.deepStrictEqual((await get(`?owner=${OWNER}&project=prj_01J0000000000000000000000A`)).body, { resources: [], next_cursor: null });
++ assert.deepStrictEqual((await get('?owner=agt_01J0000000000000000000000A')).body, { resources: [], next_cursor: null });
++
++ const malformedProject = await get('?project=bad');
++ for (const owner of ['svc:live', 'usr_short']) {
++ const malformedOwner = await get(`?owner=${encodeURIComponent(owner)}`);
++ assert.strictEqual(malformedOwner.status, malformedProject.status);
++ assert.strictEqual(malformedOwner.body.code, malformedProject.body.code);
++ }
++
++ const visited = [];
++ let cursor = null;
++ do {
++ const next = await get(`?owner=${OWNER}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ assert.strictEqual(next.status, 200, next.text);
++ const result = contracts.validate('common.resource-list-result@1', next.body);
++ assert.ok(result.valid, JSON.stringify(result.errors));
++ visited.push(...next.body.resources.map((s) => s.id));
++ cursor = next.body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(visited, ownerIds);
++});
++
// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.
t('only a service token with the capability reaches either route', async () => {
for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {