openvibers/openvibe.services Commits
Services: partial pages in the body (common.resource-list-result@1 partial); services.resource.read registered; contracts 0.110.0 (#2)
Changes
@@@ -5,9 +5,9 @@ on:pull_request:jobs:# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).-- # contracts-service: services — Services' own manifest is a placeholder until plan T13 step 4 registers-- # it (and services.resource.read, PROPOSED in server/auth.js until then), so the check reads this-- # repository's use of the pinned contracts; the contract ids it validates today: common.resource-summary@1.++ # contracts-service: services — plan T13 step 4 registered Services' manifest and services.resource.read++ # (openvibe-contracts v0.110.0), so the check reads this repository's use of the pinned contracts; the++ # contract ids it validates today: common.resource-summary@1, common.resource-list-result@1.test:uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176with:@@@ -9,7 +9,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite indevelopment, PostgreSQL through PgBouncer in production), `server/app.js`, `server/index.js`,`server/network.js` (the Network key provider, user-token verification, project membership),-- `server/auth.js` (`services.resource.read` in a `PROPOSED` set — plan T13 step 4 registers it),++ `server/auth.js` (`services.resource.read`, registered in the pinned openvibe-contracts as active and++ first-party, so contracts' manifest decides it),`server/observability.js` (`/api/health`, truthful `/api/ready`, loopback-only `/metrics`),`server/events/outbox.js` (idle: no `services.*` event type is registered yet),`migrations/0001_initial.sql` (the seven tables Services owns), `STATUS.json`, the nginx vhost and the@@@ -17,8 +18,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo- The authority registry (plan T13 step 6, `server/authorities/index.js`): built at boot from the pinnedopenvibe-contracts service manifests — every non-placeholder service whose manifest lists an `active``<id>.resource.read` capability, with its manifest `internalOrigin` (`SERVICES_<ID>_URL` overrides) and-- audience `openvibe.<id>`. With contracts v0.108.0: network, events, host, codes, media. Services itself is-- never an authority.++ audience `openvibe.<id>`. With contracts v0.110.0: network, events, host, codes, media (services.resource.read is++ Services' own id; Services itself is never an authority).- One adapter per authority (`server/authorities/adapter.js`) and the per-authority token(`server/authorities/tokens.js`): `GET /api/v1/resources` and `GET /api/v1/resources/:ovrn` over theauthority's loopback origin with a Network client-credentials token for its audience, scoped to its@@@ -29,9 +30,10 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything beloscoped read stays scoped. `GET /api/v1/resources/:ovrn` resolves the OVRN with contracts' one parser and asksthe authority that owns it. `GET /api/v1/authorities` lists the registry (first-party).- Partial pages: a slow, refusing or unreachable authority — or a Network that cannot mint its token — never-- fails the page; its rows are omitted and it is named in `X-OpenVibe-Partial-Authorities`-- (ADR-046 section 6). `common.resource-list-result@1` has no field for it; the contract gap is recorded in the-- README.++ fails the page; its rows are omitted and it is named in the body's `partial: [{ service, code }]`++ (`common.resource-list-result@1`, added in contracts 1.1.0), the code being the problem code the authority++ answered or Services' own `services.authority_timeout` / `services.authority_unavailable` when nothing answered++ (ADR-046 section 6). A complete page carries no `partial` key.- The person-facing path: a Network user token reads only projects the person owns or belongs to, one at a time,with membership answered by Network's developer projects API using the caller's own token (non-members get thesame 404 Network gives). Never an unscoped person, and never through an app, mod or agent token — the service@@@ -42,7 +44,7 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo- Services aggregates authorities and owns no other service's rows (ADR-048): it never opens, writes or migratesanother service's database, and every control operation (plan T13 step 10) will be a call to the owningauthority.--- `services.resource.read` is not registered in openvibe-contracts yet; it is enforced from the `PROPOSED` set in-- `server/auth.js` with contracts' own grant rules, so step 4 registers exactly what this service enforces.++- `services.resource.read` is registered in the pinned openvibe-contracts (active, first-party); `server/auth.js`++ lets contracts' own manifest and grant rules decide it, and an id contracts does not know is refused.- The repository is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_services` database,`/etc/openvibe/services.env` and `openvibe.services` DNS (see STATUS.json).@@@ -25,16 +25,15 @@ This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out ind| Method and path | Capability | Request → answer ||---------------------------------------------------|---------------------------|------------------|--| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in `X-OpenVibe-Partial-Authorities` |++| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in the body's `partial: [{ service, code }]` || `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) || `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) || `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest || `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |--`services.resource.read` is **PROPOSED**: it is not registered in the pinned openvibe-contracts yet (plan T13 step 4--does that after this repository exists). It lives in `server/auth.js` in a `PROPOSED` set and is enforced with--contracts' own grant rules (`capabilities.grants`, wildcards included), so what step 4 registers is exactly what this--service enforces. `services.resource.control` arrives with step 10.++`services.resource.read` is registered in the pinned openvibe-contracts (v0.110.0: `status: active`, `visibility:++first-party`). Contracts' manifest and grant rules (`capabilities.check`, wildcards included) decide it, and an id++contracts does not know is refused, never allowed. `services.resource.control` arrives with step 10.Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,@@@ -68,12 +67,12 @@ it. Services adds:so a page walk repeats nothing and skips nothing while the underlying data is stable;- **local filters** for `project`, `kind` and `service` on top of the authorities' own answers: a scoped read (aperson's, above all) never shows a row outside its scope even if an authority ignores a query parameter;--- **the partial answer**. `common.resource-list-result@1` has no field for the authorities that could not be read-- (its only properties are `resources` and `next_cursor`, `additionalProperties: false`), so a page whose fan-out was-- incomplete omits those rows and names the authorities in the `X-OpenVibe-Partial-Authorities` response header.-- **Contract gap (for a later Contracts release):** a `common.resource-list-result@1` field for the partial-- authorities (e.g. `stale: [{ service, status, code }]`, or a `partial: true` and a separate list) would let a-- client read the degradation from the body instead of a header.++- **the partial answer**. `common.resource-list-result@1`'s `partial` array (contracts 1.1.0) names the authorities++ a page could not read, each with the problem code it answered: a page whose fan-out was incomplete omits those++ rows and carries `partial: [{ service, code }]`, and a complete page carries no `partial` key at all. The code is++ the authority's own problem code when it answered one, and Services' own `resources.authority_timeout` /++ `resources.authority_unavailable` when nothing answered (a timeout, or a network or token-mint failure) — nothing++ new is registered in contracts.An authority is never required to answer for the index to answer: a timeout (default 5 s per authority), a refusal,a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page@@@ -83,10 +82,11 @@ intact.`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:every service whose manifest lists `<id>.resource.read` **and** whose capability manifest says `status: active` is an--authority. With contracts v0.108.0 that is **network, events, host, codes and media**; the moment a service's--manifest turns that on, it is in the registry on the next boot, and nothing here is hand-maintained. Services itself--is never an authority. Each authority's loopback origin comes from its manifest (`internalOrigin`);--`SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is `openvibe.<id>`.++authority. With contracts v0.110.0 that is **network, events, host, codes and media** (services.resource.read is++Services' own id, and Services is never its own authority); the moment a service's manifest turns that on, it is in++the registry on the next boot, and nothing here is hand-maintained. Each authority's loopback origin comes from its++manifest (`internalOrigin`); `SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is++`openvibe.<id>`.There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and`*.resource.*` ingestion are step 7.@@@ -116,7 +116,7 @@ There is no polling loop: the index reads the authorities on each request (stepper-authority tokens, and the developer projects API for membership.- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at thecurrent pin. Every one of them is optional to serve, by design.--- `openvibe-contracts` v0.108.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0 (package.json).++- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.12.0 (package.json).## Tests@@@ -143,7 +143,7 @@ otherwise every authority refuses and the index is partial (correctly, and visib## Versions<!-- versions:start -->--- openvibe-contracts: v0.108.0++- openvibe-contracts: v0.110.0- openvibe-sdk: v0.34.0--- openvibe-shared: v2.11.0++- openvibe-shared: v2.12.0<!-- versions:end -->@@@ -32,14 +32,16 @@ a report should assume:- **a scoped read stays scoped.** Services filters `project` (and `kind`, `service`) on the merged rows itself, so anauthority that ignores a query parameter cannot leak another project's resource into a person's page.- **a service token is judged on its own claims.** Audience `openvibe.services`, `svc:` principal, capability-- `services.resource.read` (PROPOSED until plan T13 step 4 registers it — enforced with contracts' own grant rules).++ `services.resource.read` (registered in the pinned openvibe-contracts, active and first-party — enforced with++ contracts' own manifest and grant rules).A bad token is refused, never downgraded to anonymous, and an app, mod or agent token is refused even with thegrant: a person reads with their own user token, never through a developer app's credential.- **no secret is stored, logged or served.** The per-authority client-credentials tokens are minted at call time,cached in memory with a 60 s refresh skew and never written to disk, a log line or a response body. The only routethat shows operational detail, `GET /api/v1/authorities`, is first-party (a service token) and carries no secret.--- **a failure degrades, it never stops.** An authority or Network being down yields a partial page named in-- `X-OpenVibe-Partial-Authorities`; the rest of the index keeps serving (ADR-046 section 6).++- **a failure degrades, it never stops.** An authority or Network being down yields a partial page whose body's++ `partial: [{ service, code }]` names the authorities that did not answer; the rest of the index keeps serving++ (ADR-046 section 6).- nothing starts at module load (the key refresher and the outbox relay start in `server/index.js` `main()`), and`/metrics` answers only a direct loopback caller that no proxy relayed.@@@ -7,27 +7,27 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)","port": 4930,"domain": "openvibe.services",-- "contracts": "openvibe-contracts v0.108.0",++ "contracts": "openvibe-contracts v0.110.0","sdk": "openvibe-sdk v0.34.0",-- "shared": "openvibe-shared v2.11.0",++ "shared": "openvibe-shared v2.12.0","packages": [-- "openvibe-contracts v0.108.0",++ "openvibe-contracts v0.110.0","openvibe-sdk v0.34.0",-- "openvibe-shared v2.11.0"++ "openvibe-shared v2.12.0"],"capabilities": ["services.resource.read"],-- "capabilitiesRegistered": false,-- "capabilitiesNote": "PROPOSED (server/auth.js): services.resource.read is not in the pinned openvibe-contracts yet; plan T13 step 4 registers it (and the later services.resource.control, services.recipe.run, services.usage.read, services.cost.read) after this repository exists. Services enforces exactly the id it will register.",++ "capabilitiesRegistered": true,++ "capabilitiesNote": "services.resource.read is registered in the pinned openvibe-contracts (status active, visibility first-party): contracts' manifest and grant rules decide it and an id contracts does not know is refused, never allowed. The later services.resource.control, services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.","events": [],"deployed": false,"deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /api/, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.","features": [-- "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.108.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",++ "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.110.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority","one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)","the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",-- "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the X-OpenVibe-Partial-Authorities header — common.resource-list-result@1 has no field for it (ADR-046 section 6; the contract gap is recorded in the README)",++ "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the body's partial array as { service, code } — the problem code the authority answered, or Services' own services.authority_timeout / services.authority_unavailable when nothing answered (common.resource-list-result@1, contracts 1.1.0; a complete page carries no partial key; ADR-046 section 6)","GET /api/v1/resources/:ovrn routes the name with openvibe-contracts' one parser to the authority that owns it and answers its common.resource-summary@1; a malformed name or an unknown service is 400, the authority's 404 is 404, anything else is 502","GET /api/v1/authorities: the registry as it stands (id, origin, audience, capability, manifest version) — Services-local, no contract yet","who may read: a first-party svc: Network service token holding services.resource.read, or a person's user token scoped to ONE project they own or belong to, verified with the caller's own token against Network's developer projects API (GET /api/v1/projects/:project; non-members are 404, existence is not disclosed). Never an unscoped person, and never an app, mod or agent token even with the grant",@@@ -35,7 +35,7 @@"migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"],"notYet": [-- "plan T13 step 4 (OpenVibe.Contracts registers the services.* capabilities, the service manifest leaves placeholder), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"++ "plan T13 step 4 done in OpenVibe.Contracts v0.110.0 (services.resource.read is registered, active and first-party), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"],"updated": "2026-10-07"}@@@ -97,7 +97,7 @@ server {# ── The resource index ──────────────────────────────────# The fan-out is slow by nature (it reads every authority): give it a generous read timeout while# the app's own per-authority timeout keeps one slow authority from holding the page (it lands in-- # X-OpenVibe-Partial-Authorities instead).++ # the page's `partial` array instead).location /api/ {limit_req zone=ovservices_api burst=40 nodelay;limit_req_status 429;@@@ -13,9 +13,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0","pg": "^8.23.0"},"devDependencies": {@@@ -778,9 +778,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.108.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",-- "integrity": "sha512-T07n7B482/XIdSD3ikehPGHFUTWsyGaeLCF2SerWsKD1V09s1RYs7TD9+Y2z7vdxYjSYhQPMHr1r2xP3F9xIbA==",++ "version": "0.110.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",++ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -823,9 +823,9 @@}},"node_modules/openvibe-shared": {-- "version": "2.11.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",-- "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",++ "version": "2.12.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",++ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==","license": "MIT","dependencies": {"jsonwebtoken": "^9.0.0"@@@ -24,9 +24,9 @@"express": "^4.21.2","express-rate-limit": "^7.5.0","helmet": "^8.1.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0","pg": "^8.23.0"},"devDependencies": {@@@ -5,12 +5,12 @@** | Method & path | Capability | Request → answer |* |----------------------------|----------------------------|-----------------------------------------------------------|-- * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; the authorities a page could not read are named in X-OpenVibe-Partial-Authorities) |++ * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; a page that could not read one names it in `partial: [{ service, code }]`) |* | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |* | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |*-- * services.resource.read is PROPOSED (server/auth.js): it is not in the pinned openvibe-contracts yet-- * (plan T13 step 4 registers it), and Services enforces exactly the id it will register.++ * services.resource.read is registered in the pinned openvibe-contracts (status active, visibility++ * first-party): contracts' manifest decides it, and an id contracts does not know is refused, never allowed.** Who may call: a Network service token holding services.resource.read (any project, ?project= only* narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their@@@ -63,14 +63,16 @@ function v1Router({ config, apiAuth, index, authorities }) {const r = express.Router();// One merged page over every authority (or the one ?service= names). A failing authority's rows are-- // omitted and named in the partial header; the rest of the page is served.++ // omitted and named in the body's `partial` array (common.resource-list-result@1); the rest of the++ // page is served, and a complete page carries no `partial` key at all.r.get('/resources', async (req, res) => {apiAuth.requireRead(req);const filter = listQuery(req.query, config);const scope = await apiAuth.authorizeProject(req, filter.project);const page = await index.list({ ...filter, project: scope.project });-- if (page.partial.length) res.set('X-OpenVibe-Partial-Authorities', page.partial.join(', '));-- res.json({ resources: page.resources, next_cursor: page.next_cursor });++ res.json(page.partial.length++ ? { resources: page.resources, next_cursor: page.next_cursor, partial: page.partial }++ : { resources: page.resources, next_cursor: page.next_cursor });});// One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),@@@ -14,10 +14,10 @@** A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.*-- * Capabilities: Services' capability ids are not registered in openvibe-contracts yet (plan T13 step 4-- * registers them after this repository exists). They live here in PROPOSED, and PROPOSED ids are decided-- * with contracts' own grant rules (wildcards included) so that what step 4 registers is exactly what-- * Services enforces. Once an id is registered, the contracts manifest decides, unchanged.++ * Capabilities: services.resource.read is registered in the pinned openvibe-contracts (status active,++ * visibility first-party), so contracts' manifest and grant rules decide it (wildcards included) — and++ * an id contracts does not know is refused, never allowed. Services enforces exactly the id the manifest++ * names. services.resource.control arrives with plan T13 step 10.*/const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');const { ServiceError } = require('./util');@@@ -27,20 +27,15 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;const ANON = Object.freeze({ kind: 'anonymous' });/**-- * The capabilities Services enforces (PROPOSED: no manifest in the pinned openvibe-contracts yet).-- * services.resource.read is the whole read side of the resource index (list, one, the authority list).++ * The capabilities Services enforces. services.resource.read — active and first-party in the pinned++ * openvibe-contracts — is the whole read side of the resource index (list, one, the authority list).* services.resource.control arrives with plan T13 step 10.*/const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });--const PROPOSED = Object.freeze(new Set(Object.values(CAPS)));--/** One capability against a token's claims: contracts' manifest where there is one, the PROPOSED grant rules where not. */++/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */function capabilityDecision(claims, id) {-- if (!PROPOSED.has(id)) return capabilities.check(claims, id);-- const allowed = capabilities.grants(claims && claims.cap, id);-- return allowed-- ? { allowed: true, code: null, reason: null }-- : { allowed: false, code: 'capability.denied', reason: `${id} not granted` };++ return capabilities.check(claims, id);}function decodePayload(token) {@@@ -153,4 +148,4 @@ function createApiAuth({ config, keys, userAuth, membership }) {return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };}--module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROPOSED, PROJECT_RE, PRINCIPAL_SUB };++module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };@@@ -5,7 +5,7 @@* and where each one listens.** An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as-- * `active`. At v0.108.0 that is network, media, events, host and codes; the moment a service's manifest++ * `active`. At v0.110.0 that is network, media, events, host and codes; the moment a service's manifest* (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is* hand-maintained, and nothing here is another service's data. Services itself is never an authority:* it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.@@@ -9,9 +9,9 @@* serve its one capability** The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades-- * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page,-- * named in X-OpenVibe-Partial-Authorities, not a red readiness — /api/v1/authorities lists the registry-- * as it stands.++ * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page++ * (common.resource-list-result@1's `partial` array), not a red readiness — /api/v1/authorities lists the++ * registry as it stands.** Gauges: the authorities the registry reads. Counts only, never a token, never a resource.*/@@@ -15,11 +15,10 @@* - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a* scoped read (a person's, above all) must never show a row outside its scope even if an authority* ignores a query parameter,-- * - and the partial answer. common.resource-list-result@1 has no field for the authorities that-- * could not be read (its only fields are `resources` and `next_cursor`, additionalProperties:-- * false), so a partial page names them in the X-OpenVibe-Partial-Authorities response header —-- * their rows are omitted, the rest of the page is served (ADR-046 section 6: an authority, or-- * Network, being down degrades the index; it never stops it).++ * - and the partial answer. common.resource-list-result@1's `partial` array (contracts 1.1.0) names++ * the authorities this merge could not read and the problem code each answered — their rows are++ * omitted, the rest of the page is served (ADR-046 section 6: an authority, or Network, being++ * down degrades the index; it never stops it). A complete page carries no `partial` key at all.** One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser* (contracts.resources.parse), resolves the owning service and asks that authority directly.@@@ -32,6 +31,15 @@ const { ServiceError, parseOvrn } = require('./util');const CURSOR_V = 'v1';const SEP = '\u0000';++/**++ * The problem code a partial page names for one authority. The authority's own problem code when it++ * answered one; Services' own resources.* codes when nothing answered — resources.authority_timeout when the request or++ * the cursor walk timed out, resources.authority_unavailable when the network (or Network's token mint)++ * failed. The codes live here, in the body only; nothing new is registered in contracts.++ */++const PARTIAL_CODES = Object.freeze({ 'sdk.timeout': 'resources.authority_timeout', 'sdk.network_error': 'resources.authority_unavailable' });++const partialCodeOf = (stale) => PARTIAL_CODES[stale.code] || stale.code || 'resources.authority_unavailable';++/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);@@@ -68,8 +76,9 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = g}/**-- * One merged page. `{ resources, next_cursor, partial }` — `partial` lists the authority ids whose-- * rows this page could not include (their cursor walk failed, timed out or was refused).++ * One merged page. `{ resources, next_cursor, partial }` — `partial` lists `{ service, code }` for the++ * authorities whose rows this page could not include (their cursor walk failed, timed out or was++ * refused), `code` being the problem code each one answered, in the caller's terms.*/async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;@@@ -87,12 +96,12 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = gif (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');const rest = after ? merged.filter((r) => compare(sortKeyOf(r), after) > 0) : merged;const page = rest.slice(0, limit);-- const partial = stale.map((s) => idByOrigin.get(s.authority) || s.authority);++ const partial = stale.map((s) => ({ service: idByOrigin.get(s.authority) || s.authority, code: partialCodeOf(s) }));// One line when the set of silent authorities changes — an operator wants to know, a log wants no-- // flood; the caller reads the same fact from the header.-- const signature = partial.join(',');++ // flood; the caller reads the same fact from the response body's `partial`.++ const signature = partial.map((p) => `${p.service} (${p.code})`).join(', ');if (signature !== lastPartial) {-- if (signature) log.warn(`[Services] index partial: ${signature} did not answer`);++ if (signature) log.warn(`[Services] index partial: ${signature}`);lastPartial = signature;}return {@@@ -1,10 +1,10 @@'use strict';/**-- * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.108.0): the registry is++ * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.110.0): the registry is* derived from the released service manifests — every non-placeholder service whose manifest lists an* active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest-- * loopback origins and their audiences; Services itself, placeholders and services without the-- * capability are not authorities.++ * loopback origins and their audiences; Services itself (whose manifest now lists++ * services.resource.read), placeholders and services without the capability are not authorities.** node test/authorities.test.js*/@@@ -17,10 +17,12 @@ const { check, done } = require('./helpers/app');const quiet = { warn() {}, log() {}, error() {} };async function main() {-- await check('the pin lists exactly five active <id>.resource.read authorities', () => {++ await check('the pin lists six active <id>.resource.read capabilities, five of them authorities', () => {const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();-- assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read']);++ assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read', 'services.resource.read']);for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);++ // services.resource.read is Services' own id; Services is never its own authority (the next check).++ assert.ok(!createAuthorities(loadConfig({}), { log: quiet }).ids().includes('services'));});await check('the registry is built from those manifests, with the manifest origins and audiences', () => {@@@ -2,13 +2,13 @@/*** The resource index over stub authorities (plan T13 step 6): the fan-out and merge (openvibe-sdk/* resources), the merged page and its stable cursor, the project/kind/service filters, the person-facing-- * scoping, an authority that times out or refuses (a partial page, never a failed one), the :ovrn route,-- * and the token cache (one mint per authority).++ * scoping, an authority that times out or refuses (a body-partial page, never a failed one), the :ovrn++ * route, and the token cache (one mint per authority).** node test/index.test.js*/const assert = require('assert');--const { ids } = require('openvibe-contracts');++const { ids, validate } = require('openvibe-contracts');const { boot, check, done, newProject } = require('./helpers/app');const { summary } = require('./helpers/stubs');@@@ -38,7 +38,7 @@ async function main() {do {const r = await t.call('GET', `/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, {});assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), null, 'no authority is partial here');++ assert.ok(!('partial' in r.json), 'no authority is partial here: a complete page has no partial key');assert.ok(r.json.resources.length <= 2, 'limit=2');seen.push(...ovrnsOf(r.json));cursor = r.json.next_cursor;@@@ -129,7 +129,7 @@ async function main() {} finally { await lenient.close(); }});-- await check('a slow authority is omitted and named; the rest of the page is served', async () => {++ await check('a slow authority is omitted and named in the body; the rest of the page is served', async () => {const slow = await boot({authorities: [{ id: 'alpha', resources: alpha },@@@ -140,7 +140,7 @@ async function main() {try {const r = await slow.call('GET', '/api/v1/resources');assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');++ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'resources.authority_timeout' }], 'a timed-out authority is named with Services’ own timeout code');const got = ovrnsOf(r.json);assert.deepStrictEqual(got.slice().sort(), [...alpha, ...gamma].map((x) => x.ovrn).sort());assert.deepStrictEqual(r.json.resources, r.json.resources.slice().sort((a, b) => (a.ovrn < b.ovrn ? -1 : 1)), 'partial does not disturb the order');@@@ -152,11 +152,30 @@ async function main() {try {const r = await refusing.call('GET', '/api/v1/resources');assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');++ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'authority.refused' }], 'a refusing authority is named with the problem code it answered');assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));} finally { await refusing.close(); }});++ await check('a complete page has no partial; a partial page validates against common.resource-list-result@1', async () => {++ const complete = await t.call('GET', '/api/v1/resources');++ assert.strictEqual(complete.status, 200, complete.text);++ assert.ok(!('partial' in complete.json), 'a complete page carries no partial key');++ const completeCheck = validate('common.resource-list-result@1', complete.json);++ assert.ok(completeCheck.valid, `a complete page is a common.resource-list-result@1: ${JSON.stringify(completeCheck.errors)}`);++++ const one = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta, status: 503 }] });++ try {++ const r = await one.call('GET', '/api/v1/resources');++ assert.strictEqual(r.status, 200, r.text);++ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'authority.refused' }], 'partial names the authority and the problem code it answered');++ const result = validate('common.resource-list-result@1', r.json);++ assert.ok(result.valid, `a partial page is a common.resource-list-result@1 (partial included): ${JSON.stringify(result.errors)}`);++ assert.ok(r.json.resources.length > 0, 'the rest of the page is served');++ assert.ok(!r.headers.get('x-openvibe-partial-authorities'), 'the partial authorities are a body field now, never a header');++ } finally { await one.close(); }++ });++await check('GET /api/v1/resources/:ovrn asks the owning authority and answers its summary', async () => {const target = beta[0];const r = await t.call('GET', `/api/v1/resources/${encodeURIComponent(target.ovrn)}`, {});@@@ -218,7 +237,7 @@ async function main() {try {const r = await broken.call('GET', '/api/v1/resources');assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'alpha');++ assert.deepStrictEqual(r.json.partial, [{ service: 'alpha', code: 'resources.authority_unavailable' }], 'a token mint that fails leaves the authority named unavailable');assert.deepStrictEqual(r.json.resources, []);} finally { await broken.close(); }});
@@ -5,9 +5,9 @@ on:
@@ -5,9 +5,9 @@ on:
pull_request:
pull_request:
jobs:
jobs:
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
# install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
- # contracts-service: services — Services' own manifest is a placeholder until plan T13 step 4 registers
- # it (and services.resource.read, PROPOSED in server/auth.js until then), so the check reads this
- # repository's use of the pinned contracts; the contract ids it validates today: common.resource-summary@1.
+ # contracts-service: services — plan T13 step 4 registered Services' manifest and services.resource.read
+ # (openvibe-contracts v0.110.0), so the check reads this repository's use of the pinned contracts; the
+ # contract ids it validates today: common.resource-summary@1, common.resource-list-result@1.
test:
test:
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
with:
with:
@@ -9,7 +9,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -9,7 +9,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite in
- The repository kernel (plan T13 step 3): `server/config.js` on port 4930, `server/db.js` (PGlite in
development, PostgreSQL through PgBouncer in production), `server/app.js`, `server/index.js`,
development, PostgreSQL through PgBouncer in production), `server/app.js`, `server/index.js`,
`server/network.js` (the Network key provider, user-token verification, project membership),
`server/network.js` (the Network key provider, user-token verification, project membership),
- `server/auth.js` (`services.resource.read` in a `PROPOSED` set — plan T13 step 4 registers it),
+ `server/auth.js` (`services.resource.read`, registered in the pinned openvibe-contracts as active and
+ first-party, so contracts' manifest decides it),
`server/observability.js` (`/api/health`, truthful `/api/ready`, loopback-only `/metrics`),
`server/observability.js` (`/api/health`, truthful `/api/ready`, loopback-only `/metrics`),
`server/events/outbox.js` (idle: no `services.*` event type is registered yet),
`server/events/outbox.js` (idle: no `services.*` event type is registered yet),
`migrations/0001_initial.sql` (the seven tables Services owns), `STATUS.json`, the nginx vhost and the
`migrations/0001_initial.sql` (the seven tables Services owns), `STATUS.json`, the nginx vhost and the
@@ -17,8 +18,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -17,8 +18,8 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
- The authority registry (plan T13 step 6, `server/authorities/index.js`): built at boot from the pinned
- The authority registry (plan T13 step 6, `server/authorities/index.js`): built at boot from the pinned
openvibe-contracts service manifests — every non-placeholder service whose manifest lists an `active`
openvibe-contracts service manifests — every non-placeholder service whose manifest lists an `active`
`<id>.resource.read` capability, with its manifest `internalOrigin` (`SERVICES_<ID>_URL` overrides) and
`<id>.resource.read` capability, with its manifest `internalOrigin` (`SERVICES_<ID>_URL` overrides) and
- audience `openvibe.<id>`. With contracts v0.108.0: network, events, host, codes, media. Services itself is
- never an authority.
+ audience `openvibe.<id>`. With contracts v0.110.0: network, events, host, codes, media (services.resource.read is
+ Services' own id; Services itself is never an authority).
- One adapter per authority (`server/authorities/adapter.js`) and the per-authority token
- One adapter per authority (`server/authorities/adapter.js`) and the per-authority token
(`server/authorities/tokens.js`): `GET /api/v1/resources` and `GET /api/v1/resources/:ovrn` over the
(`server/authorities/tokens.js`): `GET /api/v1/resources` and `GET /api/v1/resources/:ovrn` over the
authority's loopback origin with a Network client-credentials token for its audience, scoped to its
authority's loopback origin with a Network client-credentials token for its audience, scoped to its
@@ -29,9 +30,10 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -29,9 +30,10 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
scoped read stays scoped. `GET /api/v1/resources/:ovrn` resolves the OVRN with contracts' one parser and asks
scoped read stays scoped. `GET /api/v1/resources/:ovrn` resolves the OVRN with contracts' one parser and asks
the authority that owns it. `GET /api/v1/authorities` lists the registry (first-party).
the authority that owns it. `GET /api/v1/authorities` lists the registry (first-party).
- Partial pages: a slow, refusing or unreachable authority — or a Network that cannot mint its token — never
- Partial pages: a slow, refusing or unreachable authority — or a Network that cannot mint its token — never
- fails the page; its rows are omitted and it is named in `X-OpenVibe-Partial-Authorities`
- (ADR-046 section 6). `common.resource-list-result@1` has no field for it; the contract gap is recorded in the
- README.
+ fails the page; its rows are omitted and it is named in the body's `partial: [{ service, code }]`
+ (`common.resource-list-result@1`, added in contracts 1.1.0), the code being the problem code the authority
+ answered or Services' own `services.authority_timeout` / `services.authority_unavailable` when nothing answered
+ (ADR-046 section 6). A complete page carries no `partial` key.
- The person-facing path: a Network user token reads only projects the person owns or belongs to, one at a time,
- The person-facing path: a Network user token reads only projects the person owns or belongs to, one at a time,
with membership answered by Network's developer projects API using the caller's own token (non-members get the
with membership answered by Network's developer projects API using the caller's own token (non-members get the
same 404 Network gives). Never an unscoped person, and never through an app, mod or agent token — the service
same 404 Network gives). Never an unscoped person, and never through an app, mod or agent token — the service
@@ -42,7 +44,7 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
@@ -42,7 +44,7 @@ Notable changes to OpenVibe.Services. The service is unreleased; everything belo
- Services aggregates authorities and owns no other service's rows (ADR-048): it never opens, writes or migrates
- Services aggregates authorities and owns no other service's rows (ADR-048): it never opens, writes or migrates
another service's database, and every control operation (plan T13 step 10) will be a call to the owning
another service's database, and every control operation (plan T13 step 10) will be a call to the owning
authority.
authority.
-- `services.resource.read` is not registered in openvibe-contracts yet; it is enforced from the `PROPOSED` set in
- `server/auth.js` with contracts' own grant rules, so step 4 registers exactly what this service enforces.
+- `services.resource.read` is registered in the pinned openvibe-contracts (active, first-party); `server/auth.js`
+ lets contracts' own manifest and grant rules decide it, and an id contracts does not know is refused.
- The repository is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_services` database,
- The repository is not deployed; the first deploy needs `ovhost`'s inventory, the `ov_services` database,
`/etc/openvibe/services.env` and `openvibe.services` DNS (see STATUS.json).
`/etc/openvibe/services.env` and `openvibe.services` DNS (see STATUS.json).
@@ -25,16 +25,15 @@ This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out ind
@@ -25,16 +25,15 @@ This repository is at plan **T13 steps 3 and 6**: the kernel and the fan-out ind
| Method and path | Capability | Request → answer |
| Method and path | Capability | Request → answer |
|---------------------------------------------------|---------------------------|------------------|
|---------------------------------------------------|---------------------------|------------------|
-| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in `X-OpenVibe-Partial-Authorities` |
+| `GET /api/v1/resources?project=&kind=&service=&cursor=&limit=` | `services.resource.read` | `common.no-body@1` → `common.resource-list-result@1`, merged over every authority; an authority a page could not read is named in the body's `partial: [{ service, code }]` |
| `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) |
| `GET /api/v1/resources/:ovrn` | `services.resource.read` | `common.no-body@1` → `common.resource-summary@1` from the authority that owns the resource (URL-encode the OVRN: it carries a `/`) |
| `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) |
| `GET /api/v1/authorities` | `services.resource.read` | `common.no-body@1` → `{ authorities: […] }` — the registry as this instance built it. Services-local: no contract names it yet, and it is first-party (a service token, not a person) |
| `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest |
| `GET /api/health`, `GET /api/ready`, `GET /release.json` | none | liveness, truthful readiness (the database and the Network signing key; a down authority is a partial page, not a red readiness), the release manifest |
| `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |
| `GET /metrics` | none (loopback only) | Prometheus text, for a direct loopback caller only |
-`services.resource.read` is **PROPOSED**: it is not registered in the pinned openvibe-contracts yet (plan T13 step 4
-does that after this repository exists). It lives in `server/auth.js` in a `PROPOSED` set and is enforced with
-contracts' own grant rules (`capabilities.grants`, wildcards included), so what step 4 registers is exactly what this
-service enforces. `services.resource.control` arrives with step 10.
+`services.resource.read` is registered in the pinned openvibe-contracts (v0.110.0: `status: active`, `visibility:
+first-party`). Contracts' manifest and grant rules (`capabilities.check`, wildcards included) decide it, and an id
+contracts does not know is refused, never allowed. `services.resource.control` arrives with step 10.
Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |
Errors are RFC 9457 `application/problem+json`: `400 resources.bad_query | resources.bad_name |
resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,
resources.unknown_service | resources.project_required`, `401 token.missing`, `403 capability.denied`,
@@ -68,12 +67,12 @@ it. Services adds:
@@ -68,12 +67,12 @@ it. Services adds:
so a page walk repeats nothing and skips nothing while the underlying data is stable;
so a page walk repeats nothing and skips nothing while the underlying data is stable;
- **local filters** for `project`, `kind` and `service` on top of the authorities' own answers: a scoped read (a
- **local filters** for `project`, `kind` and `service` on top of the authorities' own answers: a scoped read (a
person's, above all) never shows a row outside its scope even if an authority ignores a query parameter;
person's, above all) never shows a row outside its scope even if an authority ignores a query parameter;
-- **the partial answer**. `common.resource-list-result@1` has no field for the authorities that could not be read
- (its only properties are `resources` and `next_cursor`, `additionalProperties: false`), so a page whose fan-out was
- incomplete omits those rows and names the authorities in the `X-OpenVibe-Partial-Authorities` response header.
- **Contract gap (for a later Contracts release):** a `common.resource-list-result@1` field for the partial
- authorities (e.g. `stale: [{ service, status, code }]`, or a `partial: true` and a separate list) would let a
- client read the degradation from the body instead of a header.
+- **the partial answer**. `common.resource-list-result@1`'s `partial` array (contracts 1.1.0) names the authorities
+ a page could not read, each with the problem code it answered: a page whose fan-out was incomplete omits those
+ rows and carries `partial: [{ service, code }]`, and a complete page carries no `partial` key at all. The code is
+ the authority's own problem code when it answered one, and Services' own `resources.authority_timeout` /
+ `resources.authority_unavailable` when nothing answered (a timeout, or a network or token-mint failure) — nothing
+ new is registered in contracts.
An authority is never required to answer for the index to answer: a timeout (default 5 s per authority), a refusal,
An authority is never required to answer for the index to answer: a timeout (default 5 s per authority), a refusal,
a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page
a malformed page or a Network that cannot mint its token makes that authority partial and leaves the rest of the page
@@ -83,10 +82,11 @@ intact.
@@ -83,10 +82,11 @@ intact.
`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:
`server/authorities/index.js` builds the registry at boot from the **pinned openvibe-contracts service manifests**:
every service whose manifest lists `<id>.resource.read` **and** whose capability manifest says `status: active` is an
every service whose manifest lists `<id>.resource.read` **and** whose capability manifest says `status: active` is an
-authority. With contracts v0.108.0 that is **network, events, host, codes and media**; the moment a service's
-manifest turns that on, it is in the registry on the next boot, and nothing here is hand-maintained. Services itself
-is never an authority. Each authority's loopback origin comes from its manifest (`internalOrigin`);
-`SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is `openvibe.<id>`.
+authority. With contracts v0.110.0 that is **network, events, host, codes and media** (services.resource.read is
+Services' own id, and Services is never its own authority); the moment a service's manifest turns that on, it is in
+the registry on the next boot, and nothing here is hand-maintained. Each authority's loopback origin comes from its
+manifest (`internalOrigin`); `SERVICES_<ID>_URL` overrides one for the operator. The audience Services mints for is
+`openvibe.<id>`.
There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and
There is no polling loop: the index reads the authorities on each request (step 6). The rebuildable read model and
`*.resource.*` ingestion are step 7.
`*.resource.*` ingestion are step 7.
@@ -116,7 +116,7 @@ There is no polling loop: the index reads the authorities on each request (step
@@ -116,7 +116,7 @@ There is no polling loop: the index reads the authorities on each request (step
per-authority tokens, and the developer projects API for membership.
per-authority tokens, and the developer projects API for membership.
- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at the
- The authorities: network (`http://127.0.0.1:4000`), media (4100), events (4300), codes (4900), host (4910) at the
current pin. Every one of them is optional to serve, by design.
current pin. Every one of them is optional to serve, by design.
-- `openvibe-contracts` v0.108.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.11.0 (package.json).
+- `openvibe-contracts` v0.110.0, `openvibe-sdk` v0.34.0, `openvibe-shared` v2.12.0 (package.json).
## Tests
## Tests
@@ -143,7 +143,7 @@ otherwise every authority refuses and the index is partial (correctly, and visib
@@ -143,7 +143,7 @@ otherwise every authority refuses and the index is partial (correctly, and visib
## Versions
## Versions
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.108.0
+- openvibe-contracts: v0.110.0
- openvibe-sdk: v0.34.0
- openvibe-sdk: v0.34.0
-- openvibe-shared: v2.11.0
+- openvibe-shared: v2.12.0
<!-- versions:end -->
<!-- versions:end -->
@@ -32,14 +32,16 @@ a report should assume:
@@ -32,14 +32,16 @@ a report should assume:
- **a scoped read stays scoped.** Services filters `project` (and `kind`, `service`) on the merged rows itself, so an
- **a scoped read stays scoped.** Services filters `project` (and `kind`, `service`) on the merged rows itself, so an
authority that ignores a query parameter cannot leak another project's resource into a person's page.
authority that ignores a query parameter cannot leak another project's resource into a person's page.
- **a service token is judged on its own claims.** Audience `openvibe.services`, `svc:` principal, capability
- **a service token is judged on its own claims.** Audience `openvibe.services`, `svc:` principal, capability
- `services.resource.read` (PROPOSED until plan T13 step 4 registers it — enforced with contracts' own grant rules).
+ `services.resource.read` (registered in the pinned openvibe-contracts, active and first-party — enforced with
+ contracts' own manifest and grant rules).
A bad token is refused, never downgraded to anonymous, and an app, mod or agent token is refused even with the
A bad token is refused, never downgraded to anonymous, and an app, mod or agent token is refused even with the
grant: a person reads with their own user token, never through a developer app's credential.
grant: a person reads with their own user token, never through a developer app's credential.
- **no secret is stored, logged or served.** The per-authority client-credentials tokens are minted at call time,
- **no secret is stored, logged or served.** The per-authority client-credentials tokens are minted at call time,
cached in memory with a 60 s refresh skew and never written to disk, a log line or a response body. The only route
cached in memory with a 60 s refresh skew and never written to disk, a log line or a response body. The only route
that shows operational detail, `GET /api/v1/authorities`, is first-party (a service token) and carries no secret.
that shows operational detail, `GET /api/v1/authorities`, is first-party (a service token) and carries no secret.
-- **a failure degrades, it never stops.** An authority or Network being down yields a partial page named in
- `X-OpenVibe-Partial-Authorities`; the rest of the index keeps serving (ADR-046 section 6).
+- **a failure degrades, it never stops.** An authority or Network being down yields a partial page whose body's
+ `partial: [{ service, code }]` names the authorities that did not answer; the rest of the index keeps serving
+ (ADR-046 section 6).
- nothing starts at module load (the key refresher and the outbox relay start in `server/index.js` `main()`), and
- nothing starts at module load (the key refresher and the outbox relay start in `server/index.js` `main()`), and
`/metrics` answers only a direct loopback caller that no proxy relayed.
`/metrics` answers only a direct loopback caller that no proxy relayed.
@@ -7,27 +7,27 @@
@@ -7,27 +7,27 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db)",
"port": 4930,
"port": 4930,
"domain": "openvibe.services",
"domain": "openvibe.services",
- "contracts": "openvibe-contracts v0.108.0",
+ "contracts": "openvibe-contracts v0.110.0",
"sdk": "openvibe-sdk v0.34.0",
"sdk": "openvibe-sdk v0.34.0",
- "shared": "openvibe-shared v2.11.0",
+ "shared": "openvibe-shared v2.12.0",
"packages": [
"packages": [
- "openvibe-contracts v0.108.0",
+ "openvibe-contracts v0.110.0",
"openvibe-sdk v0.34.0",
"openvibe-sdk v0.34.0",
- "openvibe-shared v2.11.0"
+ "openvibe-shared v2.12.0"
],
],
"capabilities": [
"capabilities": [
"services.resource.read"
"services.resource.read"
],
],
- "capabilitiesRegistered": false,
- "capabilitiesNote": "PROPOSED (server/auth.js): services.resource.read is not in the pinned openvibe-contracts yet; plan T13 step 4 registers it (and the later services.resource.control, services.recipe.run, services.usage.read, services.cost.read) after this repository exists. Services enforces exactly the id it will register.",
+ "capabilitiesRegistered": true,
+ "capabilitiesNote": "services.resource.read is registered in the pinned openvibe-contracts (status active, visibility first-party): contracts' manifest and grant rules decide it and an id contracts does not know is refused, never allowed. The later services.resource.control, services.recipe.run, services.usage.read and services.cost.read arrive with their plan T13 steps.",
"events": [],
"events": [],
"deployed": false,
"deployed": false,
"deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /api/, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.",
"deployment": "Not deployed: openvibe.services has no DNS, no ovhost inventory entry, no ov_services database and no /etc/openvibe/services.env yet. The release is deploy/ (nginx vhost deploy/nginx/openvibe.services.conf, unit deploy/systemd/openvibe-services.service, WorkingDirectory /opt/openvibe.services, EnvironmentFile /etc/openvibe/services.env, PORT 4930). The vhost proxies /api/health, /api/ready, /release.json and /api/, takes the client address from $remote_addr only (never appending X-Forwarded-For), and refuses /internal/ and /metrics at the edge; the app refuses a proxied /metrics caller itself. The first deploy needs Services' Network client (OV_OAUTH_CLIENT_ID=services with its secret) granted every authority's <id>.resource.read scope, or the fan-out reports every authority partial.",
"features": [
"features": [
- "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.108.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",
+ "the authority registry (server/authorities/index.js): built at boot from the pinned openvibe-contracts service manifests — every non-placeholder service whose manifest lists an active <id>.resource.read is an authority (at v0.110.0: network, events, host, codes, media), with its manifest internalOrigin (SERVICES_<ID>_URL overrides) and audience openvibe.<id>; Services itself is never an authority",
"one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",
"one adapter per authority (server/authorities/adapter.js): GET /api/v1/resources and GET /api/v1/resources/:ovrn over that authority's loopback origin with a Network client-credentials token for its audience, scoped to its resource.read capability and cached (one mint per authority, refreshed 60 s before expiry; server/authorities/tokens.js)",
"the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",
"the merged resource index (server/resources.js, GET /api/v1/resources?project=&kind=&service=&cursor=&limit=): openvibe-sdk/resources' createResourceIndex fans out and merges every authority's pages (per-authority cursor walk, concurrency 4, per-authority timeout), Services adds a deterministic (service, ovrn, id) total order and an opaque keyset cursor, and filters project/kind/service locally so a scoped read never shows a row outside its scope",
- "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the X-OpenVibe-Partial-Authorities header — common.resource-list-result@1 has no field for it (ADR-046 section 6; the contract gap is recorded in the README)",
+ "a slow, refusing or unreachable authority (or a Network that cannot mint its token) never fails the page: its rows are omitted and it is named in the body's partial array as { service, code } — the problem code the authority answered, or Services' own services.authority_timeout / services.authority_unavailable when nothing answered (common.resource-list-result@1, contracts 1.1.0; a complete page carries no partial key; ADR-046 section 6)",
"GET /api/v1/resources/:ovrn routes the name with openvibe-contracts' one parser to the authority that owns it and answers its common.resource-summary@1; a malformed name or an unknown service is 400, the authority's 404 is 404, anything else is 502",
"GET /api/v1/resources/:ovrn routes the name with openvibe-contracts' one parser to the authority that owns it and answers its common.resource-summary@1; a malformed name or an unknown service is 400, the authority's 404 is 404, anything else is 502",
"GET /api/v1/authorities: the registry as it stands (id, origin, audience, capability, manifest version) — Services-local, no contract yet",
"GET /api/v1/authorities: the registry as it stands (id, origin, audience, capability, manifest version) — Services-local, no contract yet",
"who may read: a first-party svc: Network service token holding services.resource.read, or a person's user token scoped to ONE project they own or belong to, verified with the caller's own token against Network's developer projects API (GET /api/v1/projects/:project; non-members are 404, existence is not disclosed). Never an unscoped person, and never an app, mod or agent token even with the grant",
"who may read: a first-party svc: Network service token holding services.resource.read, or a person's user token scoped to ONE project they own or belong to, verified with the caller's own token against Network's developer projects API (GET /api/v1/projects/:project; non-members are 404, existence is not disclosed). Never an unscoped person, and never an app, mod or agent token even with the grant",
@@ -35,7 +35,7 @@
@@ -35,7 +35,7 @@
"migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"
"migrations/0001_initial.sql: Services' own rows only (services_authorities, services_resources, services_catalog_entries, services_recipes, services_recipe_runs, services_control_log, services_events_outbox) — Services never writes another service's database (ADR-048)"
],
],
"notYet": [
"notYet": [
- "plan T13 step 4 (OpenVibe.Contracts registers the services.* capabilities, the service manifest leaves placeholder), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"
+ "plan T13 step 4 done in OpenVibe.Contracts v0.110.0 (services.resource.read is registered, active and first-party), step 5 done in Network, step 7 (read model + ingestion), steps 9-15 (console, control operations, recipes, usage, deploy)"
],
],
"updated": "2026-10-07"
"updated": "2026-10-07"
}
}
@@ -97,7 +97,7 @@ server {
@@ -97,7 +97,7 @@ server {
# ── The resource index ──────────────────────────────────
# ── The resource index ──────────────────────────────────
# The fan-out is slow by nature (it reads every authority): give it a generous read timeout while
# The fan-out is slow by nature (it reads every authority): give it a generous read timeout while
# the app's own per-authority timeout keeps one slow authority from holding the page (it lands in
# the app's own per-authority timeout keeps one slow authority from holding the page (it lands in
- # X-OpenVibe-Partial-Authorities instead).
+ # the page's `partial` array instead).
location /api/ {
location /api/ {
limit_req zone=ovservices_api burst=40 nodelay;
limit_req zone=ovservices_api burst=40 nodelay;
limit_req_status 429;
limit_req_status 429;
@@ -13,9 +13,9 @@
@@ -13,9 +13,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -778,9 +778,9 @@
@@ -778,9 +778,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.108.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
- "integrity": "sha512-T07n7B482/XIdSD3ikehPGHFUTWsyGaeLCF2SerWsKD1V09s1RYs7TD9+Y2z7vdxYjSYhQPMHr1r2xP3F9xIbA==",
+ "version": "0.110.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
+ "integrity": "sha512-+65KnRBVOzPN+MKSdaKzbvop6CT/wr8ZrO9mrQrRDdprRDmL4+CCOnykKYFOiMWPtoYkXlYg+GXmwmOw7eBL/g==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -823,9 +823,9 @@
@@ -823,9 +823,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"node_modules/openvibe-shared": {
- "version": "2.11.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
- "integrity": "sha512-F4P+3fZ1IslEA5Vmv5GKdL3TwuNjJ//52XNp+46BnZL3NzVTd6dl/jmdRgIWjlKN+YHKf69zqVWhWFJZG0EafA==",
+ "version": "2.12.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
+ "integrity": "sha512-GQpNFs2h61OKKNLexQwCfSJTLzNQ7fSbwqvrerB4oNdoxUgDvmdD43SELWycrb9DX8lkje4cR3ARznYIINnR8Q==",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -24,9 +24,9 @@
@@ -24,9 +24,9 @@
"express": "^4.21.2",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.108.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.110.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.34.0",
- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.11.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.12.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
"devDependencies": {
"devDependencies": {
@@ -5,12 +5,12 @@
@@ -5,12 +5,12 @@
*
*
* | Method & path | Capability | Request → answer |
* | Method & path | Capability | Request → answer |
* |----------------------------|----------------------------|-----------------------------------------------------------|
* |----------------------------|----------------------------|-----------------------------------------------------------|
- * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; the authorities a page could not read are named in X-OpenVibe-Partial-Authorities) |
+ * | GET /api/v1/resources | services.resource.read | ?project=&kind=&service=&cursor=&limit= → common.resource-list-result@1 (merged over every authority; a page that could not read one names it in `partial: [{ service, code }]`) |
* | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |
* | GET /api/v1/resources/:ovrn| services.resource.read | common.no-body@1 → common.resource-summary@1 from the authority that owns it |
* | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |
* | GET /api/v1/authorities | services.resource.read | common.no-body@1 → { authorities: […] } (Services-local; no contract yet) |
*
*
- * services.resource.read is PROPOSED (server/auth.js): it is not in the pinned openvibe-contracts yet
- * (plan T13 step 4 registers it), and Services enforces exactly the id it will register.
+ * services.resource.read is registered in the pinned openvibe-contracts (status active, visibility
+ * first-party): contracts' manifest decides it, and an id contracts does not know is refused, never allowed.
*
*
* Who may call: a Network service token holding services.resource.read (any project, ?project= only
* Who may call: a Network service token holding services.resource.read (any project, ?project= only
* narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their
* narrows), or a person's user token — scoped to ONE project they own or belong to, verified with their
@@ -63,14 +63,16 @@ function v1Router({ config, apiAuth, index, authorities }) {
@@ -63,14 +63,16 @@ function v1Router({ config, apiAuth, index, authorities }) {
const r = express.Router();
const r = express.Router();
// One merged page over every authority (or the one ?service= names). A failing authority's rows are
// One merged page over every authority (or the one ?service= names). A failing authority's rows are
- // omitted and named in the partial header; the rest of the page is served.
+ // omitted and named in the body's `partial` array (common.resource-list-result@1); the rest of the
+ // page is served, and a complete page carries no `partial` key at all.
r.get('/resources', async (req, res) => {
r.get('/resources', async (req, res) => {
apiAuth.requireRead(req);
apiAuth.requireRead(req);
const filter = listQuery(req.query, config);
const filter = listQuery(req.query, config);
const scope = await apiAuth.authorizeProject(req, filter.project);
const scope = await apiAuth.authorizeProject(req, filter.project);
const page = await index.list({ ...filter, project: scope.project });
const page = await index.list({ ...filter, project: scope.project });
- if (page.partial.length) res.set('X-OpenVibe-Partial-Authorities', page.partial.join(', '));
- res.json({ resources: page.resources, next_cursor: page.next_cursor });
+ res.json(page.partial.length
+ ? { resources: page.resources, next_cursor: page.next_cursor, partial: page.partial }
+ : { resources: page.resources, next_cursor: page.next_cursor });
});
});
// One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),
// One resource by its OVRN, straight from the authority that owns it. The OVRN is parsed first (400),
@@ -14,10 +14,10 @@
@@ -14,10 +14,10 @@
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
*
*
- * Capabilities: Services' capability ids are not registered in openvibe-contracts yet (plan T13 step 4
- * registers them after this repository exists). They live here in PROPOSED, and PROPOSED ids are decided
- * with contracts' own grant rules (wildcards included) so that what step 4 registers is exactly what
- * Services enforces. Once an id is registered, the contracts manifest decides, unchanged.
+ * Capabilities: services.resource.read is registered in the pinned openvibe-contracts (status active,
+ * visibility first-party), so contracts' manifest and grant rules decide it (wildcards included) — and
+ * an id contracts does not know is refused, never allowed. Services enforces exactly the id the manifest
+ * names. services.resource.control arrives with plan T13 step 10.
*/
*/
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
const { ServiceError } = require('./util');
const { ServiceError } = require('./util');
@@ -27,20 +27,15 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -27,20 +27,15 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
/**
/**
- * The capabilities Services enforces (PROPOSED: no manifest in the pinned openvibe-contracts yet).
- * services.resource.read is the whole read side of the resource index (list, one, the authority list).
+ * The capabilities Services enforces. services.resource.read — active and first-party in the pinned
+ * openvibe-contracts — is the whole read side of the resource index (list, one, the authority list).
* services.resource.control arrives with plan T13 step 10.
* services.resource.control arrives with plan T13 step 10.
*/
*/
const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
const CAPS = Object.freeze({ resourceRead: 'services.resource.read' });
-const PROPOSED = Object.freeze(new Set(Object.values(CAPS)));
-/** One capability against a token's claims: contracts' manifest where there is one, the PROPOSED grant rules where not. */
+/** One capability against a token's claims: contracts' manifest and grant rules decide; an unknown id is refused. */
function capabilityDecision(claims, id) {
function capabilityDecision(claims, id) {
- if (!PROPOSED.has(id)) return capabilities.check(claims, id);
- const allowed = capabilities.grants(claims && claims.cap, id);
- return allowed
- ? { allowed: true, code: null, reason: null }
- : { allowed: false, code: 'capability.denied', reason: `${id} not granted` };
+ return capabilities.check(claims, id);
}
}
function decodePayload(token) {
function decodePayload(token) {
@@ -153,4 +148,4 @@ function createApiAuth({ config, keys, userAuth, membership }) {
@@ -153,4 +148,4 @@ function createApiAuth({ config, keys, userAuth, membership }) {
return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
return { middleware, resolve, capabilityDecision, requireRead, requireServiceRead, authorizeProject, projectOf };
}
}
-module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROPOSED, PROJECT_RE, PRINCIPAL_SUB };
+module.exports = { createApiAuth, capabilityDecision, userPrincipal, verifyService, decodePayload, projectOf, CAPS, PROJECT_RE, PRINCIPAL_SUB };
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* and where each one listens.
* and where each one listens.
*
*
* An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as
* An authority is a service whose released openvibe-contracts manifest lists `<id>.resource.read` as
- * `active`. At v0.108.0 that is network, media, events, host and codes; the moment a service's manifest
+ * `active`. At v0.110.0 that is network, media, events, host and codes; the moment a service's manifest
* (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is
* (and its capability manifest) turn that on, it is in this registry on the next boot — nothing here is
* hand-maintained, and nothing here is another service's data. Services itself is never an authority:
* hand-maintained, and nothing here is another service's data. Services itself is never an authority:
* it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.
* it reads authorities, it does not index itself. A `retired` or `placeholder` service is not called.
@@ -9,9 +9,9 @@
@@ -9,9 +9,9 @@
* serve its one capability
* serve its one capability
*
*
* The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades
* The authorities are deliberately NOT readiness checks: an authority, or Network, being down degrades
- * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page,
- * named in X-OpenVibe-Partial-Authorities, not a red readiness — /api/v1/authorities lists the registry
- * as it stands.
+ * the index and never stops it (ADR-046 section 6). A body that could not be read is a partial page
+ * (common.resource-list-result@1's `partial` array), not a red readiness — /api/v1/authorities lists the
+ * registry as it stands.
*
*
* Gauges: the authorities the registry reads. Counts only, never a token, never a resource.
* Gauges: the authorities the registry reads. Counts only, never a token, never a resource.
*/
*/
@@ -15,11 +15,10 @@
@@ -15,11 +15,10 @@
* - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a
* - a local filter for `project` / `kind` / `service` on top of the authorities' own answers: a
* scoped read (a person's, above all) must never show a row outside its scope even if an authority
* scoped read (a person's, above all) must never show a row outside its scope even if an authority
* ignores a query parameter,
* ignores a query parameter,
- * - and the partial answer. common.resource-list-result@1 has no field for the authorities that
- * could not be read (its only fields are `resources` and `next_cursor`, additionalProperties:
- * false), so a partial page names them in the X-OpenVibe-Partial-Authorities response header —
- * their rows are omitted, the rest of the page is served (ADR-046 section 6: an authority, or
- * Network, being down degrades the index; it never stops it).
+ * - and the partial answer. common.resource-list-result@1's `partial` array (contracts 1.1.0) names
+ * the authorities this merge could not read and the problem code each answered — their rows are
+ * omitted, the rest of the page is served (ADR-046 section 6: an authority, or Network, being
+ * down degrades the index; it never stops it). A complete page carries no `partial` key at all.
*
*
* One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser
* One resource is never merged: /api/v1/resources/:ovrn parses the name with the one parser
* (contracts.resources.parse), resolves the owning service and asks that authority directly.
* (contracts.resources.parse), resolves the owning service and asks that authority directly.
@@ -32,6 +31,15 @@ const { ServiceError, parseOvrn } = require('./util');
@@ -32,6 +31,15 @@ const { ServiceError, parseOvrn } = require('./util');
const CURSOR_V = 'v1';
const CURSOR_V = 'v1';
const SEP = '\u0000';
const SEP = '\u0000';
+/**
+ * The problem code a partial page names for one authority. The authority's own problem code when it
+ * answered one; Services' own resources.* codes when nothing answered — resources.authority_timeout when the request or
+ * the cursor walk timed out, resources.authority_unavailable when the network (or Network's token mint)
+ * failed. The codes live here, in the body only; nothing new is registered in contracts.
+ */
+const PARTIAL_CODES = Object.freeze({ 'sdk.timeout': 'resources.authority_timeout', 'sdk.network_error': 'resources.authority_unavailable' });
+const partialCodeOf = (stale) => PARTIAL_CODES[stale.code] || stale.code || 'resources.authority_unavailable';
+
/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */
/** The order a page is cut in: service, then the OVRN when the summary has one (else its id). */
const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;
const sortKeyOf = (r) => `${r.service}${SEP}${r.ovrn || ''}${SEP}${r.id}`;
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
@@ -68,8 +76,9 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = g
@@ -68,8 +76,9 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = g
}
}
/**
/**
- * One merged page. `{ resources, next_cursor, partial }` — `partial` lists the authority ids whose
- * rows this page could not include (their cursor walk failed, timed out or was refused).
+ * One merged page. `{ resources, next_cursor, partial }` — `partial` lists `{ service, code }` for the
+ * authorities whose rows this page could not include (their cursor walk failed, timed out or was
+ * refused), `code` being the problem code each one answered, in the caller's terms.
*/
*/
async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {
async function list({ project = null, kind = null, service = null, cursor = null, limit = config.index.defaultLimit } = {}) {
const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;
const set = service ? adapters.filter((a) => a.authority.id === service) : adapters;
@@ -87,12 +96,12 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = g
@@ -87,12 +96,12 @@ function createResourceIndexService({ config, authorities, tokens, fetchImpl = g
if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
if (cursor && after === null) throw new ServiceError(400, 'resources.bad_query', 'cursor must be one this index issued');
const rest = after ? merged.filter((r) => compare(sortKeyOf(r), after) > 0) : merged;
const rest = after ? merged.filter((r) => compare(sortKeyOf(r), after) > 0) : merged;
const page = rest.slice(0, limit);
const page = rest.slice(0, limit);
- const partial = stale.map((s) => idByOrigin.get(s.authority) || s.authority);
+ const partial = stale.map((s) => ({ service: idByOrigin.get(s.authority) || s.authority, code: partialCodeOf(s) }));
// One line when the set of silent authorities changes — an operator wants to know, a log wants no
// One line when the set of silent authorities changes — an operator wants to know, a log wants no
- // flood; the caller reads the same fact from the header.
- const signature = partial.join(',');
+ // flood; the caller reads the same fact from the response body's `partial`.
+ const signature = partial.map((p) => `${p.service} (${p.code})`).join(', ');
if (signature !== lastPartial) {
if (signature !== lastPartial) {
- if (signature) log.warn(`[Services] index partial: ${signature} did not answer`);
+ if (signature) log.warn(`[Services] index partial: ${signature}`);
lastPartial = signature;
lastPartial = signature;
}
}
return {
return {
@@ -1,10 +1,10 @@
@@ -1,10 +1,10 @@
'use strict';
'use strict';
/**
/**
- * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.108.0): the registry is
+ * server/authorities/index.js against the REAL pinned openvibe-contracts (v0.110.0): the registry is
* derived from the released service manifests — every non-placeholder service whose manifest lists an
* derived from the released service manifests — every non-placeholder service whose manifest lists an
* active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest
* active <id>.resource.read — and never hand-maintained. The five authorities of the pin, their manifest
- * loopback origins and their audiences; Services itself, placeholders and services without the
- * capability are not authorities.
+ * loopback origins and their audiences; Services itself (whose manifest now lists
+ * services.resource.read), placeholders and services without the capability are not authorities.
*
*
* node test/authorities.test.js
* node test/authorities.test.js
*/
*/
@@ -17,10 +17,12 @@ const { check, done } = require('./helpers/app');
@@ -17,10 +17,12 @@ const { check, done } = require('./helpers/app');
const quiet = { warn() {}, log() {}, error() {} };
const quiet = { warn() {}, log() {}, error() {} };
async function main() {
async function main() {
- await check('the pin lists exactly five active <id>.resource.read authorities', () => {
+ await check('the pin lists six active <id>.resource.read capabilities, five of them authorities', () => {
const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();
const ids = contracts.capabilities.manifests.filter((c) => /^([a-z][a-z0-9-]{0,31})\.resource\.read$/.test(c.id)).map((c) => c.id).sort();
- assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read']);
+ assert.deepStrictEqual(ids, ['codes.resource.read', 'events.resource.read', 'host.resource.read', 'media.resource.read', 'network.resource.read', 'services.resource.read']);
for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
for (const id of ids) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
+ // services.resource.read is Services' own id; Services is never its own authority (the next check).
+ assert.ok(!createAuthorities(loadConfig({}), { log: quiet }).ids().includes('services'));
});
});
await check('the registry is built from those manifests, with the manifest origins and audiences', () => {
await check('the registry is built from those manifests, with the manifest origins and audiences', () => {
@@ -2,13 +2,13 @@
@@ -2,13 +2,13 @@
/**
/**
* The resource index over stub authorities (plan T13 step 6): the fan-out and merge (openvibe-sdk/
* The resource index over stub authorities (plan T13 step 6): the fan-out and merge (openvibe-sdk/
* resources), the merged page and its stable cursor, the project/kind/service filters, the person-facing
* resources), the merged page and its stable cursor, the project/kind/service filters, the person-facing
- * scoping, an authority that times out or refuses (a partial page, never a failed one), the :ovrn route,
- * and the token cache (one mint per authority).
+ * scoping, an authority that times out or refuses (a body-partial page, never a failed one), the :ovrn
+ * route, and the token cache (one mint per authority).
*
*
* node test/index.test.js
* node test/index.test.js
*/
*/
const assert = require('assert');
const assert = require('assert');
-const { ids } = require('openvibe-contracts');
+const { ids, validate } = require('openvibe-contracts');
const { boot, check, done, newProject } = require('./helpers/app');
const { boot, check, done, newProject } = require('./helpers/app');
const { summary } = require('./helpers/stubs');
const { summary } = require('./helpers/stubs');
@@ -38,7 +38,7 @@ async function main() {
@@ -38,7 +38,7 @@ async function main() {
do {
do {
const r = await t.call('GET', `/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, {});
const r = await t.call('GET', `/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, {});
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), null, 'no authority is partial here');
+ assert.ok(!('partial' in r.json), 'no authority is partial here: a complete page has no partial key');
assert.ok(r.json.resources.length <= 2, 'limit=2');
assert.ok(r.json.resources.length <= 2, 'limit=2');
seen.push(...ovrnsOf(r.json));
seen.push(...ovrnsOf(r.json));
cursor = r.json.next_cursor;
cursor = r.json.next_cursor;
@@ -129,7 +129,7 @@ async function main() {
@@ -129,7 +129,7 @@ async function main() {
} finally { await lenient.close(); }
} finally { await lenient.close(); }
});
});
- await check('a slow authority is omitted and named; the rest of the page is served', async () => {
+ await check('a slow authority is omitted and named in the body; the rest of the page is served', async () => {
const slow = await boot({
const slow = await boot({
authorities: [
authorities: [
{ id: 'alpha', resources: alpha },
{ id: 'alpha', resources: alpha },
@@ -140,7 +140,7 @@ async function main() {
@@ -140,7 +140,7 @@ async function main() {
try {
try {
const r = await slow.call('GET', '/api/v1/resources');
const r = await slow.call('GET', '/api/v1/resources');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');
+ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'resources.authority_timeout' }], 'a timed-out authority is named with Services’ own timeout code');
const got = ovrnsOf(r.json);
const got = ovrnsOf(r.json);
assert.deepStrictEqual(got.slice().sort(), [...alpha, ...gamma].map((x) => x.ovrn).sort());
assert.deepStrictEqual(got.slice().sort(), [...alpha, ...gamma].map((x) => x.ovrn).sort());
assert.deepStrictEqual(r.json.resources, r.json.resources.slice().sort((a, b) => (a.ovrn < b.ovrn ? -1 : 1)), 'partial does not disturb the order');
assert.deepStrictEqual(r.json.resources, r.json.resources.slice().sort((a, b) => (a.ovrn < b.ovrn ? -1 : 1)), 'partial does not disturb the order');
@@ -152,11 +152,30 @@ async function main() {
@@ -152,11 +152,30 @@ async function main() {
try {
try {
const r = await refusing.call('GET', '/api/v1/resources');
const r = await refusing.call('GET', '/api/v1/resources');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'beta');
+ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'authority.refused' }], 'a refusing authority is named with the problem code it answered');
assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));
assert.ok(ovrnsOf(r.json).includes(alpha[0].ovrn));
} finally { await refusing.close(); }
} finally { await refusing.close(); }
});
});
+ await check('a complete page has no partial; a partial page validates against common.resource-list-result@1', async () => {
+ const complete = await t.call('GET', '/api/v1/resources');
+ assert.strictEqual(complete.status, 200, complete.text);
+ assert.ok(!('partial' in complete.json), 'a complete page carries no partial key');
+ const completeCheck = validate('common.resource-list-result@1', complete.json);
+ assert.ok(completeCheck.valid, `a complete page is a common.resource-list-result@1: ${JSON.stringify(completeCheck.errors)}`);
+
+ const one = await boot({ authorities: [{ id: 'alpha', resources: alpha }, { id: 'beta', resources: beta, status: 503 }] });
+ try {
+ const r = await one.call('GET', '/api/v1/resources');
+ assert.strictEqual(r.status, 200, r.text);
+ assert.deepStrictEqual(r.json.partial, [{ service: 'beta', code: 'authority.refused' }], 'partial names the authority and the problem code it answered');
+ const result = validate('common.resource-list-result@1', r.json);
+ assert.ok(result.valid, `a partial page is a common.resource-list-result@1 (partial included): ${JSON.stringify(result.errors)}`);
+ assert.ok(r.json.resources.length > 0, 'the rest of the page is served');
+ assert.ok(!r.headers.get('x-openvibe-partial-authorities'), 'the partial authorities are a body field now, never a header');
+ } finally { await one.close(); }
+ });
+
await check('GET /api/v1/resources/:ovrn asks the owning authority and answers its summary', async () => {
await check('GET /api/v1/resources/:ovrn asks the owning authority and answers its summary', async () => {
const target = beta[0];
const target = beta[0];
const r = await t.call('GET', `/api/v1/resources/${encodeURIComponent(target.ovrn)}`, {});
const r = await t.call('GET', `/api/v1/resources/${encodeURIComponent(target.ovrn)}`, {});
@@ -218,7 +237,7 @@ async function main() {
@@ -218,7 +237,7 @@ async function main() {
try {
try {
const r = await broken.call('GET', '/api/v1/resources');
const r = await broken.call('GET', '/api/v1/resources');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.headers.get('x-openvibe-partial-authorities'), 'alpha');
+ assert.deepStrictEqual(r.json.partial, [{ service: 'alpha', code: 'resources.authority_unavailable' }], 'a token mint that fails leaves the authority named unavailable');
assert.deepStrictEqual(r.json.resources, []);
assert.deepStrictEqual(r.json.resources, []);
} finally { await broken.close(); }
} finally { await broken.close(); }
});
});