openvibers/openvibe.codes Commits

Codes: the resource index (GET /api/v1/resources, codes.manifest/release) under codes.resource.read; contracts v0.107.0 (T13 step 8). (#20)

OP OpenVibers committed 7 Oct 2026 · 1d362fec9e · 10 files, +449 −34

**Changes (file:line)**
- `package.json:20` — pin openvibe-contracts v0.107.0 (codeload URL).
- `package-lock.json:19,885-891` — lockfile resolved/version/integrity for v0.107.0 (npm run via node child_process; the shell refused `npm install`).
- `server/registry/resource-index.js:1` — new router (mirrors Network's): `RESOURCE_READ:38`, `manifestSummary:58`, `releaseSummary:70`, `ovrnOf:80`, `collect:97`, `filtersOf:124`, `router:149`.
- `server/http/api.js:46` — extracted/exported `createCapabilityAccess` (`checked:57`, `manageAccess:120`, export `:205`); the release routes now use the same factory, so there is one auth path.
- `server/app.js:42-43,161` — mounts the index at `/api/v1/resources` before `/api/v1`, guarded by `createCapabilityAccess(...).checked('codes.resource.read')`.
- `test/resource-index.test.js:1` — new test (auth matrix, schema validation of page and single read, tenancy, kind, cursor paging, ovrn 404, bad query 400).
- `test/contracts.test.js:14,30-49` — manifest now lists `codes.resource.read` (planned); asserts the guards exist (api.js factory + resource-index capability).
- `test/docs.test.js:22,36-37,42` — pinned tag v0.107.0.
- `README.md:17,41,44,55,63-66,79,158` — Owns bullet, Resource-index + API rows, capability list, pin (the "Depends on" line had drifted to v0.86.0), tests paragraph.
- `STATUS.json:10,12,32,63` — contracts pin, works entry, features entry (updated 2026-10-07).

**implementedBy routes** (for Opus to put in the capability after deploy): `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`.

**Tests** — `nice -n 15 ov test --no-cache`: resource-index, contracts, docs, releases → 4 passed (18 s); discovery, actor-limits, security-secrets → 3 passed (18 s); re-ran resource-index/contracts/releases after the last tweak → 3 passed (9 s). `openvibe-contracts-check --service codes --src server test` → ok (contracts 0.107.0). Deps refreshed to a new shared copy that installed v0.107.0.

**For Opus**
- `codes.resource.read` stays `planned`/`implementedBy: []` in v0.107.0 (no contracts change made), as instructed.
- Mapping decisions to sign off: manifest `state` is the constant `'valid'` (the row is immutable and written only after validation — the schema requires a state and the table has no lifecycle column); manifest `name` is the `name` in the stored body (both manifest schemas require it); `owner` only when `created_by` is `user:usr_…` (an `app:app_…` actor has none); drafts and revoked releases **are** listed, with `state` = the release status (the index is first-party and authority-wide, as Network lists archived projects). Both kinds are nameable per ADR-048's catalog, so unlike Network every summary here carries an OVRN (`ovrn:codes:<prj_>:manifest/mfs_…` / `:release/rel_…`) — worth confirming that matches Services' expectations for `codes.release`.
- Not committed or pushed.

Changes

ddiff --git a/README.md b/README.md
iindex b54377a..228d022 100644
---- a/README.md
++++ b/README.md
@@@ -14,6 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
## Owns
- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
++- **The authority resource index** (ADR-048, `codes.resource.read`): those rows as `common.resource-summary@1` — `codes.manifest` (`mfs_`) and `codes.release` (`rel_`) — for OpenVibe.Services to fan out over and merge. Both kinds are nameable, so every summary carries an OVRN; trust tiers and playground runs are metadata and audit, never resources.
- **Trust tiers** per ADR-013 (`unreviewed`, `reviewed`, `first-party`) — **metadata only**: a tier changes defaults and discovery, never a grant check. Databases written with the earlier four names are migrated at boot (untrusted→unreviewed, verified/trusted→reviewed, platform-maintained→first-party), idempotently.
- **Playground run logs** (who ran what, outcome, stage, problem code; never a credential).
- The events `codes.app.published`, `codes.app.deprecated`, `codes.app.revoked`, and `codes.moderation.action` for staff revocations and trust tier changes (the network's moderation audit log), through the openvibe-sdk transactional outbox.
@@@ -37,8 +38,9 @@ A place where a developer outside the network goes from an OpenVibe account to a
| Coding-harness registry | `/docs/harnesses`, `/api/v1/harnesses` | Lists the coding harnesses Codes can route to with their agents (`GET /api/v1/harnesses`) and picks an offer for a task (`POST /api/v1/harnesses/route`). |
| Playgrounds | `/projects/:project/apps/:app/playground` | Media upload (sandbox, `media.object.upload`) and Events publish (`events.app.publish`: types `app.<project_key>.*`, source `app-<app ULID>`), run **as the app** with its own token (from its secret typed for that one request, or a pasted token). Refused — with the missing grant named, and nothing requested or called — unless Network lists the grant as approved. Sandbox apps only. |
| Manifests | `/manifests/validate`, `/projects/:project/apps/:app/releases/new` | Validate `codes.app-manifest@1` and `mods.mod-manifest@1` with openvibe-contracts; create, publish, deprecate and revoke releases. |
++| Resource index | `GET /api/v1/resources`, `GET /api/v1/resources/:ovrn` | The authority resource index (ADR-048, `codes.resource.read`, first-party): a page of `common.resource-summary@1` for the manifests (`codes.manifest`, `mfs_`) and releases (`codes.release`, `rel_`) Codes owns, one by its OVRN. `?project=prj_…` is the tenancy boundary (another project's rows never answer), `?kind=` picks one kind, and `?cursor=`/`?limit=` page (opaque cursor, default 100, max 1000). Guarded with openvibe-contracts `requireCapability`; RFC 9457 problems (`resources.bad_query`, `resources.unknown_resource`). |
| Policy | `/policy/*` | Proposal process and the decision record; compatibility and deprecation policy rendered from ADR-002 and ADR-016 as published; licensing read from package metadata; transparency (what Codes stores and does not); governance — code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), [contributor ladder](docs/governance/contributor-ladder.md) and [moderation policy](docs/governance/moderation.md) — rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. |
--| API | `/api/v1/*` | Public release reads (`codes.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. |
++| API | `/api/v1/*` | Public release reads (`codes.release.read`: no token needed; a presented token must hold it), manifest validation, docs versions; release management by an app with its own token (`codes.release.manage`); Codes' own resource index (`codes.resource.read`, first-party). Guarded with openvibe-contracts `requireCapability`. RFC 9457 problems. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (db and docs required; Network, JWKS, OAuth client and events relay reported as optional checks). `/metrics` answers loopback callers only. |
| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo for search and AI crawlers. robots.txt keeps every disallow (the portal, staff, sign-in and the API are not crawlable) and names the sitemap; the sitemap lists the fixed public pages with STATUS.json's content date and each public app and published release with its real publish time (never "today"); llms.txt maps the site; llms-full.txt lists every fixed public page the sitemap does, one title and one line of text each. |
@@@ -50,7 +52,7 @@ Everything is server-rendered and usable without JavaScript. The only scripts of
- **OpenVibe.Events** — the outbox relay publishes `codes.app.*` with Codes' service token (`events.event.publish`); the Events playground calls it with the app's token (`events.app.publish`); the project archive pulls the project's app events with a Network export token (`events.app.read`).
- **OpenVibe.Media** — the Media playground uploads with the app's token into the project's namespace; the project archive lists the project's objects, namespaces and download URLs with a Network export token (`media.object.list`, `media.object.read`).
- **PostgreSQL 18 and Valkey 9** (OpenVibe.Host `roles/data/`, ADR-035): every read and write is async through `openvibe-sdk/db`; Valkey holds the per-actor limit counters (optional).
--- **openvibe-contracts** v0.86.0, **openvibe-sdk v0.26.0**, **openvibe-shared v2.9.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
++- **openvibe-contracts** v0.107.0, **openvibe-sdk v0.26.0**, **openvibe-shared v2.9.0** (pinned tag tarballs; the docs show the tag and the package version, and say so when they differ).
No path in Codes sends or accepts a shared loopback key (tested by grep and at runtime).
@@@ -58,7 +60,10 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
Implemented here (the service manifest's `capabilities`): `codes.release.read` (public release reads;
no token needed, a presented token must hold it) and `codes.release.manage` (release management by an
--app with its own token), both guarded with openvibe-contracts `requireCapability`.
++app with its own token), both guarded with openvibe-contracts `requireCapability`; and
++`codes.resource.read` (the authority resource index, ADR-048: `GET /api/v1/resources` and
++`GET /api/v1/resources/:ovrn`), first-party — the capability is served here, so a caller presents a
++service token holding it.
Called elsewhere: as the service principal `codes`, `events.event.publish` at Events (the outbox relay);
as the signed-in person, with their own Network token, Network's `/api/v1/projects`; as the app, with
@@@ -71,7 +76,7 @@ In Network (`server/identity/principals.js` / `server/db/database.js`, as for co
- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.
- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
--The Codes service manifest, `codes.release.manage|read` and `codes.app-manifest@1` are released in openvibe-contracts (tag v0.27.0; this repository pins v0.86.0); the CI contracts check is blocking.
++The Codes service manifest, `codes.release.manage|read`, `codes.resource.read` (planned until the contracts follow-up flips it after this ships) and `codes.app-manifest@1` are released in openvibe-contracts (this repository pins v0.107.0); the CI contracts check is blocking.
For the playgrounds to succeed end to end (not Codes' code; configuration elsewhere): Network `DEV_SANDBOX_AUDIENCES` including `openvibe.media` and `openvibe.events`, a staff-set allowance containing `media.object.upload` and `events.app.publish` for the project, a Media tenant keyed by the project id, and OpenVibe.Events serving `events.app.publish` for app tokens. On 2026-09-23 these were in place on production: a sandbox app uploaded to Media (tenant `prj_…-sandbox`) and published and read app events through public endpoints. That run used curl, not the Codes playgrounds, and it is not yet a committed, repeatable check.
@@@ -150,7 +155,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
```
--Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
++Tests: the crawl artifacts (robots.txt, sitemap.xml, llms.txt and llms-full.txt — the public pages only, llms-full.txt listing every page the sitemap does, and lastmod from STATUS.json, never the clock); ADR-013 trust-tier migration; secrets never persisted or re-displayed; Network errors surfaced honestly (404/403/409/422/503, unreachable, expired session refresh); scope editor offers only grantable capabilities and refuses forged requests before Network sees them; generated docs match the pinned versions (every contract, capability, event type, SDK module); webhook verification, v2 only (tampering, lengths, malformed headers, the ±300 s window both ways, timestamp header mismatch, a v1-only delivery refused, samples without v1, constant-time, the in-browser verifier agreeing); playgrounds refuse without the grant and call nothing; the usage page (owner, admins and staff only, Network not asked for anyone else, filters forwarded as offered, a Network failure shown as it answered); project export (complete, no secrets, audit for admin+ only, never partial) and delete (owner only, Network first, drafts and runs removed, public releases revoked with an event); manifest validation; release lifecycle and events; the authority resource index (auth matrix — no token 401, a token without `codes.resource.read` 403, with it 200 — every page and read valid against `common.resource-list-result@1`/`common.resource-summary@1`, `?project=` never returning another project's rows, `?kind=`, cursor paging, `GET /:ovrn` found and 404, a bad query 400); no internal key anywhere; PKCE sign-in; readiness, release.json, loopback metrics; the released codes manifest matching the code; IndexNow (off without a key, the key file served with a key, a publish pinging the page and the sitemap, a draft never pinging); and the home page's size budgets (`test/perf-budget.test.js`, openvibe-shared/perf-budget).
## Acceptance (must be true before "done")
ddiff --git a/STATUS.json b/STATUS.json
iindex c208590..56caa0c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,9 +7,9 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4900,
"domain": "openvibe.codes",
-- "contracts": "openvibe-contracts v0.97.0",
++ "contracts": "openvibe-contracts v0.107.0",
"packages": [
-- "openvibe-contracts v0.97.0",
++ "openvibe-contracts v0.107.0",
"openvibe-sdk v0.26.0",
"openvibe-shared v2.9.0"
],
@@@ -29,6 +29,7 @@
"App and mod manifest validation; release metadata (draft, publish, deprecate, revoke) with codes.app.* events through the SDK outbox; trust tiers per ADR-013 (unreviewed, reviewed, first-party) as staff-set metadata, older names migrated at boot",
"Policy pages rendered from the published ADRs; licensing read from package metadata; governance pages (code of conduct, contributing, contributor ladder, moderation policy) rendered from the repository's Markdown",
"Release API guarded by openvibe-contracts requireCapability (codes.release.manage, codes.release.read)",
++ "Authority resource index (ADR-048, codes.resource.read, first-party): GET /api/v1/resources lists the validated manifests (codes.manifest, mfs_) and releases (codes.release, rel_) Codes owns as pages of common.resource-summary@1, GET /api/v1/resources/:ovrn reads one by its computed OVRN; ?project=prj_… is the tenancy boundary (another project's rows never answer), ?kind= narrows, keyset cursor paging (default limit 100, max 1000). Guarded with openvibe-contracts requireCapability; every page and summary is validated against the released common.resource-list-result@1 / common.resource-summary@1 in test/resource-index.test.js. The capability is released 'planned' — the contracts follow-up flips it once this is deployed",
"Readiness (/api/ready), /release.json and loopback-only /metrics",
"Coding-harness registry: a validated catalog of harnesses and their agents (capabilities, address, price and limits), a task placement adapter over openvibe-sdk/placement, and a public read API and read-only docs page (GET /api/v1/harnesses, POST /api/v1/harnesses/route, /docs/harnesses); each harness × agent pair is routed as a platform.resource-offer@1 of kind harness with task:<kind> and harness:<flag> capabilities and a platform.harness-offer@1 detail",
"Crawl and machine-readable artifacts built with openvibe-shared/seo: robots.txt (every previous Disallow kept, search and AI crawlers named), sitemap.xml (the fixed public pages with STATUS.json's content date, each public app and published release with its real publish time — never \"today\"), llms.txt and llms-full.txt (every fixed public page with a one-line description), and the home page's WebSite/WebApplication/WebPage JSON-LD; the portal, staff console, sign-in and API are absent from all of them and Disallowed in robots.txt"
@@@ -59,6 +60,7 @@
"policy and governance pages (governance drafts pending owner review)",
"per-actor limits (openvibe-sdk/limits)",
"coding harness catalog, task routing and a read-only docs page",
++ "authority resource index (codes.resource.read) over Codes' manifests and releases, with OVRN reads",
"crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt) and the home page's JSON-LD"
]
}
ddiff --git a/package-lock.json b/package-lock.json
iindex 8e9be4f..06da316 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -16,7 +16,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.97.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
@@@ -883,9 +883,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.97.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.97.0",
-- "integrity": "sha512-HPGBJYx6Dh2kaTg5B41WGhpw38zUdoU1VnWtkcsFkaU4E521uQOJxf6sIaLdHMsahqov7b9k2HKEFCnD9065MQ==",
++ "version": "0.107.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
++ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex b94cd4f..feec03a 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,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.97.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.26.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex 0beb616..8216a8b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -10,6 +10,7 @@
* /projects the signed-in portal over Network's projects API (http/portal.js)
* /releases/:id/* release actions (http/portal.js)
* /api/v1 JSON API (http/api.js)
++ * /api/v1/resources the authority resource index, codes.resource.read (registry/resource-index.js)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
*/
@@@ -38,7 +39,8 @@ const { createDocsRoutes } = require('./http/docs');
const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
--const { createApi } = require('./http/api');
++const { createApi, createCapabilityAccess } = require('./http/api');
++const resourceIndex = require('./registry/resource-index');
const { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
@@@ -154,6 +156,9 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
++ // The authority resource index (ADR-048, codes.resource.read): mounted before /api/v1 so the path is
++ // the index's own, guarded with the same service-token check the JSON API's first-party routes use.
++ app.use('/api/v1/resources', resourceIndex.router({ guard: createCapabilityAccess({ config, keys }).checked(resourceIndex.RESOURCE_READ) }));
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/api.js b/server/http/api.js
iindex ce5879c..cf07151 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -34,6 +34,36 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const MANAGE = 'codes.release.manage';
const READ = 'codes.release.read';
++/**
++ * The service-token capability guard this service's first-party routes use: the key the token names is
++ * looked up first (the SDK's JWKS client), then the synchronous openvibe-contracts requireCapability runs
++ * against it. Exported so the resource index (server/registry/resource-index.js, mounted by server/app.js)
++ * guards codes.resource.read with the same check — never a second auth path.
++ *
++ * const access = createCapabilityAccess({ config, keys });
++ * app.use('/api/v1/resources', resourceIndex.router({ guard: access.checked('codes.resource.read') }));
++ */
++function createCapabilityAccess({ config, keys }) {
++ const guardOpts = { issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };
++ const bearerToken = (req) => String(req.headers.authorization || '').replace(/^Bearer\s+/, '');
++ const loadKey = (req, res, next) => { keys.pemForToken(bearerToken(req)).then((pem) => { req.ovNetworkKey = pem; next(); }, () => next()); };
++ const guardFor = (cap) => (req, res, next) => serviceAuth.requireCapability(cap, { ...guardOpts, getPublicKey: () => req.ovNetworkKey || null })(req, res, next);
++ const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');
++ /**
++ * The chain for a route a token is REQUIRED on: no token is 401 auth.required; a token that does not
++ * hold the capability is 403 capability.denied and an invalid one 401 token.* (requireCapability decides
++ * those two).
++ */
++ const checked = (cap, detail = 'send Authorization: Bearer <service token for audience openvibe.codes>') => {
++ const guard = guardFor(cap); // built once, at mount: an unknown capability fails the boot, not a request
++ return [
++ loadKey,
++ (req, res, next) => (bearer(req) ? guard(req, res, next) : http.sendProblem(res, 401, 'auth.required', { detail, ctx: req.ov })),
++ ];
++ };
++ return { loadKey, guardFor, bearer, checked };
++}
++
function createApi(ctx) {
const { config, docs, releases, trust, keys, actorLimits, harnesses } = ctx;
const r = asyncRouter();
@@@ -81,18 +111,13 @@ function createApi(ctx) {
});
// Guards: the key the token names is looked up first (the SDK's JWKS client), then the synchronous contracts guard
-- // runs against it.
-- const guardOpts = { issuer: config.networkIssuer, audience: 'openvibe.codes', acceptSandbox: true };
-- const bearerToken = (req) => String(req.headers.authorization || '').replace(/^Bearer\s+/, '');
-- const loadKey = (req, res, next) => { keys.pemForToken(bearerToken(req)).then((pem) => { req.ovNetworkKey = pem; next(); }, () => next()); };
-- const guardFor = (cap) => (req, res, next) => serviceAuth.requireCapability(cap, { ...guardOpts, getPublicKey: () => req.ovNetworkKey || null })(req, res, next);
-- const manageGuard = guardFor('codes.release.manage');
++ // runs against it (createCapabilityAccess — the same check server/app.js guards the resource index with).
++ const { loadKey, guardFor, bearer, checked } = createCapabilityAccess({ config, keys });
const readGuard = guardFor('codes.release.read');
-- const bearer = (req) => String(req.headers.authorization || '').startsWith('Bearer ');
// Public reads: anonymous callers pass; a presented token must be valid and hold codes.release.read.
const readAccess = [loadKey, (req, res, next) => (bearer(req) ? readGuard(req, res, next) : next())];
-- const manageAccess = [loadKey, (req, res, next) => (bearer(req) ? manageGuard(req, res, next)
-- : problem(req, res, 401, 'auth.required', 'send Authorization: Bearer <app token for audience openvibe.codes>'))];
++ // Managing releases needs a token: an app's own, holding codes.release.manage.
++ const manageAccess = checked('codes.release.manage', 'send Authorization: Bearer <app token for audience openvibe.codes>');
r.get('/apps/:app/releases', ...readAccess, reads, async (req, res) => {
if (!APP_RE.test(req.params.app)) return problem(req, res, 404, 'app.not_found', 'not an app id');
@@@ -177,4 +202,4 @@ function createApi(ctx) {
return r;
}
--module.exports = { createApi, MANAGE, READ };
++module.exports = { createApi, createCapabilityAccess, MANAGE, READ };
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..0e4114a
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,193 @@
++'use strict';
++/**
++ * Codes' authority resource index (ADR-048 section 3; capability codes.resource.read):
++ *
++ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
++ * GET /api/v1/resources/:ovrn → common.resource-summary@1
++ *
++ * It pages the resources OpenVibe.Codes owns — its validated app and mod manifests (codes.manifest, mfs_)
++ * and its releases (codes.release, rel_) — as common.resource-summary@1, the shape OpenVibe.Services fans
++ * out over and merges (openvibe-sdk/resources' createResourceIndex). Trust tiers are per-app metadata and
++ * playground runs are audit records, neither a resource; Codes hosts no repositories, so no codes.repo
++ * kind (ADR-048's catalog).
++ *
++ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's resources
++ * answer; a resource of another project is never returned. Without it the first-party caller (the
++ * capability is first-party) sees every Codes-owned resource, which is what an authority-wide fan-out
++ * needs.
++ *
++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
++ * formatter, so it is present exactly when the resource is a nameable resource. Both kinds here are
++ * nameable (ADR-048: codes.manifest is mfs_, codes.release is rel_): a manifest is named
++ * ovrn:codes:<prj_…>:manifest/mfs_…, a release ovrn:codes:<prj_…>:release/rel_…. That is also what
++ * GET /api/v1/resources/:ovrn can read: only a resource whose computed ovrn equals the one asked for
++ * answers.
++ *
++ * Field mapping (`only fields the schema allows`; common.resource-summary@1):
++ * codes.manifest — name: the name in the stored manifest body (both manifest schemas require `name`;
++ * the row is written only after it validated), state: 'valid' (a manifest row is immutable — it is
++ * written once, after validation, and has no lifecycle of its own). owner: created_by when it is a
++ * user:usr_… subject; an app:app_… actor has none.
++ * codes.release — name: releases.name, state: releases.status (draft|published|deprecated|revoked).
++ * owner: created_by likewise.
++ */
++const contracts = require('openvibe-contracts');
++const { asyncRouter } = require('../http/router');
++
++const SERVICE = 'codes';
++const RESOURCE_READ = 'codes.resource.read';
++const MANIFEST_KIND = 'codes.manifest';
++const RELEASE_KIND = 'codes.release';
++const KINDS = [MANIFEST_KIND, RELEASE_KIND];
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** The stored subject (user:usr_… / app:app_…) as an identity.subject-ref, or null. Only a person owns. */
++function userOwner(subject) {
++ const ref = contracts.ids.parseSubject(subject);
++ return ref && ref.type === 'user' ? ref : null;
++}
++
++/** A manifest body's own `name` (both schemas require it), or null when the row holds none. */
++function manifestName(body) {
++ try { const name = JSON.parse(body).name; return typeof name === 'string' && name ? name : null; } catch { return null; }
++}
++
++/** common.resource-summary@1 for a manifests row. A manifest's project is its tenancy boundary. */
++function manifestSummary(m) {
++ const owner = userOwner(m.created_by);
++ const name = manifestName(m.body);
++ return {
++ id: m.id, kind: MANIFEST_KIND, service: SERVICE, project_id: m.project_id,
++ ...(owner ? { owner } : {}),
++ ...(name ? { name } : {}),
++ state: 'valid', created_at: m.created_at,
++ };
++}
++
++/** common.resource-summary@1 for a releases row; its state is the release's own status. */
++function releaseSummary(r) {
++ const owner = userOwner(r.created_by);
++ return {
++ id: r.id, kind: RELEASE_KIND, service: SERVICE, project_id: r.project_id,
++ ...(owner ? { owner } : {}),
++ name: r.name, state: r.status, created_at: r.created_at,
++ };
++}
++
++/** The summary's ovrn: both kinds are nameable, so contracts.resources.nameOf always composes one. */
++function ovrnOf(summary) {
++ return contracts.resources.nameOf(summary);
++}
++
++/** The summary with its ovrn attached when it has one. */
++function named(summary) {
++ const ovrn = ovrnOf(summary);
++ return ovrn ? { ...summary, ovrn } : summary;
++}
++
++/** A stable (kind, id) ordering, so a cursor can be a position in it. */
++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
++
++/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
++// Scope: codes.resource.read is first-party (resourceConstraints none), so its holder sees every project and
++// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
++// principal's grants here instead of trusting the query.
++async function collect(db, { project = null, kind = null } = {}) {
++ const out = [];
++ if (!kind || kind === MANIFEST_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests ORDER BY id').all();
++ for (const m of rows) out.push(named(manifestSummary(m)));
++ }
++ if (!kind || kind === RELEASE_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases ORDER BY id').all();
++ for (const r of rows) out.push(named(releaseSummary(r)));
++ }
++ return out.sort(order);
++}
++
++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
++function decodeCursor(raw) {
++ let v;
++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
++}
++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
++
++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
++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;
++ 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 };
++}
++
++/** The Codes store (the app's own database), as server/app.js keeps it. */
++const dbOf = (req) => req.app.locals.ctx.store.db;
++
++/**
++ * router({ guard }) — guard is the middleware chain that enforces codes.resource.read
++ * (server/http/api.js's createCapabilityAccess(...).checked('codes.resource.read')), mounted at
++ * /api/v1/resources by server/app.js.
++ */
++function router({ guard }) {
++ const open = (res) => res.set('Cache-Control', 'private, max-age=60');
++ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });
++ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });
++
++ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
++ async function page(req, res) {
++ const f = filtersOf(req.query);
++ if (f.error) return bad(res, f.error);
++ const all = await collect(dbOf(req), f);
++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
++ const resources = rest.slice(0, f.limit);
++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
++ open(res).json({ resources, next_cursor });
++ }
++
++ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
++ async function one(req, res) {
++ const name = String(req.params.ovrn);
++ const parsed = contracts.resources.parse(name);
++ const db = dbOf(req);
++ let summary = null;
++ if (parsed && parsed.service === SERVICE) {
++ if (parsed.type === 'manifest') {
++ const row = await db.prepare('SELECT id, project_id, body, created_by, created_at FROM manifests WHERE id = ?').get(parsed.id);
++ if (row) summary = named(manifestSummary(row));
++ } else if (parsed.type === 'release') {
++ const row = await db.prepare('SELECT id, project_id, name, status, created_by, created_at FROM releases WHERE id = ?').get(parsed.id);
++ if (row) summary = named(releaseSummary(row));
++ }
++ }
++ if (!summary || summary.ovrn !== name) return unknown(res, name);
++ open(res).json(summary);
++ }
++
++ const r = asyncRouter();
++ r.get('/', ...guard, page);
++ r.get('/:ovrn', ...guard, one);
++ return r;
++}
++
++module.exports = {
++ router, SERVICE, RESOURCE_READ, KINDS, MANIFEST_KIND, RELEASE_KIND, DEFAULT_LIMIT, MAX_LIMIT,
++ manifestSummary, releaseSummary, ovrnOf, collect, encodeCursor,
++};
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 084032e..3d69653 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -1,8 +1,9 @@
'use strict';
/**
-- * The released contracts (openvibe-contracts tag v0.33.0) describe what the code does: the codes
++ * The released contracts (the pinned openvibe-contracts tag) describe what the code does: the codes
* service manifest's events are exactly what the outbox can produce, its capabilities are the ones
-- * the API guards with requireCapability, and the release API answers with the contracts' problems.
++ * the API and the resource index guard with requireCapability, and the release API answers with the
++ * contracts' problems.
*/
const assert = require('assert');
const fs = require('fs');
@@@ -10,6 +11,7 @@ const path = require('path');
const contracts = require('openvibe-contracts');
const { EVENT_TYPES } = require('../server/events/outbox');
const { MANAGE, READ } = require('../server/http/api');
++const { RESOURCE_READ } = require('../server/registry/resource-index');
const { check, done } = require('./helpers/boot');
(async () => {
@@@ -26,18 +28,26 @@ const { check, done } = require('./helpers/boot');
});
await check('its capabilities exist, are owned by codes, and are the ones the API guards', async () => {
-- assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ].sort());
-- for (const id of manifest.capabilities) {
++ // codes.resource.read (ADR-048, the resource index) is released too. It stays 'planned' until the
++ // contracts follow-up flips it after this ships, so only the two release capabilities are active.
++ assert.deepStrictEqual([...manifest.capabilities].sort(), [MANAGE, READ, RESOURCE_READ].sort());
++ for (const id of [MANAGE, READ]) {
const c = contracts.capabilities.get(id);
assert.ok(c, id);
assert.strictEqual(c.owner, 'codes');
assert.strictEqual(c.status, 'active');
}
++ const resource = contracts.capabilities.get(RESOURCE_READ);
++ assert.ok(resource, RESOURCE_READ);
++ assert.strictEqual(resource.owner, 'codes');
const api = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
-- // The guards are serviceAuth.requireCapability built per request with the key the token names (guardFor).
++ // The guards are serviceAuth.requireCapability built per request with the key the token names; api.js's
++ // createCapabilityAccess is the one factory the release routes and the resource index both guard with.
assert.match(api, /serviceAuth\.requireCapability\(cap,/);
-- assert.match(api, /guardFor\('codes\.release\.manage'\)/);
++ assert.match(api, /checked\('codes\.release\.manage'/);
assert.match(api, /guardFor\('codes\.release\.read'\)/);
++ const index = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
++ assert.match(index, /RESOURCE_READ = 'codes\.resource\.read'/, 'the resource index guards codes.resource.read');
});
await check('no proposal left behind: everything proposed is released', async () => {
ddiff --git a/test/docs.test.js b/test/docs.test.js
iindex 5fc76c6..86f0a8f 100644
---- a/test/docs.test.js
++++ b/test/docs.test.js
@@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
await check('package.json pins the contracts, SDK and shared tags that are installed', async () => {
const tag = (dep) => (pkg.dependencies[dep].match(/refs\/tags\/(v[0-9.]+)$/) || [])[1];
-- assert.strictEqual(tag('openvibe-contracts'), 'v0.97.0');
++ assert.strictEqual(tag('openvibe-contracts'), 'v0.107.0');
assert.strictEqual(tag('openvibe-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.26.0');
-- // The contracts tag v0.97.0 carries the released codes manifest and codes.app-manifest@1.
++ // The contracts tag v0.107.0 carries the released codes manifest, codes.app-manifest@1 and codes.resource.read.
assert.ok(contracts.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
@@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
assert.strictEqual(r.status, 200, p);
assert.ok(r.text.includes(`openvibe-contracts v${contractsVersion}`), `${p} names the installed contracts version`);
-- assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.97.0'), `${p} links the pinned tag`);
-- if (contractsVersion !== '0.97.0') assert.ok(r.text.includes('(tag v0.97.0)'), `${p} says the tag differs from the package version`);
++ assert.ok(r.text.includes('OpenVibe.Contracts/tree/v0.107.0'), `${p} links the pinned tag`);
++ if (contractsVersion !== '0.107.0') assert.ok(r.text.includes('(tag v0.107.0)'), `${p} says the tag differs from the package version`);
assert.ok(r.text.includes(`openvibe-sdk v${sdkPkg.version}`), `${p} names the SDK version`);
}
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
-- assert.strictEqual(v.contracts_tag, 'v0.97.0');
++ assert.strictEqual(v.contracts_tag, 'v0.107.0');
assert.strictEqual(v.sdk, sdkPkg.version);
});
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..59237f1
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,175 @@
++'use strict';
++// Codes' authority resource index (ADR-048 section 3; capability codes.resource.read):
++// GET /api/v1/resources pages common.resource-summary@1 for the resources Codes owns — its validated
++// manifests (codes.manifest, mfs_) and its releases (codes.release, rel_) — and GET /api/v1/resources/:ovrn
++// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
++// boundary, so a resource of another project is never returned. Both kinds are nameable, so every summary
++// carries an ovrn; every summary carries the project it was created in. The guard is the service-token
++// capability check (no token 401, a token without the capability 403, with it 200), and every page and
++// every read is validated against the released common.resource-list-result@1 / common.resource-summary@1.
++const assert = require('assert');
++const crypto = require('crypto');
++const { validate, ids, serviceAuth } = require('openvibe-contracts');
++const resourceIndex = require('../server/registry/resource-index');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const db = t.ctx.store.db;
++ const at = '2026-10-01T00:00:00Z';
++
++ // ── Fixtures: two projects, their manifests (user- and app-created) and releases in three states ──
++ const prjA = ids.newId('project'); const prjB = ids.newId('project');
++ const usrA = ids.newId('user');
++ const appA = ids.newId('app'); const appB = ids.newId('app');
++ const subjA = ids.newId('app'); const subjB = ids.newId('app');
++ const mfsA1 = ids.newId('manifest'); const mfsA2 = ids.newId('manifest'); const mfsB = ids.newId('manifest');
++ const relA1 = ids.newId('release'); const relA2 = ids.newId('release'); const relB = ids.newId('release');
++ const usrB = ids.newId('user');
++
++ const insertManifest = db.prepare('INSERT INTO manifests (id, kind, app_id, project_id, subject_id, version, body, contracts_version, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)');
++ const insertRelease = db.prepare("INSERT INTO releases (id, app_id, project_id, environment, kind, subject_id, name, version, manifest_id, status, compatibility, notes, created_by, created_at) VALUES (?, ?, ?, 'sandbox', ?, ?, ?, ?, ?, ?, '{}', '', ?, ?)");
++ const body = (subject, name, version) => JSON.stringify({ id: subject, name, version });
++ await insertManifest.run(mfsA1, 'app', appA, prjA, subjA, '1.0.0', body(subjA, 'Notes app', '1.0.0'), '0.107.0', `user:${usrA}`, at);
++ await insertManifest.run(mfsA2, 'app', appA, prjA, subjA, '1.1.0', body(subjA, 'Notes app', '1.1.0'), '0.107.0', `app:${appA}`, at);
++ await insertManifest.run(mfsB, 'app', appB, prjB, subjB, '1.0.0', body(subjB, 'Other app', '1.0.0'), '0.107.0', `user:${usrB}`, at);
++ await insertRelease.run(relA1, appA, prjA, 'app', subjA, 'Notes app', '1.0.0', mfsA1, 'published', `user:${usrA}`, at);
++ await insertRelease.run(relA2, appA, prjA, 'app', subjA, 'Notes app', '1.1.0', mfsA2, 'draft', `app:${appA}`, at);
++ await insertRelease.run(relB, appB, prjB, 'app', subjB, 'Other app', '1.0.0', mfsB, 'revoked', `user:${usrB}`, at);
++
++ const expected = [
++ { id: mfsA1, kind: resourceIndex.MANIFEST_KIND }, { id: mfsA2, kind: resourceIndex.MANIFEST_KIND }, { id: mfsB, kind: resourceIndex.MANIFEST_KIND },
++ { id: relA1, kind: resourceIndex.RELEASE_KIND }, { id: relA2, kind: resourceIndex.RELEASE_KIND }, { id: relB, kind: resourceIndex.RELEASE_KIND },
++ ].sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
++ const ovrnOfManifest = (project, id) => `ovrn:codes:${project}:manifest/${id}`;
++ const ovrnOfRelease = (project, id) => `ovrn:codes:${project}:release/${id}`;
++
++ // A first-party service token signed by the Network mock, holding the capabilities asked for.
++ const token = (cap, { aud = 'openvibe.codes', sub = 'svc:services' } = {}) => {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: [aud], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem);
++ };
++ const get = (path, headers) => t.get(path, headers ? { headers } : {});
++ const auth = { authorization: `Bearer ${token([resourceIndex.RESOURCE_READ])}` };
++
++ await check('no token 401, a token without codes.resource.read 403, wrong audience 401, with it 200', async () => {
++ const none = await get('/api/v1/resources');
++ assert.deepStrictEqual([none.status, none.json().code], [401, 'auth.required']);
++ assert.ok(/^application\/problem\+json/.test(none.headers.get('content-type')));
++ const without = await get('/api/v1/resources', { authorization: `Bearer ${token(['codes.release.read'])}` });
++ assert.deepStrictEqual([without.status, without.json().code], [403, 'capability.denied']);
++ const wrongAud = await get('/api/v1/resources', { authorization: `Bearer ${token([resourceIndex.RESOURCE_READ], { aud: 'openvibe.media' })}` });
++ assert.deepStrictEqual([wrongAud.status, wrongAud.json().code], [401, 'token.wrong_audience']);
++ const forged = await get('/api/v1/resources', { authorization: 'Bearer not.a.jwt' });
++ assert.strictEqual(forged.status, 401);
++ assert.strictEqual((await get('/api/v1/resources', auth)).status, 200);
++ // The guard is on the single read too.
++ assert.strictEqual((await get(`/api/v1/resources/${encodeURIComponent(ovrnOfRelease(prjA, relA1))}`)).status, 401);
++ });
++
++ const aPage = async (q = '', headers = auth) => {
++ const y = await get(`/api/v1/resources${q}`, headers);
++ assert.strictEqual(y.status, 200, `${q}: ${y.text.slice(0, 300)}`);
++ assert.ok(validate('common.resource-list-result@1', y.json()).valid, JSON.stringify(validate('common.resource-list-result@1', y.json()).errors));
++ return y;
++ };
++ const idsOf = async (q, headers = auth) => (await aPage(q, headers)).json().resources.map((r) => r.id);
++
++ await check('the page is common.resource-list-result@1: only its fields, every summary valid and sorted', async () => {
++ const y = await aPage();
++ assert.strictEqual(y.headers.get('cache-control'), 'private, max-age=60');
++ assert.deepStrictEqual(Object.keys(y.json()).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
++ y.json().resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid, `${s.id}: ${JSON.stringify(validate('common.resource-summary@1', s).errors)}`));
++ assert.strictEqual(y.json().next_cursor, null, 'one page holds the whole index');
++ assert.deepStrictEqual(y.json().resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'both kinds, sorted by (kind, id)');
++ assert.deepStrictEqual([...new Set(y.json().resources.map((r) => r.service))], ['codes']);
++ assert.deepStrictEqual([...new Set(y.json().resources.map((r) => r.project_id))].sort(), [prjA, prjB].sort(), 'every summary carries the project it was created in');
++ });
++
++ await check('every summary carries its computed ovrn, name, state and owner', async () => {
++ const byId = new Map((await aPage()).json().resources.map((r) => [r.id, r]));
++ assert.strictEqual(byId.get(mfsA1).ovrn, ovrnOfManifest(prjA, mfsA1));
++ assert.strictEqual(byId.get(mfsB).ovrn, ovrnOfManifest(prjB, mfsB));
++ assert.strictEqual(byId.get(relA1).ovrn, ovrnOfRelease(prjA, relA1));
++ assert.strictEqual(byId.get(relB).ovrn, ovrnOfRelease(prjB, relB));
++ assert.strictEqual(byId.get(mfsA1).name, 'Notes app', 'a manifest is named by its stored body');
++ assert.strictEqual(byId.get(relA1).name, 'Notes app', "a release is named by the release row's own name");
++ assert.deepStrictEqual(byId.get(mfsA1).owner, { type: 'user', id: usrA }, 'a user-created resource names its owner');
++ assert.ok(!('owner' in byId.get(mfsA2)), 'an app-created resource has no person owner');
++ assert.strictEqual(byId.get(mfsA1).state, 'valid', 'a stored manifest is a validated, immutable row');
++ assert.strictEqual(byId.get(relA1).state, 'published', 'a release state is its status');
++ assert.strictEqual(byId.get(relA2).state, 'draft');
++ assert.strictEqual(byId.get(relB).state, 'revoked');
++ });
++
++ await check('?project= is the tenancy boundary: another project\'s rows are never returned', async () => {
++ assert.deepStrictEqual((await idsOf(`?project=${prjA}`)).sort(), [mfsA1, mfsA2, relA1, relA2].sort());
++ assert.deepStrictEqual((await idsOf(`?project=${prjB}`)).sort(), [mfsB, relB].sort());
++ assert.deepStrictEqual(await idsOf(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
++ for (const s of (await aPage(`?project=${prjA}`)).json().resources) assert.strictEqual(s.project_id, prjA);
++ });
++
++ await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
++ assert.deepStrictEqual((await idsOf('?kind=codes.manifest')).sort(), [mfsA1, mfsA2, mfsB].sort());
++ assert.deepStrictEqual((await idsOf('?kind=codes.release')).sort(), [relA1, relA2, relB].sort());
++ assert.deepStrictEqual((await idsOf(`?project=${prjA}&kind=codes.release`)).sort(), [relA1, relA2].sort(), 'kind narrows within the project');
++ assert.deepStrictEqual(await idsOf('?kind=codes.unknown'), []);
++ });
++
++ await check('an opaque cursor pages the whole index: no duplicates, none skipped, order preserved', async () => {
++ const seen = [];
++ let cursor = null;
++ let pages = 0;
++ for (;;) {
++ const y = await aPage(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
++ seen.push(...y.json().resources.map((r) => r.id));
++ if (y.json().next_cursor === null) { assert.strictEqual(seen.length, expected.length, 'the cursor chain ends at the end'); break; }
++ assert.ok(typeof y.json().next_cursor === 'string' && y.json().next_cursor !== '');
++ cursor = y.json().next_cursor;
++ if (++pages > 50) assert.fail('the cursor chain never ended');
++ }
++ assert.deepStrictEqual(seen, expected.map((r) => r.id));
++ assert.deepStrictEqual(await idsOf('?limit=1000'), expected.map((r) => r.id), 'the maximum limit still pages one result');
++ });
++
++ await check('GET /:ovrn reads one resource by its computed name', async () => {
++ const listed = new Map((await aPage()).json().resources.map((r) => [r.id, r]));
++ for (const [why, name, id] of [['a manifest', ovrnOfManifest(prjA, mfsA1), mfsA1], ['a release', ovrnOfRelease(prjA, relA1), relA1]]) {
++ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
++ assert.strictEqual(y.status, 200, `${why}: ${y.text.slice(0, 300)}`);
++ assert.strictEqual(y.headers.get('cache-control'), 'private, max-age=60');
++ assert.ok(validate('common.resource-summary@1', y.json()).valid);
++ assert.deepStrictEqual(y.json(), listed.get(id), `${why}: the same summary the list answers`);
++ }
++ });
++
++ await check('an unknown or non-matching OVRN is 404 resources.unknown_resource', async () => {
++ const missing = [
++ ['an unknown manifest', ovrnOfManifest(prjA, ids.newId('manifest'))],
++ ['an unknown release', ovrnOfRelease(prjA, ids.newId('release'))],
++ ["another project's resource", ovrnOfRelease(prjA, relB)],
++ ['a type this service does not hold', `ovrn:codes:${prjA}:object/${mfsA1}`],
++ ['an id of the other kind', ovrnOfRelease(prjA, mfsA1)],
++ ['a non-OVRN', 'nope'],
++ ];
++ for (const [why, name] of missing) {
++ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
++ assert.strictEqual(y.status, 404, why);
++ assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
++ assert.strictEqual(y.json().code, 'resources.unknown_resource', why);
++ }
++ });
++
++ await check('a query that cannot be honoured is 400 resources.bad_query', async () => {
++ const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
++ for (const [q, why] of bad) {
++ const y = await get(`/api/v1/resources${q}`, auth);
++ assert.strictEqual(y.status, 400, why);
++ assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
++ assert.strictEqual(y.json().code, 'resources.bad_query', why);
++ }
++ });
++
++ await t.close();
++ done();
++})();