openvibers/openvibe.network Commits

Fabric offers move to /api/v1/offers; Network's resource index answers /api/v1/resources (projects, apps, node principals) under network.resource.read; pin openvibe-contracts v0.103.0 (#62)

OP OpenVibers committed 5 Oct 2026 · 90af060b28 · 13 files, +398 −39

* Fabric offers move to /api/v1/offers; Network's resource index answers /api/v1/resources (projects, apps, node principals) under network.resource.read; pin openvibe-contracts v0.103.0

## Changes (file:line)

- `server/index.js:485` — public offer routes mount moved `/api/v1/resources` → `/api/v1/offers` (`/internal/resources` unchanged at `:486`).
- `server/index.js:488-491` — mounted Network's authority resource index at `/api/v1/resources`, guarded by `network.resource.read`.
- `server/registry/resource-index.js` (new) — the index: `collect()` (`:91`) builds `common.resource-summary@1` for projects (`prj_`), apps (`app_`) and node principals (`nod_`); `ovrnOf()` (`:76`) computes OVRNs with `contracts.resources.nameOf`; `router()` (`:141`) serves `GET /` (`:180`) and `GET /:ovrn` (`:157`), honouring `?project=&kind=&cursor=&limit=` and returning exactly `common.resource-list-result@1`. `?project=` is the tenancy boundary (never another project's rows); unknown `kind` is an empty page; bad `project`/`limit`/`cursor` is a 400 `resources.bad_query`; unknown OVRN a 404 `resources.unknown_resource`.
- `server/registry/offers.js:7` — header path updated to `/api/v1/offers`.
- `server/registry/ecosystem.js:31,33` — route docs list offers at `/api/v1/offers` and the resource index at `/api/v1/resources`.
- `server/registry/ecosystem.js:331` — discovery index now advertises `offers: '/api/v1/offers'` **and** `resources: '/api/v1/resources'` (both at their new meaning).
- `docs/t2-resource-registry.md:128-140,177,182,229,240,252-256` — offer routes/mount/examples/tests → `/api/v1/offers`; added a release note naming old→new paths; discovery entry updated.
- `docs/cutover-t14-user-owned-trust.md:158` — cutover probe path → `/api/v1/offers`.
- `test/resource-registry.test.js:5,37,169,191,196,223,230,238,244,262,302,311` — offers mount/requests → `/api/v1/offers`; `:259-261` asserts both discovery keys.
- `test/resource-index.test.js` (new) — auth matrix, contract validation, `?project=`/`?kind=` scope, cursor pagination, single-read, bad-query cases.

## Callers of the old offers path (~/OpenVibers, `git grep origin/main`)

**None, outside Network itself.** No repo fetches Network's public offers list:
- `OpenVibe.SDK` `src/resources.js:100` (`createResourceIndex`, plus `browser/`, `esm/`, `types/resources.d.ts`, `test/resources.test.js`) points at `/api/v1/resources` as the **resource index** (new meaning) and its header (`:15`) already says the offers move to `/api/v1/offers` — it was never an offers-list caller.
- `OpenVibe.AI` (`server/providers/index.js`), `OpenVibe.Codes` (`server/domain/harness-placement.js`), `OpenVibe.Events` (`server/fabric/planner.js`) use `openvibe-sdk/placement` over their own local `platform.resource-offer@1` catalogs, not Network's endpoint.
- `OpenVibe.Host` reads Network's `/api/v1/nodes`, not offers. No `OpenVibe.Services` checkout exists here. `OpenVibe.Contracts` only references the paths in ADR-048 / `manifests/capabilities/network.resource.read.json`.

So the release note (added to `docs/t2-resource-registry.md`) is the only consumer-facing action needed.

## Notes

- **SDK not bumped.** The OVRNs use `contracts.resources.nameOf` already present in the pinned `openvibe-contracts v0.103.0`; `openvibe-sdk v0.28.0` has no `resources` module, but the task allowed the contracts helpers, so no bump to v0.34.0 was needed.
- **OVRN scope follows ADR-048 §3**, not the loose phrasing: `nameOf` composes one only for a **project-owned node principal** (`ovrn:network:<prj_…>:node/nod_…`). Projects get none (a project is addressed by its bare `prj_` id, never a self-referential OVRN) and apps get none (

* Say why the resource index trusts its first-party caller for scope (review)

Changes

ddiff --git a/README.md b/README.md
iindex 0e3a5bbd..32d58889 100644
---- a/README.md
++++ b/README.md
@@@ -48,7 +48,7 @@ Identity and account service for the OpenVibe network. Manages user accounts, OA
## Depends on
--- `openvibe-contracts` v0.99.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
++- `openvibe-contracts` v0.103.0 (registry manifests, capability checks), `openvibe-sdk` v0.28.0 (PostgreSQL
and Valkey adapters, events, per-actor limits, universal telemetry), `openvibe-shared` v2.10.1, pinned by release tarball
- OpenVibe.Events (Network's outbox relay; the events it consumes), OpenVibe.AI (the `network.site_copy`
workflow), OpenVibe.Blog (the changelog feed it proxies)
ddiff --git a/STATUS.json b/STATUS.json
iindex b42017e2..5fd594d9 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -6,7 +6,7 @@
"runtime": "node 22, express, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4000,
"domain": "openvibe.network (with auth., api., admin., my. and themes.openvibe.network in its manifest)",
-- "contracts": "openvibe-contracts v0.99.0",
++ "contracts": "openvibe-contracts v0.103.0",
"capabilities": [
"identity.subject.resolve",
"mods.grant.manage",
ddiff --git a/docs/cutover-t14-user-owned-trust.md b/docs/cutover-t14-user-owned-trust.md
iindex 807557b6..7707f5ab 100644
---- a/docs/cutover-t14-user-owned-trust.md
++++ b/docs/cutover-t14-user-owned-trust.md
@@@ -155,7 +155,7 @@ sudo node --env-file=/etc/openvibe/network.env -e '
})().catch((e) => { console.error("FAIL", e.message.replace(/\/\/[^@]*@/, "//***@")); process.exit(1); });'
```
--Expected: three lines ending `names user-owned`, and `migration: 0020`. `GET https://openvibe.network/api/v1/resources`
++Expected: three lines ending `names user-owned`, and `migration: 0020`. `GET https://openvibe.network/api/v1/offers`
answers as before. It lists no `user-owned` offer until a Node holding that class reports one.
---
ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex dc510e13..e0b2dcac 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -126,16 +126,22 @@ Body `express.json({limit: '256kb'})`; array capped at 500, matching `network.no
`maxItems`.
**Routing** — mounted in `server/index.js` beside the node routers: `app.use('/internal/resources', internal)` and
--`app.use('/api/v1/resources', pub)`. `ensureSchema(db)` is added to the boot work in `server/db/database.js` next to
++`app.use('/api/v1/offers', pub)`. `ensureSchema(db)` is added to the boot work in `server/db/database.js` next to
the nodes call, purely as documentation.
++**Release note (amended ADR-048).** The public offer routes moved: `GET /api/v1/resources` →
++`GET /api/v1/offers`, `GET /api/v1/resources/:offer_id` → `GET /api/v1/offers/:offer_id`, and
++`GET /api/v1/resources/:offer_id/beacon` → `GET /api/v1/offers/:offer_id/beacon`. The `/api/v1/resources` path
++itself now serves Network's resource index (server/registry/resource-index.js); `POST /internal/resources/report`
++and the `/internal/resources` reads are unchanged.
++
## 4. Public read API
| Route | Behaviour | Cache |
|---|---|---|
--| `GET /api/v1/resources` | `{offers: [<platform.resource-offer-public@1>], generated_at, filters, count}`; filters `?kind=&region=&trust=&status=&cell=&max_price_usd=`; **default excludes `status='down'`** | `public, max-age=60`, `ACAO: *`, `Timing-Allow-Origin: *` |
--| `GET /api/v1/resources/:offer_id` | one public offer (any status), or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |
--| `GET /api/v1/resources/:offer_id/beacon` | 204 — the same idiom as `nodes.js:52`, for `openvibe-sdk/geo` | `no-store` |
++| `GET /api/v1/offers` | `{offers: [<platform.resource-offer-public@1>], generated_at, filters, count}`; filters `?kind=&region=&trust=&status=&cell=&max_price_usd=`; **default excludes `status='down'`** | `public, max-age=60`, `ACAO: *`, `Timing-Allow-Origin: *` |
++| `GET /api/v1/offers/:offer_id` | one public offer (any status), or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |
++| `GET /api/v1/offers/:offer_id/beacon` | 204 — the same idiom as `nodes.js:52`, for `openvibe-sdk/geo` | `no-store` |
| `GET /internal/resources` | the same list and filters, docs **whole** (capacity and harness address included); `network.resource.report` guard | `no-store` |
| `GET /internal/resources/:offer_id` | the single offer whole, or 404 `registry.unknown_offer`; the same guard | `no-store` |
@@@ -174,12 +180,12 @@ code. The contract between the two is therefore exact:
- `capabilities[]` items are `namespace:name` strings (`node:http`, `events:gateway`, `object:r2`, …) — the contract's
own pattern; they are stored, not interpreted.
--### Worked example — `GET /api/v1/resources?kind=provider` → `plan()`
++### Worked example — `GET /api/v1/offers?kind=provider` → `plan()`
Two provider offers in one region, both `health.status: 'up'` and priced per request at `$0.02` and `$0.03`:
```json
--GET /api/v1/resources?kind=provider
++GET /api/v1/offers?kind=provider
{ "offers": [
{ "offer_id": "p-provider", "kind": "provider", "region": "us-central", "trust": "community",
"health": { "status": "up" }, "pricing": { "model": "per-request", "marginal_usd_per_unit": 0.02 } },
@@@ -226,7 +232,7 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
excludes `down`; an unknown `kind` returns `{offers: []}` with 200. The public list never contains `capacity`; the
internal list returns the stored docs whole and refuses a call without the guard's token.
6. **Single read** — `GET /internal/resources/:id` is the stored `platform.resource-offer@1` doc;
-- `GET /api/v1/resources/:id` matches the Network-local public contract above; an unknown id → 404
++ `GET /api/v1/offers/:id` matches the Network-local public contract above; an unknown id → 404
`registry.unknown_offer` on both.
7. **Headers** — `cache-control: public, max-age=60` and `access-control-allow-origin: *` on list and single; beacon 204
with `no-store` and `timing-allow-origin: *`.
@@@ -237,7 +243,7 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
`pricing.marginal_usd_per_unit`, so the two defaulted columns are covered rather than assumed. The assert helper is
written once against the mapping and reused by the upsert test and the mark-down test, so SQL and the test cannot
drift apart. This is the invariant a later migration or a partial write breaks.
--9. **Placement proof** (slice 4) — feed a live `GET /api/v1/resources` response into
++9. **Placement proof** (slice 4) — feed a live `GET /api/v1/offers` response into
`require('openvibe-sdk/placement').plan()` and assert the result matches `platform.placement-result@1`.
Run `node test/resource-registry.test.js` while iterating, `npm run test:pg` (`NETWORK_TEST_STORE=pg`) for the SQL
@@@ -249,10 +255,11 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
no-op + `report`), the `ensureSchema` call in `server/db/database.js`, `app.use('/internal/resources', internal)` in
`server/index.js`, `test/resource-registry.test.js` tests 1-4. The original guard reused `network.node.report`.
2. **Public read + filters.** `list()`, `get()`, `routers().pub` in `server/registry/offers.js`,
-- `app.use('/api/v1/resources', pub)`, tests 5-7. Also the full internal read (`GET /internal/resources[/:id]`, the
++ `app.use('/api/v1/offers', pub)`, tests 5-7. Also the full internal read (`GET /internal/resources[/:id]`, the
report's guard), because the public read leaves capacity out (§4).
--3. **Discovery entry.** (done) Add `resources: '/api/v1/resources'` to the `/api/v1/registry` index in
-- `server/registry/ecosystem.js`. Nothing else.
++3. **Discovery entry.** (done, amended) `server/registry/ecosystem.js` advertises
++ `offers: '/api/v1/offers'` and, since the amended ADR-048 moved the offers and gave Network a resource
++ index, `resources: '/api/v1/resources'` for that index. Nothing else.
4. **Placement consumption proof.** (done) §5 gains a worked example; test 9 added. No production-code change — this
slice exists to fail loudly if the response shape drifts from what `plan()` accepts.
5. **Contracts bump + kind widening.** This item was re-planned as N6 in
ddiff --git a/package-lock.json b/package-lock.json
iindex 7fc6fd0e..c94567ec 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
@@@ -1556,9 +1556,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.99.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
-- "integrity": "sha512-2dL6gKDbxseR9/kj8pgNGHBV1Drn0xq6gx6+Cz4Qees7hZ822VhPIg7BfBVUhCI8D7348J9YWG9E2nreRGWf6g==",
++ "version": "0.103.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
++ "integrity": "sha512-kTsKojjjtCmJr5uXDIM+ocnhLF5CiAMBGbRSVGmvhfeBGHs5jxldGJc+ozw+S5W2bOQp8/BkT2PVI/fr8eNirQ==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ab7707f1..ee8335dc 100644
---- a/package.json
++++ b/package.json
@@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.99.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.103.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.28.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.10.1",
"pg": "^8.23.0",
ddiff --git a/server/index.js b/server/index.js
iindex 6a4fe0d2..b69e40a0 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -478,12 +478,18 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal/nodes', nodeRouters.internal);
}
// The resource registry (plan T2, docs/t2-resource-registry.md): node and provider offers. The public list leaves
--// capacity out; the report and the full internal read take network.resource.report.
++// capacity out; the report and the full internal read take network.resource.report. The amended ADR-048 moved the
++// public routes off /api/v1/resources (now Network's resource index, below) to /api/v1/offers.
{
const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.resource.report') });
-- app.use('/api/v1/resources', offerRouters.pub);
++ app.use('/api/v1/offers', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
}
++// Network's authority resource index (ADR-048 section 3, capability network.resource.read): common.resource-summary@1
++// pages of the resources Network owns (projects, apps, node principals) for OpenVibe.Services' fan-out.
++{
++ app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./identity/principals').guard('network.resource.read') }));
++}
// Service instances (plan T2, docs/t2-cells-and-node-principal.md section 4.1): Host reports what runs on its machines
// with network.node.report. Mounted before /internal/registry so the cells read does not swallow the report path.
{
ddiff --git a/server/registry/ecosystem.js b/server/registry/ecosystem.js
iindex 4510d2bb..ccfc9cc2 100644
---- a/server/registry/ecosystem.js
++++ b/server/registry/ecosystem.js
@@@ -28,8 +28,10 @@
* library, repository, planned), each rule stated
* GET /api/v1/registry/featured public sites that are up, most used first (the navbar's
* usage ranking), with when that use was counted
-- * GET /api/v1/resources[?kind=&region=&trust=&status=&cell=&max_price_usd=]
++ * GET /api/v1/offers[?kind=&region=&trust=&status=&cell=&max_price_usd=]
* resource offers (server/registry/offers.js)
++ * GET /api/v1/resources[?project=&kind=&cursor=&limit=]
++ * Network's resource index (server/registry/resource-index.js)
* GET /contracts/<domain>/<name>.v<N>.json the schema at its $id URL
*/
const express = require('express');
@@@ -326,7 +328,7 @@ function createEcosystemRegistry({ issuer, internalOverrides = {}, fetchImpl = g
const notFound = (res, code, detail) => contracts.http.sendProblem(res, 404, code, { detail });
r.get('/.well-known/openvibe', (_req, res) => cache(res, 300).json(descriptor()));
-- r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', releases: '/api/v1/registry/releases', health: '/api/v1/registry/health', search: '/api/v1/registry/search?q=', domains: '/api/v1/registry/domains/:domain', categories: '/api/v1/registry/categories', featured: '/api/v1/registry/featured', resources: '/api/v1/resources' }));
++ r.get('/api/v1/registry', (_req, res) => cache(res).json({ services: '/api/v1/registry/services', capabilities: '/api/v1/registry/capabilities', namespaces: '/api/v1/registry/namespaces', contracts: '/api/v1/registry/contracts', topics: '/api/v1/registry/topics', releases: '/api/v1/registry/releases', health: '/api/v1/registry/health', search: '/api/v1/registry/search?q=', domains: '/api/v1/registry/domains/:domain', categories: '/api/v1/registry/categories', featured: '/api/v1/registry/featured', offers: '/api/v1/offers', resources: '/api/v1/resources' }));
r.get('/api/v1/registry/categories', (_req, res) => cache(res, 30).json({ categories: categories(), checked_at: lastPollAt ? new Date(lastPollAt).toISOString() : null }));
r.get('/api/v1/registry/categories/:id', (req, res) => {
const c = categories().find(x => x.id === String(req.params.id));
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex ef680cee..d0c2f1fb 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -4,7 +4,7 @@
* network can place on — resource offers with capabilities, capacity, health and pricing. The owner
* reports the complete set of its offers (POST /internal/resources/report, network.resource.report); an offer absent
* from a later report of the same source is marked down, never
-- * deleted. This module stores; it does not plan, price or settle. Reads (slice 2): GET /api/v1/resources is public and
++ * deleted. This module stores; it does not plan, price or settle. Reads (slice 2): GET /api/v1/offers is public and
* cacheable but leaves each offer's capacity out (the contract is first-party: it carries the capacity network.node@1
* deliberately does not publish); GET /internal/resources, behind the report's guard, returns the docs whole.
*/
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
nnew file mode 100644
iindex 00000000..b343f17e
---- /dev/null
++++ b/server/registry/resource-index.js
@@@ -0,0 +1,188 @@
++'use strict';
++/**
++ * Network's authority resource index (ADR-048 section 3; capability network.resource.read):
++ *
++ * GET /api/v1/resources[?project=&kind=&cursor=&limit=] → common.resource-list-result@1
++ * GET /api/v1/resources/:ovrn → common.resource-summary@1
++ *
++ * It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and
++ * its node principals (nod_) — as common.resource-summary@1, the shape OpenVibe.Services fans out over
++ * and merges (openvibe-sdk/resources' createResourceIndex). The T2 Fabric offer registry used to share
++ * this path; the amended ADR-048 moves its public routes to /api/v1/offers (server/registry/offers.js).
++ *
++ * Tenancy: `?project=prj_…` is the caller's tenancy boundary. With it, only that project's resources
++ * answer — the project itself, its apps and its project-owned machines; a resource of another project
++ * is never returned. Without it the first-party caller (the capability is first-party) sees every
++ * Network-owned resource, which is what an authority-wide fan-out needs.
++ *
++ * OVRN: a summary's ovrn is computed with openvibe-contracts' contracts.resources.nameOf, the one
++ * formatter, so it is present exactly when the resource is a nameable resource. Per ADR-048 section 3
++ * a project is addressed by its bare prj_ id — it is never given a self-referential OVRN — and an app
++ * is an actor, never a resource name (app_ is excluded from the OVRN id pattern). A project-owned node
++ * principal is named ovrn:network:<prj_…>:node/nod_…; a platform- or user-owned one has no project
++ * segment and so no name. That is also what GET /api/v1/resources/:ovrn can read: only a resource
++ * whose computed ovrn equals the one asked for answers.
++ */
++const express = require('express');
++const contracts = require('openvibe-contracts');
++
++const SERVICE = 'network';
++const PROJECT_KIND = 'network.project';
++const APP_KIND = 'network.app';
++const NODE_KIND = 'network.node';
++const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];
++const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DEFAULT_LIMIT = 100;
++const MAX_LIMIT = 1000;
++
++/** A subject-ref {type:'user', id} for a stored subject, or null when it is not a usr_ id. */
++const userRef = (subject) => (USER_SUBJECT_RE.test(String(subject || '')) ? { type: 'user', id: subject } : null);
++
++/** common.resource-summary@1 for a dev_projects row. A project's tenancy boundary is itself. */
++function projectSummary(p) {
++ return {
++ id: p.id, kind: PROJECT_KIND, service: SERVICE, project_id: p.id,
++ ...(userRef(p.owner_subject) ? { owner: userRef(p.owner_subject) } : {}),
++ name: p.name, state: p.archived_at ? 'archived' : 'active', created_at: p.created_at,
++ };
++}
++
++/** common.resource-summary@1 for a dev_apps row (an OAuth client; its project is its tenancy boundary). */
++function appSummary(a) {
++ return {
++ id: a.id, kind: APP_KIND, service: SERVICE, project_id: a.project_id, name: a.name,
++ state: a.revoked_at ? 'revoked' : 'active', created_at: a.created_at,
++ };
++}
++
++/** common.resource-summary@1 for a platform_node_principals row: platform-, project- or user-owned. */
++function nodeSummary(n) {
++ return {
++ id: n.id, kind: NODE_KIND, service: SERVICE,
++ ...(n.project_id ? { project_id: n.project_id } : {}),
++ ...(userRef(n.owner_subject) ? { owner: userRef(n.owner_subject) } : {}),
++ ...(n.name ? { name: n.name } : {}),
++ state: n.status, created_at: n.created_at, updated_at: n.updated_at,
++ };
++}
++
++/**
++ * The summary's ovrn, or null when it has none. ADR-048 section 3: a project is addressed by its bare
++ * prj_ id and never carries a self-referential OVRN, so the kind is checked before the contracts
++ * helper (which would otherwise compose ovrn:network:<prj_…>:project/<prj_…>). Apps fail the helper's
++ * id rule (app_ is excluded); only a project-owned node principal composes.
++ */
++function ovrnOf(summary) {
++ if (summary.kind === PROJECT_KIND) return null;
++ return contracts.resources.nameOf(summary);
++}
++
++/** The summary with its ovrn attached when it has one. */
++function named(summary) {
++ const ovrn = ovrnOf(summary);
++ return ovrn ? { ...summary, ovrn } : summary;
++}
++
++/** A stable (kind, id) ordering, so a cursor can be a position in it. */
++const order = (x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0);
++
++/** The summaries matching the filters: `project` scopes tenancy, `kind` picks one kind. Sorted by (kind, id). */
++// Scope: network.resource.read is first-party (resourceConstraints none), so its holder sees every project and
++// ?project= only narrows. If it is ever granted to a non-first-party principal, derive the scope from that
++// principal's grants here instead of trusting the query.
++async function collect(db, { project = null, kind = null } = {}) {
++ const out = [];
++ if (!kind || kind === PROJECT_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM dev_projects WHERE id = ?').all(project)
++ : await db.prepare('SELECT * FROM dev_projects ORDER BY id').all();
++ for (const p of rows) out.push(named(projectSummary(p)));
++ }
++ if (!kind || kind === APP_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM dev_apps WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT * FROM dev_apps ORDER BY id').all();
++ for (const a of rows) out.push(named(appSummary(a)));
++ }
++ if (!kind || kind === NODE_KIND) {
++ const rows = project
++ ? await db.prepare('SELECT * FROM platform_node_principals WHERE project_id = ? ORDER BY id').all(project)
++ : await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all();
++ for (const n of rows) out.push(named(nodeSummary(n)));
++ }
++ return out.sort(order);
++}
++
++/** A cursor is an opaque base64url [kind, id] position; only one this index issued decodes to that. */
++const encodeCursor = (s) => Buffer.from(JSON.stringify([s.kind, s.id])).toString('base64url');
++function decodeCursor(raw) {
++ let v;
++ try { v = JSON.parse(Buffer.from(String(raw), 'base64url').toString('utf8')); } catch { return null; }
++ return Array.isArray(v) && v.length === 2 && typeof v[0] === 'string' && typeof v[1] === 'string' ? v : null;
++}
++const afterCursor = (s, [kind, id]) => s.kind > kind || (s.kind === kind && s.id > id);
++
++/** The query as filters, or { error } for a value that cannot be honoured. An unknown kind is kept (it matches nothing). */
++function filtersOf(query) {
++ const project = typeof query.project === 'string' && query.project !== '' ? query.project : null;
++ if (project && !PROJECT_ID_RE.test(project)) return { error: 'project must be a prj_ id' };
++ const kind = typeof query.kind === 'string' && query.kind !== '' ? query.kind : null;
++ let limit = DEFAULT_LIMIT;
++ if (typeof query.limit === 'string' && query.limit !== '') {
++ if (!/^\d+$/.test(query.limit) || Number(query.limit) < 1 || Number(query.limit) > MAX_LIMIT) return { error: `limit must be an integer 1-${MAX_LIMIT}` };
++ limit = Number(query.limit);
++ }
++ let cursor = null;
++ if (typeof query.cursor === 'string' && query.cursor !== '') {
++ cursor = decodeCursor(query.cursor);
++ if (!cursor) return { error: 'cursor is not one this index issued' };
++ }
++ return { project, kind, limit, cursor };
++}
++
++function router({ guard }) {
++ const open = (res) => res.set('Cache-Control', 'private, max-age=60');
++ const bad = (res, detail) => contracts.http.sendProblem(res, 400, 'resources.bad_query', { detail });
++ const unknown = (res, name) => contracts.http.sendProblem(res, 404, 'resources.unknown_resource', { detail: `no resource named ${name}` });
++
++ /** One common.resource-list-result@1 page: the filtered, sorted summaries from the cursor, then `limit` of them. */
++ async function page(req, res) {
++ const f = filtersOf(req.query);
++ if (f.error) return bad(res, f.error);
++ const all = await collect(req.app.locals.db, f);
++ const rest = f.cursor ? all.filter((s) => afterCursor(s, f.cursor)) : all;
++ const resources = rest.slice(0, f.limit);
++ const next_cursor = rest.length > f.limit ? encodeCursor(resources[resources.length - 1]) : null;
++ open(res).json({ resources, next_cursor });
++ }
++
++ /** GET /api/v1/resources/:ovrn: the summary whose computed ovrn is exactly the one asked for. */
++ async function one(req, res) {
++ const name = String(req.params.ovrn);
++ const parsed = contracts.resources.parse(name);
++ const db = req.app.locals.db;
++ let summary = null;
++ if (parsed && parsed.service === SERVICE) {
++ if (parsed.type === 'node') {
++ const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(parsed.id);
++ if (row) summary = named(nodeSummary(row));
++ } else if (parsed.type === 'app') {
++ const row = await db.prepare('SELECT * FROM dev_apps WHERE id = ?').get(parsed.id);
++ if (row) summary = named(appSummary(row));
++ } else if (parsed.type === 'project') {
++ const row = await db.prepare('SELECT * FROM dev_projects WHERE id = ?').get(parsed.id);
++ if (row) summary = named(projectSummary(row));
++ }
++ }
++ if (!summary || summary.ovrn !== name) return unknown(res, name);
++ open(res).json(summary);
++ }
++
++ const r = express.Router();
++ r.get('/', guard, page);
++ r.get('/:ovrn', guard, one);
++ return r;
++}
++
++module.exports = { router, SERVICE, KINDS, PROJECT_KIND, APP_KIND, NODE_KIND, DEFAULT_LIMIT, MAX_LIMIT, projectSummary, appSummary, nodeSummary, ovrnOf, collect, encodeCursor };
ddiff --git a/test/registry-ecosystem.test.js b/test/registry-ecosystem.test.js
iindex a68d5279..83af7475 100644
---- a/test/registry-ecosystem.test.js
++++ b/test/registry-ecosystem.test.js
@@@ -77,8 +77,8 @@ const { createEcosystemRegistry } = require('../server/registry/ecosystem');
assert.strictEqual(inCat('events'), 'platform'); assert.strictEqual(inCat('ai'), 'platform'); assert.strictEqual(inCat('sources'), 'platform');
assert.strictEqual(inCat('sdk'), 'library'); assert.strictEqual(inCat('contracts'), 'library');
assert.strictEqual(inCat('examples'), 'repository');
-- // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space, v0.87-v0.90's zone and run, v0.95.0's services (charter only, nothing runs)
-- assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'run', 'services', 'space', 'zone']);
++ // 'planned' = exposure placeholder: Contracts 0.83.0's media-hub and space, v0.87-v0.90's zone and run, v0.95.0's services, v0.102.0's watch (charter only, nothing runs)
++ assert.deepStrictEqual(cats.planned.services.map(x => x.id).sort(), ['media-hub', 'run', 'services', 'space', 'watch', 'zone']);
const liveRow = cats.site.services.find(x => x.id === 'live');
assert.strictEqual(liveRow.runtime, 'down'); assert.ok(liveRow.checked_at, 'rows carry the last check');
r = await get('/api/v1/registry/categories/library');
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
nnew file mode 100644
iindex 00000000..14a1b9f1
---- /dev/null
++++ b/test/resource-index.test.js
@@@ -0,0 +1,155 @@
++'use strict';
++// Network's authority resource index (ADR-048 section 3; capability network.resource.read):
++// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer
++// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn
++// reads one by its computed ovrn. ?project=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
++// boundary, so a resource of another project is never returned. An ovrn is present exactly when
++// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project
++// is addressed by its bare prj_ id), never for an app (app_ is excluded from the OVRN id pattern), and
++// only for a project-owned node principal.
++const assert = require('assert');
++const crypto = require('crypto');
++const http = require('http');
++const express = require('express');
++const { validate, ids } = require('openvibe-contracts');
++const { getDb } = require('../server/db/database');
++const principals = require('../server/identity/principals');
++const resourceIndex = require('../server/registry/resource-index');
++
++(async () => {
++ const log = console.log; console.log = () => {};
++ const db = getDb();
++ console.log = log;
++ await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
++ await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
++ await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('host', 'network.resource.read', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ const app = express();
++ app.use(express.urlencoded({ extended: true }));
++ app.locals.db = db;
++ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpiry: '1h' } };
++ app.locals.privateKey = keys.privateKey;
++ app.locals.publicKey = keys.publicKey;
++ app.use('/oauth', require('../server/auth/oauth-routes'));
++ app.use('/api/v1/resources', resourceIndex.router({ guard: principals.guard('network.resource.read') }));
++ const server = http.createServer(app);
++
++ // -- Fixtures: two projects, their apps, and platform-, project- and user-owned node principals --
++ const prjA = ids.newId('project'); const prjB = ids.newId('project');
++ const usrA = ids.newId('user'); const usrB = ids.newId('user');
++ const appA = ids.newId('app'); const appA2 = ids.newId('app'); const appB = ids.newId('app');
++ const nodA = ids.newId('node'); const nodB = ids.newId('node'); const nodP = ids.newId('node'); const nodU = ids.newId('node');
++ const at = '2026-10-01T00:00:00Z';
++ await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by) VALUES (?, ?, 'Project A', 'sandbox', '[]', ?, 'test')").run(prjA, usrA, at);
++ await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by, archived_at, archived_by) VALUES (?, ?, 'Project B', 'sandbox', '[]', ?, 'test', ?, 'test')").run(prjB, usrB, at, at);
++ const insertApp = db.prepare("INSERT INTO dev_apps (id, project_id, name, environment, oauth_client_id, client_type, created_at, created_by, revoked_at) VALUES (?, ?, ?, 'sandbox', ?, 'public', ?, 'test', ?)");
++ await insertApp.run(appA, prjA, 'App A', appA, at, null);
++ await insertApp.run(appA2, prjA, 'App A2', appA2, at, at);
++ await insertApp.run(appB, prjB, 'App B', appB, at, null);
++ const insertNode = db.prepare("INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, name, trust, status, created_by, credential_hash) VALUES (?, ?, 'wnam-1', ?, ?, ?, ?, ?, 'active', 'test', ?)");
++ await insertNode.run(nodA, 'node-a', 'project', prjA, null, 'Machine A', 'community', 'a'.repeat(64));
++ await insertNode.run(nodB, 'node-b', 'project', prjB, null, 'Machine B', 'community', 'b'.repeat(64));
++ await insertNode.run(nodP, 'node-p', 'platform', null, null, 'Platform machine', 'first-party', null);
++ await insertNode.run(nodU, 'node-u', 'user', null, usrA, "Person's machine", 'community', 'c'.repeat(64));
++
++ const ovrnOfNode = (project, node) => `ovrn:network:${project}:node/${node}`;
++ const expected = [];
++ expected.push({ id: prjA, kind: 'network.project' }, { id: prjB, kind: 'network.project' });
++ expected.push({ id: appA, kind: 'network.app' }, { id: appA2, kind: 'network.app' }, { id: appB, kind: 'network.app' });
++ expected.push({ id: nodA, kind: 'network.node' }, { id: nodB, kind: 'network.node' }, { id: nodP, kind: 'network.node' }, { id: nodU, kind: 'network.node' });
++ expected.sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
++
++ (async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const token = async (id, secret, scope) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network', ...(scope ? { scope } : {}) }) })).json()).access_token;
++ const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
++ const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));
++ try {
++ assert.strictEqual((await get('/api/v1/resources')).status, 403, 'nobody');
++ assert.strictEqual((await get('/api/v1/resources', { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live has no network.resource.read');
++ const list = await get('/api/v1/resources', auth);
++ assert.strictEqual(list.status, 200, JSON.stringify(list.body));
++ assert.strictEqual(list.headers.get('cache-control'), 'private, max-age=60');
++
++ assert.deepStrictEqual(Object.keys(list.body).sort(), ['next_cursor', 'resources'], 'the page carries only the contract fields');
++ const pageCheck = validate('common.resource-list-result@1', list.body);
++ assert.ok(pageCheck.valid, JSON.stringify(pageCheck.errors));
++ list.body.resources.forEach((s) => assert.ok(validate('common.resource-summary@1', s).valid, `${s.id}: ${JSON.stringify(validate('common.resource-summary@1', s).errors)}`));
++ assert.strictEqual(list.body.next_cursor, null, 'one page holds the whole index');
++ assert.deepStrictEqual(list.body.resources.map((r) => [r.kind, r.id]), expected.map((r) => [r.kind, r.id]), 'all three kinds, sorted by (kind, id)');
++ assert.deepStrictEqual([...new Set(list.body.resources.map((r) => r.service))], ['network']);
++
++ const byId = new Map(list.body.resources.map((r) => [r.id, r]));
++ assert.strictEqual(byId.get(nodA).ovrn, ovrnOfNode(prjA, nodA), 'a project-owned node principal is named');
++ [prjA, prjB].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: a project has no self-referential OVRN`));
++ [appA, appA2, appB].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: an app is an actor, never a resource name`));
++ [nodP, nodU].forEach((id) => assert.ok(!('ovrn' in byId.get(id)), `${id}: without a project segment there is no name`));
++ assert.strictEqual(byId.get(nodU).owner.id, usrA, 'a user-owned machine names its owner');
++ assert.strictEqual(byId.get(prjB).state, 'archived', 'the project state follows archived_at');
++ assert.strictEqual(byId.get(appA2).state, 'revoked', 'the app state follows revoked_at');
++
++ const aIds = async (q, headers = auth) => {
++ const y = await get(`/api/v1/resources${q}`, headers);
++ assert.strictEqual(y.status, 200, `${q}: ${JSON.stringify(y.body)}`);
++ assert.ok(validate('common.resource-list-result@1', y.body).valid);
++ return y.body.resources.map((r) => r.id);
++ };
++ assert.deepStrictEqual((await aIds(`?project=${prjA}`)).sort(), [appA, appA2, nodA, prjA].sort(), 'project A: its apps and its machine, never platform/user nodes');
++ assert.deepStrictEqual(await aIds(`?project=${prjB}`), [appB, nodB, prjB].sort(), 'project B is never mixed in');
++ assert.deepStrictEqual(await aIds(`?project=${prjA}&kind=network.app`), [appA, appA2].sort(), 'kind narrows within the project');
++ assert.deepStrictEqual((await aIds('?kind=network.node')).sort(), [nodA, nodB, nodP, nodU].sort(), 'kind = node lists every node principal');
++ assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');
++ assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');
++ assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
++
++ const seen = [];
++ let cursor = null;
++ let pages = 0;
++ for (;;) {
++ const y = await get(`/api/v1/resources?limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, auth);
++ assert.strictEqual(y.status, 200);
++ assert.ok(validate('common.resource-list-result@1', y.body).valid);
++ seen.push(...y.body.resources.map((r) => r.id));
++ if (y.body.next_cursor === null) { assert.strictEqual(seen.length, expected.length, 'the cursor chain ends at the end'); break; }
++ assert.ok(typeof y.body.next_cursor === 'string' && y.body.next_cursor !== '');
++ cursor = y.body.next_cursor;
++ if (++pages > 50) assert.fail('the cursor chain never ended');
++ }
++ assert.deepStrictEqual(seen, expected.map((r) => r.id), 'no duplicates, none skipped, order preserved');
++ assert.deepStrictEqual(await aIds(`?project=${prjA}&limit=1000`), [appA, appA2, nodA, prjA].sort(), 'a large limit still pages one result');
++
++ const one = await get(`/api/v1/resources/${encodeURIComponent(ovrnOfNode(prjA, nodA))}`, auth);
++ assert.strictEqual(one.status, 200, JSON.stringify(one.body));
++ assert.strictEqual(one.headers.get('cache-control'), 'private, max-age=60');
++ assert.ok(validate('common.resource-summary@1', one.body).valid);
++ assert.deepStrictEqual(one.body, byId.get(nodA), 'the same summary the list answers');
++ const missing = [
++ ['a project OVRN', `ovrn:network:${prjA}:project/${prjA}`],
++ ['an app OVRN', `ovrn:network:${prjA}:app/${appA}`],
++ ['a platform node', ovrnOfNode(prjA, nodP)],
++ ['a user-owned node', ovrnOfNode(prjA, nodU)],
++ ["another project's id", ovrnOfNode(prjB, nodA)],
++ ['an unknown node', ovrnOfNode(prjA, ids.newId('node'))],
++ ['a non-OVRN', 'nope'],
++ ];
++ for (const [why, name] of missing) {
++ const y = await get(`/api/v1/resources/${encodeURIComponent(name)}`, auth);
++ assert.strictEqual(y.status, 404, why);
++ assert.strictEqual(y.headers.get('content-type'), 'application/problem+json', why);
++ assert.strictEqual(y.body.code, 'resources.unknown_resource', why);
++ }
++
++ const bad = [['?project=nope', 'project not a prj_ id'], ['?limit=0', 'limit below one'], ['?limit=abc', 'limit not a number'], ['?limit=99999', 'limit over the cap'], ['?cursor=***', 'cursor not one this index issued']];
++ for (const [q, why] of bad) {
++ const y = await get(`/api/v1/resources${q}`, auth);
++ assert.strictEqual(y.status, 400, why);
++ assert.strictEqual(y.body.code, 'resources.bad_query', why);
++ }
++ console.log('resource index: all tests passed');
++ } finally {
++ server.close();
++ }
++ })().catch((err) => { console.error(err); process.exit(1); });
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/resource-registry.test.js b/test/resource-registry.test.js
iindex 4360e33f..8ff9710b 100644
---- a/test/resource-registry.test.js
++++ b/test/resource-registry.test.js
@@@ -2,7 +2,7 @@
// The resource registry (plan T2, docs/t2-cells-and-node-principal.md section 6):
// POST /internal/resources/report takes network.resource.report; the whole batch is validated before anything is written;
// an offer missing from the same source's next report is marked down, not deleted; the filter columns always equal
--// the stored doc through the one mapping offers.columns(). GET /api/v1/resources filters in SQL, hides down offers by
++// the stored doc through the one mapping offers.columns(). GET /api/v1/offers filters in SQL, hides down offers by
// default and never shows capacity; GET /internal/resources, behind the report's guard, returns the docs whole.
// node test/resource-registry.test.js
const assert = require('assert');
@@@ -34,7 +34,7 @@ app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
const r = offers.routers({ guard: principals.guard('network.resource.report', { legacy: false }) });
--app.use('/api/v1/resources', r.pub);
++app.use('/api/v1/offers', r.pub);
app.use('/internal/resources', r.internal);
app.use(require('../server/registry/ecosystem').createEcosystemRegistry().router());
const server = http.createServer(app);
@@@ -166,7 +166,7 @@ const assertLockstep = async () => {
] }, auth);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: y.status === 204 ? null : await y.json() }));
-- const ids = async (q, prefix = '/api/v1/resources', headers) => {
++ const ids = async (q, prefix = '/api/v1/offers', headers) => {
const y = await get(`${prefix}${q}`, headers);
assert.strictEqual(y.status, 200, `${prefix}${q}`);
assert.strictEqual(y.body.count, y.body.offers.length, `${q}: count`);
@@@ -188,12 +188,12 @@ const assertLockstep = async () => {
['?kind=storage', []],
['?max_price_usd=cheap&colour=blue', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],
]) assert.deepStrictEqual(await ids(q), want, q || 'the default hides down offers');
-- x = await get('/api/v1/resources?kind=storage&max_price_usd=0.05&trust=partner&colour=blue');
++ x = await get('/api/v1/offers?kind=storage&max_price_usd=0.05&trust=partner&colour=blue');
assert.deepStrictEqual(x.body, { offers: [], generated_at: x.body.generated_at, filters: { kind: 'storage', trust: 'partner', max_price_usd: 0.05 }, count: 0 }, 'no storage has been reported yet');
const stored = new Map((await rows()).map((o) => [o.id, JSON.parse(o.doc)]));
// The public list matches Network's redacted contract; stored docs remain canonical.
for (const q of ['', '?status=down', '?kind=provider']) {
-- for (const o of (await get(`/api/v1/resources${q}`)).body.offers) {
++ for (const o of (await get(`/api/v1/offers${q}`)).body.offers) {
assert.ok(!('capacity' in o), `${o.offer_id}: no capacity in public`);
assert.ok(offers.validatePublicDoc(o).valid, `${o.offer_id}: public doc matches ${offers.PUBLIC_CONTRACT}`);
assert.deepStrictEqual(o, offers.publicDoc(stored.get(o.offer_id)));
@@@ -220,14 +220,14 @@ const assertLockstep = async () => {
assert.ok(validate(offers.CONTRACT, x.body).valid);
assert.deepStrictEqual(x.body, stored.get(id));
assert.ok(x.body.capacity);
-- x = await get(`/api/v1/resources/${id}`);
++ x = await get(`/api/v1/offers/${id}`);
assert.strictEqual(x.status, 200);
assert.ok(offers.validatePublicDoc(x.body).valid);
assert.deepStrictEqual(x.body, offers.publicDoc(stored.get(id)));
assert.ok(!('capacity' in x.body));
}
assert.strictEqual((await get('/internal/resources/l-1')).status, 403, 'the internal single read takes the guard too');
-- for (const [url, headers] of [['/api/v1/resources/nope'], ['/internal/resources/nope', auth]]) {
++ for (const [url, headers] of [['/api/v1/offers/nope'], ['/internal/resources/nope', auth]]) {
x = await get(url, headers);
assert.strictEqual(x.status, 404, url);
assert.strictEqual(x.headers.get('content-type'), 'application/problem+json', url);
@@@ -235,13 +235,13 @@ const assertLockstep = async () => {
}
// 7. Headers: list and single cacheable for a minute and open to any origin; the beacon 204 and never cached.
-- for (const url of ['/api/v1/resources', '/api/v1/resources?kind=provider', '/api/v1/resources/l-1']) {
++ for (const url of ['/api/v1/offers', '/api/v1/offers?kind=provider', '/api/v1/offers/l-1']) {
x = await get(url);
assert.strictEqual(x.headers.get('cache-control'), 'public, max-age=60', url);
assert.strictEqual(x.headers.get('access-control-allow-origin'), '*', url);
assert.strictEqual(x.headers.get('timing-allow-origin'), '*', url);
}
-- x = await get('/api/v1/resources/l-1/beacon');
++ x = await get('/api/v1/offers/l-1/beacon');
assert.strictEqual(x.status, 204);
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');
@@@ -257,8 +257,9 @@ const assertLockstep = async () => {
] }, auth);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
const idx = await get('/api/v1/registry');
-- assert.strictEqual(idx.body.resources, '/api/v1/resources', 'the registry index advertises the resource list');
-- const pub = await get('/api/v1/resources');
++ assert.strictEqual(idx.body.resources, '/api/v1/resources', 'the registry index advertises the resource index path');
++ assert.strictEqual(idx.body.offers, '/api/v1/offers', 'and the offers at their new path');
++ const pub = await get('/api/v1/offers');
assert.strictEqual(pub.status, 200);
const req = { kind: 'request', mobility: 'request', latency_class: 'interactive', objective: 'cheapest', region: 'us-central', capabilities: [], units: 1 };
const result = require('openvibe-sdk/placement').plan(req, pub.body.offers, { now });
@@@ -298,7 +299,7 @@ const assertLockstep = async () => {
}
const harnessId = detailOf('harness').id;
-- const harnessPublic = await get(`/api/v1/resources/${harnessId}`);
++ const harnessPublic = await get(`/api/v1/offers/${harnessId}`);
const harnessInternal = await get(`/internal/resources/${harnessId}`, resourceOnly);
assert.strictEqual(harnessPublic.status, 200);
assert.strictEqual(harnessInternal.status, 200);
@@@ -307,7 +308,7 @@ const assertLockstep = async () => {
assert.ok(!validate(offers.CONTRACT, harnessPublic.body).valid, 'the canonical contract requires harness detail.address');
assert.ok(!('address' in harnessPublic.body.detail), 'public single read omits the harness address');
assert.deepStrictEqual(harnessInternal.body.detail.address, detailOf('harness').address, 'internal single read keeps the address');
-- const publicHarnessList = await get('/api/v1/resources?kind=harness');
++ const publicHarnessList = await get('/api/v1/offers?kind=harness');
const internalHarnessList = await get('/internal/resources?kind=harness', resourceOnly);
assert.ok(offers.validatePublicDoc(publicHarnessList.body.offers[0]).valid, 'public list matches the redacted contract');
assert.ok(validate(offers.CONTRACT, internalHarnessList.body.offers[0]).valid, 'internal list matches the canonical contract');