openvibers/openvibe.watch Commits

Resource index: an owner filter (a person's own watches) (#35)

OP OpenVibers committed 10 Oct 2026 · 4b2df43efc · 2 files, +37 −2

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/resource-index.js b/server/api/resource-index.js
iindex db3b4f9..0fb30ff 100644
---- a/server/api/resource-index.js
++++ b/server/api/resource-index.js
@@@ -1,5 +1,10 @@
'use strict';
--/** Watch's authority resource index (ADR-048): non-deleted watches for OpenVibe.Services. */
++/**
++ * Watch's authority resource index (ADR-048): non-deleted watches for OpenVibe.Services.
++ * GET /api/v1/resources?project=&kind=&owner=&cursor=&limit=. `owner` (usr_…/agt_…) answers only that subject's
++ * watches (openvibe-sdk 0.41 sends it for a person's own resources); a summary names a person owner only, so an agent
++ * subject matches nothing. A malformed filter is a 400, never ignored.
++ */
const express = require('express');
const contracts = require('openvibe-contracts');
@@@ -8,6 +13,7 @@ const KIND = 'watch.watch';
const CAPABILITY = 'watch.resource.read';
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
@@@ -38,6 +44,9 @@ 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 kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ const owner = typeof query.owner === 'string' && query.owner !== '' ? query.owner : null;
++ if (query.owner !== undefined && typeof query.owner !== 'string') return { error: 'owner must be one usr_ or agt_ subject id' };
++ if (owner && !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) {
@@@ -50,7 +59,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 resourceIndexRouter({ db, auth }) {
@@@ -73,9 +82,12 @@ function resourceIndexRouter({ db, auth }) {
const filters = filtersOf(req.query);
if (filters.error) return bad(req, res, filters.error);
if (filters.kind && filters.kind !== KIND) return res.json({ resources: [], next_cursor: null });
++ // A summary names a person owner only (watchSummary): an agent's watches carry none, so nothing matches it.
++ if (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) return res.json({ resources: [], next_cursor: null });
const clauses = ['deleted_at IS NULL'];
const args = [];
if (filters.project) { clauses.push('project_id = ?'); args.push(filters.project); }
++ if (filters.owner) { clauses.push('owner_sub = ?'); args.push(filters.owner); }
if (filters.cursor) { clauses.push('id > ?'); args.push(filters.cursor); }
args.push(filters.limit + 1);
const rows = await db.prepare(`SELECT id, project_id, owner_sub, name, status, created_at, updated_at
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex 6c3b354..d067389 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -98,6 +98,29 @@ t('project and kind filters, and bad project or cursor', async () => {
assert.strictEqual((await api('/api/v1/resources?cursor=bad')).status, 400);
});
++t('owner narrows to one person\'s watches, combines with kind and project, pages once each, and is checked', async () => {
++ const mine = await api(`/api/v1/resources?owner=${owner}`);
++ assert.strictEqual(mine.status, 200, mine.text);
++ assert.ok(contracts.validate('common.resource-list-result@1', mine.body).valid);
++ assert.deepStrictEqual(mine.body.resources.map(r => r.id), [rows[0].id, rows[2].id].sort());
++ assert.ok(mine.body.resources.every(r => r.owner && r.owner.id === owner));
++ assert.deepStrictEqual((await api(`/api/v1/resources?owner=${owner}&kind=watch.watch&project=${projectA}`)).body.resources.map(r => r.id), [rows[0].id]);
++ assert.deepStrictEqual((await api(`/api/v1/resources?owner=${owner}&kind=watch.unknown`)).body.resources, []);
++ assert.deepStrictEqual((await api(`/api/v1/resources?owner=${agent}`)).body.resources, [], 'an agent owner is named by no summary');
++ const seen = [];
++ let cursor = null;
++ do {
++ const page = await api(`/api/v1/resources?owner=${owner}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ seen.push(...page.body.resources.map(r => r.id));
++ cursor = page.body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, [rows[0].id, rows[2].id].sort());
++ for (const badOwner of ['svc:live', 'usr_short', 'agent']) {
++ const r = await api(`/api/v1/resources?owner=${encodeURIComponent(badOwner)}`);
++ assert.deepStrictEqual([r.status, r.body.code], [400, 'resources.bad_query'], badOwner);
++ }
++});
++
t('OVRN reads require a computed name and exclude deleted watches', async () => {
for (const row of [rows[0], rows[1], rows[3]]) {
const name = `ovrn:watch:${row.project_id}:watch/${row.id}`;