openvibers/openvibe.network Commits
Serve resource offers publicly without capacity and in full to services (#7)
Changes
@@@ -146,14 +146,27 @@ the nodes call, purely as documentation.| Route | Behaviour | Cache ||---|---|---|--| `GET /api/v1/resources` | `{offers: [<platform.resource-offer@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` | the single offer, or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |++| `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/:offer_id/beacon` | 204 — the same idiom as `nodes.js:52`, for `openvibe-sdk/geo` | `no-store` |--| `GET /internal/resources` (slice 4, optional) | service-token read for consumers that cannot use the public list; `network.registry.read` **does** exist at v0.83.0 | `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/: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`.- 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.--- `offers[]` elements are the contract documents **verbatim** — no envelope fields are added inside them, so a client can++- 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 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.@@@ -164,7 +177,8 @@ the nodes call, purely as documentation.platform.placement-result@1`. Network is the registry, not the planner, and imports nothing from the SDK in productioncode. The contract between the two is therefore exact:--- **`offers[]` is `platform.resource-offer@1` verbatim**, so `plan()` consumes the HTTP response unchanged.++- **`offers[]` is `platform.resource-offer@1` verbatim**, so `plan()` consumes the HTTP response unchanged. A planner++ that weighs capacity reads `GET /internal/resources`; the public list has no `capacity` (§4).- `trust` and `health.status` must use the enums the planner knows — `first-party|partner|community|external`(`TRUST_ORDER`) and `up|degraded|down|draining` (`HEALTH_OK`). `down` and `draining` are excluded by the planner, so theregistry stores them faithfully and lets the default list hide only `down`.@@@ -193,9 +207,10 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`4. **Mark-down** — source A's offer missing from A's next report → `status='down'`, `doc.health.status='down'`,`checked_at` set, still listed under `?status=down`, header `X-Offers-Marked-Down: 1`; source B untouched.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.--6. **Single read** — `GET /api/v1/resources/:id` matches `platform.resource-offer@1`; an unknown id → 404-- `registry.unknown_offer`.++ 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.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@@@ -217,7 +232,8 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`no-op + `report`), the `ensureSchema` call in `server/db/database.js`, `app.use('/internal/resources', internal)` in`server/index.js`, `test/resource-registry.test.js` tests 1-4. Guard reuses `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.++ `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).3. **Discovery entry.** Add `resources: '/api/v1/resources'` to the `/api/v1/registry` index in`server/registry/ecosystem.js`. Nothing else.4. **Placement consumption proof.** §5 gains a worked example; test 9 added. No production-code change — this slice@@@ -467,10 +467,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(app.use('/api/v1/nodes', nodeRouters.pub);app.use('/internal/nodes', nodeRouters.internal);}--// The resource registry (plan T2, docs/t2-resource-registry.md): node and provider offers. Slice 1 is the report;--// network.node.report until Contracts publishes network.resource.report.++// 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.{const offerRouters = require('./registry/offers').routers({ guard: require('./identity/principals').guard('network.node.report') });++ app.use('/api/v1/resources', offerRouters.pub);app.use('/internal/resources', offerRouters.internal);}// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9): the public cell list; a cell's nodes,@@@ -4,7 +4,9 @@* 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-- * deleted. This module stores; it does not plan, price or settle. Slice 1 is the write path only.++ * 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.*/const express = require('express');@@@ -73,8 +75,68 @@ async function report(db, { source, offers }, now = new Date().toISOString()) {return { offers: offers.length, marked_down: marked };}++/** The query keys list() filters on; each matches its column exactly, but max_price_usd (price_usd <= it). */++const FILTERS = ['kind', 'region', 'trust', 'status', 'cell', 'max_price_usd'];++++/** Read the filters from a query string: a key given once as a string, max_price_usd a finite number >= 0; others are ignored. */++function filtersOf(query) {++ const f = {};++ for (const k of FILTERS) {++ const v = query[k];++ if (typeof v !== 'string' || v === '') continue;++ if (k !== 'max_price_usd') f[k] = v;++ else if (Number.isFinite(Number(v)) && Number(v) >= 0) f[k] = Number(v);++ }++ return f;++}++++/** The offers matching the filters, in id order, as stored contract docs. Filtered in SQL; without status, down offers are left out. */++async function list(db, filters = {}) {++ ensureSchema(db);++ const where = [];++ const args = [];++ for (const k of ['kind', 'region', 'trust', 'cell']) if (filters[k] != null) { where.push(`${k} = ?`); args.push(filters[k]); }++ if (filters.status != null) { where.push('status = ?'); args.push(filters.status); } else { where.push('status <> ?'); args.push('down'); }++ if (filters.max_price_usd != null) { where.push('price_usd <= ?'); args.push(filters.max_price_usd); }++ return (await db.prepare(`SELECT doc FROM platform_resource_offers WHERE ${where.join(' AND ')} ORDER BY id`).all(...args)).map((r) => JSON.parse(r.doc));++}++++/** One offer's stored contract doc, whatever its status, or null. */++async function get(db, id) {++ ensureSchema(db);++ const row = await db.prepare('SELECT doc FROM platform_resource_offers WHERE id = ?').get(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;++function routers({ guard }) {++ const listed = async (req, shape) => {++ const filters = filtersOf(req.query);++ const docs = (await list(req.app.locals.db, filters)).map(shape);++ return { offers: docs, generated_at: new Date().toISOString(), filters, count: docs.length };++ };++ const unknown = (res, id) => require('openvibe-contracts').http.sendProblem(res, 404, 'registry.unknown_offer', { detail: `no offer ${id}` });++ const pub = express.Router();++ const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')++ .set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');++ pub.get('/', async (req, res) => open(res, 60).json(await listed(req, publicDoc)));++ pub.get('/:offer_id', async (req, res) => {++ const doc = await get(req.app.locals.db, req.params.offer_id);++ if (!doc) return unknown(open(res, 0), req.params.offer_id);++ open(res, 60).json(publicDoc(doc));++ });++ // A beacon a browser times to estimate its distance from an offer (openvibe-sdk/geo), as nodes.js answers one.++ pub.get('/:offer_id/beacon', (req, res) => open(res, 0).status(204).end());const internal = express.Router();++ // The full docs, capacity included, for placement.plan() consumers: the same guard as the report.++ internal.get('/', guard, async (req, res) => res.set('Cache-Control', 'no-store').json(await listed(req, (d) => d)));++ internal.get('/:offer_id', guard, async (req, res) => {++ const doc = await get(req.app.locals.db, req.params.offer_id);++ if (!doc) return unknown(res.set('Cache-Control', 'no-store'), req.params.offer_id);++ res.set('Cache-Control', 'no-store').json(doc);++ });internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {const bad = check(req.body);if (bad) return res.status(400).json(bad);@@@ -88,7 +150,7 @@ function routers({ guard }) {res.set('Cache-Control', 'no-store').set('X-Offers-Marked-Down', String(out.marked_down)).json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });});-- return { internal };++ return { internal, pub };}--module.exports = { CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, routers };++module.exports = { CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, list, get, publicDoc, routers };@@@ -1,8 +1,9 @@'use strict';--// The resource registry, slice 1 (plan T2, docs/t2-resource-registry.md; Contracts platform.resource-offer@1):++// 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;// 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().++// 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.// node test/resource-registry.test.jsconst assert = require('assert');const crypto = require('crypto');@@@ -33,6 +34,7 @@ app.locals.privateKey = keys.privateKey;app.locals.publicKey = keys.publicKey;app.use('/oauth', require('../server/auth/oauth-routes'));const r = offers.routers({ guard: principals.guard('network.node.report', { legacy: false }) });++app.use('/api/v1/resources', r.pub);app.use('/internal/resources', r.internal);const server = http.createServer(app);@@@ -145,6 +147,93 @@ const assertLockstep = async () => {await post({ source: 'oregon', offers: [offer('o-2', { kind: 'provider', node_id: 'o-2' })] }, auth);assert.strictEqual((await rows()).find((o) => o.id === 'o-2').status, 'up');await assertLockstep();++++ // 5. Filters. A third source adds the trust classes, a second region, a free and a dear offer, degraded and draining.++ x = await post({ source: 'lab', offers: [++ offer('l-1', { trust: 'community' }),++ offer('l-2', { kind: 'provider', node_id: undefined, adapter: 'nats-v1', trust: 'partner', region: 'us-east', cell: undefined, pricing: { model: 'free-allowance' }, health: { status: 'degraded', checked_at: '2026-10-01T12:00:00Z' } }),++ offer('l-3', { trust: 'external', pricing: { model: 'per-request', marginal_usd_per_unit: 0.2, unit: 'request' }, health: { status: 'draining', checked_at: '2026-10-01T12:00:00Z' } }),++ ] }, auth);++ assert.strictEqual(x.status, 200, JSON.stringify(x.body));++ const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: y.status === 204 ? null : await y.json() }));++ const ids = async (q, prefix = '/api/v1/resources', headers) => {++ const y = await get(`${prefix}${q}`, headers);++ assert.strictEqual(y.status, 200, `${prefix}${q}`);++ assert.strictEqual(y.body.count, y.body.offers.length, `${q}: count`);++ assert.ok(!Number.isNaN(Date.parse(y.body.generated_at)));++ return y.body.offers.map((o) => o.offer_id);++ };++ for (const [q, want] of [++ ['', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],++ ['?status=down', ['o-1', 'o-3']],++ ['?status=draining', ['l-3']],++ ['?kind=provider', ['l-2', 'o-2']],++ ['?region=us-east', ['l-2']],++ ['?trust=community', ['l-1']],++ ['?max_price_usd=0.05', ['f-1', 'l-1', 'l-2', 'o-2']],++ ['?max_price_usd=0', ['l-2']],++ ['?cell=weur-1', ['f-1']],++ ['?cell=wnam-1', ['l-1', 'l-2', 'l-3', 'o-2']],++ ['?kind=node&cell=wnam-1&max_price_usd=0.05', ['l-1']],++ ['?kind=storage', []],++ ['?max_price_usd=cheap&colour=blue', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],++ ]) assert.deepStrictEqual(await ids(q), want, q || 'the default hides down offers');++ x = await get('/api/v1/resources?kind=storage&max_price_usd=0.05&trust=partner&colour=blue');++ 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');++ 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.++ 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.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');++ x = await get('/internal/resources', auth);++ assert.strictEqual(x.status, 200);++ assert.strictEqual(x.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual(x.body.offers, ['f-1', 'l-1', 'l-2', 'l-3', 'o-2'].map((id) => stored.get(id)), 'verbatim, capacity included');++ assert.ok(x.body.offers.every((o) => o.capacity), 'every internal offer has its capacity');++ 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.++ for (const id of ['l-1', 'o-1']) {++ x = await get(`/internal/resources/${id}`, auth);++ assert.strictEqual(x.status, 200);++ assert.ok(validate(offers.CONTRACT, x.body).valid);++ assert.deepStrictEqual(x.body, stored.get(id));++ assert.ok(x.body.capacity);++ x = await get(`/api/v1/resources/${id}`);++ assert.strictEqual(x.status, 200);++ assert.ok(validate(offers.CONTRACT, x.body).valid);++ assert.deepStrictEqual(x.body, offers.publicDoc(stored.get(id)));++ assert.ok(!('capacity' in x.body));++ }++ assert.strictEqual((await get('/internal/resources/l-1')).status, 403, 'the internal single read takes the guard too');++ for (const [url, headers] of [['/api/v1/resources/nope'], ['/internal/resources/nope', auth]]) {++ x = await get(url, headers);++ assert.strictEqual(x.status, 404, url);++ assert.strictEqual(x.headers.get('content-type'), 'application/problem+json', url);++ assert.strictEqual(x.body.code, 'registry.unknown_offer', url);++ }++++ // 7. Headers: list and single cacheable for a minute and open to any origin; the beacon 204 and never cached.++ for (const url of ['/api/v1/resources', '/api/v1/resources?kind=provider', '/api/v1/resources/l-1']) {++ x = await get(url);++ assert.strictEqual(x.headers.get('cache-control'), 'public, max-age=60', url);++ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*', url);++ assert.strictEqual(x.headers.get('timing-allow-origin'), '*', url);++ }++ x = await get('/api/v1/resources/l-1/beacon');++ assert.strictEqual(x.status, 204);++ assert.strictEqual(x.headers.get('cache-control'), 'no-store');++ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');++ assert.strictEqual(x.headers.get('timing-allow-origin'), '*');console.log('resource-registry: all tests passed');} finally {server.close();
@@ -146,14 +146,27 @@ the nodes call, purely as documentation.
@@ -146,14 +146,27 @@ the nodes call, purely as documentation.
| Route | Behaviour | Cache |
| Route | Behaviour | Cache |
|---|---|---|
|---|---|---|
-| `GET /api/v1/resources` | `{offers: [<platform.resource-offer@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` | the single offer, or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |
+| `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/: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` (slice 4, optional) | service-token read for consumers that cannot use the public list; `network.registry.read` **does** exist at v0.83.0 | `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/: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`.
- 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.
-- `offers[]` elements are the contract documents **verbatim** — no envelope fields are added inside them, so a client can
+- 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
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.
@@ -164,7 +177,8 @@ the nodes call, purely as documentation.
@@ -164,7 +177,8 @@ the nodes call, purely as documentation.
platform.placement-result@1`. Network is the registry, not the planner, and imports nothing from the SDK in production
platform.placement-result@1`. Network is the registry, not the planner, and imports nothing from the SDK in production
code. The contract between the two is therefore exact:
code. The contract between the two is therefore exact:
-- **`offers[]` is `platform.resource-offer@1` verbatim**, so `plan()` consumes the HTTP response unchanged.
+- **`offers[]` is `platform.resource-offer@1` verbatim**, so `plan()` consumes the HTTP response unchanged. A planner
+ that weighs capacity reads `GET /internal/resources`; the public list has no `capacity` (§4).
- `trust` and `health.status` must use the enums the planner knows — `first-party|partner|community|external`
- `trust` and `health.status` must use the enums the planner knows — `first-party|partner|community|external`
(`TRUST_ORDER`) and `up|degraded|down|draining` (`HEALTH_OK`). `down` and `draining` are excluded by the planner, so the
(`TRUST_ORDER`) and `up|degraded|down|draining` (`HEALTH_OK`). `down` and `draining` are excluded by the planner, so the
registry stores them faithfully and lets the default list hide only `down`.
registry stores them faithfully and lets the default list hide only `down`.
@@ -193,9 +207,10 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
@@ -193,9 +207,10 @@ mounted, a service token minted through `POST /oauth/token` `client_credentials`
4. **Mark-down** — source A's offer missing from A's next report → `status='down'`, `doc.health.status='down'`,
4. **Mark-down** — source A's offer missing from A's next report → `status='down'`, `doc.health.status='down'`,
`checked_at` set, still listed under `?status=down`, header `X-Offers-Marked-Down: 1`; source B untouched.
`checked_at` set, still listed under `?status=down`, header `X-Offers-Marked-Down: 1`; source B untouched.
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.
-6. **Single read** — `GET /api/v1/resources/:id` matches `platform.resource-offer@1`; an unknown id → 404
- `registry.unknown_offer`.
+ 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.
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
@@ -217,7 +232,8 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
@@ -217,7 +232,8 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
no-op + `report`), the `ensureSchema` call in `server/db/database.js`, `app.use('/internal/resources', internal)` in
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. Guard reuses `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.
+ `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).
3. **Discovery entry.** Add `resources: '/api/v1/resources'` to the `/api/v1/registry` index in
3. **Discovery entry.** Add `resources: '/api/v1/resources'` to the `/api/v1/registry` index in
`server/registry/ecosystem.js`. Nothing else.
`server/registry/ecosystem.js`. Nothing else.
4. **Placement consumption proof.** §5 gains a worked example; test 9 added. No production-code change — this slice
4. **Placement consumption proof.** §5 gains a worked example; test 9 added. No production-code change — this slice
@@ -467,10 +467,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
@@ -467,10 +467,11 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/nodes', nodeRouters.pub);
app.use('/api/v1/nodes', nodeRouters.pub);
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. Slice 1 is the report;
-// network.node.report until Contracts publishes network.resource.report.
+// 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.
{
{
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.node.report') });
+ app.use('/api/v1/resources', offerRouters.pub);
app.use('/internal/resources', offerRouters.internal);
app.use('/internal/resources', offerRouters.internal);
}
}
// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9): the public cell list; a cell's nodes,
// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9): the public cell list; a cell's nodes,
@@ -4,7 +4,9 @@
@@ -4,7 +4,9 @@
* network can place on — every node and provider offer, with capabilities, capacity, health and pricing. The owner
* 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
* 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
* publishes 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. Slice 1 is the write path only.
+ * 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.
*/
*/
const express = require('express');
const express = require('express');
@@ -73,8 +75,68 @@ async function report(db, { source, offers }, now = new Date().toISOString()) {
@@ -73,8 +75,68 @@ async function report(db, { source, offers }, now = new Date().toISOString()) {
return { offers: offers.length, marked_down: marked };
return { offers: offers.length, marked_down: marked };
}
}
+/** The query keys list() filters on; each matches its column exactly, but max_price_usd (price_usd <= it). */
+const FILTERS = ['kind', 'region', 'trust', 'status', 'cell', 'max_price_usd'];
+
+/** Read the filters from a query string: a key given once as a string, max_price_usd a finite number >= 0; others are ignored. */
+function filtersOf(query) {
+ const f = {};
+ for (const k of FILTERS) {
+ const v = query[k];
+ if (typeof v !== 'string' || v === '') continue;
+ if (k !== 'max_price_usd') f[k] = v;
+ else if (Number.isFinite(Number(v)) && Number(v) >= 0) f[k] = Number(v);
+ }
+ return f;
+}
+
+/** The offers matching the filters, in id order, as stored contract docs. Filtered in SQL; without status, down offers are left out. */
+async function list(db, filters = {}) {
+ ensureSchema(db);
+ const where = [];
+ const args = [];
+ for (const k of ['kind', 'region', 'trust', 'cell']) if (filters[k] != null) { where.push(`${k} = ?`); args.push(filters[k]); }
+ if (filters.status != null) { where.push('status = ?'); args.push(filters.status); } else { where.push('status <> ?'); args.push('down'); }
+ if (filters.max_price_usd != null) { where.push('price_usd <= ?'); args.push(filters.max_price_usd); }
+ return (await db.prepare(`SELECT doc FROM platform_resource_offers WHERE ${where.join(' AND ')} ORDER BY id`).all(...args)).map((r) => JSON.parse(r.doc));
+}
+
+/** One offer's stored contract doc, whatever its status, or null. */
+async function get(db, id) {
+ ensureSchema(db);
+ const row = await db.prepare('SELECT doc FROM platform_resource_offers WHERE id = ?').get(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;
+
function routers({ guard }) {
function routers({ guard }) {
+ const listed = async (req, shape) => {
+ const filters = filtersOf(req.query);
+ const docs = (await list(req.app.locals.db, filters)).map(shape);
+ return { offers: docs, generated_at: new Date().toISOString(), filters, count: docs.length };
+ };
+ const unknown = (res, id) => require('openvibe-contracts').http.sendProblem(res, 404, 'registry.unknown_offer', { detail: `no offer ${id}` });
+ const pub = express.Router();
+ const open = (res, maxAge) => res.set('Cache-Control', maxAge ? `public, max-age=${maxAge}` : 'no-store')
+ .set('Access-Control-Allow-Origin', '*').set('Timing-Allow-Origin', '*');
+ pub.get('/', async (req, res) => open(res, 60).json(await listed(req, publicDoc)));
+ pub.get('/:offer_id', async (req, res) => {
+ const doc = await get(req.app.locals.db, req.params.offer_id);
+ if (!doc) return unknown(open(res, 0), req.params.offer_id);
+ open(res, 60).json(publicDoc(doc));
+ });
+ // A beacon a browser times to estimate its distance from an offer (openvibe-sdk/geo), as nodes.js answers one.
+ pub.get('/:offer_id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
const internal = express.Router();
+ // The full docs, capacity included, for placement.plan() consumers: the same guard as the report.
+ internal.get('/', guard, async (req, res) => res.set('Cache-Control', 'no-store').json(await listed(req, (d) => d)));
+ internal.get('/:offer_id', guard, async (req, res) => {
+ const doc = await get(req.app.locals.db, req.params.offer_id);
+ if (!doc) return unknown(res.set('Cache-Control', 'no-store'), req.params.offer_id);
+ res.set('Cache-Control', 'no-store').json(doc);
+ });
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const bad = check(req.body);
const bad = check(req.body);
if (bad) return res.status(400).json(bad);
if (bad) return res.status(400).json(bad);
@@ -88,7 +150,7 @@ function routers({ guard }) {
@@ -88,7 +150,7 @@ function routers({ guard }) {
res.set('Cache-Control', 'no-store').set('X-Offers-Marked-Down', String(out.marked_down))
res.set('Cache-Control', 'no-store').set('X-Offers-Marked-Down', String(out.marked_down))
.json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });
.json({ source: req.body.source, ...out, generated_at: new Date().toISOString() });
});
});
- return { internal };
+ return { internal, pub };
}
}
-module.exports = { CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, routers };
+module.exports = { CONTRACT, DEFAULT_CELL, ensureSchema, columns, check, report, list, get, publicDoc, routers };
@@ -1,8 +1,9 @@
@@ -1,8 +1,9 @@
'use strict';
'use strict';
-// The resource registry, slice 1 (plan T2, docs/t2-resource-registry.md; Contracts platform.resource-offer@1):
+// 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;
// POST /internal/resources/report takes Host's token only; 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().
+// 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.
// node test/resource-registry.test.js
// node test/resource-registry.test.js
const assert = require('assert');
const assert = require('assert');
const crypto = require('crypto');
const crypto = require('crypto');
@@ -33,6 +34,7 @@ app.locals.privateKey = keys.privateKey;
@@ -33,6 +34,7 @@ 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.node.report', { legacy: false }) });
+app.use('/api/v1/resources', r.pub);
app.use('/internal/resources', r.internal);
app.use('/internal/resources', r.internal);
const server = http.createServer(app);
const server = http.createServer(app);
@@ -145,6 +147,93 @@ const assertLockstep = async () => {
@@ -145,6 +147,93 @@ const assertLockstep = async () => {
await post({ source: 'oregon', offers: [offer('o-2', { kind: 'provider', node_id: 'o-2' })] }, auth);
await post({ source: 'oregon', offers: [offer('o-2', { kind: 'provider', node_id: 'o-2' })] }, auth);
assert.strictEqual((await rows()).find((o) => o.id === 'o-2').status, 'up');
assert.strictEqual((await rows()).find((o) => o.id === 'o-2').status, 'up');
await assertLockstep();
await assertLockstep();
+
+ // 5. Filters. A third source adds the trust classes, a second region, a free and a dear offer, degraded and draining.
+ x = await post({ source: 'lab', offers: [
+ offer('l-1', { trust: 'community' }),
+ offer('l-2', { kind: 'provider', node_id: undefined, adapter: 'nats-v1', trust: 'partner', region: 'us-east', cell: undefined, pricing: { model: 'free-allowance' }, health: { status: 'degraded', checked_at: '2026-10-01T12:00:00Z' } }),
+ offer('l-3', { trust: 'external', pricing: { model: 'per-request', marginal_usd_per_unit: 0.2, unit: 'request' }, health: { status: 'draining', checked_at: '2026-10-01T12:00:00Z' } }),
+ ] }, auth);
+ assert.strictEqual(x.status, 200, JSON.stringify(x.body));
+ const get = (url, headers) => fetch(`${base}${url}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: y.status === 204 ? null : await y.json() }));
+ const ids = async (q, prefix = '/api/v1/resources', headers) => {
+ const y = await get(`${prefix}${q}`, headers);
+ assert.strictEqual(y.status, 200, `${prefix}${q}`);
+ assert.strictEqual(y.body.count, y.body.offers.length, `${q}: count`);
+ assert.ok(!Number.isNaN(Date.parse(y.body.generated_at)));
+ return y.body.offers.map((o) => o.offer_id);
+ };
+ for (const [q, want] of [
+ ['', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],
+ ['?status=down', ['o-1', 'o-3']],
+ ['?status=draining', ['l-3']],
+ ['?kind=provider', ['l-2', 'o-2']],
+ ['?region=us-east', ['l-2']],
+ ['?trust=community', ['l-1']],
+ ['?max_price_usd=0.05', ['f-1', 'l-1', 'l-2', 'o-2']],
+ ['?max_price_usd=0', ['l-2']],
+ ['?cell=weur-1', ['f-1']],
+ ['?cell=wnam-1', ['l-1', 'l-2', 'l-3', 'o-2']],
+ ['?kind=node&cell=wnam-1&max_price_usd=0.05', ['l-1']],
+ ['?kind=storage', []],
+ ['?max_price_usd=cheap&colour=blue', ['f-1', 'l-1', 'l-2', 'l-3', 'o-2']],
+ ]) assert.deepStrictEqual(await ids(q), want, q || 'the default hides down offers');
+ x = await get('/api/v1/resources?kind=storage&max_price_usd=0.05&trust=partner&colour=blue');
+ 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');
+ 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.
+ 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.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');
+ x = await get('/internal/resources', auth);
+ assert.strictEqual(x.status, 200);
+ assert.strictEqual(x.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual(x.body.offers, ['f-1', 'l-1', 'l-2', 'l-3', 'o-2'].map((id) => stored.get(id)), 'verbatim, capacity included');
+ assert.ok(x.body.offers.every((o) => o.capacity), 'every internal offer has its capacity');
+ 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.
+ for (const id of ['l-1', 'o-1']) {
+ x = await get(`/internal/resources/${id}`, auth);
+ assert.strictEqual(x.status, 200);
+ assert.ok(validate(offers.CONTRACT, x.body).valid);
+ assert.deepStrictEqual(x.body, stored.get(id));
+ assert.ok(x.body.capacity);
+ x = await get(`/api/v1/resources/${id}`);
+ assert.strictEqual(x.status, 200);
+ assert.ok(validate(offers.CONTRACT, x.body).valid);
+ assert.deepStrictEqual(x.body, offers.publicDoc(stored.get(id)));
+ assert.ok(!('capacity' in x.body));
+ }
+ assert.strictEqual((await get('/internal/resources/l-1')).status, 403, 'the internal single read takes the guard too');
+ for (const [url, headers] of [['/api/v1/resources/nope'], ['/internal/resources/nope', auth]]) {
+ x = await get(url, headers);
+ assert.strictEqual(x.status, 404, url);
+ assert.strictEqual(x.headers.get('content-type'), 'application/problem+json', url);
+ assert.strictEqual(x.body.code, 'registry.unknown_offer', url);
+ }
+
+ // 7. Headers: list and single cacheable for a minute and open to any origin; the beacon 204 and never cached.
+ for (const url of ['/api/v1/resources', '/api/v1/resources?kind=provider', '/api/v1/resources/l-1']) {
+ x = await get(url);
+ assert.strictEqual(x.headers.get('cache-control'), 'public, max-age=60', url);
+ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*', url);
+ assert.strictEqual(x.headers.get('timing-allow-origin'), '*', url);
+ }
+ x = await get('/api/v1/resources/l-1/beacon');
+ assert.strictEqual(x.status, 204);
+ assert.strictEqual(x.headers.get('cache-control'), 'no-store');
+ assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');
+ assert.strictEqual(x.headers.get('timing-allow-origin'), '*');
console.log('resource-registry: all tests passed');
console.log('resource-registry: all tests passed');
} finally {
} finally {
server.close();
server.close();