openvibers/openvibe.actor Commits

Resource index: an owner filter (a person's own tasks) (#26)

OP OpenVibers committed 10 Oct 2026 · 08d8d2b7ab · 2 files, +30 −1

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 ab6e461..e697be7 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -3,6 +3,9 @@
/**
* Actor's authority resource index (ADR-048, plan T13 step 8 phase 2). OpenVibe.Services reads
* actor.task summaries here with a first-party service token carrying actor.resource.read.
++ * GET /?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's tasks, the ones whose
++ * summary names them (requester user:usr_…; a task an agent or a service asked for names no owner, so an agt_ owner
++ * matches nothing). A malformed filter is a 400.
*/
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
@@@ -11,6 +14,7 @@ const SERVICE = 'actor';
const TASK_KIND = 'actor.task';
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_REQUESTER_RE = /^user:(usr_[0-9A-HJKMNP-TV-Z]{26})$/;
++const SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@@ -45,6 +49,8 @@ function filtersOf(query) {
const project = query.project === undefined ? null : query.project;
if (project !== null && (typeof project !== 'string' || !PROJECT_ID_RE.test(project))) return { error: 'project must be a prj_ id' };
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ const owner = query.owner === undefined || query.owner === '' ? null : query.owner;
++ if (owner !== null && (typeof owner !== 'string' || !SUBJECT_RE.test(owner))) return { error: 'owner must be a usr_ or agt_ subject id' };
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
@@@ -55,7 +61,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 };
}
const COLUMNS = 'id, requester, project_id, task, state, created_at';
@@@ -77,10 +83,12 @@ function router({ s, principal }) {
const f = filtersOf(req.query);
if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
if (f.kind && f.kind !== TASK_KIND) return res.json({ resources: [], next_cursor: null });
++ if (f.owner && !f.owner.startsWith('usr_')) return res.json({ resources: [], next_cursor: null });
const where = [];
const args = [];
if (f.project) { args.push(f.project); where.push(`project_id = $${args.length}`); }
++ if (f.owner) { args.push(`user:${f.owner}`); where.push(`requester = $${args.length}`); }
if (f.cursor) { args.push(f.cursor[1]); where.push(`id > $${args.length}`); }
args.push(f.limit + 1);
const sql = `SELECT ${COLUMNS} FROM tasks ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id ASC LIMIT $${args.length}`;
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex 9ceba2c..f71927e 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -113,6 +113,27 @@ const { boot, check, done } = require('./helpers/boot');
}
});
++ await check('owner narrows to one person\'s tasks, combines with project and kind, pages once each, and is checked', async () => {
++ const mine = await page(`?owner=${user.subject}`);
++ assert.deepStrictEqual(mine.resources.map((x) => x.id), [a, c].sort());
++ assert.ok(mine.resources.every((x) => x.owner && x.owner.id === user.subject));
++ assert.deepStrictEqual((await page(`?owner=${user.subject}&project=${projectA}&kind=actor.task`)).resources.map((x) => x.id), [a]);
++ assert.deepStrictEqual((await page(`?owner=${user.subject}&kind=actor.other`)).resources, []);
++ assert.deepStrictEqual((await page('?owner=agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C')).resources, [], 'an agent\'s task names no owner');
++ const seen = [];
++ let cursor = null;
++ do {
++ const body = await page(`?owner=${user.subject}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ seen.push(...body.resources.map((x) => x.id));
++ cursor = body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, [a, c].sort());
++ for (const bad of ['svc:live', 'usr_short', 'user:usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C']) {
++ const response = await t.get(`/api/v1/resources?owner=${encodeURIComponent(bad)}`, auth);
++ assert.deepStrictEqual([response.status, response.json().code], [400, 'resources.bad_query'], bad);
++ }
++ });
++
await check('one OVRN resolves only a present task in its actual project', async () => {
const name = `ovrn:actor:${projectA}:task/${a}`;
const one = await t.get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);