openvibers/openvibe.watch Commits
Resource index: an owner filter (a person's own watches) (#35)
Changes
@@@ -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@@@ -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}`;
@@ -1,5 +1,10 @@
@@ -1,5 +1,10 @@
'use strict';
'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 express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
@@ -8,6 +13,7 @@ const KIND = 'watch.watch';
@@ -8,6 +13,7 @@ const KIND = 'watch.watch';
const CAPABILITY = 'watch.resource.read';
const CAPABILITY = 'watch.resource.read';
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 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;
@@ -38,6 +44,9 @@ function filtersOf(query) {
@@ -38,6 +44,9 @@ 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 kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
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;
let limit = DEFAULT_LIMIT;
if (typeof query.limit === 'string' && query.limit !== '') {
if (typeof query.limit === 'string' && query.limit !== '') {
if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) {
if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) {
@@ -50,7 +59,7 @@ function filtersOf(query) {
@@ -50,7 +59,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, kind, owner, limit, cursor };
}
}
function resourceIndexRouter({ db, auth }) {
function resourceIndexRouter({ db, auth }) {
@@ -73,9 +82,12 @@ function resourceIndexRouter({ db, auth }) {
@@ -73,9 +82,12 @@ function resourceIndexRouter({ db, auth }) {
const filters = filtersOf(req.query);
const filters = filtersOf(req.query);
if (filters.error) return bad(req, res, filters.error);
if (filters.error) return bad(req, res, filters.error);
if (filters.kind && filters.kind !== KIND) return res.json({ resources: [], next_cursor: null });
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 clauses = ['deleted_at IS NULL'];
const args = [];
const args = [];
if (filters.project) { clauses.push('project_id = ?'); args.push(filters.project); }
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); }
if (filters.cursor) { clauses.push('id > ?'); args.push(filters.cursor); }
args.push(filters.limit + 1);
args.push(filters.limit + 1);
const rows = await db.prepare(`SELECT id, project_id, owner_sub, name, status, created_at, updated_at
const rows = await db.prepare(`SELECT id, project_id, owner_sub, name, status, created_at, updated_at
@@ -98,6 +98,29 @@ t('project and kind filters, and bad project or cursor', async () => {
@@ -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);
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 () => {
t('OVRN reads require a computed name and exclude deleted watches', async () => {
for (const row of [rows[0], rows[1], rows[3]]) {
for (const row of [rows[0], rows[1], rows[3]]) {
const name = `ovrn:watch:${row.project_id}:watch/${row.id}`;
const name = `ovrn:watch:${row.project_id}:watch/${row.id}`;