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