openvibers/openvibe.actor Commits
openvibe-contracts v0.129.0 + actor.task resource index (#22)
Changes
@@@ -48,6 +48,10 @@ Rate cards are typed in by a person from each provider's price page, with the pa| `GET /api/v1/tasks` | `actor.task.list` | `actor.task-list-result@1` || `GET /api/v1/agents` | public | `actor.agent-list-result@1` || `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |++| `GET /api/v1/resources` | `actor.resource.read` (service token) | `common.resource-list-result@1` for `actor.task`; filters: `project`, `kind`, `cursor`, `limit` |++| `GET /api/v1/resources/:ovrn` | `actor.resource.read` (service token) | one `common.resource-summary@1` for a project-owned task |++++The resource index is for OpenVibe.Services on loopback and is blocked at the public nginx vhost. It lists task summaries under the `actor.task` kind; only a service token with `actor.resource.read` can read it. A task without a project has no OVRN and cannot be fetched by name.**Who can call it:**- **A person:** their Network token as a Bearer, or this site's session. A write made with the session must come from openvibe.actor itself (fetch metadata or Origin).@@@ -189,7 +193,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).<!-- versions:start -->--- openvibe-contracts: v0.127.0++- openvibe-contracts: v0.129.0- openvibe-sdk: v0.38.0- openvibe-shared: v3.0.0<!-- versions:end -->@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9","port": 4950,"domain": "openvibe.actor",-- "contracts": "openvibe-contracts v0.127.0",++ "contracts": "openvibe-contracts v0.129.0","sdk": "openvibe-sdk v0.38.0","shared": "openvibe-shared v3.0.0","packages": [-- "openvibe-contracts v0.127.0",++ "openvibe-contracts v0.129.0","openvibe-sdk v0.38.0","openvibe-shared v3.0.0"],@@@ -94,6 +94,10 @@ server {proxy_pass http://127.0.0.1:4950;}++ # ── OpenVibe.Services' resource index: loopback only ────++ # Services reads http://127.0.0.1:4950/api/v1/resources directly with a service token.++ location ~* ^/api/v1/resources(/|$) { return 404; }++# ── API ─────────────────────────────────────────────────location /api/ {limit_req zone=ovactor_api burst=20 nodelay;@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"@@@ -871,9 +871,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.127.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",-- "integrity": "sha512-ySC+Qh8I3OlN5MULCqv0vfcAGubZQzWM0Fw7EOpnOo76vVnrkwx+m33aTxDum2yQqPkU8Ot5UtMuMwm1Cc1k3g==",++ "version": "0.129.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",++ "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -27,7 +27,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"@@@ -27,6 +27,7 @@ const { createEngine } = require('./tasks/engine');const { createTaskStream } = require('./tasks/stream');const { createPrincipal } = require('./http/principal');const { createApi } = require('./http/api');++const resourceIndex = require('./registry/resource-index');const { createPageRoutes } = require('./http/pages');const { createActorReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');@@@ -139,6 +140,8 @@ async function createApp(opts = {}) {// ── API ─────────────────────────────────────────────────app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ // OpenVibe.Services reads Actor's task index through a first-party service token on loopback.++ app.use('/api/v1/resources', resourceIndex.router(ctx));app.use('/api/v1', createApi(ctx));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));@@@ -20,7 +20,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read'];function decodePayload(token) {const parts = String(token || '').split('.');nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++++/**++ * 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.++ */++const contracts = require('openvibe-contracts');++const { asyncRouter } = require('../http/router');++++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 TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const DEFAULT_LIMIT = 100;++const MAX_LIMIT = 1000;++++/** Only the public summary fields of a task row; its text is reduced to a one-line name. */++function taskSummary(row) {++ const owner = USER_REQUESTER_RE.exec(String(row.requester || ''));++ const summary = {++ id: row.id,++ kind: TASK_KIND,++ service: SERVICE,++ ...(PROJECT_ID_RE.test(String(row.project_id || '')) ? { project_id: row.project_id } : {}),++ ...(owner ? { owner: { type: 'user', id: owner[1] } } : {}),++ name: String(row.task || '').replace(/\s+/g, ' ').trim().slice(0, 80),++ state: row.state,++ created_at: new Date(row.created_at).toISOString(),++ };++ const ovrn = contracts.resources.nameOf(summary);++ return ovrn ? { ...summary, ovrn } : summary;++}++++/** An opaque base64url [kind, id] position, matching the other authority indexes. */++const encodeCursor = (summary) => Buffer.from(JSON.stringify([summary.kind, summary.id])).toString('base64url');++function decodeCursor(raw) {++ let value;++ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }++ return Array.isArray(value) && value.length === 2 && value[0] === TASK_KIND &&++ typeof value[1] === 'string' && TASK_ID_RE.test(value[1]) ? value : null;++}++++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;++ 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}` };++ limit = Number(query.limit);++ }++ let cursor = null;++ if (typeof query.cursor === 'string' && query.cursor !== '') {++ cursor = decodeCursor(query.cursor);++ if (!cursor) return { error: 'cursor is not one this index issued' };++ }++ return { project, kind, limit, cursor };++}++++const COLUMNS = 'id, requester, project_id, task, state, created_at';++++function router({ s, principal }) {++ const r = asyncRouter();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ /** The existing capability guard checks the token; this also excludes people and app or agent tokens. */++ function firstParty(req, res, next) {++ if (req.principal.kind === 'app' && req.principal.requester.startsWith('service:')) return next();++ return problem(req, res, 403, 'capability.denied', 'actor.resource.read is first-party and requires a service token');++ }++++ r.get('/', principal.requireCapability('actor.resource.read'), firstParty, async (req, res) => {++ 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 });++++ const where = [];++ const args = [];++ if (f.project) { args.push(f.project); where.push(`project_id = $${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}`;++ const rows = await s.db.many(sql, args);++ const resources = rows.slice(0, f.limit).map(taskSummary);++ const next_cursor = rows.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;++ return res.json({ resources, next_cursor });++ });++++ r.get('/:ovrn', principal.requireCapability('actor.resource.read'), firstParty, async (req, res) => {++ const name = String(req.params.ovrn);++ const parsed = contracts.resources.parse(name);++ let summary = null;++ if (parsed && parsed.service === SERVICE && parsed.type === 'task' && TASK_ID_RE.test(parsed.id)) {++ const row = await s.db.maybe(`SELECT ${COLUMNS} FROM tasks WHERE id = $1`, [parsed.id]);++ if (row) summary = taskSummary(row);++ }++ if (!summary || summary.ovrn !== name) return problem(req, res, 404, 'resources.unknown_resource', `no resource named ${name}`);++ return res.json(summary);++ });++++ return r;++}++++module.exports = { router, taskSummary, SERVICE, TASK_KIND, DEFAULT_LIMIT, MAX_LIMIT, encodeCursor, filtersOf };@@@ -1,8 +1,8 @@'use strict';/*** The released contracts (the pinned openvibe-contracts tag, 0.115.0 or later) describe what Actor does. The-- * released `actor` service manifest and its four capability manifests are checked against the code: the domain,-- * port, health and ready paths, the capabilities (each implemented by exactly the routes in server/http/api.js, each++ * released `actor` service manifest and its five capability manifests are checked against the code: the domain,++ * port, health and ready paths, the capabilities (routes in server/http/api.js and server/registry/resource-index.js, each* route guarded by the one capability it names), no events but the account ones (ADR-033), and the shapes Actor answers with (the contracts'* actor.agent-list-result@1, platform.placement-result@1 and platform.task@1).*/@@@ -13,16 +13,19 @@ const contracts = require('openvibe-contracts');const configLib = require('../server/config');const { boot, check, done } = require('./helpers/boot');--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read'];const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');++const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');--/** `METHOD /api/v1/path` for every route api.js registers, with the capability its guard names (null: public). */++/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */function codeRoutes() {const out = new Map();-- for (const m of API_SRC.matchAll(/^\s*r\.(get|post)\('([^']+)'(.*)$/gm)) {-- const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);-- const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);-- out.set(`${m[1].toUpperCase()} /api/v1${m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });++ for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources']]) {++ for (const m of source.matchAll(/^\s*r\.(get|post)\('([^']+)'(.*)$/gm)) {++ const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);++ const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);++ out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });++ }}return out;}@@@ -60,35 +63,45 @@ function codeRoutes() {} finally { await t.close(); }});-- await check('the manifest lists exactly the four capabilities, all active, owned by actor, and the code guards exactly those', () => {++ await check('the manifest lists five capabilities, the resource read planned, owned by actor, and the code guards all five', () => {assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());for (const id of CAPS) {const c = contracts.capabilities.get(id);assert.ok(c, `${id} not released`);-- assert.strictEqual(c.status, 'active', id);++ assert.strictEqual(c.status, id === 'actor.resource.read' ? 'planned' : 'active', id);assert.strictEqual(c.owner, 'actor', id);}const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z.]+)'/g).map((s) => s.slice(1, -1));assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');++ const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');++ assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);});await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {const declared = new Set();for (const id of CAPS) {const impl = contracts.capabilities.get(id).implementedBy;-- assert.ok(impl && impl.length > 0, `${id} implementedBy is empty`);-- for (const r of impl) {++ // The released resource capability is planned, so its manifest has no implementedBy yet.++ const expected = id === 'actor.resource.read'++ ? ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']++ : impl;++ if (id === 'actor.resource.read') assert.deepStrictEqual(impl, []);++ else assert.ok(impl && impl.length > 0, `${id} implementedBy is empty`);++ for (const r of expected) {declared.add(r);-- assert.ok(routes.has(r), `${id} says ${r}, but api.js registers no such route`);++ assert.ok(routes.has(r), `${id} says ${r}, but the code registers no such route`);}}-- for (const r of routes.keys()) assert.ok(declared.has(r), `api.js registers ${r}, which no capability lists`);++ for (const r of routes.keys()) assert.ok(declared.has(r), `the code registers ${r}, which no capability lists`);});await check('each guarded route requires the capability that lists it; the agent reads are public', () => {for (const id of CAPS) {-- for (const r of contracts.capabilities.get(id).implementedBy) {++ const implemented = id === 'actor.resource.read'++ ? ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']++ : contracts.capabilities.get(id).implementedBy;++ for (const r of implemented) {const route = routes.get(r);if (id === 'actor.agent.read') {assert.strictEqual(route.guard, null, `${r} must stay public`);nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++// Actor's task index is a first-party, capability-guarded authority page for OpenVibe.Services.++const assert = require('assert');++const crypto = require('crypto');++const { ids, serviceAuth, resources, validate } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const user = t.network.addUser('resource-reader');++ const projectA = ids.newId('project');++ const projectB = ids.newId('project');++ async function add(requester, project, task, state = 'succeeded') {++ const id = ids.newId('task');++ const created = new Date().toISOString();++ await t.ctx.s.db.query(++ `INSERT INTO tasks (id, requester, project_id, task, mode, budget_task, budget_day, state, created_at)++ VALUES ($1, $2, $3, $4, 'balanced', 1, 1, $5, $6)`,++ [id, requester, project, task, state, created]);++ return id;++ }++ const a = await add(`user:${user.subject}`, projectA, 'First line\nprivate second line with secret text');++ const b = await add('service:codes', projectB, `${'B'.repeat(90)}\nsecret`, 'failed');++ const c = await add(`user:${user.subject}`, null, 'C task');++ const d = await add('agent:agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', null, 'D task');++ const erased = await add(`user:${user.subject}`, projectA, 'Erased task');++ await t.ctx.s.db.query('DELETE FROM tasks WHERE id = $1', [erased]);++++ const token = (cap, sub = 'svc:services') => {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app',++ aud: ['openvibe.actor'], cap, iat: now, exp: now + 300, jti: crypto.randomUUID(),++ ...(sub.startsWith('app:') ? { project_id: projectA, ns: [projectA], env: 'production' } : {}) }, t.network.privatePem);++ };++ const auth = { bearer: token(['actor.resource.read']) };++ const page = async (query = '') => {++ const response = await t.get(`/api/v1/resources${query}`, auth);++ assert.strictEqual(response.status, 200, response.text);++ const body = response.json();++ assert.deepStrictEqual(Object.keys(body).sort(), ['next_cursor', 'resources']);++ assert.deepStrictEqual(validate('common.resource-list-result@1', body).errors, []);++ return body;++ };++++ await check('only service tokens with actor.resource.read may list or read resources', async () => {++ for (const path of ['/api/v1/resources', `/api/v1/resources/${encodeURIComponent(`ovrn:actor:${projectA}:task/${a}`)}`]) {++ const anonymous = await t.get(path);++ assert.strictEqual(anonymous.status, 401);++ assert.strictEqual(anonymous.json().code, 'token.required');++ const person = await t.get(path, { as: user });++ assert.strictEqual(person.status, 403);++ assert.strictEqual(person.json().code, 'capability.denied');++ const without = await t.get(path, { bearer: token(['actor.task.read']) });++ assert.strictEqual(without.status, 403);++ assert.strictEqual(without.json().code, 'capability.denied');++ const app = await t.get(path, { bearer: token(['actor.resource.read'], `app:${ids.newId('app')}`) });++ assert.strictEqual(app.status, 403);++ assert.strictEqual(app.json().code, 'capability.denied');++ assert.strictEqual((await t.get(path, auth)).status, 200);++ }++ });++++ await check('a page validates and reveals only the allowed task summary fields', async () => {++ const body = await page();++ assert.deepStrictEqual(body.resources.map((x) => x.id), [a, b, c, d].sort());++ assert.ok(body.resources.every((x) => x.kind === 'actor.task' && x.service === 'actor'));++ assert.strictEqual(body.next_cursor, null);++ const byId = new Map(body.resources.map((x) => [x.id, x]));++ assert.strictEqual(byId.get(a).name, 'First line private second line with secret text');++ assert.deepStrictEqual(byId.get(a).owner, { type: 'user', id: user.subject });++ assert.strictEqual(byId.get(a).project_id, projectA);++ assert.strictEqual(byId.get(a).ovrn, resources.nameOf(byId.get(a)));++ assert.strictEqual(byId.get(b).state, 'failed');++ assert.strictEqual(byId.get(b).name, 'B'.repeat(80));++ assert.ok(!('owner' in byId.get(b)));++ assert.ok(!('project_id' in byId.get(c)) && !('ovrn' in byId.get(c)));++ assert.ok(!('owner' in byId.get(d)) && !('ovrn' in byId.get(d)));++ for (const summary of body.resources) {++ assert.deepStrictEqual(validate('common.resource-summary@1', summary).errors, []);++ assert.deepStrictEqual(Object.keys(summary).sort(), ['id', 'kind', 'service', 'project_id', 'ovrn', 'owner', 'name', 'state', 'created_at'].filter((key) => key in summary).sort());++ assert.ok(!JSON.stringify(summary).includes('Erased task'));++ }++ });++++ await check('the keyset cursor walks every row once with limit=1', async () => {++ const expected = (await page()).resources.map((x) => x.id);++ const seen = [];++ let cursor = null;++ do {++ const body = await page(`?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);++ assert.strictEqual(body.resources.length, 1);++ seen.push(body.resources[0].id);++ cursor = body.next_cursor;++ assert.ok(seen.length <= expected.length);++ } while (cursor);++ assert.deepStrictEqual(seen, expected);++ assert.deepStrictEqual((await page('?limit=1000')).resources.map((x) => x.id), expected);++ });++++ await check('project and kind filters narrow the page; invalid project is a 400 problem', async () => {++ assert.deepStrictEqual((await page(`?project=${projectA}`)).resources.map((x) => x.id), [a]);++ assert.deepStrictEqual((await page(`?project=${projectB}`)).resources.map((x) => x.id), [b]);++ assert.deepStrictEqual((await page('?kind=actor.task')).resources.map((x) => x.id), [a, b, c, d].sort());++ assert.deepStrictEqual((await page('?kind=actor.other')).resources, []);++ const bad = await t.get('/api/v1/resources?project=wrong', auth);++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.json().code, 'resources.bad_query');++ for (const query of ['?limit=0', '?limit=1001', '?cursor=broken']) {++ const response = await t.get(`/api/v1/resources${query}`, auth);++ assert.strictEqual(response.status, 400);++ assert.strictEqual(response.json().code, 'resources.bad_query');++ }++ });++++ 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);++ assert.strictEqual(one.status, 200, one.text);++ assert.deepStrictEqual(validate('common.resource-summary@1', one.json()).errors, []);++ assert.deepStrictEqual(one.json(), (await page()).resources.find((x) => x.id === a));++ for (const other of [++ `ovrn:actor:${projectB}:task/${a}`,++ `ovrn:actor:${projectA}:task/${erased}`,++ `ovrn:actor:${projectA}:task/${c}`,++ `ovrn:actor:${projectA}:task/${d}`,++ `ovrn:network:${projectA}:task/${a}`,++ 'invalid',++ ]) {++ const response = await t.get(`/api/v1/resources/${encodeURIComponent(other)}`, auth);++ assert.strictEqual(response.status, 404, other);++ assert.strictEqual(response.json().code, 'resources.unknown_resource');++ }++ });++ } finally { await t.close(); }++ done();++})().catch((error) => { console.error(error); process.exit(1); });
@@ -48,6 +48,10 @@ Rate cards are typed in by a person from each provider's price page, with the pa
@@ -48,6 +48,10 @@ Rate cards are typed in by a person from each provider's price page, with the pa
| `GET /api/v1/tasks` | `actor.task.list` | `actor.task-list-result@1` |
| `GET /api/v1/tasks` | `actor.task.list` | `actor.task-list-result@1` |
| `GET /api/v1/agents` | public | `actor.agent-list-result@1` |
| `GET /api/v1/agents` | public | `actor.agent-list-result@1` |
| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
+| `GET /api/v1/resources` | `actor.resource.read` (service token) | `common.resource-list-result@1` for `actor.task`; filters: `project`, `kind`, `cursor`, `limit` |
+| `GET /api/v1/resources/:ovrn` | `actor.resource.read` (service token) | one `common.resource-summary@1` for a project-owned task |
+
+The resource index is for OpenVibe.Services on loopback and is blocked at the public nginx vhost. It lists task summaries under the `actor.task` kind; only a service token with `actor.resource.read` can read it. A task without a project has no OVRN and cannot be fetched by name.
**Who can call it:**
**Who can call it:**
- **A person:** their Network token as a Bearer, or this site's session. A write made with the session must come from openvibe.actor itself (fetch metadata or Origin).
- **A person:** their Network token as a Bearer, or this site's session. A write made with the session must come from openvibe.actor itself (fetch metadata or Origin).
@@ -189,7 +193,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
@@ -189,7 +193,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.127.0
+- openvibe-contracts: v0.129.0
- openvibe-sdk: v0.38.0
- openvibe-sdk: v0.38.0
- openvibe-shared: v3.0.0
- openvibe-shared: v3.0.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"port": 4950,
"domain": "openvibe.actor",
"domain": "openvibe.actor",
- "contracts": "openvibe-contracts v0.127.0",
+ "contracts": "openvibe-contracts v0.129.0",
"sdk": "openvibe-sdk v0.38.0",
"sdk": "openvibe-sdk v0.38.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"packages": [
"packages": [
- "openvibe-contracts v0.127.0",
+ "openvibe-contracts v0.129.0",
"openvibe-sdk v0.38.0",
"openvibe-sdk v0.38.0",
"openvibe-shared v3.0.0"
"openvibe-shared v3.0.0"
],
],
@@ -94,6 +94,10 @@ server {
@@ -94,6 +94,10 @@ server {
proxy_pass http://127.0.0.1:4950;
proxy_pass http://127.0.0.1:4950;
}
}
+ # ── OpenVibe.Services' resource index: loopback only ────
+ # Services reads http://127.0.0.1:4950/api/v1/resources directly with a service token.
+ location ~* ^/api/v1/resources(/|$) { return 404; }
+
# ── API ─────────────────────────────────────────────────
# ── API ─────────────────────────────────────────────────
location /api/ {
location /api/ {
limit_req zone=ovactor_api burst=20 nodelay;
limit_req zone=ovactor_api burst=20 nodelay;
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.127.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
- "integrity": "sha512-ySC+Qh8I3OlN5MULCqv0vfcAGubZQzWM0Fw7EOpnOo76vVnrkwx+m33aTxDum2yQqPkU8Ot5UtMuMwm1Cc1k3g==",
+ "version": "0.129.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
+ "integrity": "sha512-Ly0F/ap6wmAf8hYpNrOhMLsU3cZYBjsFDW2P1F/xVmHVsRDzAPAZDXtFcohP0UNeZgyxgrR0BI0pPG5guD/Efw==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -27,7 +27,7 @@
@@ -27,7 +27,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.127.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -27,6 +27,7 @@ const { createEngine } = require('./tasks/engine');
@@ -27,6 +27,7 @@ const { createEngine } = require('./tasks/engine');
const { createTaskStream } = require('./tasks/stream');
const { createTaskStream } = require('./tasks/stream');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const resourceIndex = require('./registry/resource-index');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createActorReadiness } = require('./observability');
const { createActorReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
@@ -139,6 +140,8 @@ async function createApp(opts = {}) {
@@ -139,6 +140,8 @@ async function createApp(opts = {}) {
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.ACTOR_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ // OpenVibe.Services reads Actor's task index through a first-party service token on loopback.
+ app.use('/api/v1/resources', resourceIndex.router(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
@@ -20,7 +20,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
@@ -20,7 +20,7 @@ const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];
+const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+
+/**
+ * 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.
+ */
+const contracts = require('openvibe-contracts');
+const { asyncRouter } = require('../http/router');
+
+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 TASK_ID_RE = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DEFAULT_LIMIT = 100;
+const MAX_LIMIT = 1000;
+
+/** Only the public summary fields of a task row; its text is reduced to a one-line name. */
+function taskSummary(row) {
+ const owner = USER_REQUESTER_RE.exec(String(row.requester || ''));
+ const summary = {
+ id: row.id,
+ kind: TASK_KIND,
+ service: SERVICE,
+ ...(PROJECT_ID_RE.test(String(row.project_id || '')) ? { project_id: row.project_id } : {}),
+ ...(owner ? { owner: { type: 'user', id: owner[1] } } : {}),
+ name: String(row.task || '').replace(/\s+/g, ' ').trim().slice(0, 80),
+ state: row.state,
+ created_at: new Date(row.created_at).toISOString(),
+ };
+ const ovrn = contracts.resources.nameOf(summary);
+ return ovrn ? { ...summary, ovrn } : summary;
+}
+
+/** An opaque base64url [kind, id] position, matching the other authority indexes. */
+const encodeCursor = (summary) => Buffer.from(JSON.stringify([summary.kind, summary.id])).toString('base64url');
+function decodeCursor(raw) {
+ let value;
+ try { value = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
+ return Array.isArray(value) && value.length === 2 && value[0] === TASK_KIND &&
+ typeof value[1] === 'string' && TASK_ID_RE.test(value[1]) ? value : null;
+}
+
+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;
+ 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}` };
+ limit = Number(query.limit);
+ }
+ let cursor = null;
+ if (typeof query.cursor === 'string' && query.cursor !== '') {
+ cursor = decodeCursor(query.cursor);
+ if (!cursor) return { error: 'cursor is not one this index issued' };
+ }
+ return { project, kind, limit, cursor };
+}
+
+const COLUMNS = 'id, requester, project_id, task, state, created_at';
+
+function router({ s, principal }) {
+ const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ /** The existing capability guard checks the token; this also excludes people and app or agent tokens. */
+ function firstParty(req, res, next) {
+ if (req.principal.kind === 'app' && req.principal.requester.startsWith('service:')) return next();
+ return problem(req, res, 403, 'capability.denied', 'actor.resource.read is first-party and requires a service token');
+ }
+
+ r.get('/', principal.requireCapability('actor.resource.read'), firstParty, async (req, res) => {
+ 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 });
+
+ const where = [];
+ const args = [];
+ if (f.project) { args.push(f.project); where.push(`project_id = $${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}`;
+ const rows = await s.db.many(sql, args);
+ const resources = rows.slice(0, f.limit).map(taskSummary);
+ const next_cursor = rows.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
+ return res.json({ resources, next_cursor });
+ });
+
+ r.get('/:ovrn', principal.requireCapability('actor.resource.read'), firstParty, async (req, res) => {
+ const name = String(req.params.ovrn);
+ const parsed = contracts.resources.parse(name);
+ let summary = null;
+ if (parsed && parsed.service === SERVICE && parsed.type === 'task' && TASK_ID_RE.test(parsed.id)) {
+ const row = await s.db.maybe(`SELECT ${COLUMNS} FROM tasks WHERE id = $1`, [parsed.id]);
+ if (row) summary = taskSummary(row);
+ }
+ if (!summary || summary.ovrn !== name) return problem(req, res, 404, 'resources.unknown_resource', `no resource named ${name}`);
+ return res.json(summary);
+ });
+
+ return r;
+}
+
+module.exports = { router, taskSummary, SERVICE, TASK_KIND, DEFAULT_LIMIT, MAX_LIMIT, encodeCursor, filtersOf };
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
'use strict';
'use strict';
/**
/**
* The released contracts (the pinned openvibe-contracts tag, 0.115.0 or later) describe what Actor does. The
* The released contracts (the pinned openvibe-contracts tag, 0.115.0 or later) describe what Actor does. The
- * released `actor` service manifest and its four capability manifests are checked against the code: the domain,
- * port, health and ready paths, the capabilities (each implemented by exactly the routes in server/http/api.js, each
+ * released `actor` service manifest and its five capability manifests are checked against the code: the domain,
+ * port, health and ready paths, the capabilities (routes in server/http/api.js and server/registry/resource-index.js, each
* route guarded by the one capability it names), no events but the account ones (ADR-033), and the shapes Actor answers with (the contracts'
* route guarded by the one capability it names), no events but the account ones (ADR-033), and the shapes Actor answers with (the contracts'
* actor.agent-list-result@1, platform.placement-result@1 and platform.task@1).
* actor.agent-list-result@1, platform.placement-result@1 and platform.task@1).
*/
*/
@@ -13,16 +13,19 @@ const contracts = require('openvibe-contracts');
@@ -13,16 +13,19 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read'];
+const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read'];
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
+const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
-/** `METHOD /api/v1/path` for every route api.js registers, with the capability its guard names (null: public). */
+/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
function codeRoutes() {
function codeRoutes() {
const out = new Map();
const out = new Map();
- for (const m of API_SRC.matchAll(/^\s*r\.(get|post)\('([^']+)'(.*)$/gm)) {
- const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
- const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
- out.set(`${m[1].toUpperCase()} /api/v1${m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
+ for (const [source, prefix] of [[API_SRC, '/api/v1'], [INDEX_SRC, '/api/v1/resources']]) {
+ for (const m of source.matchAll(/^\s*r\.(get|post)\('([^']+)'(.*)$/gm)) {
+ const guard = m[3].match(/requireCapability\('([a-z.]+)'\)/);
+ const read = m[3].match(/limits\.reads\('([a-z.]+)'\)/);
+ out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
+ }
}
}
return out;
return out;
}
}
@@ -60,35 +63,45 @@ function codeRoutes() {
@@ -60,35 +63,45 @@ function codeRoutes() {
} finally { await t.close(); }
} finally { await t.close(); }
});
});
- await check('the manifest lists exactly the four capabilities, all active, owned by actor, and the code guards exactly those', () => {
+ await check('the manifest lists five capabilities, the resource read planned, owned by actor, and the code guards all five', () => {
assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());
assert.deepStrictEqual([...manifest.capabilities].sort(), [...CAPS].sort());
for (const id of CAPS) {
for (const id of CAPS) {
const c = contracts.capabilities.get(id);
const c = contracts.capabilities.get(id);
assert.ok(c, `${id} not released`);
assert.ok(c, `${id} not released`);
- assert.strictEqual(c.status, 'active', id);
+ assert.strictEqual(c.status, id === 'actor.resource.read' ? 'planned' : 'active', id);
assert.strictEqual(c.owner, 'actor', id);
assert.strictEqual(c.owner, 'actor', id);
}
}
const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');
const principalSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'principal.js'), 'utf8');
const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z.]+)'/g).map((s) => s.slice(1, -1));
const listed = principalSrc.match(/const CAPABILITIES = \[([^\]]+)\]/)[1].match(/'([a-z.]+)'/g).map((s) => s.slice(1, -1));
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
assert.deepStrictEqual([...listed].sort(), [...CAPS].sort(), 'principal.js accepts exactly Actor\'s capabilities');
+ const appSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'app.js'), 'utf8');
+ assert.match(appSrc, /app\.use\('\/api\/v1\/resources', resourceIndex\.router\(ctx\)\)/);
});
});
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
await check('every capability is implemented by routes that exist, and every route is some capability\'s', () => {
const declared = new Set();
const declared = new Set();
for (const id of CAPS) {
for (const id of CAPS) {
const impl = contracts.capabilities.get(id).implementedBy;
const impl = contracts.capabilities.get(id).implementedBy;
- assert.ok(impl && impl.length > 0, `${id} implementedBy is empty`);
- for (const r of impl) {
+ // The released resource capability is planned, so its manifest has no implementedBy yet.
+ const expected = id === 'actor.resource.read'
+ ? ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']
+ : impl;
+ if (id === 'actor.resource.read') assert.deepStrictEqual(impl, []);
+ else assert.ok(impl && impl.length > 0, `${id} implementedBy is empty`);
+ for (const r of expected) {
declared.add(r);
declared.add(r);
- assert.ok(routes.has(r), `${id} says ${r}, but api.js registers no such route`);
+ assert.ok(routes.has(r), `${id} says ${r}, but the code registers no such route`);
}
}
}
}
- for (const r of routes.keys()) assert.ok(declared.has(r), `api.js registers ${r}, which no capability lists`);
+ for (const r of routes.keys()) assert.ok(declared.has(r), `the code registers ${r}, which no capability lists`);
});
});
await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
await check('each guarded route requires the capability that lists it; the agent reads are public', () => {
for (const id of CAPS) {
for (const id of CAPS) {
- for (const r of contracts.capabilities.get(id).implementedBy) {
+ const implemented = id === 'actor.resource.read'
+ ? ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn']
+ : contracts.capabilities.get(id).implementedBy;
+ for (const r of implemented) {
const route = routes.get(r);
const route = routes.get(r);
if (id === 'actor.agent.read') {
if (id === 'actor.agent.read') {
assert.strictEqual(route.guard, null, `${r} must stay public`);
assert.strictEqual(route.guard, null, `${r} must stay public`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+// Actor's task index is a first-party, capability-guarded authority page for OpenVibe.Services.
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids, serviceAuth, resources, validate } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const user = t.network.addUser('resource-reader');
+ const projectA = ids.newId('project');
+ const projectB = ids.newId('project');
+ async function add(requester, project, task, state = 'succeeded') {
+ const id = ids.newId('task');
+ const created = new Date().toISOString();
+ await t.ctx.s.db.query(
+ `INSERT INTO tasks (id, requester, project_id, task, mode, budget_task, budget_day, state, created_at)
+ VALUES ($1, $2, $3, $4, 'balanced', 1, 1, $5, $6)`,
+ [id, requester, project, task, state, created]);
+ return id;
+ }
+ const a = await add(`user:${user.subject}`, projectA, 'First line\nprivate second line with secret text');
+ const b = await add('service:codes', projectB, `${'B'.repeat(90)}\nsecret`, 'failed');
+ const c = await add(`user:${user.subject}`, null, 'C task');
+ const d = await add('agent:agt_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', null, 'D task');
+ const erased = await add(`user:${user.subject}`, projectA, 'Erased task');
+ await t.ctx.s.db.query('DELETE FROM tasks WHERE id = $1', [erased]);
+
+ const token = (cap, sub = 'svc:services') => {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app',
+ aud: ['openvibe.actor'], cap, iat: now, exp: now + 300, jti: crypto.randomUUID(),
+ ...(sub.startsWith('app:') ? { project_id: projectA, ns: [projectA], env: 'production' } : {}) }, t.network.privatePem);
+ };
+ const auth = { bearer: token(['actor.resource.read']) };
+ const page = async (query = '') => {
+ const response = await t.get(`/api/v1/resources${query}`, auth);
+ assert.strictEqual(response.status, 200, response.text);
+ const body = response.json();
+ assert.deepStrictEqual(Object.keys(body).sort(), ['next_cursor', 'resources']);
+ assert.deepStrictEqual(validate('common.resource-list-result@1', body).errors, []);
+ return body;
+ };
+
+ await check('only service tokens with actor.resource.read may list or read resources', async () => {
+ for (const path of ['/api/v1/resources', `/api/v1/resources/${encodeURIComponent(`ovrn:actor:${projectA}:task/${a}`)}`]) {
+ const anonymous = await t.get(path);
+ assert.strictEqual(anonymous.status, 401);
+ assert.strictEqual(anonymous.json().code, 'token.required');
+ const person = await t.get(path, { as: user });
+ assert.strictEqual(person.status, 403);
+ assert.strictEqual(person.json().code, 'capability.denied');
+ const without = await t.get(path, { bearer: token(['actor.task.read']) });
+ assert.strictEqual(without.status, 403);
+ assert.strictEqual(without.json().code, 'capability.denied');
+ const app = await t.get(path, { bearer: token(['actor.resource.read'], `app:${ids.newId('app')}`) });
+ assert.strictEqual(app.status, 403);
+ assert.strictEqual(app.json().code, 'capability.denied');
+ assert.strictEqual((await t.get(path, auth)).status, 200);
+ }
+ });
+
+ await check('a page validates and reveals only the allowed task summary fields', async () => {
+ const body = await page();
+ assert.deepStrictEqual(body.resources.map((x) => x.id), [a, b, c, d].sort());
+ assert.ok(body.resources.every((x) => x.kind === 'actor.task' && x.service === 'actor'));
+ assert.strictEqual(body.next_cursor, null);
+ const byId = new Map(body.resources.map((x) => [x.id, x]));
+ assert.strictEqual(byId.get(a).name, 'First line private second line with secret text');
+ assert.deepStrictEqual(byId.get(a).owner, { type: 'user', id: user.subject });
+ assert.strictEqual(byId.get(a).project_id, projectA);
+ assert.strictEqual(byId.get(a).ovrn, resources.nameOf(byId.get(a)));
+ assert.strictEqual(byId.get(b).state, 'failed');
+ assert.strictEqual(byId.get(b).name, 'B'.repeat(80));
+ assert.ok(!('owner' in byId.get(b)));
+ assert.ok(!('project_id' in byId.get(c)) && !('ovrn' in byId.get(c)));
+ assert.ok(!('owner' in byId.get(d)) && !('ovrn' in byId.get(d)));
+ for (const summary of body.resources) {
+ assert.deepStrictEqual(validate('common.resource-summary@1', summary).errors, []);
+ assert.deepStrictEqual(Object.keys(summary).sort(), ['id', 'kind', 'service', 'project_id', 'ovrn', 'owner', 'name', 'state', 'created_at'].filter((key) => key in summary).sort());
+ assert.ok(!JSON.stringify(summary).includes('Erased task'));
+ }
+ });
+
+ await check('the keyset cursor walks every row once with limit=1', async () => {
+ const expected = (await page()).resources.map((x) => x.id);
+ const seen = [];
+ let cursor = null;
+ do {
+ const body = await page(`?limit=1${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
+ assert.strictEqual(body.resources.length, 1);
+ seen.push(body.resources[0].id);
+ cursor = body.next_cursor;
+ assert.ok(seen.length <= expected.length);
+ } while (cursor);
+ assert.deepStrictEqual(seen, expected);
+ assert.deepStrictEqual((await page('?limit=1000')).resources.map((x) => x.id), expected);
+ });
+
+ await check('project and kind filters narrow the page; invalid project is a 400 problem', async () => {
+ assert.deepStrictEqual((await page(`?project=${projectA}`)).resources.map((x) => x.id), [a]);
+ assert.deepStrictEqual((await page(`?project=${projectB}`)).resources.map((x) => x.id), [b]);
+ assert.deepStrictEqual((await page('?kind=actor.task')).resources.map((x) => x.id), [a, b, c, d].sort());
+ assert.deepStrictEqual((await page('?kind=actor.other')).resources, []);
+ const bad = await t.get('/api/v1/resources?project=wrong', auth);
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.json().code, 'resources.bad_query');
+ for (const query of ['?limit=0', '?limit=1001', '?cursor=broken']) {
+ const response = await t.get(`/api/v1/resources${query}`, auth);
+ assert.strictEqual(response.status, 400);
+ assert.strictEqual(response.json().code, 'resources.bad_query');
+ }
+ });
+
+ 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);
+ assert.strictEqual(one.status, 200, one.text);
+ assert.deepStrictEqual(validate('common.resource-summary@1', one.json()).errors, []);
+ assert.deepStrictEqual(one.json(), (await page()).resources.find((x) => x.id === a));
+ for (const other of [
+ `ovrn:actor:${projectB}:task/${a}`,
+ `ovrn:actor:${projectA}:task/${erased}`,
+ `ovrn:actor:${projectA}:task/${c}`,
+ `ovrn:actor:${projectA}:task/${d}`,
+ `ovrn:network:${projectA}:task/${a}`,
+ 'invalid',
+ ]) {
+ const response = await t.get(`/api/v1/resources/${encodeURIComponent(other)}`, auth);
+ assert.strictEqual(response.status, 404, other);
+ assert.strictEqual(response.json().code, 'resources.unknown_resource');
+ }
+ });
+ } finally { await t.close(); }
+ done();
+})().catch((error) => { console.error(error); process.exit(1); });