openvibers/openvibe.network Commits

Add v0.85.0 resource offer kinds and detail checks (#12)

OP OpenVibers committed 2 Oct 2026 · 083ae8eba1 · 13 files, +269 −90

local checks ✓ 3/3 · CI ✓ 3/3 · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Network/pull/12

Changes

ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex e9cd34e5..5b50f4a1 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -1,12 +1,12 @@
# T2 — the resource registry (design)
--Status: **slice 1 built** (`migrations/0007_resource_registry.sql`, `server/registry/offers.js` write path,
--`test/resource-registry.test.js`); **cells and node principals built** (section 9, `migrations/0008`); slices 2-5 are design.
--Pinned versions at time of writing: `openvibe-contracts` **v0.83.0** (`package.json:30`), `openvibe-sdk` v0.25.2,
--`openvibe-shared` v2.3.1. Every claim below was checked against that pin.
++Status: **resource registry and N6 built** (`migrations/0007_resource_registry.sql`,
++`migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`); **cells and node principals built**
++(section 9, `migrations/0008`). The original slice plan in §7 is historical.
++Current pin: `openvibe-contracts` **v0.85.0** (`package.json`); the v0.83.0 observations below describe the original design.
--Scope: one table and one module that store **what the network can sell or place on** — every compute node and every
--provider — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
++Scope: one table and one module that store **what the network can sell or place on** — node, provider, storage, delivery,
++runtime, agent and harness offers — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
the network's real inventory. It follows the existing node registry (`server/registry/nodes.js`) exactly. It does not
plan, does not price, does not settle, and does not create cells or service instances.
@@@ -69,7 +69,7 @@ mapping is used in the SQL upsert (as bind values) and in test 8 — one definit
| Column | Written from the validated `platform.resource-offer@1` doc | Why it is not a plain copy |
|---|---|---|
| `id` | `doc.offer_id` | required by the contract |
--| `kind` | `doc.kind` | required; enum `node\|provider` at v0.83.0 |
++| `kind` | `doc.kind` | required; enum `node\|provider\|storage\|delivery\|runtime\|agent\|harness` at v0.85.0 |
| `region` | `doc.region` | required |
| `trust` | `doc.trust` | required |
| `cell` | `doc.cell ?? 'wnam-1'` | `cell` is **optional** — it is not in the contract's `required` list, so an offer may omit it. The stored `doc` stays **verbatim** (no `cell` is synthesised into it), so when the field is absent the column legitimately holds the default. |
@@@ -88,34 +88,21 @@ Invariants that follow from the table:
### The `kind` CHECK — reconciled with the pinned contract
--`platform.resource-offer@1` at v0.83.0 defines `kind` as the enum `["node","provider"]` and
--`additionalProperties: false`. The roadmap's *other* offer kinds (`storage`, `delivery`, `runtime`, `agent`,
--`harness`) have **no contract at v0.83.0** — verified against `node_modules/openvibe-contracts/contracts/platform/`,
--which ships 8 files, and `validate('platform.storage-offer@1', …)` throws `unknown contract`.
--
--Therefore:
--
--1. **`0007` permits only `node` and `provider`.** Nothing wider is written to the migration. A wider list would admit
-- rows that the pinned contract cannot validate, so every read would be able to return a document that no writer could
-- have produced.
--2. The other five kinds are **reserved and documented, not implemented**. They appear in no migration, no `CHECK`,
-- no constant, no test and no runtime code. Naming them in code — even inside a map — would reference contracts that
-- do not exist, and `validate()` would throw inside the route and turn a 400 into a 500.
--3. The `CHECK` is widened **only** by slice 5, after T1 publishes the tags and Network bumps the pin
-- (`migrations/0009_resource_offer_kinds.sql`: `ALTER TABLE … DROP CONSTRAINT … ADD CONSTRAINT`).
++`0007` originally permitted only `node` and `provider`, matching the v0.83.0 contract at that point. With the
++v0.85.0 pin, `0009_resource_offer_kinds.sql` widens the CHECK to all seven kinds. Every stored document validates as
++`platform.resource-offer@1`; the five new kinds carry their per-kind contract inside `detail` (Decision C in
++`docs/t2-cells-and-node-principal.md` §6). The filter columns retain the mapping above.
## 3. Module — `server/registry/offers.js`
--A copy of `nodes.js` with four deltas.
++The module follows `nodes.js`, with registry-specific validation, filters and public projection.
**Report** — `report(db, {source, offers}, now)` → `{offers, marked_down}`; same upsert shape and same mark-down of the
same source's absent ids as `nodes.js`, with the six duplicated columns taken from the validated doc through the mapping
in §2. The response header is `X-Offers-Marked-Down`.
--**Guard** — `principals.guard('network.node.report')` for the first slice. `network.resource.report` **does not exist
--at v0.83.0**, and `principals.js` throws at boot on an unknown capability, so inventing the string breaks the service.
--`network.node.report` is already held by Host, which is the component that reports. Slice 5 switches the constant to
--`network.resource.report` in one line when T1 publishes it. Do not invent a capability string.
++**Guard** — `principals.guard('network.resource.report')` protects the report and full internal reads. Host receives
++that capability by default. A token holding only `network.node.report` cannot access these routes.
**Validation — one pass over the whole batch, then one transaction for the writes.** The report has exactly two phases,
in this order:
@@@ -146,27 +133,25 @@ the nodes call, purely as documentation.
| Route | Behaviour | Cache |
|---|---|---|
--| `GET /api/v1/resources` | `{offers: [<platform.resource-offer@1> minus capacity…], 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` | the single offer minus capacity (any status), or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |
++| `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 /internal/resources` | the same list and filters, docs **whole** (capacity included); the report's guard, `network.node.report` | `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` |
--**Exposure (decided, slice 2).** `platform.resource-offer@1` describes itself as *first-party only: it carries capacity
--the public node registry (`network.node@1`) deliberately leaves out*. So the unauthenticated `/api/v1/resources` list and
--single read return each contract document verbatim **except the `capacity` object**, which is omitted (`capacity` is
--optional in the contract, so the public doc still validates). Everything else — capabilities, region, cell, trust,
--health, pricing, latency — is what `openvibe-sdk/geo` and a planner's trust/price filter need, and is no more than the
--public node list already says. The full documents, capacity included, are read from `GET /internal/resources[/:id]`,
--behind exactly the guard of `POST /internal/resources/report` (`network.node.report` today, the one-line switch in
--slice 5): that is the read `placement.plan()` consumers use, since capacity is what it places on. No capability is added
--to Contracts for this; a dedicated read capability, if one is wanted, arrives with T1 like `network.resource.report`.
++**Public contract (Network-local `platform.resource-offer-public@1`).** A public offer has the same fields and
++validation as `platform.resource-offer@1`, except `capacity` is absent for every kind and `detail.address` is absent
++for `kind: harness`. `server/registry/offers.js` defines this projection as `publicDoc()` and validates it with
++`validatePublicDoc()`. The canonical v0.85.0 harness detail **requires** `address`, so a redacted public harness offer
++does **not** validate directly as `platform.resource-offer@1`; clients should use the public contract. Other public
++offers still validate as the canonical contract. The full stored documents, including capacity and harness address,
++are available through `GET /internal/resources[/:id]` with `network.resource.report`.
- Filters are applied in **SQL**, not JS. A `kind` the pinned contract does not define yields an empty list with 200,
never a 500. Unknown query keys are ignored. `?status=down` is how a caller sees the hidden-down rows.
- Query values are taken once, as strings; `max_price_usd` must be a finite number ≥ 0, else it is ignored. `filters`
echoes only the filters that were applied.
--- `offers[]` elements are the contract documents **verbatim** (the public list minus `capacity`) — no envelope fields are added inside them, so a client can
++- `offers[]` elements are the public contract projection (capacity omitted, plus harness address omitted) — no envelope fields are added inside them, so a client can
pass the array straight into `plan()`. `filters` and `count` live beside `offers`, never inside it. The convenience
columns (`cell`, `price_usd`) are therefore *not* re-added to each doc: a doc that omitted `cell` is returned without
one, exactly as reported.
@@@ -240,8 +225,9 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
5. **Filters** — `?kind=provider`, `?region=`, `?trust=community`, `?max_price_usd=0.05`, `?cell=wnam-1`; the default list
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 doc, `GET /api/v1/resources/:id` the same minus
-- `capacity`, both matching `platform.resource-offer@1`; an unknown id → 404 `registry.unknown_offer` on both.
++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
++ `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: *`.
8. **Schema lockstep** — after a report, every row is compared against the **mapping table in §2**, not against a
@@@ -257,11 +243,11 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
Run `node test/resource-registry.test.js` while iterating, `npm run test:pg` (`NETWORK_TEST_STORE=pg`) for the SQL
filter path — PGlite and a real PostgreSQL must agree on it — and `npm test` once at the end.
--## 7. Slices — five, each independently mergeable
++## 7. Original slice plan (historical)
1. **Table + report (write path).** `migrations/0007_resource_registry.sql`, `server/registry/offers.js` (`ensureSchema`
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. Guard reuses `network.node.report`.
++ `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
report's guard), because the public read leaves capacity out (§4).
@@@ -269,23 +255,14 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
`server/registry/ecosystem.js`. 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.** *Blocked on T1.* `package.json:30` pin bump, a
-- `node -e "validate('platform.storage-offer@1', …)"` smoke check, `migrations/0009_resource_offer_kinds.sql` widening
-- the `kind` CHECK to the published kinds, the one-line capability switch, a `KIND → CONTRACT` map used for per-kind
-- validation (`validate(KIND_CONTRACT[kind], offer)` when the map has an entry, else the base offer contract), and a
-- test per new kind. Nothing waits on this slice; it is only startable once T1 publishes the tags, and
-- `scripts/contracts-drift.js` is what flags the pin in the meantime.
-- **Re-planned** in `docs/t2-cells-and-node-principal.md` §6 (per-kind offer as `detail`, slice N6); it supersedes this item.
--
--## 8. Unresolved
--
--- **T1's contract release** blocks slice 5 and nothing else. Until it lands, five roadmap offer kinds are documented and
-- unreachable, and the registry accepts `node` and `provider` only.
--- **Route weight and `service_instance`** are first-class in the roadmap but absent from `platform.resource-offer@1`
-- (it has no route-weight field and no service-instance reference). They belong to `platform.placement-plan@1` /
-- `platform.service-instance@1`, neither of which is at v0.83.0, so they cannot be modelled here now.
--- **`network.resource.report`** does not exist at v0.83.0; `network.node.report` is reused for slice 1. The semantic
-- mismatch (nodes reporting offers) is deliberate and temporary, and it is the only reason slice 1 does not need T1.
++5. **Contracts bump + kind widening.** This item was re-planned as N6 in
++ `docs/t2-cells-and-node-principal.md` §6: five per-kind contracts inside `detail`, v0.85.0 pin,
++ `0009_resource_offer_kinds.sql`, and the `network.resource.report` guard. N6 is built.
++
++## 8. Separate concerns
++
++Route weight and service instances are outside `platform.resource-offer@1`; their contracts and registry paths are
++separate from resource offers. `network.node.report` remains the guard for node reports.
## 9. Cells and node principals (lane B, first slice)
ddiff --git a/migrations/0009_resource_offer_kinds.sql b/migrations/0009_resource_offer_kinds.sql
nnew file mode 100644
iindex 00000000..174efed7
---- /dev/null
++++ b/migrations/0009_resource_offer_kinds.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- plan T2. Widen resource offer kinds with the v0.85.0 per-kind detail contracts.
++
++ALTER TABLE platform_resource_offers DROP CONSTRAINT IF EXISTS platform_resource_offers_kind_check;
++ALTER TABLE platform_resource_offers ADD CONSTRAINT platform_resource_offers_kind_check
++ CHECK (kind IN ('node','provider','storage','delivery','runtime','agent','harness'));
ddiff --git a/package-lock.json b/package-lock.json
iindex 92ac691f..61f653e8 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.83.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
@@@ -1556,9 +1556,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.83.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
-- "integrity": "sha512-KMIJfTjpjVfE/cH2VDmzbZL9KnBm9guyFRniic7kdl/dN0RV2IlgExJG13hDOO39Qq4+RfjPcwuhp06qWuD2SQ==",
++ "version": "0.85.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
++ "integrity": "sha512-Xo54hG4kiL1kOTcxwbnH8cmt2zZhvp/gqCs+egPBLLSBg+SjVuhXIPaX8EJPQux5x1YxwO7wjDDywcHvIaGoqA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d5ab1a66..6b99d297 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.83.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.85.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 6431d19c..0cce8e64 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -149,6 +149,7 @@ const DEFAULT_GRANTS = [
['host', 'network.operator.alert', SELF_AUDIENCE, []],
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
['host', 'network.node.report', SELF_AUDIENCE, []],
++ ['host', 'network.resource.report', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
ddiff --git a/server/index.js b/server/index.js
iindex e3d8dd88..d20ef425 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -468,9 +468,9 @@ 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.node.report until Contracts publishes network.resource.report.
++// capacity out; the report and the full internal read take network.resource.report.
{
-- const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.node.report') });
++ const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.resource.report') });
app.use('/api/v1/resources', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
}
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex 1f9885fd..0a81323e 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -1,9 +1,9 @@
'use strict';
/**
-- * The resource registry (plan T2, docs/t2-resource-registry.md; Contracts 0.83.0 platform.resource-offer@1): what the
-- * network can place on — every node and provider offer, with capabilities, capacity, health and pricing. The owner
-- * reports the complete set of its offers (POST /internal/resources/report, network.node.report until Contracts
-- * publishes network.resource.report); an offer absent from a later report of the same source is marked down, never
++ * The resource registry (plan T2, docs/t2-resource-registry.md; Contracts platform.resource-offer@1): what the
++ * 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
* 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.
@@@ -11,6 +11,9 @@
const express = require('express');
const CONTRACT = 'platform.resource-offer@1';
++// Network's public projection of CONTRACT: capacity is absent, and harness detail.address is absent.
++// The latter is required by the internal contract, so public harness docs need their own validator.
++const PUBLIC_CONTRACT = 'platform.resource-offer-public@1';
const SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.node-report-request@1's source
const MAX_OFFERS = 500;
const DEFAULT_CELL = 'wnam-1';
@@@ -42,8 +45,19 @@ function check(body) {
return { error: `The body must be {source, offers}: source matching ${SOURCE}, at most ${MAX_OFFERS} offers` };
}
for (let i = 0; i < body.offers.length; i++) {
-- const v = validate(CONTRACT, body.offers[i]);
++ const offer = body.offers[i];
++ const v = validate(CONTRACT, offer);
if (!v.valid) return { error: `offer ${i} does not match ${CONTRACT}`, details: (v.errors || []).slice(0, 5) };
++ const detail = offer.detail;
++ if (!detail) continue;
++ if (detail.id !== offer.offer_id) return { error: 'registry.detail_id_mismatch' };
++ if (detail.node != null && offer.node_id != null && detail.node !== offer.node_id) {
++ return { error: 'registry.detail_node_mismatch' };
++ }
++ if ((detail.region != null && detail.region !== offer.region)
++ || (detail.regions != null && !detail.regions.includes(offer.region))) {
++ return { error: 'registry.detail_region_mismatch' };
++ }
}
return null;
}
@@@ -108,8 +122,29 @@ async function get(db, id) {
return row ? JSON.parse(row.doc) : null;
}
--/** The public form of an offer: the contract doc verbatim without capacity (design section 4). */
--const publicDoc = ({ capacity, ...doc }) => doc;
++/** The public form of an offer: capacity and harness connection address are private. */
++function publicDoc({ capacity, ...doc }) {
++ if (doc.kind !== 'harness' || !doc.detail) return doc;
++ const { address, ...detail } = doc.detail;
++ return { ...doc, detail };
++}
++
++/** Validate the public projection against CONTRACT with only its two documented redactions. */
++function validatePublicDoc(doc) {
++ const { validate } = require('openvibe-contracts');
++ if (!doc || typeof doc !== 'object' || Array.isArray(doc)) {
++ return { valid: false, errors: [{ path: '/', message: 'must be an offer object' }] };
++ }
++ if (Object.hasOwn(doc, 'capacity')) {
++ return { valid: false, errors: [{ path: '/capacity', message: 'must be absent from the public contract' }] };
++ }
++ if (doc.kind !== 'harness') return validate(CONTRACT, doc);
++ if (!doc.detail || typeof doc.detail !== 'object' || Array.isArray(doc.detail) || Object.hasOwn(doc.detail, 'address')) {
++ return { valid: false, errors: [{ path: '/detail/address', message: 'must be absent from the public contract' }] };
++ }
++ // Supply a schema-valid sentinel solely for validation; it is never stored or returned.
++ return validate(CONTRACT, { ...doc, detail: { ...doc.detail, address: { kind: 'cli', target: 'redacted' } } });
++}
function routers({ guard }) {
const listed = async (req, shape) => {
@@@ -153,4 +188,4 @@ function routers({ guard }) {
return { internal, pub };
}
--module.exports = { CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, list, get, publicDoc, routers };
++module.exports = { CONTRACT, PUBLIC_CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, list, get, publicDoc, validatePublicDoc, routers };
ddiff --git a/test/fixtures/resource-offers/agent.json b/test/fixtures/resource-offers/agent.json
nnew file mode 100644
iindex 00000000..cf128121
---- /dev/null
++++ b/test/fixtures/resource-offers/agent.json
@@@ -0,0 +1,17 @@
++{
++ "id": "agent-1",
++ "harness": "harness-1",
++ "provider": "example-ai",
++ "model": "model-1",
++ "context_limits": {
++ "input_tokens": 128000,
++ "output_tokens": 8192
++ },
++ "concurrency": 4,
++ "price_per_1k_tokens": {
++ "fresh_usd": 0.003,
++ "cached_usd": 0.001,
++ "output_usd": 0.01
++ },
++ "availability_windows": []
++}
ddiff --git a/test/fixtures/resource-offers/delivery.json b/test/fixtures/resource-offers/delivery.json
nnew file mode 100644
iindex 00000000..9890da98
---- /dev/null
++++ b/test/fixtures/resource-offers/delivery.json
@@@ -0,0 +1,19 @@
++{
++ "id": "delivery-1",
++ "transports": [
++ "http",
++ "hls"
++ ],
++ "regions": [
++ "us-west"
++ ],
++ "edge": true,
++ "node": "node-1",
++ "price_per_gb_usd": 0.02,
++ "price_per_request_usd": 1e-05,
++ "cache_rules": {
++ "enabled": true,
++ "ttl_seconds": 300,
++ "max_object_mb": 100
++ }
++}
ddiff --git a/test/fixtures/resource-offers/harness.json b/test/fixtures/resource-offers/harness.json
nnew file mode 100644
iindex 00000000..474964af
---- /dev/null
++++ b/test/fixtures/resource-offers/harness.json
@@@ -0,0 +1,24 @@
++{
++ "id": "harness-1",
++ "name": "Example Agent",
++ "provider": "example-ai",
++ "capabilities": {
++ "host_access": true,
++ "mcp": true,
++ "long_autonomy": true,
++ "resume": true
++ },
++ "address": {
++ "kind": "cli",
++ "target": "example-agent"
++ },
++ "price": {
++ "amount_usd": 0.1,
++ "unit": "hour"
++ },
++ "limits": {
++ "max_duration_seconds": 7200,
++ "max_concurrent_runs": 4,
++ "max_context_tokens": 128000
++ }
++}
ddiff --git a/test/fixtures/resource-offers/runtime.json b/test/fixtures/resource-offers/runtime.json
nnew file mode 100644
iindex 00000000..510a0f02
---- /dev/null
++++ b/test/fixtures/resource-offers/runtime.json
@@@ -0,0 +1,19 @@
++{
++ "id": "runtime-1",
++ "kind": "container",
++ "region": "us-west",
++ "node": "node-1",
++ "limits": {
++ "cpu_cores": 2,
++ "memory_mb": 4096,
++ "duration_seconds": 3600
++ },
++ "price": {
++ "amount_usd": 0.05,
++ "unit": "hour"
++ },
++ "availability": "available",
++ "constraints": [
++ "first-party"
++ ]
++}
ddiff --git a/test/fixtures/resource-offers/storage.json b/test/fixtures/resource-offers/storage.json
nnew file mode 100644
iindex 00000000..5197cf04
---- /dev/null
++++ b/test/fixtures/resource-offers/storage.json
@@@ -0,0 +1,20 @@
++{
++ "id": "storage-1",
++ "class": "warm",
++ "capacity_gb": 1000,
++ "price_per_gb_month_usd": 0.02,
++ "price_per_operation_usd": 1e-05,
++ "region": "us-west",
++ "node": "node-1",
++ "durability": {
++ "replicas": 3,
++ "target_nines": 9
++ },
++ "lifecycle_rules": [
++ {
++ "after_days": 90,
++ "action": "transition",
++ "target_class": "cold"
++ }
++ ]
++}
ddiff --git a/test/resource-registry.test.js b/test/resource-registry.test.js
iindex cc2f7eea..4e67ff99 100644
---- a/test/resource-registry.test.js
++++ b/test/resource-registry.test.js
@@@ -1,6 +1,6 @@
'use strict';
--// The resource registry, slices 1-2 (plan T2, docs/t2-resource-registry.md; Contracts platform.resource-offer@1):
--// POST /internal/resources/report takes Host's token only; the whole batch is validated before anything is written;
++// 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
// default and never shows capacity; GET /internal/resources, behind the report's guard, returns the docs whole.
@@@ -33,7 +33,7 @@ app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvib
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.node.report', { legacy: false }) });
++const r = offers.routers({ guard: principals.guard('network.resource.report', { legacy: false }) });
app.use('/api/v1/resources', r.pub);
app.use('/internal/resources', r.internal);
app.use(require('../server/registry/ecosystem').createEcosystemRegistry().router());
@@@ -54,28 +54,37 @@ const assertLockstep = async () => {
(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) => (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' }) })).json()).access_token;
++ 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 post = (body, headers) => fetch(`${base}/internal/resources/report`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
.then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
try {
-- // The migration: the table exists, kind is exactly the pinned contract's enum, status the planner's.
++ // The kind constraint is widened, remains strict, and can be applied twice.
++ const migration = fs.readFileSync(path.join(__dirname, '../migrations/0009_resource_offer_kinds.sql'), 'utf8');
++ await globalThis.__ovNetworkDdl(migration);
++ await globalThis.__ovNetworkDdl(migration);
const insert = (kind, status) => db.prepare("INSERT INTO platform_resource_offers (id, source, kind, region, trust, status, doc, reported_at) VALUES ('x', 'x', ?, 'us-west', 'partner', ?, '{}', 'now')").run(kind, status);
-- await assert.rejects(insert('storage', 'up'), 'kind storage has no contract at 0.83.0');
++ await insert('storage', 'up');
++ await db.prepare("DELETE FROM platform_resource_offers WHERE id = 'x'").run();
++ await assert.rejects(insert('unknown', 'up'), 'kind outside the seven-kind enum');
await assert.rejects(insert('node', 'gone'), 'status outside up|degraded|down|draining');
assert.deepStrictEqual(await rows(), []);
// 1. Auth matrix.
const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
++ const resourceOnly = { authorization: `Bearer ${await token('host', 'host-secret', 'network.resource.report')}` };
++ const nodeOnly = { authorization: `Bearer ${await token('host', 'host-secret', 'network.node.report')}` };
assert.strictEqual((await post({ source: 'oregon', offers: [] })).status, 403, 'nobody');
assert.ok([401, 403].includes((await post({ source: 'oregon', offers: [] }, { 'x-internal-key': 'legacy-key' })).status), 'not the retired shared key');
-- assert.strictEqual((await post({ source: 'oregon', offers: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.node.report');
++ assert.strictEqual((await post({ source: 'oregon', offers: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.resource.report');
++ assert.strictEqual((await post({ source: 'oregon', offers: [] }, nodeOnly)).status, 403, 'network.node.report alone cannot report resources');
++ assert.strictEqual((await post({ source: 'oregon', offers: [] }, resourceOnly)).status, 200, 'network.resource.report can report resources');
// 2. Validation writes nothing: bad offers, a bad envelope, and a bad offer last in an otherwise good batch.
for (const [why, body] of [
['trust outside the enum', { source: 'oregon', offers: [offer('o-1', { trust: 'random' })] }],
['region missing', { source: 'oregon', offers: [(({ region, ...o }) => o)(offer('o-1'))] }],
['additionalProperties: false', { source: 'oregon', offers: [offer('o-1', { address: '10.0.0.1' })] }],
-- ['kind without a contract', { source: 'oregon', offers: [offer('o-1', { kind: 'storage' })] }],
++ ['new kind without detail', { source: 'oregon', offers: [offer('o-1', { kind: 'storage' })] }],
['source pattern', { source: 'Oregon!', offers: [offer('o-1')] }],
['extra envelope key', { source: 'oregon', offers: [offer('o-1')], cell: 'wnam-1' }],
['offers not an array', { source: 'oregon', offers: offer('o-1') }],
@@@ -180,20 +189,22 @@ const assertLockstep = async () => {
['?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');
-- assert.deepStrictEqual(x.body, { offers: [], generated_at: x.body.generated_at, filters: { kind: 'storage', trust: 'partner', max_price_usd: 0.05 }, count: 0 }, 'an unknown kind is an empty 200');
++ 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 never carries capacity; every element is still a contract document, otherwise verbatim.
++ // 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) {
assert.ok(!('capacity' in o), `${o.offer_id}: no capacity in public`);
-- assert.ok(validate(offers.CONTRACT, o).valid, `${o.offer_id}: public doc matches the contract`);
++ 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)));
}
}
// The internal list: the report's guard, the same filters, the docs whole.
assert.strictEqual((await get('/internal/resources')).status, 403, 'nobody');
assert.ok([401, 403].includes((await get('/internal/resources', { 'x-internal-key': 'legacy-key' })).status), 'not the retired shared key');
-- assert.strictEqual((await get('/internal/resources', { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.node.report');
++ assert.strictEqual((await get('/internal/resources', { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.resource.report');
++ assert.strictEqual((await get('/internal/resources', nodeOnly)).status, 403, 'node reporting cannot read full resources');
++ assert.strictEqual((await get('/internal/resources', resourceOnly)).status, 200, 'resource reporting can read full resources');
x = await get('/internal/resources', auth);
assert.strictEqual(x.status, 200);
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
@@@ -202,7 +213,7 @@ const assertLockstep = async () => {
assert.deepStrictEqual(await ids('?kind=provider&status=degraded', '/internal/resources', auth), ['l-2']);
assert.deepStrictEqual(await ids('?status=down', '/internal/resources', auth), ['o-1', 'o-3']);
-- // 6. Single read: internal whole, public minus capacity, both platform.resource-offer@1; a down offer is readable.
++ // 6. Single read: internal whole, public projection; a down offer is readable.
for (const id of ['l-1', 'o-1']) {
x = await get(`/internal/resources/${id}`, auth);
assert.strictEqual(x.status, 200);
@@@ -211,7 +222,7 @@ const assertLockstep = async () => {
assert.ok(x.body.capacity);
x = await get(`/api/v1/resources/${id}`);
assert.strictEqual(x.status, 200);
-- assert.ok(validate(offers.CONTRACT, x.body).valid);
++ assert.ok(offers.validatePublicDoc(x.body).valid);
assert.deepStrictEqual(x.body, offers.publicDoc(stored.get(id)));
assert.ok(!('capacity' in x.body));
}
@@@ -255,6 +266,56 @@ const assertLockstep = async () => {
assert.ok(check.valid, JSON.stringify(check.errors));
assert.strictEqual(result.selected, 'p-provider', 'the cheaper eligible offer wins');
assert.deepStrictEqual(result.candidates.filter((c) => c.eligible).map((c) => c.id).sort(), ['p-node', 'p-provider', 'p-provider-b']);
++
++ // 10. v0.85.0 detail contracts: all five new kinds report and filter independently.
++ const kinds = ['storage', 'delivery', 'runtime', 'agent', 'harness'];
++ const detailOf = (kind) => JSON.parse(fs.readFileSync(path.join(__dirname, 'fixtures/resource-offers', `${kind}.json`), 'utf8'));
++ const kindOffer = (kind, detail = detailOf(kind), extra = {}) => offer(detail.id, { kind, node_id: undefined, detail, ...extra });
++ for (const kind of kinds) {
++ const doc = kindOffer(kind);
++ assert.ok(validate(offers.CONTRACT, doc).valid, `${kind} fixture matches the pinned envelope contract`);
++ x = await post({ source: `kind-${kind}`, offers: [doc] }, resourceOnly);
++ assert.strictEqual(x.status, 200, `${kind}: ${JSON.stringify(x.body)}`);
++ assert.deepStrictEqual(await ids(`?kind=${kind}`), [doc.offer_id], `${kind} filter`);
++ }
++ await assertLockstep();
++
++ // Contract rejection and Network-local cross-checks reject the entire report before a write.
++ for (const [why, bad, error] of [
++ ['storage without detail', offer('bad-storage', { kind: 'storage', node_id: undefined, detail: undefined }), 'offer 0 does not match platform.resource-offer@1'],
++ ['node with detail', offer('bad-node', { detail: detailOf('storage') }), 'offer 0 does not match platform.resource-offer@1'],
++ ['detail id mismatch', kindOffer('storage', detailOf('storage'), { offer_id: 'another-id' }), 'registry.detail_id_mismatch'],
++ ['detail node mismatch', kindOffer('storage', detailOf('storage'), { node_id: 'another-node' }), 'registry.detail_node_mismatch'],
++ ['detail region mismatch', kindOffer('storage', { ...detailOf('storage'), region: 'us-east' }), 'registry.detail_region_mismatch'],
++ ['detail regions mismatch', kindOffer('delivery', { ...detailOf('delivery'), regions: ['us-east'] }), 'registry.detail_region_mismatch'],
++ ]) {
++ const unchanged = await rows();
++ const batch = why === 'detail id mismatch' ? [kindOffer('agent'), bad] : [bad];
++ x = await post({ source: `bad-${why.replaceAll(' ', '-')}`, offers: batch }, resourceOnly);
++ assert.strictEqual(x.status, 400, why);
++ assert.strictEqual(x.body.error, error, why);
++ assert.deepStrictEqual(await rows(), unchanged, `${why}: nothing written`);
++ }
++
++ const harnessId = detailOf('harness').id;
++ const harnessPublic = await get(`/api/v1/resources/${harnessId}`);
++ const harnessInternal = await get(`/internal/resources/${harnessId}`, resourceOnly);
++ assert.strictEqual(harnessPublic.status, 200);
++ assert.strictEqual(harnessInternal.status, 200);
++ assert.ok(offers.validatePublicDoc(harnessPublic.body).valid, 'public single read matches the redacted contract');
++ assert.ok(validate(offers.CONTRACT, harnessInternal.body).valid, 'internal single read matches the canonical contract');
++ 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 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');
++ assert.ok(!('address' in publicHarnessList.body.offers[0].detail), 'public list omits the harness address');
++ assert.deepStrictEqual(internalHarnessList.body.offers[0].detail.address, detailOf('harness').address, 'internal list keeps the address');
++ assert.ok(!offers.validatePublicDoc({ ...harnessPublic.body, capacity: {} }).valid, 'public contract rejects capacity');
++ assert.ok(!offers.validatePublicDoc({ ...harnessPublic.body, detail: harnessInternal.body.detail }).valid, 'public contract rejects harness address');
++ assert.ok(!offers.validatePublicDoc({ ...harnessPublic.body, detail: { id: harnessId } }).valid, 'public contract checks required harness detail fields');
console.log('resource-registry: all tests passed');
} finally {
server.close();