openvibers/openvibe.bot Commits

Resource index: an owner filter (a person's own robots) (#72)

OP OpenVibers committed 10 Oct 2026 · 584be380f5 · 2 files, +33 −3

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 3f8fb8b..b589544 100644
---- a/server/api/resource-index.js
++++ b/server/api/resource-index.js
@@@ -1,6 +1,11 @@
'use strict';
--/** Bot's authority resource index for OpenVibe.Services (ADR-048). Robots are person-owned. */
++/**
++ * Bot's authority resource index for OpenVibe.Services (ADR-048). Robots are person-owned.
++ * GET /api/v1/resources?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's
++ * robots (openvibe-sdk 0.41 sends it for a person's own resources; a robot's owner is a person, so an agt_ owner
++ * matches nothing). A robot is in no project, so a project filter matches nothing. A malformed filter is a 400.
++ */
const express = require('express');
const contracts = require('openvibe-contracts');
const { fail } = require('../util');
@@@ -10,6 +15,7 @@ const KIND = 'bot.robot';
const CAPABILITY = 'bot.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,8 @@ 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 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 (query.limit !== undefined && query.limit !== '') {
if (typeof query.limit !== 'string' || !/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
@@@ -48,7 +56,7 @@ function filtersOf(query) {
cursor = typeof query.cursor === 'string' ? decodeCursor(query.cursor) : null;
if (!cursor) return { error: 'cursor is not one this index issued' };
}
-- return { project, kind, limit, cursor };
++ return { project, kind, owner, limit, cursor };
}
function router({ db, apiAuth }) {
@@@ -69,8 +77,10 @@ function router({ db, apiAuth }) {
const filters = filtersOf(req.query);
if (filters.error) return bad(res, filters.error, req.ov);
if (filters.project || (filters.kind && filters.kind !== KIND)) return res.json({ resources: [], next_cursor: null });
++ if (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) return res.json({ resources: [], next_cursor: null });
const rows = await db.many(`SELECT id, owner_subject, name, estop_latched, created_at, updated_at
-- FROM robots WHERE ($1::text IS NULL OR id > $1) ORDER BY id LIMIT $2`, [filters.cursor, filters.limit + 1]);
++ FROM robots WHERE ($1::text IS NULL OR id > $1) AND ($3::text IS NULL OR owner_subject = $3) ORDER BY id LIMIT $2`,
++ [filters.cursor, filters.limit + 1, filters.owner]);
const resources = rows.slice(0, filters.limit).map(robotSummary);
const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1].id) : null;
return res.json({ resources, next_cursor });
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex accd768..5938232 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -60,6 +60,26 @@ const { boot, check, done } = require('./helpers/app');
}
});
++ await check('owner narrows to one person\'s robots, combines with kind, pages once each, and is checked', async () => {
++ assert.deepStrictEqual((await get(`?owner=${alice.subject}`)).json.resources.map((r) => r.id), [first.id, third.id].sort());
++ assert.deepStrictEqual((await get(`?owner=${bob.subject}&kind=bot.robot`)).json.resources.map((r) => r.id), [second.id]);
++ assert.deepStrictEqual((await get(`?owner=${alice.subject}&kind=bot.unknown`)).json.resources, []);
++ assert.deepStrictEqual((await get('?owner=agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C')).json.resources, [], 'a robot\'s owner is a person');
++ const seen = [];
++ let cursor = null;
++ do {
++ const page = (await get(`?owner=${alice.subject}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`)).json;
++ assert.ok(contracts.validate('common.resource-list-result@1', page).valid);
++ seen.push(...page.resources.map((r) => r.id));
++ cursor = page.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, [first.id, third.id].sort());
++ for (const bad of ['svc:live', 'usr_short']) {
++ const response = await get(`?owner=${encodeURIComponent(bad)}`);
++ assert.deepStrictEqual([response.status, response.json.code], [400, 'resources.bad_query'], bad);
++ }
++ });
++
await check('a service token with bot.resource.read is required on both routes', async () => {
for (const path of ['', '/ovrn%3Abot%3Aprj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C%3Arobot%2F' + first.id]) {
const anonymous = await get(path, { token: null });