openvibers/openvibe.network Commits

Serve resource offers publicly without capacity and in full to services (#7)

OP OpenVibers committed 2 Oct 2026 · 10888fcfbd · 4 files, +184 −16

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

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

Changes

ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex 5459a2bc..71932cf0 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -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=&region=&trust=&status=&cell=&max_price_usd=`; **default excludes `status='down'`** | `public, max-age=60`, `ACAO: *`, `Timing-Allow-Origin: *` |
--| `GET /api/v1/resources/:offer_id` | the single offer, 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=&region=&trust=&status=&cell=&max_price_usd=`; **default excludes `status='down'`** | `public, max-age=60`, `ACAO: *`, `Timing-Allow-Origin: *` |
++| `GET /api/v1/resources/:offer_id` | the single offer minus capacity (any status), or 404 `problem+json` `registry.unknown_offer` via `contracts.http.sendProblem` | `public, max-age=60` |
| `GET /api/v1/resources/: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 can
pass the array straight into `plan()`. `filters` and `count` live beside `offers`, never inside it. The convenience
columns (`cell`, `price_usd`) are therefore *not* re-added to each doc: a doc that omitted `cell` is returned without
one, exactly as reported.
@@@ -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
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_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`.
@@@ -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 204
with `no-store` and `timing-allow-origin: *`.
8. **Schema lockstep** — after a report, every row is compared against the **mapping table in §2**, not against a
@@@ -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
ddiff --git a/server/index.js b/server/index.js
iindex df88550f..ecaabf0f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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,
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex 69859318..1f9885fd 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -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 };
ddiff --git a/test/resource-registry.test.js b/test/resource-registry.test.js
iindex 0388de73..d16b2b05 100644
---- a/test/resource-registry.test.js
++++ b/test/resource-registry.test.js
@@@ -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.js
const 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();