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)
Changes
@@@ -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:## CapabilitiesImplemented 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 samecredentials 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 beePart 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 -->@@@ -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)"],@@@ -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",@@@ -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"@@@ -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));@@@ -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()). */nnew file mode 100644@@@ -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,++};@@@ -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 () => {@@@ -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 () => {nnew file mode 100644@@@ -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();++})();
@@ -49,7 +49,7 @@ Stage A adds a few safety rules of its own:
@@ -49,7 +49,7 @@ Stage A adds a few safety rules of its own:
## Depends on
## 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.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.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`.
- `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:
@@ -58,8 +58,16 @@ Stage A adds a few safety rules of its own:
## Capabilities
## Capabilities
Implemented here (the service manifest's `capabilities`, Stage B's API, audience `openvibe.host`):
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
Called elsewhere, as the service principal `host` (the OAuth client `host`; `ovhost` reads the same
credentials from `/etc/openvibe/host.env`):
credentials from `/etc/openvibe/host.env`):
@@ -346,10 +354,36 @@ People present their Network user JWT as a Bearer token. Services and apps prese
@@ -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 /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 |
| `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/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.
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
### Activation and rollback
@@ -423,7 +457,8 @@ Server-rendered pages with the shared chrome (`openvibe-shared` v2.10.1 `shell.p
@@ -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]`.
- 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.
- 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).
- 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)
### 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
@@ -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.
- **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).
- **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.
- **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.
- **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.
- 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
@@ -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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.83.0
+- openvibe-contracts: v0.107.0
- openvibe-sdk: v0.26.0
- openvibe-sdk: v0.26.0
- openvibe-shared: v2.10.1
- openvibe-shared: v2.10.1
<!-- versions:end -->
<!-- versions:end -->
@@ -14,18 +14,20 @@
@@ -14,18 +14,20 @@
"port": 4910,
"port": 4910,
"domain": "openvibe.host",
"domain": "openvibe.host",
"tenantDomain": "*.openvibe.host",
"tenantDomain": "*.openvibe.host",
- "contracts": "openvibe-contracts v0.83.0",
+ "contracts": "openvibe-contracts v0.107.0",
"sdk": "openvibe-sdk v0.26.0",
"sdk": "openvibe-sdk v0.26.0",
"shared": "openvibe-shared v2.10.1",
"shared": "openvibe-shared v2.10.1",
"packages": [
"packages": [
- "openvibe-contracts v0.83.0",
+ "openvibe-contracts v0.107.0",
"openvibe-sdk v0.26.0",
"openvibe-sdk v0.26.0",
"openvibe-shared v2.10.1"
"openvibe-shared v2.10.1"
],
],
"capabilities": [
"capabilities": [
"host.site.manage",
"host.site.manage",
"host.deploy.create",
"host.deploy.create",
- "host.domain.manage"
+ "host.domain.manage",
+ "host.site.config",
+ "host.resource.read"
],
],
"capabilitiesRegistered": true,
"capabilitiesRegistered": true,
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
"serviceManifestRegistered": "host manifest v0.2.0 released (openvibe-contracts v0.24.0+), with the four host.* events",
@@ -47,7 +49,7 @@
@@ -47,7 +49,7 @@
"deployed": "production: openvibe.host and *.openvibe.host since 2026-10-07",
"deployed": "production: openvibe.host and *.openvibe.host since 2026-10-07",
"placeholderInSites": false,
"placeholderInSites": false,
"adoptedByServices": [],
"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,
"launchReady": true,
"features": [
"features": [
"ovhost: inventory, validate, plan, deploy with automatic rollback, rollback, status, releases",
"ovhost: inventory, validate, plan, deploy with automatic rollback, rollback, status, releases",
@@ -60,6 +62,7 @@
@@ -60,6 +62,7 @@
"Prometheus alerts relay to Network",
"Prometheus alerts relay to Network",
"browser check and release-lifecycle acceptance suite",
"browser check and release-lifecycle acceptance suite",
"Stage B: tenant static sites with immutable deploys, activation, rollback, custom domains, quotas and takedowns",
"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)",
"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)"
"per-actor limits on Stage B writes (openvibe-sdk/limits)"
],
],
@@ -17,7 +17,7 @@
@@ -17,7 +17,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1501,9 +1501,9 @@
@@ -1501,9 +1501,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -33,7 +33,7 @@
@@ -33,7 +33,7 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"iovalkey": "^0.4.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -36,11 +36,12 @@ const { createDomains } = require('./domain/domains');
@@ -36,11 +36,12 @@ const { createDomains } = require('./domain/domains');
const { createSiteConfig } = require('./domain/site-config');
const { createSiteConfig } = require('./domain/site-config');
const { createSiteSources } = require('./domain/site-sources');
const { createSiteSources } = require('./domain/site-sources');
const { createSsoClient } = require('openvibe-sdk/sso');
const { createSsoClient } = require('openvibe-sdk/sso');
-const { createViewerResolver } = require('./auth/viewer');
+const { createViewerResolver, guard } = require('./auth/viewer');
const { createHostSession } = require('./auth/sso');
const { createHostSession } = require('./auth/sso');
const { createTenantServer } = require('./http/tenant');
const { createTenantServer } = require('./http/tenant');
const { createUploadGate } = require('./http/upload');
const { createUploadGate } = require('./http/upload');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
+const resourceIndex = require('./registry/resource-index');
const { createDashboard } = require('./http/dashboard');
const { createDashboard } = require('./http/dashboard');
const { createHostReadiness } = require('./observability');
const { createHostReadiness } = require('./observability');
const { createWorker } = require('./worker');
const { createWorker } = require('./worker');
@@ -276,6 +277,13 @@ async function createApp(opts = {}) {
@@ -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 ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
app.use('/api/v1', rateLimit({ windowMs: 60_000, max: 240, standardHeaders: true, legacyHeaders: false }), createApi(ctx));
@@ -7,6 +7,7 @@
@@ -7,6 +7,7 @@
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.deploy.create upload deploys, list them and their logs, activate, roll back
* host.domain.manage add, verify and remove custom domains
* 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.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
* 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.
* as (domain/access.js). Browsers are judged by their role alone.
@@ -18,6 +19,7 @@ const CAPABILITIES = Object.freeze({
@@ -18,6 +19,7 @@ const CAPABILITIES = Object.freeze({
DEPLOY_CREATE: 'host.deploy.create',
DEPLOY_CREATE: 'host.deploy.create',
DOMAIN_MANAGE: 'host.domain.manage',
DOMAIN_MANAGE: 'host.domain.manage',
SITE_CONFIG: 'host.site.config',
SITE_CONFIG: 'host.site.config',
+ RESOURCE_READ: 'host.resource.read',
});
});
/** → { allowed, code, reason } (capabilities.check()). */
/** → { allowed, code, reason } (capabilities.check()). */
new file mode 100644
new file mode 100644
@@ -0,0 +1,201 @@
@@ -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,
+};
@@ -47,7 +47,7 @@ runTests([
@@ -47,7 +47,7 @@ runTests([
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
const m = JSON.parse(fs.readFileSync(require.resolve('openvibe-contracts/manifests/services/host.json'), 'utf8'));
contracts.assertValid('registry.service-manifest', m);
contracts.assertValid('registry.service-manifest', m);
assert.strictEqual(m.id, 'host');
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 () => {
test('as root, an inventory that is not root-owned or is writable by others is refused', async () => {
@@ -13,20 +13,6 @@ const { listen } = require('./stageb/mocks');
@@ -13,20 +13,6 @@ const { listen } = require('./stageb/mocks');
const { CAPABILITIES } = require('../server/auth/capabilities');
const { CAPABILITIES } = require('../server/auth/capabilities');
const { EVENT_TYPES } = require('../server/events/outbox');
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 () => {
(async () => {
const ids = Object.values(CAPABILITIES);
const ids = Object.values(CAPABILITIES);
const caps = ids.map((id) => contracts.capabilities.get(id));
const caps = ids.map((id) => contracts.capabilities.get(id));
@@ -36,7 +22,10 @@ const PENDING_CONTRACTS_RELEASE = {
@@ -36,7 +22,10 @@ const PENDING_CONTRACTS_RELEASE = {
for (const [i, c] of caps.entries()) {
for (const [i, c] of caps.entries()) {
assert.ok(c, `${ids[i]} is not in openvibe-contracts`);
assert.ok(c, `${ids[i]} is not in openvibe-contracts`);
assert.strictEqual(c.owner, 'host');
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 = {
@@ -50,16 +39,8 @@ const PENDING_CONTRACTS_RELEASE = {
const cap = caps.find((c) => c.id === r.cap);
const cap = caps.find((c) => c.id === r.cap);
assert.ok(cap, r.cap);
assert.ok(cap, r.cap);
const key = `${r.method} ${r.route}`;
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`);
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 () => {
await check('the released service manifest declares every event the service emits', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,193 @@
@@ -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();
+})();