openvibers/openrestream Commits
Resource index: an owner filter (a person's own streams) (#49)
Changes
@@@ -1,5 +1,6 @@'use strict';--/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned. */++/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned.++ * GET /api/v1/resources accepts project, kind, owner, cursor, and limit query parameters. */const express = require('express');const contracts = require('openvibe-contracts');@@@ -7,6 +8,7 @@ const SERVICE = 'openre';const KIND = 'openre.stream';const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_LIMIT = 100;const MAX_LIMIT = 1000;@@@ -36,6 +38,8 @@ function decodeCursor(raw) {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 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 !== '') {@@@ -47,7 +51,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, owner, kind, limit, cursor };}function router({ db, guard }) {@@@ -67,7 +71,8 @@ function router({ db, guard }) {const f = filtersOf(req.query);if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });-- const rows = await db.prepare("SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ? ORDER BY id LIMIT ?").all(f.cursor || '', f.limit + 1);++ const ownerClause = f.owner ? ' AND owner_subject = ?' : '';++ const rows = await db.prepare(`SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ?${ownerClause} ORDER BY id LIMIT ?`).all(f.cursor || '', ...(f.owner ? [f.owner] : []), f.limit + 1);const resources = rows.slice(0, f.limit).map(streamSummary);const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 1]) : null;return res.set('Cache-Control', 'private, max-age=60').json({ resources, next_cursor });@@@ -9,6 +9,7 @@ const t = suite('resource-index');const reader = serviceToken('services', ['openre.resource.read']);let api;let ids;++let ownerIds;const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });@@@ -22,15 +23,20 @@ t('summary and query functions keep the contract shape without a socket', () =>assert.ok(contracts.validate('common.resource-summary@1', summary).valid);assert.strictEqual(contracts.resources.nameOf(summary), null);assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');-- assert.deepStrictEqual(filtersOf({}), { project: null, kind: null, limit: 100, cursor: null });++ assert.strictEqual(filtersOf({ owner: OWNER }).owner, OWNER);++ assert.strictEqual(filtersOf({ owner: 'agt_01J0000000000000000000000A' }).owner, 'agt_01J0000000000000000000000A');++ assert.strictEqual(filtersOf({ owner: '' }).owner, null);++ assert.deepStrictEqual(filtersOf({}), { project: null, owner: null, kind: null, limit: 100, cursor: null });});t('boot and list schema, ordered rows, and safe summary fields', async () => {api = await bootApi();const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;++ const c = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Third stream' })).definition;const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;-- ids = [a.id, b.id].sort();++ ids = [a.id, b.id, c.id].sort();++ ownerIds = [a.id, c.id].sort();await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);const page = await get('');@@@ -69,6 +75,41 @@ t('cursor visits each row exactly once and filters follow person ownership', asyassert.strictEqual((await get('?limit=1001')).status, 400);});++t('owner filters exact subjects across kinds and cursor pages', async () => {++ const page = await get(`?owner=${OWNER}`);++ assert.strictEqual(page.status, 200, page.text);++ const validation = contracts.validate('common.resource-list-result@1', page.body);++ assert.ok(validation.valid, JSON.stringify(validation.errors));++ assert.deepStrictEqual(page.body.resources.map((s) => s.id), ownerIds);++ assert.ok(page.body.resources.every((s) => s.owner.id === OWNER));++ assert.strictEqual(page.body.next_cursor, null);++++ const withKind = await get(`?owner=${OWNER}&kind=openre.stream`);++ assert.deepStrictEqual(withKind.body.resources.map((s) => s.id), ownerIds);++ assert.deepStrictEqual((await get(`?owner=${OWNER}&kind=unknown.kind`)).body, { resources: [], next_cursor: null });++ assert.deepStrictEqual((await get(`?owner=${OWNER}&project=prj_01J0000000000000000000000A`)).body, { resources: [], next_cursor: null });++ assert.deepStrictEqual((await get('?owner=agt_01J0000000000000000000000A')).body, { resources: [], next_cursor: null });++++ const malformedProject = await get('?project=bad');++ for (const owner of ['svc:live', 'usr_short']) {++ const malformedOwner = await get(`?owner=${encodeURIComponent(owner)}`);++ assert.strictEqual(malformedOwner.status, malformedProject.status);++ assert.strictEqual(malformedOwner.body.code, malformedProject.body.code);++ }++++ const visited = [];++ let cursor = null;++ do {++ const next = await get(`?owner=${OWNER}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ assert.strictEqual(next.status, 200, next.text);++ const result = contracts.validate('common.resource-list-result@1', next.body);++ assert.ok(result.valid, JSON.stringify(result.errors));++ visited.push(...next.body.resources.map((s) => s.id));++ cursor = next.body.next_cursor;++ } while (cursor);++ assert.deepStrictEqual(visited, ownerIds);++});++// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.t('only a service token with the capability reaches either route', async () => {for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {
@@ -1,5 +1,6 @@
@@ -1,5 +1,6 @@
'use strict';
'use strict';
-/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned. */
+/** OpenRestream's authority index for OpenVibe.Services (ADR-048). Stream definitions are person-owned.
+ * GET /api/v1/resources accepts project, kind, owner, cursor, and limit query parameters. */
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -7,6 +8,7 @@ const SERVICE = 'openre';
@@ -7,6 +8,7 @@ const SERVICE = 'openre';
const KIND = 'openre.stream';
const KIND = 'openre.stream';
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[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}$/;
+const OWNER_SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
@@ -36,6 +38,8 @@ function decodeCursor(raw) {
@@ -36,6 +38,8 @@ function decodeCursor(raw) {
function filtersOf(query) {
function filtersOf(query) {
const project = query.project === undefined || query.project === '' ? null : query.project;
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' };
if (project !== null && (typeof project !== 'string' || !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 !== '') {
@@ -47,7 +51,7 @@ function filtersOf(query) {
@@ -47,7 +51,7 @@ function filtersOf(query) {
cursor = decodeCursor(query.cursor);
cursor = decodeCursor(query.cursor);
if (!cursor) return { error: 'cursor is not one this index issued' };
if (!cursor) return { error: 'cursor is not one this index issued' };
}
}
- return { project, kind, limit, cursor };
+ return { project, owner, kind, limit, cursor };
}
}
function router({ db, guard }) {
function router({ db, guard }) {
@@ -67,7 +71,8 @@ function router({ db, guard }) {
@@ -67,7 +71,8 @@ function router({ db, guard }) {
const f = filtersOf(req.query);
const f = filtersOf(req.query);
if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);
if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
if (f.project || (f.kind && f.kind !== KIND)) return res.set('Cache-Control', 'private, max-age=60').json({ resources: [], next_cursor: null });
- const rows = await db.prepare("SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ? ORDER BY id LIMIT ?").all(f.cursor || '', f.limit + 1);
+ const ownerClause = f.owner ? ' AND owner_subject = ?' : '';
+ const rows = await db.prepare(`SELECT id, owner_subject, title, state, created_at, updated_at FROM stream_definitions WHERE state != 'archived' AND id > ?${ownerClause} ORDER BY id LIMIT ?`).all(f.cursor || '', ...(f.owner ? [f.owner] : []), f.limit + 1);
const resources = rows.slice(0, f.limit).map(streamSummary);
const resources = rows.slice(0, f.limit).map(streamSummary);
const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 1]) : null;
const next_cursor = rows.length > f.limit ? encodeCursor(rows[f.limit - 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 });
@@ -9,6 +9,7 @@ const t = suite('resource-index');
@@ -9,6 +9,7 @@ const t = suite('resource-index');
const reader = serviceToken('services', ['openre.resource.read']);
const reader = serviceToken('services', ['openre.resource.read']);
let api;
let api;
let ids;
let ids;
+let ownerIds;
const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });
const get = (path, token = reader) => request(api.base, 'GET', `/api/v1/resources${path}`, { token });
@@ -22,15 +23,20 @@ t('summary and query functions keep the contract shape without a socket', () =>
@@ -22,15 +23,20 @@ t('summary and query functions keep the contract shape without a socket', () =>
assert.ok(contracts.validate('common.resource-summary@1', summary).valid);
assert.ok(contracts.validate('common.resource-summary@1', summary).valid);
assert.strictEqual(contracts.resources.nameOf(summary), null);
assert.strictEqual(contracts.resources.nameOf(summary), null);
assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');
assert.strictEqual(filtersOf({ project: 'bad' }).error, 'project must be a prj_ id');
- assert.deepStrictEqual(filtersOf({}), { project: null, kind: null, limit: 100, cursor: null });
+ assert.strictEqual(filtersOf({ owner: OWNER }).owner, OWNER);
+ assert.strictEqual(filtersOf({ owner: 'agt_01J0000000000000000000000A' }).owner, 'agt_01J0000000000000000000000A');
+ assert.strictEqual(filtersOf({ owner: '' }).owner, null);
+ assert.deepStrictEqual(filtersOf({}), { project: null, owner: null, kind: null, limit: 100, cursor: null });
});
});
t('boot and list schema, ordered rows, and safe summary fields', async () => {
t('boot and list schema, ordered rows, and safe summary fields', async () => {
api = await bootApi();
api = await bootApi();
const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;
const a = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'First stream' })).definition;
const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;
const b = (await api.rt.store.definitions.create({ owner_subject: OTHER, title: 'Second stream' })).definition;
+ const c = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Third stream' })).definition;
const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;
const archived = (await api.rt.store.definitions.create({ owner_subject: OWNER, title: 'Archived' })).definition;
- ids = [a.id, b.id].sort();
+ ids = [a.id, b.id, c.id].sort();
+ ownerIds = [a.id, c.id].sort();
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'disabled' WHERE id = ?").run(b.id);
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);
await api.rt.db.prepare("UPDATE stream_definitions SET state = 'archived' WHERE id = ?").run(archived.id);
const page = await get('');
const page = await get('');
@@ -69,6 +75,41 @@ t('cursor visits each row exactly once and filters follow person ownership', asy
@@ -69,6 +75,41 @@ t('cursor visits each row exactly once and filters follow person ownership', asy
assert.strictEqual((await get('?limit=1001')).status, 400);
assert.strictEqual((await get('?limit=1001')).status, 400);
});
});
+t('owner filters exact subjects across kinds and cursor pages', async () => {
+ const page = await get(`?owner=${OWNER}`);
+ assert.strictEqual(page.status, 200, page.text);
+ const validation = contracts.validate('common.resource-list-result@1', page.body);
+ assert.ok(validation.valid, JSON.stringify(validation.errors));
+ assert.deepStrictEqual(page.body.resources.map((s) => s.id), ownerIds);
+ assert.ok(page.body.resources.every((s) => s.owner.id === OWNER));
+ assert.strictEqual(page.body.next_cursor, null);
+
+ const withKind = await get(`?owner=${OWNER}&kind=openre.stream`);
+ assert.deepStrictEqual(withKind.body.resources.map((s) => s.id), ownerIds);
+ assert.deepStrictEqual((await get(`?owner=${OWNER}&kind=unknown.kind`)).body, { resources: [], next_cursor: null });
+ assert.deepStrictEqual((await get(`?owner=${OWNER}&project=prj_01J0000000000000000000000A`)).body, { resources: [], next_cursor: null });
+ assert.deepStrictEqual((await get('?owner=agt_01J0000000000000000000000A')).body, { resources: [], next_cursor: null });
+
+ const malformedProject = await get('?project=bad');
+ for (const owner of ['svc:live', 'usr_short']) {
+ const malformedOwner = await get(`?owner=${encodeURIComponent(owner)}`);
+ assert.strictEqual(malformedOwner.status, malformedProject.status);
+ assert.strictEqual(malformedOwner.body.code, malformedProject.body.code);
+ }
+
+ const visited = [];
+ let cursor = null;
+ do {
+ const next = await get(`?owner=${OWNER}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ assert.strictEqual(next.status, 200, next.text);
+ const result = contracts.validate('common.resource-list-result@1', next.body);
+ assert.ok(result.valid, JSON.stringify(result.errors));
+ visited.push(...next.body.resources.map((s) => s.id));
+ cursor = next.body.next_cursor;
+ } while (cursor);
+ assert.deepStrictEqual(visited, ownerIds);
+});
+
// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.
// An OVRN carries a slash (<type>/<id>): callers (OpenVibe.Services included) encode it as one path segment.
t('only a service token with the capability reaches either route', async () => {
t('only a service token with the capability reaches either route', async () => {
for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {
for (const path of ['', `/${encodeURIComponent('ovrn:openre:prj_01J0000000000000000000000A:stream/std_01J0000000000000000000000A')}`]) {