openvibers/openvibe.sdk Commits

Add Link compute APIs to the SDK (#156)

OP OpenVibers committed 4 days ago · bd1b2cfa92 · 37 files, +348 −277

* Expose Link and compute picker in SDK

* Merge remote-tracking branch 'origin/main' into rename/link-1109-SDK

* Release 0.118.0: Link is the SDK's machine module

ov.node is ov.link and the types, bundle, surface and README name 0.118.0. A
link principal token is not a user token, the copied contract types carry the
link actor_type, and the browser-entry list has ./link in place of ./node.

* Release 0.124.0: Link is the SDK's machine module

Merges main, keeps ov.link in place of ov.node (exports, types, esm, browser
entries and jwt principal rule), pins openvibe-contracts v0.372.0, regenerates
the bundle and surface, and bumps to 0.124.0.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b793766..409a236 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -1,5 +1,12 @@
# Changelog
++## 0.124.0 (2026-11-09)
++
++- OpenVibe Link: `openvibe-sdk/node` is `openvibe-sdk/link` (`ov.node` is `ov.link`), building
++ `platform.link-capabilities@1` and mapping a Link to its `platform.resource-offer@1` and
++ `platform.runtime-offer@1`. A link principal token is never a user token. The Contracts pin moves to
++ v0.372.0.
++
## 0.123.0 (2026-11-09)
- ov.exchange: keys (list, create, revoke), private usage, deployments, and web, image and icon search with fetch (the Exchange API platform)
@@@ -423,7 +430,7 @@
## 0.79.0 (2026-10-31)
- **`openvibe-sdk/machines`: the network's machines hub** (plan L34, step M2; Contracts 0.258.0). A person's machines
-- are the network's: Bot stays the registry underneath, and OpenVibe.Network fronts it on openvibe.network/machines for
++ are the network's: Bot stays the registry underneath, and OpenVibe.Network fronts it on openvibe.link/machines for
the person and for the apps the person consented to.
- `createMachines({ networkUrl, token, fetch, timeoutMs })`: `token` returns the caller's own bearer token — the
person's session token, or an app's token holding `network.machines.read` or `network.machines.manage` with the
@@@ -961,7 +968,7 @@ still passes a number:
## 0.40.1 (2026-10-10)
--- **`verifyUserToken` refuses node principals.** A node token (`actor_type: node`, `sub node:nod_…`: a machine paired
++- **`verifyUserToken` refuses node principals.** A node token (`actor_type: link`, `sub link:lnk_…`: a machine paired
for a service, such as a robot's computer for OpenVibe.Bot) is signed with Network's key like every principal token.
It was refused for service, app and module principals only, so a service that checks user tokens without an
audience would have taken a node token for a session. It is `token.not_user` now, by its `actor_type` or its `sub`
@@@ -1398,7 +1405,7 @@ This release makes the move from better-sqlite3 mostly mechanical: call sites ke
## 0.17.0 (2026-09-28)
--- **`openvibe-sdk/geo`** (browser and server): `createGeoClient(client, { samples?, timeoutMs? })` finds the closest OpenVibe node (roadmap WS-X1 task 5).
++- **`openvibe-sdk/geo`** (browser and server): `createGeoClient(client, { samples?, timeoutMs? })` finds the closest OpenVibe Link (roadmap WS-X1 task 5).
- `nodes({ role?, region? })` reads Network's public node registry, `GET /api/v1/nodes` (`network.node-list-result@1`), without sending a token.
- `measure(nodes)` times each node's beacon several times (the best sample counts, since the first also pays for DNS and TLS) and returns the nodes fastest first.
- `nearest({ role?, region?, preferRegion? })` returns the fastest measured node that is up or degraded. When nothing can be measured, it falls back to `preferRegion`, then to the first healthy node, and returns `measured: false`.
ddiff --git a/README.md b/README.md
iindex a0bd441..906569e 100644
---- a/README.md
++++ b/README.md
@@@ -10,7 +10,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
> Supported browser and server clients for the OpenVibe platform.
--**Status:** alpha, v0.123.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
++**Status:** alpha, v0.124.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0.
@@@ -19,7 +19,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
```bash
--npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0
++npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.124.0
```
It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against).
ddiff --git a/STATUS.json b/STATUS.json
iindex 3e24aad..3a978d3 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -3,7 +3,7 @@
"stage": "alpha",
"code": true,
"wave": "W2",
-- "version": "0.123.0",
++ "version": "0.124.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 306dc25..b896ef6 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -1,4 +1,4 @@
--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.123.0, MIT). Do not edit.
++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.124.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, locker, registry, trust, modules, realtime, media, cam, mediaUpload, ui, community, homeFeed, bot, search, bench, meme, vision, themes, design, redact, chat, ai, credentials, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
@@@ -56,7 +56,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.123.0';
++const SDK_VERSION = '0.124.0';
module.exports = {
createClient,
@@@ -5762,13 +5762,13 @@ module.exports = { createProjectsClient };
function (module, exports) {
'use strict';
/**
-- * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
++ * openvibe-sdk/geo — which OpenVibe Link is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
*
* const geo = createGeoClient(createClient());
* const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
* const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first
*
-- * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not
++ * The registry is Network's public GET /api/v1/links (network.link-list-result@1). Distance is measured, not
* guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also
* pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every
* beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so
@@@ -5782,7 +5782,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
/** The registry's nodes, optionally for one role or region. */
async function nodes({ role, region } = {}) {
-- const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });
++ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/links', query: { role, region }, auth: false });
return (r && r.nodes) || [];
}
ddiff --git a/docs/api-surface.json b/docs/api-surface.json
iindex ca1c0d3..c15dd4b 100644
---- a/docs/api-surface.json
++++ b/docs/api-surface.json
@@@ -807,6 +807,12 @@
"createValkeyLimitStore": "function",
"defaultActor": "function"
},
++ "openvibe-sdk/link": {
++ "createLink": "function",
++ "linkCapabilities": "function",
++ "linkOffers": "function",
++ "validateLinkCapabilities": "function"
++ },
"openvibe-sdk/live": {
"createLiveClient": "function"
},
@@@ -918,11 +924,6 @@
"noLooks": "function",
"noLooksTags": "function"
},
-- "openvibe-sdk/node": {
-- "nodeCapabilities": "function",
-- "nodeOffers": "function",
-- "validateNodeCapabilities": "function"
-- },
"openvibe-sdk/notifications": {
"createNotificationsClient": "function"
},
ddiff --git a/docs/exchange.md b/docs/exchange.md
iindex 4fc799d..cae167b 100644
---- a/docs/exchange.md
++++ b/docs/exchange.md
@@@ -62,7 +62,7 @@ adult gate. A request names one as `model: '@preset/<name>'`.
An offer has two forms. A **Link machine** reports its offer to the Network's user-owned AI offers — the same registry
OpenVibe Link's "Offer to the network" writes to (`POST /internal/ai/offers/report`, the capability
`network.resource.report`). A **standalone server** registers itself on the Exchange's public seller API with the
--developer key, so it needs no Node at all. The SDK validates both before anything is sent.
++developer key, so it needs no Link at all. The SDK validates both before anything is sent.
```js
const offer = await exchange.seller.offer({
ddiff --git a/docs/locker.md b/docs/locker.md
iindex 332b9ed..8209658 100644
---- a/docs/locker.md
++++ b/docs/locker.md
@@@ -1,6 +1,6 @@
# Locker: end-to-end encryption, in the SDK
--`openvibe-sdk/locker` seals anything and opens it again, in Node and in the browser, with WebCrypto
++`openvibe-sdk/locker` seals anything and opens it again, in Link and in the browser, with WebCrypto
only: `globalThis.crypto.subtle`, no other crypto library, nothing hand-rolled. The format is
`locker.envelope@1` in OpenVibe.Contracts; the written-out format is
[docs/locker.md in Contracts](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/locker.md).
ddiff --git a/docs/placement.md b/docs/placement.md
iindex 2bd40b9..e441b48 100644
---- a/docs/placement.md
++++ b/docs/placement.md
@@@ -24,7 +24,7 @@ const out = place(
priors: { 'app:codes/claude-code@opus': 0.82 },
outcomes, // the learned counts (below)
rules: { noCloud: true, onlyFree: false, region: 'eu', prefer: 'cheapest' },
-- names: { 'node:desk': 'your desktop' },
++ names: { 'link:desk': 'your desktop' },
},
);
@@@ -78,7 +78,7 @@ five-class preference order.
**A streaming machine.** When an offer says its machine is streaming, a step that asks for a GPU or 2 or more cores
is excluded with `it is streaming`. The contract does not carry that field yet; `place()` reads
`offer.health.streaming === true` (and tolerates a top-level `offer.streaming`), so OpenVibe Link can start sending
--it without another SDK change. Node's status already reports `screen.state`, but Bot's `platform.resource-offer@1`
++it without another SDK change. Link's status already reports `screen.state`, but Bot's `platform.resource-offer@1`
does not project it today.
## Learning from outcomes
ddiff --git a/docs/redact.md b/docs/redact.md
iindex d148eff..ea81369 100644
---- a/docs/redact.md
++++ b/docs/redact.md
@@@ -1,7 +1,7 @@
# openvibe-sdk/redact
The shared secret scrubber for memory (Memory M2). It is the JavaScript port of OpenVibe Link's
--`internal/memory/redact.go` (Node #87), so Link and OpenVibe.AI run one shape list against one corpus.
++`internal/memory/redact.go` (Link #87), so Link and OpenVibe.AI run one shape list against one corpus.
```js
const { redact, onlySecret } = require('openvibe-sdk/redact');
ddiff --git a/docs/share.md b/docs/share.md
iindex 7a5e60d..533c795 100644
---- a/docs/share.md
++++ b/docs/share.md
@@@ -1,4 +1,4 @@
--# Network-wide sharing (Node)
++# Network-wide sharing (Link)
`openvibe-sdk/share` implements the 2026-11-06 sharing design in each service's PostgreSQL database. The service is the authority. The module never reads titles, content or media URLs. It uses openvibe-contracts v0.297.0 (share.v1, with the Locker lock kind and the member key slots).
@@@ -27,7 +27,7 @@ app.use(shares.router({
}));
```
--`verifiedViewer(req)` returns `{ subject: { type, id }, staff?: true }` from verified service authentication, or `null`. A caller's body, query, forwarded header or unverified token must never set a subject or staff bit. Cookie authentication must check CSRF for mutations in this function or in earlier middleware. Authentication precedes dispatch. The router supports Node HTTP and Express (mount at the root so it sees `/api/v1/...`); JSON bodies are capped at 64 KiB. Services still authorize their own resource read/edit routes.
++`verifiedViewer(req)` returns `{ subject: { type, id }, staff?: true }` from verified service authentication, or `null`. A caller's body, query, forwarded header or unverified token must never set a subject or staff bit. Cookie authentication must check CSRF for mutations in this function or in earlier middleware. Authentication precedes dispatch. The router supports Link HTTP and Express (mount at the root so it sees `/api/v1/...`); JSON bodies are capped at 64 KiB. Services still authorize their own resource read/edit routes.
For a non-GET request without an `Authorization` header the router itself requires `X-Requested-With` (any value) or `Sec-Fetch-Site: same-origin|same-site`, otherwise it answers `403 { code: 'share.csrf' }` before authentication. Pass `csrf: false` only when `authenticate` already enforces CSRF. `describe(ref)` fills `title`, `link`, `roles`, `audiences` and `transferable`; omitted values default to `null`, `null`, `['viewer']`, all four audiences and `true`.
@@@ -52,7 +52,7 @@ The migration creates `shares`, `share_members`, `share_transfers`, `share_acces
| `sharedWithMe(viewer, { service?, limit = 50, cursor? }?)` | `{ items, next_cursor }`; ids and access metadata only |
| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
| `flush({ limit = 100 }?)` | number of durable deliveries sent |
--| `router({ service, authenticate })` | Node/Express middleware |
++| `router({ service, authenticate })` | Link/Express middleware |
`ref` is `{ service, kind, id }`. Subjects follow `identity.subject-ref@1`; the common user case is `{ type: 'user', id: 'usr_<ULID>' }`. Members and transfer recipients must be active **user** subjects: a `guest`, `app`, `mod`, `agent`, `service` or `system` subject is refused, and a user subject is checked against Network (batched, cached 60 s) so unknown, banned and deleted accounts cannot be added. A bare subject-id string is never an identity projection. Expiry timestamps are absolute RFC3339 timestamps in the future. Viewer/editor membership is effective for **people** only. Public/link grant viewing to everyone but never grant editing to strangers; private is owner-only. Active pending transfer recipients may preview in every audience. Staff view/edit overrides append audit records even on public resources; staff cannot start/accept/decline another person's transfer. A staff member who is not the owner may narrow access only — set the audience to `private` and remove members — never widen, add a member or raise a role; anything else is a generic 404 like any other non-owner. Owners and trusted staff may manage share settings. Generic 404 responses reveal no resource title or existence through share settings.
ddiff --git a/esm/link.mjs b/esm/link.mjs
nnew file mode 100644
iindex 0000000..ce0bd61
---- /dev/null
++++ b/esm/link.mjs
@@@ -0,0 +1,7 @@
++// Generated by scripts/esm.js from src/link.js. Do not edit.
++import cjs from '../src/link.js';
++
++export const {
++ linkCapabilities, linkOffers, validateLinkCapabilities, createLink,
++} = cjs;
++export default cjs;
ddiff --git a/esm/node.mjs b/esm/node.mjs
ddeleted file mode 100644
iindex fd37bf9..0000000
---- a/esm/node.mjs
++++ /dev/null
@@@ -1,7 +0,0 @@
--// Generated by scripts/esm.js from src/node.js. Do not edit.
--import cjs from '../src/node.js';
--
--export const {
-- nodeCapabilities, nodeOffers, validateNodeCapabilities,
--} = cjs;
--export default cjs;
ddiff --git a/package-lock.json b/package-lock.json
iindex fa2a9e1..a208330 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-sdk",
-- "version": "0.123.0",
++ "version": "0.124.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-sdk",
-- "version": "0.123.0",
++ "version": "0.124.0",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
@@@ -14,7 +14,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"pg": "^8.23.0"
},
"engines": {
@@@ -198,9 +198,9 @@
"license": "MIT"
},
"node_modules/openvibe-contracts": {
-- "version": "0.355.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
-- "integrity": "sha512-j3Pl93MLl/9hsIVqz4xwnlOVEUDRZlYDnmyaLTahMk46uxLJyLfLsOrJwR1N9jWublhJBhoBEc6q5fRj+bAwMw==",
++ "version": "0.372.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
++ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==",
"dev": true,
"license": "AGPL-3.0-only",
"dependencies": {
ddiff --git a/package.json b/package.json
iindex 9d472cb..255d4a3 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-sdk",
-- "version": "0.123.0",
++ "version": "0.124.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"types": "types/index.d.ts",
@@@ -225,11 +225,11 @@
"import": "./esm/delivery.mjs",
"default": "./src/delivery.js"
},
-- "./node": {
-- "types": "./types/node.d.ts",
++ "./link": {
++ "types": "./types/link.d.ts",
"browser": null,
-- "import": "./esm/node.mjs",
-- "default": "./src/node.js"
++ "import": "./esm/link.mjs",
++ "default": "./src/link.js"
},
"./db": {
"types": "./types/db.d.ts",
@@@ -612,7 +612,7 @@
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.355.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"pg": "^8.23.0"
},
"peerDependencies": {
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex bd0aea3..ae2441f 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -59,7 +59,7 @@ const ENTRIES = {
'report.mjs': '../src/report.js',
'vibes.mjs': '../src/vibes.js',
'delivery.mjs': '../src/delivery.js',
-- 'node.mjs': '../src/node.js',
++ 'link.mjs': '../src/link.js',
'db.mjs': '../src/db/index.js',
'valkey.mjs': '../src/valkey.js',
'cache.mjs': '../src/cache.js',
ddiff --git a/src/auth/jwt.js b/src/auth/jwt.js
iindex 1657012..562f984 100644
---- a/src/auth/jwt.js
++++ b/src/auth/jwt.js
@@@ -77,7 +77,7 @@ async function verifyUserToken(token, opts = {}) {
const claims = await verifyJwt(token, opts, 'user');
// Every Network principal: a service, a developer app, a module, or a node (a machine paired for a service, e.g.
// a robot's computer for OpenVibe.Bot). None of them is a person's session, whatever audience it names.
-- const principal = ['service', 'app', 'mod', 'node'].includes(claims.actor_type) || /^(svc|app|mod|node):/.test(String(claims.sub || ''));
++ const principal = ['service', 'app', 'mod', 'link'].includes(claims.actor_type) || /^(svc|app|mod|link):/.test(String(claims.sub || ''));
if (principal && !opts.allowServiceTokens) throw fail('token.not_user', 'a principal token (service, app, module or node) is not a user token');
// A typed token (Network's realtime ticket, an export token: `typ` or `purpose` set) is never a session, whatever its
// issuer and audience say (OpenVibe.Events' rule, now every service's).
ddiff --git a/src/core/index.js b/src/core/index.js
iindex 78ac8a2..d3df9d7 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.123.0';
++const SDK_VERSION = '0.124.0';
module.exports = {
createClient,
ddiff --git a/src/geo.js b/src/geo.js
iindex dce697e..ad6811e 100644
---- a/src/geo.js
++++ b/src/geo.js
@@@ -1,12 +1,12 @@
'use strict';
/**
-- * openvibe-sdk/geo — which OpenVibe node is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
++ * openvibe-sdk/geo — which OpenVibe Link is closest (roadmap WS-X1 task 5, ADR-034 §12). Browser-safe.
*
* const geo = createGeoClient(createClient());
* const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
* const all = await geo.measure(await geo.nodes({ role: 'ingest' })); // [{ node, rtt_ms, ok }] fastest first
*
-- * The registry is Network's public GET /api/v1/nodes (network.node-list-result@1). Distance is measured, not
++ * The registry is Network's public GET /api/v1/links (network.link-list-result@1). Distance is measured, not
* guessed: each node's beacon is fetched `samples` times and the best round trip counts (the first fetch also
* pays for DNS and TLS). Only up or degraded nodes are candidates. When nothing can be measured (offline, every
* beacon blocked) nearest() falls back to `preferRegion`, then to the first healthy node, and says so
@@@ -20,7 +20,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
/** The registry's nodes, optionally for one role or region. */
async function nodes({ role, region } = {}) {
-- const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/nodes', query: { role, region }, auth: false });
++ const r = await client.json({ service: 'network', baseUrl, path: '/api/v1/links', query: { role, region }, auth: false });
return (r && r.nodes) || [];
}
ddiff --git a/src/link.js b/src/link.js
nnew file mode 100644
iindex 0000000..271e113
---- /dev/null
++++ b/src/link.js
@@@ -0,0 +1,103 @@
++'use strict';
++/**
++ * openvibe-sdk/link — platform.link-capabilities@1, plus linkOffers() that maps a node descriptor to a
++ * platform.resource-offer@1 (kind "node") and a platform.runtime-offer@1.
++ * Pure construction and validation, no I/O and no dependency at require time.
++ *
++ * const { linkCapabilities, linkOffers, validateLinkCapabilities } = require('openvibe-sdk/link');
++ * const caps = linkCapabilities({ node_id, cpu, arch, memory_mb, storage, network, regions, tags, costs });
++ * const { resource, runtime } = linkOffers(caps);
++ */
++
++/** The fields platform.link-capabilities@1 requires or allows; null/undefined are stripped. */
++function linkCapabilities(fields) {
++ const f = fields || {};
++ const record = {
++ node_id: f.node_id,
++ cpu: f.cpu,
++ arch: f.arch,
++ memory_mb: f.memory_mb,
++ gpu: f.gpu,
++ storage: f.storage,
++ network: f.network,
++ regions: f.regions,
++ tags: f.tags,
++ costs: f.costs,
++ capabilities: f.capabilities,
++ agent_version: f.agent_version,
++ updated_at: f.updated_at,
++ };
++ for (const k of Object.keys(record)) if (record[k] == null) delete record[k];
++ return record;
++}
++
++/**
++ * Map a node descriptor (platform.link-capabilities@1) to two offers:
++ * - resource: a platform.resource-offer@1 with kind "node", capabilities including every advertised
++ * worker capability prefixed "worker:<name>", trust from opts.trust or "first-party";
++ * - runtime: a platform.runtime-offer@1 with kind "container" (the default runtime a node offers),
++ * limits from cpu/memory, price from costs.per_hour_usd.
++ * opts.trust defaults to "first-party"; it is "user-owned" only when opts.trust says so.
++ */
++function linkOffers(descriptor, opts = {}) {
++ const d = descriptor || {};
++ const trust = opts.trust || 'first-party';
++ const caps = Array.isArray(d.capabilities) ? d.capabilities.slice() : [];
++ // Add worker:<name> for each worker capability already in the form "worker:<name>"
++ // (the descriptor's capabilities field uses the same form as resource-offer).
++ const region = Array.isArray(d.regions) && d.regions.length ? d.regions[0] : 'global';
++ const now = new Date().toISOString();
++
++ const resource = {
++ offer_id: `node:${d.node_id || 'unknown'}`,
++ kind: 'node',
++ node_id: d.node_id,
++ region,
++ trust,
++ capabilities: caps,
++ capacity: {
++ cpu: d.cpu ? { available_cores: d.cpu.cores, utilization: 0 } : undefined,
++ memory: d.memory_mb != null ? { available_mb: d.memory_mb } : undefined,
++ gpu: d.gpu ? { type: d.gpu.model, vram_free_mb: d.gpu.vram_mb } : undefined,
++ disk: d.storage ? { scratch_gb: d.storage.available_gb != null ? d.storage.available_gb : d.storage.capacity_gb } : undefined,
++ },
++ health: { status: 'up', checked_at: now },
++ pricing: d.costs ? { model: 'per-second', marginal_usd_per_unit: (Number(d.costs.per_hour_usd) || 0) / 3600 } : { model: 'prepaid' },
++ updated_at: d.updated_at || now,
++ };
++ // Strip undefined keys from capacity
++ if (resource.capacity) {
++ for (const k of Object.keys(resource.capacity)) if (resource.capacity[k] == null) delete resource.capacity[k];
++ }
++
++ const cpuCores = d.cpu ? Number(d.cpu.cores) || 1 : 1;
++ const memoryMb = Number(d.memory_mb) || 512;
++ const runtime = {
++ id: `runtime:node:${d.node_id || 'unknown'}`,
++ kind: 'container',
++ region,
++ node: d.node_id,
++ limits: { cpu_cores: cpuCores, memory_mb: memoryMb },
++ price: { amount_usd: d.costs ? (Number(d.costs.per_hour_usd) || 0) : 0, unit: 'hour' },
++ availability: 'available',
++ constraints: [trust],
++ };
++
++ return { resource, runtime };
++}
++
++/** { ok, errors } for a record against platform.link-capabilities@1. */
++function validateLinkCapabilities(record) {
++ let contracts = null;
++ try { contracts = require('openvibe-contracts'); } catch { /* not installed */ }
++ if (!contracts) return { ok: false, errors: [] };
++ const r = contracts.validate('platform.link-capabilities@1', record);
++ return { ok: Boolean(r.valid), errors: r.errors || [] };
++}
++
++/** A Link client whose compute API is shared by products choosing where work runs. */
++function createLink(options) {
++ return { compute: require('./machines').createMachines(options).compute };
++}
++
++module.exports = { linkCapabilities, linkOffers, validateLinkCapabilities, createLink };
ddiff --git a/src/machines.js b/src/machines.js
iindex daebe8b..1a9cd0d 100644
---- a/src/machines.js
++++ b/src/machines.js
@@@ -3,7 +3,7 @@
* openvibe-sdk/machines — the network's machines hub (plan L34, step M2; Contracts 0.258.0).
*
* A person's machines are the network's: Bot stays the registry underneath, and OpenVibe.Network fronts it on
-- * openvibe.network/machines for the person and for the apps the person consented to. This module is the hub's API:
++ * openvibe.link/machines for the person and for the apps the person consented to. This module is the hub's API:
*
* // createMachines from the openvibe-sdk/machines entry (CommonJS or ESM)
* const machines = createMachines({ networkUrl: 'https://openvibe.network', token: () => sessionToken });
@@@ -98,6 +98,12 @@ function createMachines({ networkUrl, token, fetch: fetchImpl = globalThis.fetch
roles: (id, roles) => call('PUT', `/api/v1/machines/${enc(id)}/roles`, { roles: Array.isArray(roles) ? roles : [] }),
/** The machine is no longer the caller's: { machine }. */
revoke: (id) => call('POST', `/api/v1/machines/${enc(id)}/revoke`, {}),
++ /** All places work may run, optionally filtered to one source. */
++ compute: {
++ list: ({ kind } = {}) => call('GET', `/api/v1/link/compute${kind ? `?kind=${enc(kind)}` : ''}`),
++ /** Resolve a ready machine without starting a paid rental. */
++ pick: ({ kind = 'personal', id } = {}) => call('POST', '/api/v1/link/compute/pick', { kind, ...(id ? { id } : {}) }),
++ },
};
}
ddiff --git a/src/node.js b/src/node.js
ddeleted file mode 100644
iindex 30793ef..0000000
---- a/src/node.js
++++ /dev/null
@@@ -1,98 +0,0 @@
--'use strict';
--/**
-- * openvibe-sdk/node — platform.node-capabilities@1, plus nodeOffers() that maps a node descriptor to a
-- * platform.resource-offer@1 (kind "node") and a platform.runtime-offer@1.
-- * Pure construction and validation, no I/O and no dependency at require time.
-- *
-- * const { nodeCapabilities, nodeOffers, validateNodeCapabilities } = require('openvibe-sdk/node');
-- * const caps = nodeCapabilities({ node_id, cpu, arch, memory_mb, storage, network, regions, tags, costs });
-- * const { resource, runtime } = nodeOffers(caps);
-- */
--
--/** The fields platform.node-capabilities@1 requires or allows; null/undefined are stripped. */
--function nodeCapabilities(fields) {
-- const f = fields || {};
-- const record = {
-- node_id: f.node_id,
-- cpu: f.cpu,
-- arch: f.arch,
-- memory_mb: f.memory_mb,
-- gpu: f.gpu,
-- storage: f.storage,
-- network: f.network,
-- regions: f.regions,
-- tags: f.tags,
-- costs: f.costs,
-- capabilities: f.capabilities,
-- agent_version: f.agent_version,
-- updated_at: f.updated_at,
-- };
-- for (const k of Object.keys(record)) if (record[k] == null) delete record[k];
-- return record;
--}
--
--/**
-- * Map a node descriptor (platform.node-capabilities@1) to two offers:
-- * - resource: a platform.resource-offer@1 with kind "node", capabilities including every advertised
-- * worker capability prefixed "worker:<name>", trust from opts.trust or "first-party";
-- * - runtime: a platform.runtime-offer@1 with kind "container" (the default runtime a node offers),
-- * limits from cpu/memory, price from costs.per_hour_usd.
-- * opts.trust defaults to "first-party"; it is "user-owned" only when opts.trust says so.
-- */
--function nodeOffers(descriptor, opts = {}) {
-- const d = descriptor || {};
-- const trust = opts.trust || 'first-party';
-- const caps = Array.isArray(d.capabilities) ? d.capabilities.slice() : [];
-- // Add worker:<name> for each worker capability already in the form "worker:<name>"
-- // (the descriptor's capabilities field uses the same form as resource-offer).
-- const region = Array.isArray(d.regions) && d.regions.length ? d.regions[0] : 'global';
-- const now = new Date().toISOString();
--
-- const resource = {
-- offer_id: `node:${d.node_id || 'unknown'}`,
-- kind: 'node',
-- node_id: d.node_id,
-- region,
-- trust,
-- capabilities: caps,
-- capacity: {
-- cpu: d.cpu ? { available_cores: d.cpu.cores, utilization: 0 } : undefined,
-- memory: d.memory_mb != null ? { available_mb: d.memory_mb } : undefined,
-- gpu: d.gpu ? { type: d.gpu.model, vram_free_mb: d.gpu.vram_mb } : undefined,
-- disk: d.storage ? { scratch_gb: d.storage.available_gb != null ? d.storage.available_gb : d.storage.capacity_gb } : undefined,
-- },
-- health: { status: 'up', checked_at: now },
-- pricing: d.costs ? { model: 'per-second', marginal_usd_per_unit: (Number(d.costs.per_hour_usd) || 0) / 3600 } : { model: 'prepaid' },
-- updated_at: d.updated_at || now,
-- };
-- // Strip undefined keys from capacity
-- if (resource.capacity) {
-- for (const k of Object.keys(resource.capacity)) if (resource.capacity[k] == null) delete resource.capacity[k];
-- }
--
-- const cpuCores = d.cpu ? Number(d.cpu.cores) || 1 : 1;
-- const memoryMb = Number(d.memory_mb) || 512;
-- const runtime = {
-- id: `runtime:node:${d.node_id || 'unknown'}`,
-- kind: 'container',
-- region,
-- node: d.node_id,
-- limits: { cpu_cores: cpuCores, memory_mb: memoryMb },
-- price: { amount_usd: d.costs ? (Number(d.costs.per_hour_usd) || 0) : 0, unit: 'hour' },
-- availability: 'available',
-- constraints: [trust],
-- };
--
-- return { resource, runtime };
--}
--
--/** { ok, errors } for a record against platform.node-capabilities@1. */
--function validateNodeCapabilities(record) {
-- let contracts = null;
-- try { contracts = require('openvibe-contracts'); } catch { /* not installed */ }
-- if (!contracts) return { ok: false, errors: [] };
-- const r = contracts.validate('platform.node-capabilities@1', record);
-- return { ok: Boolean(r.valid), errors: r.errors || [] };
--}
--
--module.exports = { nodeCapabilities, nodeOffers, validateNodeCapabilities };
ddiff --git a/src/usage.js b/src/usage.js
iindex 37760a5..f4eef2a 100644
---- a/src/usage.js
++++ b/src/usage.js
@@@ -24,7 +24,7 @@
* sandbox traffic, so callers must only record billable production readings; `requireProject: true` makes
* record() refuse a reading whose `project` is not a `prj_…` id.
*
-- * const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker',
++ * const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-link.worker',
* billingUrl, tokenClient: createServiceTokenClient({ clientId: 'run', clientSecret }) });
* await reporter.ensureSchema(); // services put it in a migration instead
* const reading = reporter.sample({ idempotency_key: reporter.key('job', jobId, n), operation: 'function.invoke',
@@@ -105,7 +105,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
* @param {object} o
* @param {object} o.db openvibe-sdk/db handle (server); the outbox table lives in the same database
* @param {string} o.service the Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'
-- * @param {string} o.source the sample's `source`, e.g. 'openvibe-node.worker', 'ai.runs'
++ * @param {string} o.source the sample's `source`, e.g. 'openvibe-link.worker', 'ai.runs'
* @param {string} [o.table] the outbox table (default 'usage_outbox'); services create it in a migration
* @param {string} [o.billingUrl] OpenVibe.Billing's base URL; without it (or a tokenClient) only queueing happens
* @param {{ getToken(ctx?: { audience?: string }): Promise<string> }} [o.tokenClient] openvibe-sdk/auth's
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 3e63b17..fa8875b 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
assert.deepEqual(problems, []);
-- assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './design', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './live', './menu', './news', './node', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './shoes', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
++ assert.deepEqual(blocked.sort(), ['./account-data', './actor', './app-signin', './assertions', './blog', './cache', './chat-apps', './commerce', './compute', './consent', './cost', './coupons', './db', './deals', './delivery', './design', './download', './events', './exchange', './food', './govern', './host', './identity', './inventory', './limits', './link', './live', './menu', './news', './notifications', './openre', './placement', './play', './pubsub', './quest', './queue', './reports', './resources', './reviews', './run', './runtime', './service', './share', './shoes', './space', './sso', './storage', './streams', './telemetry', './testing', './tips', './trade', './usage', './valkey', './vibes', './video', './vip', './wiki', './work', './zone']);
const rel = [...scanned].map((f) => path.relative(ROOT, f).split(path.sep).join('/')); // the same names on Windows
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/cam/index.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
ddiff --git a/test/geo.test.js b/test/geo.test.js
iindex d71fb25..5a54e8b 100644
---- a/test/geo.test.js
++++ b/test/geo.test.js
@@@ -12,7 +12,7 @@ const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('s
async function registry() {
return stubServer((req, res) => {
const u = new URL(req.url, 'http://x');
-- if (u.pathname === '/api/v1/nodes') {
++ if (u.pathname === '/api/v1/links') {
const role = u.searchParams.get('role');
return send(res, 200, { nodes: role === 'gpu' ? [] : NODES, generated_at: '2026-09-28T12:00:00Z' });
}
ddiff --git a/test/jwt.test.js b/test/jwt.test.js
iindex 7237fb5..69af000 100644
---- a/test/jwt.test.js
++++ b/test/jwt.test.js
@@@ -62,10 +62,10 @@ run([
await assert.rejects(verifyUserToken(svc, { jwks }), { code: 'token.not_user' });
assert.equal((await verifyUserToken(svc, { jwks, allowServiceTokens: true })).sub, 'svc:live');
// A node token (a machine paired for Bot) is a principal too, by its actor_type or its sub alone.
-- const node = { iss: 'https://openvibe.network', sub: 'node:nod_01JAB2C3D4E5F6G7H8J9K0MNPR', actor_type: 'node', aud: ['openvibe.bot'], cap: [], iat: now(), exp: now() + 300, jti: 'tok_2' };
++ const node = { iss: 'https://openvibe.network', sub: 'link:lnk_01JAB2C3D4E5F6G7H8J9K0MNPR', actor_type: 'link', aud: ['openvibe.bot'], cap: [], iat: now(), exp: now() + 300, jti: 'tok_2' };
await assert.rejects(verifyUserToken(sign(node, good.privateKey), { jwks }), { code: 'token.not_user' });
await assert.rejects(verifyUserToken(sign({ ...node, actor_type: undefined }, good.privateKey), { jwks }), { code: 'token.not_user' });
-- await assert.rejects(verifyUserToken(sign({ ...claims(), actor_type: 'node' }, good.privateKey), { jwks }), { code: 'token.not_user' });
++ await assert.rejects(verifyUserToken(sign({ ...claims(), actor_type: 'link' }, good.privateKey), { jwks }), { code: 'token.not_user' });
await assert.rejects(verifyUserToken(sign(claims(), good.privateKey), { jwks: { keys: [] } }), { code: 'token.no_key' });
}],
ddiff --git a/test/link-brand.test.js b/test/link-brand.test.js
nnew file mode 100644
iindex 0000000..eaa904f
---- /dev/null
++++ b/test/link-brand.test.js
@@@ -0,0 +1,10 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { spawnSync } = require('node:child_process');
++const path = require('node:path');
++const root = path.resolve(__dirname, '..');
++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
++console.log(' ok old Link brand and principal IDs are absent');
ddiff --git a/test/link.test.js b/test/link.test.js
nnew file mode 100644
iindex 0000000..cd2b68d
---- /dev/null
++++ b/test/link.test.js
@@@ -0,0 +1,81 @@
++'use strict';
++/** openvibe-sdk/link: platform.link-capabilities@1 — build, validate, and linkOffers(). */
++const assert = require('node:assert/strict');
++const fs = require('node:fs');
++const path = require('node:path');
++const { run } = require('./helpers');
++const { linkCapabilities, linkOffers, validateLinkCapabilities } = require('../src/link');
++
++const FIXTURES = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures', 'platform.link-capabilities');
++let contracts = null;
++try { contracts = require('openvibe-contracts'); } catch { /* the fixture cases skip */ }
++if (contracts && (!fs.existsSync(path.join(FIXTURES, 'valid')) || !fs.existsSync(path.join(FIXTURES, 'invalid')))) contracts = null;
++
++run([
++ ['linkCapabilities passes every valid fixture through unchanged, and they validate', async () => {
++ if (!contracts) { console.log('node-capabilities fixtures: skipped (matching openvibe-contracts fixtures unavailable)'); return; }
++ for (const f of fs.readdirSync(path.join(FIXTURES, 'valid'))) {
++ const fixture = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'valid', f), 'utf8'));
++ assert.deepEqual(linkCapabilities({ ...fixture }), fixture, f);
++ assert.deepEqual(validateLinkCapabilities(fixture), { ok: true, errors: [] }, f);
++ }
++ }],
++ ['linkCapabilities strips null/undefined', async () => {
++ const c = linkCapabilities({ node_id: 'n1', cpu: { cores: 4 }, arch: 'x86_64', memory_mb: 8192,
++ storage: { capacity_gb: 100 }, network: { ingress_mbps: 100, egress_mbps: 100 },
++ regions: ['us-west'], tags: [], costs: { per_hour_usd: 0.1 },
++ gpu: null, capabilities: undefined, agent_version: null });
++ assert.ok(!('gpu' in c), 'null gpu stripped');
++ assert.ok(!('capabilities' in c), 'undefined capabilities stripped');
++ assert.ok(!('agent_version' in c), 'null agent_version stripped');
++ }],
++ ['validateLinkCapabilities refuses every invalid fixture with its errors', async () => {
++ if (!contracts) { console.log('node-capabilities invalid fixtures: skipped (matching openvibe-contracts fixtures unavailable)'); return; }
++ for (const f of fs.readdirSync(path.join(FIXTURES, 'invalid'))) {
++ const fixture = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'invalid', f), 'utf8'));
++ const r = validateLinkCapabilities(fixture);
++ assert.equal(r.ok, false, f);
++ assert.ok(r.errors.length > 0, f);
++ }
++ }],
++ ['linkOffers: default trust is first-party, capabilities pass through, kind is "node"', async () => {
++ const caps = linkCapabilities({ node_id: 'n1', cpu: { cores: 8 }, arch: 'x86_64', memory_mb: 16384,
++ storage: { capacity_gb: 500, available_gb: 300 }, network: { ingress_mbps: 1000, egress_mbps: 1000 },
++ regions: ['us-west'], tags: ['gpu'], costs: { per_hour_usd: 0.2 },
++ capabilities: ['node:http', 'worker:ffmpeg'] });
++ const { resource, runtime } = linkOffers(caps);
++ assert.equal(resource.kind, 'node');
++ assert.equal(resource.trust, 'first-party');
++ assert.equal(resource.node_id, 'n1');
++ assert.equal(resource.region, 'us-west');
++ assert.deepEqual(resource.capabilities, ['node:http', 'worker:ffmpeg']);
++ assert.equal(resource.offer_id, 'node:n1');
++ assert.equal(resource.health.status, 'up');
++ // runtime
++ assert.equal(runtime.kind, 'container');
++ assert.equal(runtime.node, 'n1');
++ assert.deepEqual(runtime.limits, { cpu_cores: 8, memory_mb: 16384 });
++ assert.equal(runtime.price.amount_usd, 0.2);
++ assert.equal(runtime.price.unit, 'hour');
++ assert.deepEqual(runtime.constraints, ['first-party']);
++ }],
++ ['linkOffers: trust is "user-owned" only when opts.trust says so', async () => {
++ const caps = linkCapabilities({ node_id: 'n2', cpu: { cores: 4 }, arch: 'arm64', memory_mb: 8192,
++ storage: { capacity_gb: 200 }, network: { ingress_mbps: 500, egress_mbps: 500 },
++ regions: ['eu-west'], tags: [], costs: { per_hour_usd: 0.1 } });
++ const { resource, runtime } = linkOffers(caps, { trust: 'user-owned' });
++ assert.equal(resource.trust, 'user-owned');
++ assert.deepEqual(runtime.constraints, ['user-owned']);
++ // default (no opts) stays first-party
++ const { resource: r2 } = linkOffers(caps);
++ assert.equal(r2.trust, 'first-party');
++ }],
++ ['linkOffers: per-second pricing from per_hour_usd', async () => {
++ const caps = linkCapabilities({ node_id: 'n3', cpu: { cores: 2 }, arch: 'x86_64', memory_mb: 4096,
++ storage: { capacity_gb: 100 }, network: { ingress_mbps: 100, egress_mbps: 100 },
++ regions: ['us-east'], tags: [], costs: { per_hour_usd: 0.36 } });
++ const { resource } = linkOffers(caps);
++ assert.equal(resource.pricing.model, 'per-second');
++ assert.ok(Math.abs(resource.pricing.marginal_usd_per_unit - 0.0001) < 1e-12); // 0.36 / 3600
++ }],
++]);
ddiff --git a/test/machines.test.js b/test/machines.test.js
iindex 9f5991d..61fadb3 100644
---- a/test/machines.test.js
++++ b/test/machines.test.js
@@@ -25,6 +25,20 @@ async function hub(answer) {
}
run([
++ ['Link compute lists and picks through the same authenticated hub', async () => {
++ const { createLink } = require('../src/link');
++ const { stub, state } = await hub((req) => req.method === 'POST'
++ ? { body: { choice: { id: MACHINE.id, kind: 'personal', status: 'ready' } } }
++ : { body: { sources: [], candidates: [MACHINE] } });
++ const link = createLink({ networkUrl: stub.url, token: () => state.token });
++ try {
++ assert.equal((await link.compute.list({ kind: 'personal' })).candidates.length, 1);
++ assert.equal((await link.compute.pick({ kind: 'personal', id: MACHINE.id })).choice.id, MACHINE.id);
++ assert.equal(state.requests[0].url, '/api/v1/link/compute?kind=personal');
++ assert.equal(state.requests[1].url, '/api/v1/link/compute/pick');
++ assert.deepEqual(JSON.parse(state.requests[1].body), { kind: 'personal', id: MACHINE.id });
++ } finally { await stub.close(); }
++ }],
['the default timeout is ten seconds', async () => {
assert.equal(DEFAULT_TIMEOUT_MS, 10_000);
}],
@@@ -35,7 +49,7 @@ run([
if (req.url === '/api/v1/machines/roles') return { body: { roles: [{ id: 'codes.worker', name: 'Codes worker', installs: 'the runner', shows: 'Runs your coding jobs.' }] } };
if (req.url.endsWith('/roles') && req.method === 'PUT') return { body: { machine: { ...MACHINE, roles: ['codes.worker', 'desks.host'] } } };
if (req.url.endsWith('/revoke')) return { body: { machine: { ...MACHINE, online: false } } };
-- if (req.url === '/api/v1/machines/pairings') return { body: { pairing: { code: 'ABCD-EFGH', expires_at: '2026-10-31T12:00:00.000Z', installer: 'curl -fsSL https://openvibe.bot/install | sh' } } };
++ if (req.url === '/api/v1/machines/pairings') return { body: { pairing: { code: 'ABCD-EFGH', expires_at: '2026-10-31T12:00:00.000Z', installer: 'curl -fsSL https://openvibe.link/install | sh' } } };
if (req.method === 'PATCH') return { body: { machine: { ...MACHINE, name: 'Studio' } } };
return { body: { machine: MACHINE } };
});
ddiff --git a/test/node.test.js b/test/node.test.js
ddeleted file mode 100644
iindex 17d8d6f..0000000
---- a/test/node.test.js
++++ /dev/null
@@@ -1,81 +0,0 @@
--'use strict';
--/** openvibe-sdk/node: platform.node-capabilities@1 — build, validate, and nodeOffers(). */
--const assert = require('node:assert/strict');
--const fs = require('node:fs');
--const path = require('node:path');
--const { run } = require('./helpers');
--const { nodeCapabilities, nodeOffers, validateNodeCapabilities } = require('../src/node');
--
--const FIXTURES = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures', 'platform.node-capabilities');
--let contracts = null;
--try { contracts = require('openvibe-contracts'); } catch { /* the fixture cases skip */ }
--if (contracts && (!fs.existsSync(path.join(FIXTURES, 'valid')) || !fs.existsSync(path.join(FIXTURES, 'invalid')))) contracts = null;
--
--run([
-- ['nodeCapabilities passes every valid fixture through unchanged, and they validate', async () => {
-- if (!contracts) { console.log('node-capabilities fixtures: skipped (matching openvibe-contracts fixtures unavailable)'); return; }
-- for (const f of fs.readdirSync(path.join(FIXTURES, 'valid'))) {
-- const fixture = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'valid', f), 'utf8'));
-- assert.deepEqual(nodeCapabilities({ ...fixture }), fixture, f);
-- assert.deepEqual(validateNodeCapabilities(fixture), { ok: true, errors: [] }, f);
-- }
-- }],
-- ['nodeCapabilities strips null/undefined', async () => {
-- const c = nodeCapabilities({ node_id: 'n1', cpu: { cores: 4 }, arch: 'x86_64', memory_mb: 8192,
-- storage: { capacity_gb: 100 }, network: { ingress_mbps: 100, egress_mbps: 100 },
-- regions: ['us-west'], tags: [], costs: { per_hour_usd: 0.1 },
-- gpu: null, capabilities: undefined, agent_version: null });
-- assert.ok(!('gpu' in c), 'null gpu stripped');
-- assert.ok(!('capabilities' in c), 'undefined capabilities stripped');
-- assert.ok(!('agent_version' in c), 'null agent_version stripped');
-- }],
-- ['validateNodeCapabilities refuses every invalid fixture with its errors', async () => {
-- if (!contracts) { console.log('node-capabilities invalid fixtures: skipped (matching openvibe-contracts fixtures unavailable)'); return; }
-- for (const f of fs.readdirSync(path.join(FIXTURES, 'invalid'))) {
-- const fixture = JSON.parse(fs.readFileSync(path.join(FIXTURES, 'invalid', f), 'utf8'));
-- const r = validateNodeCapabilities(fixture);
-- assert.equal(r.ok, false, f);
-- assert.ok(r.errors.length > 0, f);
-- }
-- }],
-- ['nodeOffers: default trust is first-party, capabilities pass through, kind is "node"', async () => {
-- const caps = nodeCapabilities({ node_id: 'n1', cpu: { cores: 8 }, arch: 'x86_64', memory_mb: 16384,
-- storage: { capacity_gb: 500, available_gb: 300 }, network: { ingress_mbps: 1000, egress_mbps: 1000 },
-- regions: ['us-west'], tags: ['gpu'], costs: { per_hour_usd: 0.2 },
-- capabilities: ['node:http', 'worker:ffmpeg'] });
-- const { resource, runtime } = nodeOffers(caps);
-- assert.equal(resource.kind, 'node');
-- assert.equal(resource.trust, 'first-party');
-- assert.equal(resource.node_id, 'n1');
-- assert.equal(resource.region, 'us-west');
-- assert.deepEqual(resource.capabilities, ['node:http', 'worker:ffmpeg']);
-- assert.equal(resource.offer_id, 'node:n1');
-- assert.equal(resource.health.status, 'up');
-- // runtime
-- assert.equal(runtime.kind, 'container');
-- assert.equal(runtime.node, 'n1');
-- assert.deepEqual(runtime.limits, { cpu_cores: 8, memory_mb: 16384 });
-- assert.equal(runtime.price.amount_usd, 0.2);
-- assert.equal(runtime.price.unit, 'hour');
-- assert.deepEqual(runtime.constraints, ['first-party']);
-- }],
-- ['nodeOffers: trust is "user-owned" only when opts.trust says so', async () => {
-- const caps = nodeCapabilities({ node_id: 'n2', cpu: { cores: 4 }, arch: 'arm64', memory_mb: 8192,
-- storage: { capacity_gb: 200 }, network: { ingress_mbps: 500, egress_mbps: 500 },
-- regions: ['eu-west'], tags: [], costs: { per_hour_usd: 0.1 } });
-- const { resource, runtime } = nodeOffers(caps, { trust: 'user-owned' });
-- assert.equal(resource.trust, 'user-owned');
-- assert.deepEqual(runtime.constraints, ['user-owned']);
-- // default (no opts) stays first-party
-- const { resource: r2 } = nodeOffers(caps);
-- assert.equal(r2.trust, 'first-party');
-- }],
-- ['nodeOffers: per-second pricing from per_hour_usd', async () => {
-- const caps = nodeCapabilities({ node_id: 'n3', cpu: { cores: 2 }, arch: 'x86_64', memory_mb: 4096,
-- storage: { capacity_gb: 100 }, network: { ingress_mbps: 100, egress_mbps: 100 },
-- regions: ['us-east'], tags: [], costs: { per_hour_usd: 0.36 } });
-- const { resource } = nodeOffers(caps);
-- assert.equal(resource.pricing.model, 'per-second');
-- assert.ok(Math.abs(resource.pricing.marginal_usd_per_unit - 0.0001) < 1e-12); // 0.36 / 3600
-- }],
--]);
ddiff --git a/test/usage-reporter.test.js b/test/usage-reporter.test.js
iindex a9e2778..4c5fa09 100644
---- a/test/usage-reporter.test.js
++++ b/test/usage-reporter.test.js
@@@ -35,14 +35,14 @@ function tokensStub() {
}
const reading = (n) => usageSample({ id: `run:job_A:${n}`, idempotency_key: `run:job_A:${n}`, service: 'run',
-- operation: 'function.invoke', quantity: 1, unit: 's', at: '2026-10-02T10:00:00Z', source: 'openvibe-node.worker' });
++ operation: 'function.invoke', quantity: 1, unit: 's', at: '2026-10-02T10:00:00Z', source: 'openvibe-link.worker' });
async function open({ statuses = [], table } = {}) {
const db = createDb({ pglite: true });
const billing = billingStub(statuses);
const tokens = tokensStub();
const now = { t: 1_000_000 };
-- const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', table,
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-link.worker', table,
billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
now: () => now.t, log: quiet });
await reporter.ensureSchema();
@@@ -54,7 +54,7 @@ async function deliverOnce(status) {
const db = createDb({ pglite: true });
const billing = billingStub([status]);
const tokens = tokensStub();
-- const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker',
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-link.worker',
billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
now: () => 1_000_000, log: quiet });
await reporter.ensureSchema();
@@@ -83,13 +83,13 @@ run([
const s = reporter.sample({ idempotency_key: reporter.key('job', 'job_A', 0), operation: 'function.invoke',
quantity: 1, unit: 's', resource: 'job_A', at: '2026-10-02T10:00:00Z' });
assert.equal(s.service, 'run');
-- assert.equal(s.source, 'openvibe-node.worker');
++ assert.equal(s.source, 'openvibe-link.worker');
assert.equal(s.id, s.idempotency_key, 'id defaults to the key');
assert.deepEqual(validateUsageSample(s), { ok: true, errors: [] });
const explicit = reporter.sample({ id: 'use-1', idempotency_key: 'k', service: 'someone-else', source: 'someone-else',
operation: 'function.invoke', quantity: 1, unit: 's' });
assert.equal(explicit.service, 'run', 'the reporter service wins');
-- assert.equal(explicit.source, 'openvibe-node.worker');
++ assert.equal(explicit.source, 'openvibe-link.worker');
assert.equal(explicit.id, 'use-1', 'an explicit id is kept');
} finally { await done(); }
}],
@@@ -158,7 +158,7 @@ run([
['without a billingUrl or tokenClient the reporter still queues, but the relay is off', async () => {
const db = createDb({ pglite: true });
try {
-- const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', log: quiet });
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-link.worker', log: quiet });
assert.equal(reporter.enabled, false);
await reporter.ensureSchema();
await assert.rejects(reporter.record(null, reading(0)), /transaction handle/);
@@@ -213,7 +213,7 @@ run([
['requireProject refuses a reading without a production project, and accepts one with it', async () => {
const db = createDb({ pglite: true });
try {
-- const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-node.worker', requireProject: true, log: quiet });
++ const reporter = createUsageReporter({ db, service: 'run', source: 'openvibe-link.worker', requireProject: true, log: quiet });
await reporter.ensureSchema();
await assert.rejects(db.tx((t) => reporter.record(t, reading(0))), /production project/, 'a first-party reading (no project) is refused');
// A malformed project fails contracts validation first; either way the reading is refused.
ddiff --git a/testdata/blind-fill-link-send-v1.json b/testdata/blind-fill-link-send-v1.json
iindex f219cd8..e87f2be 100644
---- a/testdata/blind-fill-link-send-v1.json
++++ b/testdata/blind-fill-link-send-v1.json
@@@ -1,8 +1,8 @@
{
"format": "openvibe-locker-link-send-v1",
"recipient": {
-- "type": "node",
-- "id": "nod_01JAB2C3D4E5F6G7H8J9K0MNPQ"
++ "type": "link",
++ "id": "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"
},
"private_jwk": {
"key_ops": [
@@@ -16,7 +16,7 @@
"d": "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0"
},
"plaintext_json": "{\"request_id\":\"fr_01JAB2C3D4E5F6G7H8J9K0MNPQ\",\"origin\":\"https://example.org\",\"username\":\"vector-user\",\"password\":\"vector-password\",\"code\":\"123456\",\"submit\":true,\"exp\":1893456000000}",
-- "envelope_base64": "T1ZMSwAAAnJ7ImNodW5rX3NpemUiOjEwNDg1NzYsImxvY2siOiJwZW9wbGUiLCJub25jZV9iYXNlIjoieDZFalZrUDBHSUk9IiwicGFkZGVkX3NpemUiOjI1Niwic2xvdHMiOnsicGVvcGxlIjpbeyJhbGdvcml0aG0iOiJFQ0RILVAyNTYvSEtERi1TSEEyNTYvQUVTLTI1Ni1HQ00iLCJlcGhlbWVyYWxfcHVibGljX2tleSI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6InBObmdwVFNFM1gzQ19DR3pmcEF0ZW5qbGlMUEZaOWFyQlZOdEkwY3RQWHciLCJ5IjoiZlhaVDJKY0REaVhFYjZ4SDNMVXJxbFBha00zR1F3ZTNhcWJWXzF2aFZMSSJ9LCJub25jZSI6ImdkRHZ6MmI1aDdUM1NHS3EiLCJyZWNpcGllbnRfa2V5IjoiVENHaDJnSDZISXVUMC1BOUdlMGpPVHJGbVJYV1FrZEE2ZlpHOEp4aWtUQSIsInNhbHQiOiI1MXV5RmtBZkxmaUtQMEFjZWJ3eTdnPT0iLCJzdWJqZWN0Ijp7ImlkIjoibm9kXzAxSkFCMkMzRDRFNUY2RzdIOEo5SzBNTlBRIiwidHlwZSI6Im5vZGUifSwid3JhcHBlZF9rZXkiOiJYUy9wcDhtako5by90alpYOHNMcDBXcnA2RTVnTXNFeGFJYmJkalRBMXMxWXl5K290WFEzSFNtZDZGNWR1Mlp0In1dfSwic3VpdGUiOiJBRVMtMjU2LUdDTSIsInZlcnNpb24iOiJsb2NrZXIuZW52ZWxvcGUudjEifQAAARABx6EjVkP0GIIAAAAA69pYH8E0kbpjNaAex1w2c3LlQrxhh736ExhRmzg6br1BRsQhpfz6lnnj7HSwPHl9tokF8lFZ7B/StHSeKZjXlwIib6cK/v5ochfyGemSfYL5e7s9cNUjBJDWZ9SjuiZ9MGRdPJiHbP8BLvtToG4MrvRudpXHY5XfQKoZg1sNbSeW0HfrsGasGQcEjGdvrRUeYFDDBmTL1B5Dk9+X1eHRNTtiQJ5HWibwP+UocDmKrqLypSEsip1D8gjjuBkHgLy39yc0lvGcg14+4AD668aC8BgDHI/5AVL+FLCN7FceUKYuupW2x+B0PlsT9VQWy6EUuIcXd2s2Y/bP/p601ZkUi8WLrNmwl4hS7YT4pvPKJ70=",
++ "envelope_base64": "T1ZMSwAAAnJ7ImNodW5rX3NpemUiOjEwNDg1NzYsImxvY2siOiJwZW9wbGUiLCJub25jZV9iYXNlIjoieDZFalZrUDBHSUk9IiwicGFkZGVkX3NpemUiOjI1Niwic2xvdHMiOnsicGVvcGxlIjpbeyJhbGdvcml0aG0iOiJFQ0RILVAyNTYvSEtERi1TSEEyNTYvQUVTLTI1Ni1HQ00iLCJlcGhlbWVyYWxfcHVibGljX2tleSI6eyJjcnYiOiJQLTI1NiIsImt0eSI6IkVDIiwieCI6InBObmdwVFNFM1gzQ19DR3pmcEF0ZW5qbGlMUEZaOWFyQlZOdEkwY3RQWHciLCJ5IjoiZlhaVDJKY0REaVhFYjZ4SDNMVXJxbFBha00zR1F3ZTNhcWJWXzF2aFZMSSJ9LCJub25jZSI6ImdkRHZ6MmI1aDdUM1NHS3EiLCJyZWNpcGllbnRfa2V5IjoiVENHaDJnSDZISXVUMC1BOUdlMGpPVHJGbVJYV1FrZEE2ZlpHOEp4aWtUQSIsInNhbHQiOiI1MXV5RmtBZkxmaUtQMEFjZWJ3eTdnPT0iLCJzdWJqZWN0Ijp7ImlkIjoibG5rXzAxSkFCMkMzRDRFNUY2RzdIOEo5SzBNTlBRIiwidHlwZSI6ImxpbmsifSwid3JhcHBlZF9rZXkiOiJBcEJJVzNpaStwdjBTU05ONWdBR1FiaGpZZTlxNno5UWltYlRFSndLVExGbCs5WThvemdZNDVCWi9pQUxrU0s0In1dfSwic3VpdGUiOiJBRVMtMjU2LUdDTSIsInZlcnNpb24iOiJsb2NrZXIuZW52ZWxvcGUudjEifQAAARABx6EjVkP0GIIAAAAA69pYH8E0kbpjNaAex1w2c3LlQrxhh736ExhRmzg6br1BRsQhpfz6lnnj7HSwPHl9tokF8lFZ7B/StHSeKZjXlwIib6cK/v5ochfyGemSfYL5e7s9cNUjBJDWZ9SjuiZ9MGRdPJiHbP8BLvtToG4MrvRudpXHY5XfQKoZg1sNbSeW0HfrsGasGQcEjGdvrRUeYFDDBmTL1B5Dk9+X1eHRNTtiQJ5HWibwP+UocDmKrqLypSEsip1D8gjjuBkHgLy39yc0lvGcg14+4AD668aC8BgDHI/5AVL+FLCN7FceUKYuupW2x+B0PlsT9VQWy6EUuIcXd2s2Y/bP/p601ZkUi8WLrNmwl4hS7YT4pvPKJ70=",
"expected": {
"request_id": "fr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"origin": "https://example.org",
ddiff --git a/types/contracts.d.ts b/types/contracts.d.ts
iindex c14174b..f7c45ad 100644
---- a/types/contracts.d.ts
++++ b/types/contracts.d.ts
@@@ -45,7 +45,7 @@ export type ServiceTokenClaims = {
*/
iss: string;
sub: string;
-- actor_type: "service" | "app" | "mod" | "node" | "agent";
++ actor_type: "service" | "app" | "mod" | "link" | "agent";
/**
* @minItems 1
*/
@@@ -112,7 +112,6 @@ export type ServiceTokenClaims = {
};
[k: string]: unknown | undefined;
};
--
/** [email protected] (owner: contracts) */
/**
* Typed reference to another service's entity. Store this instead of a foreign key; resolve a display projection from the owner.
@@@ -2228,12 +2227,22 @@ export interface Capability {
* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
*/
implementedBy?: string[];
++ /**
++ * Input and output contract overrides for a route when one capability covers several operations.
++ */
++ routeSchemas?: {
++ [k: string]:
++ | {
++ inputSchema?: string;
++ outputSchema?: string;
++ }
++ | undefined;
++ };
/**
* The action has an external side effect (money, sending as the person, publishing, applying, deleting, physical control): an agent needs its owner's confirmation unless a standing rule covers it (roadmap WS-Z2).
*/
sensitive?: boolean;
}
--
/** [email protected] (owner: events) */
/**
* Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.
ddiff --git a/types/geo.d.ts b/types/geo.d.ts
iindex 7452944..5e6264b 100644
---- a/types/geo.d.ts
++++ b/types/geo.d.ts
@@@ -1,7 +1,7 @@
import type { OpenVibeClient } from './core';
--/** Contracts network.node@1. */
--export interface OpenVibeNode {
++/** Contracts network.link@1. */
++export interface OpenVibeLink {
id: string; name: string;
roles: Array<'web' | 'app' | 'data' | 'media-worker' | 'ingest' | 'edge-probe' | 'edge-relay' | 'edge-cache' | 'gpu' | 'staging' | 'ci'>;
location: { region: string; country?: string; city?: string; lat?: number; lon?: number };
@@@ -9,10 +9,10 @@ export interface OpenVibeNode {
health: { status: 'up' | 'degraded' | 'down' | 'unknown'; checked_at: string };
updated_at: string;
}
--export interface Measured { node: OpenVibeNode; rtt_ms: number | null; ok: boolean }
++export interface Measured { node: OpenVibeLink; rtt_ms: number | null; ok: boolean }
export interface GeoClient {
-- nodes(opts?: { role?: string; region?: string }): Promise<OpenVibeNode[]>;
-- measure(nodes: OpenVibeNode[]): Promise<Measured[]>;
-- nearest(opts?: { role?: string; region?: string; preferRegion?: string; list?: OpenVibeNode[] }): Promise<{ node: OpenVibeNode; rtt_ms: number | null; measured: boolean } | null>;
++ nodes(opts?: { role?: string; region?: string }): Promise<OpenVibeLink[]>;
++ measure(nodes: OpenVibeLink[]): Promise<Measured[]>;
++ nearest(opts?: { role?: string; region?: string; preferRegion?: string; list?: OpenVibeLink[] }): Promise<{ node: OpenVibeLink; rtt_ms: number | null; measured: boolean } | null>;
}
export function createGeoClient(client: OpenVibeClient, opts?: { baseUrl?: string; fetch?: typeof fetch; samples?: number; timeoutMs?: number; now?: () => number }): GeoClient;
ddiff --git a/types/link.d.ts b/types/link.d.ts
nnew file mode 100644
iindex 0000000..9d20cbc
---- /dev/null
++++ b/types/link.d.ts
@@@ -0,0 +1,21 @@
++/** platform.link-capabilities@1: Link machine hardware, placement regions, tags and operating costs. Validated by
++ * openvibe-contracts (required lazily by validateLinkCapabilities). linkOffers() maps a descriptor to a
++ * platform.resource-offer@1 (kind "node") and a platform.runtime-offer@1. */
++export type LinkCpu = { cores: number; threads?: number };
++export type LinkGpu = { model: string; count: number; vram_mb?: number };
++export type LinkStorage = { capacity_gb: number; available_gb?: number; kind?: string };
++export type LinkNetwork = { ingress_mbps: number; egress_mbps: number };
++export type LinkCosts = { per_hour_usd: number; egress_per_gb_usd?: number };
++export type LinkCapabilities = { node_id: string; cpu: LinkCpu; arch: string; memory_mb: number; gpu?: LinkGpu;
++ storage: LinkStorage; network: LinkNetwork; regions: string[]; tags: string[]; costs: LinkCosts;
++ capabilities?: string[]; agent_version?: string; updated_at?: string };
++export type LinkOffersOptions = { trust?: string };
++export type ValidationError = { path: string; message: string };
++/** Null/undefined fields are stripped. */
++export declare function linkCapabilities(fields: Partial<LinkCapabilities>): LinkCapabilities;
++/** Map a Link descriptor to { resource: resource-offer@1, runtime: runtime-offer@1 }. trust defaults to
++ * "first-party"; it is "user-owned" only when opts.trust says so. */
++export declare function linkOffers(descriptor: LinkCapabilities, opts?: LinkOffersOptions): {
++ resource: Record<string, unknown>; runtime: Record<string, unknown> };
++export declare function validateLinkCapabilities(record: unknown): { ok: boolean; errors: ValidationError[] };
++export declare function createLink(options: import('./machines').MachinesClientOptions): { compute: import('./machines').LinkComputeClient };
ddiff --git a/types/machines.d.ts b/types/machines.d.ts
iindex fd1d41f..78b6066 100644
---- a/types/machines.d.ts
++++ b/types/machines.d.ts
@@@ -12,7 +12,7 @@ export interface Machine {
roles: string[];
/** What it does right now: its Codes jobs, its desks, its local models, its robots. */
running: Array<{ kind: 'codes.jobs' | 'desks' | 'models' | 'robots'; count: number; label?: string }>;
-- /** The node principal on Network (nod_…), when it has one. */
++ /** The node principal on Network (lnk_…), when it has one. */
principal_id?: string;
/** The name the owner gave it. */
name?: string;
@@@ -66,6 +66,24 @@ export interface MachinesClient {
roles(id: string, roles: string[]): Promise<{ machine: Machine }>;
/** The machine is no longer the caller's. */
revoke(id: string): Promise<{ machine: Machine }>;
++ compute: LinkComputeClient;
++}
++
++export type LinkComputeKind = 'personal' | 'cloud' | 'exchange' | 'community';
++export type LinkComputeStatus = 'ready' | 'unavailable' | 'coming_soon';
++export interface LinkComputeSource {
++ kind: LinkComputeKind;
++ name: string;
++ status: LinkComputeStatus;
++ reason: string | null;
++ price?: { amount_usd: number; unit: 'hour' } | null;
++ quoted_at?: string;
++ expires_at?: string;
++}
++export interface LinkComputeCandidate extends LinkComputeSource { id: string; principal_id?: string; price: { amount_usd: number; unit: 'hour' } | null }
++export interface LinkComputeClient {
++ list(options?: { kind?: LinkComputeKind }): Promise<{ sources: LinkComputeSource[]; candidates: LinkComputeCandidate[] }>;
++ pick(input?: { kind?: LinkComputeKind; id?: string }): Promise<{ choice: LinkComputeCandidate }>;
}
/** A refusal: the hub's own status and code (err.status, err.code). */
ddiff --git a/types/node.d.ts b/types/node.d.ts
ddeleted file mode 100644
iindex 657e48f..0000000
---- a/types/node.d.ts
++++ /dev/null
@@@ -1,20 +0,0 @@
--/** platform.node-capabilities@1: node hardware, placement regions, tags and operating costs. Validated by
-- * openvibe-contracts (required lazily by validateNodeCapabilities). nodeOffers() maps a descriptor to a
-- * platform.resource-offer@1 (kind "node") and a platform.runtime-offer@1. */
--export type NodeCpu = { cores: number; threads?: number };
--export type NodeGpu = { model: string; count: number; vram_mb?: number };
--export type NodeStorage = { capacity_gb: number; available_gb?: number; kind?: string };
--export type NodeNetwork = { ingress_mbps: number; egress_mbps: number };
--export type NodeCosts = { per_hour_usd: number; egress_per_gb_usd?: number };
--export type NodeCapabilities = { node_id: string; cpu: NodeCpu; arch: string; memory_mb: number; gpu?: NodeGpu;
-- storage: NodeStorage; network: NodeNetwork; regions: string[]; tags: string[]; costs: NodeCosts;
-- capabilities?: string[]; agent_version?: string; updated_at?: string };
--export type NodeOffersOptions = { trust?: string };
--export type ValidationError = { path: string; message: string };
--/** Null/undefined fields are stripped. */
--export declare function nodeCapabilities(fields: Partial<NodeCapabilities>): NodeCapabilities;
--/** Map a Node descriptor to { resource: resource-offer@1, runtime: runtime-offer@1 }. trust defaults to
-- * "first-party"; it is "user-owned" only when opts.trust says so. */
--export declare function nodeOffers(descriptor: NodeCapabilities, opts?: NodeOffersOptions): {
-- resource: Record<string, unknown>; runtime: Record<string, unknown> };
--export declare function validateNodeCapabilities(record: unknown): { ok: boolean; errors: ValidationError[] };
ddiff --git a/types/usage.d.ts b/types/usage.d.ts
iindex 180a1bd..b658533 100644
---- a/types/usage.d.ts
++++ b/types/usage.d.ts
@@@ -22,7 +22,7 @@ export type UsageReporterOptions = {
db: unknown;
/** The Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'. */
service: string;
-- /** The sample's `source`, e.g. 'openvibe-node.worker', 'ai.runs'. */
++ /** The sample's `source`, e.g. 'openvibe-link.worker', 'ai.runs'. */
source: string;
/** The outbox table (default 'usage_outbox'); services create it in a migration. */
table?: string;