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)
Changes
@@@ -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 rImplemented 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:4900npx 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")@@@ -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"]}@@@ -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",@@@ -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"@@@ -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 }));@@@ -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 };nnew file mode 100644@@@ -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,++};@@@ -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 () => {@@@ -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);});nnew file mode 100644@@@ -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();++})();
@@ -14,6 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
@@ -14,6 +14,7 @@ A place where a developer outside the network goes from an OpenVibe account to a
## Owns
## Owns
- **Release metadata** keyed to Network app ids: validated app and mod manifests, releases (`draft → published → deprecated → revoked`), an append-only release log.
- **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.
- **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).
- **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.
- 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
@@ -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`). |
| 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. |
| 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. |
| 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. |
| 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. |
| 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. |
| 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
@@ -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.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`).
- **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).
- **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).
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
@@ -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;
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
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);
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
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
@@ -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`.
- OAuth client `codes`, name `OpenVibe.Codes`, redirect URI `https://openvibe.codes/auth/callback`.
- Service grant `['codes', 'events.event.publish', 'openvibe.events', []]` (the outbox relay).
- 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.
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
@@ -150,7 +155,7 @@ fnm exec --using=22.22.1 npm run dev # http://localhost:4900
npx openvibe-contracts-check --service codes --src server
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")
## Acceptance (must be true before "done")
@@ -7,9 +7,9 @@
@@ -7,9 +7,9 @@
"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": 4900,
"port": 4900,
"domain": "openvibe.codes",
"domain": "openvibe.codes",
- "contracts": "openvibe-contracts v0.97.0",
+ "contracts": "openvibe-contracts v0.107.0",
"packages": [
"packages": [
- "openvibe-contracts v0.97.0",
+ "openvibe-contracts v0.107.0",
"openvibe-sdk v0.26.0",
"openvibe-sdk v0.26.0",
"openvibe-shared v2.9.0"
"openvibe-shared v2.9.0"
],
],
@@ -29,6 +29,7 @@
@@ -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",
"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",
"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)",
"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",
"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",
"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"
"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 @@
@@ -59,6 +60,7 @@
"policy and governance pages (governance drafts pending owner review)",
"policy and governance pages (governance drafts pending owner review)",
"per-actor limits (openvibe-sdk/limits)",
"per-actor limits (openvibe-sdk/limits)",
"coding harness catalog, task routing and a read-only docs page",
"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"
"crawl artifacts (robots.txt, sitemap.xml, llms.txt, llms-full.txt) and the home page's JSON-LD"
]
]
}
}
@@ -16,7 +16,7 @@
@@ -16,7 +16,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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -883,9 +883,9 @@
@@ -883,9 +883,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,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.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.9.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -10,6 +10,7 @@
@@ -10,6 +10,7 @@
* /projects the signed-in portal over Network's projects API (http/portal.js)
* /projects the signed-in portal over Network's projects API (http/portal.js)
* /releases/:id/* release actions (http/portal.js)
* /releases/:id/* release actions (http/portal.js)
* /api/v1 JSON API (http/api.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)
* /auth/* Network SSO with PKCE (auth/sso.js)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
* /api/health, /api/ready, /release.json, /metrics (loopback only)
*/
*/
@@ -38,7 +39,8 @@ const { createDocsRoutes } = require('./http/docs');
@@ -38,7 +39,8 @@ const { createDocsRoutes } = require('./http/docs');
const { createToolRoutes } = require('./http/tools');
const { createToolRoutes } = require('./http/tools');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createPortalRoutes, createReleaseActionRoutes } = require('./http/portal');
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 { createCodesReadiness } = require('./observability');
const { createActorLimits } = require('./http/actor-limits');
const { createActorLimits } = require('./http/actor-limits');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
@@ -154,6 +156,9 @@ async function createApp(opts = {}) {
@@ -154,6 +156,9 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── 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/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 }));
@@ -34,6 +34,36 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -34,6 +34,36 @@ const REL_RE = /^rel_[0-9A-HJKMNP-TV-Z]{26}$/;
const MANAGE = 'codes.release.manage';
const MANAGE = 'codes.release.manage';
const READ = 'codes.release.read';
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) {
function createApi(ctx) {
const { config, docs, releases, trust, keys, actorLimits, harnesses } = ctx;
const { config, docs, releases, trust, keys, actorLimits, harnesses } = ctx;
const r = asyncRouter();
const r = asyncRouter();
@@ -81,18 +111,13 @@ function createApi(ctx) {
@@ -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
// 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 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.
// 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 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) => {
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');
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) {
@@ -177,4 +202,4 @@ function createApi(ctx) {
return r;
return r;
}
}
-module.exports = { createApi, MANAGE, READ };
+module.exports = { createApi, createCapabilityAccess, MANAGE, READ };
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -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,
+};
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'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
* 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 assert = require('assert');
const fs = require('fs');
const fs = require('fs');
@@ -10,6 +11,7 @@ const path = require('path');
@@ -10,6 +11,7 @@ const path = require('path');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { EVENT_TYPES } = require('../server/events/outbox');
const { EVENT_TYPES } = require('../server/events/outbox');
const { MANAGE, READ } = require('../server/http/api');
const { MANAGE, READ } = require('../server/http/api');
+const { RESOURCE_READ } = require('../server/registry/resource-index');
const { check, done } = require('./helpers/boot');
const { check, done } = require('./helpers/boot');
(async () => {
(async () => {
@@ -26,18 +28,26 @@ const { check, done } = require('./helpers/boot');
@@ -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 () => {
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);
const c = contracts.capabilities.get(id);
assert.ok(c, id);
assert.ok(c, id);
assert.strictEqual(c.owner, 'codes');
assert.strictEqual(c.owner, 'codes');
assert.strictEqual(c.status, 'active');
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');
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, /serviceAuth\.requireCapability\(cap,/);
- assert.match(api, /guardFor\('codes\.release\.manage'\)/);
+ assert.match(api, /checked\('codes\.release\.manage'/);
assert.match(api, /guardFor\('codes\.release\.read'\)/);
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 () => {
await check('no proposal left behind: everything proposed is released', async () => {
@@ -19,11 +19,11 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -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 () => {
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];
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-sdk'), `v${sdkPkg.version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(tag('openvibe-shared'), `v${require('openvibe-shared/package.json').version}`);
assert.strictEqual(sdkPkg.version, '0.26.0');
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.services.get('codes') && contracts.services.get('codes').status === 'alpha');
assert.ok(contracts.resolve('codes.app-manifest@1'));
assert.ok(contracts.resolve('codes.app-manifest@1'));
});
});
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
@@ -33,13 +33,13 @@ const sdkPkg = require('openvibe-sdk/package.json');
const r = await t.get(p);
const r = await t.get(p);
assert.strictEqual(r.status, 200, 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 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`);
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();
const v = (await t.get('/api/v1/docs/versions')).json();
assert.strictEqual(v.contracts, contractsVersion);
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);
assert.strictEqual(v.sdk, sdkPkg.version);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,175 @@
@@ -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();
+})();