openvibers/openvibe.sdk Commits
Add Link compute APIs to the SDK (#156)
Changes
@@@ -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 forthe person and for the apps the person consented to.- `createMachines({ networkUrl, token, fetch, timeoutMs })`: `token` returns the caller's own bearer token — theperson'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 pairedfor 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 anaudience 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`.@@@ -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/tagsAfter 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).@@@ -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",@@@ -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) || [];}@@@ -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"},@@@ -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 registryOpenVibe 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.```jsconst offer = await exchange.seller.offer({@@@ -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 WebCryptoonly: `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).@@@ -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 coresis 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@@@ -1,7 +1,7 @@# openvibe-sdk/redactThe 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.```jsconst { redact, onlySecret } = require('openvibe-sdk/redact');@@@ -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.nnew file mode 100644@@@ -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;ddeleted file mode 100644@@@ -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;@@@ -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": {@@@ -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": {@@@ -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',@@@ -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).@@@ -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,@@@ -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) || [];}nnew file mode 100644@@@ -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 };@@@ -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.fetchroles: (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 } : {}) }),++ },};}ddeleted file mode 100644@@@ -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 };@@@ -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@@@ -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 Windowsfor (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`);@@@ -12,7 +12,7 @@ const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('sasync 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' });}@@@ -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' });}],nnew file mode 100644@@@ -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');nnew file mode 100644@@@ -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++ }],++]);@@@ -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 } };});ddeleted file mode 100644@@@ -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-- }],--]);@@@ -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.@@@ -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",@@@ -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.@@@ -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;nnew file mode 100644@@@ -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 };@@@ -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). */ddeleted file mode 100644@@@ -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[] };@@@ -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;
@@ -1,5 +1,12 @@
@@ -1,5 +1,12 @@
# Changelog
# 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)
## 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)
- 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 @@
@@ -423,7 +430,7 @@
## 0.79.0 (2026-10-31)
## 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
- **`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.
the person and for the apps the person consented to.
- `createMachines({ networkUrl, token, fetch, timeoutMs })`: `token` returns the caller's own bearer token — the
- `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
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:
@@ -961,7 +968,7 @@ still passes a number:
## 0.40.1 (2026-10-10)
## 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.
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
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`
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
@@ -1398,7 +1405,7 @@ This release makes the move from better-sqlite3 mostly mechanical: call sites ke
## 0.17.0 (2026-09-28)
## 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.
- `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.
- `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`.
- `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`.
@@ -10,7 +10,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
@@ -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.
> 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.
**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.
**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
@@ -19,7 +19,7 @@ npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags
After v0.97.1 is tagged:
After v0.97.1 is tagged:
```bash
```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).
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).
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"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",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -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
// 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.
// (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 */
/* eslint-disable */
@@ -56,7 +56,7 @@ const { paginate, offsetPager } = __load(7);
@@ -56,7 +56,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -5762,13 +5762,13 @@ module.exports = { createProjectsClient };
@@ -5762,13 +5762,13 @@ module.exports = { createProjectsClient };
function (module, exports) {
function (module, exports) {
'use strict';
'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 geo = createGeoClient(createClient());
* const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
* 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
* 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
* 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
* 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
* 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
@@ -5782,7 +5782,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
/** The registry's nodes, optionally for one role or region. */
/** The registry's nodes, optionally for one role or region. */
async function nodes({ role, 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) || [];
return (r && r.nodes) || [];
}
}
@@ -807,6 +807,12 @@
@@ -807,6 +807,12 @@
"createValkeyLimitStore": "function",
"createValkeyLimitStore": "function",
"defaultActor": "function"
"defaultActor": "function"
},
},
+ "openvibe-sdk/link": {
+ "createLink": "function",
+ "linkCapabilities": "function",
+ "linkOffers": "function",
+ "validateLinkCapabilities": "function"
+ },
"openvibe-sdk/live": {
"openvibe-sdk/live": {
"createLiveClient": "function"
"createLiveClient": "function"
},
},
@@ -918,11 +924,6 @@
@@ -918,11 +924,6 @@
"noLooks": "function",
"noLooks": "function",
"noLooksTags": "function"
"noLooksTags": "function"
},
},
- "openvibe-sdk/node": {
- "nodeCapabilities": "function",
- "nodeOffers": "function",
- "validateNodeCapabilities": "function"
- },
"openvibe-sdk/notifications": {
"openvibe-sdk/notifications": {
"createNotificationsClient": "function"
"createNotificationsClient": "function"
},
},
@@ -62,7 +62,7 @@ adult gate. A request names one as `model: '@preset/<name>'`.
@@ -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
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
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
`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
```js
const offer = await exchange.seller.offer({
const offer = await exchange.seller.offer({
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
# Locker: end-to-end encryption, in the SDK
# 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
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
`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).
[docs/locker.md in Contracts](https://github.com/OpenVibers/OpenVibe.Contracts/blob/main/docs/locker.md).
@@ -24,7 +24,7 @@ const out = place(
@@ -24,7 +24,7 @@ const out = place(
priors: { 'app:codes/claude-code@opus': 0.82 },
priors: { 'app:codes/claude-code@opus': 0.82 },
outcomes, // the learned counts (below)
outcomes, // the learned counts (below)
rules: { noCloud: true, onlyFree: false, region: 'eu', prefer: 'cheapest' },
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.
@@ -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
**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
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
`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.
does not project it today.
## Learning from outcomes
## Learning from outcomes
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
# openvibe-sdk/redact
# openvibe-sdk/redact
The shared secret scrubber for memory (Memory M2). It is the JavaScript port of OpenVibe Link's
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
```js
const { redact, onlySecret } = require('openvibe-sdk/redact');
const { redact, onlySecret } = require('openvibe-sdk/redact');
@@ -1,4 +1,4 @@
@@ -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).
`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({
@@ -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`.
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
@@ -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 |
| `sharedWithMe(viewer, { service?, limit = 50, cursor? }?)` | `{ items, next_cursor }`; ids and access metadata only |
| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
| `expireMembers({ limit = 100 }?)` | number of resources swept, including expired transfers |
| `flush({ limit = 100 }?)` | number of durable deliveries sent |
| `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.
`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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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;
deleted file mode 100644
deleted file mode 100644
@@ -1,7 +0,0 @@
@@ -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;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.123.0",
+ "version": "0.124.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.123.0",
+ "version": "0.124.0",
"license": "MIT",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"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"
"pg": "^8.23.0"
},
},
"engines": {
"engines": {
@@ -198,9 +198,9 @@
@@ -198,9 +198,9 @@
"license": "MIT"
"license": "MIT"
},
},
"node_modules/openvibe-contracts": {
"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,
"dev": true,
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"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.",
"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",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -225,11 +225,11 @@
@@ -225,11 +225,11 @@
"import": "./esm/delivery.mjs",
"import": "./esm/delivery.mjs",
"default": "./src/delivery.js"
"default": "./src/delivery.js"
},
},
- "./node": {
- "types": "./types/node.d.ts",
+ "./link": {
+ "types": "./types/link.d.ts",
"browser": null,
"browser": null,
- "import": "./esm/node.mjs",
- "default": "./src/node.js"
+ "import": "./esm/link.mjs",
+ "default": "./src/link.js"
},
},
"./db": {
"./db": {
"types": "./types/db.d.ts",
"types": "./types/db.d.ts",
@@ -612,7 +612,7 @@
@@ -612,7 +612,7 @@
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
"iovalkey": "^0.4.0",
"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"
"pg": "^8.23.0"
},
},
"peerDependencies": {
"peerDependencies": {
@@ -59,7 +59,7 @@ const ENTRIES = {
@@ -59,7 +59,7 @@ const ENTRIES = {
'report.mjs': '../src/report.js',
'report.mjs': '../src/report.js',
'vibes.mjs': '../src/vibes.js',
'vibes.mjs': '../src/vibes.js',
'delivery.mjs': '../src/delivery.js',
'delivery.mjs': '../src/delivery.js',
- 'node.mjs': '../src/node.js',
+ 'link.mjs': '../src/link.js',
'db.mjs': '../src/db/index.js',
'db.mjs': '../src/db/index.js',
'valkey.mjs': '../src/valkey.js',
'valkey.mjs': '../src/valkey.js',
'cache.mjs': '../src/cache.js',
'cache.mjs': '../src/cache.js',
@@ -77,7 +77,7 @@ async function verifyUserToken(token, opts = {}) {
@@ -77,7 +77,7 @@ async function verifyUserToken(token, opts = {}) {
const claims = await verifyJwt(token, opts, 'user');
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.
// 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.
// 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');
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
// 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).
// issuer and audience say (OpenVibe.Events' rule, now every service's).
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** 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 = {
module.exports = {
createClient,
createClient,
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
'use strict';
'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 geo = createGeoClient(createClient());
* const { node, rtt_ms } = await geo.nearest({ role: 'edge-probe' }); // measured from here
* 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
* 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
* 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
* 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
* 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
@@ -20,7 +20,7 @@ function createGeoClient(client, { baseUrl, fetch: fetchImpl = null, samples = 3
/** The registry's nodes, optionally for one role or region. */
/** The registry's nodes, optionally for one role or region. */
async function nodes({ role, 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) || [];
return (r && r.nodes) || [];
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -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 };
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* openvibe-sdk/machines — the network's machines hub (plan L34, step M2; Contracts 0.258.0).
* 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
* 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)
* // createMachines from the openvibe-sdk/machines entry (CommonJS or ESM)
* const machines = createMachines({ networkUrl: 'https://openvibe.network', token: () => sessionToken });
* const machines = createMachines({ networkUrl: 'https://openvibe.network', token: () => sessionToken });
@@ -98,6 +98,12 @@ function createMachines({ networkUrl, token, fetch: fetchImpl = globalThis.fetch
@@ -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 : [] }),
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 }. */
/** The machine is no longer the caller's: { machine }. */
revoke: (id) => call('POST', `/api/v1/machines/${enc(id)}/revoke`, {}),
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 } : {}) }),
+ },
};
};
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,98 +0,0 @@
@@ -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 };
@@ -24,7 +24,7 @@
@@ -24,7 +24,7 @@
* sandbox traffic, so callers must only record billable production readings; `requireProject: true` makes
* sandbox traffic, so callers must only record billable production readings; `requireProject: true` makes
* record() refuse a reading whose `project` is not a `prj_…` id.
* 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 }) });
* billingUrl, tokenClient: createServiceTokenClient({ clientId: 'run', clientSecret }) });
* await reporter.ensureSchema(); // services put it in a migration instead
* await reporter.ensureSchema(); // services put it in a migration instead
* const reading = reporter.sample({ idempotency_key: reporter.key('job', jobId, n), operation: 'function.invoke',
* 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}$/;
@@ -105,7 +105,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
* @param {object} o
* @param {object} o
* @param {object} o.db openvibe-sdk/db handle (server); the outbox table lives in the same database
* @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.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.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 {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
* @param {{ getToken(ctx?: { audience?: string }): Promise<string> }} [o.tokenClient] openvibe-sdk/auth's
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
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
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']) {
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`);
assert.ok(rel.includes(must), `${must} is scanned`);
@@ -12,7 +12,7 @@ const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('s
@@ -12,7 +12,7 @@ const NODES = [node('oregon-1', 'us-west'), node('fra-1', 'eu-central'), node('s
async function registry() {
async function registry() {
return stubServer((req, res) => {
return stubServer((req, res) => {
const u = new URL(req.url, 'http://x');
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');
const role = u.searchParams.get('role');
return send(res, 200, { nodes: role === 'gpu' ? [] : NODES, generated_at: '2026-09-28T12:00:00Z' });
return send(res, 200, { nodes: role === 'gpu' ? [] : NODES, generated_at: '2026-09-28T12:00:00Z' });
}
}
@@ -62,10 +62,10 @@ run([
@@ -62,10 +62,10 @@ run([
await assert.rejects(verifyUserToken(svc, { jwks }), { code: 'token.not_user' });
await assert.rejects(verifyUserToken(svc, { jwks }), { code: 'token.not_user' });
assert.equal((await verifyUserToken(svc, { jwks, allowServiceTokens: true })).sub, 'svc:live');
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.
// 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, 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({ ...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' });
await assert.rejects(verifyUserToken(sign(claims(), good.privateKey), { jwks: { keys: [] } }), { code: 'token.no_key' });
}],
}],
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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
+ }],
+]);
@@ -25,6 +25,20 @@ async function hub(answer) {
@@ -25,6 +25,20 @@ async function hub(answer) {
}
}
run([
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 () => {
['the default timeout is ten seconds', async () => {
assert.equal(DEFAULT_TIMEOUT_MS, 10_000);
assert.equal(DEFAULT_TIMEOUT_MS, 10_000);
}],
}],
@@ -35,7 +49,7 @@ run([
@@ -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 === '/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('/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.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' } } };
if (req.method === 'PATCH') return { body: { machine: { ...MACHINE, name: 'Studio' } } };
return { body: { machine: MACHINE } };
return { body: { machine: MACHINE } };
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,81 +0,0 @@
@@ -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
- }],
-]);
@@ -35,14 +35,14 @@ function tokensStub() {
@@ -35,14 +35,14 @@ function tokensStub() {
}
}
const reading = (n) => usageSample({ id: `run:job_A:${n}`, idempotency_key: `run:job_A:${n}`, service: 'run',
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 } = {}) {
async function open({ statuses = [], table } = {}) {
const db = createDb({ pglite: true });
const db = createDb({ pglite: true });
const billing = billingStub(statuses);
const billing = billingStub(statuses);
const tokens = tokensStub();
const tokens = tokensStub();
const now = { t: 1_000_000 };
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,
billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
now: () => now.t, log: quiet });
now: () => now.t, log: quiet });
await reporter.ensureSchema();
await reporter.ensureSchema();
@@ -54,7 +54,7 @@ async function deliverOnce(status) {
@@ -54,7 +54,7 @@ async function deliverOnce(status) {
const db = createDb({ pglite: true });
const db = createDb({ pglite: true });
const billing = billingStub([status]);
const billing = billingStub([status]);
const tokens = tokensStub();
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,
billingUrl: 'http://billing.internal/', tokenClient: tokens.tokenClient, fetchImpl: billing.fetchImpl,
now: () => 1_000_000, log: quiet });
now: () => 1_000_000, log: quiet });
await reporter.ensureSchema();
await reporter.ensureSchema();
@@ -83,13 +83,13 @@ run([
@@ -83,13 +83,13 @@ run([
const s = reporter.sample({ idempotency_key: reporter.key('job', 'job_A', 0), operation: 'function.invoke',
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' });
quantity: 1, unit: 's', resource: 'job_A', at: '2026-10-02T10:00:00Z' });
assert.equal(s.service, 'run');
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.equal(s.id, s.idempotency_key, 'id defaults to the key');
assert.deepEqual(validateUsageSample(s), { ok: true, errors: [] });
assert.deepEqual(validateUsageSample(s), { ok: true, errors: [] });
const explicit = reporter.sample({ id: 'use-1', idempotency_key: 'k', service: 'someone-else', source: 'someone-else',
const explicit = reporter.sample({ id: 'use-1', idempotency_key: 'k', service: 'someone-else', source: 'someone-else',
operation: 'function.invoke', quantity: 1, unit: 's' });
operation: 'function.invoke', quantity: 1, unit: 's' });
assert.equal(explicit.service, 'run', 'the reporter service wins');
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');
assert.equal(explicit.id, 'use-1', 'an explicit id is kept');
} finally { await done(); }
} finally { await done(); }
}],
}],
@@ -158,7 +158,7 @@ run([
@@ -158,7 +158,7 @@ run([
['without a billingUrl or tokenClient the reporter still queues, but the relay is off', async () => {
['without a billingUrl or tokenClient the reporter still queues, but the relay is off', async () => {
const db = createDb({ pglite: true });
const db = createDb({ pglite: true });
try {
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);
assert.equal(reporter.enabled, false);
await reporter.ensureSchema();
await reporter.ensureSchema();
await assert.rejects(reporter.record(null, reading(0)), /transaction handle/);
await assert.rejects(reporter.record(null, reading(0)), /transaction handle/);
@@ -213,7 +213,7 @@ run([
@@ -213,7 +213,7 @@ run([
['requireProject refuses a reading without a production project, and accepts one with it', async () => {
['requireProject refuses a reading without a production project, and accepts one with it', async () => {
const db = createDb({ pglite: true });
const db = createDb({ pglite: true });
try {
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 reporter.ensureSchema();
await assert.rejects(db.tx((t) => reporter.record(t, reading(0))), /production project/, 'a first-party reading (no project) is refused');
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.
// A malformed project fails contracts validation first; either way the reading is refused.
@@ -1,8 +1,8 @@
@@ -1,8 +1,8 @@
{
{
"format": "openvibe-locker-link-send-v1",
"format": "openvibe-locker-link-send-v1",
"recipient": {
"recipient": {
- "type": "node",
- "id": "nod_01JAB2C3D4E5F6G7H8J9K0MNPQ"
+ "type": "link",
+ "id": "lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ"
},
},
"private_jwk": {
"private_jwk": {
"key_ops": [
"key_ops": [
@@ -16,7 +16,7 @@
@@ -16,7 +16,7 @@
"d": "cG9YgmeoPeaI8lXZRpijV5--2sC4rYWSqWbyGCYKPu0"
"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}",
"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": {
"expected": {
"request_id": "fr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"request_id": "fr_01JAB2C3D4E5F6G7H8J9K0MNPQ",
"origin": "https://example.org",
"origin": "https://example.org",
@@ -45,7 +45,7 @@ export type ServiceTokenClaims = {
@@ -45,7 +45,7 @@ export type ServiceTokenClaims = {
*/
*/
iss: string;
iss: string;
sub: string;
sub: string;
- actor_type: "service" | "app" | "mod" | "node" | "agent";
+ actor_type: "service" | "app" | "mod" | "link" | "agent";
/**
/**
* @minItems 1
* @minItems 1
*/
*/
@@ -112,7 +112,6 @@ export type ServiceTokenClaims = {
@@ -112,7 +112,6 @@ export type ServiceTokenClaims = {
};
};
[k: string]: unknown | undefined;
[k: string]: unknown | undefined;
};
};
-
/** [email protected] (owner: contracts) */
/** [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.
* 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 {
@@ -2228,12 +2227,22 @@ export interface Capability {
* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
* Current route(s) that perform this action today, e.g. 'POST /api/v1/:app/files'.
*/
*/
implementedBy?: string[];
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).
* 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;
sensitive?: boolean;
}
}
-
/** [email protected] (owner: events) */
/** [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.
* Durable platform event (OpenVibe.Events, Wave 3). Producers write it to an outbox in the same transaction as the domain change.
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
import type { OpenVibeClient } from './core';
import type { OpenVibeClient } from './core';
-/** Contracts network.node@1. */
-export interface OpenVibeNode {
+/** Contracts network.link@1. */
+export interface OpenVibeLink {
id: string; name: string;
id: string; name: string;
roles: Array<'web' | 'app' | 'data' | 'media-worker' | 'ingest' | 'edge-probe' | 'edge-relay' | 'edge-cache' | 'gpu' | 'staging' | 'ci'>;
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 };
location: { region: string; country?: string; city?: string; lat?: number; lon?: number };
@@ -9,10 +9,10 @@ export interface OpenVibeNode {
@@ -9,10 +9,10 @@ export interface OpenVibeNode {
health: { status: 'up' | 'degraded' | 'down' | 'unknown'; checked_at: string };
health: { status: 'up' | 'degraded' | 'down' | 'unknown'; checked_at: string };
updated_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 {
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;
export function createGeoClient(client: OpenVibeClient, opts?: { baseUrl?: string; fetch?: typeof fetch; samples?: number; timeoutMs?: number; now?: () => number }): GeoClient;
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -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 };
@@ -12,7 +12,7 @@ export interface Machine {
@@ -12,7 +12,7 @@ export interface Machine {
roles: string[];
roles: string[];
/** What it does right now: its Codes jobs, its desks, its local models, its robots. */
/** 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 }>;
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;
principal_id?: string;
/** The name the owner gave it. */
/** The name the owner gave it. */
name?: string;
name?: string;
@@ -66,6 +66,24 @@ export interface MachinesClient {
@@ -66,6 +66,24 @@ export interface MachinesClient {
roles(id: string, roles: string[]): Promise<{ machine: Machine }>;
roles(id: string, roles: string[]): Promise<{ machine: Machine }>;
/** The machine is no longer the caller's. */
/** The machine is no longer the caller's. */
revoke(id: string): Promise<{ machine: Machine }>;
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). */
/** A refusal: the hub's own status and code (err.status, err.code). */
deleted file mode 100644
deleted file mode 100644
@@ -1,20 +0,0 @@
@@ -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[] };
@@ -22,7 +22,7 @@ export type UsageReporterOptions = {
@@ -22,7 +22,7 @@ export type UsageReporterOptions = {
db: unknown;
db: unknown;
/** The Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'. */
/** The Contracts service id (services/<id>.json), e.g. 'run', 'tools', 'ai'. */
service: string;
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;
source: string;
/** The outbox table (default 'usage_outbox'); services create it in a migration. */
/** The outbox table (default 'usage_outbox'); services create it in a migration. */
table?: string;
table?: string;