openvibers/openvibe.actor Commits

openvibe-contracts v0.129.0 + actor.task resource index (#22)

OP OpenVibers committed 10 Oct 2026 · f2f5bd0b32 · 10 files, +293 −24

* openvibe-contracts v0.129.0 (was v0.127.0)

Opened by scripts/pin-bump.js in OpenVibers/OpenVibe.Shared after the v0.129.0 release.

* Resource index: actor.task for OpenVibe.Services (actor.resource.read)

GET /api/v1/resources and /:ovrn (common.resource-list-result@1 / resource-summary@1), service tokens with
actor.resource.read only; the public vhost 404s it. contracts.test.js expects the five capabilities (the resource
read planned in 0.129.0).

Changes

ddiff --git a/README.md b/README.md
iindex 772963d..d2d8aee 100644
---- a/README.md
++++ b/README.md
@@@ -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 -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 12ab4f6..2acac64 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/deploy/nginx/openvibe.actor.conf b/deploy/nginx/openvibe.actor.conf
iindex 71487bd..5b74deb 100644
---- a/deploy/nginx/openvibe.actor.conf
++++ b/deploy/nginx/openvibe.actor.conf
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex c20c9ec..98e9c55 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 3b8c7d7..0050f41 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex f37a1f9..d7c893c 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 }));
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex fc3a372..bf111c1 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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('.');
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..ab6e461
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -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 };
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 67e8c64..7b1ef86 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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`);
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..9ceba2c
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -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); });