openvibers/openvibe.network Commits
Add v0.85.0 resource offer kinds and detail checks (#12)
Changes
@@@ -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 againstthe network's real inventory. It follows the existing node registry (`server/registry/nodes.js`) exactly. It does notplan, 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 thesame source's absent ids as `nodes.js`, with the six duplicated columns taken from the validated doc through the mappingin §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=®ion=&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=®ion=&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 canpass the array straight into `plan()`. `filters` and `count` live beside `offers`, never inside it. The conveniencecolumns (`cell`, `price_usd`) are therefore *not* re-added to each doc: a doc that omitted `cell` is returned withoutone, 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 listexcludes `down`; an unknown `kind` returns `{offers: []}` with 200. The public list never contains `capacity`; theinternal 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 204with `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 SQLfilter 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]`, thereport'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 — thisslice 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)nnew file mode 100644@@@ -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'));@@@ -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",@@@ -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",@@@ -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@@@ -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);}@@@ -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 sourceconst 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 };nnew file mode 100644@@@ -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": []++}nnew file mode 100644@@@ -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++ }++}nnew file mode 100644@@@ -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++ }++}nnew file mode 100644@@@ -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"++ ]++}nnew file mode 100644@@@ -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"++ }++ ]++}@@@ -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://openvibapp.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();
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
# T2 — the resource registry (design)
# 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
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.
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
@@ -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 |
| 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 |
| `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 |
| `region` | `doc.region` | required |
| `trust` | `doc.trust` | 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. |
| `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:
@@ -88,34 +88,21 @@ Invariants that follow from the table:
### The `kind` CHECK — reconciled with the pinned contract
### 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`
## 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
**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
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`.
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,
**Validation — one pass over the whole batch, then one transaction for the writes.** The report has exactly two phases,
in this order:
in this order:
@@ -146,27 +133,25 @@ the nodes call, purely as documentation.
@@ -146,27 +133,25 @@ the nodes call, purely as documentation.
| Route | Behaviour | Cache |
| Route | Behaviour | Cache |
|---|---|---|
|---|---|---|
-| `GET /api/v1/resources` | `{offers: [<platform.resource-offer@1> minus capacity…], generated_at, filters, count}`; filters `?kind=®ion=&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=®ion=&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/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` |
| `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,
- 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.
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`
- 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.
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
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
columns (`cell`, `price_usd`) are therefore *not* re-added to each doc: a doc that omitted `cell` is returned without
one, exactly as reported.
one, exactly as reported.
@@ -240,8 +225,9 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
@@ -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
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
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.
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
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: *`.
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
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`
@@ -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
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.
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`
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
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`,
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/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).
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`
@@ -269,23 +255,14 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
`server/registry/ecosystem.js`. Nothing else.
`server/registry/ecosystem.js`. Nothing else.
4. **Placement consumption proof.** (done) §5 gains a worked example; test 9 added. No production-code change — this
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.
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)
## 9. Cells and node principals (lane B, first slice)
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -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'));
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1556,9 +1556,9 @@
@@ -1556,9 +1556,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.83.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.83.0",
- "integrity": "sha512-KMIJfTjpjVfE/cH2VDmzbZL9KnBm9guyFRniic7kdl/dN0RV2IlgExJG13hDOO39Qq4+RfjPcwuhp06qWuD2SQ==",
+ "version": "0.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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,7 @@
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.3.1",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -149,6 +149,7 @@ const DEFAULT_GRANTS = [
@@ -149,6 +149,7 @@ const DEFAULT_GRANTS = [
['host', 'network.operator.alert', SELF_AUDIENCE, []],
['host', 'network.operator.alert', SELF_AUDIENCE, []],
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
['host', 'network.node.report', SELF_AUDIENCE, []],
['host', 'network.node.report', SELF_AUDIENCE, []],
+ ['host', 'network.resource.report', SELF_AUDIENCE, []],
// WS-N task 12: ovhost incident / maintenance post to the status page.
// WS-N task 12: ovhost incident / maintenance post to the status page.
['host', 'network.status.incident', SELF_AUDIENCE, []],
['host', 'network.status.incident', SELF_AUDIENCE, []],
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
// WS-L task 4: OpenVibe.Host's scheduled Tools job proof (openvibe-toolsjob.timer, OpenVibe.Examples
@@ -468,9 +468,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -468,9 +468,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal/nodes', nodeRouters.internal);
app.use('/internal/nodes', nodeRouters.internal);
}
}
// The resource registry (plan T2, docs/t2-resource-registry.md): node and provider offers. The public list leaves
// 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('/api/v1/resources', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
app.use('/internal/resources', offerRouters.internal);
}
}
@@ -1,9 +1,9 @@
@@ -1,9 +1,9 @@
'use strict';
'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
* 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
* 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.
* deliberately does not publish); GET /internal/resources, behind the report's guard, returns the docs whole.
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
const express = require('express');
const express = require('express');
const CONTRACT = 'platform.resource-offer@1';
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 SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.node-report-request@1's source
const MAX_OFFERS = 500;
const MAX_OFFERS = 500;
const DEFAULT_CELL = 'wnam-1';
const DEFAULT_CELL = 'wnam-1';
@@ -42,8 +45,19 @@ function check(body) {
@@ -42,8 +45,19 @@ function check(body) {
return { error: `The body must be {source, offers}: source matching ${SOURCE}, at most ${MAX_OFFERS} offers` };
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++) {
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) };
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;
return null;
}
}
@@ -108,8 +122,29 @@ async function get(db, id) {
@@ -108,8 +122,29 @@ async function get(db, id) {
return row ? JSON.parse(row.doc) : null;
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 }) {
function routers({ guard }) {
const listed = async (req, shape) => {
const listed = async (req, shape) => {
@@ -153,4 +188,4 @@ function routers({ guard }) {
@@ -153,4 +188,4 @@ function routers({ guard }) {
return { internal, pub };
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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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": []
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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"
+ ]
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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"
+ }
+ ]
+}
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'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
// 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/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.
// 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
@@ -33,7 +33,7 @@ app.locals.config = { internalKey: 'legacy-key', jwt: { issuer: 'https://openvib
app.locals.privateKey = keys.privateKey;
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
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('/api/v1/resources', r.pub);
app.use('/internal/resources', r.internal);
app.use('/internal/resources', r.internal);
app.use(require('../server/registry/ecosystem').createEcosystemRegistry().router());
app.use(require('../server/registry/ecosystem').createEcosystemRegistry().router());
@@ -54,28 +54,37 @@ const assertLockstep = async () => {
@@ -54,28 +54,37 @@ const assertLockstep = async () => {
(async () => {
(async () => {
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const base = `http://127.0.0.1:${server.address().port}`;
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) })
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) }));
.then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
try {
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);
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');
await assert.rejects(insert('node', 'gone'), 'status outside up|degraded|down|draining');
assert.deepStrictEqual(await rows(), []);
assert.deepStrictEqual(await rows(), []);
// 1. Auth matrix.
// 1. Auth matrix.
const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
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.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.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.
// 2. Validation writes nothing: bad offers, a bad envelope, and a bad offer last in an otherwise good batch.
for (const [why, body] of [
for (const [why, body] of [
['trust outside the enum', { source: 'oregon', offers: [offer('o-1', { trust: 'random' })] }],
['trust outside the enum', { source: 'oregon', offers: [offer('o-1', { trust: 'random' })] }],
['region missing', { source: 'oregon', offers: [(({ region, ...o }) => o)(offer('o-1'))] }],
['region missing', { source: 'oregon', offers: [(({ region, ...o }) => o)(offer('o-1'))] }],
['additionalProperties: false', { source: 'oregon', offers: [offer('o-1', { address: '10.0.0.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')] }],
['source pattern', { source: 'Oregon!', offers: [offer('o-1')] }],
['extra envelope key', { source: 'oregon', offers: [offer('o-1')], cell: 'wnam-1' }],
['extra envelope key', { source: 'oregon', offers: [offer('o-1')], cell: 'wnam-1' }],
['offers not an array', { source: 'oregon', offers: offer('o-1') }],
['offers not an array', { source: 'oregon', offers: offer('o-1') }],
@@ -180,20 +189,22 @@ const assertLockstep = async () => {
@@ -180,20 +189,22 @@ const assertLockstep = async () => {
['?max_price_usd=cheap&colour=blue', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],
['?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');
]) 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/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)]));
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 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/resources${q}`)).body.offers) {
assert.ok(!('capacity' in o), `${o.offer_id}: no capacity in public`);
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)));
assert.deepStrictEqual(o, offers.publicDoc(stored.get(o.offer_id)));
}
}
}
}
// The internal list: the report's guard, the same filters, the docs whole.
// The internal list: the report's guard, the same filters, the docs whole.
assert.strictEqual((await get('/internal/resources')).status, 403, 'nobody');
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.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);
x = await get('/internal/resources', auth);
assert.strictEqual(x.status, 200);
assert.strictEqual(x.status, 200);
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
@@ -202,7 +213,7 @@ const assertLockstep = async () => {
@@ -202,7 +213,7 @@ const assertLockstep = async () => {
assert.deepStrictEqual(await ids('?kind=provider&status=degraded', '/internal/resources', auth), ['l-2']);
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']);
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']) {
for (const id of ['l-1', 'o-1']) {
x = await get(`/internal/resources/${id}`, auth);
x = await get(`/internal/resources/${id}`, auth);
assert.strictEqual(x.status, 200);
assert.strictEqual(x.status, 200);
@@ -211,7 +222,7 @@ const assertLockstep = async () => {
@@ -211,7 +222,7 @@ const assertLockstep = async () => {
assert.ok(x.body.capacity);
assert.ok(x.body.capacity);
x = await get(`/api/v1/resources/${id}`);
x = await get(`/api/v1/resources/${id}`);
assert.strictEqual(x.status, 200);
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.deepStrictEqual(x.body, offers.publicDoc(stored.get(id)));
assert.ok(!('capacity' in x.body));
assert.ok(!('capacity' in x.body));
}
}
@@ -255,6 +266,56 @@ const assertLockstep = async () => {
@@ -255,6 +266,56 @@ const assertLockstep = async () => {
assert.ok(check.valid, JSON.stringify(check.errors));
assert.ok(check.valid, JSON.stringify(check.errors));
assert.strictEqual(result.selected, 'p-provider', 'the cheaper eligible offer wins');
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']);
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');
console.log('resource-registry: all tests passed');
} finally {
} finally {
server.close();
server.close();