openvibers/openvibe.chat Commits

Resource index: an owner filter (a person's own rooms) (#63)

OP OpenVibers committed 10 Oct 2026 · 99dec56865 · 2 files, +45 −7

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 1dde656..bc10340 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -1,6 +1,7 @@
/**
* Chat's authority resource index (ADR-048, plan T13 step 8). Rooms are person-owned,
-- * so their summaries have no project or OVRN. This router is mounted on the loopback API.
++ * so their summaries have no project or OVRN. GET /api/v1/resources accepts project,
++ * kind, owner, cursor, and limit query parameters. This router is mounted on the loopback API.
*/
'use strict';
@@@ -14,6 +15,7 @@ const ROOM_KIND = 'chat.room';
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
/** The room fields allowed by common.resource-summary@1. */
@@@ -45,6 +47,8 @@ function decodeCursor(raw) {
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' || !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 !== '') {
@@@ -56,19 +60,20 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
if (cursor === null) return { error: 'cursor is not one this index issued' };
}
-- return { project, kind, limit, cursor };
++ return { project, owner, kind, limit, cursor };
}
async function page(req, res, next) {
try {
const filters = filtersOf(req.query);
if (filters.error) return contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail: filters.error });
-- if (filters.project || (filters.kind && filters.kind !== ROOM_KIND)) {
++ if (filters.project || (filters.owner && !USER_SUBJECT_RE.test(filters.owner)) || (filters.kind && filters.kind !== ROOM_KIND)) {
return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
}
++ const ownerClause = filters.owner ? ' AND owner_subject = ?' : '';
++ const params = [filters.cursor || '', ...(filters.owner ? [filters.owner] : []), filters.limit + 1];
const rows = await db.all(`SELECT slug, name, visibility, owner_subject, created_at FROM rooms
-- WHERE archived_at IS NULL AND slug > ? COLLATE "C" ORDER BY slug COLLATE "C" LIMIT ?`,
-- [filters.cursor || '', filters.limit + 1]);
++ WHERE archived_at IS NULL AND slug > ? COLLATE "C"${ownerClause} ORDER BY slug COLLATE "C" LIMIT ?`, params);
const resources = rows.slice(0, filters.limit).map(roomSummary);
const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 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 334ea77..760ed0e 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -5,13 +5,14 @@ const contracts = require('openvibe-contracts');
const { boot, suite } = require('./helpers');
const t = suite('resource-index');
--let h, token, user;
++let h, token, user, otherUser;
const get = (path, opts = {}) => h.http('GET', `/api/v1/resources${path}`, { token, ...opts });
t('boot and seed rooms', async () => {
h = await boot();
token = h.serviceToken(['chat.resource.read']);
user = h.addUser('index-owner', { subject: contracts.ids.newId('user') });
++ otherUser = h.addUser('other-index-owner', { subject: contracts.ids.newId('user') });
for (const [slug, visibility, archived] of [
['index-alpha', 'public', false], ['index-beta', 'private', false],
['index-gamma', 'public', false], ['index-archived', 'public', true],
@@@ -19,6 +20,10 @@ t('boot and seed rooms', async () => {
await h.db.run('INSERT INTO rooms (slug, name, visibility, owner_id, owner_subject, archived_at) VALUES (?, ?, ?, ?, ?, ?)',
[slug, `Name ${slug}`, visibility, user.id, user.subject_id, archived ? '2026-10-01 00:00:00' : null]);
}
++ for (const slug of ['second-alpha', 'second-beta']) {
++ await h.db.run('INSERT INTO rooms (slug, name, visibility, owner_id, owner_subject) VALUES (?, ?, ?, ?, ?)',
++ [slug, `Name ${slug}`, 'public', otherUser.id, otherUser.subject_id]);
++ }
});
t('page validates and contains only allowed fields', async () => {
@@@ -52,12 +57,40 @@ t('limit-one cursor walks each active room exactly once', async () => {
assert.strictEqual(new Set(seen).size, seen.length);
});
++t('owner filters exact subjects and combines with kind and cursor', async () => {
++ const owner = encodeURIComponent(user.subject_id);
++ const response = await get(`/?owner=${owner}`);
++ assert.strictEqual(response.status, 200, response.text);
++ const result = contracts.validate('common.resource-list-result@1', response.body);
++ assert.ok(result.valid, JSON.stringify(result.errors));
++ assert.deepStrictEqual(response.body.resources.map((r) => r.id), ['index-alpha', 'index-beta', 'index-gamma']);
++ assert.ok(response.body.resources.every((r) => r.owner.id === user.subject_id));
++ assert.deepStrictEqual((await get(`/?owner=${owner}&kind=chat.room`)).body.resources, response.body.resources);
++ assert.deepStrictEqual((await get(`/?owner=${owner}&kind=other.kind`)).body.resources, []);
++ assert.deepStrictEqual((await get(`/?owner=${owner}&project=prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3`)).body.resources, []);
++ assert.deepStrictEqual((await get(`/?owner=${encodeURIComponent(otherUser.subject_id)}`)).body.resources.map((r) => r.id), ['second-alpha', 'second-beta']);
++ assert.deepStrictEqual((await get('/?owner=agt_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3')).body.resources, []);
++ assert.deepStrictEqual((await get('/?owner=')).body.resources, (await get('/')).body.resources);
++
++ const seen = [];
++ let cursor = null;
++ do {
++ const page = await get(`/?owner=${owner}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ assert.strictEqual(page.status, 200, page.text);
++ assert.ok(contracts.validate('common.resource-list-result@1', page.body).valid);
++ seen.push(...page.body.resources.map((r) => r.id));
++ cursor = page.body.next_cursor;
++ } while (cursor);
++ assert.deepStrictEqual(seen, response.body.resources.map((r) => r.id));
++ assert.strictEqual(new Set(seen).size, seen.length);
++});
++
t('project and kind filters, and bad queries', async () => {
const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
assert.deepStrictEqual((await get(`/?project=${project}`)).body.resources, []);
assert.deepStrictEqual((await get('/?kind=other.kind')).body.resources, []);
assert.deepStrictEqual((await get('/?kind=chat.room')).body.resources, (await get('/')).body.resources);
-- for (const query of ['project=bad', 'limit=0', 'limit=1001', 'cursor=bad']) {
++ for (const query of ['project=bad', 'owner=svc:live', 'owner=usr_short', 'owner=usr_short&owner=usr_short', 'limit=0', 'limit=1001', 'cursor=bad']) {
const response = await get(`/?${query}`);
assert.strictEqual(response.status, 400, response.text);
assert.strictEqual(response.body.code, 'resources.bad_query');