openvibers/openvibe.host Commits

Host: the resource index (GET /api/v1/resources, host.site/deploy/domain) under host.resource.read; contracts v0.107.0 (T13 step 8). (#28)

OP OpenVibers committed 7 Oct 2026 · 825db9dc92 · 10 files, +464 −40

* Host: the resource index (GET /api/v1/resources, host.site/deploy/domain) under host.resource.read; contracts v0.107.0 (T13 step 8).

**One line per change**

- `package.json:36` — pinned `openvibe-contracts` to the codeload tag `v0.107.0`; `package-lock.json:20,1505` — version/resolved/integrity updated by `npm install --package-lock-only`-equivalent (`npm install --ignore-scripts --no-audit --no-fund` through `child_process`, since the shell refuses npm directly); `node_modules/openvibe-contracts` now 0.107.0.
- `server/registry/resource-index.js:1-201` (new) — the router: `SERVICE='host'`, kinds `host.site`/`host.deploy`/`host.domain`; `siteSummary`/`deploySummary`/`domainSummary` map `host_sites`/`host_deploys`/`host_domains` to `common.resource-summary@1` (id, kind, service, project_id, `owner` from `created_by` when it is a `usr_` subject, name — site label / domain hostname, no name for a deploy — state from the row's status, `created_at` as ISO; nothing else); `ovrnOf` = `contracts.resources.nameOf`, `named` attaches it; `collect` (project scope + kind filter, sorted by `(kind, id)`), opaque base64url `(kind,id)` cursor (`encodeCursor`/`decodeCursor`/`afterCursor`), `filtersOf` (400 on a bad `project`/`limit`/`cursor`, unknown kind = empty page), `router({guard})` with `GET /` → `common.resource-list-result@1` and `GET /:ovrn` (parse, look up by type, 404 unless the computed ovrn equals the name); `Cache-Control: private, max-age=60`; 400 `resources.bad_query`, 404 `resources.unknown_resource`; handlers wrapped in the repo's `run()` so a rejection reaches the app error handler. `firstParty` refuses a person (403 `capability.denied`) and anonymous (401 `auth.required`) before the guard.
- `server/app.js:44,280-285` — mounts the router at `/api/v1/resources` before the `/api/v1` router, with the repo's own `viewers.middleware('api')` and `guard('host.resource.read')` from `auth/viewer.js` (no new auth invented).
- `server/auth/capabilities.js:10,22` — `CAPABILITIES.RESOURCE_READ = 'host.resource.read'`.
- `test/resource-index.test.js:1-193` (new) — Network-shaped suite on the Stage B harness.
- `test/host-contracts-events.test.js:22-42` — the pin bump made the old `PENDING_CONTRACTS_RELEASE` exemption turn red exactly as it promised (v0.107.0 releases the git-source routes), so it is deleted; the "active" check now allows `host.resource.read` to be `planned` (documented).
- `README.md:52, 60-68, 356-383 (API row + the resource-index subsection), 459-461, 504, 567` — contracts pin v0.107.0, the capability paragraph, the API table row, the kinds/OVRN/tenancy/paging section, the grants note (Services is the expected caller, no `X-OV-Subject`), an acceptance bullet, the versions block.
- `STATUS.json:17,21,24-30,52,65` — contracts pin v0.107.0, `host.site.config` + `host.resource.read` in `capabilities`, an extended `capabilitiesNote`, a feature line; `updated` stays 2026-10-07.

**implementedBy routes** (for Opus, when Host has deployed and Contracts flips `host.resource.read` to `active`):
`GET /api/v1/resources` and `GET /api/v1/resources/:ovrn`.

**Tests** (`nice -n 15 ov test --no-cache …`, no `--all`): `test/resource-index.test.js`, `test/host-contracts-events.test.js` (+ `test/host-isolation.test.js`, `test/host-quota-auth.test.js`, `test/host-domains.test.js`, `test/host-lifecycle.test.js` for the touched `server/app.js`/`capabilities.js`) — **6 passed in 30 s**. Also green: `openvibe-contracts-check --service host --src lib --src server` (5 capabilities enforced, ok), `openvibe-shared/scripts/docs-status.js . --check`, `pin-drift.js`. Nothing committed or p

* cli-inventory: the released host manifest lists host.resource.read (openvibe-contracts 0.107.0).

Changes

ddiff --git a/README.md b/README.md
iindex 17de289..0318600 100644
---- a/README.md
++++ b/README.md
@@@ -49,7 +49,7 @@ Stage A adds a few safety rules of its own:
## Depends on
--- OpenVibe.Contracts (`openvibe-contracts` v0.83.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, and v0.32.0 adds the takedown routes to `host.site.manage`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks.
++- OpenVibe.Contracts (`openvibe-contracts` v0.107.0; `host.*` capabilities and the `host` manifest were released in v0.24.0, v0.32.0 added the takedown routes to `host.site.manage`, v0.83.0 added `host.site.config`, and v0.107.0 adds the planned `host.resource.read` and the git-source routes to `host.site.manage`/`host.deploy.create`): service manifests (vhost rendering, snapshots, the first-party domain list), ids, problem+json, service-token verification, capability checks, the `common.resource-summary@1`/`common.resource-list-result@1` schemas and the `contracts.resources` OVRN helpers (ADR-048).
- OpenVibe.Network (Stage B): SSO for the dashboard, the JWKS that verifies user and service tokens, client-credentials tokens for the outbox relay.
- OpenVibe.Events (Stage B): `host.*` events through the `openvibe-sdk` v0.26.0 transactional outbox (openvibe-sdk/limits for the per-actor limits).
- `openvibe-shared` v2.10.1 (Stage B): shared chrome (Host's own pages are composed with `openvibe-shared/shell`), legal pages, `/release.json`, `/metrics`, `/api/ready`.
@@@ -58,8 +58,16 @@ Stage A adds a few safety rules of its own:
## Capabilities
Implemented here (the service manifest's `capabilities`, Stage B's API, audience `openvibe.host`):
--`host.site.manage` (sites, members, takedowns), `host.deploy.create` (uploads, activation, rollback)
--and `host.domain.manage` (custom domains). See [Grants and registration](#grants-and-registration-for-the-lead).
++`host.site.manage` (sites, members, takedowns), `host.deploy.create` (uploads, activation, rollback),
++`host.domain.manage` (custom domains) and `host.site.config` (headers, redirects, SPA fallback). See
++[Grants and registration](#grants-and-registration-for-the-lead).
++
++`host.resource.read` (first-party) is the authority resource index Host serves for OpenVibe.Services'
++fan-out ([ADR-048](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/adr/ADR-048-services-control-plane.md)
++section 3, plan T13 step 8): `GET /api/v1/resources` lists Host's sites, deploys and domains as a page of
++`common.resource-summary@1` and `GET /api/v1/resources/:ovrn` reads one by its OVRN, exactly as
++OpenVibe.Network's index does. Host ships it; OpenVibe.Contracts keeps the capability `planned` with no
++`implementedBy` routes until the deploy, and flips it afterwards.
Called elsewhere, as the service principal `host` (the OAuth client `host`; `ovhost` reads the same
credentials from `/etc/openvibe/host.env`):
@@@ -346,10 +354,36 @@ People present their Network user JWT as a Bearer token. Services and apps prese
| `POST /sites/:id/source/deploys?ref=…&commit_sha=…` (the upload body; always a preview) | `host.deploy.create` | deployer |
| `POST/DELETE /projects/:id/takedown` · `POST/DELETE /sites/:id/takedown` | `host.site.manage` | Network staff only |
| `GET/POST /sites/:id/domains` · `POST /domains/:id/verify` · `DELETE /domains/:id` | `host.domain.manage` | maintainer (reads: member) |
++| `GET /resources[?project=&kind=&cursor=&limit=]` · `GET /resources/:ovrn` (the resource index, ADR-048) | `host.resource.read` | first-party service only |
Errors are RFC 9457 problems (`application/problem+json`, with the legacy `error` field). A refused upload answers 413/422 with `deploy_id` and the log lines; `503 upload.busy` (with `Retry-After`) when `HOST_MAX_CONCURRENT_UPLOADS` uploads are already being validated; `507 storage.host_full` while the disk has less than `HOST_MIN_FREE_BYTES` free. Network staff (`role: admin`) can read every project, site, deploy and log, delete sites, projects and domains, set quotas, and **take a site or project down** (451 on every host, content kept for review, members see the reason and cannot publish or delete around it); they cannot publish into a tenant's site.
--The four capability ids (`host.site.config` since v0.83.0) and the service manifest are released in `openvibe-contracts`, which Host pins, and the CI contract check is blocking; `test/host-contracts-events.test.js` holds the routes, guards and emitted events to the released manifests.
++The five capability ids (`host.site.config` since v0.83.0; `host.resource.read` planned since v0.107.0) and the service manifest are released in `openvibe-contracts`, which Host pins, and the CI contract check is blocking; `test/host-contracts-events.test.js` holds the routes, guards and emitted events to the released manifests.
++
++#### The resource index (ADR-048 section 3)
++
++`GET /api/v1/resources` lists the resources Host owns as a page of `common.resource-summary@1`
++(`common.resource-list-result@1`: `{ resources, next_cursor }`), and `GET /api/v1/resources/:ovrn` reads
++one by its computed OVRN. It is the same path, shape and paging every authority answers, so
++OpenVibe.Services can fan out over all of them and merge:
++
++| Kind | Rows | Name | State |
++|---|---|---|---|
++| `host.site` (`sit_`) | `host_sites` | the site's label (`<name>.openvibe.host`) | `active` / `deleted` |
++| `host.deploy` (`dpl_`) | `host_deploys` | — | `ready` / `failed` / `deleted` |
++| `host.domain` (`dom_`) | `host_domains` | the hostname it answers on | `pending` / `verified` / `failed` / `lapsed` |
++
++Every summary carries `service: "host"`, the `project_id` of the project that owns it, the `created_by`
++person as `owner` when it is a `usr_` subject, and its `ovrn` computed by
++`contracts.resources.nameOf` — `ovrn:host:<prj_…>:site/<sit_…>`, `:deploy/<dpl_…>`,
++`:domain/<dom_…>`. Host lists no projects (only Network lists projects): Host mints its own `prj_` ids
++until it adopts Network projects (ADR-014). `?project=prj_…` is the tenancy boundary — with it only
++that project's rows answer, and one of another project is never returned; without it the first-party
++caller sees every Host-owned resource. `?kind=` narrows to one kind, `?cursor=` is an opaque keyset
++position and `?limit=` is 1–1000 (default 100). A query that cannot be honoured is `400
++resources.bad_query`; an OVRN that names nothing is `404 resources.unknown_resource`. The index is
++first-party: only a service token holding `host.resource.read` is admitted (a person gets 403, no token
++401), and `test/resource-index.test.js` validates every summary and page against the released schemas.
### Activation and rollback
@@@ -423,7 +457,8 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v2.10.1 `shell.p
- Grant `[host, events.event.publish, openvibe.events]`.
- With `HOST_OBJECT_STORE=media`: grants `[host, media.object.upload, openvibe.media]`, `[host, media.object.read, openvibe.media]`, `[host, media.object.list, openvibe.media]` and `[host, media.object.delete, openvibe.media]`, covering the `host` namespace and its subtree (`host.*`), plus a `host` tenant in Media. Network-side; Host asks for the verbs as its scope.
- Callers of Host get `[<client>, host.site.manage | host.deploy.create | host.domain.manage, openvibe.host]` as needed. None exist yet (Codes, the expected first caller, is not built).
--- Released in `openvibe-contracts` v0.83.0 (CI contract check blocking); v0.32.0 adds the takedown routes to `host.site.manage.implementedBy`.
++- The resource index is read the other way round: **OpenVibe.Services** (not built yet) calls `GET /api/v1/resources` on `svc:services` with `host.resource.read` for audience `openvibe.host`, without `X-OV-Subject` (the capability is first-party and answers the whole index).
++- Released in `openvibe-contracts` v0.107.0 (CI contract check blocking); v0.32.0 added the takedown routes to `host.site.manage.implementedBy` and v0.107.0 added the git-source routes. `host.resource.read` stays `planned` with an empty `implementedBy` until Opus flips it after the deploy.
### Deploying Stage B (for the operator)
@@@ -466,6 +501,7 @@ The service runs on the host (loopback, since 2026-09-23) and the pending tenant
- **Tenant sitemap/robots** (`test/host-sitemap.test.js`): a deploy that ships none gets a generated `sitemap.xml` (the manifest's HTML pages, `index.html` → the directory URL, the error page left out, on the host the request came in on — so a verified custom domain gets its own) and `robots.txt` (crawlers welcomed and the sitemap named; a sandbox site gets `Disallow: /` and stays `noindex`); a file the tenant uploaded at either path wins; correct content types and caching; HEAD works; no secret in the body.
- **Preview deploys** (`test/host-preview.test.js`): `POST /sites/:id/deploys?preview=1` stores a deploy with `source=preview` and points the site at it without activating (the public site keeps serving its active deploy); `/preview/<deploy-id>/…` on the dashboard is served to a project member only — non-members and signed-out callers get a plain 404, a public tenant host never serves it, every response is `noindex, no-store`, and the response's CSP sandboxes the tenant content into an opaque origin (no `allow-same-origin`) so it can never act as `openvibe.host`; a preview is never pinged to IndexNow and never listed in the site's sitemap; it vanishes when it expires, when the site deploys or rolls back, or when it is deleted; and it serves only its own project's objects (a same-named file of another project is not reachable).
- **Git deploys** (`test/host-git-source.test.js`): a maintainer connects a source, a deployer gets 403 and a non-member 404, a bad URL, provider or ref is 422 and a credential field is refused; an app deployer's ingest is a `source=git` deploy with its `host_deploy_git` row and the site's preview while the public host keeps serving the active deploy; activate flips the pointer and clears the preview; an invalid file (a failed deploy and `host.deploy.failed`), a ref mismatch, an unconnected site, a bad SHA and `activate=1` all leave `active_deploy_id` unchanged; `host_deploy_git` rows are immutable; source responses hold only the allowlisted fields.
++- **Resource index** (`test/resource-index.test.js`): no token 401, a person 403, a service token without `host.resource.read` 403 and with it 200; every summary and every page validates against `common.resource-summary@1` and `common.resource-list-result@1`; all three kinds (sites, deploys, domains) with their states, names, owners and OVRNs; `?project=` never returns another project's rows; `?kind=` narrows and an unknown kind is an empty page; the cursor pages the whole index with no duplicate, gap or reordering; `GET /:ovrn` reads the one resource whose computed OVRN it is (a wrong project, another service's OVRN, an unknown id and a non-OVRN are 404 `resources.unknown_resource`); a bad query is 400 `resources.bad_query`.
- **Home-page size budget** (`test/perf-budget.test.js`): the server on a fresh database, the home page measured with `openvibe-shared/perf-budget` — html 15.9 KB (4.3 br), js 4 files 212.2 KB (49.9 br), css 1 file 3.1 KB (0.9 br), 0 external — within the committed budgets.
- The isolation test also covers cross-origin reads from a tenant page (no CORS grant anywhere), ETag/Range existence oracles, delegated service tokens, app principals and the dashboard.
@@@ -528,7 +564,7 @@ review is [docs/threat-review.md](docs/threat-review.md). The launch has not bee
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.83.0
++- openvibe-contracts: v0.107.0
- openvibe-sdk: v0.26.0
- openvibe-shared: v2.10.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 5a2ec0a..efeec9f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -14,18 +14,20 @@
"port": 4910,
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
-- "contracts": "openvibe-contracts v0.83.0",
++ "contracts": "openvibe-contracts v0.107.0",
"sdk": "openvibe-sdk v0.26.0",
"shared": "openvibe-shared v2.10.1",
"packages": [
-- "openvibe-contracts v0.83.0",
++ "openvibe-contracts v0.107.0",
"openvibe-sdk v0.26.0",
"openvibe-shared v2.10.1"
],
"capabilities": [
"host.site.manage",
"host.deploy.create",
-- "host.domain.manage"
++ "host.domain.manage",
++ "host.site.config",
++ "host.resource.read"
],
"capabilitiesRegistered": true,
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
@@@ -47,7 +49,7 @@
"deployed": "production: openvibe.host and *.openvibe.host since 2026-10-07",
"placeholderInSites": false,
"adoptedByServices": [],
-- "capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy",
++ "capabilitiesNote": "released in openvibe-contracts v0.24.0; v0.32.0 adds the four takedown routes to host.site.manage.implementedBy; v0.83.0 adds host.site.config; v0.107.0 adds the git-source routes and the planned host.resource.read (Host ships the resource index at GET /api/v1/resources and GET /api/v1/resources/:ovrn; OpenVibe.Contracts flips the capability to active, with the implementedBy routes, only after the deploy)",
"launchReady": true,
"features": [
"ovhost: inventory, validate, plan, deploy with automatic rollback, rollback, status, releases",
@@@ -60,6 +62,7 @@
"Prometheus alerts relay to Network",
"browser check and release-lifecycle acceptance suite",
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns",
++ "the authority resource index (ADR-048 section 3, plan T13 step 8): GET /api/v1/resources pages Host's sites (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and custom domains (host.domain, dom_) as common.resource-summary@1, and GET /api/v1/resources/:ovrn reads one by its OVRN, for OpenVibe.Services' fan-out; guarded by the first-party host.resource.read, scoped by ?project= and paginated with an opaque cursor",
"Stage B git deploys, Phase 1: a site's Git source (repo + branch); the tenant's own CI posts each build, which lands as a source=git preview with immutable commit provenance and goes live only through activate (Host never clones, fetches, holds a credential or builds)",
"per-actor limits on Stage B writes (openvibe-sdk/limits)"
],
ddiff --git a/package-lock.json b/package-lock.json
iindex 95e4da4..82fae5e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.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.10.1",
"pg": "^8.23.0"
@@@ -1501,9 +1501,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.83.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
-- "integrity": "sha512-KMIJfTjpjVfE/cH2VDmzbZL9KnBm9guyFRniic7kdl/dN0RV2IlgExJG13hDOO39Qq4+RfjPcwuhp06qWuD2SQ==",
++ "version": "0.107.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.107.0",
++ "integrity": "sha512-bttjTDLzCfHFtq5h0EkBaPspVV6U934jj5Hz7RViIh2UTiC2Xzy3d6AnHLsEomVyTwaiwg9veNiO+eZTZ0lxsg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 79ed133..816583f 100644
---- a/package.json
++++ b/package.json
@@@ -33,7 +33,7 @@
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.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.10.1",
"pg": "^8.23.0"
ddiff --git a/server/app.js b/server/app.js
iindex a91f6a8..99b6cb2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -36,11 +36,12 @@ const { createDomains } = require('./domain/domains');
const { createSiteConfig } = require('./domain/site-config');
const { createSiteSources } = require('./domain/site-sources');
const { createSsoClient } = require('openvibe-sdk/sso');
--const { createViewerResolver } = require('./auth/viewer');
++const { createViewerResolver, guard } = require('./auth/viewer');
const { createHostSession } = require('./auth/sso');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
++const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
@@@ -276,6 +277,13 @@ async function createApp(opts = {}) {
},
}));
++ // ── Resource index (ADR-048 section 3, plan T13 step 8) ──
++ // The resources Host owns (sites, deploys, domains) as common.resource-summary@1, for OpenVibe.Services'
++ // fan-out. Its own router, mounted here and not in http/api.js: it is the same path and shape every
++ // authority answers (Network's index), and it is first-party — the capability is host.resource.read.
++ app.use('/api/v1/resources', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }),
++ viewers.middleware('api'), resourceIndex.router({ guard: guard('host.resource.read') }));
++
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
ddiff --git a/server/auth/capabilities.js b/server/auth/capabilities.js
iindex c8eb242..8526ba6 100644
---- a/server/auth/capabilities.js
++++ b/server/auth/capabilities.js
@@@ -7,6 +7,7 @@
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.domain.manage add, verify and remove custom domains
* host.site.config read, set and reset a site's response headers, redirects and SPA fallback
++ * host.resource.read read the resource index (GET /api/v1/resources; ADR-048 section 3, plan T13 step 8)
*
* A service token is judged by its capability AND by the project role of the principal it acts
* as (domain/access.js). Browsers are judged by their role alone.
@@@ -18,6 +19,7 @@ const CAPABILITIES = Object.freeze({
DEPLOY_CREATE: 'host.deploy.create',
DOMAIN_MANAGE: 'host.domain.manage',
SITE_CONFIG: 'host.site.config',
++ RESOURCE_READ: 'host.resource.read',
});
/** → { allowed, code, reason } (capabilities.check()). */
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 0000000..6a66696
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,201 @@
++'use strict';
++/**
++ * Host's authority resource index (ADR-048 section 3; capability host.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.Host owns — its tenant sites (sit_), the immutable deploys of those
++ * sites (dpl_) and their default and custom domains (dom_), each from its own table — as
++ * common.resource-summary@1, the shape OpenVibe.Services fans out over and merges
++ * (openvibe-sdk/resources' createResourceIndex). It is the same shape and the same path
++ * OpenVibe.Network's index answers (server/registry/resource-index.js there).
++ *
++ * Host lists no projects: Host mints its own prj_ ids until it adopts Network projects (ADR-014), and
++ * only Network lists projects (ADR-048, step 8). Every summary still carries the project_id of the
++ * project that owns it, which is the tenancy boundary.
++ *
++ * 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, resourceConstraints none) sees every Host-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. All three Host kinds
++ * are: sit_, dpl_ and dom_ carry a three-letter prefix and the resource carries a project, so every
++ * summary is named ovrn:host:<prj_…>:<type>/<id> — and that is also what GET /api/v1/resources/:ovrn
++ * can read: only a resource whose computed ovrn equals the one asked for answers.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++// Host's JSON handler wrapper: a rejected handler reaches the app's error handler, and these handlers
++// write their own response. Express 4 does not catch a rejected async handler.
++const { run } = require('../http/errors');
++
++const SERVICE = 'host';
++const SITE_KIND = 'host.site';
++const DEPLOY_KIND = 'host.deploy';
++const DOMAIN_KIND = 'host.domain';
++const KINDS = [SITE_KIND, DEPLOY_KIND, DOMAIN_KIND];
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */
++const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);
++
++/** common.resource-summary@1 for a host_sites row. A site is named by the label it is served at. */
++function siteSummary(s) {
++ return {
++ id: s.id, kind: SITE_KIND, service: SERVICE, project_id: s.project_id,
++ ...(userRef(s.created_by) ? { owner: userRef(s.created_by) } : {}),
++ name: s.name, state: s.status, created_at: new Date(Number(s.created_at)).toISOString(),
++ };
++}
++
++/** common.resource-summary@1 for a host_deploys row. A deploy has no name of its own. */
++function deploySummary(d) {
++ return {
++ id: d.id, kind: DEPLOY_KIND, service: SERVICE, project_id: d.project_id,
++ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),
++ state: d.state, created_at: new Date(Number(d.created_at)).toISOString(),
++ };
++}
++
++/** common.resource-summary@1 for a host_domains row: a domain's name is the hostname it answers on. */
++function domainSummary(d) {
++ return {
++ id: d.id, kind: DOMAIN_KIND, service: SERVICE, project_id: d.project_id,
++ ...(userRef(d.created_by) ? { owner: userRef(d.created_by) } : {}),
++ name: d.hostname, state: d.status, created_at: new Date(Number(d.created_at)).toISOString(),
++ };
++}
++
++/** The summary's ovrn, or null when it has none: contracts.resources.nameOf is the one formatter. */
++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: host.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 === SITE_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM host_sites WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT * FROM host_sites ORDER BY id').all();
++ for (const r of rows) out.push(named(siteSummary(r)));
++ }
++ if (!kind || kind === DEPLOY_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM host_deploys WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT * FROM host_deploys ORDER BY id').all();
++ for (const r of rows) out.push(named(deploySummary(r)));
++ }
++ if (!kind || kind === DOMAIN_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM host_domains WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT * FROM host_domains ORDER BY id').all();
++ for (const r of rows) out.push(named(domainSummary(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 OVRN type segment → the table and summary it belongs to. */
++const OF_TYPE = {
++ site: { table: 'host_sites', summary: siteSummary },
++ deploy: { table: 'host_deploys', summary: deploySummary },
++ domain: { table: 'host_domains', summary: domainSummary },
++};
++
++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}` });
++
++ /**
++ * The resource index is read by first-party services for OpenVibe.Services' fan-out; a person holds
++ * no service token and the capability is first-party, so only a service token reaches the guard.
++ */
++ function firstParty(req, res, next) {
++ const v = req.viewer;
++ if (v && v.kind === 'service') return next();
++ if (!v || v.kind === 'anonymous') return contracts.http.sendProblem(res, 401, 'auth.required', { detail: 'the resource index is read by first-party services: present a service token for audience openvibe.host', ctx: req.ov });
++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: `${SERVICE}.resource.read is first-party and not granted to a person`, ctx: req.ov });
++ }
++
++ /** 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(req.app.locals.ctx.store.db, 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);
++ let summary = null;
++ if (parsed && parsed.service === SERVICE && OF_TYPE[parsed.type]) {
++ const db = req.app.locals.ctx.store.db;
++ const row = await db.prepare(`SELECT * FROM ${OF_TYPE[parsed.type].table} WHERE id = ?`).get(parsed.id);
++ if (row) summary = named(OF_TYPE[parsed.type].summary(row));
++ }
++ if (!summary || summary.ovrn !== name) return unknown(res, name);
++ open(res).json(summary);
++ }
++
++ const r = express.Router();
++ r.use(firstParty);
++ r.get('/', guard, run(page));
++ r.get('/:ovrn', guard, run(one));
++ return r;
++}
++
++module.exports = {
++ router, SERVICE, KINDS, SITE_KIND, DEPLOY_KIND, DOMAIN_KIND, DEFAULT_LIMIT, MAX_LIMIT,
++ siteSummary, deploySummary, domainSummary, ovrnOf, collect, encodeCursor, filtersOf,
++};
ddiff --git a/test/cli-inventory.test.js b/test/cli-inventory.test.js
iindex e3dcba9..fad8b28 100644
---- a/test/cli-inventory.test.js
++++ b/test/cli-inventory.test.js
@@@ -47,7 +47,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
-- assert.deepStrictEqual(m.capabilities, ['host.site.manage', 'host.deploy.create', 'host.domain.manage', 'host.site.config'], 'Stage A has no API; the Stage B service enforces these four');
++ assert.deepStrictEqual([...m.capabilities].sort(), ['host.deploy.create', 'host.domain.manage', 'host.resource.read', 'host.site.config', 'host.site.manage'], 'Stage A has no API; the Stage B service enforces these five (host.resource.read: the resource index, openvibe-contracts 0.107.0)');
}),
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
ddiff --git a/test/host-contracts-events.test.js b/test/host-contracts-events.test.js
iindex a28c865..ce3c093 100644
---- a/test/host-contracts-events.test.js
++++ b/test/host-contracts-events.test.js
@@@ -13,20 +13,6 @@ const { listen } = require('./stageb/mocks');
const { CAPABILITIES } = require('../server/auth/capabilities');
const { EVENT_TYPES } = require('../server/events/outbox');
--/**
-- * Routes Host guards today whose capability entries are only in OpenVibe.Contracts main (OpenVibe.Contracts#18
-- * added the git-source routes for the next release; the pinned tag predates it). They are exempt from the
-- * "the released manifest lists every route" half of the check until the pin reaches that release, and no
-- * further: each key must be a real guarded route with the capability the release gives it, and must not be
-- * in the pinned manifest — so when the pin is bumped the exemption turns red and has to be deleted.
-- */
--const PENDING_CONTRACTS_RELEASE = {
-- 'GET /api/v1/sites/:id/source': 'host.site.manage',
-- 'PUT /api/v1/sites/:id/source': 'host.site.manage',
-- 'DELETE /api/v1/sites/:id/source': 'host.site.manage',
-- 'POST /api/v1/sites/:id/source/deploys': 'host.deploy.create',
--};
--
(async () => {
const ids = Object.values(CAPABILITIES);
const caps = ids.map((id) => contracts.capabilities.get(id));
@@@ -36,7 +22,10 @@ const PENDING_CONTRACTS_RELEASE = {
for (const [i, c] of caps.entries()) {
assert.ok(c, `${ids[i]} is not in openvibe-contracts`);
assert.strictEqual(c.owner, 'host');
-- assert.strictEqual(c.status, 'active', c.id);
++ // host.resource.read is `planned` in the released manifest on purpose: Host ships the index
++ // (server/registry/resource-index.js) but OpenVibe.Contracts flips the capability to `active`,
++ // with the implementedBy routes, only after the deploy. Every other capability is active.
++ assert.strictEqual(c.status, c.id === 'host.resource.read' ? 'planned' : 'active', c.id);
}
});
@@@ -50,16 +39,8 @@ const PENDING_CONTRACTS_RELEASE = {
const cap = caps.find((c) => c.id === r.cap);
assert.ok(cap, r.cap);
const key = `${r.method} ${r.route}`;
-- const pending = PENDING_CONTRACTS_RELEASE[key];
-- if (pending != null) {
-- assert.strictEqual(pending, r.cap, `${key} is exempt for ${pending}, not ${r.cap}`);
-- assert.ok(caps.every((c) => !c.implementedBy.includes(key)), `${key} is released now: drop it from PENDING_CONTRACTS_RELEASE`);
-- continue;
-- }
assert.ok(cap.implementedBy.includes(key), `${key} missing from ${r.cap}.implementedBy`);
}
-- const guarded = new Set(routes.map((r) => `${r.method} ${r.route}`));
-- for (const key of Object.keys(PENDING_CONTRACTS_RELEASE)) assert.ok(guarded.has(key), `${key} is not a guarded route: drop it from PENDING_CONTRACTS_RELEASE`);
});
await check('the released service manifest declares every event the service emits', async () => {
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 0000000..e9ec7bd
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,193 @@
++'use strict';
++// Host's authority resource index (ADR-048 section 3, capability host.resource.read, plan T13 step 8):
++// GET /api/v1/resources pages common.resource-summary@1 for the resources Host owns - its tenant sites
++// (host.site, sit_), the immutable deploys of those sites (host.deploy, dpl_) and their default and
++// custom domains (host.domain, dom_) - as common.resource-list-result@1, 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, and Host lists no projects at all.
++// Every summary and every page is validated against the released schemas.
++const assert = require('assert');
++const { validate } = require('openvibe-contracts');
++const { boot, check, done } = require('./stageb/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.user('alice');
++ const prjA = (await t.project(alice, 'Resource A')).id;
++ const prjB = (await t.project(alice, 'Resource B')).id;
++
++ // Sites: two in A, one in B (each creation also mints the site's default domain).
++ const siteA1 = await t.site(alice, prjA, 'ri-a-one');
++ const siteA2 = await t.site(alice, prjA, 'ri-a-two');
++ const siteB = await t.site(alice, prjB, 'ri-b-one');
++
++ // Deploys of A's first site: one ready, one refused (a failed deploy).
++ const readyDeploy = await t.deploy(alice, siteA1.id, { 'index.html': 'v1' });
++ const refusedUpload = await t.upload(alice, siteA1.id, { 'bad.php': 'x' });
++ assert.ok([413, 422].includes(refusedUpload.status), refusedUpload.text);
++ const failedDeploy = { id: refusedUpload.json().deploy_id };
++ assert.match(failedDeploy.id, /^dpl_/);
++
++ // A custom domain of A's first site, still waiting for its TXT record.
++ const custom = await t.api('POST', `/api/v1/sites/${siteA1.id}/domains`, { as: alice, json: { hostname: 'ri.example.org' } });
++ assert.strictEqual(custom.status, 201, custom.text);
++ const customDomain = custom.json().domain;
++
++ // The default domain each site create mints (its id is what the index lists).
++ const defaultDomains = [];
++ for (const site of [siteA1, siteA2, siteB]) {
++ const seen = await t.api('GET', `/api/v1/sites/${site.id}`, { as: alice });
++ assert.strictEqual(seen.status, 200, seen.text);
++ for (const d of seen.json().domains) if (d.kind === 'default') defaultDomains.push(d);
++ }
++ assert.strictEqual(defaultDomains.length, 3);
++
++ const resourcesOf = async (query, headers) => {
++ const r = await t.api('GET', `/api/v1/resources${query}`, headers);
++ assert.strictEqual(r.status, 200, `${query}: ${r.status} ${r.text}`);
++ const page = r.json();
++ assert.deepStrictEqual(Object.keys(page).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
++ const pageCheck = validate('common.resource-list-result@1', page);
++ assert.ok(pageCheck.valid, `${query}: ${JSON.stringify(pageCheck.errors)}`);
++ for (const s of page.resources) {
++ const v = validate('common.resource-summary@1', s);
++ assert.ok(v.valid, `${query} ${s.id}: ${JSON.stringify(v.errors)}`);
++ }
++ return page;
++ };
++
++ await check('auth: no token 401, a token without host.resource.read 403, with it 200', async () => {
++ const anonymous = await t.api('GET', '/api/v1/resources');
++ assert.strictEqual(anonymous.status, 401);
++ assert.strictEqual(anonymous.json().code, 'auth.required');
++ const person = await t.api('GET', '/api/v1/resources', { as: alice });
++ assert.strictEqual(person.status, 403, 'the index is first-party: a person holds no service capability');
++ assert.strictEqual(person.json().code, 'capability.denied');
++ const wrongAudience = t.network.sign({ sub: 'svc:services', aud: ['openvibe.blog'], cap: ['host.resource.read'] });
++ assert.strictEqual((await t.api('GET', '/api/v1/resources', { as: wrongAudience })).status, 401);
++ const noCap = t.network.serviceToken('codes', ['host.site.manage']);
++ const denied = await t.api('GET', '/api/v1/resources', { as: noCap });
++ assert.strictEqual(denied.status, 403);
++ assert.strictEqual(denied.json().code, 'capability.denied');
++ const allowed = await t.api('GET', '/api/v1/resources', { as: t.network.serviceToken('services', ['host.resource.read']) });
++ assert.strictEqual(allowed.status, 200, allowed.text);
++ });
++
++ const bearer = t.network.serviceToken('services', ['host.resource.read']);
++ const auth = { as: bearer };
++
++ await check('the page lists every kind Host owns, sorted by (kind, id), each summary valid', async () => {
++ const page = await resourcesOf('', auth);
++ const expected = [
++ { kind: 'host.deploy', id: readyDeploy.id }, { kind: 'host.deploy', id: failedDeploy.id },
++ { kind: 'host.domain', id: customDomain.id },
++ { kind: 'host.site', id: siteA1.id }, { kind: 'host.site', id: siteA2.id }, { kind: 'host.site', id: siteB.id },
++ ];
++ const byId = new Map(page.resources.map((r) => [r.id, r]));
++ for (const d of defaultDomains) expected.push({ kind: 'host.domain', id: d.id });
++ expected.sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
++ assert.deepStrictEqual(page.resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'all three kinds, sorted by (kind, id)');
++ assert.deepStrictEqual([...new Set(page.resources.map((r) => r.service))], ['host']);
++ assert.strictEqual(page.next_cursor, null, 'one page holds the whole index');
++
++ const site = byId.get(siteA1.id);
++ assert.strictEqual(site.project_id, prjA, 'a summary carries its project');
++ assert.strictEqual(site.name, 'ri-a-one');
++ assert.strictEqual(site.state, 'active');
++ assert.deepStrictEqual(site.owner, { type: 'user', id: alice.subject }, 'a usr_ subject is the owner');
++ assert.strictEqual(byId.get(readyDeploy.id).state, 'ready');
++ assert.strictEqual(byId.get(failedDeploy.id).state, 'failed');
++ assert.strictEqual(byId.get(customDomain.id).name, 'ri.example.org');
++ assert.strictEqual(byId.get(customDomain.id).state, 'pending');
++ });
++
++ await check('every summary carries an OVRN composed by contracts.resources.nameOf', async () => {
++ const page = await resourcesOf('', auth);
++ for (const s of page.resources) {
++ assert.strictEqual(s.ovrn, `ovrn:host:${s.project_id}:${s.kind.split('.')[1]}/${s.id}`, s.id);
++ }
++ assert.strictEqual(page.resources.find((r) => r.id === readyDeploy.id).ovrn, `ovrn:host:${prjA}:deploy/${readyDeploy.id}`);
++ assert.strictEqual(page.resources.find((r) => r.id === siteB.id).ovrn, `ovrn:host:${prjB}:site/${siteB.id}`);
++ });
++
++ await check('?project= is the tenancy boundary: another project\'s rows are never returned', async () => {
++ const a = await resourcesOf(`?project=${prjA}`, auth);
++ const ids = a.resources.map((r) => r.id);
++ assert.ok(ids.includes(siteA1.id) && ids.includes(siteA2.id) && ids.includes(readyDeploy.id) && ids.includes(customDomain.id));
++ assert.ok(a.resources.every((r) => r.project_id === prjA), 'only project A');
++ assert.ok(!ids.includes(siteB.id), 'project B is never mixed in');
++ const b = await resourcesOf(`?project=${prjB}`, auth);
++ assert.deepStrictEqual([...new Set(b.resources.map((r) => r.project_id))], [prjB]);
++ assert.ok(b.resources.every((r) => r.id !== siteA1.id && r.id !== readyDeploy.id));
++ const empty = await resourcesOf('?project=prj_01J8ZQ4Y7N3M2K1H0G9F8E7D6C', auth);
++ assert.deepStrictEqual(empty.resources, [], 'an unknown project has no resources');
++ // Host mints its own prj_ ids and lists no projects: no host.project kind exists.
++ assert.ok((await resourcesOf('?kind=host.project', auth)).resources.length === 0);
++ });
++
++ await check('?kind= narrows to one kind; an unknown kind is an empty page, not an error', async () => {
++ const sites = await resourcesOf('?kind=host.site', auth);
++ assert.deepStrictEqual(sites.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id, siteB.id].sort());
++ const deploys = await resourcesOf('?kind=host.deploy', auth);
++ assert.deepStrictEqual(deploys.resources.map((r) => r.id).sort(), [readyDeploy.id, failedDeploy.id].sort());
++ const domains = await resourcesOf('?kind=host.domain', auth);
++ assert.strictEqual(domains.resources.length, 4, "three default domains and A's custom one");
++ const scoped = await resourcesOf(`?project=${prjA}&kind=host.site`, auth);
++ assert.deepStrictEqual(scoped.resources.map((r) => r.id).sort(), [siteA1.id, siteA2.id].sort(), 'kind narrows within the project');
++ assert.deepStrictEqual((await resourcesOf('?kind=host.unknown', auth)).resources, []);
++ });
++
++ await check('the cursor pages the index without duplicates, gaps or reordering', async () => {
++ const whole = (await resourcesOf('', auth)).resources.map((r) => r.id);
++ const seen = [];
++ let cursor = null;
++ let pages = 0;
++ for (;;) {
++ const page = await resourcesOf(`?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);
++ assert.ok(page.resources.length <= 2);
++ seen.push(...page.resources.map((r) => r.id));
++ if (page.next_cursor === null) break;
++ assert.ok(typeof page.next_cursor === 'string' && page.next_cursor !== '');
++ cursor = page.next_cursor;
++ if (++pages > 50) assert.fail('the cursor chain never ended');
++ }
++ assert.deepStrictEqual(seen, whole, 'no duplicates, none skipped, order preserved');
++ assert.deepStrictEqual((await resourcesOf('?limit=1000', auth)).resources.map((r) => r.id), whole, 'a large limit still pages one result');
++ });
++
++ await check('GET /:ovrn reads the one resource whose computed ovrn it is', async () => {
++ const page = await resourcesOf('', auth);
++ const one = await t.api('GET', `/api/v1/resources/${encodeURIComponent(`ovrn:host:${prjA}:site/${siteA1.id}`)}`, auth);
++ assert.strictEqual(one.status, 200, one.text);
++ assert.strictEqual(one.headers['cache-control'], 'private, max-age=60');
++ assert.ok(validate('common.resource-summary@1', one.json()).valid);
++ assert.deepStrictEqual(one.json(), page.resources.find((r) => r.id === siteA1.id), 'the same summary the list answers');
++
++ const missing = [
++ ['a project OVRN', `ovrn:host:${prjA}:project/${prjA}`],
++ ['an unknown type', `ovrn:host:${prjA}:widget/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],
++ ["another service's OVRN", `ovrn:network:${prjA}:site/${siteA1.id}`],
++ ['the right id under another project', `ovrn:host:${prjB}:site/${siteA1.id}`],
++ ['an unknown id', `ovrn:host:${prjA}:site/sit_01J8ZQ4Y7N3M2K1H0G9F8E7D6C`],
++ ['a non-OVRN', 'nope'],
++ ];
++ for (const [why, name] of missing) {
++ const r = await t.api('GET', `/api/v1/resources/${encodeURIComponent(name)}`, auth);
++ assert.strictEqual(r.status, 404, `${why}: ${r.status} ${r.text}`);
++ assert.strictEqual(r.headers['content-type'], 'application/problem+json', why);
++ assert.strictEqual(r.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 r = await t.api('GET', `/api/v1/resources${q}`, auth);
++ assert.strictEqual(r.status, 400, why);
++ assert.strictEqual(r.json().code, 'resources.bad_query', why);
++ }
++ });
++
++ await t.close();
++ done();
++})();