openvibers/openvibe.actor Commits
Resource index: an owner filter (a person's own tasks) (#26)
Changes
@@@ -3,6 +3,9 @@/*** Actor's authority resource index (ADR-048, plan T13 step 8 phase 2). OpenVibe.Services reads* actor.task summaries here with a first-party service token carrying actor.resource.read.++ * GET /?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's tasks, the ones whose++ * summary names them (requester user:usr_…; a task an agent or a service asked for names no owner, so an agt_ owner++ * matches nothing). A malformed filter is a 400.*/const contracts = require('openvibe-contracts');const { asyncRouter } = require('../http/router');@@@ -11,6 +14,7 @@ const SERVICE = 'actor';const TASK_KIND = 'actor.task';const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const USER_REQUESTER_RE = /^user:(usr_[0-9A-HJKMNP-TV-Z]{26})$/;++const SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_LIMIT = 100;const MAX_LIMIT = 1000;@@@ -45,6 +49,8 @@ function filtersOf(query) {const project = query.project === undefined ? 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 (typeof query.limit === 'string' && query.limit !== '') {if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };@@@ -55,7 +61,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 };}const COLUMNS = 'id, requester, project_id, task, state, created_at';@@@ -77,10 +83,12 @@ function router({ s, principal }) {const f = filtersOf(req.query);if (f.error) return problem(req, res, 400, 'resources.bad_query', f.error);if (f.kind && f.kind !== TASK_KIND) return res.json({ resources: [], next_cursor: null });++ if (f.owner && !f.owner.startsWith('usr_')) return res.json({ resources: [], next_cursor: null });const where = [];const args = [];if (f.project) { args.push(f.project); where.push(`project_id = $${args.length}`); }++ if (f.owner) { args.push(`user:${f.owner}`); where.push(`requester = $${args.length}`); }if (f.cursor) { args.push(f.cursor[1]); where.push(`id > $${args.length}`); }args.push(f.limit + 1);const sql = `SELECT ${COLUMNS} FROM tasks ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id ASC LIMIT $${args.length}`;@@@ -113,6 +113,27 @@ const { boot, check, done } = require('./helpers/boot');}});++ await check('owner narrows to one person\'s tasks, combines with project and kind, pages once each, and is checked', async () => {++ const mine = await page(`?owner=${user.subject}`);++ assert.deepStrictEqual(mine.resources.map((x) => x.id), [a, c].sort());++ assert.ok(mine.resources.every((x) => x.owner && x.owner.id === user.subject));++ assert.deepStrictEqual((await page(`?owner=${user.subject}&project=${projectA}&kind=actor.task`)).resources.map((x) => x.id), [a]);++ assert.deepStrictEqual((await page(`?owner=${user.subject}&kind=actor.other`)).resources, []);++ assert.deepStrictEqual((await page('?owner=agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C')).resources, [], 'an agent\'s task names no owner');++ const seen = [];++ let cursor = null;++ do {++ const body = await page(`?owner=${user.subject}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ seen.push(...body.resources.map((x) => x.id));++ cursor = body.next_cursor;++ } while (cursor);++ assert.deepStrictEqual(seen, [a, c].sort());++ for (const bad of ['svc:live', 'usr_short', 'user:usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C']) {++ const response = await t.get(`/api/v1/resources?owner=${encodeURIComponent(bad)}`, auth);++ assert.deepStrictEqual([response.status, response.json().code], [400, 'resources.bad_query'], bad);++ }++ });++await check('one OVRN resolves only a present task in its actual project', async () => {const name = `ovrn:actor:${projectA}:task/${a}`;const one = await t.get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
@@ -3,6 +3,9 @@
@@ -3,6 +3,9 @@
/**
/**
* Actor's authority resource index (ADR-048, plan T13 step 8 phase 2). OpenVibe.Services reads
* Actor's authority resource index (ADR-048, plan T13 step 8 phase 2). OpenVibe.Services reads
* actor.task summaries here with a first-party service token carrying actor.resource.read.
* actor.task summaries here with a first-party service token carrying actor.resource.read.
+ * GET /?project=&kind=&owner=&cursor=&limit=: `owner` (usr_…/agt_…) answers only that subject's tasks, the ones whose
+ * summary names them (requester user:usr_…; a task an agent or a service asked for names no owner, so an agt_ owner
+ * matches nothing). A malformed filter is a 400.
*/
*/
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
@@ -11,6 +14,7 @@ const SERVICE = 'actor';
@@ -11,6 +14,7 @@ const SERVICE = 'actor';
const TASK_KIND = 'actor.task';
const TASK_KIND = 'actor.task';
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_REQUESTER_RE = /^user:(usr_[0-9A-HJKMNP-TV-Z]{26})$/;
const USER_REQUESTER_RE = /^user:(usr_[0-9A-HJKMNP-TV-Z]{26})$/;
+const SUBJECT_RE = /^(usr|agt)_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_LIMIT = 100;
const DEFAULT_LIMIT = 100;
const MAX_LIMIT = 1000;
const MAX_LIMIT = 1000;
@@ -45,6 +49,8 @@ function filtersOf(query) {
@@ -45,6 +49,8 @@ function filtersOf(query) {
const project = query.project === undefined ? null : query.project;
const project = query.project === undefined ? 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 kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
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;
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) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
@@ -55,7 +61,7 @@ function filtersOf(query) {
@@ -55,7 +61,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 };
}
}
const COLUMNS = 'id, requester, project_id, task, state, created_at';
const COLUMNS = 'id, requester, project_id, task, state, created_at';
@@ -77,10 +83,12 @@ function router({ s, principal }) {
@@ -77,10 +83,12 @@ function router({ s, principal }) {
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.kind && f.kind !== TASK_KIND) return res.json({ resources: [], next_cursor: null });
if (f.kind && f.kind !== TASK_KIND) return res.json({ resources: [], next_cursor: null });
+ if (f.owner && !f.owner.startsWith('usr_')) return res.json({ resources: [], next_cursor: null });
const where = [];
const where = [];
const args = [];
const args = [];
if (f.project) { args.push(f.project); where.push(`project_id = $${args.length}`); }
if (f.project) { args.push(f.project); where.push(`project_id = $${args.length}`); }
+ if (f.owner) { args.push(`user:${f.owner}`); where.push(`requester = $${args.length}`); }
if (f.cursor) { args.push(f.cursor[1]); where.push(`id > $${args.length}`); }
if (f.cursor) { args.push(f.cursor[1]); where.push(`id > $${args.length}`); }
args.push(f.limit + 1);
args.push(f.limit + 1);
const sql = `SELECT ${COLUMNS} FROM tasks ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id ASC LIMIT $${args.length}`;
const sql = `SELECT ${COLUMNS} FROM tasks ${where.length ? `WHERE ${where.join(' AND ')}` : ''} ORDER BY id ASC LIMIT $${args.length}`;
@@ -113,6 +113,27 @@ const { boot, check, done } = require('./helpers/boot');
@@ -113,6 +113,27 @@ const { boot, check, done } = require('./helpers/boot');
}
}
});
});
+ await check('owner narrows to one person\'s tasks, combines with project and kind, pages once each, and is checked', async () => {
+ const mine = await page(`?owner=${user.subject}`);
+ assert.deepStrictEqual(mine.resources.map((x) => x.id), [a, c].sort());
+ assert.ok(mine.resources.every((x) => x.owner && x.owner.id === user.subject));
+ assert.deepStrictEqual((await page(`?owner=${user.subject}&project=${projectA}&kind=actor.task`)).resources.map((x) => x.id), [a]);
+ assert.deepStrictEqual((await page(`?owner=${user.subject}&kind=actor.other`)).resources, []);
+ assert.deepStrictEqual((await page('?owner=agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C')).resources, [], 'an agent\'s task names no owner');
+ const seen = [];
+ let cursor = null;
+ do {
+ const body = await page(`?owner=${user.subject}&limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ seen.push(...body.resources.map((x) => x.id));
+ cursor = body.next_cursor;
+ } while (cursor);
+ assert.deepStrictEqual(seen, [a, c].sort());
+ for (const bad of ['svc:live', 'usr_short', 'user:usr_01J8ZQ4Y7N3M2K1H0G9F8E7D6C']) {
+ const response = await t.get(`/api/v1/resources?owner=${encodeURIComponent(bad)}`, auth);
+ assert.deepStrictEqual([response.status, response.json().code], [400, 'resources.bad_query'], bad);
+ }
+ });
+
await check('one OVRN resolves only a present task in its actual project', async () => {
await check('one OVRN resolves only a present task in its actual project', async () => {
const name = `ovrn:actor:${projectA}:task/${a}`;
const name = `ovrn:actor:${projectA}:task/${a}`;
const one = await t.get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
const one = await t.get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);