openvibers/openvibe.chat Commits
Resource index: an owner filter (a person's own rooms) (#63)
Changes
@@@ -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 });@@@ -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');
@@ -1,6 +1,7 @@
@@ -1,6 +1,7 @@
/**
/**
* Chat's authority resource index (ADR-048, plan T13 step 8). Rooms are person-owned,
* 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';
'use strict';
@@ -14,6 +15,7 @@ const ROOM_KIND = 'chat.room';
@@ -14,6 +15,7 @@ const ROOM_KIND = 'chat.room';
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
/** The room fields allowed by common.resource-summary@1. */
/** The room fields allowed by common.resource-summary@1. */
@@ -45,6 +47,8 @@ function decodeCursor(raw) {
@@ -45,6 +47,8 @@ function decodeCursor(raw) {
function filtersOf(query) {
function filtersOf(query) {
const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
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' };
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;
const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
let limit = DEFAULT_LIMIT;
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
if (typeof query.limit === 'string' && query.limit !== '') {
@@ -56,19 +60,20 @@ function filtersOf(query) {
@@ -56,19 +60,20 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
cursor = decodeCursor(query.cursor);
if (cursor === null) return { error: 'cursor is not one this index issued' };
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) {
async function page(req, res, next) {
try {
try {
const filters = filtersOf(req.query);
const filters = filtersOf(req.query);
if (filters.error) return contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail: filters.error });
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 });
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
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 resources = rows.slice(0, filters.limit).map(roomSummary);
const next_cursor = rows.length > filters.limit ? encodeCursor(resources[resources.length - 1]) : null;
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 });
return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });
@@ -5,13 +5,14 @@ const contracts = require('openvibe-contracts');
@@ -5,13 +5,14 @@ const contracts = require('openvibe-contracts');
const { boot, suite } = require('./helpers');
const { boot, suite } = require('./helpers');
const t = suite('resource-index');
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 });
const get = (path, opts = {}) => h.http('GET', `/api/v1/resources${path}`, { token, ...opts });
t('boot and seed rooms', async () => {
t('boot and seed rooms', async () => {
h = await boot();
h = await boot();
token = h.serviceToken(['chat.resource.read']);
token = h.serviceToken(['chat.resource.read']);
user = h.addUser('index-owner', { subject: contracts.ids.newId('user') });
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 [
for (const [slug, visibility, archived] of [
['index-alpha', 'public', false], ['index-beta', 'private', false],
['index-alpha', 'public', false], ['index-beta', 'private', false],
['index-gamma', 'public', false], ['index-archived', 'public', true],
['index-gamma', 'public', false], ['index-archived', 'public', true],
@@ -19,6 +20,10 @@ t('boot and seed rooms', async () => {
@@ -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 (?, ?, ?, ?, ?, ?)',
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]);
[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 () => {
t('page validates and contains only allowed fields', async () => {
@@ -52,12 +57,40 @@ t('limit-one cursor walks each active room exactly once', async () => {
@@ -52,12 +57,40 @@ t('limit-one cursor walks each active room exactly once', async () => {
assert.strictEqual(new Set(seen).size, seen.length);
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 () => {
t('project and kind filters, and bad queries', async () => {
const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
assert.deepStrictEqual((await get(`/?project=${project}`)).body.resources, []);
assert.deepStrictEqual((await get(`/?project=${project}`)).body.resources, []);
assert.deepStrictEqual((await get('/?kind=other.kind')).body.resources, []);
assert.deepStrictEqual((await get('/?kind=other.kind')).body.resources, []);
assert.deepStrictEqual((await get('/?kind=chat.room')).body.resources, (await get('/')).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}`);
const response = await get(`/?${query}`);
assert.strictEqual(response.status, 400, response.text);
assert.strictEqual(response.status, 400, response.text);
assert.strictEqual(response.body.code, 'resources.bad_query');
assert.strictEqual(response.body.code, 'resources.bad_query');