openvibers/openvibe.network Commits

Add Link compute registry and principal migration (#710)

OP OpenVibers committed 4 days ago · 44a7295be3 · 79 files, +2201 −2086

* Add Link compute registry and principal migration

* Complete the Link rename in the registry, admin and test paths

Finish the module and route renames the earlier commit left half-done: registry-admin
and cells load link-principals, link-principals mounts ../link/egress, the internal
and admin routes are /link-pairings and /link-principals, the machine capabilities
validate platform.link-capabilities@1, and the resource OVRN type is link.

Tests follow: the principal actor_type and the node token are link, the applied
migration list includes 0062, the account hub sends /machines to openvibe.link, the
rehearsal seed carries pre-rename ids and asserts 0062 rewrites them, and the
chat.preferences event reports its current schema version.

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

* Pin openvibe-contracts v0.372.0 on the merged Link branch

Merges main and moves the Contracts pin to the released Link tag.

Changes

ddiff --git a/README.md b/README.md
iindex 1c7fdfae..8ce2dd14 100644
---- a/README.md
++++ b/README.md
@@@ -83,9 +83,9 @@ person's username and picture (a service token, `identity.subject.resolve`).
developer projects, apps, credentials, grants and quotas (ADR-014), mod principals
- notifications and email, themes and theme preferences, user modules, platform blocks, the follow
graph (ADR-030), creator analytics (ADR-021), the OpenCoins wallet
--- node principals: a person's paired machine (`nod_…`) with a one-time pairing code, a scoped credential and a
++- node principals: a person's paired machine (`lnk_…`) with a one-time pairing code, a scoped credential and a
token; a revoke — by the pairing service, its owner, or an account deletion — stops the machine and is announced
-- as `network.node.revoked@1`
++ as `network.link.revoked@1`
- the machines hub (`/api/v1/machines`, plan L34): a person's machines, their roles and what they do right now.
Bot stays the registry underneath — Network calls it as the person, so Bot checks the machine is that person's
own — and Network publishes what changes (`network.machine.online`, `.offline`, `.paired`, `.roles_changed`,
@@@ -195,7 +195,7 @@ To rotate a service principal, load **Network's** environment before the script
sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate <id> --write-env /etc/openvibe/<id>.env
```
--The script requires the database named `ov_network` (or an explicit `--database-name <name>` override). Do not use `--env-file` after the script name: Node loads it as environment and can select the target service's database.
++The script requires the database named `ov_network` (or an explicit `--database-name <name>` override). Do not use `--env-file` after the script name: Link loads it as environment and can select the target service's database.
---
ddiff --git a/STATUS.json b/STATUS.json
iindex aaa74294..e5b9747c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -44,7 +44,7 @@
"themes and preferences, user modules, platform blocks, follow graph, creator analytics",
"OpenCoins wallet",
"service registry, public discovery and the status page with incidents",
-- "node principals: pairing a person's machine, scoped service and owner revoke, and network.node.revoked@1 for instant revocation",
++ "node principals: pairing a person's machine, scoped service and owner revoke, and network.link.revoked@1 for instant revocation",
"operator alerts relay target and admin panel",
"serves /shared/* from its pinned openvibe-shared",
"per-actor limits (openvibe-sdk/limits)",
ddiff --git a/deploy/nginx/openvibe.network.conf b/deploy/nginx/openvibe.network.conf
iindex f92e71bc..9259c0e1 100644
---- a/deploy/nginx/openvibe.network.conf
++++ b/deploy/nginx/openvibe.network.conf
@@@ -143,7 +143,7 @@ server {
}
# ── Link Send from a paired machine: one encrypted file per POST (up to 20 MB before base64) ──
-- location = /api/v1/node/self/send {
++ location = /api/v1/link/self/send {
limit_req zone=openvibenetwork_api burst=120 nodelay;
client_max_body_size 40m;
proxy_request_buffering off;
ddiff --git a/deploy/nginx/parked-domains.conf b/deploy/nginx/parked-domains.conf
iindex ff8cfc7e..c3c6d3da 100644
---- a/deploy/nginx/parked-domains.conf
++++ b/deploy/nginx/parked-domains.conf
@@@ -144,7 +144,7 @@ server {
}
# openvibe.link: OpenVibe Link's home since 2026-11-07, served by OpenVibe.Network on 127.0.0.1:4000, which
--# renders the Link page when the Host is openvibe.link. The old name node.openvibe.network 301s there from the
++# renders the Link page when the Host is openvibe.link. The old name openvibe.link 301s there from the
# app (except /install, which keeps serving the installer); www.openvibe.link is not a second home and 301s to
# the apex here, and the app does the same for a request that reaches it on the www host.
#
ddiff --git a/docs/ai-offers.md b/docs/ai-offers.md
iindex 441130e1..fb98a959 100644
---- a/docs/ai-offers.md
++++ b/docs/ai-offers.md
@@@ -1,6 +1,6 @@
# Community AI offers in Network
--OpenVibe.Node's `ai` plugin can offer a person's local models to the network ("Offer to the network"). Bot reports each
++OpenVibe.Link's `ai` plugin can offer a person's local models to the network ("Offer to the network"). Bot reports each
machine's offer, Network validates and stores it, and OpenVibe.AI reads it to place only opted-in requests.
## Why its own table
ddiff --git a/docs/contracts-proposal/identity.service-token-claims.v1.json b/docs/contracts-proposal/identity.service-token-claims.v1.json
iindex f7e30f2c..8486b703 100644
---- a/docs/contracts-proposal/identity.service-token-claims.v1.json
++++ b/docs/contracts-proposal/identity.service-token-claims.v1.json
@@@ -26,7 +26,7 @@
},
"sub": {
"type": "string",
-- "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|node:nod_[0-9A-HJKMNP-TV-Z]{26}|agent:agt_[0-9A-HJKMNP-TV-Z]{26})$"
++ "pattern": "^(svc:[a-z][a-z0-9-]{1,39}|app:app_[0-9A-HJKMNP-TV-Z]{26}|mod:mod_[0-9A-HJKMNP-TV-Z]{26}|link:lnk_[0-9A-HJKMNP-TV-Z]{26}|agent:agt_[0-9A-HJKMNP-TV-Z]{26})$"
},
"actor_type": {
"enum": [
ddiff --git a/docs/cutover-t14-user-owned-trust.md b/docs/cutover-t14-user-owned-trust.md
iindex 1eaf211c..abd90772 100644
---- a/docs/cutover-t14-user-owned-trust.md
++++ b/docs/cutover-t14-user-owned-trust.md
@@@ -1,8 +1,8 @@
# Cutover runbook: the user-owned trust class (migration 0020)
--This PR lets the resource registry hold the trust class `user-owned`, a person's own Node (ADR-046, the universal
++This PR lets the resource registry hold the trust class `user-owned`, a person's own Link (ADR-046, the universal
adaptive fabric). The class has been in the `platform.resource-offer@1` enum since Contracts 0.87.0, and Network's pin
--has carried it since 0.90.0. Until now, though, PostgreSQL refused it, so a user's own Node could never be reported.
++has carried it since 0.90.0. Until now, though, PostgreSQL refused it, so a user's own Link could never be reported.
The data change is `migrations/0020_user_owned_trust.sql`. It drops and re-adds three CHECK constraints, each one
wider than before. No column is added, renamed or dropped, and no row is rewritten. The migration is `phase: expand`
and every drop is `IF EXISTS`, so it is safe to run on a live production database and safe to run twice.
@@@ -22,13 +22,13 @@ freeze window or snapshot import. The migration runs as part of the normal boot
Widening the owner rule is required too. Without it, a `user` principal could still be only `community`. An offer
must carry its node's trust class (`server/registry/cells.js` `checkPlacement`, `registry.trust_mismatch`), so a
--user-owned offer from a person's own Node would still be refused. The only thing the owner rule newly refuses is a
++user-owned offer from a person's own Link would still be refused. The only thing the owner rule newly refuses is a
`project` machine marked `user-owned`, and no such row can exist, because both trust CHECKs refused that value until
now. Every row in production therefore passes all three new constraints. Each ADD validates the table, which takes
milliseconds on a table this size.
The code is unchanged apart from a comment in `server/registry/offers.js` `check()`. The contract already accepts
--the value, and `columns()` copies `trust` through as it is. Pairing (`server/registry/node-principals.js` `redeem`)
++the value, and `columns()` copies `trust` through as it is. Pairing (`server/registry/link-principals.js` `redeem`)
still creates a paired user machine as `community`. Which machines become `user-owned`, and with what consent, are
ADR-046's open questions for the owner. Until a principal holds the class, no `user-owned` offer reaches the table.
@@@ -80,7 +80,7 @@ a transaction that it rolls back, and shows that a `stranger` class and a user-o
seed: test/fixtures/rehearsal/user-owned-trust.sql
migrations: migrations
node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const m=await c.query("SELECT id FROM ov_migrations WHERE id=$1",["0020"]);const k=await c.query("SELECT conname,pg_get_constraintdef(oid) AS d FROM pg_constraint WHERE conrelid IN (\x27platform_resource_offers\x27::regclass,\x27platform_node_principals\x27::regclass) AND conname=ANY($1) ORDER BY conname",[["platform_resource_offers_trust_check","platform_node_principals_trust_check","platform_node_principals_owner"]]);const p=await c.query("SELECT node_id||\x27=\x27||trust AS v FROM platform_node_principals WHERE node_id LIKE \x27rh-%\x27 ORDER BY node_id");const o=await c.query("SELECT id||\x27=\x27||trust AS v FROM platform_resource_offers WHERE id LIKE \x27rh-%\x27 ORDER BY id");await c.end();const rows=p.rows.map(r=>r.v).concat(o.rows.map(r=>r.v)).join(",");const want="rh-platform=first-party,rh-project=partner,rh-user=community,rh-platform=first-party,rh-project=partner,rh-provider=external,rh-user=community";if(m.rows.length!==1||k.rows.length!==3||!k.rows.every(r=>r.d.includes("user-owned"))||rows!==want){console.error("FAIL migration="+m.rows.length+" constraints="+JSON.stringify(k.rows)+" rows="+rows);process.exit(1);}console.log("rehearse: 0020 applied, "+k.rows.map(r=>r.conname).join(",")+" name user-owned, seed rows unchanged");})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
--node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const refused=async(sql,args)=>{await c.query("SAVEPOINT s");try{await c.query(sql,args);return false}catch(e){await c.query("ROLLBACK TO SAVEPOINT s");return e.code==="23514"}};const P="INSERT INTO platform_node_principals (id,node_id,home_cell,owner_kind,project_id,owner_subject,trust,created_by,credential_hash) VALUES ($1,$2,\x27wnam-1\x27,$3,$4,$5,$6,\x27rehearsal\x27,$7)";const usr="usr_0000000000000000000000RH01",prj="prj_0000000000000000000000RH01";await c.query("BEGIN");try{await c.query(P,["nod_0000000000000000000000RH04","rh-mine","user",null,usr,"user-owned","d".repeat(64)]);await c.query("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-mine\x27,\x27rh-mine\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27user-owned\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");const a=await refused(P,["nod_0000000000000000000000RH05","rh-stranger","user",null,usr,"stranger","e".repeat(64)]);const b=await refused(P,["nod_0000000000000000000000RH06","rh-prj-mine","project",prj,null,"user-owned","f".repeat(64)]);const d=await refused("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-odd\x27,\x27rh\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27stranger\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");if(!a||!b||!d){console.error("FAIL refused stranger-principal="+a+" project-user-owned="+b+" stranger-offer="+d);process.exitCode=1}else console.log("rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)")}finally{await c.query("ROLLBACK");await c.end()}})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
++node -e 'const{Client}=require("pg");(async()=>{const c=new Client({connectionString:process.env.REHEARSAL_DATABASE_URL});await c.connect();const refused=async(sql,args)=>{await c.query("SAVEPOINT s");try{await c.query(sql,args);return false}catch(e){await c.query("ROLLBACK TO SAVEPOINT s");return e.code==="23514"}};const P="INSERT INTO platform_node_principals (id,node_id,home_cell,owner_kind,project_id,owner_subject,trust,created_by,credential_hash) VALUES ($1,$2,\x27wnam-1\x27,$3,$4,$5,$6,\x27rehearsal\x27,$7)";const usr="usr_0000000000000000000000RH01",prj="prj_0000000000000000000000RH01";await c.query("BEGIN");try{await c.query(P,["lnk_0000000000000000000000RH04","rh-mine","user",null,usr,"user-owned","d".repeat(64)]);await c.query("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-mine\x27,\x27rh-mine\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27user-owned\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");const a=await refused(P,["lnk_0000000000000000000000RH05","rh-stranger","user",null,usr,"stranger","e".repeat(64)]);const b=await refused(P,["lnk_0000000000000000000000RH06","rh-prj-mine","project",prj,null,"user-owned","f".repeat(64)]);const d=await refused("INSERT INTO platform_resource_offers (id,source,kind,region,cell,trust,status,doc,reported_at) VALUES (\x27rh-odd\x27,\x27rh\x27,\x27node\x27,\x27us-west\x27,\x27wnam-1\x27,\x27stranger\x27,\x27up\x27,\x27{}\x27,\x272026-10-04T00:00:00Z\x27)");if(!a||!b||!d){console.error("FAIL refused stranger-principal="+a+" project-user-owned="+b+" stranger-offer="+d);process.exitCode=1}else console.log("rehearse: user-owned principal and offer stored; stranger and a user-owned project machine refused (rolled back)")}finally{await c.query("ROLLBACK");await c.end()}})().catch(e=>{console.error("FAIL",e.code||e.message);process.exit(1)})'
```
**Go:** the rehearsal job ends with `rehearse: 0020 applied, platform_node_principals_owner,
@@@ -155,7 +155,7 @@ sudo node --env-file=/etc/openvibe/network.env -e '
```
Expected: three lines ending `names user-owned`, and `migration: 0020`. `GET https://openvibe.network/api/v1/offers`
--answers as before. It lists no `user-owned` offer until a Node holding that class reports one.
++answers as before. It lists no `user-owned` offer until a Link holding that class reports one.
---
@@@ -183,7 +183,7 @@ answers as before. It lists no `user-owned` offer until a Node holding that clas
AND owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$' AND trust = 'community'));
DELETE FROM ov_migrations WHERE id = '0020';
```
-- If any row holds `user-owned`, a person's Node is registered with that class, and narrowing the constraints would
++ If any row holds `user-owned`, a person's Link is registered with that class, and narrowing the constraints would
fail. Revoke or reclassify those principals first; that is an owner decision.
Restart the service after either rollback and confirm `health network` says `ready`.
ddiff --git a/docs/machine-pools.md b/docs/machine-pools.md
iindex d5220ada..bbfba170 100644
---- a/docs/machine-pools.md
++++ b/docs/machine-pools.md
@@@ -2,13 +2,13 @@
Several of one person's machines on the same local network can serve one model together through llama.cpp RPC:
`rpc-server` on the members and `llama-server --rpc` on the head (the pooled machines design, 2026-11-06). Network
--holds the pool record (`bot.machine-pool@1`, `server/registry/machine-pools.js`, migration 0045); Node runs the
++holds the pool record (`bot.machine-pool@1`, `server/registry/machine-pools.js`, migration 0045); Link runs the
engines and the private link; AI routes and verifies the pool's offer. Pools across the internet, pools of different
owners and group pools are not enabled.
## How a pool comes together
--1. **Each machine announces itself** with its own node token (`POST /api/v1/node/self/pools/announce`): a private
++1. **Each machine announces itself** with its own node token (`POST /api/v1/link/self/pools/announce`): a private
IPv4 address and prefix configured on one of its interfaces (RFC 1918 only), its link port, its free VRAM and RAM
and a short hardware name. Network takes the machine and its owner from the token and Bot, and the public address
from the connection. A claimed owner, machine id or public address in the body is never trusted.
@@@ -16,25 +16,25 @@ owners and group pools are not enabled.
last five minutes, grouped by local network (one public address and one private subnet); the page gets an opaque
network key, never an address or a public IP. Two to eight machines; the default head has the most free VRAM, and
the owner can pick another.
--3. **Each machine approves it on itself.** Node's local approve step shows the pool, asks the person at that machine,
++3. **Each machine approves it on itself.** Link's local approve step shows the pool, asks the person at that machine,
generates an Ed25519 key there, and uploads only its self-signed public certificate
-- (`POST /api/v1/node/self/pools/:id/approve`). An owner session, an app or a service cannot approve for a machine,
++ (`POST /api/v1/link/self/pools/:id/approve`). An owner session, an app or a service cannot approve for a machine,
and one machine cannot approve for another. Every machine needs its own key; a private key is refused.
--4. **Node measures each link** before serving (round trip under 5 ms; 1 Gb/s for models over 30B, 300 Mb/s below)
++4. **Link measures each link** before serving (round trip under 5 ms; 1 Gb/s for models over 30B, 300 Mb/s below)
and runs the engines.
A pool is never edited. Changing the head, the members or a key means a new pool and new approvals.
## While it serves
--- Every member renews its readiness every two seconds (`POST /api/v1/node/self/pools/:id/status` with `{ ready }`);
++- Every member renews its readiness every two seconds (`POST /api/v1/link/self/pools/:id/status` with `{ ready }`);
the answer carries the pool, so the same request rechecks membership. The pool's offer is ready only while every
member has approved, renewed in the last ten seconds, and is still an active node of the owner.
--- Idle-only and the kill switch hold across the pool: Node pauses the whole pool when any member is in use or
++- Idle-only and the kill switch hold across the pool: Link pauses the whole pool when any member is in use or
stopped, and a member that is paused reports `ready: false`.
- Every read rechecks each member against Bot. A member removed from Bot, paired to a different owner or revoked, a
-- member leaving (`POST /api/v1/node/self/pools/:id/leave`) or the owner pressing Stop pool
-- (`POST /api/v1/machine-pools/:id/revoke`) revokes the whole pool and forgets its public certificates. Each Node
++ member leaving (`POST /api/v1/link/self/pools/:id/leave`) or the owner pressing Stop pool
++ (`POST /api/v1/machine-pools/:id/revoke`) revokes the whole pool and forgets its public certificates. Each Link
sees the revocation at its next renewal and drops its private link and its key.
## The AI offer
@@@ -55,12 +55,12 @@ machine's share of the pool's free VRAM, which is how its credits are split (a c
| `GET /api/v1/machine-pools` | owner session | → `{ pools, candidates }` |
| `POST /api/v1/machine-pools` | owner session | `{ machines, head? }` → 201 `{ pool }` |
| `POST /api/v1/machine-pools/:id/revoke` | owner session | → `{ revoked }` |
--| `POST /api/v1/node/self/pools/announce` | the machine | `{ address, port, free_vram, free_ram, hardware }` → `{ machine }` |
--| `GET /api/v1/node/self/pools/:id` | a member | → `{ pool }` (`bot.machine-pool@1`) |
--| `POST /api/v1/node/self/pools/:id/approve` | a member | `{ certificate }` → `{ pool }` |
--| `POST /api/v1/node/self/pools/:id/status` | an approved member | `{ ready }` → `{ ok, pool }` |
--| `POST /api/v1/node/self/pools/:id/leave` | a member | → `{ revoked }` |
++| `POST /api/v1/link/self/pools/announce` | the machine | `{ address, port, free_vram, free_ram, hardware }` → `{ machine }` |
++| `GET /api/v1/link/self/pools/:id` | a member | → `{ pool }` (`bot.machine-pool@1`) |
++| `POST /api/v1/link/self/pools/:id/approve` | a member | `{ certificate }` → `{ pool }` |
++| `POST /api/v1/link/self/pools/:id/status` | an approved member | `{ ready }` → `{ ok, pool }` |
++| `POST /api/v1/link/self/pools/:id/leave` | a member | → `{ revoked }` |
--The machine routes use the existing `network.node.self.manage` capability; each handler then checks the live node
++The machine routes use the existing `network.link.self.manage` capability; each handler then checks the live node
principal and its exact membership. Migration 0045 is expand-only. `test/machine-pools.test.js` runs on the test
runner's isolated, migrated PGlite store.
ddiff --git a/docs/platform-plan.md b/docs/platform-plan.md
iindex 04b41141..71868ce4 100644
---- a/docs/platform-plan.md
++++ b/docs/platform-plan.md
@@@ -33,7 +33,7 @@ certificate (DNS-01 via Cloudflare) and its own nginx vhost.
All of these are **in OpenVibe.Shared, consumed as a pinned release tarball**
(`"openvibe-shared": "…/refs/tags/vX.Y.Z"` in each package.json), served to browsers from
--`https://openvibe.network/shared/*` and installed into the Node services that need the server-side
++`https://openvibe.network/shared/*` and installed into the Link services that need the server-side
helpers.
| System | Module | How a room uses it |
@@@ -124,7 +124,7 @@ cookies and CORS and buys nothing at this size. `my.openvibe.network` already re
## 2026-09-18 — shared systems shipped
--- **Shared browser modules** (`/shared/*`): `ov-icons.js` (animated icon family with progress and state), `island.js` (activity island docked beside the navbar brand, also `OpenVibeNavbar.activity`), `ui.js` (toast, confirm, alert), navbar launcher (sites + tool families + tool search from the catalog), linked brand segments (tool → OpenVibe → site apex), bell mounted by the navbar itself. Node: `openvibe-shared/seo`, `openvibe-shared/icons`. Contract: `docs/shared-contracts.md`.
++- **Shared browser modules** (`/shared/*`): `ov-icons.js` (animated icon family with progress and state), `island.js` (activity island docked beside the navbar brand, also `OpenVibeNavbar.activity`), `ui.js` (toast, confirm, alert), navbar launcher (sites + tool families + tool search from the catalog), linked brand segments (tool → OpenVibe → site apex), bell mounted by the navbar itself. Link: `openvibe-shared/seo`, `openvibe-shared/icons`. Contract: `docs/shared-contracts.md`.
- **Tool registry** (OpenVibe.Tools `apps/gateway/server/registry`): every tool and family with canonical / short / alias hosts; unknown `*.openvibe.tools` → 301 to the index, unknown custom domains → 404; satellite hosts that reach the gateway are streamed to the satellite with `X-OV-*` headers. `GET https://openvibe.tools/api/catalog.json`.
- **Owner-only domains**: `openvibe.network/admin` → Domains (API `/api/admin/domains`, public `/api/domains`). Custom domain flow: DNS → `deploy/scripts/add-custom-domain.sh <domain>` on the host → attach the host to a tool in admin. Tools picks it up within a minute.
- **Server-rendered pages**: openvibe.tools index, family pages, `/tool/<id>`, `/all-tools`, `/search`, sitemap, robots, `llms.txt`; openvibe.network home rendered from the same catalog plus `/llms.txt`.
ddiff --git a/docs/shared-contracts.md b/docs/shared-contracts.md
iindex 5e685677..b20d462f 100644
---- a/docs/shared-contracts.md
++++ b/docs/shared-contracts.md
@@@ -2,7 +2,7 @@
*2026-09-18. The shared browser modules live in OpenVibe.Shared, consumed as a pinned release
tarball, and are served from `https://openvibe.network/shared/<file>.js` (5-minute edge cache; a
--year for a `?v=` content-hash URL). Node helpers come from the same pinned release in each
++year for a `?v=` content-hash URL). Link helpers come from the same pinned release in each
service's `node_modules`. This file is the contract; the modules are the implementation.*
## 1. Tool catalog and domains
@@@ -105,7 +105,7 @@ Unknown names fall back to the OV mark. Reduced motion is honoured.
notice for status, maintenance and similar; it always sits directly under the navbar. Themed, stacked, accessible (`role=status|alert`), one
implementation for every site.
--## 6. SEO (`seo.js`, Node — `require('openvibe-shared/seo')`)
++## 6. SEO (`seo.js`, Link — `require('openvibe-shared/seo')`)
```js
const seo = require('openvibe-shared/seo');
ddiff --git a/docs/sign-in.md b/docs/sign-in.md
iindex 21118f3d..77ee2ccf 100644
---- a/docs/sign-in.md
++++ b/docs/sign-in.md
@@@ -11,7 +11,7 @@ OAuth 2.0 / OpenID Connect provider:
| User info | `https://openvibe.network/oauth/userinfo` |
| Keys | `https://openvibe.network/api/.well-known/jwks` |
--A Node site gets the whole client (routes, PKCE, cookies, offline verification) from `openvibe-sdk/sso`. Third-party
++A Link site gets the whole client (routes, PKCE, cookies, offline verification) from `openvibe-sdk/sso`. Third-party
apps register in the developer console ([developer-projects.md](developer-projects.md)).
## The account chooser
ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex d3767c8d..c1a4660e 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -4,15 +4,15 @@ Status: **N1–N7 merged** on `origin/main` `2ca00ed` (registry slice 5, the nod
operator writers; each slice's state is in §8). The cells layer of `docs/t2-resource-registry.md` §9 was already on
`origin/main` (`d6ed8c4`, PR #5: `migrations/0008_cells_and_node_principals.sql`, `server/registry/cells.js`,
`test/cells-registry.test.js`); this document designed the rest of lane B on top of it and re-planned registry slice 5.
--Pinned: Network `openvibe-contracts` **v0.162.0** (`package.json:33`). v0.98.0 added `network.node.revoked@1`, the event
++Pinned: Network `openvibe-contracts` **v0.162.0** (`package.json:33`). v0.98.0 added `network.link.revoked@1`, the event
Network now emits on every node-principal revoke (§10), so a connected machine is stopped at once rather than at its next
reauth. v0.85.0 closed every contract gap in §2 and the §9.1 T1 brief (the doc was
written against v0.83.0, checked then against v0.84.0). OpenVibe.Bot
--`origin/main` `17ce069` (pins Contracts v0.79.0, `package.json:29`), OpenVibe.Node `origin/main` `4d2b8e1`. Bot is not
++`origin/main` `17ce069` (pins Contracts v0.79.0, `package.json:29`), OpenVibe.Link `origin/main` `4d2b8e1`. Bot is not
deployed (`ov access run openvibe-ovh releases bot` → `unknown service "bot"`), so no production device exists.
Scope (plan §3 T2, T14, T15): "cells now, hardware later" finished on today's single host, and "node identity is a
--Network principal": Network owns the node principal and its pairing credentials; Node presents capabilities; Bot keeps
++Network principal": Network owns the node principal and its pairing credentials; Link presents capabilities; Bot keeps
robots, the robot binding, profiles, operators, command leases and physical safety. Not in scope: a second physical cell,
WireGuard, geo routing, a scheduler, Run/Media/Actor bindings (they reuse what is built here).
@@@ -21,14 +21,14 @@ WireGuard, geo routing, a scheduler, Run/Media/Actor bindings (they reuse what i
| Piece | Where | Reused as |
|---|---|---|
| Cells, regions | `0008:9-26` (`platform_regions`, `platform_cells` with `residency`, `status` incl. `draining`, `route_weight`; seeds `us-west`, `wnam-1`) | the cell model, unchanged |
--| Node principal | `0008:28-49` `platform_node_principals` (`nod_<ULID>`, `node_id` unique, `home_cell`, `owner_kind platform\|project`, `trust`, `status active\|draining\|revoked`); owner rule `0008:42-44` | extended (§3), never duplicated |
++| Link principal | `0008:28-49` `platform_node_principals` (`lnk_<ULID>`, `node_id` unique, `home_cell`, `owner_kind platform\|project`, `trust`, `status active\|draining\|revoked`); owner rule `0008:42-44` | extended (§3), never duplicated |
| Service instances | `0008:51-66` `platform_service_instances`; FK `(node_id, cell)` → principal `(node_id, home_cell)` `0008:64` | gets its writer (§4.1) |
| `project.home_cell` | `0008:68` `dev_projects.home_cell` default `wnam-1` | unchanged |
| `resource.home_cell` | `0007:13` `platform_resource_offers.cell` + FK `0008:70` (NOT VALID) | **is** the resource home cell; no new column |
--| Node registry | `0001_initial.sql:651-657` `platform_nodes` (doc, status = health); `server/registry/nodes.js:19-41` report, adopt at `:27-28` | the **only** store of platform machine health; public list stays platform-only |
++| Link registry | `0001_initial.sql:651-657` `platform_nodes` (doc, status = health); `server/registry/nodes.js:19-41` report, adopt at `:27-28` | the **only** store of platform machine health; public list stays platform-only |
| Adoption, placement checks | `cells.js:23-29` (boot backfill), `:32-35` `cellForRegion`, `:49-58` `adoptPlatformNodes`, `:64-78` `checkPlacement` (called from `offers.js:144`) | reused by pairing and by the instance writer |
| Topology read | `cells.js:92-109` (`health: 'unknown'` for a principal with no `platform_nodes` row, `:98`) | gains `last_seen_at` and capabilities |
--| Guards | `principals.js:344-345` `guard()` throws `unknown capability` at boot; `index.js:466,473,480` mount nodes/offers/cells with `network.node.report` and `network.registry.read`; `DEFAULT_GRANTS` `principals.js:151` (host → `network.node.report`), `:197` (live → `network.registry.read`) | no capability is invented (§5) |
++| Guards | `principals.js:344-345` `guard()` throws `unknown capability` at boot; `index.js:466,473,480` mount nodes/offers/cells with `network.link.report` and `network.registry.read`; `DEFAULT_GRANTS` `principals.js:151` (host → `network.link.report`), `:197` (live → `network.registry.read`) | no capability is invented (§5) |
| Service tokens | `principals.js:303-325` `issueToken` (refuses a non-slug client at `:306`), called from `oauth-routes.js:230-237`; claims validated by `assertValid('identity.service-token-claims@1')` `:323` | a node branch beside it (§4.3) |
| Service OAuth clients | `database.js:85-106` `contractClients` seed (no `bot`); `server/setup/service-principal.js` (owner provisions a secret into `/etc/openvibe/<id>.env`) | `bot` added to the seed |
| Principal precedents | `mod:<mod_id>` (`identity/mod-principals.js:24`, contract `network.mod-principal@1`); `agt_` (designed, `docs/t2-projects-and-grants.md`) | same shape: a `<type>:<prefix>_<ULID>` sub |
@@@ -46,7 +46,7 @@ Bot today (`OpenVibe.Bot` `origin/main`), the thing being moved:
| Service-token subs accepted | `server/api/auth.js:15` `PRINCIPAL_SUB = /^(svc\|app\|mod):/` |
| Tests | `test/pairing.test.js:13,27,36,48,67` (once; 10 min expiry; 5 tries; never logged; rotate 60 s / revoke disconnects) |
--Node today (`OpenVibe.Node` `origin/main`): pairs with `POST <server>/api/v1/pair` (`internal/link/pair.go:47-48`),
++Link today (`OpenVibe.Link` `origin/main`): pairs with `POST <server>/api/v1/pair` (`internal/link/pair.go:47-48`),
`server` defaults to `https://openvibe.bot` (`internal/config/config.go:25`), stores `{device_id, credential,
publish_key, server, device_url}` mode 0600 (`internal/credentials/credentials.go:1,59-66`), and holds one outbound
WebSocket with the credential in the upgrade's `Authorization` header (`internal/link/link.go:1-2`; `docs/HANDOFF.md:40-42`).
@@@ -71,9 +71,9 @@ Every plan field and the one place it lives:
Rules that keep one source of truth:
--1. **`platform_nodes` is not replaced and not widened.** It stays what `network.node@1` says it is: "one machine of the
++1. **`platform_nodes` is not replaced and not widened.** It stays what `network.link@1` says it is: "one machine of the
OpenVibe platform", public. A paired (user or project) machine gets a principal and **no** `platform_nodes` row, so it
-- never appears in `GET /api/v1/nodes`. The principal table is the identity of every machine; `platform_nodes` is the
++ never appears in `GET /api/v1/links`. The principal table is the identity of every machine; `platform_nodes` is the
public health doc of platform machines only. Joined by `node_id`, as `cells.js:95-98` already does.
2. **Home cell, owner, trust are assigned by Network**, never by a report (`cells.js:7-10` boundary, kept): pairing sets
them from the pairing code (§4.2); a report that disagrees is refused (`checkPlacement`, and the instance writer).
@@@ -89,9 +89,9 @@ brief, §9.1:
|---|---|---|
| `platform.service-instance@1` (required `id service version cell node region endpoints state started_at`, `additionalProperties:false`) | the row's fields | optional `route_weight` (0–1000), so a read that carries the stored weight still validates |
| `platform.node-capabilities@1` (required `node_id cpu arch memory_mb storage network regions tags costs`; `costs.per_hour_usd` required) | hardware, regions, costs | optional `capabilities[]` (the resource-offer pattern `^[a-z][a-z0-9-]*:[a-z0-9.-]+$`, e.g. `robot:drive`, `video:whip`) so Bot binds to presented capabilities; optional `agent_version`; optional `updated_at` |
--| `network.node@1` (required `id roles location health updated_at`, `additionalProperties:false`) | public platform machine | optional `cell` (Network fills it on read from the principal; a report carrying a different one is refused `409 registry.node_cell_mismatch`) |
--| `identity.service-token-claims@1` (`sub` pattern `svc:\|app:\|mod:`, `actor_type` `service\|app\|mod`, lines 24-33) | — | node actor: `sub` gains `node:nod_<ULID>`, `actor_type` gains `node` (§4.3) |
--| `lib/ids.js` (`PREFIX` line 9, `SUBJECT_TYPES` line 13, `principalSub` lines 54-57) | — | `node: 'nod'`, `principalSub({type:'node'})` → `node:nod_…` (not a SubjectRef type: a node never authors content) |
++| `network.link@1` (required `id roles location health updated_at`, `additionalProperties:false`) | public platform machine | optional `cell` (Network fills it on read from the principal; a report carrying a different one is refused `409 registry.node_cell_mismatch`) |
++| `identity.service-token-claims@1` (`sub` pattern `svc:\|app:\|mod:`, `actor_type` `service\|app\|mod`, lines 24-33) | — | node actor: `sub` gains `link:lnk_<ULID>`, `actor_type` gains `node` (§4.3) |
++| `lib/ids.js` (`PREFIX` line 9, `SUBJECT_TYPES` line 13, `principalSub` lines 54-57) | — | `link: 'nod'`, `principalSub({type:'node'})` → `link:lnk_…` (not a SubjectRef type: a node never authors content) |
## 3. Migrations (exact columns)
@@@ -214,55 +214,55 @@ not `revoked`, whose `home_cell` = `cell` → `409 registry.node_cell_mismatch`
by `id` (`route_weight` untouched on update), and every instance of the same `source` absent from the report and not
already `stopped` is set to `stopped` (never deleted). `endpoints` is stored as JSON text (`0008:57`).
--Route: `POST /internal/registry/instances/report`, guard `network.node.report` (Host reports machines **and** what runs
++Route: `POST /internal/registry/instances/report`, guard `network.link.report` (Host reports machines **and** what runs
on them; Host holds it, `principals.js:151`), mounted in `server/index.js` beside `cellRouters` (`:480-482`) as
`app.use('/internal/registry/instances', …)` **before** `app.use('/internal/registry', cellRouters.internal)`. Header
`X-Instances-Stopped`. Read: the existing topology (`cells.js:100-103`) plus `region` from the cell. No public route:
instances carry endpoints.
--### 4.2 Pairing — `server/registry/node-principals.js` (slices N4b, N4c)
++### 4.2 Pairing — `server/registry/link-principals.js` (slices N4b, N4c)
| Route | Auth | Behaviour |
|---|---|---|
--| `POST /internal/node-pairings` | service token, `network.node.manage` | body `{owner: {kind:'user', subject:'usr_…'}, ref, home_cell?}`; the requesting service is the token's `svc:<id>`. Refuses an unknown, banned or deleted user (`404 registry.unknown_owner`) and a `home_cell` that is not `active`. Deletes that service's unused codes for the same `ref` (Bot's rule, `domain/index.js:164`), inserts one, → `201 {pairing_id, code: 'XXXX-XXXX', expires_at}`; the code is shown once, only its sha256 stored. TTL 10 min. `owner.kind:'project'` → `501 registry.not_yet` in this slice (§8). |
--| `POST /api/v1/node-pairing` | none: the code is the credential; `rateLimit({windowMs: 60_000, max: 10})` on the route | body `{pairing?: 'pair_…', code, name?, region?}`. Same outcomes as Bot's redeem (`domain/index.js:180-205`): `422 registry.invalid_pairing_code` (shape), `403 registry.pairing_code_invalid\|_used\|_expired\|_locked`; with `pairing` a wrong code counts a try against that row, 5 kills it. In one `db.tx`: mark used, create the principal (`nod_<ULID>`; `node_id = 'n-' + ulid.toLowerCase()`, 28 chars, matches `network.node@1`'s id pattern; owner from the code; `trust` `community` for a user; `home_cell` = the code's, else `cellForRegion(region)` `cells.js:32`; `paired_by_service`/`pairing_ref` from the code; `created_by = 'pairing:<pair_id>'`), set `principal_id` on the code. → `201 {principal, node_id, home_cell, credential, token_endpoint: '<issuer>/oauth/token', paired_for: {service, ref}}`; `credential` shown once, `Cache-Control: no-store`, never logged. |
--| `GET /internal/node-principals/:id` | `network.node.manage` | only a principal whose `paired_by_service` is the caller (else `404 registry.unknown_node`, never 403, so a service cannot probe others) → `{principal, node_id, name, owner, home_cell, status, paired_for, last_seen_at, created_at, revoked_at}`. No hash ever. |
--| `POST /internal/node-principals/:id/revoke` | `network.node.manage`, same scoping | `status = 'revoked'`, `revoked_at`, `revoked_by = 'svc:<id>'`, previous hash cleared; idempotent. In the same transaction writes `network.node.revoked@1` to the outbox (actor the service; `reason` from the body when given). A second revoke changes nothing and emits nothing. |
++| `POST /internal/link-pairings` | service token, `network.link.manage` | body `{owner: {kind:'user', subject:'usr_…'}, ref, home_cell?}`; the requesting service is the token's `svc:<id>`. Refuses an unknown, banned or deleted user (`404 registry.unknown_owner`) and a `home_cell` that is not `active`. Deletes that service's unused codes for the same `ref` (Bot's rule, `domain/index.js:164`), inserts one, → `201 {pairing_id, code: 'XXXX-XXXX', expires_at}`; the code is shown once, only its sha256 stored. TTL 10 min. `owner.kind:'project'` → `501 registry.not_yet` in this slice (§8). |
++| `POST /api/v1/link-pairing` | none: the code is the credential; `rateLimit({windowMs: 60_000, max: 10})` on the route | body `{pairing?: 'pair_…', code, name?, region?}`. Same outcomes as Bot's redeem (`domain/index.js:180-205`): `422 registry.invalid_pairing_code` (shape), `403 registry.pairing_code_invalid\|_used\|_expired\|_locked`; with `pairing` a wrong code counts a try against that row, 5 kills it. In one `db.tx`: mark used, create the principal (`lnk_<ULID>`; `node_id = 'n-' + ulid.toLowerCase()`, 28 chars, matches `network.link@1`'s id pattern; owner from the code; `trust` `community` for a user; `home_cell` = the code's, else `cellForRegion(region)` `cells.js:32`; `paired_by_service`/`pairing_ref` from the code; `created_by = 'pairing:<pair_id>'`), set `principal_id` on the code. → `201 {principal, node_id, home_cell, credential, token_endpoint: '<issuer>/oauth/token', paired_for: {service, ref}}`; `credential` shown once, `Cache-Control: no-store`, never logged. |
++| `GET /internal/link-principals/:id` | `network.link.manage` | only a principal whose `paired_by_service` is the caller (else `404 registry.unknown_node`, never 403, so a service cannot probe others) → `{principal, node_id, name, owner, home_cell, status, paired_for, last_seen_at, created_at, revoked_at}`. No hash ever. |
++| `POST /internal/link-principals/:id/revoke` | `network.link.manage`, same scoping | `status = 'revoked'`, `revoked_at`, `revoked_by = 'svc:<id>'`, previous hash cleared; idempotent. In the same transaction writes `network.link.revoked@1` to the outbox (actor the service; `reason` from the body when given). A second revoke changes nothing and emits nothing. |
| `GET /api/v1/me/nodes`, `POST /api/v1/me/nodes/:principal/revoke` | session (`requireAuth`), the owner only (slice N5) | the person's own machines (`owner_subject = me`): the same view; revoke as above (same event, actor the owner) with `revoked_by = usr_…`. |
--### 4.3 Node tokens and self routes (slice N4c)
++### 4.3 Link tokens and self routes (slice N4c)
--**Token.** `POST /oauth/token` `grant_type=client_credentials` with `client_id = nod_…`, `client_secret = credential`,
--`audience`. Branched in `oauth-routes.js:230` **before** `principals.issueToken` (`if (/^nod_/.test(client_id)) return
++**Token.** `POST /oauth/token` `grant_type=client_credentials` with `client_id = lnk_…`, `client_secret = credential`,
++`audience`. Branched in `oauth-routes.js:230` **before** `principals.issueToken` (`if (/^lnk_/.test(client_id)) return
nodePrincipals.issueNodeToken(…)`), so `principals.js:306` stays as it is. One `401 invalid_client` for every refusal
(unknown, revoked, wrong or expired-previous secret). The audience must be `openvibe.network` or
`openvibe.<paired_by_service>` (`400 invalid_scope` otherwise): a Bot-paired machine can talk to Network and Bot only.
| Claim | Value |
|---|---|
--| `sub`, `actor_type` | `node:nod_<ULID>`, `node` |
++| `sub`, `actor_type` | `link:lnk_<ULID>`, `node` |
| `aud` | `[audience]` |
--| `cap` | `openvibe.network` → `['network.node.self.manage']`; any other audience → `[]` (the receiving service authorises a node by its own binding, not by a capability) |
++| `cap` | `openvibe.network` → `['network.link.self.manage']`; any other audience → `[]` (the receiving service authorises a node by its own binding, not by a capability) |
| `project_id` | the owning project, when `owner_kind = 'project'` |
| `iat`, `exp`, `jti` | 300 s (`TOKEN_TTL_S`), `tok_` + 12 random bytes hex, as `principals.js:321` |
Validated with `assertValid('identity.service-token-claims@1')` like every token (`principals.js:323`). Issuance sets
`last_seen_at` when it is older than 60 s — the health of a paired machine.
--**Self routes** (guard `principals.guard('network.node.self.manage')`, no option). The guard checks the signature,
++**Self routes** (guard `principals.guard('network.link.self.manage')`, no option). The guard checks the signature,
audience and capability only: it has no node-ownership option (`principals.js:344` takes `ownApp` and `namespace`, and
`ownApp` compares against `sub` stripped of `svc:`, `:391-393`), and nothing enforces a manifest's
`resourceConstraints: ['owner']`. So each self handler does the ownership check itself, before any write: take the
--verified `req.principal.sub`, require `/^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/` (else `403 capability.owner_denied`),
++verified `req.principal.sub`, require `/^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/` (else `403 capability.owner_denied`),
load that `node_principals` row and require `status = 'active'` (else `401 registry.node_revoked`, since a token can
outlive a revoke by ≤ 300 s); the row's `node_id` and `id` are the only ones the handler then writes. The node is never
taken from the body or the path. Tested with a valid token for another node and a revoked node.
--- `PUT /api/v1/node/self/capabilities` — body `platform.node-capabilities@1`; its `node_id` must equal the resolved
++- `PUT /api/v1/link/self/capabilities` — body `platform.node-capabilities@1`; its `node_id` must equal the resolved
row's `node_id` (`409 registry.node_mismatch`); upserts `platform_node_capabilities`. `regions` is what the machine can serve, never
its home cell.
--- `POST /api/v1/node/self/credential` — rotation by the machine itself: new credential (once), the old one valid 60 s
-- (`credential_prev_hash`, `prev_valid_until`). The long-lived secret only ever travels between Network and the Node;
++- `POST /api/v1/link/self/credential` — rotation by the machine itself: new credential (once), the old one valid 60 s
++ (`credential_prev_hash`, `prev_valid_until`). The long-lived secret only ever travels between Network and the Link;
no service relays it.
Topology (`cells.js:96-99`) gains `last_seen_at`, `name`, `paired_for` and the `capabilities` doc per node; a principal
@@@ -272,22 +272,22 @@ without a `platform_nodes` row reports `health: 'up'` when `last_seen_at` is wit
| Holder | Holds | Never holds |
|---|---|---|
--| Network | the principal (`nod_`), owner, trust, home cell, status; pairing codes; credential hashes; node tokens; presented capabilities | robots, profiles, operators, leases, safety, publish keys |
++| Network | the principal (`lnk_`), owner, trust, home cell, status; pairing codes; credential hashes; node tokens; presented capabilities | robots, profiles, operators, leases, safety, publish keys |
| Bot | robot ↔ device binding (`devices.node_principal`), robots, profiles, operators, queue/leases, e-stop and limits, command audit, the WHIP publish key | any node credential (after B3), pairing codes (after B3) |
--| Node | its credential (file 0600), short-lived tokens, the publish key Bot gave it; presents `platform.node-capabilities@1`; enforces local safety (deadman, latch) | anything it did not present |
++| Link | its credential (file 0600), short-lived tokens, the publish key Bot gave it; presents `platform.node-capabilities@1`; enforces local safety (deadman, latch) | anything it did not present |
## 5. Capabilities — none invented
`principals.guard` throws at boot on an id the catalog lacks (`principals.js:345`). At v0.84.0 the catalog has
--`network.node.report` and `network.registry.read` and nothing for pairing; **no `bot.*` capability exists in the
++`network.link.report` and `network.registry.read` and nothing for pairing; **no `bot.*` capability exists in the
catalog at all** (Bot's `bot.device.connect` etc. are Bot-local strings, `server/api/v1.js:36`). Therefore:
| Capability | Status | Used by |
|---|---|---|
--| `network.node.report` | exists (`internal`, `active`) | Host's node report; also the instance report (N2) — T1 adds the route to its `implementedBy`, non-blocking |
++| `network.link.report` | exists (`internal`, `active`) | Host's node report; also the instance report (N2) — T1 adds the route to its `implementedBy`, non-blocking |
| `network.registry.read` | exists | the topology read |
--| `network.node.manage` | **T1 addition**, `internal`, `active`, `resourceConstraints: ['owner']` | N4b: pairing codes, scoped read and revoke; default grant `['bot', 'network.node.manage', SELF_AUDIENCE, []]` |
--| `network.node.self.manage` | **T1 addition**, `internal`, `active`, `resourceConstraints: ['owner']` | N4c: the node's own capabilities and credential |
++| `network.link.manage` | **T1 addition**, `internal`, `active`, `resourceConstraints: ['owner']` | N4b: pairing codes, scoped read and revoke; default grant `['bot', 'network.link.manage', SELF_AUDIENCE, []]` |
++| `network.link.self.manage` | **T1 addition**, `internal`, `active`, `resourceConstraints: ['owner']` | N4c: the node's own capabilities and credential |
| `network.resource.report` | **T1 addition** (already planned, `t2-resource-registry.md` §3) | slice 5: the one-line switch in `index.js:473` |
`resourceConstraints: ['owner']` is declarative: no guard enforces it (§4.3). N4b's handlers scope every read and
@@@ -336,7 +336,7 @@ All Network tests follow `test/cells-registry.test.js:1-30` (bare `express()`, g
secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js` (PGlite) and
`NETWORK_TEST_STORE=pg node test/run.js` (`npm run test:pg`, `test/run.js:18`).
--- **N2 `test/registry-instances.test.js`:** auth matrix (no token 403; token without `network.node.report` 403); a bad
++- **N2 `test/registry-instances.test.js`:** auth matrix (no token 403; token without `network.link.report` 403); a bad
instance anywhere in the batch → 400 and zero rows; unknown node 400, node in another cell 409, revoked node 409,
retired cell 409, region ≠ cell region 400 — each with nothing written; upsert keeps a hand-set `route_weight`;
absent instance of the same source → `stopped`, other source untouched, `X-Instances-Stopped: 1`; topology lists it
@@@ -349,21 +349,21 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
a pairing row with both owners rejected; migration applied twice (`IF NOT EXISTS`).
- **N4b `test/node-pairing.test.js`:** Bot's five checks moved verbatim (`pairing.test.js:13,27,36,48,67`: once; 10 min;
5 tries; never logged and never in a read; revoke), plus: the code and credential appear in no log line and in no
-- `GET`; `GET /internal/node-principals/:id` by another service → 404; pairing creates no `platform_nodes` row and the
-- node is absent from `GET /api/v1/nodes`; home cell from `region`, else `wnam-1`; Host's report naming a paired
++ `GET`; `GET /internal/link-principals/:id` by another service → 404; pairing creates no `platform_nodes` row and the
++ node is absent from `GET /api/v1/links`; home cell from `region`, else `wnam-1`; Host's report naming a paired
`node_id` is refused `409 registry.node_not_platform` (`cells.js:43`).
- **N4c `test/node-tokens.test.js`:** credential → token whose claims validate as `identity.service-token-claims@1`
-- with `sub node:nod_…`; audience `openvibe.bot` OK, `openvibe.website` OK for any person's machine (OpenVibe Connect,
++ with `sub link:lnk_…`; audience `openvibe.bot` OK, `openvibe.website` OK for any person's machine (OpenVibe Connect,
CON-4, 2026-11-02; no capability), `openvibe.chat` 400; revoked / wrong secret 401 with one message;
rotation: new works, old works < 60 s and fails after (clock injected), revoke kills both; capabilities PUT with
another `node_id` 409; node A's valid token cannot change node B's capabilities or credential (B's rows unchanged);
a revoked node's still-unexpired token on either self route 401 `registry.node_revoked`; a `svc:` token holding
-- `network.node.self.manage` 403 `capability.owner_denied`; topology shows `last_seen_at` and `health: 'up'`.
++ `network.link.self.manage` 403 `capability.owner_denied`; topology shows `last_seen_at` and `health: 'up'`.
- **N5 `test/node-principals-me.test.js`:** a person lists only their machines; revoking another's → 404.
- **N6 `test/resource-registry.test.js` (extend):** one valid offer per new kind (fixtures copied from Contracts'
`fixtures/platform.<kind>-offer/valid/`), each listed by `?kind=`; `kind: storage` without `detail` 400; `detail.id`
mismatch 400; `kind: node` with `detail` 400; harness `detail.address` absent from the public read, present
-- internally; test 8's lockstep mapping still holds; guard is `network.resource.report` and `network.node.report` alone
++ internally; test 8's lockstep mapping still holds; guard is `network.resource.report` and `network.link.report` alone
is refused.
## 8. Slices — ordered, each independently mergeable
@@@ -374,14 +374,14 @@ secrets set, tokens via `POST /oauth/token`), run with `node test/<file>.test.js
| N2 | Service-instance writer | `server/registry/instances.js` (new), `server/index.js` (mount), `test/registry-instances.test.js` | N1 | basic | **merged** |
| N3 | Project placement | `migrations/00NN_project_regions.sql`, `server/developer/store.js` (`setPlacement`, `projectView` + `home_cell`, `residency`, `preferred_regions`), `server/developer/routes.js` (`PUT /:project/placement`, audit `project.placement_changed`), test above | nothing | basic | **merged** (`0015_project_regions.sql`) |
| N4a | Pairing schema | `migrations/00NN_node_pairing.sql`, `test/cells-registry.test.js` | nothing | basic | **merged** (`0014_node_pairing.sql`) |
--| N4b | Pairing + scoped read/revoke | `server/registry/node-principals.js` (new), `server/index.js`, `server/identity/principals.js` (`DEFAULT_GRANTS` bot), `server/db/database.js` (seed `bot`), `test/node-pairing.test.js` | N4a; **Contracts T1 release** (`network.node.manage`) + Network pin bump to it | **Opus** (security boundary) | **merged** |
--| N4c | Node tokens + self routes + topology | `server/registry/node-principals.js`, `server/auth/oauth-routes.js:230`, `server/registry/cells.js` (topology), `test/node-tokens.test.js` | N4b; **T1** (node actor, `network.node.self.manage`) | **Opus** (token issuance) | **merged** (main `4e7b56b`, PR #19) |
--| N5 | Owner API for own machines | `server/registry/node-principals.js` (`userRouter`), `server/index.js` (`/api/v1/me/nodes`, `rateLimit` as `/api/v1/me/blocks` `:427`), `test/node-principals-me.test.js` | N4b | basic (page: For Opus) | **merged** (PR #18) |
++| N4b | Pairing + scoped read/revoke | `server/registry/link-principals.js` (new), `server/index.js`, `server/identity/principals.js` (`DEFAULT_GRANTS` bot), `server/db/database.js` (seed `bot`), `test/node-pairing.test.js` | N4a; **Contracts T1 release** (`network.link.manage`) + Network pin bump to it | **Opus** (security boundary) | **merged** |
++| N4c | Link tokens + self routes + topology | `server/registry/link-principals.js`, `server/auth/oauth-routes.js:230`, `server/registry/cells.js` (topology), `test/node-tokens.test.js` | N4b; **T1** (node actor, `network.link.self.manage`) | **Opus** (token issuance) | **merged** (main `4e7b56b`, PR #19) |
++| N5 | Owner API for own machines | `server/registry/link-principals.js` (`userRouter`), `server/index.js` (`/api/v1/me/nodes`, `rateLimit` as `/api/v1/me/blocks` `:427`), `test/node-principals-me.test.js` | N4b | basic (page: For Opus) | **merged** (PR #18) |
| N6 | Registry slice 5 (offer kinds as `detail`) | `package.json` pin, `migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`, `server/index.js:473`, `principals.js` grant, `test/resource-registry.test.js` | **T1 release** (§9.1 items 4-5) | basic | **merged** (pin is v0.85.0) |
--| N7 | Registry writers & operator reads | `server/registry/cells.js` (`cellView`, `setCell`, internal node-principals read), `server/registry/instances.js` (`setInstance`), `server/registry/nodes.js` (`GET /internal/nodes`), `server/index.js` (staff `/api/admin/registry`), tests | nothing (staff session; no new capability) | basic | **merged** (PR #27) |
--| N8 | Node-revoked event | `server/registry/node-principals.js` (emit in `revokeScoped`/`revokeRow`, `revokeOwnedBy`), `server/identity/account-data.js` (account deletion), tests | Contracts v0.98.0 (`network.node.revoked@1`) | basic | **in review (this PR)** |
++| N7 | Registry writers & operator reads | `server/registry/cells.js` (`cellView`, `setCell`, internal node-principals read), `server/registry/instances.js` (`setInstance`), `server/registry/nodes.js` (`GET /internal/links`), `server/index.js` (staff `/api/admin/registry`), tests | nothing (staff session; no new capability) | basic | **merged** (PR #27) |
++| N8 | Link-revoked event | `server/registry/link-principals.js` (emit in `revokeScoped`/`revokeRow`, `revokeOwnedBy`), `server/identity/account-data.js` (account deletion), tests | Contracts v0.98.0 (`network.link.revoked@1`) | basic | **in review (this PR)** |
--Bot (T15) and Node (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Network
++Bot (T15) and Link (T14) follow, each step keeping Bot working (§9.2, §9.3). Owner steps: none for the Network
slices (main is not deployed and the PostgreSQL cutover waits on the owner); at Bot's deploy the owner provisions Bot's
client secret (`sudo node --env-file=/etc/openvibe/network.env server/setup/service-principal.js rotate bot --write-env /etc/openvibe/bot.env`) and later
flips `BOT_PAIRING_AUTHORITY=network`.
@@@ -390,9 +390,9 @@ flips `BOT_PAIRING_AUTHORITY=network`.
### 9.1 Contracts (T1) — one minor release (0.85.0), additive only
--1. `lib/ids.js`: `PREFIX.node = 'nod'`; `principalSub({type:'node', id})` → `node:nod_<ULID>` (do **not** add `node`
++1. `lib/ids.js`: `PREFIX.node = 'nod'`; `principalSub({type:'node', id})` → `link:lnk_<ULID>` (do **not** add `node`
to `SUBJECT_TYPES` or `identity.subject-ref@1`: a node authors nothing). Test in `test/` beside the `agent` case.
--2. `contracts/identity/service-token-claims.v1.json`: `sub` pattern gains `|node:nod_[0-9A-HJKMNP-TV-Z]{26}`;
++2. `contracts/identity/service-token-claims.v1.json`: `sub` pattern gains `|link:lnk_[0-9A-HJKMNP-TV-Z]{26}`;
`actor_type` enum gains `node`; `lib/service-auth.js` `verifyServiceToken` accepts it (it validates against this
contract, line 48). Fixtures `valid/node-token.json`, `invalid/node-bad-id.json`. Coordinate with the WS-Z2 `agent`
addition (`docs/t2-projects-and-grants.md` §2) if both land in one release.
@@@ -400,19 +400,19 @@ flips `BOT_PAIRING_AUTHORITY=network`.
resourceConstraints quotaClass events`) set; `inputSchema`/`outputSchema` are omitted (both optional) because the
bodies are Network-local or several:
```json
-- { "id": "network.node.manage", "version": "1.0", "owner": "network", "status": "active", "visibility": "internal",
++ { "id": "network.link.manage", "version": "1.0", "owner": "network", "status": "active", "visibility": "internal",
"description": "A service mints one-time pairing codes for its users' machines and reads or revokes the node principals it paired. Never grantable to apps.",
"permissions": ["nodes:pair", "nodes:read", "nodes:revoke"], "resourceConstraints": ["owner"], "quotaClass": "admin",
"events": [],
-- "implementedBy": ["POST /internal/node-pairings", "GET /internal/node-principals/:id", "POST /internal/node-principals/:id/revoke"] }
++ "implementedBy": ["POST /internal/link-pairings", "GET /internal/link-principals/:id", "POST /internal/link-principals/:id/revoke"] }
```
```json
-- { "id": "network.node.self.manage", "version": "1.0", "owner": "network", "status": "active", "visibility": "internal",
++ { "id": "network.link.self.manage", "version": "1.0", "owner": "network", "status": "active", "visibility": "internal",
"description": "A paired machine presents its own capabilities and rotates its own credential, never another machine's.",
"permissions": ["nodes:self"], "resourceConstraints": ["owner"], "quotaClass": "admin", "events": [],
-- "implementedBy": ["PUT /api/v1/node/self/capabilities", "POST /api/v1/node/self/credential"] }
++ "implementedBy": ["PUT /api/v1/link/self/capabilities", "POST /api/v1/link/self/credential"] }
```
-- Add both ids to `manifests/services/network.json` `capabilities`. `network.node.report.json`: `implementedBy` gains
++ Add both ids to `manifests/services/network.json` `capabilities`. `network.link.report.json`: `implementedBy` gains
`POST /internal/registry/instances/report` (nothing else changes).
4. `manifests/capabilities/network.resource.report.json` (as planned in `t2-resource-registry.md` §3; the report
envelope `{source, offers}` is Network-local, `offers.js:34`, so no `inputSchema`; no `outputSchema`):
@@@ -442,16 +442,16 @@ flips `BOT_PAIRING_AUTHORITY=network`.
`server/api/auth.js:15` `PRINCIPAL_SUB` gains `node`. A node token is a Bearer that decodes as a JWT with
`actor_type: node` and `aud: openvibe.bot`, verified with `serviceAuth.verifyServiceToken`.
**Binding** — one domain function `devices.bindNode(principalId)`, idempotent, used by both entry points below: if
-- `devices WHERE node_principal = <nod_> AND revoked_at IS NULL` exists, return it. Else Bot calls Network
-- `GET /internal/node-principals/:id` (its `svc:bot` token, `network.node.manage`), requires `status active`,
++ `devices WHERE node_principal = <lnk_> AND revoked_at IS NULL` exists, return it. Else Bot calls Network
++ `GET /internal/link-principals/:id` (its `svc:bot` token, `network.link.manage`), requires `status active`,
`paired_for.service = 'bot'` and `paired_for.ref` a robot whose owner is the principal's owner (else 403
`bot.node_not_bound`), and inserts the device row from **Network's record plus safe initial values**: `robot_ids
[ref]`, `name` = the record's `name`, `kind 'onboard'`, `drivers []`, `capabilities {}`, `agent_version null`,
-- `credential_hash NULL`, `node_principal = <nod_>`. Nothing the device says is needed to create it.
++ `credential_hash NULL`, `node_principal = <lnk_>`. Nothing the device says is needed to create it.
**Bootstrap (HTTP, once after pairing)** — `POST /api/v1/devices/bind` (in `server/api/v1.js` beside `POST /pair`,
`:193`), node token required, no body: `bindNode`, then issue (or re-issue: replace `publish_key_hash`) the publish
key → `201 {device_id, publish_key, whip_url, robot_id, profile}`, exactly `POST /pair`'s answer (`v1.js:199`)
-- without `credential`, `Cache-Control: no-store`. Calling it again (a Node that lost its file) answers the same
++ without `credential`, `Cache-Control: no-store`. Calling it again (a Link that lost its file) answers the same
`device_id` and a new publish key; the old key stops working.
**Connect** — `/device` socket (`realtime.js:78-88`): a node-token Bearer runs `bindNode` (so a device that skipped
bootstrap still connects; it just has no publish key until it calls bind), `attachDevice`, then the existing
@@@ -464,15 +464,15 @@ flips `BOT_PAIRING_AUTHORITY=network`.
row unchanged). `docs/protocol.md` §status documents the three fields. Credential devices keep today's behaviour.
A node token expires after 300 s: the device sends `{"type":"reauth","token":…}` before expiry; no valid reauth
within 330 s of the last → close 4002. Bot's `POST /devices/:id/revoke` on a Network-paired device also calls
-- `POST /internal/node-principals/:id/revoke`, then closes the socket (instant, as today). Owner-forced
-- `POST /devices/:id/rotate` on such a device sends `{"type":"rotate"}` to the Node, which rotates with Network itself;
++ `POST /internal/link-principals/:id/revoke`, then closes the socket (instant, as today). Owner-forced
++ `POST /devices/:id/rotate` on such a device sends `{"type":"rotate"}` to the Link, which rotates with Network itself;
the answer carries no credential. Tests: `test/pairing.test.js` unchanged and green; a new
-- `test/node-principal.test.js` with a stub Network (JWKS + `/internal/node-principals/:id`): bind creates the row
++ `test/node-principal.test.js` with a stub Network (JWKS + `/internal/link-principals/:id`): bind creates the row
with the initial values and answers no credential; bind twice → same `device_id`, new publish key; upgrade with a
node token and no prior bind → `hello` then `config` arrive first; a `status` with `drivers`/`device_kind` updates
the row; a principal paired for another robot's owner, revoked, or `paired_for.service` ≠ `bot` → 403 and close 4002.
- **B2 (Network pairing behind a flag).** `BOT_PAIRING_AUTHORITY=bot|network` (default `bot`). With `network`,
-- `createPairingCode` (`domain/index.js:158`) calls `POST /internal/node-pairings {owner:{kind:'user',subject},
++ `createPairingCode` (`domain/index.js:158`) calls `POST /internal/link-pairings {owner:{kind:'user',subject},
ref: rob_…}` and stores no code; the installer command and QR carry `--network https://openvibe.network --pairing
pair_…`; Bot's `POST /pair` and the `pair` frame answer `410 bot.pairing_moved` with the Network URL. Tests cover both
flag values. Existing Bot-credential devices keep working throughout (dual-accept).
@@@ -481,14 +481,14 @@ flips `BOT_PAIRING_AUTHORITY=network`.
`byCredential`, `POST /pair`, the `pair` frame and the flag; `docs/protocol.md` §1 and §3 rewritten. No credential
import from Bot to Network ever: Bot is not deployed, so no live device needs one; any dev device re-pairs once.
--### 9.3 Node (T14) — short brief, after N4c and B1
++### 9.3 Link (T14) — short brief, after N4c and B1
Today `credentials.Save` refuses a file without `device_id` (`credentials.go:78`), `Load` refuses one too (`:145`),
--and the link ignores every frame before `hello` (`link.go:462`), so the Node can learn nothing from a `paired` frame
++and the link ignores every frame before `hello` (`link.go:462`), so the Link can learn nothing from a `paired` frame
on the v2 path; Bot's `device_id` and publish key come over HTTP instead (B1's `POST /api/v1/devices/bind`).
--- **Pair.** `openvibe-node pair --network <origin> [--pairing pair_…] CODE --server <bot>`: (1) redeem at
-- `POST <network>/api/v1/node-pairing` (the `pair.go:47-61` https/loopback rules kept for both origins) →
++- **Pair.** `openvibe-link pair --network <origin> [--pairing pair_…] CODE --server <bot>`: (1) redeem at
++ `POST <network>/api/v1/link-pairing` (the `pair.go:47-61` https/loopback rules kept for both origins) →
`{principal, node_id, credential, token_endpoint, paired_for}`; (2) save at once a v2 file (below) with
`device_id` empty, so a crash here never loses the one-time credential; (3) fetch a token (`POST token_endpoint`,
`client_credentials`, `client_id = principal`, `client_secret = credential`, `audience openvibe.bot`); (4)
@@@ -506,9 +506,9 @@ on the v2 path; Bot's `device_id` and publish key come over HTTP instead (B1's `
existing `CredentialRetryMin` wait and the "pair again" log (`link.go:243-275`).
- **Status.** The first `status` after `config` carries `device_kind`, `drivers` and `agent_version` (B1's optional
fields) from the configured plugins, and again when they change.
--- **Rotate.** On Bot's `{"type":"rotate"}`, `POST <network>/api/v1/node/self/credential` and swap `node_credential`
++- **Rotate.** On Bot's `{"type":"rotate"}`, `POST <network>/api/v1/link/self/credential` and swap `node_credential`
in the file atomically; the old one works 60 s.
--- **Capabilities.** After pairing and at each start, `PUT <network>/api/v1/node/self/capabilities` with
++- **Capabilities.** After pairing and at each start, `PUT <network>/api/v1/link/self/capabilities` with
`platform.node-capabilities@1` built from the plugins' `describe` (audience `openvibe.network` token).
- Without `--network` it pairs with Bot exactly as today. Tests: `credentials_test.go` round-trips v1 and v2 and
refuses v2 without `principal`; `link_test.go` with the `botfixture_test.go` fake plus a stub Network: pair → bind →
@@@ -518,7 +518,7 @@ on the v2 path; Bot's `device_id` and publish key come over HTTP instead (B1's `
## 10. Unresolved, and resolved since
- ~~**Revocation latency outside Bot**~~ — **resolved (N8)**: a revoke made on Network (the service route, the owner's
-- route, or an account deletion) now writes `network.node.revoked@1` to the outbox in the same transaction as the
++ route, or an account deletion) now writes `network.link.revoked@1` to the outbox in the same transaction as the
revoke (Contracts v0.98.0), so a service holding the machine's session stops it at once instead of at its next
`reauth` (≤ 330 s). A second revoke emits nothing; the payload carries the ids, the owner and the reason, never the
credential or its hash.
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex 522fc3c6..12531333 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -61,7 +61,7 @@ allowances changes.
**Gaps — not in v0.85.0, so the slices that needed them were blocked at that tag (section 8):**
--1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` / `node:` and `actor_type` only
++1. `identity.service-token-claims@1`: `sub` allows only `svc:` / `app:` / `mod:` / `link:` and `actor_type` only
`service | app | mod | node`. An agent token cannot validate. (v0.85.0 closed the same gap for the node actor of
`docs/t2-resource-registry.md` §9.)
2. No capability for a service to create or consume a confirmation (`network.*` at v0.85.0 has none; inventing one makes
ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex f1b3f568..4665ba7a 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -102,14 +102,14 @@ same source's absent ids as `nodes.js`, with the six duplicated columns taken fr
in §2. The response header is `X-Offers-Marked-Down`.
**Guard** — `principals.guard('network.resource.report')` protects the report and full internal reads. Host receives
--that capability by default. A token holding only `network.node.report` cannot access these routes.
++that capability by default. A token holding only `network.link.report` cannot access these routes.
**Validation — one pass over the whole batch, then one transaction for the writes.** The report has exactly two phases,
in this order:
1. **Validation pass, before any write.** Every offer in the array is validated, in order, with
`validate('platform.resource-offer@1', offer)`. The envelope is Network-local (as `nodes.js:54-57` validates a whole
-- body against `network.node-report-request@1`) because no `platform.resource-offer-report@1` exists. On the first bad
++ body against `network.link-report-request@1`) because no `platform.resource-offer-report@1` exists. On the first bad
offer the request ends there, with `400 {error: 'offer 3 does not match platform.resource-offer@1',
details: (v.errors || []).slice(0, 5)}`. No transaction has been opened, so a bad offer anywhere in the batch —
including the last of 500 — leaves the table byte-for-byte unchanged: no upsert, no mark-down, no partial batch.
@@@ -122,7 +122,7 @@ in this order:
whole body before its loop, because there is no per-node contract to check; here the per-item check moves into that
same up-front pass.
--Body `express.json({limit: '256kb'})`; array capped at 500, matching `network.node-report-request.v1.json`'s
++Body `express.json({limit: '256kb'})`; array capped at 500, matching `network.link-report-request.v1.json`'s
`maxItems`.
**Routing** — mounted in `server/index.js` beside the node routers: `app.use('/internal/resources', internal)` and
@@@ -177,7 +177,7 @@ code. The contract between the two is therefore exact:
§2, so `?max_price_usd=` filters exactly the price the planner would marginalise. Network never recomputes a price;
the SDK's `marginalCost` / `priceOf` do. `pricing.rate_card` points at a `platform.rate-card@1` id and is not
dereferenced here.
--- `capabilities[]` items are `namespace:name` strings (`node:http`, `events:gateway`, `object:r2`, …) — the contract's
++- `capabilities[]` items are `namespace:name` strings (`link:http`, `events:gateway`, `object:r2`, …) — the contract's
own pattern; they are stored, not interpreted.
### Worked example — `GET /api/v1/offers?kind=provider` → `plan()`
@@@ -253,7 +253,7 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
1. **Table + report (write path).** `migrations/0007_resource_registry.sql`, `server/registry/offers.js` (`ensureSchema`
no-op + `report`), the `ensureSchema` call in `server/db/database.js`, `app.use('/internal/resources', internal)` in
-- `server/index.js`, `test/resource-registry.test.js` tests 1-4. The original guard reused `network.node.report`.
++ `server/index.js`, `test/resource-registry.test.js` tests 1-4. The original guard reused `network.link.report`.
2. **Public read + filters.** `list()`, `get()`, `routers().pub` in `server/registry/offers.js`,
`app.use('/api/v1/offers', pub)`, tests 5-7. Also the full internal read (`GET /internal/resources[/:id]`, the
report's guard), because the public read leaves capacity out (§4).
@@@ -269,7 +269,7 @@ filter path — PGlite and a real PostgreSQL must agree on it — and `npm test`
## 8. Separate concerns
Route weight and service instances are outside `platform.resource-offer@1`; their contracts and registry paths are
--separate from resource offers. `network.node.report` remains the guard for node reports.
++separate from resource offers. `network.link.report` remains the guard for node reports.
## 9. Cells and node principals (lane B, first slice)
@@@ -280,9 +280,9 @@ Plan T2: "cells now, hardware later" and "node identity is a Network principal",
| Table | What it is | Constraints that carry the rule |
|---|---|---|
--| `platform_regions` | `us-west`, … (`network.node@1`'s region pattern) + country | id pattern |
++| `platform_regions` | `us-west`, … (`network.link@1`'s region pattern) + country | id pattern |
| `platform_cells` | `wnam-1`, … with region, residency, `status` (`planned\|active\|draining\|retired`), `route_weight` | region FK; the migration seeds `us-west` and `wnam-1` (`active`) |
--| `platform_node_principals` | one per machine: `nod_<ULID>`, `node_id` (unique), `home_cell`, owner, `trust`, `status` (`active\|draining\|revoked`) | `owner_kind = 'platform'` ⇔ no project and `first-party`; `owner_kind = 'project'` ⇔ a `dev_projects` row and never `first-party`; `revoked` ⇔ `revoked_at`; home cell FK |
++| `platform_node_principals` | one per machine: `lnk_<ULID>`, `node_id` (unique), `home_cell`, owner, `trust`, `status` (`active\|draining\|revoked`) | `owner_kind = 'platform'` ⇔ no project and `first-party`; `owner_kind = 'project'` ⇔ a `dev_projects` row and never `first-party`; `revoked` ⇔ `revoked_at`; home cell FK |
| `platform_service_instances` | `platform.service-instance@1`'s columns (`state` is its enum) + `route_weight`, `source` | `(node_id, cell)` FK to the principal's `(node_id, home_cell)`: an instance runs on a registered node, in its home cell |
| `dev_projects.home_cell` | `project.home_cell` | FK to cells; existing rows take `wnam-1` |
| `platform_resource_offers.cell` | (0007) | FK to cells, `NOT VALID`: new writes are checked, rows written before 0008 are not |
@@@ -292,7 +292,7 @@ Ownership reuses `dev_projects` (the project is the ownership boundary) and the
**The node-principal boundary.** A report never assigns identity, owner, trust class or home cell:
--- Host's node report (`POST /internal/nodes/report`, `network.node.report`) creates a platform-owned, first-party
++- Host's node report (`POST /internal/links/report`, `network.link.report`) creates a platform-owned, first-party
principal for a machine it is the first to name, in the first active cell of the machine's region, else `wnam-1`.
It is refused whole (`409 registry.node_not_platform` / `registry.node_revoked`, nothing written, nothing marked down)
when it names a machine a project owns or one that was revoked.
@@@ -302,7 +302,7 @@ Ownership reuses `dev_projects` (the project is the ownership boundary) and the
`409 registry.cell_retired`); an offer for a registered node must carry that node's home cell and trust class
(`409 registry.node_cell_mismatch`, `registry.trust_mismatch`, `registry.node_revoked`). Checked before anything is
written, like the contract validation.
--- A node principal holds no token yet: `identity.service-token-claims@1` has no node actor, and a `nod_` id is refused
++- A node principal holds no token yet: `identity.service-token-claims@1` has no node actor, and a `lnk_` id is refused
as a client-credentials client (`unauthorized_client`).
**Read API.** `GET /api/v1/cells` and `/api/v1/cells/:id` (public, `max-age=60`, `ACAO: *`; 404
ddiff --git a/migrations/0062_link_principal_ids.sql b/migrations/0062_link_principal_ids.sql
nnew file mode 100644
iindex 00000000..88d417d9
---- /dev/null
++++ b/migrations/0062_link_principal_ids.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- Move paired-machine principal IDs to lnk_ while preserving all referencing rows.
++-- Old table and column names remain for a later contract migration.
++DO $$
++DECLARE
++ fk record;
++ definition text;
++BEGIN
++ FOR fk IN
++ SELECT c.conrelid::regclass AS relation, c.conname, pg_get_constraintdef(c.oid) AS definition
++ FROM pg_constraint c
++ WHERE c.contype = 'f' AND c.confrelid = 'platform_node_principals'::regclass
++ LOOP
++ definition := regexp_replace(fk.definition, 'ON UPDATE (NO ACTION|RESTRICT|CASCADE|SET NULL|SET DEFAULT)', 'ON UPDATE CASCADE');
++ IF definition = fk.definition THEN
++ definition := definition || ' ON UPDATE CASCADE';
++ END IF;
++ EXECUTE format('ALTER TABLE %s DROP CONSTRAINT %I', fk.relation, fk.conname);
++ EXECUTE format('ALTER TABLE %s ADD CONSTRAINT %I %s', fk.relation, fk.conname, definition);
++ END LOOP;
++END $$;
++
++ALTER TABLE platform_node_principals DROP CONSTRAINT platform_node_principals_id_check;
++UPDATE platform_node_principals
++SET id = 'lnk_' || substring(id FROM 5)
++WHERE id ~ '^nod_[0-9A-HJKMNP-TV-Z]{26}$';
++ALTER TABLE platform_node_principals
++ ADD CONSTRAINT platform_node_principals_id_check CHECK (id ~ '^lnk_[0-9A-HJKMNP-TV-Z]{26}$');
ddiff --git a/package-lock.json b/package-lock.json
iindex 40bf964f..b2d58507 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -20,7 +20,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.172.0",
"pg": "^8.23.0",
@@@ -2303,9 +2303,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.352.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
-- "integrity": "sha512-TN02BrMU4jzXYZ0rjp5dmLbQI06h5tT6miISS6zS7E7/UFI+fGFidTVtQpZXEYG8gppGEo2UEtZ//hk9kkASyQ==",
++ "version": "0.372.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
++ "integrity": "sha512-HnfBnkhfMXYNbW4NnpTAMqZc0uV9d4XvwA20VaZXZFbBTPZiClYfoZ0/TWWWh24BFANx9TSzC+4jE0WDHKuNhA==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 4a42e2fa..7517484d 100644
---- a/package.json
++++ b/package.json
@@@ -24,7 +24,7 @@
"iovalkey": "^0.4.0",
"jsonwebtoken": "^9.0.0",
"multer": "^1.4.5-lts.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.352.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.372.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.172.0",
"pg": "^8.23.0",
ddiff --git a/server/auth/oauth-routes.js b/server/auth/oauth-routes.js
iindex 31fe4d09..2f85ed21 100644
---- a/server/auth/oauth-routes.js
++++ b/server/auth/oauth-routes.js
@@@ -403,9 +403,9 @@ router.post('/token', async (req, res) => {
// short-lived, capability-scoped token. Checked before the user-grant path below.
if (grant_type === 'client_credentials') {
// A paired machine (node principal, docs/t2-cells-and-node-principal.md section 4.3): its own credential store.
-- if (/^nod_/.test(client_id)) {
++ if (/^lnk_/.test(client_id)) {
if (req.body.agent !== undefined) return res.status(400).json({ error: 'invalid_grant', error_description: 'nodes host no agents' });
-- const out = await require('../registry/node-principals').issueNodeToken(db, {
++ const out = await require('../registry/link-principals').issueNodeToken(db, {
clientId: client_id, clientSecret: client_secret, audience: req.body.audience,
privateKey: req.app.locals.privateKey, issuer: config.jwt.issuer,
});
ddiff --git a/server/home/render.js b/server/home/render.js
iindex 4ce3fb46..ab5926f2 100644
---- a/server/home/render.js
++++ b/server/home/render.js
@@@ -67,7 +67,7 @@ const INTENTS = [
['codes', 'fa-code', 'Code with any agent', 'Claude Code, Codex, OpenCode or your own model on one task, with hand-offs.', 'https://openvibe.codes/', 'code coding agent agents ai claude codex opencode deepseek harness contribute'],
['ai', 'fa-robot', 'Compare AI models', 'Which AI does each job best, what it costs and how fast it is: the Bench results, on Report.', 'https://openvibe.report/ai', 'ai artificial intelligence benchmark benchmarks bench leaderboard comparison llm gpt claude gemini which-ai best-ai'],
['actor', 'fa-user-astronaut', 'Give a task to an agent', 'Actor picks the best agent for it, works on a desk you can watch and take over, and shows the cost and why.', 'https://openvibe.actor/', 'agent agents ai assistant task tasks automate automation router openrouter delegate'],
-- ['bot', 'fa-desktop', 'Put your computer to work', 'Desks for your agents, a spare laptop as a desk host, and the AI models you run (Ollama, LM Studio) for your own work.', 'https://openvibe.bot/machines', 'computer computers pc laptop desktop desk desks machine machines ollama lmstudio llama gpu homelab node'],
++ ['bot', 'fa-desktop', 'Put your computer to work', 'Desks for your agents, a spare laptop as a desk host, and the AI models you run (Ollama, LM Studio) for your own work.', 'https://openvibe.link/machines', 'computer computers pc laptop desktop desk desks machine machines ollama lmstudio llama gpu homelab node'],
['work', 'fa-briefcase', 'Find work', 'Jobs from open boards in one search, each linked to where you apply.', 'https://openvibe.work/', 'job jobs work career hiring remote employment apply listings'],
['food', 'fa-utensils', 'Eat well for less', 'Food near you, meal plans and recipes for your budget, and delivery apps compared.', 'https://openvibe.food/', 'food eat meal meals grocery groceries recipe recipes foodbank pantry delivery doordash ubereats grubhub instacart hellofresh'],
['quest', 'fa-ranking-star', 'Earn badges', 'Quests across the network for the things you already do there.', 'https://openvibe.quest/', 'quest quests badge badges achievement achievements rewards opencoins earn'],
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0d5dc1a7..1883c2e7 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -17,7 +17,7 @@ const cache = require('openvibe-shared/cache-policy');
* - history, username history, the sign-in IP log and email log;
* - project memberships; a project the person owns passes to another owner or admin, else it is archived and
* its apps revoked;
-- * - the person's paired node principals, revoked and announced as network.node.revoked@1.
++ * - the person's paired node principals, revoked and announced as network.link.revoked@1.
* The OpenCoins balance is closed with one ledger entry (account_deleted); ledger rows stay. The user row becomes
* a tombstone (username released, email, password and profile cleared, deleted_at), as do accounts merged into it;
* the subject is never reused and resolves as deleted. Its tokens are revoked strictly, and network.account.deleted
@@@ -397,8 +397,8 @@ async function erase(db, deletion, { now = Date.now() } = {}) {
await del('dev_project_members', `subject_id IN ${inS}`, subjects, 'project_memberships');
}
// The person's paired machines (plan T2): each is revoked with the account and announced as
-- // network.node.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
-- const nodePrincipals = require('../registry/node-principals');
++ // network.link.revoked@1 in this same transaction, so a service holding a machine's session stops it at once.
++ const nodePrincipals = require('../registry/link-principals');
count('nodes_revoked', await nodePrincipals.revokeOwnedBy(db, subjects, { actor: { type: 'user', id: subject }, now }));
if (await tableExists(db, 'account_merges')) await db.prepare(`UPDATE account_merges SET pre_state = NULL, reduced_at = COALESCE(reduced_at, ?) WHERE into_user_id IN ${inU} OR from_user_id IN ${inU}`).run(at, ...userIds, ...userIds);
// OpenCoins: loyalty (ADR-012). The balance closes with one ledger entry; the ledger stays.
ddiff --git a/server/identity/principals.js b/server/identity/principals.js
iindex 4120e3d1..c01a8f1e 100644
---- a/server/identity/principals.js
++++ b/server/identity/principals.js
@@@ -353,7 +353,7 @@ const DEFAULT_GRANTS = [
// WS-H task 11: Host relays the alerts firing on the production host (ovhost alerts relay).
['host', 'network.operator.alert', SELF_AUDIENCE, []],
// WS-X1: Host reports the platform's machines to the node registry (ovhost nodes report).
-- ['host', 'network.node.report', SELF_AUDIENCE, []],
++ ['host', 'network.link.report', SELF_AUDIENCE, []],
['host', 'network.resource.report', SELF_AUDIENCE, []],
// D11 S3 (OWNER-SETTINGS.md): Network's admin proxy reads and changes every service's configuration as the owner,
// on /internal/config. The nine internal <svc>.config.manage capabilities (Contracts 0.288.0) are held by Network
@@@ -383,7 +383,7 @@ const DEFAULT_GRANTS = [
// Host's demand planner reads AI's usage feed (Host #283): ai.usage.read on openvibe.ai.
['host', 'ai.usage.read', 'openvibe.ai', []],
// Plan T2 N4b: Bot pairs its users' machines with Network and reads or revokes only the principals it paired.
-- ['bot', 'network.node.manage', SELF_AUDIENCE, []],
++ ['bot', 'network.link.manage', SELF_AUDIENCE, []],
// Plan T15: an owner adds an operator by @username on the robot's panel; Bot resolves it to the subject here.
['bot', 'identity.subject.resolve', SELF_AUDIENCE, []],
// Plan T15: Bot runs each robot's OpenRestream stream for its owner (find/create/archive, key rotation), plays the live
@@@ -486,7 +486,7 @@ const DEFAULT_GRANTS = [
// (robots.agent_control); its gate, limits and e-stop still judge every step.
['actor', 'bot.robot.read', 'openvibe.bot', []],
['actor', 'bot.robot.control', 'openvibe.bot', []],
-- // Plan T14/T17 (contracts 0.166.0): the agent uses a person's own computer (OpenVibe.Node's computer plugin) through
++ // Plan T14/T17 (contracts 0.166.0): the agent uses a person's own computer (OpenVibe.Link's computer plugin) through
// Bot, only as that machine's owner (X-OV-Subject) and only where the owner turned on "Let my agent use this
// computer"; the level the owner set on the machine decides what may happen there.
['actor', 'bot.computer.control', 'openvibe.bot', []],
@@@ -553,7 +553,7 @@ const DEFAULT_GRANTS = [
// Wave 20: the Codes portal relays its release events.
['services', 'events.event.publish', 'openvibe.events', []],
['ai', 'events.event.publish', 'openvibe.events', []],
-- // AI on your own machines (contracts 0.219.0): a person's own model, on their own machine through its OpenVibe Node,
++ // AI on your own machines (contracts 0.219.0): a person's own model, on their own machine through its OpenVibe Link,
// for that person's runs only (Bot checks the owner and their switch, "Let my AI use this computer's models").
['ai', 'bot.machine.ai', 'openvibe.bot', []], // ai.run.* (AI server/events.js, 2026-09-24)
// Run sessions (contracts 0.223.0, the Desks plan's D6): Run leases one desk per session on OpenVibe.Bot, acting for
ddiff --git a/server/index.js b/server/index.js
iindex 15f62edc..a4a56681 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -236,9 +236,9 @@ function buildAllowedOriginsSet() {
'https://openvibe.network',
// Link's own front door (the owner, 2026-11-07): openvibe.link is served by Network, and its page asks the
// network's own API (the account's look, the navbar's frame, the shared font) from that origin. The old
-- // name node.openvibe.network stays here too: it 301s to openvibe.link, but a cached page may still ask.
++ // name openvibe.link stays here too: it 301s to openvibe.link, but a cached page may still ask.
++ 'https://openvibe.link',
'https://openvibe.link',
-- 'https://node.openvibe.network',
// The account hub's own origin (openvibe.my) asks /api/v1/me/* with the person's Bearer token and no
// credentials: the session cookie is host-only on openvibe.my, never sent to openvibe.network.
'https://openvibe.my', 'https://www.openvibe.my',
@@@ -348,7 +348,7 @@ config._registry = resolvedRegistry;
// paths 301 there; www.openvibe.my 301s to the apex. The route also mounts the hub's own OAuth client
// (PKCE, host-only cookies) at /auth, before Network's sign-in pages, so /my never renders off-origin.
app.use(await require('./my/routes').createMyRoutes({ db, config }));
--app.use(await require('./node/auth').createLinkAuthRoutes({ db, config }));
++app.use(await require('./link/auth').createLinkAuthRoutes({ db, config }));
// ── Analytics Tracking (ADR-021) ──────────────────────────────
// openvibe-shared/analytics on PostgreSQL (server/analytics/network.js; migrations/0002_analytics.sql).
@@@ -579,12 +579,12 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/admin/account-deletions', dataRouters.admin);
}
// The node registry (WS-X1, ADR-034 §12): public list for the geo API; Host reports its inventory's machines; services
--// holding network.registry.read read the same list uncached at GET /internal/nodes.
++// holding network.registry.read read the same list uncached at GET /internal/links.
{
const { guard } = require('./identity/principals');
-- const nodeRouters = require('./registry/nodes').routers({ guard: guard('network.node.report'), readGuard: guard('network.registry.read') });
-- app.use('/api/v1/nodes', nodeRouters.pub);
-- app.use('/internal/nodes', nodeRouters.internal);
++ const nodeRouters = require('./registry/nodes').routers({ guard: guard('network.link.report'), readGuard: guard('network.registry.read') });
++ app.use('/api/v1/links', nodeRouters.pub);
++ app.use('/internal/links', nodeRouters.internal);
}
// The resource registry (plan T2, docs/t2-resource-registry.md): node and provider offers. The public list leaves
// capacity out; the report and the full internal read take network.resource.report. The amended ADR-048 moved the
@@@ -614,9 +614,9 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./identity/principals').guard('network.resource.read') }));
}
// Service instances (plan T2, docs/t2-cells-and-node-principal.md section 4.1): Host reports what runs on its machines
--// with network.node.report. Mounted before /internal/registry so the cells read does not swallow the report path.
++// with network.link.report. Mounted before /internal/registry so the cells read does not swallow the report path.
{
-- const instanceRouters = require('./registry/instances').routers({ guard: require('./identity/principals').guard('network.node.report') });
++ const instanceRouters = require('./registry/instances').routers({ guard: require('./identity/principals').guard('network.link.report') });
app.use('/internal/registry/instances', instanceRouters.internal);
}
// Cells and node principals (plan T2, docs/t2-resource-registry.md section 9): the public cell list; a cell's nodes,
@@@ -627,37 +627,38 @@ const notifyAccountData = async (userId, n) => await notificationService.create(
app.use('/internal/registry', cellRouters.internal);
}
// Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2): a service holding
--// network.node.manage mints one-time codes and reads or revokes the principals it paired; the machine redeems its code
++// network.link.manage mints one-time codes and reads or revokes the principals it paired; the machine redeems its code
// with no other credential, so the redeem route has its own tight rate limit.
{
-- const pairingRouters = require('./registry/node-principals').routers({ guard: require('./identity/principals').guard('network.node.manage') });
-- app.use('/api/v1/node-pairing', rateLimit({ windowMs: 60_000, max: 10 }), pairingRouters.pairing);
++ const pairingRouters = require('./registry/link-principals').routers({ guard: require('./identity/principals').guard('network.link.manage') });
++ app.use('/api/v1/link-pairing', rateLimit({ windowMs: 60_000, max: 10 }), pairingRouters.pairing);
app.use('/internal', pairingRouters.internal);
// The person's own machines (slice N5): list and revoke, session only, same limit as /api/v1/me/blocks.
-- app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/node-principals').userRouter(requireAuth));
++ app.use('/api/v1/me/nodes', rateLimit({ windowMs: 60_000, max: 60 }), require('./registry/link-principals').userRouter(requireAuth));
// The machine itself (slice N4c): its own capabilities and credential, resolved from its node token alone.
-- app.use('/api/v1/node/self', require('./identity/principals').guard('network.node.self.manage'), require('./registry/node-principals').selfRouter());
-- const sendRelay = require('./node/send-relay').router({
-- nodeGuard: require('./identity/principals').guard('network.node.self.manage'), requireAuth, db,
++ app.use('/api/v1/link/self', require('./identity/principals').guard('network.link.self.manage'), require('./registry/link-principals').selfRouter());
++ const sendRelay = require('./link/send-relay').router({
++ nodeGuard: require('./identity/principals').guard('network.link.self.manage'), requireAuth, db,
});
-- app.use('/api/v1/node/self/send', rateLimit({ windowMs: 60_000, max: 120 }), sendRelay.self);
++ app.use('/api/v1/link/self/send', rateLimit({ windowMs: 60_000, max: 120 }), sendRelay.self);
app.use('/api/v1/me/nodes/send-keys', rateLimit({ windowMs: 60_000, max: 60 }), sendRelay.keys);
app.use('/api/v1/me/nodes/send', rateLimit({ windowMs: 60_000, max: 30 }), sendRelay.web);
}
--// The machines hub (plan L34, step M2): a person's machines on openvibe.network/machines. Bot stays the registry
++// The machines hub (plan L34, step M2): a person's machines on openvibe.link/machines. Bot stays the registry
// underneath; Network fronts it and calls it as the person, so Bot checks the machine is that person's own.
--// The internal route is Bot's report of a machine's state (network.node.manage, the capability it already holds).
++// The internal route is Bot's report of a machine's state (network.link.manage, the capability it already holds).
{
const machines = require('./registry/machines');
const bot = machines.createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
// Same-owner machine pools (bot.machine-pool@1): the owner proposes and stops them; each machine announces, approves,
-- // renews and leaves with its own node token. /api/v1/node/self's guard above checks that token for the machine routes.
++ // renews and leaves with its own node token. /api/v1/link/self's guard above checks that token for the machine routes.
const poolRouters = require('./registry/machine-pools').routers({ bot, requireAuth });
app.use('/api/v1/machine-pools', rateLimit({ windowMs: 60_000, max: 60 }), poolRouters.user);
-- app.use('/api/v1/node/self/pools', poolRouters.self);
++ app.use('/api/v1/link/self/pools', poolRouters.self);
const machineRouters = machines.routers({ bot, requireAuth, publicKey, issuer: config.jwt.issuer });
app.use('/api/v1/machines', rateLimit({ windowMs: 60_000, max: 60 }), machineRouters.hub);
-- const machineInternal = machines.internalRouters({ guard: require('./identity/principals').guard('network.node.manage') });
++ app.use('/api/v1/link/compute', rateLimit({ windowMs: 60_000, max: 60 }), machineRouters.compute);
++ const machineInternal = machines.internalRouters({ guard: require('./identity/principals').guard('network.link.manage') });
app.use('/internal', machineInternal.internal);
}
app.get('/internal/blocks', require('./identity/principals').guard('network.blocks.read'), require('./identity/blocks').internalHandler(db));
@@@ -729,9 +730,9 @@ const openStats = require('./stats/open').createOpenStats({
db, selfToken, release, statusRows: () => require('./status/routes').rows(ecosystem),
prometheusUrl: config.prometheusUrl || process.env.PROMETHEUS_URL, liveUrl: process.env.OV_LIVE_INTERNAL_URL,
});
--// The Node's own front door on its own subdomain (node.openvibe.network): the Host header decides. It mounts
++// The Node's own front door on its own subdomain (openvibe.link): the Host header decides. It mounts
// before the agent layer, so the Node's page is not answered by Network's home renderer.
--app.use(require('./node/routes').createNodeRoutes({ release: release.release }));
++app.use(require('./link/routes').createLinkRoutes({ release: release.release }));
// The agent layer (openvibe-shared/agent-layer): /.well-known/agent.json, and the public pages as Markdown twins. It
// answers their HTML as well, with the discovery tags in the head, so it mounts before the routes for those pages.
require('openvibe-shared/agent-layer').mount(app, {
ddiff --git a/server/link/auth.js b/server/link/auth.js
nnew file mode 100644
iindex 00000000..8e22471b
---- /dev/null
++++ b/server/link/auth.js
@@@ -0,0 +1,29 @@
++'use strict';
++// openvibe.link is a separate cookie origin. Its web session uses the SDK's confidential
++// OAuth flow; the existing public `link` client belongs to the installed mobile app.
++const express = require('express');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { HOST, SITE } = require('./page');
++const { hostOf } = require('./routes');
++
++async function createLinkAuthRoutes({ db, config, log = console } = {}) {
++ const row = await db.prepare('SELECT client_secret FROM oauth_clients WHERE client_id = ?').get('link-web');
++ const client = createSsoClient({
++ site: 'link', siteName: 'OpenVibe Link', baseUrl: SITE,
++ clientId: 'link-web', clientSecret: row?.client_secret || '',
++ redirectUri: `${SITE}/auth/callback`, scope: 'profile theme',
++ networkUrl: config.networkUrl, networkInternalUrl: config.internalUrl,
++ issuer: config.jwt.issuer, audience: ['openvibe.network'],
++ secureCookies: config.nodeEnv === 'production', log,
++ });
++ const auth = client.router(express);
++ const router = express.Router();
++ router.use('/auth', (req, res, next) => {
++ if (hostOf(req) !== HOST) return next();
++ res.set('Cache-Control', 'no-store');
++ return auth(req, res, next);
++ });
++ return router;
++}
++
++module.exports = { createLinkAuthRoutes };
ddiff --git a/server/link/egress-settings.js b/server/link/egress-settings.js
nnew file mode 100644
iindex 00000000..a0efaa9a
---- /dev/null
++++ b/server/link/egress-settings.js
@@@ -0,0 +1,28 @@
++'use strict';
++(() => {
++ const form = document.querySelector('[data-egress-settings]');
++ if (!form) return;
++ const status = form.querySelector('[data-egress-status]');
++ const url = '/api/v1/me/nodes/egress';
++ let loaded = false;
++ fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(async r => {
++ if (!r.ok) throw Error('Sign in to change your browsing settings.');
++ const p = await r.json();
++ form.elements.enabled.checked = p.egress_via_link;
++ form.elements.offline.value = p.offline_choice;
++ form.elements.cap.value = p.monthly_bytes / 1e9;
++ form.elements.sites.value = p.excluded_sites.join(', ');
++ loaded = true;
++ }).catch(e => { status.textContent = e.message; });
++ form.addEventListener('submit', async e => {
++ e.preventDefault();
++ if (!loaded) { status.textContent = 'Sign in to change your browsing settings.'; return; }
++ try {
++ const r = await fetch(url, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ egress_via_link: form.elements.enabled.checked, offline_choice: form.elements.offline.value,
++ monthly_bytes: Math.round(Number(form.elements.cap.value) * 1e9),
++ excluded_sites: form.elements.sites.value.split(/[\s,]+/).filter(Boolean) }) });
++ status.textContent = r.ok ? 'Browsing settings saved.' : 'Check the limit and site names, then try again.';
++ } catch { status.textContent = 'Your settings could not be saved. Try again.'; }
++ });
++})();
ddiff --git a/server/link/egress.js b/server/link/egress.js
nnew file mode 100644
iindex 00000000..efcab98d
---- /dev/null
++++ b/server/link/egress.js
@@@ -0,0 +1,178 @@
++'use strict';
++
++// Run is authoritative for session ownership; Network is authoritative for device ownership.
++// Session credentials are random, short lived, stored only as hashes, and rechecked on EVERY open.
++const crypto = require('crypto');
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const SESSION = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MACHINE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
++const NODE = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const DESK = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
++const TOKEN = /^[A-Za-z0-9_-]{43}$/;
++const DEFAULT_BYTES = 20_000_000_000;
++const EXPLANATION = "Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.";
++const sha = value => crypto.createHash('sha256').update(value).digest('hex');
++const fail = (status, code) => Object.assign(new Error(code), { status, code: `egress.${code}` });
++const wrap = fn => async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try { await fn(req, res); } catch (e) {
++ http.sendProblem(res, e.status || 503, e.code || 'egress.unavailable', { detail: 'Your browsing connection is unavailable.' });
++ }
++};
++
++async function settings(db, owner) {
++ const row = await db.prepare('SELECT * FROM link_egress_settings WHERE owner_subject = ?').get(owner);
++ return row ? { egress_via_link: row.egress_via_link, offline_choice: row.offline_choice,
++ monthly_bytes: Number(row.monthly_bytes), excluded_sites: row.excluded_sites } :
++ { egress_via_link: true, offline_choice: 'openvibe', monthly_bytes: DEFAULT_BYTES, excluded_sites: [] };
++}
++
++function validateSettings(body) {
++ if (!body || typeof body.egress_via_link !== 'boolean' || !['openvibe', 'wait'].includes(body.offline_choice) ||
++ !Number.isSafeInteger(body.monthly_bytes) || body.monthly_bytes < 0 || body.monthly_bytes > 10_000_000_000_000 ||
++ !Array.isArray(body.excluded_sites) || body.excluded_sites.length > 100) throw fail(422, 'invalid_settings');
++ const sites = body.excluded_sites.map(s => typeof s === 'string' ? s.trim().toLowerCase() : '');
++ if (sites.some(s => s.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(s))) throw fail(422, 'invalid_site');
++ return { ...body, excluded_sites: [...new Set(sites)] };
++}
++
++async function node(db, id) {
++ if (!NODE.test(id || '')) throw fail(403, 'node_required');
++ const row = await db.prepare('SELECT id, owner_kind, owner_subject, status, name, pairing_ref, paired_by_service FROM platform_node_principals WHERE id = ?').get(id);
++ if (!row || row.status !== 'active') throw fail(403, 'node_unavailable');
++ return row;
++}
++async function ownNode(db, req) {
++ if (!String(req.principal?.sub).startsWith('link:')) throw fail(403, 'node_required');
++ return node(db, req.principal.sub.slice(5));
++}
++
++async function cloudMachine(bot, principal) {
++ if (principal.paired_by_service !== 'bot' || principal.owner_kind !== 'user') throw fail(403, 'node_required');
++ // Pairing refs identify a robot or worker group, not a dev_ machine. Bot owns this binding.
++ const machines = (await bot.list(principal.owner_subject)).machines || [];
++ const machine = machines.find(m => m.node_id === principal.id && MACHINE.test(m.id || ''));
++ if (!machine) throw fail(403, 'node_unavailable');
++ return machine.id;
++}
++
++async function selectRoute(db, owner, now = Date.now()) {
++ const prefs = await settings(db, owner);
++ const devices = await db.prepare(`SELECT n.id, n.name, d.relay_url, d.checked_at FROM platform_node_principals n
++ LEFT JOIN link_egress_devices d ON d.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
++ ORDER BY d.checked_at DESC NULLS LAST, n.id`).all(owner);
++ if (!prefs.egress_via_link || !devices.length) return { mode: 'direct', ...prefs };
++ const online = devices.find(d => Number(d.checked_at) > now - 45_000);
++ if (!online) return { mode: prefs.offline_choice === 'wait' ? 'wait' : 'direct', ...prefs };
++ return { mode: 'link', device: online.id, device_name: online.name || 'My computer', relay_url: online.relay_url, ...prefs };
++}
++
++async function open(db, { session_id: session, credential, node_id: device }, now = Date.now()) {
++ if (!SESSION.test(session || '') || !TOKEN.test(credential || '') || !NODE.test(device || '')) throw fail(403, 'refused');
++ const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE session_id = ?').get(session);
++ if (!row || row.ended || Number(row.expires_at) <= now || row.node_id !== device ||
++ !crypto.timingSafeEqual(Buffer.from(row.credential_hash, 'hex'), Buffer.from(sha(credential), 'hex'))) throw fail(403, 'refused');
++ const target = await node(db, device);
++ // Never trust the ownership captured at registration: unpairing, transfers and opt-out take effect here.
++ if (target.owner_kind !== 'user' || target.owner_subject !== row.owner_subject) throw fail(403, 'owner_mismatch');
++ const prefs = await settings(db, row.owner_subject);
++ if (!prefs.egress_via_link || prefs.monthly_bytes === 0) throw fail(403, 'disabled');
++ return { session_id: session, node_id: device, expires_at: Number(row.expires_at), ...prefs };
++}
++
++function mount({ user, self, internal, pairing, requireAuth, now = () => Date.now() }) {
++ if (user) {
++ user.get('/egress', requireAuth, wrap(async (req, res) => {
++ const owner = await ensureUserSubject(req.app.locals.db, req.user);
++ if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
++ res.json({ ...await settings(req.app.locals.db, owner), explanation: EXPLANATION });
++ }));
++ user.put('/egress', requireAuth, express.json({ limit: '16kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db;
++ const owner = await ensureUserSubject(db, req.user);
++ if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
++ const p = validateSettings(req.body);
++ await db.prepare(`INSERT INTO link_egress_settings(owner_subject, egress_via_link, offline_choice, monthly_bytes, excluded_sites)
++ VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT(owner_subject) DO UPDATE SET
++ egress_via_link = EXCLUDED.egress_via_link, offline_choice = EXCLUDED.offline_choice,
++ monthly_bytes = EXCLUDED.monthly_bytes, excluded_sites = EXCLUDED.excluded_sites`)
++ .run(owner, p.egress_via_link, p.offline_choice, p.monthly_bytes, JSON.stringify(p.excluded_sites));
++ res.json(await settings(db, owner));
++ }));
++ }
++ if (self) {
++ self.get('/egress/identity', wrap(async (req, res) => {
++ const n = await ownNode(req.app.locals.db, req);
++ if (n.owner_kind !== 'user') throw fail(403, 'person_required');
++ res.json({ node_id: n.id, owner: n.owner_subject, name: n.name });
++ }));
++ self.put('/egress/heartbeat', express.json({ limit: '2kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db, n = await ownNode(db, req);
++ if (n.owner_kind !== 'user') throw fail(403, 'person_required');
++ const url = String(req.body?.relay_url || '');
++ // A device cannot make a cloud host connect to an arbitrary URL or local address.
++ if (!/^wss:\/\/[a-z0-9-]+\.openvibe\.link\/__ov_egress$/.test(url)) throw fail(422, 'invalid_relay');
++ await db.prepare(`INSERT INTO link_egress_devices(node_id, relay_url, checked_at) VALUES (?, ?, ?)
++ ON CONFLICT(node_id) DO UPDATE SET relay_url = EXCLUDED.relay_url, checked_at = EXCLUDED.checked_at`).run(n.id, url, now());
++ res.json(await settings(db, n.owner_subject));
++ }));
++ // A cloud Node can read only the decision for a desk assigned to its own Bot machine id.
++ self.get('/egress/desks/:desk', wrap(async (req, res) => {
++ const db = req.app.locals.db, n = await ownNode(db, req);
++ if (!DESK.test(req.params.desk)) throw fail(404, 'desk_unknown');
++ const { config, privateKey } = req.app.locals;
++ const bot = require('../registry/machines').createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
++ const machine = await cloudMachine(bot, n);
++ const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE machine = ? AND desk = ? AND ended = false AND expires_at > ?')
++ .get(machine, req.params.desk, now());
++ if (!row) throw fail(404, 'desk_unknown');
++ const route = await selectRoute(db, row.owner_subject, now());
++ const credential = crypto.randomBytes(32).toString('base64url');
++ await db.prepare('UPDATE link_egress_sessions SET credential_hash = ?, node_id = ? WHERE session_id = ? AND ended = false')
++ .run(sha(credential), route.device || null, row.session_id);
++ res.json({ session_id: row.session_id, credential, ...route });
++ }));
++ }
++ if (internal) {
++ const guard = require('../identity/principals').guard('network.registry.read');
++ const runOnly = (req, res, next) => req.principal?.sub === 'svc:run' ? next() : http.sendProblem(res, 403, 'egress.run_required');
++ internal.put('/egress/sessions/:session', guard, runOnly, express.json({ limit: '4kb' }), wrap(async (req, res) => {
++ const db = req.app.locals.db, b = req.body || {};
++ if (!SESSION.test(req.params.session) || !SUBJECT.test(b.owner || '') || !MACHINE.test(b.machine || '') ||
++ !DESK.test(b.desk || '') || !TOKEN.test(b.credential || '') || !Number.isSafeInteger(b.expires_at) ||
++ b.expires_at <= now() || b.expires_at > now() + 86_400_000) throw fail(422, 'invalid_session');
++ const route = await selectRoute(db, b.owner, now());
++ const old = await db.prepare('SELECT owner_subject FROM link_egress_sessions WHERE session_id = ?').get(req.params.session);
++ if (old && old.owner_subject !== b.owner) throw fail(403, 'owner_mismatch');
++ await db.prepare(`INSERT INTO link_egress_sessions(session_id, owner_subject, machine, desk, node_id, credential_hash, expires_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET machine = EXCLUDED.machine,
++ desk = EXCLUDED.desk, node_id = EXCLUDED.node_id, credential_hash = EXCLUDED.credential_hash,
++ expires_at = EXCLUDED.expires_at, ended = false`).run(req.params.session, b.owner, b.machine, b.desk,
++ route.device || null, sha(b.credential), b.expires_at);
++ res.json(route);
++ }));
++ internal.post('/egress/sessions/:session/renew', guard, runOnly, wrap(async (req, res) => {
++ await req.app.locals.db.prepare('UPDATE link_egress_sessions SET expires_at = ? WHERE session_id = ? AND ended = false')
++ .run(now() + 120_000, req.params.session);
++ res.status(204).end();
++ }));
++ internal.delete('/egress/sessions/:session', guard, runOnly, wrap(async (req, res) => {
++ await req.app.locals.db.prepare('UPDATE link_egress_sessions SET ended = true WHERE session_id = ?').run(req.params.session);
++ res.status(204).end();
++ }));
++ }
++
++}
++
++function publicRouter() {
++ const r = express.Router();
++ r.post('/api/v1/link-egress/open', require('express-rate-limit')({ windowMs: 10_000, max: 3000 }),
++ express.json({ limit: '2kb' }), wrap(async (req, res) => res.json(await open(req.app.locals.db, req.body || {}))));
++ return r;
++}
++
++module.exports = { cloudMachine, publicRouter, mount, settings, validateSettings, selectRoute, open, EXPLANATION, DEFAULT_BYTES };
ddiff --git a/server/link/page.js b/server/link/page.js
nnew file mode 100644
iindex 00000000..5560c1ae
---- /dev/null
++++ b/server/link/page.js
@@@ -0,0 +1,292 @@
++'use strict';
++// openvibe.link: OpenVibe Link's own front door (the machine agent). What Link is (your machines, connected), what it
++// does today, how to install it on each OS, and what it never does. The chrome is Network's own; the page is Link's.
++// The version, the release date and the checksums come from Link's latest GitHub release (server/link/release.js).
++
++const kit = require('openvibe-shared/kit');
++
++const HOST = 'openvibe.link';
++const WWW_HOST = `www.${HOST}`;
++// The name the product had until 2026-11-07; its /install line is in people's notes and keeps working.
++const SITE = `https://${HOST}`;
++const DOCS = 'https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs';
++const INSTALL_LINE = `curl -fsSL ${SITE}/install | sh`;
++
++// What Link does today, each with where it happens. A page that promises tomorrow is a page that lies today.
++const DOES = [
++ { icon: 'code', title: 'Run your agents and code on your machine', body: 'Codes starts a coding task from any browser and this machine runs it, as an unprivileged user in a sandbox.', href: 'https://openvibe.codes/run', cta: 'OpenVibe.Codes' },
++ { icon: 'spark', title: 'Offer your models or your GPU and earn credits', body: 'Exchange lists what your machine serves; you choose the models, the hours, the price and who may use it.', href: 'https://openvibe.exchange/sell', cta: 'OpenVibe.Exchange' },
++ { icon: 'game', title: 'Drive robots', body: 'Bot drives what you approve, at the level you set, with a kill switch on the machine and a deadline on every command.', href: 'https://openvibe.bot', cta: 'OpenVibe.Bot' },
++ { icon: 'live', title: 'Stream an agent\u2019s desk', body: 'Live carries your machine\u2019s screen to a channel, private until you choose: under a second behind, with a curtain that hides secret screens.', href: 'https://openvibe.live/docs/agent-streams', cta: 'Agent streams on OpenVibe.Live' },
++ { icon: 'lock', title: 'Keep your memory, read-only', body: 'The collectors read what your coding tools remember: local, redacted, and never uploaded unless you say so.', href: `${DOCS}/memory.md`, cta: 'The memory docs' },
++];
++
++// What Link never does. The two sentences people ask for most.
++const NEVER = [
++ 'It cannot reach your computer without your pairing: nobody drives it, reads it or runs a command on it until you approved the machine.',
++ 'Nothing runs that you did not turn on: no task, no camera, no driver and no upload starts on its own.',
++ 'It opens no port: it connects out, so it works behind any home router.',
++ 'It keeps a local kill switch: one command stops everything it drives, and it stays stopped.',
++];
++
++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
++
++/** Which OS the visitor is on, for the install block's default panel. Server-side, so it is right before JS runs. */
++function detectOS(userAgent) {
++ const ua = String(userAgent || '');
++ if (/Android/i.test(ua)) return 'android';
++ if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
++ if (/Windows/i.test(ua)) return 'windows';
++ if (/Macintosh|Mac OS X/i.test(ua)) return 'mac';
++ if (/Linux|X11/i.test(ua)) return 'linux';
++ return 'linux';
++}
++
++/**
++ * The install block per OS: the visitor's own OS opens first, every other way in stays one click away. Exact steps,
++ * the release's own assets, and nothing behind a script the page does not show.
++ */
++function installBlock(release, userAgent) {
++ const current = detectOS(userAgent);
++ const v = esc(release.version || '');
++ const pkg = String(release.version || '').replace(/^v/, '');
++ const latest = 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download';
++ const releases = 'https://github.com/OpenVibers/OpenVibe.Link/releases';
++ const dl = (name) => `${latest}/${name}`;
++ const file = (name, label) => `<a href="${esc(dl(name))}">${esc(label || name)}</a>`;
++ const pkgFile = (name, label) => (pkg ? `<a href="${esc(dl(name))}">${esc(label || name)}</a>` : `<a href="${releases}">${esc(label || name)}</a>`);
++ // The signed APK is attached when the repo has the release key; CI attaches a debug APK when it does not. Offer
++ // whichever the release actually carries, and the release page when neither is there yet.
++ const assets = Array.isArray(release.assets) ? release.assets : null;
++ const signedApk = release.version ? `openvibe-link-${release.version}.apk` : '';
++ const debugApk = release.version ? `openvibe-link-${release.version}-debug.apk` : '';
++ const apkName = !release.version ? '' : (!assets ? signedApk : (assets.includes(signedApk) ? signedApk : (assets.includes(debugApk) ? debugApk : '')));
++ const apk = release.version
++ ? (apkName ? `${releases}/download/${encodeURIComponent(release.version)}/${encodeURIComponent(apkName)}` : `${releases}/tag/${encodeURIComponent(release.version)}`)
++ : releases;
++ const apkNote = apkName && apkName === debugApk
++ ? ' This is the debug build: the signed Android release waits on the repository\'s release key.'
++ : '';
++ const open = (os) => (current === os ? ' open' : '');
++ return `<div class="np-os-list">
++<details class="np-os" data-os="windows"${open('windows')}>
++<summary><span>Windows</span><small>x64 and ARM64</small></summary>
++<div class="np-os-body">
++<p>Download the program for this PC: ${file('openvibe-link-windows-amd64.exe', 'Windows x64 (.exe)')} or ${file('openvibe-link-windows-arm64.exe', 'Windows ARM64 (.exe)')}.</p>
++<p>Windows may show SmartScreen the first time: choose <strong>More info</strong>, then <strong>Run anyway</strong>. In an <strong>Administrator</strong> PowerShell, from the folder you saved it in:</p>
++${kit.snippet({ code: '.\\openvibe-link pair <CODE>\n.\\openvibe-link install', id: 'np-install-win', label: 'Copy the Windows steps' })}
++<p class="np-fine">The code comes from <a href="/pair">openvibe.link/pair</a> (the signed-in machines hub, <strong>Pair a machine</strong>). </p>
++</div>
++</details>
++
++<details class="np-os" data-os="mac"${open('mac')}>
++<summary><span>macOS</span><small>Apple silicon and Intel</small></summary>
++<div class="np-os-body">
++<p>The one command installs the right build and the service, and asks for a pairing code:</p>
++${kit.snippet({ code: INSTALL_LINE, id: 'np-install-mac', label: 'Copy the macOS install command' })}
++<p>Or download the program: ${file('openvibe-link-darwin-arm64', 'Apple silicon')}, ${file('openvibe-link-darwin-amd64', 'Intel')}, or the ${pkgFile(`openvibe-link_${pkg}_darwin_arm64.tar.gz`, 'Apple silicon tarball')} and ${pkgFile(`openvibe-link_${pkg}_darwin_amd64.tar.gz`, 'Intel tarball')} with the installer and plugins beside it. Gatekeeper: right-click the file and choose <strong>Open</strong> the first time.</p>
++<p>Then in Terminal: <code>sudo ./openvibe-link pair &lt;CODE&gt;</code> and <code>sudo ./openvibe-link install</code>.</p>
++</div>
++</details>
++
++<details class="np-os" data-os="linux"${open('linux')}>
++<summary><span>Linux</span><small>x64 and ARM64 · .deb, .rpm, tarball, script</small></summary>
++<div class="np-os-body">
++<p>The one command, on any distribution, finds the CPU and installs the service:</p>
++${kit.snippet({ code: INSTALL_LINE, id: 'np-install-linux', label: 'Copy the Linux install command' })}
++<p>Or use your package manager: ${pkgFile(`openvibe-link_${pkg}_amd64.deb`, 'Debian/Ubuntu x64 .deb')}, ${pkgFile(`openvibe-link_${pkg}_arm64.deb`, 'Debian/Ubuntu ARM64 .deb')}, ${pkgFile(`openvibe-link-${pkg}-1.x86_64.rpm`, 'Fedora/RHEL x64 .rpm')} or ${pkgFile(`openvibe-link-${pkg}-1.aarch64.rpm`, 'Fedora/RHEL ARM64 .rpm')}.</p>
++${kit.snippet({ code: 'sudo apt install ./openvibe-link_<version>_amd64.deb\n# or: sudo dnf install ./openvibe-link-<version>-1.x86_64.rpm\nopenvibe-link pair <CODE>\nsudo openvibe-link install', id: 'np-install-pkg', label: 'Copy the package steps' })}
++<p>Or take the tarball: ${pkgFile(`openvibe-link_${pkg}_linux_amd64.tar.gz`, 'x64 .tar.gz')} or ${pkgFile(`openvibe-link_${pkg}_linux_arm64.tar.gz`, 'ARM64 .tar.gz')}; unpack it and run <code>sudo ./install.sh --local .</code>. On a Raspberry Pi or another robot, the one command also finds the kit and its driver.</p>
++</div>
++</details>
++
++<details class="np-os" data-os="android"${open('android')}>
++<summary><span>Android</span><small>the existing APK, direct</small></summary>
++<div class="np-os-body">
++<p>Download <a href="${esc(apk)}">${apkName ? esc(apkName) : 'the APK from the release page'}</a> on the phone, allow installing from that source when Android asks, and open <strong>OpenVibe Link</strong>.${apkNote}</p>
++<p>Pair from the app with a code from <a href="/pair">openvibe.link/pair</a> or <a href="https://openvibe.bot/link/phone">openvibe.bot/link/phone</a>. The APK carries approvals, Activity, pairing and <strong>Send to OpenVibe</strong>; it is the direct build, not Google Play, and has no phone automation.</p>
++</div>
++</details>
++
++<details class="np-os" data-os="ios"${open('ios')}>
++<summary><span>iPhone and iPad</span><small>what works today, without the App Store</small></summary>
++<div class="np-os-body">
++<p>There is no native iOS app yet. What works today is the home-screen web app: open <a href="https://openvibe.my/">openvibe.my</a> in Safari, sign in, then <strong>Share</strong> &rarr; <strong>Add to Home Screen</strong>. It gives you <strong>Approvals</strong>, <strong>Activity</strong>, your <strong>machines</strong> and pairing from the phone, and it looks like an app on the home screen.</p>
++<p class="np-fine">A native iOS app (and push notifications, a background connection, or using the phone as a Link camera or microphone) waits on an Apple Developer account and the App Store review that follows it. We will say when that starts; nothing here pretends it already works.</p>
++</div>
++</details>
++</div>
++<p class="np-fine">The program is <code>openvibe-link</code>. Every download is checked against the release's <a href="${esc(dl('SHA256SUMS'))}">SHA256SUMS</a>.</p>`;
++}
++
++/** The release facts: the version, when it came out, the checksums and the release page. */
++function releaseBlock(release) {
++ const { versionLabel, dateLabel, ageDays } = require('./release');
++ const version = versionLabel(release);
++ const date = dateLabel(release);
++ const days = ageDays(release);
++ const rows = [
++ kit.row({ title: 'Latest version', sub: release.version || '', meta: date || '', metaIso: date || undefined }),
++ kit.row({ href: release.checksumsUrl || '#', title: 'SHA256SUMS', sub: 'the checksums of every file in the release, to check the download against' }),
++ kit.row({ href: release.url || '#', title: 'The release on GitHub', sub: 'every asset, and the notes of that release' }),
++ ];
++ return `<section class="np-sec" aria-labelledby="h-release"><h2 id="h-release">The latest release</h2>
++<p class="np-fine">${version ? `Version <strong>${esc(version)}</strong>` : 'The download links always point at the latest release.'}${date ? `, published ${esc(date)}` : ''}. The install command above always installs that release.</p>
++${kit.list(rows, { flush: true })}
++${release.stale && days >= 1 ? `<p class="np-fine np-warn">GitHub could not be reached just now, so this is the last release we read, from ${days === 1 ? 'a day' : `${days} days`} ago.</p>` : ''}
++</section>`;
++}
++
++/** The page. `release` is what server/link/release.js answered. */
++function render({ release = {}, releaseId, userAgent } = {}) {
++ const title = 'OpenVibe Link: your machines, connected';
++ const desc = 'OpenVibe Link runs on your computer, server, phone or robot: it runs your agents and code, offers your models or GPU to the network, drives robots, and keeps your memory read-only.';
++ const ld = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'OpenVibe Link', applicationCategory: 'DeveloperApplication',
++ operatingSystem: 'Linux, macOS, Windows', description: desc, url: SITE,
++ offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
++ publisher: { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe' } };
++ const does = DOES.map((d) => `<article class="np-does">
++${kit.icon(d.icon)}
++<h3>${esc(d.title)}${d.soon ? ' <span class="np-soon">coming with S1</span>' : ''}</h3>
++<p>${esc(d.body)}</p>
++<p class="np-fine"><a href="${esc(d.href)}">${esc(d.cta)}</a></p>
++</article>`).join('');
++
++ return `<!DOCTYPE html>
++<html lang="en">
++<head>
++<meta charset="utf-8">
++<meta name="viewport" content="width=device-width, initial-scale=1">
++<title>${esc(title)}</title>
++<meta name="description" content="${esc(desc)}">
++<link rel="canonical" href="${SITE}/">
++<meta property="og:url" content="${SITE}/">
++${require('../boost').meta(releaseId)}
++${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
++<meta name="color-scheme" content="dark light">
++<script src="/shared/theme-loader.js" defer></script>
++<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, '\\u003c')}</script>
++<link rel="stylesheet" href="/shared/kit.css">
++<style>
++html,body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0}
++${CSS}
++</style>
++</head>
++<body>
++<div id="navbar-mount"></div>
++<main class="np" id="main">
++<header class="np-head">
++<p class="np-kicker">OpenVibe Link</p>
++<h1>Your machines, connected</h1>
++<p class="np-lede">Link runs on your computer, server or phone. Your agents use it only as far as you allow, and you choose what, if anything, it offers the network.</p>
++<p class="np-cta">${kit.button({ href: '#install', label: 'Install Link', kind: 'primary' })} ${kit.button({ href: `${DOCS}/install.md`, label: 'The install guide', kind: 'quiet' })}</p>
++</header>
++
++<section class="np-sec" aria-labelledby="h-does"><h2 id="h-does">What it does today</h2>
++<div class="np-grid">${does}</div>
++</section>
++
++<section class="np-sec" id="install" aria-labelledby="h-install"><h2 id="h-install">Install it</h2>
++<p class="np-fine" style="margin:0 0 12px">Your system is open first; choose another to see its steps.</p>
++${installBlock(release, userAgent)}
++</section>
++
++<section class="np-sec" id="machines" aria-labelledby="h-machines"><h2 id="h-machines">Your machines</h2>
++<p class="np-fine">Sign in to see every machine you paired: whether it is online, what it is allowed to do, and the one-time code to add another. The same page renames or revokes one, and the phone pairs there too.</p>
++<p class="np-cta" style="margin-top:12px">${kit.button({ href: '/machines', label: 'Open your machines', kind: 'primary' })} ${kit.button({ href: '/pair', label: 'Pair a machine', kind: 'quiet' })}</p>
++</section>
++
++<section class="np-sec" id="browsing"><h2>Your usual internet address</h2>
++<p>Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.</p>
++<form class="np-send" data-egress-settings>
++<label><span><input type="checkbox" name="enabled" checked> Use my computer's internet</span></label>
++<label>When my computer is offline<select name="offline"><option value="openvibe">Use OpenVibe's address</option><option value="wait">Wait for my computer</option></select></label>
++<label>Monthly limit (GB)<input type="number" name="cap" min="0" max="10000" value="20" step="1"></label>
++<label>Sites that should use OpenVibe's address<textarea name="sites" placeholder="example.com, another.example"></textarea></label>
++<button type="submit">Save browsing settings</button><p role="status" data-egress-status></p>
++</form></section>
++<section class="np-sec" id="send" aria-labelledby="h-send"><h2 id="h-send">Send to my machine</h2>
++<p class="np-fine">Choose one of your paired machines and send a file or a short text note. Link encrypts it for that machine before it leaves this page; the relay only holds the encrypted envelope for up to 24 hours.</p>
++<form class="np-send" data-link-send>
++<label>Send to <select name="machine" required><option value="">Loading your machines…</option></select></label>
++<label>File <input name="file" type="file"></label>
++<label>Or a text note <textarea name="text" rows="3" maxlength="10000" placeholder="Write a short note"></textarea></label>
++<button type="submit">Encrypt and send</button><p role="status" aria-live="polite" data-send-status></p>
++</form>
++</section>
++
++${releaseBlock(release)}
++
++<section class="np-sec" aria-labelledby="h-never"><h2 id="h-never">What it never does</h2>
++<ul class="np-never">${NEVER.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
++</section>
++
++<section class="np-sec" aria-labelledby="h-docs"><h2 id="h-docs">The docs</h2>
++<ul class="np-docs">
++<li><a href="${DOCS}/install.md">Install</a> &mdash; every OS, every option, and what the installer does.</li>
++<li><a href="${DOCS}/computer.md">Your computer for your agents</a> &mdash; the computer plugin, its levels and the live screen.</li>
++<li><a href="${DOCS}/worker.md">A machine that runs your jobs</a> &mdash; the sandbox the job runner uses.</li>
++<li><a href="${DOCS}/protocol.md">The protocol</a> &mdash; the wire format between Link and OpenVibe.Bot.</li>
++<li><a href="${DOCS}/memory.md">Your memory</a> &mdash; what the collectors read and how it is uploaded.</li>
++</ul>
++<p class="np-fine">The source is on <a href="https://github.com/OpenVibers/OpenVibe.Link">GitHub</a>. The installer also lives at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working.</p>
++</section>
++</main>
++${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
++<script src="/shared/navbar.js" defer></script>
++${require('../boost').SCRIPT}
++<script src="/shared/kit-web.js" defer></script>
++<script type="module" src="/send.js"></script>
++<script src="/egress-settings.js" defer></script>
++<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
++</body>
++</html>`;
++}
++
++const CSS = `
++.np{max-width:1000px;margin:0 auto;padding:32px 20px 72px}
++.np-kicker{margin:0 0 6px;font-size:.78rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent,#3b82f6)}
++.np h1{margin:0 0 12px;font-size:clamp(1.7rem,5vw,2.6rem);letter-spacing:-.02em;line-height:1.12}
++.np-lede{margin:0 0 18px;max-width:760px;color:var(--text-secondary,#96a7c2);line-height:1.65;font-size:1.03rem}
++.np-lede code,.np code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
++.np-cta{display:flex;flex-wrap:wrap;gap:10px;margin:0}
++.np-sec{margin-top:44px}
++.np-send{display:grid;gap:12px;max-width:560px;padding:18px;border:1px solid var(--border,#1f2d47);border-radius:16px;background:var(--bg-card,#0f1729)}
++.np-send label{display:grid;gap:6px;font-size:.92rem;font-weight:650}
++.np-send input,.np-send select,.np-send textarea{width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border,#1f2d47);border-radius:9px;background:var(--bg-primary,#080d18);color:var(--text-primary,#eef2ff);font:inherit}
++.np-send button{justify-self:start;padding:10px 14px;border:0;border-radius:9px;background:var(--accent,#3b82f6);color:white;font:inherit;font-weight:700;cursor:pointer}
++.np-send [role=status]{margin:0;min-height:1.3em;color:var(--text-secondary,#96a7c2)}
++.np-sec h2{margin:0 0 14px;font-size:1.3rem;letter-spacing:-.01em}
++.np-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
++.np-does{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);display:flex;flex-direction:column;gap:6px}
++.np-does svg{width:22px;height:22px;color:var(--accent,#3b82f6)}
++.np-does h3{margin:2px 0 0;font-size:1rem;line-height:1.35}
++.np-does p{margin:0;color:var(--text-secondary,#96a7c2);font-size:.92rem;line-height:1.55}
++.np-soon{display:inline-block;margin-left:6px;padding:2px 8px;border-radius:999px;font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#7386a3);border:1px solid var(--border,#1f2d47);vertical-align:middle}
++.np-installs{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
++.np-os-list{display:grid;gap:10px;max-width:860px}
++.np-os{border-radius:14px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);overflow:hidden}
++.np-os[open]{border-color:var(--accent,#3b82f6)}
++.np-os summary{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:14px 16px;cursor:pointer;list-style:none}
++.np-os summary::-webkit-details-marker{display:none}
++.np-os summary span{font-weight:800;font-size:1rem}
++.np-os summary small{color:var(--text-muted,#7386a3);font-size:.82rem;text-align:right;margin-left:auto}
++.np-os summary::after{content:'+';margin-left:12px;font-weight:800;color:var(--text-muted,#7386a3);font-size:1.05rem;line-height:1}
++.np-os[open] summary::after{content:'−'}
++.np-os-body{padding:0 16px 16px;border-top:1px solid var(--border,#1f2d47)}
++.np-os-body p{color:var(--text-secondary,#96a7c2);font-size:.92rem;line-height:1.6}
++.np-os-body a{color:var(--accent,#3b82f6)}
++.np-install{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47)}
++.np-install h3{margin:0 0 8px;font-size:1rem}
++.np-install .ovk-snip{margin:12px 0}
++.np-fine{margin:10px 0 0;font-size:.88rem;color:var(--text-muted,#7386a3);line-height:1.55}
++.np-warn{color:#f0b429}
++.np-never,.np-docs{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-width:820px}
++.np-never li{position:relative;padding:11px 14px 11px 42px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);color:var(--text-secondary,#96a7c2);font-size:.93rem;line-height:1.55}
++.np-never li::before{content:"";position:absolute;left:15px;top:16px;width:13px;height:13px;border-radius:50%;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);box-shadow:inset 0 0 0 2px var(--accent,#3b82f6)}
++.np-docs li{padding:11px 14px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);font-size:.93rem;line-height:1.55}
++.np a:not(.ovk-btn){color:var(--accent,#3b82f6)}
++@media (max-width:640px){.np{padding:22px 16px 52px}}
++`;
++
++module.exports = { render, HOST, WWW_HOST, SITE, DOCS, INSTALL_LINE, DOES, NEVER };
ddiff --git a/server/link/release.js b/server/link/release.js
nnew file mode 100644
iindex 00000000..020dacbd
---- /dev/null
++++ b/server/link/release.js
@@@ -0,0 +1,182 @@
++'use strict';
++// The Node's latest release, read from GitHub (OpenVibers/OpenVibe.Link). The page shows the version and the
++// checksums; /install answers with the same release's installer.
++//
++// The answer is held for fifteen minutes in memory and on disk (under DATA_DIR), so a restart serves the last
++// good answer at once instead of waiting on GitHub. When the hold is over the visitor still gets the last
++// answer immediately and a refresh runs in the background; when GitHub cannot be reached the last good answer
++// is kept, and the page says how old it is only once it is more than a day old. A cold start without GitHub
++// falls back to the release links that always resolve (the "latest" ones).
++//
++// GitHub allows 60 unauthenticated calls an hour per IP. GITHUB_TOKEN, when set, raises that limit. The token
++// is read from the environment at request time and is never logged.
++
++const fs = require('fs');
++const path = require('path');
++
++const REPO = 'OpenVibers/OpenVibe.Link';
++const API = `https://api.github.com/repos/${REPO}/releases/latest`;
++const TTL_MS = 15 * 60 * 1000; // fifteen minutes
++const TIMEOUT_MS = 8000;
++const FAIL_COOLDOWN_MS = 60 * 1000; // a cold failure waits a minute before asking GitHub again
++const DAY_MS = 24 * 60 * 60 * 1000;
++const LATEST = `https://github.com/${REPO}/releases/latest`;
++const CACHE_FILE = 'node-release.json';
++
++// What the page says when GitHub cannot be reached and nothing was cached: no version (the page says "the latest
++// release" instead of naming one that may be old), and the links that always resolve to the newest release.
++const FALLBACK = {
++ version: '',
++ publishedAt: '',
++ url: LATEST,
++ installUrl: `https://github.com/${REPO}/releases/latest/download/install.sh`,
++ checksumsUrl: `https://github.com/${REPO}/releases/latest/download/SHA256SUMS`,
++};
++
++let cached = null; // { at, data } — at is when GitHub was read, epoch ms
++let inflight = null; // one fetch at a time
++let diskFile = null; // the file this process reads and writes
++let failedAt = 0; // a cold failure, so the next visitor does not wait 8 s too
++
++/** The release fields the page and /install use. */
++function shape(json) {
++ const tag = String(json.tag_name || '').trim();
++ const assets = Array.isArray(json.assets) ? json.assets : [];
++ const asset = (name) => assets.find((a) => a && a.name === name);
++ const install = asset('install.sh');
++ const sums = asset('SHA256SUMS');
++ return {
++ version: tag,
++ assets: assets.map((a) => String((a && a.name) || '')).filter(Boolean),
++ publishedAt: String(json.published_at || ''),
++ url: String(json.html_url || (tag ? `https://github.com/${REPO}/releases/tag/${tag}` : LATEST)),
++ installUrl: String((install && install.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/install.sh` : FALLBACK.installUrl)),
++ checksumsUrl: String((sums && sums.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS` : FALLBACK.checksumsUrl)),
++ };
++}
++
++/** GITHUB_TOKEN from the environment, or no Authorization at all. Never read from anywhere else, never logged. */
++function authorization() {
++ const token = String(process.env.GITHUB_TOKEN || '').trim();
++ return token ? { authorization: `Bearer ${token}` } : {};
++}
++
++/**
++ * Point the process at a data directory's copy, once per directory, and load what a previous process left.
++ * `null` (or an empty string) turns the disk cache off, which is what tests with an injected clock and fetch use.
++ */
++function useDisk(dataDir) {
++ if (dataDir === null || dataDir === '') return;
++ const dir = dataDir === undefined ? (process.env.DATA_DIR || 'data') : String(dataDir);
++ const file = path.join(path.resolve(dir), CACHE_FILE);
++ if (file === diskFile) return;
++ diskFile = file;
++ try {
++ const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
++ if (!saved || !Number.isFinite(saved.at) || !saved.data || !String(saved.data.version || '').trim()) return;
++ if (!cached || saved.at > cached.at) cached = { at: saved.at, data: saved.data };
++ } catch {
++ // No copy yet, or an unreadable one: the first fetch writes a fresh one.
++ }
++}
++
++/** Keep the answer for the next process. A read-only data dir costs a restart's cache, never the page. */
++function saveDisk(entry) {
++ if (!diskFile) return;
++ try {
++ fs.mkdirSync(path.dirname(diskFile), { recursive: true });
++ const tmp = `${diskFile}.${process.pid}.tmp`;
++ fs.writeFileSync(tmp, JSON.stringify(entry));
++ fs.renameSync(tmp, diskFile);
++ } catch {
++ // The page still works from memory.
++ }
++}
++
++/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */
++async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
++ const controller = typeof AbortController === 'function' ? new AbortController() : null;
++ const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null;
++ try {
++ let res;
++ try {
++ res = await fetchImpl(API, {
++ signal: controller ? controller.signal : undefined,
++ headers: { accept: 'application/vnd.github+json', 'user-agent': 'openvibe-network', ...authorization() },
++ });
++ } finally {
++ if (timer) clearTimeout(timer);
++ }
++ if (!res || !res.ok) throw new Error(`GitHub releases API answered ${res && res.status}`);
++ const data = shape(await res.json());
++ if (!data.version) throw new Error('GitHub releases API answered without a tag');
++ cached = { at: now(), data };
++ failedAt = 0;
++ saveDisk(cached);
++ return { ...data, cached: false, stale: false, ageMs: 0 };
++ } catch (err) {
++ failedAt = now();
++ throw err;
++ }
++}
++
++/** The one background refresh that may be running. */
++function refresh(options) {
++ if (inflight) return inflight;
++ inflight = fetchLatest(options)
++ .catch(() => null)
++ .finally(() => { inflight = null; });
++ return inflight;
++}
++
++/**
++ * The latest release. Returns the fresh answer, or the last good answer at once while a refresh runs in the
++ * background; when a refresh fails the last good answer is kept and marked stale with its age. Never throws.
++ */
++async function latest({ fetchImpl = globalThis.fetch, now = Date.now, dataDir } = {}) {
++ useDisk(dataDir);
++ if (cached) {
++ const ageMs = Math.max(0, now() - cached.at);
++ if (ageMs < TTL_MS) return { ...cached.data, cached: true, stale: false, ageMs };
++ // Serving the last answer immediately and refreshing behind the request keeps the visitor off GitHub's
++ // clock. A failed refresh waits out the cooldown, so a long outage does not spend the hour's calls.
++ const cooling = failedAt && now() - failedAt < FAIL_COOLDOWN_MS;
++ if (!inflight && !cooling) refresh({ fetchImpl, now });
++ return { ...cached.data, cached: true, stale: true, ageMs };
++ }
++ if (failedAt && now() - failedAt < FAIL_COOLDOWN_MS) {
++ return { ...FALLBACK, cached: false, stale: true, ageMs: 0 };
++ }
++ try {
++ return await fetchLatest({ fetchImpl, now });
++ } catch {
++ return { ...FALLBACK, cached: false, stale: true, ageMs: 0 };
++ }
++}
++
++/** Resolves when no background refresh is running (tests use it instead of sleeping). */
++function whenIdle() {
++ return inflight || Promise.resolve();
++}
++
++/** How many whole days old the answer is; 0 while it is younger than a day. */
++function ageDays(release) {
++ const ms = Number((release && release.ageMs) || 0);
++ return ms >= DAY_MS ? Math.floor(ms / DAY_MS) : 0;
++}
++
++/** The version without the leading v, for a sentence. */
++function versionLabel(release) {
++ return String((release && release.version) || '').replace(/^v/, '');
++}
++
++/** The release date, the day it was published. */
++function dateLabel(release) {
++ const iso = String((release && release.publishedAt) || '');
++ if (!iso) return '';
++ const d = new Date(iso);
++ if (Number.isNaN(d.getTime())) return '';
++ return d.toISOString().slice(0, 10);
++}
++
++module.exports = { latest, refresh, whenIdle, ageDays, versionLabel, dateLabel, REPO, API, TTL_MS, TIMEOUT_MS, FAIL_COOLDOWN_MS, DAY_MS, FALLBACK, LATEST };
ddiff --git a/server/link/routes.js b/server/link/routes.js
nnew file mode 100644
iindex 00000000..d45fc597
---- /dev/null
++++ b/server/link/routes.js
@@@ -0,0 +1,150 @@
++'use strict';
++// openvibe.link, served by Network: OpenVibe Link's front door (the Node renamed, 2026-11-07). The Host header
++// decides: on openvibe.link '/' is Link's page, '/install' answers with the installer of the latest release, and
++// the page has its Markdown twin, its llms.txt and its crawl files, like Network's other pages.
++//
++// www.openvibe.link answers 301 to openvibe.link.
++//
++// The route is mounted before the agent layer, so Link's page is not answered by Network's home renderer.
++
++const { markdownTwin, linkHeaderTags } = require('openvibe-shared/agent-layer');
++const fs = require('node:fs');
++const pathLib = require('node:path');
++const page = require('./page');
++const release = require('./release');
++
++const HOST = page.HOST;
++const WWW_HOST = page.WWW_HOST;
++const SITE = page.SITE;
++
++/** The request's host, lowercased, without the port. */
++function hostOf(req) {
++ return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
++}
++
++/** Link's llms.txt: what the page is, how to install it, and where the docs are. */
++function llmsTxt() {
++ return [
++ '# OpenVibe Link',
++ '',
++ '> OpenVibe Link is one program (openvibe-link) that runs on your computer, server, phone or robot. It connects out to OpenVibe, needs no open ports, keeps a local kill switch, and does nothing you did not turn on.',
++ '',
++ '## The page',
++ '',
++ `- [OpenVibe Link: your machines, connected](${SITE}/): what it is, what it does today (run your agents and code, offer your models or GPU and earn credits, drive robots, stream an agent's desk, keep your memory read-only), how to install it on each OS, and what it never does`,
++ '',
++ '## Install',
++ '',
++ `- [Install Link](${SITE}/install): the latest release's installer, as a script to run`,
++ `- [Your machines](${SITE}/machines): the signed-in Link machine hub (pair, capabilities, online state)`,
++ '',
++ '```sh',
++ page.INSTALL_LINE,
++ '```',
++ '',
++ '## The docs',
++ '',
++ `- [Install](${page.DOCS}/install.md): every OS, every option, and what the installer does`,
++ `- [Your computer for your agents](${page.DOCS}/computer.md): the computer plugin, its levels and the live screen`,
++ `- [A machine that runs your jobs](${page.DOCS}/worker.md): the sandbox the job runner uses`,
++ `- [The protocol](${page.DOCS}/protocol.md): the wire format between Link and OpenVibe.Bot`,
++ `- [Your memory](${page.DOCS}/memory.md): what the collectors read and how it is uploaded`,
++ '- [The source on GitHub](https://github.com/OpenVibers/OpenVibe.Link)',
++ '',
++ ].join('\n');
++}
++
++/**
++ * Link's routes. Returns null for a request that is not for this host, so the router can be mounted early
++ * and every other host falls through to Network's own pages.
++ */
++function createLinkRoutes({ releaseId, releaseApi = release } = {}) {
++ const router = require('express').Router();
++ router.use(require('./egress').publicRouter());
++
++ router.use(async (req, res, next) => {
++ const host = hostOf(req);
++ if (host !== HOST && host !== WWW_HOST) return next();
++
++ const url = new URL(req.url, SITE);
++ const path = url.pathname;
++
++ // www is not a second name for Link: every path lands on the apex, and nginx does the same before us.
++ if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
++
++ // The installer: the latest release's own asset, so `curl -fsSL https://openvibe.link/install | sh`
++ // installs that release. The same cached answer the page shows.
++ if (path === '/install') {
++ let rel;
++ try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
++ const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
++ return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
++ }
++
++ // The machine hub lives on Link's own origin. It uses Network's same-origin
++ // session and machine API, and keeps the existing roles and pairing controls.
++ if (path === '/machines') {
++ return res.set('Cache-Control', 'no-store').sendFile(pathLib.join(__dirname, '..', '..', 'public', 'my.html'));
++ }
++ if (path === '/pair' || path === '/panel') {
++ return res.set('Cache-Control', 'no-store').redirect(302, `${SITE}/machines`);
++ }
++
++ if (path === '/llms.txt') {
++ return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt());
++ }
++
++ // The crawl files of this host, so a crawler on openvibe.link is told about this page and not about
++ // Network's (the apex serves its own). IndexNow's key file (when INDEXNOW_KEY is set) is served by the
++ // app's global middleware on this host too, so an engine can fetch it at https://openvibe.link/<key>.txt.
++ if (path === '/robots.txt') {
++ const robots = require('openvibe-shared/seo').robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`] });
++ return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robots);
++ }
++ if (path === '/sitemap.xml') {
++ const xml = require('openvibe-shared/seo').sitemapXml([{ loc: `${SITE}/`, changefreq: 'weekly', priority: 1.0 }]);
++ return res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(xml);
++ }
++
++ if (path === '/locker.mjs' && (req.method === 'GET' || req.method === 'HEAD')) {
++ let source;
++ try { source = fs.readFileSync(pathResolveLocker(), 'utf8'); }
++ catch { return res.status(503).type('text/plain').send('Locker is not available right now.'); }
++ res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400',
++ 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff' });
++ return res.end(req.method === 'HEAD' ? undefined : source);
++ }
++ if (path === '/send.js' && (req.method === 'GET' || req.method === 'HEAD')) {
++ const source = fs.readFileSync(pathLib.join(__dirname, 'send.js'), 'utf8');
++ res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=600', 'X-Content-Type-Options': 'nosniff' });
++ return res.end(req.method === 'HEAD' ? undefined : source);
++ }
++
++ if (path !== '/' && path !== '/index.md' && path !== '/index.html') return next();
++
++ const isTwin = path === '/index.md' || url.searchParams.get('format') === 'markdown'
++ || (req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
++ let rel;
++ try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
++ const html = page.render({ release: rel, releaseId, userAgent: req.headers && req.headers['user-agent'] });
++ const canonical = `${SITE}/`;
++ const links = linkHeaderTags(canonical, `${SITE}/llms.txt`);
++ res.setHeader('Link', [res.getHeader('Link'), links.header].filter(Boolean).join(', '));
++ res.setHeader('Vary', [res.getHeader('Vary'), 'Accept'].filter(Boolean).join(', '));
++ res.setHeader('Cache-Control', 'public, max-age=600');
++ if (isTwin) {
++ res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
++ return res.end(req.method === 'HEAD' ? undefined : markdownTwin({ html, title: 'OpenVibe Link: your machines, connected', canonical }));
++ }
++ res.setHeader('Content-Type', 'text/html; charset=utf-8');
++ return res.end(req.method === 'HEAD' ? undefined : html);
++ });
++
++ return router;
++}
++
++function pathResolveLocker() {
++ return pathLib.join(pathLib.dirname(require.resolve('openvibe-sdk')), 'browser', 'openvibe-sdk.mjs');
++}
++
++module.exports = { createLinkRoutes, llmsTxt, hostOf, HOST, WWW_HOST, SITE };
ddiff --git a/server/link/send-relay.js b/server/link/send-relay.js
nnew file mode 100644
iindex 00000000..8457bea6
---- /dev/null
++++ b/server/link/send-relay.js
@@@ -0,0 +1,183 @@
++'use strict';
++
++// Link Send's relay stores only Locker envelopes. A sender and recipient are resolved from node tokens, and both
++// principals must still belong to the same user. Nothing in this module opens or inspects an envelope.
++const crypto = require('crypto');
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { ensureUserSubject } = require('../identity/subjects');
++
++const MAX_ENVELOPE = 32 * 1024 * 1024;
++const MAX_OWNER_ITEMS = 100;
++const MAX_OWNER_BYTES = 128 * 1024 * 1024;
++const TTL_MS = 24 * 60 * 60 * 1000;
++const id = () => `snd_${crypto.randomBytes(16).toString('hex')}`;
++const bad = (status, code, message) => Object.assign(new Error(message), { status, code });
++
++async function principal(db, req) {
++ const sub = String(req.principal?.sub || '');
++ if (!sub.startsWith('link:')) throw bad(403, 'send.machine_required', 'only a paired machine can use this route');
++ const row = await db.prepare("SELECT id, owner_subject, owner_kind, status FROM platform_node_principals WHERE id = ?").get(sub.slice(5));
++ if (!row || row.status !== 'active' || row.owner_kind !== 'user' || !row.owner_subject) {
++ throw bad(401, 'send.machine_unavailable', 'this machine is no longer paired to an account');
++ }
++ return row;
++}
++
++function router({ nodeGuard, requireAuth, db }) {
++ const self = express.Router();
++ self.use(http.middleware(), nodeGuard);
++ self.put('/key', express.json({ limit: '4kb' }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const machine = await principal(db, req);
++ const key = req.body?.public_key;
++ if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string' || Object.keys(key).some((k) => !['kty', 'crv', 'x', 'y', 'ext', 'key_ops'].includes(k))) {
++ throw bad(400, 'send.bad_key', 'Link needs a public P-256 receive key');
++ }
++ const jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
++ let thumbprint;
++ try {
++ crypto.createPublicKey({ key: jwk, format: 'jwk' });
++ thumbprint = crypto.createHash('sha256').update(JSON.stringify({ crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y })).digest('base64url');
++ } catch { throw bad(400, 'send.bad_key', 'Link needs a valid public P-256 receive key'); }
++ await db.prepare(`INSERT INTO link_send_keys (node_id, public_key, key_thumbprint, updated_at) VALUES (?, ?::jsonb, ?, now())
++ ON CONFLICT (node_id) DO UPDATE SET public_key = EXCLUDED.public_key, key_thumbprint = EXCLUDED.key_thumbprint, updated_at = now()`)
++ .run(machine.id, JSON.stringify(jwk), thumbprint);
++ res.set('Cache-Control', 'no-store').json({ node_id: machine.id, key_thumbprint: thumbprint });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.get('/inbox', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const target = await principal(db, req);
++ // The list is polled every few seconds while Link's window is open: names and sizes only. The ciphertext
++ // is fetched once, by accept, when the person chooses to receive it.
++ const rows = await db.prepare(`SELECT i.id, i.sender_node, s.name AS sender_name, octet_length(i.envelope) AS bytes,
++ i.created_at, i.expires_at
++ FROM link_send_items i LEFT JOIN platform_node_principals s ON s.id = i.sender_node
++ WHERE i.target_node = ? AND i.state = 'pending' AND i.expires_at > now() ORDER BY i.created_at`).all(target.id);
++ res.set('Cache-Control', 'no-store').json({ items: rows.map((r) => ({
++ id: r.id, sender_node: r.sender_node || null,
++ sender_name: r.sender_node ? (r.sender_name || 'Link machine') : 'openvibe.link',
++ bytes: Number(r.bytes) || 0, created_at: r.created_at, expires_at: r.expires_at,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.get('/targets', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const sender = await principal(db, req);
++ const rows = await db.prepare(`SELECT n.id, n.name, n.last_seen_at, k.public_key
++ FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active' AND n.id <> ?
++ ORDER BY n.created_at DESC, n.id`).all(sender.owner_subject, sender.id);
++ res.set('Cache-Control', 'no-store').json({ machines: rows.map((r) => ({
++ id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
++ public_key: r.public_key,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const sender = await principal(db, req);
++ const targetId = String(req.body?.target_node || '');
++ const envelope = req.body?.envelope;
++ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
++ throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
++ }
++ const target = await db.prepare("SELECT id FROM platform_node_principals WHERE id = ? AND status = 'active' AND owner_kind = 'user' AND owner_subject = ?").get(targetId, sender.owner_subject);
++ if (!target || target.id === sender.id) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
++ await assertQuota(db, sender.owner_subject, Buffer.byteLength(envelope));
++ const itemId = id();
++ const expires = new Date(Date.now() + TTL_MS).toISOString();
++ await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
++ .run(itemId, sender.owner_subject, sender.id, target.id, envelope, expires);
++ res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
++ } catch (e) { return sendError(res, e); }
++ });
++ self.post('/:id/:decision', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const target = await principal(db, req);
++ const decision = req.params.decision;
++ if (!['accept', 'decline', 'complete'].includes(decision)) throw bad(400, 'send.bad_decision', 'choose accept, decline or complete');
++ const row = await db.prepare("SELECT id, envelope FROM link_send_items WHERE id = ? AND target_node = ? AND state = 'pending' AND expires_at > now()")
++ .get(req.params.id, target.id);
++ if (!row) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
++ if (decision === 'decline' || decision === 'complete') {
++ await db.prepare("DELETE FROM link_send_items WHERE id = ? AND target_node = ?").run(row.id, target.id);
++ return res.status(204).end();
++ }
++ res.set('Cache-Control', 'no-store').json({ id: row.id, envelope: row.envelope });
++ } catch (e) { return sendError(res, e); }
++ });
++
++ const keys = express.Router();
++ keys.use(http.middleware(), requireAuth);
++ keys.get('/', async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const me = await ensureUserSubject(db, req.user);
++ const rows = await db.prepare(`SELECT n.id, n.name, n.status, n.last_seen_at, k.public_key
++ FROM platform_node_principals n LEFT JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
++ ORDER BY n.created_at DESC, n.id`).all(me);
++ res.set('Cache-Control', 'private, no-store').json({ machines: rows.map((r) => ({
++ id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
++ public_key: r.public_key || null,
++ })) });
++ } catch (e) { return sendError(res, e); }
++ });
++ // The browser sender on openvibe.link: POST /api/v1/me/nodes/send, as the signed-in person.
++ const web = express.Router();
++ web.use(http.middleware(), requireAuth);
++ web.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
++ try {
++ const db = req.app.locals.db;
++ const owner = await ensureUserSubject(db, req.user);
++ const targetId = String(req.body?.target_node || '');
++ const envelope = req.body?.envelope;
++ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
++ if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
++ throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
++ }
++ const target = await db.prepare(`SELECT n.id FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
++ WHERE n.id = ? AND n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'`).get(targetId, owner);
++ if (!target) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
++ await assertQuota(db, owner, Buffer.byteLength(envelope));
++ const itemId = id(), expires = new Date(Date.now() + TTL_MS).toISOString();
++ await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, NULL, ?, ?, ?)")
++ .run(itemId, owner, target.id, envelope, expires);
++ res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
++ } catch (e) { return sendError(res, e); }
++ });
++ const cleanup = setInterval(() => {
++ if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
++ }, 15 * 60_000);
++ cleanup.unref?.();
++ if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
++ return { self, keys, web };
++}
++
++async function assertQuota(db, owner, adding) {
++ const quota = await db.prepare("SELECT COUNT(*) AS items, COALESCE(SUM(octet_length(envelope)), 0) AS bytes FROM link_send_items WHERE owner_subject = ? AND expires_at > now()").get(owner);
++ if (Number(quota.items) >= MAX_OWNER_ITEMS || Number(quota.bytes) + adding > MAX_OWNER_BYTES) {
++ throw bad(429, 'send.queue_full', 'your transfer queue is full; accept or decline a waiting item first');
++ }
++}
++
++async function cleanupExpired(db) {
++ await db.prepare("DELETE FROM link_send_items WHERE expires_at <= now()").run();
++}
++
++function sendError(res, e) {
++ const status = Number(e.status) || 500;
++ const code = e.code || 'send.failed';
++ const message = status < 500 ? e.message : 'Link could not complete that transfer';
++ return http.sendProblem(res, status, code, { detail: message });
++}
++
++module.exports = { router, principal, MAX_ENVELOPE, MAX_OWNER_ITEMS, MAX_OWNER_BYTES, TTL_MS, assertQuota, cleanupExpired };
ddiff --git a/server/link/send.js b/server/link/send.js
nnew file mode 100644
iindex 00000000..ee418f52
---- /dev/null
++++ b/server/link/send.js
@@@ -0,0 +1,65 @@
++import { locker } from '/locker.mjs';
++
++const form = document.querySelector('[data-link-send]');
++if (form) {
++ const select = form.elements.machine;
++ const status = form.querySelector('[data-send-status]');
++ const say = (text) => { status.textContent = text; };
++ const machines = async () => {
++ const r = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
++ if (r.status === 401 || r.status === 403) throw new Error('Sign in on openvibe.link to choose one of your machines.');
++ if (!r.ok) throw new Error('Your machines could not be loaded.');
++ const body = await r.json();
++ select.replaceChildren(new Option('Choose a machine', ''));
++ for (const m of body.machines || []) {
++ if (!m.public_key) continue;
++ select.add(new Option(`${m.name}${m.online ? '' : ' (offline)'}`, m.id));
++ }
++ if (select.options.length === 1) say('Pair a machine and open Link once to turn on receiving.');
++ };
++ const base64 = (bytes) => {
++ let out = '';
++ for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
++ return btoa(out);
++ };
++ async function packageContent(file, text) {
++ let name, mime, data;
++ if (file) { name = file.name; mime = file.type || 'application/octet-stream'; data = new Uint8Array(await file.arrayBuffer()); }
++ else { name = 'note.txt'; mime = 'text/plain;charset=utf-8'; data = new TextEncoder().encode(text); }
++ const meta = new TextEncoder().encode(JSON.stringify({ name, mime }));
++ const out = new Uint8Array(4 + meta.length + data.length);
++ new DataView(out.buffer).setUint32(0, meta.length);
++ out.set(meta, 4); out.set(data, 4 + meta.length);
++ return out;
++ }
++ machines().catch((e) => { select.replaceChildren(new Option('Sign in to load machines', '')); say(e.message); });
++ form.addEventListener('submit', async (event) => {
++ event.preventDefault();
++ const machine = (form.elements.machine.value);
++ const file = form.elements.file.files[0];
++ const text = form.elements.text.value;
++ if (!machine) return say('Choose one of your machines.');
++ if (!file && !text.trim()) return say('Choose a file or write a note.');
++ if (file && file.size > 20 * 1024 * 1024) return say('Choose a file smaller than 20 MB for now.');
++ try {
++ say('Encrypting in this browser…');
++ const listed = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
++ if (!listed.ok) throw new Error('Sign in on openvibe.link, then try again.');
++ const target = (await listed.json()).machines.find((m) => m.id === machine && m.public_key);
++ if (!target) throw new Error('That machine has no receive key yet. Open Link on it and try again.');
++ const payload = await packageContent(file, text);
++ const sealed = await locker.seal(payload, { lock: 'people', keys: [{ subject: { type: 'link', id: target.id }, publicKey: target.public_key }] });
++ say('Sending encrypted data…');
++ const sent = await fetch('/api/v1/me/nodes/send', {
++ method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ target_node: target.id, envelope: base64(sealed.bytes) }),
++ });
++ if (!sent.ok) {
++ const detail = await sent.json().catch(() => ({}));
++ throw new Error(detail.detail || 'Link could not send this item.');
++ }
++ say('Sent. The receiving machine will ask before saving it.');
++ form.elements.file.value = ''; form.elements.text.value = '';
++ } catch (e) { say(e.message || 'Link could not send this item.'); }
++ });
++}
ddiff --git a/server/my/routes.js b/server/my/routes.js
iindex 1877cfc5..91a316cd 100644
---- a/server/my/routes.js
++++ b/server/my/routes.js
@@@ -205,6 +205,10 @@ async function createMyRoutes({ db, config = require('../config'), sso, pagePath
router.use((req, res, next) => {
const host = hostOf(req);
if (host === WWW_HOST) return res.redirect(301, `https://${MY_HOST}${req.originalUrl}`);
++ // The machines hub moved to the Link product: /machines goes there from both the apex and the hub.
++ if (req.path === '/machines' && ['GET', 'HEAD'].includes(req.method) && (host === MY_HOST || NETWORK_HOSTS.has(host))) {
++ return res.redirect(302, `https://openvibe.link/machines${queryOf(req)}`);
++ }
if (NETWORK_HOSTS.has(host)) {
if (!isHubPath(req.path) || !['GET', 'HEAD'].includes(req.method)) return next();
return res.redirect(302, `https://${MY_HOST}${pathFromNetwork(req.path)}${queryOf(req)}`);
ddiff --git a/server/node/auth.js b/server/node/auth.js
ddeleted file mode 100644
iindex 8e22471b..00000000
---- a/server/node/auth.js
++++ /dev/null
@@@ -1,29 +0,0 @@
--'use strict';
--// openvibe.link is a separate cookie origin. Its web session uses the SDK's confidential
--// OAuth flow; the existing public `link` client belongs to the installed mobile app.
--const express = require('express');
--const { createSsoClient } = require('openvibe-sdk/sso');
--const { HOST, SITE } = require('./page');
--const { hostOf } = require('./routes');
--
--async function createLinkAuthRoutes({ db, config, log = console } = {}) {
-- const row = await db.prepare('SELECT client_secret FROM oauth_clients WHERE client_id = ?').get('link-web');
-- const client = createSsoClient({
-- site: 'link', siteName: 'OpenVibe Link', baseUrl: SITE,
-- clientId: 'link-web', clientSecret: row?.client_secret || '',
-- redirectUri: `${SITE}/auth/callback`, scope: 'profile theme',
-- networkUrl: config.networkUrl, networkInternalUrl: config.internalUrl,
-- issuer: config.jwt.issuer, audience: ['openvibe.network'],
-- secureCookies: config.nodeEnv === 'production', log,
-- });
-- const auth = client.router(express);
-- const router = express.Router();
-- router.use('/auth', (req, res, next) => {
-- if (hostOf(req) !== HOST) return next();
-- res.set('Cache-Control', 'no-store');
-- return auth(req, res, next);
-- });
-- return router;
--}
--
--module.exports = { createLinkAuthRoutes };
ddiff --git a/server/node/egress-settings.js b/server/node/egress-settings.js
ddeleted file mode 100644
iindex a0efaa9a..00000000
---- a/server/node/egress-settings.js
++++ /dev/null
@@@ -1,28 +0,0 @@
--'use strict';
--(() => {
-- const form = document.querySelector('[data-egress-settings]');
-- if (!form) return;
-- const status = form.querySelector('[data-egress-status]');
-- const url = '/api/v1/me/nodes/egress';
-- let loaded = false;
-- fetch(url, { credentials: 'same-origin', cache: 'no-store' }).then(async r => {
-- if (!r.ok) throw Error('Sign in to change your browsing settings.');
-- const p = await r.json();
-- form.elements.enabled.checked = p.egress_via_link;
-- form.elements.offline.value = p.offline_choice;
-- form.elements.cap.value = p.monthly_bytes / 1e9;
-- form.elements.sites.value = p.excluded_sites.join(', ');
-- loaded = true;
-- }).catch(e => { status.textContent = e.message; });
-- form.addEventListener('submit', async e => {
-- e.preventDefault();
-- if (!loaded) { status.textContent = 'Sign in to change your browsing settings.'; return; }
-- try {
-- const r = await fetch(url, { method: 'PUT', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ egress_via_link: form.elements.enabled.checked, offline_choice: form.elements.offline.value,
-- monthly_bytes: Math.round(Number(form.elements.cap.value) * 1e9),
-- excluded_sites: form.elements.sites.value.split(/[\s,]+/).filter(Boolean) }) });
-- status.textContent = r.ok ? 'Browsing settings saved.' : 'Check the limit and site names, then try again.';
-- } catch { status.textContent = 'Your settings could not be saved. Try again.'; }
-- });
--})();
ddiff --git a/server/node/egress.js b/server/node/egress.js
ddeleted file mode 100644
iindex 1d80f93b..00000000
---- a/server/node/egress.js
++++ /dev/null
@@@ -1,178 +0,0 @@
--'use strict';
--
--// Run is authoritative for session ownership; Network is authoritative for device ownership.
--// Session credentials are random, short lived, stored only as hashes, and rechecked on EVERY open.
--const crypto = require('crypto');
--const express = require('express');
--const { http } = require('openvibe-contracts');
--const { ensureUserSubject } = require('../identity/subjects');
--const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const SESSION = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
--const MACHINE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
--const NODE = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
--const DESK = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
--const TOKEN = /^[A-Za-z0-9_-]{43}$/;
--const DEFAULT_BYTES = 20_000_000_000;
--const EXPLANATION = "Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.";
--const sha = value => crypto.createHash('sha256').update(value).digest('hex');
--const fail = (status, code) => Object.assign(new Error(code), { status, code: `egress.${code}` });
--const wrap = fn => async (req, res) => {
-- res.set('Cache-Control', 'no-store');
-- try { await fn(req, res); } catch (e) {
-- http.sendProblem(res, e.status || 503, e.code || 'egress.unavailable', { detail: 'Your browsing connection is unavailable.' });
-- }
--};
--
--async function settings(db, owner) {
-- const row = await db.prepare('SELECT * FROM link_egress_settings WHERE owner_subject = ?').get(owner);
-- return row ? { egress_via_link: row.egress_via_link, offline_choice: row.offline_choice,
-- monthly_bytes: Number(row.monthly_bytes), excluded_sites: row.excluded_sites } :
-- { egress_via_link: true, offline_choice: 'openvibe', monthly_bytes: DEFAULT_BYTES, excluded_sites: [] };
--}
--
--function validateSettings(body) {
-- if (!body || typeof body.egress_via_link !== 'boolean' || !['openvibe', 'wait'].includes(body.offline_choice) ||
-- !Number.isSafeInteger(body.monthly_bytes) || body.monthly_bytes < 0 || body.monthly_bytes > 10_000_000_000_000 ||
-- !Array.isArray(body.excluded_sites) || body.excluded_sites.length > 100) throw fail(422, 'invalid_settings');
-- const sites = body.excluded_sites.map(s => typeof s === 'string' ? s.trim().toLowerCase() : '');
-- if (sites.some(s => s.length > 253 || !/^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/.test(s))) throw fail(422, 'invalid_site');
-- return { ...body, excluded_sites: [...new Set(sites)] };
--}
--
--async function node(db, id) {
-- if (!NODE.test(id || '')) throw fail(403, 'node_required');
-- const row = await db.prepare('SELECT id, owner_kind, owner_subject, status, name, pairing_ref, paired_by_service FROM platform_node_principals WHERE id = ?').get(id);
-- if (!row || row.status !== 'active') throw fail(403, 'node_unavailable');
-- return row;
--}
--async function ownNode(db, req) {
-- if (!String(req.principal?.sub).startsWith('node:')) throw fail(403, 'node_required');
-- return node(db, req.principal.sub.slice(5));
--}
--
--async function cloudMachine(bot, principal) {
-- if (principal.paired_by_service !== 'bot' || principal.owner_kind !== 'user') throw fail(403, 'node_required');
-- // Pairing refs identify a robot or worker group, not a dev_ machine. Bot owns this binding.
-- const machines = (await bot.list(principal.owner_subject)).machines || [];
-- const machine = machines.find(m => m.node_id === principal.id && MACHINE.test(m.id || ''));
-- if (!machine) throw fail(403, 'node_unavailable');
-- return machine.id;
--}
--
--async function selectRoute(db, owner, now = Date.now()) {
-- const prefs = await settings(db, owner);
-- const devices = await db.prepare(`SELECT n.id, n.name, d.relay_url, d.checked_at FROM platform_node_principals n
-- LEFT JOIN link_egress_devices d ON d.node_id = n.id
-- WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
-- ORDER BY d.checked_at DESC NULLS LAST, n.id`).all(owner);
-- if (!prefs.egress_via_link || !devices.length) return { mode: 'direct', ...prefs };
-- const online = devices.find(d => Number(d.checked_at) > now - 45_000);
-- if (!online) return { mode: prefs.offline_choice === 'wait' ? 'wait' : 'direct', ...prefs };
-- return { mode: 'link', device: online.id, device_name: online.name || 'My computer', relay_url: online.relay_url, ...prefs };
--}
--
--async function open(db, { session_id: session, credential, node_id: device }, now = Date.now()) {
-- if (!SESSION.test(session || '') || !TOKEN.test(credential || '') || !NODE.test(device || '')) throw fail(403, 'refused');
-- const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE session_id = ?').get(session);
-- if (!row || row.ended || Number(row.expires_at) <= now || row.node_id !== device ||
-- !crypto.timingSafeEqual(Buffer.from(row.credential_hash, 'hex'), Buffer.from(sha(credential), 'hex'))) throw fail(403, 'refused');
-- const target = await node(db, device);
-- // Never trust the ownership captured at registration: unpairing, transfers and opt-out take effect here.
-- if (target.owner_kind !== 'user' || target.owner_subject !== row.owner_subject) throw fail(403, 'owner_mismatch');
-- const prefs = await settings(db, row.owner_subject);
-- if (!prefs.egress_via_link || prefs.monthly_bytes === 0) throw fail(403, 'disabled');
-- return { session_id: session, node_id: device, expires_at: Number(row.expires_at), ...prefs };
--}
--
--function mount({ user, self, internal, pairing, requireAuth, now = () => Date.now() }) {
-- if (user) {
-- user.get('/egress', requireAuth, wrap(async (req, res) => {
-- const owner = await ensureUserSubject(req.app.locals.db, req.user);
-- if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
-- res.json({ ...await settings(req.app.locals.db, owner), explanation: EXPLANATION });
-- }));
-- user.put('/egress', requireAuth, express.json({ limit: '16kb' }), wrap(async (req, res) => {
-- const db = req.app.locals.db;
-- const owner = await ensureUserSubject(db, req.user);
-- if (!SUBJECT.test(owner || '')) throw fail(403, 'person_required');
-- const p = validateSettings(req.body);
-- await db.prepare(`INSERT INTO link_egress_settings(owner_subject, egress_via_link, offline_choice, monthly_bytes, excluded_sites)
-- VALUES (?, ?, ?, ?, ?::jsonb) ON CONFLICT(owner_subject) DO UPDATE SET
-- egress_via_link = EXCLUDED.egress_via_link, offline_choice = EXCLUDED.offline_choice,
-- monthly_bytes = EXCLUDED.monthly_bytes, excluded_sites = EXCLUDED.excluded_sites`)
-- .run(owner, p.egress_via_link, p.offline_choice, p.monthly_bytes, JSON.stringify(p.excluded_sites));
-- res.json(await settings(db, owner));
-- }));
-- }
-- if (self) {
-- self.get('/egress/identity', wrap(async (req, res) => {
-- const n = await ownNode(req.app.locals.db, req);
-- if (n.owner_kind !== 'user') throw fail(403, 'person_required');
-- res.json({ node_id: n.id, owner: n.owner_subject, name: n.name });
-- }));
-- self.put('/egress/heartbeat', express.json({ limit: '2kb' }), wrap(async (req, res) => {
-- const db = req.app.locals.db, n = await ownNode(db, req);
-- if (n.owner_kind !== 'user') throw fail(403, 'person_required');
-- const url = String(req.body?.relay_url || '');
-- // A device cannot make a cloud host connect to an arbitrary URL or local address.
-- if (!/^wss:\/\/[a-z0-9-]+\.openvibe\.link\/__ov_egress$/.test(url)) throw fail(422, 'invalid_relay');
-- await db.prepare(`INSERT INTO link_egress_devices(node_id, relay_url, checked_at) VALUES (?, ?, ?)
-- ON CONFLICT(node_id) DO UPDATE SET relay_url = EXCLUDED.relay_url, checked_at = EXCLUDED.checked_at`).run(n.id, url, now());
-- res.json(await settings(db, n.owner_subject));
-- }));
-- // A cloud Node can read only the decision for a desk assigned to its own Bot machine id.
-- self.get('/egress/desks/:desk', wrap(async (req, res) => {
-- const db = req.app.locals.db, n = await ownNode(db, req);
-- if (!DESK.test(req.params.desk)) throw fail(404, 'desk_unknown');
-- const { config, privateKey } = req.app.locals;
-- const bot = require('../registry/machines').createBotClient({ baseUrl: config.services.bot.internalUrl, privateKey, issuer: config.jwt.issuer });
-- const machine = await cloudMachine(bot, n);
-- const row = await db.prepare('SELECT * FROM link_egress_sessions WHERE machine = ? AND desk = ? AND ended = false AND expires_at > ?')
-- .get(machine, req.params.desk, now());
-- if (!row) throw fail(404, 'desk_unknown');
-- const route = await selectRoute(db, row.owner_subject, now());
-- const credential = crypto.randomBytes(32).toString('base64url');
-- await db.prepare('UPDATE link_egress_sessions SET credential_hash = ?, node_id = ? WHERE session_id = ? AND ended = false')
-- .run(sha(credential), route.device || null, row.session_id);
-- res.json({ session_id: row.session_id, credential, ...route });
-- }));
-- }
-- if (internal) {
-- const guard = require('../identity/principals').guard('network.registry.read');
-- const runOnly = (req, res, next) => req.principal?.sub === 'svc:run' ? next() : http.sendProblem(res, 403, 'egress.run_required');
-- internal.put('/egress/sessions/:session', guard, runOnly, express.json({ limit: '4kb' }), wrap(async (req, res) => {
-- const db = req.app.locals.db, b = req.body || {};
-- if (!SESSION.test(req.params.session) || !SUBJECT.test(b.owner || '') || !MACHINE.test(b.machine || '') ||
-- !DESK.test(b.desk || '') || !TOKEN.test(b.credential || '') || !Number.isSafeInteger(b.expires_at) ||
-- b.expires_at <= now() || b.expires_at > now() + 86_400_000) throw fail(422, 'invalid_session');
-- const route = await selectRoute(db, b.owner, now());
-- const old = await db.prepare('SELECT owner_subject FROM link_egress_sessions WHERE session_id = ?').get(req.params.session);
-- if (old && old.owner_subject !== b.owner) throw fail(403, 'owner_mismatch');
-- await db.prepare(`INSERT INTO link_egress_sessions(session_id, owner_subject, machine, desk, node_id, credential_hash, expires_at)
-- VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET machine = EXCLUDED.machine,
-- desk = EXCLUDED.desk, node_id = EXCLUDED.node_id, credential_hash = EXCLUDED.credential_hash,
-- expires_at = EXCLUDED.expires_at, ended = false`).run(req.params.session, b.owner, b.machine, b.desk,
-- route.device || null, sha(b.credential), b.expires_at);
-- res.json(route);
-- }));
-- internal.post('/egress/sessions/:session/renew', guard, runOnly, wrap(async (req, res) => {
-- await req.app.locals.db.prepare('UPDATE link_egress_sessions SET expires_at = ? WHERE session_id = ? AND ended = false')
-- .run(now() + 120_000, req.params.session);
-- res.status(204).end();
-- }));
-- internal.delete('/egress/sessions/:session', guard, runOnly, wrap(async (req, res) => {
-- await req.app.locals.db.prepare('UPDATE link_egress_sessions SET ended = true WHERE session_id = ?').run(req.params.session);
-- res.status(204).end();
-- }));
-- }
--
--}
--
--function publicRouter() {
-- const r = express.Router();
-- r.post('/api/v1/link-egress/open', require('express-rate-limit')({ windowMs: 10_000, max: 3000 }),
-- express.json({ limit: '2kb' }), wrap(async (req, res) => res.json(await open(req.app.locals.db, req.body || {}))));
-- return r;
--}
--
--module.exports = { cloudMachine, publicRouter, mount, settings, validateSettings, selectRoute, open, EXPLANATION, DEFAULT_BYTES };
ddiff --git a/server/node/page.js b/server/node/page.js
ddeleted file mode 100644
iindex 848fc344..00000000
---- a/server/node/page.js
++++ /dev/null
@@@ -1,294 +0,0 @@
--'use strict';
--// openvibe.link: OpenVibe Link's own front door (the Node renamed). What Link is (your machines, connected), what it
--// does today, how to install it on each OS, and what it never does. The chrome is Network's own; the page is Link's.
--// The version, the release date and the checksums come from the Node's latest GitHub release (server/node/release.js).
--// node.openvibe.network is the old name: it 301s here (server/node/routes.js) except /install, which keeps serving.
--
--const kit = require('openvibe-shared/kit');
--
--const HOST = 'openvibe.link';
--const WWW_HOST = `www.${HOST}`;
--// The name the product had until 2026-11-07; its /install line is in people's notes and keeps working.
--const LEGACY_HOST = 'node.openvibe.network';
--const SITE = `https://${HOST}`;
--const DOCS = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs';
--const INSTALL_LINE = `curl -fsSL ${SITE}/install | sh`;
--
--// What the Node does today, each with where it happens. A page that promises tomorrow is a page that lies today.
--const DOES = [
-- { icon: 'code', title: 'Run your agents and code on your machine', body: 'Codes starts a coding task from any browser and this machine runs it, as an unprivileged user in a sandbox.', href: 'https://openvibe.codes/run', cta: 'OpenVibe.Codes' },
-- { icon: 'spark', title: 'Offer your models or your GPU and earn credits', body: 'Exchange lists what your machine serves; you choose the models, the hours, the price and who may use it.', href: 'https://openvibe.exchange/sell', cta: 'OpenVibe.Exchange' },
-- { icon: 'game', title: 'Drive robots', body: 'Bot drives what you approve, at the level you set, with a kill switch on the machine and a deadline on every command.', href: 'https://openvibe.bot', cta: 'OpenVibe.Bot' },
-- { icon: 'live', title: 'Stream an agent\u2019s desk', body: 'Live carries your machine\u2019s screen to a channel, private until you choose: under a second behind, with a curtain that hides secret screens.', href: 'https://openvibe.live/docs/agent-streams', cta: 'Agent streams on OpenVibe.Live' },
-- { icon: 'lock', title: 'Keep your memory, read-only', body: 'The collectors read what your coding tools remember: local, redacted, and never uploaded unless you say so.', href: `${DOCS}/memory.md`, cta: 'The memory docs' },
--];
--
--// What the Node never does. The two sentences people ask for most.
--const NEVER = [
-- 'It cannot reach your computer without your pairing: nobody drives it, reads it or runs a command on it until you approved the machine.',
-- 'Nothing runs that you did not turn on: no task, no camera, no driver and no upload starts on its own.',
-- 'It opens no port: it connects out, so it works behind any home router.',
-- 'It keeps a local kill switch: one command stops everything it drives, and it stays stopped.',
--];
--
--const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
--
--/** Which OS the visitor is on, for the install block's default panel. Server-side, so it is right before JS runs. */
--function detectOS(userAgent) {
-- const ua = String(userAgent || '');
-- if (/Android/i.test(ua)) return 'android';
-- if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
-- if (/Windows/i.test(ua)) return 'windows';
-- if (/Macintosh|Mac OS X/i.test(ua)) return 'mac';
-- if (/Linux|X11/i.test(ua)) return 'linux';
-- return 'linux';
--}
--
--/**
-- * The install block per OS: the visitor's own OS opens first, every other way in stays one click away. Exact steps,
-- * the release's own assets, and nothing behind a script the page does not show.
-- */
--function installBlock(release, userAgent) {
-- const current = detectOS(userAgent);
-- const v = esc(release.version || '');
-- const pkg = String(release.version || '').replace(/^v/, '');
-- const latest = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download';
-- const releases = 'https://github.com/OpenVibers/OpenVibe.Node/releases';
-- const dl = (name) => `${latest}/${name}`;
-- const file = (name, label) => `<a href="${esc(dl(name))}">${esc(label || name)}</a>`;
-- const pkgFile = (name, label) => (pkg ? `<a href="${esc(dl(name))}">${esc(label || name)}</a>` : `<a href="${releases}">${esc(label || name)}</a>`);
-- // The signed APK is attached when the repo has the release key; CI attaches a debug APK when it does not. Offer
-- // whichever the release actually carries, and the release page when neither is there yet.
-- const assets = Array.isArray(release.assets) ? release.assets : null;
-- const signedApk = release.version ? `openvibe-link-${release.version}.apk` : '';
-- const debugApk = release.version ? `openvibe-link-${release.version}-debug.apk` : '';
-- const apkName = !release.version ? '' : (!assets ? signedApk : (assets.includes(signedApk) ? signedApk : (assets.includes(debugApk) ? debugApk : '')));
-- const apk = release.version
-- ? (apkName ? `${releases}/download/${encodeURIComponent(release.version)}/${encodeURIComponent(apkName)}` : `${releases}/tag/${encodeURIComponent(release.version)}`)
-- : releases;
-- const apkNote = apkName && apkName === debugApk
-- ? ' This is the debug build: the signed Android release waits on the repository\'s release key.'
-- : '';
-- const open = (os) => (current === os ? ' open' : '');
-- return `<div class="np-os-list">
--<details class="np-os" data-os="windows"${open('windows')}>
--<summary><span>Windows</span><small>x64 and ARM64</small></summary>
--<div class="np-os-body">
--<p>Download the program for this PC: ${file('openvibe-link-windows-amd64.exe', 'Windows x64 (.exe)')} or ${file('openvibe-link-windows-arm64.exe', 'Windows ARM64 (.exe)')}.</p>
--<p>Windows may show SmartScreen the first time: choose <strong>More info</strong>, then <strong>Run anyway</strong>. In an <strong>Administrator</strong> PowerShell, from the folder you saved it in:</p>
--${kit.snippet({ code: '.\\openvibe-link pair <CODE>\n.\\openvibe-link install', id: 'np-install-win', label: 'Copy the Windows steps' })}
--<p class="np-fine">The code comes from <a href="/pair">openvibe.link/pair</a> (the signed-in machines hub, <strong>Pair a machine</strong>). <code>openvibe-node.exe</code> is the same program under its old name.</p>
--</div>
--</details>
--
--<details class="np-os" data-os="mac"${open('mac')}>
--<summary><span>macOS</span><small>Apple silicon and Intel</small></summary>
--<div class="np-os-body">
--<p>The one command installs the right build and the service, and asks for a pairing code:</p>
--${kit.snippet({ code: INSTALL_LINE, id: 'np-install-mac', label: 'Copy the macOS install command' })}
--<p>Or download the program: ${file('openvibe-link-darwin-arm64', 'Apple silicon')}, ${file('openvibe-link-darwin-amd64', 'Intel')}, or the ${pkgFile(`openvibe-link_${pkg}_darwin_arm64.tar.gz`, 'Apple silicon tarball')} and ${pkgFile(`openvibe-link_${pkg}_darwin_amd64.tar.gz`, 'Intel tarball')} with the installer and plugins beside it. Gatekeeper: right-click the file and choose <strong>Open</strong> the first time.</p>
--<p>Then in Terminal: <code>sudo ./openvibe-link pair &lt;CODE&gt;</code> and <code>sudo ./openvibe-link install</code>.</p>
--</div>
--</details>
--
--<details class="np-os" data-os="linux"${open('linux')}>
--<summary><span>Linux</span><small>x64 and ARM64 · .deb, .rpm, tarball, script</small></summary>
--<div class="np-os-body">
--<p>The one command, on any distribution, finds the CPU and installs the service:</p>
--${kit.snippet({ code: INSTALL_LINE, id: 'np-install-linux', label: 'Copy the Linux install command' })}
--<p>Or use your package manager: ${pkgFile(`openvibe-link_${pkg}_amd64.deb`, 'Debian/Ubuntu x64 .deb')}, ${pkgFile(`openvibe-link_${pkg}_arm64.deb`, 'Debian/Ubuntu ARM64 .deb')}, ${pkgFile(`openvibe-link-${pkg}-1.x86_64.rpm`, 'Fedora/RHEL x64 .rpm')} or ${pkgFile(`openvibe-link-${pkg}-1.aarch64.rpm`, 'Fedora/RHEL ARM64 .rpm')}.</p>
--${kit.snippet({ code: 'sudo apt install ./openvibe-link_<version>_amd64.deb\n# or: sudo dnf install ./openvibe-link-<version>-1.x86_64.rpm\nopenvibe-link pair <CODE>\nsudo openvibe-link install', id: 'np-install-pkg', label: 'Copy the package steps' })}
--<p>Or take the tarball: ${pkgFile(`openvibe-link_${pkg}_linux_amd64.tar.gz`, 'x64 .tar.gz')} or ${pkgFile(`openvibe-link_${pkg}_linux_arm64.tar.gz`, 'ARM64 .tar.gz')}; unpack it and run <code>sudo ./install.sh --local .</code>. On a Raspberry Pi or another robot, the one command also finds the kit and its driver.</p>
--</div>
--</details>
--
--<details class="np-os" data-os="android"${open('android')}>
--<summary><span>Android</span><small>the existing APK, direct</small></summary>
--<div class="np-os-body">
--<p>Download <a href="${esc(apk)}">${apkName ? esc(apkName) : 'the APK from the release page'}</a> on the phone, allow installing from that source when Android asks, and open <strong>OpenVibe Link</strong>.${apkNote}</p>
--<p>Pair from the app with a code from <a href="/pair">openvibe.link/pair</a> or <a href="https://openvibe.bot/link/phone">openvibe.bot/link/phone</a>. The APK carries approvals, Activity, pairing and <strong>Send to OpenVibe</strong>; it is the direct build, not Google Play, and has no phone automation.</p>
--</div>
--</details>
--
--<details class="np-os" data-os="ios"${open('ios')}>
--<summary><span>iPhone and iPad</span><small>what works today, without the App Store</small></summary>
--<div class="np-os-body">
--<p>There is no native iOS app yet. What works today is the home-screen web app: open <a href="https://openvibe.my/">openvibe.my</a> in Safari, sign in, then <strong>Share</strong> &rarr; <strong>Add to Home Screen</strong>. It gives you <strong>Approvals</strong>, <strong>Activity</strong>, your <strong>machines</strong> and pairing from the phone, and it looks like an app on the home screen.</p>
--<p class="np-fine">A native iOS app (and push notifications, a background connection, or using the phone as a Link camera or microphone) waits on an Apple Developer account and the App Store review that follows it. We will say when that starts; nothing here pretends it already works.</p>
--</div>
--</details>
--</div>
--<p class="np-fine">The program is <code>openvibe-link</code>. <code>openvibe-node</code> is kept as an alias, so existing installs and scripts keep working. Every download is checked against the release's <a href="${esc(dl('SHA256SUMS'))}">SHA256SUMS</a>.</p>`;
--}
--
--/** The release facts: the version, when it came out, the checksums and the release page. */
--function releaseBlock(release) {
-- const { versionLabel, dateLabel, ageDays } = require('./release');
-- const version = versionLabel(release);
-- const date = dateLabel(release);
-- const days = ageDays(release);
-- const rows = [
-- kit.row({ title: 'Latest version', sub: release.version || '', meta: date || '', metaIso: date || undefined }),
-- kit.row({ href: release.checksumsUrl || '#', title: 'SHA256SUMS', sub: 'the checksums of every file in the release, to check the download against' }),
-- kit.row({ href: release.url || '#', title: 'The release on GitHub', sub: 'every asset, and the notes of that release' }),
-- ];
-- return `<section class="np-sec" aria-labelledby="h-release"><h2 id="h-release">The latest release</h2>
--<p class="np-fine">${version ? `Version <strong>${esc(version)}</strong>` : 'The download links always point at the latest release.'}${date ? `, published ${esc(date)}` : ''}. The install command above always installs that release.</p>
--${kit.list(rows, { flush: true })}
--${release.stale && days >= 1 ? `<p class="np-fine np-warn">GitHub could not be reached just now, so this is the last release we read, from ${days === 1 ? 'a day' : `${days} days`} ago.</p>` : ''}
--</section>`;
--}
--
--/** The page. `release` is what server/node/release.js answered. */
--function render({ release = {}, releaseId, userAgent } = {}) {
-- const title = 'OpenVibe Link: your machines, connected';
-- const desc = 'OpenVibe Link runs on your computer, server, phone or robot: it runs your agents and code, offers your models or GPU to the network, drives robots, and keeps your memory read-only.';
-- const ld = { '@context': 'https://schema.org', '@type': 'SoftwareApplication', name: 'OpenVibe Link', applicationCategory: 'DeveloperApplication',
-- operatingSystem: 'Linux, macOS, Windows', description: desc, url: SITE,
-- offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
-- publisher: { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe' } };
-- const does = DOES.map((d) => `<article class="np-does">
--${kit.icon(d.icon)}
--<h3>${esc(d.title)}${d.soon ? ' <span class="np-soon">coming with S1</span>' : ''}</h3>
--<p>${esc(d.body)}</p>
--<p class="np-fine"><a href="${esc(d.href)}">${esc(d.cta)}</a></p>
--</article>`).join('');
--
-- return `<!DOCTYPE html>
--<html lang="en">
--<head>
--<meta charset="utf-8">
--<meta name="viewport" content="width=device-width, initial-scale=1">
--<title>${esc(title)}</title>
--<meta name="description" content="${esc(desc)}">
--<link rel="canonical" href="${SITE}/">
--<meta property="og:url" content="${SITE}/">
--${require('../boost').meta(releaseId)}
--${require('openvibe-shared/app-icon').headTags({ site: 'network', iconBase: '/assets' })}
--<meta name="color-scheme" content="dark light">
--<script src="/shared/theme-loader.js" defer></script>
--<script type="application/ld+json">${JSON.stringify(ld).replace(/</g, '\\u003c')}</script>
--<link rel="stylesheet" href="/shared/kit.css">
--<style>
--html,body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;margin:0}
--${CSS}
--</style>
--</head>
--<body>
--<div id="navbar-mount"></div>
--<main class="np" id="main">
--<header class="np-head">
--<p class="np-kicker">OpenVibe Link</p>
--<h1>Your machines, connected</h1>
--<p class="np-lede">Link runs on your computer, server or phone. Your agents use it only as far as you allow, and you choose what, if anything, it offers the network.</p>
--<p class="np-cta">${kit.button({ href: '#install', label: 'Install Link', kind: 'primary' })} ${kit.button({ href: `${DOCS}/install.md`, label: 'The install guide', kind: 'quiet' })}</p>
--</header>
--
--<section class="np-sec" aria-labelledby="h-does"><h2 id="h-does">What it does today</h2>
--<div class="np-grid">${does}</div>
--</section>
--
--<section class="np-sec" id="install" aria-labelledby="h-install"><h2 id="h-install">Install it</h2>
--<p class="np-fine" style="margin:0 0 12px">Your system is open first; choose another to see its steps.</p>
--${installBlock(release, userAgent)}
--</section>
--
--<section class="np-sec" id="machines" aria-labelledby="h-machines"><h2 id="h-machines">Your machines</h2>
--<p class="np-fine">Sign in to see every machine you paired: whether it is online, what it is allowed to do, and the one-time code to add another. The same page renames or revokes one, and the phone pairs there too.</p>
--<p class="np-cta" style="margin-top:12px">${kit.button({ href: 'https://openvibe.my/machines', label: 'Open your machines', kind: 'primary' })} ${kit.button({ href: '/pair', label: 'Pair a machine', kind: 'quiet' })}</p>
--</section>
--
--<section class="np-sec" id="browsing"><h2>Your usual internet address</h2>
--<p>Your Actor's browsing on OpenVibe computers goes out through this computer's internet, so sites see your usual address.</p>
--<form class="np-send" data-egress-settings>
--<label><span><input type="checkbox" name="enabled" checked> Use my computer's internet</span></label>
--<label>When my computer is offline<select name="offline"><option value="openvibe">Use OpenVibe's address</option><option value="wait">Wait for my computer</option></select></label>
--<label>Monthly limit (GB)<input type="number" name="cap" min="0" max="10000" value="20" step="1"></label>
--<label>Sites that should use OpenVibe's address<textarea name="sites" placeholder="example.com, another.example"></textarea></label>
--<button type="submit">Save browsing settings</button><p role="status" data-egress-status></p>
--</form></section>
--<section class="np-sec" id="send" aria-labelledby="h-send"><h2 id="h-send">Send to my machine</h2>
--<p class="np-fine">Choose one of your paired machines and send a file or a short text note. Link encrypts it for that machine before it leaves this page; the relay only holds the encrypted envelope for up to 24 hours.</p>
--<form class="np-send" data-link-send>
--<label>Send to <select name="machine" required><option value="">Loading your machines…</option></select></label>
--<label>File <input name="file" type="file"></label>
--<label>Or a text note <textarea name="text" rows="3" maxlength="10000" placeholder="Write a short note"></textarea></label>
--<button type="submit">Encrypt and send</button><p role="status" aria-live="polite" data-send-status></p>
--</form>
--</section>
--
--${releaseBlock(release)}
--
--<section class="np-sec" aria-labelledby="h-never"><h2 id="h-never">What it never does</h2>
--<ul class="np-never">${NEVER.map((n) => `<li>${esc(n)}</li>`).join('')}</ul>
--</section>
--
--<section class="np-sec" aria-labelledby="h-docs"><h2 id="h-docs">The docs</h2>
--<ul class="np-docs">
--<li><a href="${DOCS}/install.md">Install</a> &mdash; every OS, every option, and what the installer does.</li>
--<li><a href="${DOCS}/computer.md">Your computer for your agents</a> &mdash; the computer plugin, its levels and the live screen.</li>
--<li><a href="${DOCS}/worker.md">A machine that runs your jobs</a> &mdash; the sandbox the job runner uses.</li>
--<li><a href="${DOCS}/protocol.md">The protocol</a> &mdash; the wire format between Link and OpenVibe.Bot.</li>
--<li><a href="${DOCS}/memory.md">Your memory</a> &mdash; what the collectors read and how it is uploaded.</li>
--</ul>
--<p class="np-fine">The source is on <a href="https://github.com/OpenVibers/OpenVibe.Node">GitHub</a>. The installer also lives at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working.</p>
--</section>
--</main>
--${require('openvibe-shared/footer').ssr({ service: 'network', variant: 'compact' })}
--<script src="/shared/navbar.js" defer></script>
--${require('../boost').SCRIPT}
--<script src="/shared/kit-web.js" defer></script>
--<script type="module" src="/send.js"></script>
--<script src="/egress-settings.js" defer></script>
--<script>document.addEventListener('DOMContentLoaded',function(){try{if(window.OpenVibeNavbar)OpenVibeNavbar.init({service:'network',apiBase:'https://openvibe.network',sessionUrl:'/auth/me',loginUrl:'/auth/login?next={path}',logoutUrl:'/auth/logout?next={url}',silentLogin:'/auth/login?silent=1&next={url}',links:[{label:'Link',href:'/',icon:'fa-desktop',featured:true,active:true},{label:'Machines',href:'/machines',icon:'fa-laptop'},{label:'Network',href:'https://openvibe.network/'},{label:'My Account',href:'https://openvibe.network/my'}]});}catch(e){}});</script>
--</body>
--</html>`;
--}
--
--const CSS = `
--.np{max-width:1000px;margin:0 auto;padding:32px 20px 72px}
--.np-kicker{margin:0 0 6px;font-size:.78rem;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--accent,#3b82f6)}
--.np h1{margin:0 0 12px;font-size:clamp(1.7rem,5vw,2.6rem);letter-spacing:-.02em;line-height:1.12}
--.np-lede{margin:0 0 18px;max-width:760px;color:var(--text-secondary,#96a7c2);line-height:1.65;font-size:1.03rem}
--.np-lede code,.np code{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:.9em}
--.np-cta{display:flex;flex-wrap:wrap;gap:10px;margin:0}
--.np-sec{margin-top:44px}
--.np-send{display:grid;gap:12px;max-width:560px;padding:18px;border:1px solid var(--border,#1f2d47);border-radius:16px;background:var(--bg-card,#0f1729)}
--.np-send label{display:grid;gap:6px;font-size:.92rem;font-weight:650}
--.np-send input,.np-send select,.np-send textarea{width:100%;box-sizing:border-box;padding:10px;border:1px solid var(--border,#1f2d47);border-radius:9px;background:var(--bg-primary,#080d18);color:var(--text-primary,#eef2ff);font:inherit}
--.np-send button{justify-self:start;padding:10px 14px;border:0;border-radius:9px;background:var(--accent,#3b82f6);color:white;font:inherit;font-weight:700;cursor:pointer}
--.np-send [role=status]{margin:0;min-height:1.3em;color:var(--text-secondary,#96a7c2)}
--.np-sec h2{margin:0 0 14px;font-size:1.3rem;letter-spacing:-.01em}
--.np-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(260px,1fr));gap:14px}
--.np-does{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);display:flex;flex-direction:column;gap:6px}
--.np-does svg{width:22px;height:22px;color:var(--accent,#3b82f6)}
--.np-does h3{margin:2px 0 0;font-size:1rem;line-height:1.35}
--.np-does p{margin:0;color:var(--text-secondary,#96a7c2);font-size:.92rem;line-height:1.55}
--.np-soon{display:inline-block;margin-left:6px;padding:2px 8px;border-radius:999px;font-size:.68rem;font-weight:800;letter-spacing:.06em;text-transform:uppercase;color:var(--text-muted,#7386a3);border:1px solid var(--border,#1f2d47);vertical-align:middle}
--.np-installs{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:14px}
--.np-os-list{display:grid;gap:10px;max-width:860px}
--.np-os{border-radius:14px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);overflow:hidden}
--.np-os[open]{border-color:var(--accent,#3b82f6)}
--.np-os summary{display:flex;align-items:baseline;justify-content:space-between;gap:12px;padding:14px 16px;cursor:pointer;list-style:none}
--.np-os summary::-webkit-details-marker{display:none}
--.np-os summary span{font-weight:800;font-size:1rem}
--.np-os summary small{color:var(--text-muted,#7386a3);font-size:.82rem;text-align:right;margin-left:auto}
--.np-os summary::after{content:'+';margin-left:12px;font-weight:800;color:var(--text-muted,#7386a3);font-size:1.05rem;line-height:1}
--.np-os[open] summary::after{content:'−'}
--.np-os-body{padding:0 16px 16px;border-top:1px solid var(--border,#1f2d47)}
--.np-os-body p{color:var(--text-secondary,#96a7c2);font-size:.92rem;line-height:1.6}
--.np-os-body a{color:var(--accent,#3b82f6)}
--.np-install{padding:18px;border-radius:16px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47)}
--.np-install h3{margin:0 0 8px;font-size:1rem}
--.np-install .ovk-snip{margin:12px 0}
--.np-fine{margin:10px 0 0;font-size:.88rem;color:var(--text-muted,#7386a3);line-height:1.55}
--.np-warn{color:#f0b429}
--.np-never,.np-docs{list-style:none;margin:0;padding:0;display:grid;gap:8px;max-width:820px}
--.np-never li{position:relative;padding:11px 14px 11px 42px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);color:var(--text-secondary,#96a7c2);font-size:.93rem;line-height:1.55}
--.np-never li::before{content:"";position:absolute;left:15px;top:16px;width:13px;height:13px;border-radius:50%;background:color-mix(in srgb,var(--accent,#3b82f6) 22%,transparent);box-shadow:inset 0 0 0 2px var(--accent,#3b82f6)}
--.np-docs li{padding:11px 14px;border-radius:12px;background:var(--bg-card,#0f1729);border:1px solid var(--border,#1f2d47);font-size:.93rem;line-height:1.55}
--.np a:not(.ovk-btn){color:var(--accent,#3b82f6)}
--@media (max-width:640px){.np{padding:22px 16px 52px}}
--`;
--
--module.exports = { render, HOST, WWW_HOST, LEGACY_HOST, SITE, DOCS, INSTALL_LINE, DOES, NEVER };
ddiff --git a/server/node/release.js b/server/node/release.js
ddeleted file mode 100644
iindex cf664ad7..00000000
---- a/server/node/release.js
++++ /dev/null
@@@ -1,182 +0,0 @@
--'use strict';
--// The Node's latest release, read from GitHub (OpenVibers/OpenVibe.Node). The page shows the version and the
--// checksums; /install answers with the same release's installer.
--//
--// The answer is held for fifteen minutes in memory and on disk (under DATA_DIR), so a restart serves the last
--// good answer at once instead of waiting on GitHub. When the hold is over the visitor still gets the last
--// answer immediately and a refresh runs in the background; when GitHub cannot be reached the last good answer
--// is kept, and the page says how old it is only once it is more than a day old. A cold start without GitHub
--// falls back to the release links that always resolve (the "latest" ones).
--//
--// GitHub allows 60 unauthenticated calls an hour per IP. GITHUB_TOKEN, when set, raises that limit. The token
--// is read from the environment at request time and is never logged.
--
--const fs = require('fs');
--const path = require('path');
--
--const REPO = 'OpenVibers/OpenVibe.Node';
--const API = `https://api.github.com/repos/${REPO}/releases/latest`;
--const TTL_MS = 15 * 60 * 1000; // fifteen minutes
--const TIMEOUT_MS = 8000;
--const FAIL_COOLDOWN_MS = 60 * 1000; // a cold failure waits a minute before asking GitHub again
--const DAY_MS = 24 * 60 * 60 * 1000;
--const LATEST = `https://github.com/${REPO}/releases/latest`;
--const CACHE_FILE = 'node-release.json';
--
--// What the page says when GitHub cannot be reached and nothing was cached: no version (the page says "the latest
--// release" instead of naming one that may be old), and the links that always resolve to the newest release.
--const FALLBACK = {
-- version: '',
-- publishedAt: '',
-- url: LATEST,
-- installUrl: `https://github.com/${REPO}/releases/latest/download/install.sh`,
-- checksumsUrl: `https://github.com/${REPO}/releases/latest/download/SHA256SUMS`,
--};
--
--let cached = null; // { at, data } — at is when GitHub was read, epoch ms
--let inflight = null; // one fetch at a time
--let diskFile = null; // the file this process reads and writes
--let failedAt = 0; // a cold failure, so the next visitor does not wait 8 s too
--
--/** The release fields the page and /install use. */
--function shape(json) {
-- const tag = String(json.tag_name || '').trim();
-- const assets = Array.isArray(json.assets) ? json.assets : [];
-- const asset = (name) => assets.find((a) => a && a.name === name);
-- const install = asset('install.sh');
-- const sums = asset('SHA256SUMS');
-- return {
-- version: tag,
-- assets: assets.map((a) => String((a && a.name) || '')).filter(Boolean),
-- publishedAt: String(json.published_at || ''),
-- url: String(json.html_url || (tag ? `https://github.com/${REPO}/releases/tag/${tag}` : LATEST)),
-- installUrl: String((install && install.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/install.sh` : FALLBACK.installUrl)),
-- checksumsUrl: String((sums && sums.browser_download_url) || (tag ? `https://github.com/${REPO}/releases/download/${tag}/SHA256SUMS` : FALLBACK.checksumsUrl)),
-- };
--}
--
--/** GITHUB_TOKEN from the environment, or no Authorization at all. Never read from anywhere else, never logged. */
--function authorization() {
-- const token = String(process.env.GITHUB_TOKEN || '').trim();
-- return token ? { authorization: `Bearer ${token}` } : {};
--}
--
--/**
-- * Point the process at a data directory's copy, once per directory, and load what a previous process left.
-- * `null` (or an empty string) turns the disk cache off, which is what tests with an injected clock and fetch use.
-- */
--function useDisk(dataDir) {
-- if (dataDir === null || dataDir === '') return;
-- const dir = dataDir === undefined ? (process.env.DATA_DIR || 'data') : String(dataDir);
-- const file = path.join(path.resolve(dir), CACHE_FILE);
-- if (file === diskFile) return;
-- diskFile = file;
-- try {
-- const saved = JSON.parse(fs.readFileSync(file, 'utf8'));
-- if (!saved || !Number.isFinite(saved.at) || !saved.data || !String(saved.data.version || '').trim()) return;
-- if (!cached || saved.at > cached.at) cached = { at: saved.at, data: saved.data };
-- } catch {
-- // No copy yet, or an unreadable one: the first fetch writes a fresh one.
-- }
--}
--
--/** Keep the answer for the next process. A read-only data dir costs a restart's cache, never the page. */
--function saveDisk(entry) {
-- if (!diskFile) return;
-- try {
-- fs.mkdirSync(path.dirname(diskFile), { recursive: true });
-- const tmp = `${diskFile}.${process.pid}.tmp`;
-- fs.writeFileSync(tmp, JSON.stringify(entry));
-- fs.renameSync(tmp, diskFile);
-- } catch {
-- // The page still works from memory.
-- }
--}
--
--/** Ask GitHub once and keep the answer. Resolves to the shaped release; rejects when GitHub could not be read. */
--async function fetchLatest({ fetchImpl = globalThis.fetch, now = Date.now } = {}) {
-- const controller = typeof AbortController === 'function' ? new AbortController() : null;
-- const timer = controller ? setTimeout(() => controller.abort(), TIMEOUT_MS) : null;
-- try {
-- let res;
-- try {
-- res = await fetchImpl(API, {
-- signal: controller ? controller.signal : undefined,
-- headers: { accept: 'application/vnd.github+json', 'user-agent': 'openvibe-network', ...authorization() },
-- });
-- } finally {
-- if (timer) clearTimeout(timer);
-- }
-- if (!res || !res.ok) throw new Error(`GitHub releases API answered ${res && res.status}`);
-- const data = shape(await res.json());
-- if (!data.version) throw new Error('GitHub releases API answered without a tag');
-- cached = { at: now(), data };
-- failedAt = 0;
-- saveDisk(cached);
-- return { ...data, cached: false, stale: false, ageMs: 0 };
-- } catch (err) {
-- failedAt = now();
-- throw err;
-- }
--}
--
--/** The one background refresh that may be running. */
--function refresh(options) {
-- if (inflight) return inflight;
-- inflight = fetchLatest(options)
-- .catch(() => null)
-- .finally(() => { inflight = null; });
-- return inflight;
--}
--
--/**
-- * The latest release. Returns the fresh answer, or the last good answer at once while a refresh runs in the
-- * background; when a refresh fails the last good answer is kept and marked stale with its age. Never throws.
-- */
--async function latest({ fetchImpl = globalThis.fetch, now = Date.now, dataDir } = {}) {
-- useDisk(dataDir);
-- if (cached) {
-- const ageMs = Math.max(0, now() - cached.at);
-- if (ageMs < TTL_MS) return { ...cached.data, cached: true, stale: false, ageMs };
-- // Serving the last answer immediately and refreshing behind the request keeps the visitor off GitHub's
-- // clock. A failed refresh waits out the cooldown, so a long outage does not spend the hour's calls.
-- const cooling = failedAt && now() - failedAt < FAIL_COOLDOWN_MS;
-- if (!inflight && !cooling) refresh({ fetchImpl, now });
-- return { ...cached.data, cached: true, stale: true, ageMs };
-- }
-- if (failedAt && now() - failedAt < FAIL_COOLDOWN_MS) {
-- return { ...FALLBACK, cached: false, stale: true, ageMs: 0 };
-- }
-- try {
-- return await fetchLatest({ fetchImpl, now });
-- } catch {
-- return { ...FALLBACK, cached: false, stale: true, ageMs: 0 };
-- }
--}
--
--/** Resolves when no background refresh is running (tests use it instead of sleeping). */
--function whenIdle() {
-- return inflight || Promise.resolve();
--}
--
--/** How many whole days old the answer is; 0 while it is younger than a day. */
--function ageDays(release) {
-- const ms = Number((release && release.ageMs) || 0);
-- return ms >= DAY_MS ? Math.floor(ms / DAY_MS) : 0;
--}
--
--/** The version without the leading v, for a sentence. */
--function versionLabel(release) {
-- return String((release && release.version) || '').replace(/^v/, '');
--}
--
--/** The release date, the day it was published. */
--function dateLabel(release) {
-- const iso = String((release && release.publishedAt) || '');
-- if (!iso) return '';
-- const d = new Date(iso);
-- if (Number.isNaN(d.getTime())) return '';
-- return d.toISOString().slice(0, 10);
--}
--
--module.exports = { latest, refresh, whenIdle, ageDays, versionLabel, dateLabel, REPO, API, TTL_MS, TIMEOUT_MS, FAIL_COOLDOWN_MS, DAY_MS, FALLBACK, LATEST };
ddiff --git a/server/node/routes.js b/server/node/routes.js
ddeleted file mode 100644
iindex 0dba95e8..00000000
---- a/server/node/routes.js
++++ /dev/null
@@@ -1,155 +0,0 @@
--'use strict';
--// openvibe.link, served by Network: OpenVibe Link's front door (the Node renamed, 2026-11-07). The Host header
--// decides: on openvibe.link '/' is Link's page, '/install' answers with the installer of the latest release, and
--// the page has its Markdown twin, its llms.txt and its crawl files, like Network's other pages.
--//
--// node.openvibe.network is the old name and answers 301 to the same path on openvibe.link — except /install, which
--// keeps serving the installer, because `curl -fsSL https://node.openvibe.network/install | sh` is in people's notes.
--// www.openvibe.link answers 301 to openvibe.link.
--//
--// The route is mounted before the agent layer, so Link's page is not answered by Network's home renderer.
--
--const { markdownTwin, linkHeaderTags } = require('openvibe-shared/agent-layer');
--const fs = require('node:fs');
--const pathLib = require('node:path');
--const page = require('./page');
--const release = require('./release');
--
--const HOST = page.HOST;
--const WWW_HOST = page.WWW_HOST;
--const LEGACY_HOST = page.LEGACY_HOST;
--const SITE = page.SITE;
--
--/** The request's host, lowercased, without the port. */
--function hostOf(req) {
-- return String((req.headers && req.headers.host) || '').split(':')[0].toLowerCase();
--}
--
--/** Link's llms.txt: what the page is, how to install it, and where the docs are. */
--function llmsTxt() {
-- return [
-- '# OpenVibe Link',
-- '',
-- '> OpenVibe Link is one program (openvibe-link; openvibe-node is its alias) that runs on your computer, server, phone or robot. It connects out to OpenVibe, needs no open ports, keeps a local kill switch, and does nothing you did not turn on.',
-- '',
-- '## The page',
-- '',
-- `- [OpenVibe Link: your machines, connected](${SITE}/): what it is, what it does today (run your agents and code, offer your models or GPU and earn credits, drive robots, stream an agent's desk, keep your memory read-only), how to install it on each OS, and what it never does`,
-- '',
-- '## Install',
-- '',
-- `- [Install Link](${SITE}/install): the latest release's installer, as a script to run`,
-- `- [Your machines](${SITE}/machines): the signed-in machines hub (pair, capabilities, online state), at openvibe.my/machines`,
-- '',
-- '```sh',
-- page.INSTALL_LINE,
-- '```',
-- '',
-- '## The docs',
-- '',
-- `- [Install](${page.DOCS}/install.md): every OS, every option, and what the installer does`,
-- `- [Your computer for your agents](${page.DOCS}/computer.md): the computer plugin, its levels and the live screen`,
-- `- [A machine that runs your jobs](${page.DOCS}/worker.md): the sandbox the job runner uses`,
-- `- [The protocol](${page.DOCS}/protocol.md): the wire format between the Node and OpenVibe.Bot`,
-- `- [Your memory](${page.DOCS}/memory.md): what the collectors read and how it is uploaded`,
-- '- [The source on GitHub](https://github.com/OpenVibers/OpenVibe.Node)',
-- '',
-- ].join('\n');
--}
--
--/**
-- * Link's routes. Returns null for a request that is not for this host, so the router can be mounted early
-- * and every other host falls through to Network's own pages.
-- */
--function createNodeRoutes({ releaseId, releaseApi = release } = {}) {
-- const router = require('express').Router();
-- router.use(require('./egress').publicRouter());
--
-- router.use(async (req, res, next) => {
-- const host = hostOf(req);
-- if (host !== HOST && host !== WWW_HOST && host !== LEGACY_HOST) return next();
--
-- const url = new URL(req.url, SITE);
-- const path = url.pathname;
--
-- // www is not a second name for Link: every path lands on the apex, and nginx does the same before us.
-- if (host === WWW_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
--
-- // The installer: the latest release's own asset, so `curl -fsSL https://openvibe.link/install | sh`
-- // (and the old node.openvibe.network line) installs that release. The same cached answer the page shows.
-- // It is answered before the legacy-host redirect: this is the one path the old name keeps serving itself.
-- if (path === '/install') {
-- let rel;
-- try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
-- const target = rel && rel.installUrl ? rel.installUrl : release.FALLBACK.installUrl;
-- return res.set('Cache-Control', 'public, max-age=600').redirect(302, target);
-- }
--
-- // The old host is a signpost, not a second home: the same path on the new one.
-- if (host === LEGACY_HOST) return res.redirect(301, `${SITE}${path}${url.search}`);
--
-- // Your machines and pairing are the signed-in account hub's own pages (openvibe.my/machines): it lists every
-- // paired machine, whether it is online, what it is allowed to do, and pairs a new one. Link links there rather
-- // than keeping a second machines UI here. Link's local OAuth session supplies its navbar identity.
-- if (path === '/machines' || path === '/pair') {
-- return res.set('Cache-Control', 'no-store').redirect(302, 'https://openvibe.my/machines');
-- }
--
-- if (path === '/llms.txt') {
-- return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(llmsTxt());
-- }
--
-- // The crawl files of this host, so a crawler on openvibe.link is told about this page and not about
-- // Network's (the apex serves its own). IndexNow's key file (when INDEXNOW_KEY is set) is served by the
-- // app's global middleware on this host too, so an engine can fetch it at https://openvibe.link/<key>.txt.
-- if (path === '/robots.txt') {
-- const robots = require('openvibe-shared/seo').robotsTxt({ sitemaps: [`${SITE}/sitemap.xml`] });
-- return res.type('text/plain').set('Cache-Control', 'public, max-age=3600').send(robots);
-- }
-- if (path === '/sitemap.xml') {
-- const xml = require('openvibe-shared/seo').sitemapXml([{ loc: `${SITE}/`, changefreq: 'weekly', priority: 1.0 }]);
-- return res.type('application/xml').set('Cache-Control', 'public, max-age=3600').send(xml);
-- }
--
-- if (path === '/locker.mjs' && (req.method === 'GET' || req.method === 'HEAD')) {
-- let source;
-- try { source = fs.readFileSync(pathResolveLocker(), 'utf8'); }
-- catch { return res.status(503).type('text/plain').send('Locker is not available right now.'); }
-- res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=86400',
-- 'Access-Control-Allow-Origin': '*', 'X-Content-Type-Options': 'nosniff' });
-- return res.end(req.method === 'HEAD' ? undefined : source);
-- }
-- if (path === '/send.js' && (req.method === 'GET' || req.method === 'HEAD')) {
-- const source = fs.readFileSync(pathLib.join(__dirname, 'send.js'), 'utf8');
-- res.set({ 'Content-Type': 'text/javascript; charset=utf-8', 'Cache-Control': 'public, max-age=600', 'X-Content-Type-Options': 'nosniff' });
-- return res.end(req.method === 'HEAD' ? undefined : source);
-- }
--
-- if (path !== '/' && path !== '/index.md' && path !== '/index.html') return next();
--
-- const isTwin = path === '/index.md' || url.searchParams.get('format') === 'markdown'
-- || (req.headers.accept && req.accepts(['html', 'text/markdown']) === 'text/markdown');
-- let rel;
-- try { rel = await releaseApi.latest(); } catch { rel = release.FALLBACK; }
-- const html = page.render({ release: rel, releaseId, userAgent: req.headers && req.headers['user-agent'] });
-- const canonical = `${SITE}/`;
-- const links = linkHeaderTags(canonical, `${SITE}/llms.txt`);
-- res.setHeader('Link', [res.getHeader('Link'), links.header].filter(Boolean).join(', '));
-- res.setHeader('Vary', [res.getHeader('Vary'), 'Accept'].filter(Boolean).join(', '));
-- res.setHeader('Cache-Control', 'public, max-age=600');
-- if (isTwin) {
-- res.setHeader('Content-Type', 'text/markdown; charset=utf-8');
-- return res.end(req.method === 'HEAD' ? undefined : markdownTwin({ html, title: 'OpenVibe Link: your machines, connected', canonical }));
-- }
-- res.setHeader('Content-Type', 'text/html; charset=utf-8');
-- return res.end(req.method === 'HEAD' ? undefined : html);
-- });
--
-- return router;
--}
--
--function pathResolveLocker() {
-- return pathLib.join(pathLib.dirname(require.resolve('openvibe-sdk')), 'browser', 'openvibe-sdk.mjs');
--}
--
--module.exports = { createNodeRoutes, llmsTxt, hostOf, HOST, WWW_HOST, LEGACY_HOST, SITE };
ddiff --git a/server/node/send-relay.js b/server/node/send-relay.js
ddeleted file mode 100644
iindex eea38364..00000000
---- a/server/node/send-relay.js
++++ /dev/null
@@@ -1,183 +0,0 @@
--'use strict';
--
--// Link Send's relay stores only Locker envelopes. A sender and recipient are resolved from node tokens, and both
--// principals must still belong to the same user. Nothing in this module opens or inspects an envelope.
--const crypto = require('crypto');
--const express = require('express');
--const { http } = require('openvibe-contracts');
--const { ensureUserSubject } = require('../identity/subjects');
--
--const MAX_ENVELOPE = 32 * 1024 * 1024;
--const MAX_OWNER_ITEMS = 100;
--const MAX_OWNER_BYTES = 128 * 1024 * 1024;
--const TTL_MS = 24 * 60 * 60 * 1000;
--const id = () => `snd_${crypto.randomBytes(16).toString('hex')}`;
--const bad = (status, code, message) => Object.assign(new Error(message), { status, code });
--
--async function principal(db, req) {
-- const sub = String(req.principal?.sub || '');
-- if (!sub.startsWith('node:')) throw bad(403, 'send.machine_required', 'only a paired machine can use this route');
-- const row = await db.prepare("SELECT id, owner_subject, owner_kind, status FROM platform_node_principals WHERE id = ?").get(sub.slice(5));
-- if (!row || row.status !== 'active' || row.owner_kind !== 'user' || !row.owner_subject) {
-- throw bad(401, 'send.machine_unavailable', 'this machine is no longer paired to an account');
-- }
-- return row;
--}
--
--function router({ nodeGuard, requireAuth, db }) {
-- const self = express.Router();
-- self.use(http.middleware(), nodeGuard);
-- self.put('/key', express.json({ limit: '4kb' }), async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const machine = await principal(db, req);
-- const key = req.body?.public_key;
-- if (!key || key.kty !== 'EC' || key.crv !== 'P-256' || typeof key.x !== 'string' || typeof key.y !== 'string' || Object.keys(key).some((k) => !['kty', 'crv', 'x', 'y', 'ext', 'key_ops'].includes(k))) {
-- throw bad(400, 'send.bad_key', 'Link needs a public P-256 receive key');
-- }
-- const jwk = { kty: key.kty, crv: key.crv, x: key.x, y: key.y };
-- let thumbprint;
-- try {
-- crypto.createPublicKey({ key: jwk, format: 'jwk' });
-- thumbprint = crypto.createHash('sha256').update(JSON.stringify({ crv: 'P-256', kty: 'EC', x: jwk.x, y: jwk.y })).digest('base64url');
-- } catch { throw bad(400, 'send.bad_key', 'Link needs a valid public P-256 receive key'); }
-- await db.prepare(`INSERT INTO link_send_keys (node_id, public_key, key_thumbprint, updated_at) VALUES (?, ?::jsonb, ?, now())
-- ON CONFLICT (node_id) DO UPDATE SET public_key = EXCLUDED.public_key, key_thumbprint = EXCLUDED.key_thumbprint, updated_at = now()`)
-- .run(machine.id, JSON.stringify(jwk), thumbprint);
-- res.set('Cache-Control', 'no-store').json({ node_id: machine.id, key_thumbprint: thumbprint });
-- } catch (e) { return sendError(res, e); }
-- });
-- self.get('/inbox', async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const target = await principal(db, req);
-- // The list is polled every few seconds while Link's window is open: names and sizes only. The ciphertext
-- // is fetched once, by accept, when the person chooses to receive it.
-- const rows = await db.prepare(`SELECT i.id, i.sender_node, s.name AS sender_name, octet_length(i.envelope) AS bytes,
-- i.created_at, i.expires_at
-- FROM link_send_items i LEFT JOIN platform_node_principals s ON s.id = i.sender_node
-- WHERE i.target_node = ? AND i.state = 'pending' AND i.expires_at > now() ORDER BY i.created_at`).all(target.id);
-- res.set('Cache-Control', 'no-store').json({ items: rows.map((r) => ({
-- id: r.id, sender_node: r.sender_node || null,
-- sender_name: r.sender_node ? (r.sender_name || 'Link machine') : 'openvibe.link',
-- bytes: Number(r.bytes) || 0, created_at: r.created_at, expires_at: r.expires_at,
-- })) });
-- } catch (e) { return sendError(res, e); }
-- });
-- self.get('/targets', async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const sender = await principal(db, req);
-- const rows = await db.prepare(`SELECT n.id, n.name, n.last_seen_at, k.public_key
-- FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
-- WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active' AND n.id <> ?
-- ORDER BY n.created_at DESC, n.id`).all(sender.owner_subject, sender.id);
-- res.set('Cache-Control', 'no-store').json({ machines: rows.map((r) => ({
-- id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
-- public_key: r.public_key,
-- })) });
-- } catch (e) { return sendError(res, e); }
-- });
-- self.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const sender = await principal(db, req);
-- const targetId = String(req.body?.target_node || '');
-- const envelope = req.body?.envelope;
-- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
-- if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
-- throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
-- }
-- const target = await db.prepare("SELECT id FROM platform_node_principals WHERE id = ? AND status = 'active' AND owner_kind = 'user' AND owner_subject = ?").get(targetId, sender.owner_subject);
-- if (!target || target.id === sender.id) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
-- await assertQuota(db, sender.owner_subject, Buffer.byteLength(envelope));
-- const itemId = id();
-- const expires = new Date(Date.now() + TTL_MS).toISOString();
-- await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, ?, ?, ?, ?)")
-- .run(itemId, sender.owner_subject, sender.id, target.id, envelope, expires);
-- res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
-- } catch (e) { return sendError(res, e); }
-- });
-- self.post('/:id/:decision', async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const target = await principal(db, req);
-- const decision = req.params.decision;
-- if (!['accept', 'decline', 'complete'].includes(decision)) throw bad(400, 'send.bad_decision', 'choose accept, decline or complete');
-- const row = await db.prepare("SELECT id, envelope FROM link_send_items WHERE id = ? AND target_node = ? AND state = 'pending' AND expires_at > now()")
-- .get(req.params.id, target.id);
-- if (!row) throw bad(404, 'send.item_unavailable', 'that item is no longer waiting');
-- if (decision === 'decline' || decision === 'complete') {
-- await db.prepare("DELETE FROM link_send_items WHERE id = ? AND target_node = ?").run(row.id, target.id);
-- return res.status(204).end();
-- }
-- res.set('Cache-Control', 'no-store').json({ id: row.id, envelope: row.envelope });
-- } catch (e) { return sendError(res, e); }
-- });
--
-- const keys = express.Router();
-- keys.use(http.middleware(), requireAuth);
-- keys.get('/', async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const me = await ensureUserSubject(db, req.user);
-- const rows = await db.prepare(`SELECT n.id, n.name, n.status, n.last_seen_at, k.public_key
-- FROM platform_node_principals n LEFT JOIN link_send_keys k ON k.node_id = n.id
-- WHERE n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'
-- ORDER BY n.created_at DESC, n.id`).all(me);
-- res.set('Cache-Control', 'private, no-store').json({ machines: rows.map((r) => ({
-- id: r.id, name: r.name || 'Link machine', online: r.last_seen_at && Date.now() - Date.parse(r.last_seen_at) < 5 * 60_000,
-- public_key: r.public_key || null,
-- })) });
-- } catch (e) { return sendError(res, e); }
-- });
-- // The browser sender on openvibe.link: POST /api/v1/me/nodes/send, as the signed-in person.
-- const web = express.Router();
-- web.use(http.middleware(), requireAuth);
-- web.post('/', express.json({ limit: MAX_ENVELOPE }), async (req, res) => {
-- try {
-- const db = req.app.locals.db;
-- const owner = await ensureUserSubject(db, req.user);
-- const targetId = String(req.body?.target_node || '');
-- const envelope = req.body?.envelope;
-- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(targetId)) throw bad(400, 'send.bad_target', 'choose one of your paired machines');
-- if (typeof envelope !== 'string' || !envelope || Buffer.byteLength(envelope) > MAX_ENVELOPE || !/^[A-Za-z0-9+/]+={0,2}$/.test(envelope)) {
-- throw bad(400, 'send.bad_envelope', 'the encrypted file is missing or too large');
-- }
-- const target = await db.prepare(`SELECT n.id FROM platform_node_principals n JOIN link_send_keys k ON k.node_id = n.id
-- WHERE n.id = ? AND n.owner_kind = 'user' AND n.owner_subject = ? AND n.status = 'active'`).get(targetId, owner);
-- if (!target) throw bad(404, 'send.target_unavailable', 'that machine is not available on your account');
-- await assertQuota(db, owner, Buffer.byteLength(envelope));
-- const itemId = id(), expires = new Date(Date.now() + TTL_MS).toISOString();
-- await db.prepare("INSERT INTO link_send_items (id, owner_subject, sender_node, target_node, envelope, expires_at) VALUES (?, ?, NULL, ?, ?, ?)")
-- .run(itemId, owner, target.id, envelope, expires);
-- res.set('Cache-Control', 'no-store').status(201).json({ id: itemId, expires_at: expires });
-- } catch (e) { return sendError(res, e); }
-- });
-- const cleanup = setInterval(() => {
-- if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
-- }, 15 * 60_000);
-- cleanup.unref?.();
-- if (db) cleanupExpired(db).catch((e) => console.error('[Link Send] relay expiry cleanup failed:', e.message));
-- return { self, keys, web };
--}
--
--async function assertQuota(db, owner, adding) {
-- const quota = await db.prepare("SELECT COUNT(*) AS items, COALESCE(SUM(octet_length(envelope)), 0) AS bytes FROM link_send_items WHERE owner_subject = ? AND expires_at > now()").get(owner);
-- if (Number(quota.items) >= MAX_OWNER_ITEMS || Number(quota.bytes) + adding > MAX_OWNER_BYTES) {
-- throw bad(429, 'send.queue_full', 'your transfer queue is full; accept or decline a waiting item first');
-- }
--}
--
--async function cleanupExpired(db) {
-- await db.prepare("DELETE FROM link_send_items WHERE expires_at <= now()").run();
--}
--
--function sendError(res, e) {
-- const status = Number(e.status) || 500;
-- const code = e.code || 'send.failed';
-- const message = status < 500 ? e.message : 'Link could not complete that transfer';
-- return http.sendProblem(res, status, code, { detail: message });
--}
--
--module.exports = { router, principal, MAX_ENVELOPE, MAX_OWNER_ITEMS, MAX_OWNER_BYTES, TTL_MS, assertQuota, cleanupExpired };
ddiff --git a/server/node/send.js b/server/node/send.js
ddeleted file mode 100644
iindex b4039e17..00000000
---- a/server/node/send.js
++++ /dev/null
@@@ -1,65 +0,0 @@
--import { locker } from '/locker.mjs';
--
--const form = document.querySelector('[data-link-send]');
--if (form) {
-- const select = form.elements.machine;
-- const status = form.querySelector('[data-send-status]');
-- const say = (text) => { status.textContent = text; };
-- const machines = async () => {
-- const r = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
-- if (r.status === 401 || r.status === 403) throw new Error('Sign in on openvibe.link to choose one of your machines.');
-- if (!r.ok) throw new Error('Your machines could not be loaded.');
-- const body = await r.json();
-- select.replaceChildren(new Option('Choose a machine', ''));
-- for (const m of body.machines || []) {
-- if (!m.public_key) continue;
-- select.add(new Option(`${m.name}${m.online ? '' : ' (offline)'}`, m.id));
-- }
-- if (select.options.length === 1) say('Pair a machine and open Link once to turn on receiving.');
-- };
-- const base64 = (bytes) => {
-- let out = '';
-- for (let i = 0; i < bytes.length; i += 0x8000) out += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
-- return btoa(out);
-- };
-- async function packageContent(file, text) {
-- let name, mime, data;
-- if (file) { name = file.name; mime = file.type || 'application/octet-stream'; data = new Uint8Array(await file.arrayBuffer()); }
-- else { name = 'note.txt'; mime = 'text/plain;charset=utf-8'; data = new TextEncoder().encode(text); }
-- const meta = new TextEncoder().encode(JSON.stringify({ name, mime }));
-- const out = new Uint8Array(4 + meta.length + data.length);
-- new DataView(out.buffer).setUint32(0, meta.length);
-- out.set(meta, 4); out.set(data, 4 + meta.length);
-- return out;
-- }
-- machines().catch((e) => { select.replaceChildren(new Option('Sign in to load machines', '')); say(e.message); });
-- form.addEventListener('submit', async (event) => {
-- event.preventDefault();
-- const machine = (form.elements.machine.value);
-- const file = form.elements.file.files[0];
-- const text = form.elements.text.value;
-- if (!machine) return say('Choose one of your machines.');
-- if (!file && !text.trim()) return say('Choose a file or write a note.');
-- if (file && file.size > 20 * 1024 * 1024) return say('Choose a file smaller than 20 MB for now.');
-- try {
-- say('Encrypting in this browser…');
-- const listed = await fetch('/api/v1/me/nodes/send-keys', { credentials: 'same-origin', cache: 'no-store' });
-- if (!listed.ok) throw new Error('Sign in on openvibe.link, then try again.');
-- const target = (await listed.json()).machines.find((m) => m.id === machine && m.public_key);
-- if (!target) throw new Error('That machine has no receive key yet. Open Link on it and try again.');
-- const payload = await packageContent(file, text);
-- const sealed = await locker.seal(payload, { lock: 'people', keys: [{ subject: { type: 'node', id: target.id }, publicKey: target.public_key }] });
-- say('Sending encrypted data…');
-- const sent = await fetch('/api/v1/me/nodes/send', {
-- method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify({ target_node: target.id, envelope: base64(sealed.bytes) }),
-- });
-- if (!sent.ok) {
-- const detail = await sent.json().catch(() => ({}));
-- throw new Error(detail.detail || 'Link could not send this item.');
-- }
-- say('Sent. The receiving machine will ask before saving it.');
-- form.elements.file.value = ''; form.elements.text.value = '';
-- } catch (e) { say(e.message || 'Link could not send this item.'); }
-- });
--}
ddiff --git a/server/registry/ai-offers.js b/server/registry/ai-offers.js
iindex f0fa3e66..f7260dce 100644
---- a/server/registry/ai-offers.js
++++ b/server/registry/ai-offers.js
@@@ -1,6 +1,6 @@
'use strict';
/**
-- * Community AI offers in OpenVibe.Network (the design's "Offer to the network"; plan E4). OpenVibe.Node's ai plugin
++ * Community AI offers in OpenVibe.Network (the design's "Offer to the network"; plan E4). OpenVibe.Link's ai plugin
* advertises one machine's local models, hours, concurrency, credit price and audience; OpenVibe.Bot reports the
* complete set for source `bot` (POST /internal/ai/offers/report, network.resource.report); a missing offer is marked
* down, never deleted. OpenVibe.AI reads the live ones whole with network.registry.read (GET /internal/ai/offers) and
ddiff --git a/server/registry/cells.js b/server/registry/cells.js
iindex 69211d5e..02bfd46e 100644
---- a/server/registry/cells.js
++++ b/server/registry/cells.js
@@@ -2,9 +2,9 @@
/**
* Cells and node principals (plan T2 "cells now, hardware later", docs/t2-resource-registry.md section 9;
* migrations/0008_cells_and_node_principals.sql). Network is the authority for where things run: regions, cells, the
-- * node principal of every machine (nod_<ULID>: owner, trust class, home cell) and the service instances on them.
++ * node principal of every machine (lnk_<ULID>: owner, trust class, home cell) and the service instances on them.
*
-- * The node-principal boundary: a report never assigns identity. Host's node report (network.node.report) creates
++ * The node-principal boundary: a report never assigns identity. Host's node report (network.link.report) creates
* platform-owned, first-party principals for machines it is the first to name, and is refused for a machine that a
* project owns or that was revoked. A resource offer must name a known cell, and an offer for a registered node must
* carry that node's home cell and trust class. Today there is one cell, wnam-1 (us-west), seeded by the migration.
@@@ -13,7 +13,7 @@ const express = require('express');
const { ids, http } = require('openvibe-contracts');
const BOOTSTRAP_CELL = 'wnam-1';
--const NODE_ID = /^[a-z][a-z0-9-]{1,39}$/; // network.node@1's id, and the 0008 CHECK
++const NODE_ID = /^[a-z][a-z0-9-]{1,39}$/; // network.link@1's id, and the 0008 CHECK
class RegistryError extends Error {
constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
@@@ -52,7 +52,7 @@ async function adoptPlatformNodes(db, nodes, by, now = new Date().toISOString())
let added = 0;
for (const n of nodes) {
const cell = await cellForRegion(db, n.location && n.location.region);
-- added += (await insert.run(`nod_${ids.ulid(Date.parse(now) || Date.now())}`, n.id, cell, now, by, now)).changes || 0;
++ added += (await insert.run(`lnk_${ids.ulid(Date.parse(now) || Date.now())}`, n.id, cell, now, by, now)).changes || 0;
}
return added;
}
@@@ -157,9 +157,9 @@ function routers({ readGuard }) {
res.json({ ...t, generated_at: new Date().toISOString() });
});
// Every node principal (platform, project and person-owned), newest first; ?cell=&owner_kind=&status= narrow it.
-- internal.get('/node-principals', readGuard, async (req, res) => {
++ internal.get('/link-principals', readGuard, async (req, res) => {
res.set('Cache-Control', 'no-store');
-- const nodePrincipals = await require('./node-principals').listPrincipals(req.app.locals.db, require('./node-principals').principalFilter(req.query));
++ const nodePrincipals = await require('./link-principals').listPrincipals(req.app.locals.db, require('./link-principals').principalFilter(req.query));
res.json({ node_principals: nodePrincipals, generated_at: new Date().toISOString() });
});
return { pub, internal };
ddiff --git a/server/registry/instances.js b/server/registry/instances.js
iindex 2a51570c..edc2e42c 100644
---- a/server/registry/instances.js
++++ b/server/registry/instances.js
@@@ -3,7 +3,7 @@
* Service instances (plan T2, docs/t2-cells-and-node-principal.md section 4.1; Contracts
* platform.service-instance@1; migrations/0008_cells_and_node_principals.sql): what runs on the platform's machines —
* one row per running instance with its cell, node, endpoints and state. Host reports the complete set its inventory
-- * knows (POST /internal/registry/instances/report, network.node.report); an instance absent from a later report of the
++ * knows (POST /internal/registry/instances/report, network.link.report); an instance absent from a later report of the
* same source is set to `stopped`, never deleted. Placement is checked against what Network holds before anything is
* written: the cell must exist and not be retired, the node must be a live principal whose home cell is the instance's
* cell, and the instance's region must be the cell's region. Endpoints are stored as JSON text; no public route
@@@ -12,7 +12,7 @@
const express = require('express');
const CONTRACT = 'platform.service-instance@1';
--const SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.node-report-request@1's source
++const SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.link-report-request@1's source
const MAX_INSTANCES = 500;
function ensureSchema(db) { /* the schema is migrations/0008_cells_and_node_principals.sql; nothing is created at runtime */ }
ddiff --git a/server/registry/link-principals.js b/server/registry/link-principals.js
nnew file mode 100644
iindex 00000000..af5aa29b
---- /dev/null
++++ b/server/registry/link-principals.js
@@@ -0,0 +1,546 @@
++'use strict';
++/**
++ * Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2, slice N4b;
++ * migrations/0014_node_pairing.sql). A service holding network.link.manage (Bot) mints a one-time pairing code for one
++ * of its users; the machine redeems it with no other credential and becomes a node principal (lnk_<ULID>) owned by that
++ * person, with a long-lived credential shown once. The service may then read and revoke only the principals it paired.
++ *
++ * Secrets: the code and the credential are shown once and stored only as their sha256; neither is ever logged nor
++ * returned by a read. A paired principal is never a platform machine: no platform_nodes row is created, and Host's node
++ * report naming its node_id is refused (cells.checkPlatformNodes, 409 registry.node_not_platform).
++ *
++ * POST /internal/link-pairings network.link.manage → 201 {pairing_id, code, expires_at}
++ * POST /api/v1/link-pairing none (the code) → 201 {principal, node_id, home_cell, credential, …}
++ * GET /internal/link-principals/:id network.link.manage → the principal, if the caller paired it
++ * POST /internal/link-principals/:id/revoke network.link.manage → the principal, revoked (idempotent)
++ * GET /api/v1/me/nodes session → the person's own machines, newest first
++ * POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
++ *
++ * Every revoke — the service route, the owner's route, or an account deletion — writes network.link.revoked@1 into
++ * the outbox in the same transaction (Contracts 0.98.0; docs/t2-cells-and-node-principal.md section 10): a service
++ * holding the machine's session stops it at once, not at its next reauth (≤ 330 s). A second revoke emits nothing;
++ * the payload carries the ids, the owner and the reason, never the credential or its hash.
++ *
++ * The paired machine itself (section 4.3, slice N4c): its credential buys a node token (sub link:lnk_…) at
++ * POST /oauth/token, and with it the machine manages only its own row. The routes resolve the node from the verified
++ * token, never from the body or the path.
++ * POST /oauth/token client_id=lnk_… the credential → 200 node token (oauth-routes.js)
++ * PUT /api/v1/link/self/capabilities network.link.self.manage → 200 {node_id, reported_at}
++ * POST /api/v1/link/self/credential network.link.self.manage → 200 {principal, node_id, credential, …}
++ */
++const crypto = require('crypto');
++const express = require('express');
++const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
++const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
++const { TOKEN_TTL_S, SELF_AUDIENCE, NODE_GRANTS } = require('../identity/principals');
++const eventRelay = require('../developer/event-relay');
++
++const CODE_TTL_MS = 10 * 60 * 1000;
++const MAX_TRIES = 5;
++const PREV_GRACE_MS = 60 * 1000; // a rotated-out credential still works this long (Bot's grace window)
++const SEEN_EVERY_MS = 60 * 1000; // last_seen_at is written at most this often
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PAIRING_ID = /^pair_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PRINCIPAL_ID = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
++const REGION = /^[a-z][a-z0-9-]{1,39}$/;
++const CONTROL = /[\u0000-\u001f\u007f]/;
++
++// Crockford base32 (Bot's alphabet): no I, L, O or U, so a code read aloud cannot be mistyped into another.
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
++const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
++const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
++function newCode() {
++ const b = crypto.randomBytes(8);
++ let s = '';
++ for (let i = 0; i < 8; i++) s += CROCKFORD[b[i] % 32]; // 256 % 32 = 0: no bias
++ return s;
++}
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++/** Constant-time equality of two stored hashes. */
++function sameHash(a, b) {
++ const x = Buffer.from(String(a || ''));
++ const y = Buffer.from(String(b || ''));
++ if (x.length !== y.length) { crypto.timingSafeEqual(x, x); return false; }
++ return crypto.timingSafeEqual(x, y);
++}
++const text = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !CONTROL.test(v) ? v.trim() : null;
++const iso = (ms) => new Date(ms).toISOString();
++
++/** The first active cell of a region, else wnam-1 (the same rule as cells.js cellForRegion, for a paired machine). */
++async function cellForRegion(db, region) {
++ const row = region ? await db.prepare("SELECT id FROM platform_cells WHERE region = ? AND status = 'active' ORDER BY id LIMIT 1").get(region) : null;
++ return row ? row.id : BOOTSTRAP_CELL;
++}
++
++/** The service a verified service token speaks for: svc:<id> → <id>. */
++const serviceOf = (req) => String(req.principal && req.principal.sub || '').replace(/^svc:/, '');
++
++/** What a service or the owner may see of a principal: never a hash. */
++const principalView = (p) => ({
++ principal: p.id, node_id: p.node_id, name: p.name || null,
++ owner: p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject } : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id } : { kind: p.owner_kind },
++ home_cell: p.home_cell, status: p.status,
++ paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,
++ last_seen_at: p.last_seen_at || null, created_at: p.created_at, revoked_at: p.revoked_at || null,
++});
++
++/**
++ * Mint a pairing code for `service`'s user. Replaces that service's unused codes for the same ref.
++ * Throws RegistryError. → { pairing_id, code, expires_at } (the code in the clear, this once).
++ */
++async function createPairing(db, service, body, now = Date.now()) {
++ const owner = body && body.owner;
++ if (owner && owner.kind === 'project') throw new RegistryError(501, 'registry.not_yet', 'project-owned machines are not paired yet');
++ if (!owner || owner.kind !== 'user' || typeof owner.subject !== 'string' || !SUBJECT.test(owner.subject)) {
++ throw new RegistryError(400, 'registry.invalid_pairing_request', "owner must be {kind: 'user', subject: 'usr_…'}");
++ }
++ const ref = text(body.ref, 80);
++ if (!ref) throw new RegistryError(400, 'registry.invalid_pairing_request', 'ref must be 1-80 characters');
++ if (body.home_cell !== undefined && body.home_cell !== null && typeof body.home_cell !== 'string') throw new RegistryError(400, 'registry.invalid_pairing_request', 'home_cell must be a cell id');
++ const user = await db.prepare(`SELECT id FROM users WHERE subject_id = ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL
++ AND merged_into IS NULL AND COALESCE(is_anon, 0) = 0`).get(owner.subject);
++ if (!user) throw new RegistryError(404, 'registry.unknown_owner', `no person ${owner.subject}`);
++ const homeCell = body.home_cell || null;
++ if (homeCell) {
++ const cell = await db.prepare('SELECT status FROM platform_cells WHERE id = ?').get(homeCell);
++ if (!cell) throw new RegistryError(400, 'registry.unknown_cell', `no cell ${homeCell}`);
++ if (cell.status !== 'active') throw new RegistryError(409, 'registry.cell_not_active', `cell ${homeCell} is ${cell.status}`);
++ }
++ const id = `pair_${ids.ulid(now)}`;
++ const expiresAt = iso(now + CODE_TTL_MS);
++ let code = null;
++ await db.tx(async (t) => {
++ // One live code per (service, ref), as Bot's one live code per robot.
++ await t.prepare('DELETE FROM platform_node_pairings WHERE service = ? AND ref = ? AND used_at IS NULL').run(service, ref);
++ const insert = t.prepare(`INSERT INTO platform_node_pairings (id, code_hash, owner_kind, owner_subject, service, ref, home_cell, created_by, created_at, expires_at)
++ VALUES (?, ?, 'user', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (code_hash) DO NOTHING`);
++ // 40 random bits: a clash with a stored hash is improbable, and is drawn again rather than failing.
++ for (let i = 0; i < 3 && !code; i++) {
++ const c = newCode();
++ if ((await insert.run(id, sha256(c), owner.subject, service, ref, homeCell, `svc:${service}`, iso(now), expiresAt)).changes) code = c;
++ }
++ if (!code) throw new Error('could not draw a unique pairing code');
++ });
++ return { pairing_id: id, code: formatCode(code), expires_at: expiresAt };
++}
++
++/**
++ * Redeem a code (Bot's redeem, OpenVibe.Bot server/domain/index.js:158-205). With `pairing` a wrong code counts a try
++ * against that row (5 lock it); without it the code is found by its hash. Refusals are returned from the transaction and
++ * thrown after it commits, so a counted try survives. Throws RegistryError. → { principal row, credential }
++ */
++async function redeem(db, body, now = Date.now()) {
++ const b = body && typeof body === 'object' ? body : {};
++ const normal = normaliseCode(typeof b.code === 'string' ? b.code : '');
++ if (!isCodeShape(normal)) throw new RegistryError(422, 'registry.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
++ if (b.pairing !== undefined && b.pairing !== null && (typeof b.pairing !== 'string' || !PAIRING_ID.test(b.pairing))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'pairing must be a pair_ id');
++ const name = b.name === undefined || b.name === null ? null : text(b.name, 80);
++ if (b.name != null && !name) throw new RegistryError(400, 'registry.invalid_pairing_request', 'name must be 1-80 characters');
++ if (b.region != null && (typeof b.region !== 'string' || !REGION.test(b.region))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'region must be a region id');
++ const hash = sha256(normal);
++ const at = iso(now);
++ const result = await db.tx(async (t) => {
++ let row;
++ if (b.pairing) {
++ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE id = ? FOR UPDATE').get(b.pairing);
++ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
++ if (!sameHash(row.code_hash, hash)) {
++ if (row.used_at) return { error: [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
++ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
++ const tries = Number(row.tries) + 1;
++ await t.prepare('UPDATE platform_node_pairings SET tries = ? WHERE id = ?').run(tries, row.id);
++ return { error: tries >= MAX_TRIES ? [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] : [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
++ }
++ } else {
++ row = await t.prepare('SELECT * FROM platform_node_pairings WHERE code_hash = ? FOR UPDATE').get(hash);
++ if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
++ }
++ if (row.used_at) return { error: [403, 'registry.pairing_code_used', 'that pairing code has already been used'] };
++ if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
++ if (Date.parse(row.expires_at) <= now) return { error: [403, 'registry.pairing_code_expired', 'that pairing code has expired'] };
++ if (row.owner_kind !== 'user') return { error: [501, 'registry.not_yet', 'project-owned machines are not paired yet'] };
++ await t.prepare('UPDATE platform_node_pairings SET used_at = ? WHERE id = ?').run(at, row.id);
++ const ulid = ids.ulid(now);
++ const principalId = `lnk_${ulid}`;
++ const credential = crypto.randomBytes(32).toString('base64url');
++ const homeCell = row.home_cell || await cellForRegion(t, b.region);
++ // A paired machine is a principal only: no platform_nodes row, so it is never a platform machine.
++ await t.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, name, trust, status,
++ credential_hash, paired_by_service, pairing_ref, created_at, created_by, updated_at)
++ VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)
++ .run(principalId, `n-${ulid.toLowerCase()}`, homeCell, row.owner_subject, name, sha256(credential), row.service, row.ref, at, `pairing:${row.id}`, at);
++ await t.prepare('UPDATE platform_node_pairings SET principal_id = ? WHERE id = ?').run(principalId, row.id);
++ const principal = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId);
++ // The machine paired with the person's account (plan L34): the hub's network.machine.paired event, in the
++ // same transaction as the principal it names.
++ await eventRelay.writerFor(db).enqueue(db, pairedEnvelope(principal, { at }));
++ return { principal, credential };
++ });
++ if (result.error) throw new RegistryError(...result.error);
++ return result;
++}
++
++/**
++ * The principal `id` matching a caller-owned scope (`where` + `params`, literals only), or null. A principal
++ * another caller owns is indistinguishable from none: the routes answer 404, never 403, so nobody can probe
++ * whose machine an id is.
++ */
++async function byScope(db, id, scope) {
++ if (!PRINCIPAL_ID.test(String(id))) return null;
++ return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params) || null;
++}
++
++/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
++const pairedBy = (db, id, service) => byScope(db, id, { where: 'paired_by_service = ?', params: [service] });
++
++/** The payload's `owner` (network.link.revoked@1's oneOf), from the principal row. */
++const ownerOf = (p) => p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject }
++ : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id }
++ : { kind: 'platform' };
++
++/**
++ * The network.link.revoked@1 envelope for a principal row just revoked (Contracts 0.98.0; docs/t2-cells-and-node-
++ * principal.md section 10). Only the ids, who owns the machine, why and when: never the credential or its hash.
++ * Validates payload and envelope (a bug if either is off, never user input).
++ */
++function revokedEnvelope(p, { actor, reason, at }) {
++ const payload = { node_id: p.node_id, principal_id: p.id, owner: ownerOf(p), at };
++ if (reason) payload.reason = reason;
++ const env = {
++ event_id: ids.newId('event', Date.parse(at)), event_type: 'network.link.revoked', version: 1, source: 'network',
++ actor, timestamp: at, visibility: 'internal', subject: { type: 'node_principal', id: p.id }, payload,
++ };
++ const ev = validate('events.event-envelope@1', env);
++ const pv = validate('network.link.revoked@1', payload);
++ if (!ev.valid || !pv.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
++ return env;
++}
++
++/** The caller's reason text (1-500 chars, no control characters), or null; anything else is a 400. */
++function reasonOf(raw) {
++ if (raw === undefined || raw === null) return null;
++ const r = text(raw, 500);
++ if (!r) throw new RegistryError(400, 'registry.invalid_reason', 'reason must be 1-500 characters');
++ return r;
++}
++
++/**
++ * The network.machine.paired@1 envelope for a machine that just redeemed its code (plan L34): the machine paired
++ * with the person's account. Only the ids, the owner and when: never the credential or its hash. The payload
++ * contract is Contracts 0.261.0, so the envelope is checked here and the payload by the tests.
++ */
++function pairedEnvelope(p, { at }) {
++ const payload = { principal_id: p.id, node_id: p.node_id, owner: p.owner_subject, at };
++ const env = {
++ event_id: ids.newId('event', Date.parse(at)), event_type: 'network.machine.paired', version: 1, source: 'network',
++ actor: { type: 'service', id: 'network' }, timestamp: at, visibility: 'internal',
++ subject: { type: 'machine', id: p.id }, payload,
++ };
++ const ev = validate('events.event-envelope@1', env);
++ if (!ev.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).slice(0, 3))}`);
++ return env;
++}
++
++/**
++ * Revoke one active principal row and enqueue its network.link.revoked@1 on the ambient transaction, so the change
++ * and the event share it. → changes (0 or 1): a row already revoked changes nothing and emits nothing.
++ */
++async function revokeRow(db, p, { revokedBy, actor, reason, at }) {
++ const changed = (await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
++ prev_valid_until = NULL, updated_at = ? WHERE id = ? AND status <> 'revoked'`).run(at, revokedBy, at, p.id)).changes;
++ if (!changed) return 0;
++ await eventRelay.writerFor(db).enqueue(db, revokedEnvelope(p, { actor, reason, at }));
++ return 1;
++}
++
++/**
++ * Revoke a principal matching a caller-owned scope; a second revoke changes nothing (and emits nothing). → the row,
++ * or null. The previous credential and its grace window are cleared. `credential_hash` stays: migrations/0014's
++ * platform_node_principals_credential CHECK requires one for a user-owned principal, and a revoked row cannot
++ * authenticate because every reader requires status = 'active'. One function, so the service route and the
++ * person's own route revoke identically, both writing the event in the same transaction as the revoke.
++ */
++async function revokeScoped(db, id, scope, revokedBy, now = Date.now(), reason = null) {
++ const at = iso(now);
++ const actor = String(revokedBy).startsWith('svc:')
++ ? { type: 'service', id: String(revokedBy).slice(4) }
++ : { type: 'user', id: String(revokedBy) };
++ return await db.tx(async () => {
++ const p = await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
++ if (!p) return null;
++ await revokeRow(db, p, { revokedBy, actor, reason, at });
++ return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
++ });
++}
++
++/** Revoke a principal `service` paired; revoked_by is svc:<service>. → the row, or null. */
++const revoke = (db, id, service, now = Date.now(), reason = null) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now, reason);
++
++/**
++ * Revoke every active principal owned by any of `subjects` and enqueue its event, on the caller's ambient
++ * transaction (an account deletion: the person's machines go with the account, and each revoke is announced so a
++ * consumer stops the machine at once). Returns how many changed; a row already revoked emits nothing.
++ */
++async function revokeOwnedBy(db, subjects, { revokedBy = 'account_deleted', actor, reason = 'account_deleted', now = Date.now() } = {}) {
++ const at = iso(now);
++ let revoked = 0;
++ for (const sid of subjects || []) {
++ if (!SUBJECT.test(String(sid))) continue;
++ const rows = await db.prepare("SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ? AND status <> 'revoked'").all(String(sid));
++ for (const p of rows) revoked += await revokeRow(db, p, { revokedBy, actor: actor || { type: 'user', id: String(sid) }, reason, at });
++ }
++ return revoked;
++}
++
++/** The query filters of the operator and service principal lists; anything but a string is ignored. */
++const principalFilter = (q = {}) => Object.fromEntries(['cell', 'owner_kind', 'status'].map((k) => [k, typeof q[k] === 'string' ? q[k] : null]));
++
++/** Every node principal for operators and services holding network.registry.read, newest first, at most 500. */
++async function listPrincipals(db, { cell = null, owner_kind: ownerKind = null, status = null } = {}) {
++ const where = []; const params = [];
++ if (cell) { where.push('home_cell = ?'); params.push(cell); }
++ if (ownerKind) { where.push('owner_kind = ?'); params.push(ownerKind); }
++ if (status) { where.push('status = ?'); params.push(status); }
++ const rows = await db.prepare(`SELECT * FROM platform_node_principals ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
++ ORDER BY created_at DESC, id DESC LIMIT 500`).all(...params);
++ return rows.map((p) => ({ ...principalView(p), trust: p.trust }));
++}
++
++/** What an operator may set a principal to: revoked has its own routes (revokeScoped), and is never undone. */
++const OPERATOR_STATUS = ['active', 'draining'];
++
++/**
++ * Operator write (plan T2): drain a node principal, or make it active again, from a staff session
++ * (server/registry/registry-admin.js). Throws RegistryError. → the principal (with trust)
++ */
++async function setPrincipalStatus(db, id, status, now = Date.now()) {
++ if (!OPERATOR_STATUS.includes(status)) throw new RegistryError(422, 'registry.invalid_status', 'status must be active or draining (revoking has its own route)');
++ const get = db.prepare('SELECT * FROM platform_node_principals WHERE id = ?');
++ const p = PRINCIPAL_ID.test(String(id)) ? await get.get(String(id)) : null;
++ if (!p) throw new RegistryError(404, 'registry.unknown_node', `no node principal ${id}`);
++ const at = iso(now);
++ const changed = (await db.prepare("UPDATE platform_node_principals SET status = ?, updated_at = ? WHERE id = ? AND status <> 'revoked'").run(status, at, p.id)).changes;
++ if (!changed) throw new RegistryError(409, 'registry.node_revoked', `node principal ${id} was revoked`);
++ const row = await get.get(p.id);
++ return { ...principalView(row), trust: row.trust };
++}
++
++/** Scope for a person's own machines: the principals they own, and no others. */
++const ownScope = (me) => ({ where: "owner_kind = 'user' AND owner_subject = ?", params: [me] });
++
++/** The signed-in person's subject id, or a RegistryError for a guest (never manages machines). */
++async function meOf(db, user) {
++ if (!user || user.is_anon) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
++ const sid = await require('../identity/subjects').ensureUserSubject(db, user);
++ if (!SUBJECT.test(String(sid || ''))) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
++ return sid;
++}
++
++// Mounted at /api/v1/me/nodes behind requireAuth (server/index.js): a person's own machines, and nothing else.
++function userRouter(requireAuth, now = () => Date.now()) {
++ const router = express.Router();
++ require('../link/egress').mount({ user: router, requireAuth, now });
++ router.use(http.middleware());
++ const send = async (res, fn) => {
++ try { return await fn(); } catch (e) {
++ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[Node principals]', e.message);
++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the machines could not be read or changed' });
++ }
++ };
++ router.get('/', requireAuth, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const db = req.app.locals.db;
++ const me = await meOf(db, req.user);
++ const rows = await db.prepare(`SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ?
++ ORDER BY created_at DESC, id DESC`).all(me);
++ res.json({ nodes: rows.map(principalView) });
++ }));
++ router.post('/:principal/revoke', requireAuth, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const db = req.app.locals.db;
++ const me = await meOf(db, req.user);
++ const p = await revokeScoped(db, req.params.principal, ownScope(me), me, now(), reasonOf((req.body || {}).reason));
++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.principal}` });
++ res.json(principalView(p));
++ }));
++ return router;
++}
++
++/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */
++const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.link', 'openvibe.website']);
++
++/** Every refusal of a node's credential, whatever the reason: one status, one body. */
++const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
++
++/**
++ * grant_type=client_credentials for a node principal (client_id lnk_…): its current credential, or the previous one
++ * while prev_valid_until is in the future, buys a 300 s token for openvibe.network (network.link.self.manage) or for
++ * the service that paired it (no capability: that service authorises the node by its own binding). The secret is only
++ * ever hashed and compared in constant time; it is never logged or returned. → { status, body } (OAuth shapes).
++ */
++async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey, issuer, now = Date.now() }) {
++ if (typeof clientId !== 'string' || !PRINCIPAL_ID.test(clientId) || typeof clientSecret !== 'string' || !clientSecret) return INVALID_CLIENT;
++ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(clientId);
++ const hash = sha256(clientSecret);
++ // Both comparisons always run, so the answer's timing does not tell which secret (if any) matched.
++ const current = sameHash(p && p.credential_hash, hash);
++ const previous = sameHash(p && p.credential_prev_hash, hash);
++ const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
++ if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
++ const aud = typeof audience === 'string' ? audience.trim() : '';
++ // A person's machine may also speak to the services any of their computers can use, whoever paired it: OpenVibe
++ // Connect (openvibe.website) binds a share to one machine and checks the node token at every tunnel hello (ADR-057
++ // decision 4, CON-4). The token carries no capability: Connect authorises the node by its own share binding.
++ const anyNode = p.owner_kind === 'user' && NODE_WIDE_AUDIENCES.includes(aud);
++ // The Agent Streams plan (S1, Contracts 0.298.0): the desk publishes the person's agent stream on OpenVibe.Live, so
++ // a person's machine may ask for a token for openvibe.live that carries live.agent_stream.publish and names its
++ // owner (on_behalf_of). It is the owner's stream; a machine without an owner (a platform one) gets nothing.
++ const nodeCaps = p.owner_kind === 'user' && p.owner_subject ? NODE_GRANTS.filter(([a]) => a === aud).map(([, c]) => c) : [];
++ if (aud !== SELF_AUDIENCE && !anyNode && !nodeCaps.length && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
++ const audiences = [SELF_AUDIENCE, ...(p.paired_by_service ? [`openvibe.${p.paired_by_service}`] : []),
++ ...(p.owner_kind === 'user' ? [...NODE_WIDE_AUDIENCES, ...NODE_GRANTS.map(([a]) => a)] : [])];
++ return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${audiences.join(', ')}` } };
++ }
++ const iat = Math.floor(now / 1000);
++ const claims = {
++ iss: issuer, sub: `link:${p.id}`, actor_type: 'link', aud: [aud],
++ cap: aud === SELF_AUDIENCE ? ['network.link.self.manage'] : nodeCaps,
++ // The person the machine acts for, on the audiences where it acts for them (the Agent Streams plan's Live
++ // token): Live reads the owner of an agent stream from it. Other audiences name nobody.
++ ...(nodeCaps.length && p.owner_subject ? { on_behalf_of: p.owner_subject } : {}),
++ ...(p.owner_kind === 'project' && p.project_id ? { project_id: p.project_id } : {}),
++ iat, exp: iat + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
++ };
++ assertValid('identity.service-token-claims@1', claims);
++ const access = serviceAuth.signServiceToken(claims, privateKey);
++ // The health of a paired machine: written at most once a minute, so a busy machine is not a write per token.
++ await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)')
++ .run(iso(now), p.id, iso(now - SEEN_EVERY_MS));
++ return { status: 200, body: { access_token: access, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
++}
++
++const NODE_SUB = /^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/;
++
++/**
++ * The node principal a verified token speaks for, live. A token of any other actor (svc:, app:) is 403
++ * capability.owner_denied whatever it holds; a node revoked since its token was issued (≤ 300 s ago) is 401.
++ */
++async function selfOf(db, req) {
++ const m = NODE_SUB.exec(String(req.principal && req.principal.sub || ''));
++ if (!m) throw new RegistryError(403, 'capability.owner_denied', 'only a node principal manages itself');
++ const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(m[1]);
++ if (!p || p.status !== 'active') throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
++ return p;
++}
++
++/**
++ * Store the capabilities the machine presents (platform.link-capabilities@1, verbatim). Its node_id must be the
++ * machine's own. Throws RegistryError. → { node_id, reported_at }
++ */
++async function putCapabilities(db, p, body, now = Date.now()) {
++ const v = validate('platform.link-capabilities@1', body);
++ if (!v.valid) throw new RegistryError(400, 'registry.invalid_capabilities', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 500));
++ if (body.node_id !== p.node_id) throw new RegistryError(409, 'registry.node_mismatch', `this token is node ${p.node_id}, not ${String(body.node_id).slice(0, 80)}`);
++ const at = iso(now);
++ await db.prepare(`INSERT INTO platform_node_capabilities (node_id, doc, reported_at) VALUES (?, ?, ?)
++ ON CONFLICT (node_id) DO UPDATE SET doc = excluded.doc, reported_at = excluded.reported_at`).run(p.node_id, JSON.stringify(body), at);
++ return { node_id: p.node_id, reported_at: at };
++}
++
++/**
++ * The machine rotates its own credential: a new one (returned this once), the old one valid PREV_GRACE_MS more so a
++ * machine that crashes mid-rotation can still sign in. Throws RegistryError. → { principal, node_id, credential, prev_valid_until }
++ */
++async function rotateCredential(db, id, now = Date.now()) {
++ const credential = crypto.randomBytes(32).toString('base64url');
++ const at = iso(now);
++ const until = iso(now + PREV_GRACE_MS);
++ const row = await db.tx(async (t) => {
++ const p = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ? FOR UPDATE').get(id);
++ if (!p || p.status !== 'active') return null;
++ await t.prepare(`UPDATE platform_node_principals SET credential_prev_hash = credential_hash,
++ prev_valid_until = CASE WHEN credential_hash IS NULL THEN NULL ELSE ? END, credential_hash = ?, updated_at = ?
++ WHERE id = ? AND status = 'active'`).run(until, sha256(credential), at, id);
++ return p;
++ });
++ if (!row) throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
++ return { principal: row.id, node_id: row.node_id, credential, prev_valid_until: row.credential_hash ? until : null };
++}
++
++// Mounted at /api/v1/link/self behind principals.guard('network.link.self.manage') (server/index.js). The guard checks
++// the token's signature, audience and capability; ownership is selfOf's, before any write.
++function selfRouter(now = () => Date.now()) {
++ const router = express.Router();
++ require('../link/egress').mount({ self: router, now });
++ router.use(http.middleware());
++ const send = async (res, fn) => {
++ res.set('Cache-Control', 'no-store');
++ try { return await fn(); } catch (e) {
++ if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
++ console.error('[Node self]', e.message);
++ return http.sendProblem(res, 500, 'registry.failed', { detail: 'the node could not be changed' });
++ }
++ };
++ router.put('/capabilities', express.json({ limit: '64kb' }), async (req, res) => await send(res, async () => {
++ const db = req.app.locals.db;
++ res.json(await putCapabilities(db, await selfOf(db, req), req.body, now()));
++ }));
++ router.post('/credential', async (req, res) => await send(res, async () => {
++ const db = req.app.locals.db;
++ res.json(await rotateCredential(db, (await selfOf(db, req)).id, now()));
++ }));
++ return router;
++}
++
++function routers({ guard, now = () => Date.now() }) {
++ const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });
++ const internal = express.Router();
++ internal.post('/link-pairings', guard, express.json({ limit: '8kb' }), async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ try {
++ res.status(201).json(await createPairing(req.app.locals.db, serviceOf(req), req.body || {}, now()));
++ } catch (e) {
++ if (e instanceof RegistryError) return problem(res, e);
++ throw e;
++ }
++ });
++ internal.get('/link-principals/:id', guard, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ const p = await pairedBy(req.app.locals.db, req.params.id, serviceOf(req));
++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
++ res.json(principalView(p));
++ });
++ internal.post('/link-principals/:id/revoke', guard, async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ let reason;
++ try { reason = reasonOf((req.body || {}).reason); } catch (e) { return problem(res, e); }
++ const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now(), reason);
++ if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
++ res.json(principalView(p));
++ });
++
++ // Mounted at /api/v1/link-pairing behind its own rate limit (server/index.js).
++ const pairing = express.Router();
++ pairing.post('/', express.json({ limit: '8kb' }), async (req, res) => {
++ res.set('Cache-Control', 'no-store');
++ let out;
++ try {
++ out = await redeem(req.app.locals.db, req.body, now());
++ } catch (e) {
++ if (e instanceof RegistryError) return problem(res, e);
++ throw e;
++ }
++ const p = out.principal;
++ res.status(201).json({
++ principal: p.id, node_id: p.node_id, home_cell: p.home_cell, credential: out.credential,
++ token_endpoint: `${req.app.locals.config.jwt.issuer}/oauth/token`,
++ paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
++ });
++ });
++ require('../link/egress').mount({ internal, pairing, now });
++ return { internal, pairing };
++}
++
++module.exports = { CODE_TTL_MS, MAX_TRIES, NODE_WIDE_AUDIENCES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
ddiff --git a/server/registry/machine-pools.js b/server/registry/machine-pools.js
iindex fc49f035..7d859895 100644
---- a/server/registry/machine-pools.js
++++ b/server/registry/machine-pools.js
@@@ -22,11 +22,11 @@
* GET /api/v1/machine-pools session → { pools, candidates } (no address or public IP)
* POST /api/v1/machine-pools session { machines, head? } → 201 { pool }
* POST /api/v1/machine-pools/:id/revoke session → { revoked }
-- * POST /api/v1/node/self/pools/announce node { address, port, free_vram, free_ram, hardware } → { machine }
-- * GET /api/v1/node/self/pools/:id node → { pool } (bot.machine-pool@1)
-- * POST /api/v1/node/self/pools/:id/approve node { certificate } → { pool }
-- * POST /api/v1/node/self/pools/:id/status node { ready } → { ok, pool }
-- * POST /api/v1/node/self/pools/:id/leave node → { revoked }
++ * POST /api/v1/link/self/pools/announce node { address, port, free_vram, free_ram, hardware } → { machine }
++ * GET /api/v1/link/self/pools/:id node → { pool } (bot.machine-pool@1)
++ * POST /api/v1/link/self/pools/:id/approve node { certificate } → { pool }
++ * POST /api/v1/link/self/pools/:id/status node { ready } → { ok, pool }
++ * POST /api/v1/link/self/pools/:id/leave node → { revoked }
*/
const express = require('express');
const crypto = require('node:crypto');
@@@ -74,8 +74,8 @@ function certificate(value) {
/** The calling machine's live node principal. A user, app or service token is refused: only a machine approves. */
async function node(db, req) {
const sub = String(req.principal?.sub || '');
-- if (!sub.startsWith('node:')) fail(403, 'only the machine itself may do this');
-- const p = await db.prepare('SELECT id, owner_subject, status, owner_kind FROM platform_node_principals WHERE id = ?').get(sub.slice('node:'.length));
++ if (!sub.startsWith('link:')) fail(403, 'only the machine itself may do this');
++ const p = await db.prepare('SELECT id, owner_subject, status, owner_kind FROM platform_node_principals WHERE id = ?').get(sub.slice('link:'.length));
if (!p || p.status !== 'active' || p.owner_kind !== 'user') fail(401, 'machine is no longer paired to an owner');
return p;
}
@@@ -201,8 +201,8 @@ function routers({ bot, requireAuth }) {
res.status(201).json({ pool: { id: pool.id, head: pool.head, machines: pool.machines.map((m) => ({ id: m.id, approved: false, ready: false })) } });
}));
user.post('/:id/revoke', wrap(async (req, res, db) => { await revoke(db, req.params.id, await owner(db, req)); res.json({ revoked: true }); }));
-- // Mounted under /api/v1/node/self, whose guard (server/index.js) has already checked the node token and
-- // network.node.self.manage once; every handler then resolves the live node principal (node()), which refuses a
++ // Mounted under /api/v1/link/self, whose guard (server/index.js) has already checked the node token and
++ // network.link.self.manage once; every handler then resolves the live node principal (node()), which refuses a
// request that reached it without one.
self.use(express.json({ limit: '16kb' }));
self.post('/announce', wrap(async (req, res, db) => {
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
iindex 2dfcee10..8744d430 100644
---- a/server/registry/machines.js
++++ b/server/registry/machines.js
@@@ -1,6 +1,6 @@
'use strict';
/**
-- * The machines hub (plan L34, step M2): a person's machines on openvibe.network/machines.
++ * The machines hub (plan L34, step M2): a person's machines on openvibe.link/machines.
*
* Bot stays the registry of the machines. Network fronts it: every route here calls Bot's machines API as the
* person (with a service token of Network's own, naming the person in X-OV-Subject), so Bot checks that the
@@@ -13,7 +13,7 @@
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
-- * POST /internal/machines/status network.node.manage → 202 (Bot's report)
++ * POST /internal/machines/status network.link.manage → 202 (Bot's report)
*
* Who may call: the person, with their own session; or an app holding the capability with the person's
* consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be
@@@ -36,7 +36,7 @@ const BOT_CAP = 'bot.machine.manage';
const SELF_AUDIENCE = 'openvibe.network';
const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const MACHINE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
--const PRINCIPAL = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
++const PRINCIPAL = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
const KINDS = new Set(['onboard', 'bridge', 'server']);
const TOKEN_TTL_S = 300;
@@@ -49,7 +49,7 @@ const TOKEN_TTL_S = 300;
const LOCAL_ROLES = [
{ id: 'codes.worker', name: 'Codes worker', installs: 'the Codes job runner (the coding agent and the job sandbox)', shows: 'Runs your coding jobs, such as a task you start on openvibe.codes.' },
{ id: 'desks.host', name: 'Desk host', installs: 'the desk sandbox (a private browser desk for every session)', shows: "Hosts desks for agents, yours and other people's." },
-- { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Node's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
++ { id: 'ai.host', name: 'AI host', installs: "the local model server (Ollama, LM Studio) as OpenVibe Link's ai plugin", shows: 'Serves your own AI models, on your own machine.' },
{ id: 'bot.robot', name: 'Robot', installs: 'the robot drivers the profile asks for', shows: 'Drives a robot, and takes its commands.' },
];
@@@ -243,7 +243,9 @@ function hubGuard(capability, { db, publicKey, issuer, requireAuth }) {
*/
function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.locals.db, now = () => Date.now() }) {
const hub = express.Router();
++ const compute = express.Router();
hub.use(http.middleware());
++ compute.use(http.middleware());
const read = hubGuard(READ, { db, publicKey, issuer, requireAuth });
const manage = hubGuard(MANAGE, { db, publicKey, issuer, requireAuth });
@@@ -309,6 +311,47 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
res.json({ machines });
}));
++ // Link is the single place to discover where a job can run. The other sources are
++ // visible while their placement adapters are being connected, but cannot be picked.
++ const computeKinds = ['personal', 'cloud', 'exchange', 'community'];
++ const computeList = async (req) => {
++ const subject = await subjectOf(req);
++ const out = await bot.list(subject);
++ const machines = (out && Array.isArray(out.machines) ? out.machines : []).map(machineOf);
++ const candidates = machines.map((m) => {
++ const ready = m.online && m.roles.includes('codes.worker');
++ return { id: m.id, ...(m.principal_id ? { principal_id: m.principal_id } : {}), kind: 'personal', name: m.name || m.id,
++ status: ready ? 'ready' : 'unavailable', price: { amount_usd: 0, unit: 'hour' },
++ reason: ready ? null : m.online ? 'Turn on the Codes worker role in Link.' : 'This machine is offline.' };
++ });
++ const sources = [
++ { kind: 'personal', name: 'Your Link machines', status: candidates.some((c) => c.status === 'ready') ? 'ready' : 'unavailable', reason: candidates.length ? null : 'Pair a machine in Link.' },
++ { kind: 'cloud', name: 'OpenVibe cloud machines', status: 'coming_soon', reason: 'No cloud pool is accepting jobs yet.' },
++ { kind: 'exchange', name: 'Exchange GPU rentals', status: 'coming_soon', reason: 'A live GPU quote and spending ceiling are required before a rental can start.', price: null },
++ { kind: 'community', name: 'Community machines', status: 'coming_soon', reason: 'Shared machine permissions and placement are being connected.' },
++ ];
++ return { sources, candidates };
++ };
++ compute.get('/', read, async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const result = await computeList(req);
++ const kind = req.query.kind;
++ if (kind && !computeKinds.includes(kind)) return http.sendProblem(res, 400, 'link.compute_kind', { detail: 'Unknown compute source.' });
++ res.json(kind ? { sources: result.sources.filter((s) => s.kind === kind), candidates: result.candidates.filter((c) => c.kind === kind) } : result);
++ }));
++ compute.post('/pick', read, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const kind = String((req.body || {}).kind || 'personal');
++ const id = String((req.body || {}).id || '');
++ if (!computeKinds.includes(kind)) return http.sendProblem(res, 400, 'link.compute_kind', { detail: 'Unknown compute source.' });
++ if (kind !== 'personal') return http.sendProblem(res, 409, 'link.compute_unavailable', { detail: 'This source is not accepting jobs yet.' });
++ const { candidates } = await computeList(req);
++ const chosen = id ? candidates.find((c) => c.id === id) : candidates.find((c) => c.status === 'ready');
++ if (!chosen) return http.sendProblem(res, 404, 'link.compute_missing', { detail: 'No such machine of yours is available.' });
++ if (chosen.status !== 'ready') return http.sendProblem(res, 409, 'link.compute_unavailable', { detail: chosen.reason });
++ res.json({ choice: chosen });
++ }));
++
hub.get('/:id', read, async (req, res) => await send(res, async () => {
res.set('Cache-Control', 'private, no-store');
const subject = await subjectOf(req);
@@@ -379,11 +422,11 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
res.json({ machine });
}));
-- return { hub };
++ return { hub, compute };
}
/**
-- * Bot's report of a machine's state (POST /internal/machines/status, network.node.manage — the capability Bot
++ * Bot's report of a machine's state (POST /internal/machines/status, network.link.manage — the capability Bot
* already holds for the pairing authority). Each change is published once as network.machine.online or
* network.machine.offline; Bot reports a change, not a heartbeat, so the event says what changed.
*/
ddiff --git a/server/registry/node-principals.js b/server/registry/node-principals.js
ddeleted file mode 100644
iindex 18455bd4..00000000
---- a/server/registry/node-principals.js
++++ /dev/null
@@@ -1,546 +0,0 @@
--'use strict';
--/**
-- * Pairing a person's machine (plan T2, docs/t2-cells-and-node-principal.md section 4.2, slice N4b;
-- * migrations/0014_node_pairing.sql). A service holding network.node.manage (Bot) mints a one-time pairing code for one
-- * of its users; the machine redeems it with no other credential and becomes a node principal (nod_<ULID>) owned by that
-- * person, with a long-lived credential shown once. The service may then read and revoke only the principals it paired.
-- *
-- * Secrets: the code and the credential are shown once and stored only as their sha256; neither is ever logged nor
-- * returned by a read. A paired principal is never a platform machine: no platform_nodes row is created, and Host's node
-- * report naming its node_id is refused (cells.checkPlatformNodes, 409 registry.node_not_platform).
-- *
-- * POST /internal/node-pairings network.node.manage → 201 {pairing_id, code, expires_at}
-- * POST /api/v1/node-pairing none (the code) → 201 {principal, node_id, home_cell, credential, …}
-- * GET /internal/node-principals/:id network.node.manage → the principal, if the caller paired it
-- * POST /internal/node-principals/:id/revoke network.node.manage → the principal, revoked (idempotent)
-- * GET /api/v1/me/nodes session → the person's own machines, newest first
-- * POST /api/v1/me/nodes/:id/revoke session → their principal, revoked (idempotent)
-- *
-- * Every revoke — the service route, the owner's route, or an account deletion — writes network.node.revoked@1 into
-- * the outbox in the same transaction (Contracts 0.98.0; docs/t2-cells-and-node-principal.md section 10): a service
-- * holding the machine's session stops it at once, not at its next reauth (≤ 330 s). A second revoke emits nothing;
-- * the payload carries the ids, the owner and the reason, never the credential or its hash.
-- *
-- * The paired machine itself (section 4.3, slice N4c): its credential buys a node token (sub node:nod_…) at
-- * POST /oauth/token, and with it the machine manages only its own row. The routes resolve the node from the verified
-- * token, never from the body or the path.
-- * POST /oauth/token client_id=nod_… the credential → 200 node token (oauth-routes.js)
-- * PUT /api/v1/node/self/capabilities network.node.self.manage → 200 {node_id, reported_at}
-- * POST /api/v1/node/self/credential network.node.self.manage → 200 {principal, node_id, credential, …}
-- */
--const crypto = require('crypto');
--const express = require('express');
--const { ids, http, serviceAuth, validate, assertValid } = require('openvibe-contracts');
--const { BOOTSTRAP_CELL, RegistryError } = require('./cells');
--const { TOKEN_TTL_S, SELF_AUDIENCE, NODE_GRANTS } = require('../identity/principals');
--const eventRelay = require('../developer/event-relay');
--
--const CODE_TTL_MS = 10 * 60 * 1000;
--const MAX_TRIES = 5;
--const PREV_GRACE_MS = 60 * 1000; // a rotated-out credential still works this long (Bot's grace window)
--const SEEN_EVERY_MS = 60 * 1000; // last_seen_at is written at most this often
--const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
--const PAIRING_ID = /^pair_[0-9A-HJKMNP-TV-Z]{26}$/;
--const PRINCIPAL_ID = /^nod_[0-9A-HJKMNP-TV-Z]{26}$/;
--const REGION = /^[a-z][a-z0-9-]{1,39}$/;
--const CONTROL = /[\u0000-\u001f\u007f]/;
--
--// Crockford base32 (Bot's alphabet): no I, L, O or U, so a code read aloud cannot be mistyped into another.
--const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
--const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
--const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
--const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
--function newCode() {
-- const b = crypto.randomBytes(8);
-- let s = '';
-- for (let i = 0; i < 8; i++) s += CROCKFORD[b[i] % 32]; // 256 % 32 = 0: no bias
-- return s;
--}
--const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
--/** Constant-time equality of two stored hashes. */
--function sameHash(a, b) {
-- const x = Buffer.from(String(a || ''));
-- const y = Buffer.from(String(b || ''));
-- if (x.length !== y.length) { crypto.timingSafeEqual(x, x); return false; }
-- return crypto.timingSafeEqual(x, y);
--}
--const text = (v, max) => typeof v === 'string' && v.trim().length >= 1 && v.trim().length <= max && !CONTROL.test(v) ? v.trim() : null;
--const iso = (ms) => new Date(ms).toISOString();
--
--/** The first active cell of a region, else wnam-1 (the same rule as cells.js cellForRegion, for a paired machine). */
--async function cellForRegion(db, region) {
-- const row = region ? await db.prepare("SELECT id FROM platform_cells WHERE region = ? AND status = 'active' ORDER BY id LIMIT 1").get(region) : null;
-- return row ? row.id : BOOTSTRAP_CELL;
--}
--
--/** The service a verified service token speaks for: svc:<id> → <id>. */
--const serviceOf = (req) => String(req.principal && req.principal.sub || '').replace(/^svc:/, '');
--
--/** What a service or the owner may see of a principal: never a hash. */
--const principalView = (p) => ({
-- principal: p.id, node_id: p.node_id, name: p.name || null,
-- owner: p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject } : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id } : { kind: p.owner_kind },
-- home_cell: p.home_cell, status: p.status,
-- paired_for: p.paired_by_service ? { service: p.paired_by_service, ref: p.pairing_ref } : null,
-- last_seen_at: p.last_seen_at || null, created_at: p.created_at, revoked_at: p.revoked_at || null,
--});
--
--/**
-- * Mint a pairing code for `service`'s user. Replaces that service's unused codes for the same ref.
-- * Throws RegistryError. → { pairing_id, code, expires_at } (the code in the clear, this once).
-- */
--async function createPairing(db, service, body, now = Date.now()) {
-- const owner = body && body.owner;
-- if (owner && owner.kind === 'project') throw new RegistryError(501, 'registry.not_yet', 'project-owned machines are not paired yet');
-- if (!owner || owner.kind !== 'user' || typeof owner.subject !== 'string' || !SUBJECT.test(owner.subject)) {
-- throw new RegistryError(400, 'registry.invalid_pairing_request', "owner must be {kind: 'user', subject: 'usr_…'}");
-- }
-- const ref = text(body.ref, 80);
-- if (!ref) throw new RegistryError(400, 'registry.invalid_pairing_request', 'ref must be 1-80 characters');
-- if (body.home_cell !== undefined && body.home_cell !== null && typeof body.home_cell !== 'string') throw new RegistryError(400, 'registry.invalid_pairing_request', 'home_cell must be a cell id');
-- const user = await db.prepare(`SELECT id FROM users WHERE subject_id = ? AND COALESCE(is_banned, 0) = 0 AND deleted_at IS NULL
-- AND merged_into IS NULL AND COALESCE(is_anon, 0) = 0`).get(owner.subject);
-- if (!user) throw new RegistryError(404, 'registry.unknown_owner', `no person ${owner.subject}`);
-- const homeCell = body.home_cell || null;
-- if (homeCell) {
-- const cell = await db.prepare('SELECT status FROM platform_cells WHERE id = ?').get(homeCell);
-- if (!cell) throw new RegistryError(400, 'registry.unknown_cell', `no cell ${homeCell}`);
-- if (cell.status !== 'active') throw new RegistryError(409, 'registry.cell_not_active', `cell ${homeCell} is ${cell.status}`);
-- }
-- const id = `pair_${ids.ulid(now)}`;
-- const expiresAt = iso(now + CODE_TTL_MS);
-- let code = null;
-- await db.tx(async (t) => {
-- // One live code per (service, ref), as Bot's one live code per robot.
-- await t.prepare('DELETE FROM platform_node_pairings WHERE service = ? AND ref = ? AND used_at IS NULL').run(service, ref);
-- const insert = t.prepare(`INSERT INTO platform_node_pairings (id, code_hash, owner_kind, owner_subject, service, ref, home_cell, created_by, created_at, expires_at)
-- VALUES (?, ?, 'user', ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (code_hash) DO NOTHING`);
-- // 40 random bits: a clash with a stored hash is improbable, and is drawn again rather than failing.
-- for (let i = 0; i < 3 && !code; i++) {
-- const c = newCode();
-- if ((await insert.run(id, sha256(c), owner.subject, service, ref, homeCell, `svc:${service}`, iso(now), expiresAt)).changes) code = c;
-- }
-- if (!code) throw new Error('could not draw a unique pairing code');
-- });
-- return { pairing_id: id, code: formatCode(code), expires_at: expiresAt };
--}
--
--/**
-- * Redeem a code (Bot's redeem, OpenVibe.Bot server/domain/index.js:158-205). With `pairing` a wrong code counts a try
-- * against that row (5 lock it); without it the code is found by its hash. Refusals are returned from the transaction and
-- * thrown after it commits, so a counted try survives. Throws RegistryError. → { principal row, credential }
-- */
--async function redeem(db, body, now = Date.now()) {
-- const b = body && typeof body === 'object' ? body : {};
-- const normal = normaliseCode(typeof b.code === 'string' ? b.code : '');
-- if (!isCodeShape(normal)) throw new RegistryError(422, 'registry.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
-- if (b.pairing !== undefined && b.pairing !== null && (typeof b.pairing !== 'string' || !PAIRING_ID.test(b.pairing))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'pairing must be a pair_ id');
-- const name = b.name === undefined || b.name === null ? null : text(b.name, 80);
-- if (b.name != null && !name) throw new RegistryError(400, 'registry.invalid_pairing_request', 'name must be 1-80 characters');
-- if (b.region != null && (typeof b.region !== 'string' || !REGION.test(b.region))) throw new RegistryError(400, 'registry.invalid_pairing_request', 'region must be a region id');
-- const hash = sha256(normal);
-- const at = iso(now);
-- const result = await db.tx(async (t) => {
-- let row;
-- if (b.pairing) {
-- row = await t.prepare('SELECT * FROM platform_node_pairings WHERE id = ? FOR UPDATE').get(b.pairing);
-- if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
-- if (!sameHash(row.code_hash, hash)) {
-- if (row.used_at) return { error: [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
-- if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
-- const tries = Number(row.tries) + 1;
-- await t.prepare('UPDATE platform_node_pairings SET tries = ? WHERE id = ?').run(tries, row.id);
-- return { error: tries >= MAX_TRIES ? [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] : [403, 'registry.pairing_code_invalid', 'that is not the pairing code'] };
-- }
-- } else {
-- row = await t.prepare('SELECT * FROM platform_node_pairings WHERE code_hash = ? FOR UPDATE').get(hash);
-- if (!row) return { error: [403, 'registry.pairing_code_invalid', 'that is not a live pairing code'] };
-- }
-- if (row.used_at) return { error: [403, 'registry.pairing_code_used', 'that pairing code has already been used'] };
-- if (Number(row.tries) >= MAX_TRIES) return { error: [403, 'registry.pairing_code_locked', 'too many wrong tries; the code is dead'] };
-- if (Date.parse(row.expires_at) <= now) return { error: [403, 'registry.pairing_code_expired', 'that pairing code has expired'] };
-- if (row.owner_kind !== 'user') return { error: [501, 'registry.not_yet', 'project-owned machines are not paired yet'] };
-- await t.prepare('UPDATE platform_node_pairings SET used_at = ? WHERE id = ?').run(at, row.id);
-- const ulid = ids.ulid(now);
-- const principalId = `nod_${ulid}`;
-- const credential = crypto.randomBytes(32).toString('base64url');
-- const homeCell = row.home_cell || await cellForRegion(t, b.region);
-- // A paired machine is a principal only: no platform_nodes row, so it is never a platform machine.
-- await t.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, name, trust, status,
-- credential_hash, paired_by_service, pairing_ref, created_at, created_by, updated_at)
-- VALUES (?, ?, ?, 'user', ?, ?, 'community', 'active', ?, ?, ?, ?, ?, ?)`)
-- .run(principalId, `n-${ulid.toLowerCase()}`, homeCell, row.owner_subject, name, sha256(credential), row.service, row.ref, at, `pairing:${row.id}`, at);
-- await t.prepare('UPDATE platform_node_pairings SET principal_id = ? WHERE id = ?').run(principalId, row.id);
-- const principal = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(principalId);
-- // The machine paired with the person's account (plan L34): the hub's network.machine.paired event, in the
-- // same transaction as the principal it names.
-- await eventRelay.writerFor(db).enqueue(db, pairedEnvelope(principal, { at }));
-- return { principal, credential };
-- });
-- if (result.error) throw new RegistryError(...result.error);
-- return result;
--}
--
--/**
-- * The principal `id` matching a caller-owned scope (`where` + `params`, literals only), or null. A principal
-- * another caller owns is indistinguishable from none: the routes answer 404, never 403, so nobody can probe
-- * whose machine an id is.
-- */
--async function byScope(db, id, scope) {
-- if (!PRINCIPAL_ID.test(String(id))) return null;
-- return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params) || null;
--}
--
--/** A principal `service` paired, or null (another service's principal is indistinguishable from none). */
--const pairedBy = (db, id, service) => byScope(db, id, { where: 'paired_by_service = ?', params: [service] });
--
--/** The payload's `owner` (network.node.revoked@1's oneOf), from the principal row. */
--const ownerOf = (p) => p.owner_kind === 'user' ? { kind: 'user', subject: p.owner_subject }
-- : p.owner_kind === 'project' ? { kind: 'project', project_id: p.project_id }
-- : { kind: 'platform' };
--
--/**
-- * The network.node.revoked@1 envelope for a principal row just revoked (Contracts 0.98.0; docs/t2-cells-and-node-
-- * principal.md section 10). Only the ids, who owns the machine, why and when: never the credential or its hash.
-- * Validates payload and envelope (a bug if either is off, never user input).
-- */
--function revokedEnvelope(p, { actor, reason, at }) {
-- const payload = { node_id: p.node_id, principal_id: p.id, owner: ownerOf(p), at };
-- if (reason) payload.reason = reason;
-- const env = {
-- event_id: ids.newId('event', Date.parse(at)), event_type: 'network.node.revoked', version: 1, source: 'network',
-- actor, timestamp: at, visibility: 'internal', subject: { type: 'node_principal', id: p.id }, payload,
-- };
-- const ev = validate('events.event-envelope@1', env);
-- const pv = validate('network.node.revoked@1', payload);
-- if (!ev.valid || !pv.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).concat(pv.errors || [])).slice(0, 300)}`);
-- return env;
--}
--
--/** The caller's reason text (1-500 chars, no control characters), or null; anything else is a 400. */
--function reasonOf(raw) {
-- if (raw === undefined || raw === null) return null;
-- const r = text(raw, 500);
-- if (!r) throw new RegistryError(400, 'registry.invalid_reason', 'reason must be 1-500 characters');
-- return r;
--}
--
--/**
-- * The network.machine.paired@1 envelope for a machine that just redeemed its code (plan L34): the machine paired
-- * with the person's account. Only the ids, the owner and when: never the credential or its hash. The payload
-- * contract is Contracts 0.261.0, so the envelope is checked here and the payload by the tests.
-- */
--function pairedEnvelope(p, { at }) {
-- const payload = { principal_id: p.id, node_id: p.node_id, owner: p.owner_subject, at };
-- const env = {
-- event_id: ids.newId('event', Date.parse(at)), event_type: 'network.machine.paired', version: 1, source: 'network',
-- actor: { type: 'service', id: 'network' }, timestamp: at, visibility: 'internal',
-- subject: { type: 'machine', id: p.id }, payload,
-- };
-- const ev = validate('events.event-envelope@1', env);
-- if (!ev.valid) throw new Error(`node-principals: bad event ${JSON.stringify((ev.errors || []).slice(0, 3))}`);
-- return env;
--}
--
--/**
-- * Revoke one active principal row and enqueue its network.node.revoked@1 on the ambient transaction, so the change
-- * and the event share it. → changes (0 or 1): a row already revoked changes nothing and emits nothing.
-- */
--async function revokeRow(db, p, { revokedBy, actor, reason, at }) {
-- const changed = (await db.prepare(`UPDATE platform_node_principals SET status = 'revoked', revoked_at = ?, revoked_by = ?, credential_prev_hash = NULL,
-- prev_valid_until = NULL, updated_at = ? WHERE id = ? AND status <> 'revoked'`).run(at, revokedBy, at, p.id)).changes;
-- if (!changed) return 0;
-- await eventRelay.writerFor(db).enqueue(db, revokedEnvelope(p, { actor, reason, at }));
-- return 1;
--}
--
--/**
-- * Revoke a principal matching a caller-owned scope; a second revoke changes nothing (and emits nothing). → the row,
-- * or null. The previous credential and its grace window are cleared. `credential_hash` stays: migrations/0014's
-- * platform_node_principals_credential CHECK requires one for a user-owned principal, and a revoked row cannot
-- * authenticate because every reader requires status = 'active'. One function, so the service route and the
-- * person's own route revoke identically, both writing the event in the same transaction as the revoke.
-- */
--async function revokeScoped(db, id, scope, revokedBy, now = Date.now(), reason = null) {
-- const at = iso(now);
-- const actor = String(revokedBy).startsWith('svc:')
-- ? { type: 'service', id: String(revokedBy).slice(4) }
-- : { type: 'user', id: String(revokedBy) };
-- return await db.tx(async () => {
-- const p = await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
-- if (!p) return null;
-- await revokeRow(db, p, { revokedBy, actor, reason, at });
-- return await db.prepare(`SELECT * FROM platform_node_principals WHERE id = ? AND ${scope.where}`).get(String(id), ...scope.params);
-- });
--}
--
--/** Revoke a principal `service` paired; revoked_by is svc:<service>. → the row, or null. */
--const revoke = (db, id, service, now = Date.now(), reason = null) => revokeScoped(db, id, { where: 'paired_by_service = ?', params: [service] }, `svc:${service}`, now, reason);
--
--/**
-- * Revoke every active principal owned by any of `subjects` and enqueue its event, on the caller's ambient
-- * transaction (an account deletion: the person's machines go with the account, and each revoke is announced so a
-- * consumer stops the machine at once). Returns how many changed; a row already revoked emits nothing.
-- */
--async function revokeOwnedBy(db, subjects, { revokedBy = 'account_deleted', actor, reason = 'account_deleted', now = Date.now() } = {}) {
-- const at = iso(now);
-- let revoked = 0;
-- for (const sid of subjects || []) {
-- if (!SUBJECT.test(String(sid))) continue;
-- const rows = await db.prepare("SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ? AND status <> 'revoked'").all(String(sid));
-- for (const p of rows) revoked += await revokeRow(db, p, { revokedBy, actor: actor || { type: 'user', id: String(sid) }, reason, at });
-- }
-- return revoked;
--}
--
--/** The query filters of the operator and service principal lists; anything but a string is ignored. */
--const principalFilter = (q = {}) => Object.fromEntries(['cell', 'owner_kind', 'status'].map((k) => [k, typeof q[k] === 'string' ? q[k] : null]));
--
--/** Every node principal for operators and services holding network.registry.read, newest first, at most 500. */
--async function listPrincipals(db, { cell = null, owner_kind: ownerKind = null, status = null } = {}) {
-- const where = []; const params = [];
-- if (cell) { where.push('home_cell = ?'); params.push(cell); }
-- if (ownerKind) { where.push('owner_kind = ?'); params.push(ownerKind); }
-- if (status) { where.push('status = ?'); params.push(status); }
-- const rows = await db.prepare(`SELECT * FROM platform_node_principals ${where.length ? `WHERE ${where.join(' AND ')}` : ''}
-- ORDER BY created_at DESC, id DESC LIMIT 500`).all(...params);
-- return rows.map((p) => ({ ...principalView(p), trust: p.trust }));
--}
--
--/** What an operator may set a principal to: revoked has its own routes (revokeScoped), and is never undone. */
--const OPERATOR_STATUS = ['active', 'draining'];
--
--/**
-- * Operator write (plan T2): drain a node principal, or make it active again, from a staff session
-- * (server/registry/registry-admin.js). Throws RegistryError. → the principal (with trust)
-- */
--async function setPrincipalStatus(db, id, status, now = Date.now()) {
-- if (!OPERATOR_STATUS.includes(status)) throw new RegistryError(422, 'registry.invalid_status', 'status must be active or draining (revoking has its own route)');
-- const get = db.prepare('SELECT * FROM platform_node_principals WHERE id = ?');
-- const p = PRINCIPAL_ID.test(String(id)) ? await get.get(String(id)) : null;
-- if (!p) throw new RegistryError(404, 'registry.unknown_node', `no node principal ${id}`);
-- const at = iso(now);
-- const changed = (await db.prepare("UPDATE platform_node_principals SET status = ?, updated_at = ? WHERE id = ? AND status <> 'revoked'").run(status, at, p.id)).changes;
-- if (!changed) throw new RegistryError(409, 'registry.node_revoked', `node principal ${id} was revoked`);
-- const row = await get.get(p.id);
-- return { ...principalView(row), trust: row.trust };
--}
--
--/** Scope for a person's own machines: the principals they own, and no others. */
--const ownScope = (me) => ({ where: "owner_kind = 'user' AND owner_subject = ?", params: [me] });
--
--/** The signed-in person's subject id, or a RegistryError for a guest (never manages machines). */
--async function meOf(db, user) {
-- if (!user || user.is_anon) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
-- const sid = await require('../identity/subjects').ensureUserSubject(db, user);
-- if (!SUBJECT.test(String(sid || ''))) throw new RegistryError(403, 'registry.guest', 'sign in with an account to manage your machines');
-- return sid;
--}
--
--// Mounted at /api/v1/me/nodes behind requireAuth (server/index.js): a person's own machines, and nothing else.
--function userRouter(requireAuth, now = () => Date.now()) {
-- const router = express.Router();
-- require('../node/egress').mount({ user: router, requireAuth, now });
-- router.use(http.middleware());
-- const send = async (res, fn) => {
-- try { return await fn(); } catch (e) {
-- if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
-- console.error('[Node principals]', e.message);
-- return http.sendProblem(res, 500, 'registry.failed', { detail: 'the machines could not be read or changed' });
-- }
-- };
-- router.get('/', requireAuth, async (req, res) => await send(res, async () => {
-- res.set('Cache-Control', 'private, no-store');
-- const db = req.app.locals.db;
-- const me = await meOf(db, req.user);
-- const rows = await db.prepare(`SELECT * FROM platform_node_principals WHERE owner_kind = 'user' AND owner_subject = ?
-- ORDER BY created_at DESC, id DESC`).all(me);
-- res.json({ nodes: rows.map(principalView) });
-- }));
-- router.post('/:principal/revoke', requireAuth, async (req, res) => await send(res, async () => {
-- res.set('Cache-Control', 'private, no-store');
-- const db = req.app.locals.db;
-- const me = await meOf(db, req.user);
-- const p = await revokeScoped(db, req.params.principal, ownScope(me), me, now(), reasonOf((req.body || {}).reason));
-- if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.principal}` });
-- res.json(principalView(p));
-- }));
-- return router;
--}
--
--/** Audiences every person's machine may buy a token for, whichever service paired it (CON-4). */
--const NODE_WIDE_AUDIENCES = Object.freeze(['openvibe.link', 'openvibe.website']);
--
--/** Every refusal of a node's credential, whatever the reason: one status, one body. */
--const INVALID_CLIENT = Object.freeze({ status: 401, body: Object.freeze({ error: 'invalid_client', error_description: 'Invalid client credentials' }) });
--
--/**
-- * grant_type=client_credentials for a node principal (client_id nod_…): its current credential, or the previous one
-- * while prev_valid_until is in the future, buys a 300 s token for openvibe.network (network.node.self.manage) or for
-- * the service that paired it (no capability: that service authorises the node by its own binding). The secret is only
-- * ever hashed and compared in constant time; it is never logged or returned. → { status, body } (OAuth shapes).
-- */
--async function issueNodeToken(db, { clientId, clientSecret, audience, privateKey, issuer, now = Date.now() }) {
-- if (typeof clientId !== 'string' || !PRINCIPAL_ID.test(clientId) || typeof clientSecret !== 'string' || !clientSecret) return INVALID_CLIENT;
-- const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(clientId);
-- const hash = sha256(clientSecret);
-- // Both comparisons always run, so the answer's timing does not tell which secret (if any) matched.
-- const current = sameHash(p && p.credential_hash, hash);
-- const previous = sameHash(p && p.credential_prev_hash, hash);
-- const prevLive = !!(p && p.prev_valid_until && Date.parse(p.prev_valid_until) > now);
-- if (!p || p.status !== 'active' || !p.credential_hash || !(current || (previous && prevLive))) return INVALID_CLIENT;
-- const aud = typeof audience === 'string' ? audience.trim() : '';
-- // A person's machine may also speak to the services any of their computers can use, whoever paired it: OpenVibe
-- // Connect (openvibe.website) binds a share to one machine and checks the node token at every tunnel hello (ADR-057
-- // decision 4, CON-4). The token carries no capability: Connect authorises the node by its own share binding.
-- const anyNode = p.owner_kind === 'user' && NODE_WIDE_AUDIENCES.includes(aud);
-- // The Agent Streams plan (S1, Contracts 0.298.0): the desk publishes the person's agent stream on OpenVibe.Live, so
-- // a person's machine may ask for a token for openvibe.live that carries live.agent_stream.publish and names its
-- // owner (on_behalf_of). It is the owner's stream; a machine without an owner (a platform one) gets nothing.
-- const nodeCaps = p.owner_kind === 'user' && p.owner_subject ? NODE_GRANTS.filter(([a]) => a === aud).map(([, c]) => c) : [];
-- if (aud !== SELF_AUDIENCE && !anyNode && !nodeCaps.length && !(p.paired_by_service && aud === `openvibe.${p.paired_by_service}`)) {
-- const audiences = [SELF_AUDIENCE, ...(p.paired_by_service ? [`openvibe.${p.paired_by_service}`] : []),
-- ...(p.owner_kind === 'user' ? [...NODE_WIDE_AUDIENCES, ...NODE_GRANTS.map(([a]) => a)] : [])];
-- return { status: 400, body: { error: 'invalid_scope', error_description: `a node token is for ${audiences.join(', ')}` } };
-- }
-- const iat = Math.floor(now / 1000);
-- const claims = {
-- iss: issuer, sub: `node:${p.id}`, actor_type: 'node', aud: [aud],
-- cap: aud === SELF_AUDIENCE ? ['network.node.self.manage'] : nodeCaps,
-- // The person the machine acts for, on the audiences where it acts for them (the Agent Streams plan's Live
-- // token): Live reads the owner of an agent stream from it. Other audiences name nobody.
-- ...(nodeCaps.length && p.owner_subject ? { on_behalf_of: p.owner_subject } : {}),
-- ...(p.owner_kind === 'project' && p.project_id ? { project_id: p.project_id } : {}),
-- iat, exp: iat + TOKEN_TTL_S, jti: `tok_${crypto.randomBytes(12).toString('hex')}`,
-- };
-- assertValid('identity.service-token-claims@1', claims);
-- const access = serviceAuth.signServiceToken(claims, privateKey);
-- // The health of a paired machine: written at most once a minute, so a busy machine is not a write per token.
-- await db.prepare('UPDATE platform_node_principals SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)')
-- .run(iso(now), p.id, iso(now - SEEN_EVERY_MS));
-- return { status: 200, body: { access_token: access, token_type: 'Bearer', expires_in: TOKEN_TTL_S, scope: claims.cap.join(' ') } };
--}
--
--const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
--
--/**
-- * The node principal a verified token speaks for, live. A token of any other actor (svc:, app:) is 403
-- * capability.owner_denied whatever it holds; a node revoked since its token was issued (≤ 300 s ago) is 401.
-- */
--async function selfOf(db, req) {
-- const m = NODE_SUB.exec(String(req.principal && req.principal.sub || ''));
-- if (!m) throw new RegistryError(403, 'capability.owner_denied', 'only a node principal manages itself');
-- const p = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(m[1]);
-- if (!p || p.status !== 'active') throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
-- return p;
--}
--
--/**
-- * Store the capabilities the machine presents (platform.node-capabilities@1, verbatim). Its node_id must be the
-- * machine's own. Throws RegistryError. → { node_id, reported_at }
-- */
--async function putCapabilities(db, p, body, now = Date.now()) {
-- const v = validate('platform.node-capabilities@1', body);
-- if (!v.valid) throw new RegistryError(400, 'registry.invalid_capabilities', v.errors.map((e) => `${e.path} ${e.message}`).join('; ').slice(0, 500));
-- if (body.node_id !== p.node_id) throw new RegistryError(409, 'registry.node_mismatch', `this token is node ${p.node_id}, not ${String(body.node_id).slice(0, 80)}`);
-- const at = iso(now);
-- await db.prepare(`INSERT INTO platform_node_capabilities (node_id, doc, reported_at) VALUES (?, ?, ?)
-- ON CONFLICT (node_id) DO UPDATE SET doc = excluded.doc, reported_at = excluded.reported_at`).run(p.node_id, JSON.stringify(body), at);
-- return { node_id: p.node_id, reported_at: at };
--}
--
--/**
-- * The machine rotates its own credential: a new one (returned this once), the old one valid PREV_GRACE_MS more so a
-- * machine that crashes mid-rotation can still sign in. Throws RegistryError. → { principal, node_id, credential, prev_valid_until }
-- */
--async function rotateCredential(db, id, now = Date.now()) {
-- const credential = crypto.randomBytes(32).toString('base64url');
-- const at = iso(now);
-- const until = iso(now + PREV_GRACE_MS);
-- const row = await db.tx(async (t) => {
-- const p = await t.prepare('SELECT * FROM platform_node_principals WHERE id = ? FOR UPDATE').get(id);
-- if (!p || p.status !== 'active') return null;
-- await t.prepare(`UPDATE platform_node_principals SET credential_prev_hash = credential_hash,
-- prev_valid_until = CASE WHEN credential_hash IS NULL THEN NULL ELSE ? END, credential_hash = ?, updated_at = ?
-- WHERE id = ? AND status = 'active'`).run(until, sha256(credential), at, id);
-- return p;
-- });
-- if (!row) throw new RegistryError(401, 'registry.node_revoked', 'this node principal is revoked');
-- return { principal: row.id, node_id: row.node_id, credential, prev_valid_until: row.credential_hash ? until : null };
--}
--
--// Mounted at /api/v1/node/self behind principals.guard('network.node.self.manage') (server/index.js). The guard checks
--// the token's signature, audience and capability; ownership is selfOf's, before any write.
--function selfRouter(now = () => Date.now()) {
-- const router = express.Router();
-- require('../node/egress').mount({ self: router, now });
-- router.use(http.middleware());
-- const send = async (res, fn) => {
-- res.set('Cache-Control', 'no-store');
-- try { return await fn(); } catch (e) {
-- if (e instanceof RegistryError) return http.sendProblem(res, e.status, e.code, { detail: e.message });
-- console.error('[Node self]', e.message);
-- return http.sendProblem(res, 500, 'registry.failed', { detail: 'the node could not be changed' });
-- }
-- };
-- router.put('/capabilities', express.json({ limit: '64kb' }), async (req, res) => await send(res, async () => {
-- const db = req.app.locals.db;
-- res.json(await putCapabilities(db, await selfOf(db, req), req.body, now()));
-- }));
-- router.post('/credential', async (req, res) => await send(res, async () => {
-- const db = req.app.locals.db;
-- res.json(await rotateCredential(db, (await selfOf(db, req)).id, now()));
-- }));
-- return router;
--}
--
--function routers({ guard, now = () => Date.now() }) {
-- const problem = (res, e) => http.sendProblem(res, e.status, e.code, { detail: e.message });
-- const internal = express.Router();
-- internal.post('/node-pairings', guard, express.json({ limit: '8kb' }), async (req, res) => {
-- res.set('Cache-Control', 'no-store');
-- try {
-- res.status(201).json(await createPairing(req.app.locals.db, serviceOf(req), req.body || {}, now()));
-- } catch (e) {
-- if (e instanceof RegistryError) return problem(res, e);
-- throw e;
-- }
-- });
-- internal.get('/node-principals/:id', guard, async (req, res) => {
-- res.set('Cache-Control', 'no-store');
-- const p = await pairedBy(req.app.locals.db, req.params.id, serviceOf(req));
-- if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
-- res.json(principalView(p));
-- });
-- internal.post('/node-principals/:id/revoke', guard, async (req, res) => {
-- res.set('Cache-Control', 'no-store');
-- let reason;
-- try { reason = reasonOf((req.body || {}).reason); } catch (e) { return problem(res, e); }
-- const p = await revoke(req.app.locals.db, req.params.id, serviceOf(req), now(), reason);
-- if (!p) return http.sendProblem(res, 404, 'registry.unknown_node', { detail: `no node principal ${req.params.id}` });
-- res.json(principalView(p));
-- });
--
-- // Mounted at /api/v1/node-pairing behind its own rate limit (server/index.js).
-- const pairing = express.Router();
-- pairing.post('/', express.json({ limit: '8kb' }), async (req, res) => {
-- res.set('Cache-Control', 'no-store');
-- let out;
-- try {
-- out = await redeem(req.app.locals.db, req.body, now());
-- } catch (e) {
-- if (e instanceof RegistryError) return problem(res, e);
-- throw e;
-- }
-- const p = out.principal;
-- res.status(201).json({
-- principal: p.id, node_id: p.node_id, home_cell: p.home_cell, credential: out.credential,
-- token_endpoint: `${req.app.locals.config.jwt.issuer}/oauth/token`,
-- paired_for: { service: p.paired_by_service, ref: p.pairing_ref },
-- });
-- });
-- require('../node/egress').mount({ internal, pairing, now });
-- return { internal, pairing };
--}
--
--module.exports = { CODE_TTL_MS, MAX_TRIES, NODE_WIDE_AUDIENCES, PREV_GRACE_MS, OPERATOR_STATUS, createPairing, redeem, revoke, revokeOwnedBy, principalView, principalFilter, listPrincipals, setPrincipalStatus, routers, userRouter, issueNodeToken, selfRouter };
ddiff --git a/server/registry/nodes.js b/server/registry/nodes.js
iindex 1826f978..6b560e82 100644
---- a/server/registry/nodes.js
++++ b/server/registry/nodes.js
@@@ -1,9 +1,9 @@
'use strict';
/**
-- * The node registry (ADR-034 section 12, roadmap WS-X1; Contracts 0.76.0 network.node@1): every machine of the
++ * The node registry (ADR-034 section 12, roadmap WS-X1; Contracts 0.76.0 network.link@1): every machine of the
* platform with its roles, region-level location, beacon and health. Host reports the complete set its inventory
-- * knows (POST /internal/nodes/report, network.node.report); a node absent from a later report of the same source is
-- * marked down, never silently deleted. GET /api/v1/nodes is public and cacheable: products and openvibe-sdk/geo
++ * knows (POST /internal/links/report, network.link.report); a node absent from a later report of the same source is
++ * marked down, never silently deleted. GET /api/v1/links is public and cacheable: products and openvibe-sdk/geo
* find "the nearest node with role R" from it. Nothing private is stored: the contract has no address field.
*/
const express = require('express');
@@@ -72,7 +72,7 @@ function routers({ guard, readGuard = null }) {
// serves it; a report names another URL for nodes whose traffic does not come through here.
pub.get('/:id/beacon', (req, res) => open(res, 0).status(204).end());
const internal = express.Router();
-- // The same list for services holding network.registry.read (GET /internal/nodes), never cached.
++ // The same list for services holding network.registry.read (GET /internal/links), never cached.
if (readGuard) internal.get('/', readGuard, async (req, res) => {
const role = typeof req.query.role === 'string' ? req.query.role : null;
const region = typeof req.query.region === 'string' ? req.query.region : null;
@@@ -80,8 +80,8 @@ function routers({ guard, readGuard = null }) {
});
internal.post('/report', guard, express.json({ limit: '256kb' }), async (req, res) => {
const { validate } = require('openvibe-contracts');
-- const v = validate('network.node-report-request@1', req.body);
-- if (!v.valid) return res.status(400).json({ error: 'The body does not match network.node-report-request@1', details: (v.errors || []).slice(0, 5) });
++ const v = validate('network.link-report-request@1', req.body);
++ if (!v.valid) return res.status(400).json({ error: 'The body does not match network.link-report-request@1', details: (v.errors || []).slice(0, 5) });
let out;
try { out = await report(req.app.locals.db, req.body); } catch (e) {
if (e instanceof require('./cells').RegistryError) return require('openvibe-contracts').http.sendProblem(res, e.status, e.code, { detail: e.message });
ddiff --git a/server/registry/offers.js b/server/registry/offers.js
iindex 37ec88cf..72043c43 100644
---- a/server/registry/offers.js
++++ b/server/registry/offers.js
@@@ -5,7 +5,7 @@
* reports the complete set of its offers (POST /internal/resources/report, network.resource.report); an offer absent
* from a later report of the same source is marked down, never
* deleted. This module stores; it does not plan, price or settle. Reads (slice 2): GET /api/v1/offers is public and
-- * cacheable but leaves each offer's capacity out (the contract is first-party: it carries the capacity network.node@1
++ * cacheable but leaves each offer's capacity out (the contract is first-party: it carries the capacity network.link@1
* deliberately does not publish); GET /internal/resources, behind the report's guard, returns the docs whole.
*/
const express = require('express');
@@@ -14,7 +14,7 @@ const CONTRACT = 'platform.resource-offer@1';
// Network's public projection of CONTRACT: capacity is absent, and harness detail.address is absent.
// The latter is required by the internal contract, so public harness docs need their own validator.
const PUBLIC_CONTRACT = 'platform.resource-offer-public@1';
--const SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.node-report-request@1's source
++const SOURCE = /^[a-z][a-z0-9-]{1,39}$/; // the same rule as network.link-report-request@1's source
const MAX_OFFERS = 500;
const DEFAULT_CELL = 'wnam-1';
ddiff --git a/server/registry/registry-admin.js b/server/registry/registry-admin.js
iindex 201c7a02..f2d8d8c0 100644
---- a/server/registry/registry-admin.js
++++ b/server/registry/registry-admin.js
@@@ -5,16 +5,16 @@
* so no report carries them; a staff session sets them, next to /api/admin/deploy. No service capability is involved.
* Nothing routes on a weight or on draining until a scheduler and a route consumer exist. Every change lands in
* audit_log.
-- * GET /api/admin/registry/node-principals?cell=&owner_kind=&status= → { node_principals: [...] }
++ * GET /api/admin/registry/link-principals?cell=&owner_kind=&status= → { node_principals: [...] }
* PUT /api/admin/registry/cells/:id { route_weight?, status? } → the cell
* PUT /api/admin/registry/instances/:id { route_weight?, state? } → the instance
-- * PUT /api/admin/registry/node-principals/:id { status } → the principal (active or draining)
++ * PUT /api/admin/registry/link-principals/:id { status } → the principal (active or draining)
*/
const express = require('express');
const { http } = require('openvibe-contracts');
const { RegistryError, setCell } = require('./cells');
const { setInstance } = require('./instances');
--const { principalFilter, listPrincipals, setPrincipalStatus } = require('./node-principals');
++const { principalFilter, listPrincipals, setPrincipalStatus } = require('./link-principals');
/** The body of a PUT: a JSON object with at least one of `allowed` and nothing else. Throws RegistryError. */
function fields(body, allowed) {
@@@ -46,7 +46,7 @@ function createRegistryAdmin(db, requireAuth, requireAdmin) {
return out;
});
-- router.get('/node-principals', send(async (req) => ({
++ router.get('/link-principals', send(async (req) => ({
node_principals: await listPrincipals(db, principalFilter(req.query)), generated_at: new Date().toISOString(),
})));
router.put('/cells/:id', send((req) => {
@@@ -57,7 +57,7 @@ function createRegistryAdmin(db, requireAuth, requireAdmin) {
const body = fields(req.body, ['route_weight', 'state']);
return write(req, 'registry_instance_update', () => setInstance(db, req.params.id, body));
}));
-- router.put('/node-principals/:id', send((req) => {
++ router.put('/link-principals/:id', send((req) => {
const { status } = fields(req.body, ['status']);
return write(req, 'registry_node_principal_update', () => setPrincipalStatus(db, req.params.id, status));
}));
ddiff --git a/server/registry/resource-index.js b/server/registry/resource-index.js
iindex b43875e3..7c75d946 100644
---- a/server/registry/resource-index.js
++++ b/server/registry/resource-index.js
@@@ -6,7 +6,7 @@
* GET /api/v1/resources/:ovrn → common.resource-summary@1
*
* It pages the resources OpenVibe.Network owns — its developer projects (prj_), their apps (app_) and
-- * its node principals (nod_) — as common.resource-summary@1, the shape OpenVibe.Services fans out over
++ * its node principals (lnk_) — as common.resource-summary@1, the shape OpenVibe.Services fans out over
* and merges (openvibe-sdk/resources' createResourceIndex). The T2 Fabric offer registry used to share
* this path; the amended ADR-048 moves its public routes to /api/v1/offers (server/registry/offers.js).
*
@@@ -19,7 +19,7 @@
* formatter, so it is present exactly when the resource is a nameable resource. Per ADR-048 section 3
* a project is addressed by its bare prj_ id — it is never given a self-referential OVRN — and an app
* is an actor, never a resource name (app_ is excluded from the OVRN id pattern). A project-owned node
-- * principal is named ovrn:network:<prj_…>:node/nod_…; a platform- or user-owned one has no project
++ * principal is named ovrn:network:<prj_…>:link/lnk_…; a platform- or user-owned one has no project
* segment and so no name. That is also what GET /api/v1/resources/:ovrn can read: only a resource
* whose computed ovrn equals the one asked for answers.
*/
@@@ -29,7 +29,7 @@ const contracts = require('openvibe-contracts');
const SERVICE = 'network';
const PROJECT_KIND = 'network.project';
const APP_KIND = 'network.app';
--const NODE_KIND = 'network.node';
++const NODE_KIND = 'network.link';
const KINDS = [PROJECT_KIND, APP_KIND, NODE_KIND];
const PROJECT_ID_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER_SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -176,7 +176,7 @@ function router({ guard }) {
const db = req.app.locals.db;
let summary = null;
if (parsed && parsed.service === SERVICE) {
-- if (parsed.type === 'node') {
++ if (parsed.type === 'link') {
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(parsed.id);
if (row) summary = named(nodeSummary(row));
} else if (parsed.type === 'app') {
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex bbe7971b..5c296d35 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -215,11 +215,11 @@ const server = http.createServer(app);
// dana had folded an older account in (ADR-029): its subject goes with her.
const old = await mk('dana_old');
await accountMerge.merge(db, old, await db.prepare('SELECT * FROM users WHERE id = ?').get(dana.id), { initiatedBy: 'person' });
-- // Two paired machines, one per subject: an account deletion revokes both and announces each (network.node.revoked@1).
++ // Two paired machines, one per subject: an account deletion revokes both and announces each (network.link.revoked@1).
const pairedNodes = [];
for (const subjectId of [dana.subject_id, old.subject_id]) {
const ulid = ids.ulid();
-- const principal = `nod_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;
++ const principal = `lnk_${ulid}`; const nodeId = `n-${ulid.toLowerCase()}`;
await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status,
credential_hash, created_at, created_by, updated_at)
VALUES (?, ?, 'wnam-1', 'user', ?, 'community', 'active', ?, ov_now_iso(), 'test', ov_now_iso())`)
@@@ -228,7 +228,7 @@ const server = http.createServer(app);
}
const oldToken = tok(dana);
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 1000 })).deleted, 1);
-- const nodeEvents = await events('network.node.revoked');
++ const nodeEvents = await events('network.link.revoked');
assert.strictEqual(nodeEvents.length, 2, 'each paired machine revoked exactly once');
for (const [principal, nodeId, subjectId] of pairedNodes) {
const p = await db.prepare('SELECT status, revoked_by FROM platform_node_principals WHERE id = ?').get(principal);
@@@ -236,7 +236,7 @@ const server = http.createServer(app);
const rev = nodeEvents.find((e) => e.subject.id === principal);
assert.ok(rev, `an event for ${principal}`);
assert.ok(validate('events.event-envelope@1', rev).valid, JSON.stringify(validate('events.event-envelope@1', rev).errors));
-- assert.ok(validate('network.node.revoked@1', rev.payload).valid, JSON.stringify(validate('network.node.revoked@1', rev.payload).errors));
++ assert.ok(validate('network.link.revoked@1', rev.payload).valid, JSON.stringify(validate('network.link.revoked@1', rev.payload).errors));
assert.deepStrictEqual([rev.payload.node_id, rev.payload.principal_id, rev.payload.owner, rev.payload.reason, rev.actor],
[nodeId, principal, { kind: 'user', subject: subjectId }, 'account_deleted', { type: 'user', id: dana.subject_id }]);
assert.ok(!/hash|credential|secret/i.test(JSON.stringify(rev.payload)), 'the payload carries no secret field');
@@@ -265,7 +265,7 @@ const server = http.createServer(app);
assert.deepStrictEqual([ev[0].payload.deletion_id, ev[0].payload.subject, ev[0].payload.aliases], [del, dana.subject_id, [old.subject_id]]);
assert.ok((await events('network.user.token_valid_after')).some((e) => e.payload.reason === 'account_deleted'));
assert.strictEqual((await accountData.sweep(db, { dir: exportDir, now: due + 5000 })).deleted, 0, 'once');
-- assert.strictEqual((await events('network.node.revoked')).length, 2, 'a second sweep emits nothing new');
++ assert.strictEqual((await events('network.link.revoked')).length, 2, 'a second sweep emits nothing new');
// Services confirm; staff see what is outstanding.
const confirm = { subject: dana.subject_id, completed_at: new Date().toISOString(), erased: { messages: 3 }, retained: { moderation_actions: 1 } };
ddiff --git a/test/agent-schema.test.js b/test/agent-schema.test.js
iindex 4a33ad80..dda37e64 100644
---- a/test/agent-schema.test.js
++++ b/test/agent-schema.test.js
@@@ -44,7 +44,7 @@ const quiet = { log() {}, warn() {}, error() {} };
// The real directory: 0016 applies on top of 0015, once.
const first = await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0016', 'agents', 'expand'], ['0017', 'agent_grants', 'expand'], ['0018', 'confirmations', 'expand'], ['0019', 'agent_budgets', 'expand'], ['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand']]);
assert.ok(await hasAgents());
assert.deepStrictEqual((await asOwner((db) => db.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
// Expand only, IF NOT EXISTS throughout: running the file again changes nothing and fails nothing.
ddiff --git a/test/agent-stream-grants.test.js b/test/agent-stream-grants.test.js
iindex 5a2137ba..ceea8c66 100644
---- a/test/agent-stream-grants.test.js
++++ b/test/agent-stream-grants.test.js
@@@ -14,7 +14,7 @@ const assert = require('assert');
const crypto = require('crypto');
const contracts = require('openvibe-contracts');
const principals = require('../server/identity/principals');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
const CAP = 'live.agent_stream.publish';
const ISSUER = 'https://openvibe.network';
@@@ -38,7 +38,7 @@ assert.ok(nodeGrant, "a person's machine may publish for its owner on Live");
const OWNER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const CREDENTIAL = 'node-credential-for-the-agent-stream-test';
const principal = {
-- id: 'nod_01JAB2C3D4E5F6G7H8J9K0MNPQ',
++ id: 'lnk_01JAB2C3D4E5F6G7H8J9K0MNPQ',
status: 'active',
owner_kind: 'user',
owner_subject: OWNER,
@@@ -70,7 +70,7 @@ const keys = crypto.generateKeyPairSync('rsa', {
});
assert.ok(v.ok, v.reason);
assert.deepStrictEqual([v.claims.sub, v.claims.actor_type, v.claims.aud, v.claims.cap],
-- [`node:${principal.id}`, 'node', ['openvibe.live'], [CAP]]);
++ [`link:${principal.id}`, 'link', ['openvibe.live'], [CAP]]);
assert.strictEqual(v.claims.on_behalf_of, OWNER, 'the token names the owner it acts for');
assert.ok(contracts.validate('identity.service-token-claims@1', v.claims).valid,
'the claims fit identity.service-token-claims@1');
ddiff --git a/test/api-rate-limit.test.js b/test/api-rate-limit.test.js
iindex c27be45b..742a1acf 100644
---- a/test/api-rate-limit.test.js
++++ b/test/api-rate-limit.test.js
@@@ -10,7 +10,7 @@ const path = require('path');
const src = fs.readFileSync(path.join(__dirname, '../server/index.js'), 'utf8');
assert.ok(src.includes("app.use('/api/', rateLimit({ windowMs: 60_000, max: 600 }));"), 'general API: 600 a minute per address');
assert.ok(src.includes("app.use('/api/auth/', rateLimit({ windowMs: 15 * 60_000, max: 30, skipSuccessfulRequests: true }));"), 'failed sign-ins stay at 30 per 15 minutes');
--assert.ok(src.includes("app.use('/api/v1/node-pairing', rateLimit({ windowMs: 60_000, max: 10 })"), 'pairing stays at 10 a minute');
++assert.ok(src.includes("app.use('/api/v1/link-pairing', rateLimit({ windowMs: 60_000, max: 10 })"), 'pairing stays at 10 a minute');
const conf = fs.readFileSync(path.join(__dirname, '../deploy/nginx/openvibe.network.conf'), 'utf8');
assert.ok(/zone=openvibenetwork_api:10m rate=10r\/s/.test(conf), "nginx's /api/ zone is the 10 r/s the app now matches");
// /oauth/authorize (every site's sign-in and silent check, no password) has its own zone: under the password
ddiff --git a/test/cells-registry.test.js b/test/cells-registry.test.js
iindex 05e6064e..ba97b7b7 100644
---- a/test/cells-registry.test.js
++++ b/test/cells-registry.test.js
@@@ -27,14 +27,14 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
--app.use('/internal/nodes', require('../server/registry/nodes').routers({ guard: principals.guard('network.node.report') }).internal);
--app.use('/internal/resources', require('../server/registry/offers').routers({ guard: principals.guard('network.node.report') }).internal);
++app.use('/internal/links', require('../server/registry/nodes').routers({ guard: principals.guard('network.link.report') }).internal);
++app.use('/internal/resources', require('../server/registry/offers').routers({ guard: principals.guard('network.link.report') }).internal);
const c = cells.routers({ readGuard: principals.guard('network.registry.read') });
app.use('/api/v1/cells', c.pub);
app.use('/internal/registry', c.internal);
const server = http.createServer(app);
--const nod = () => `nod_${ids.ulid()}`;
++const nod = () => `lnk_${ids.ulid()}`;
const project = `prj_${ids.ulid()}`;
const principal = (nodeId) => db.prepare('SELECT * FROM platform_node_principals WHERE node_id = ?').get(nodeId);
const insertPrincipal = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, trust, status, created_by, revoked_at)
@@@ -76,8 +76,8 @@ try {
[{ node_id: 'p7', owner_kind: 'platform', trust: 'first-party', status: 'revoked' }, 'revoked needs revoked_at'],
[{ node_id: 'p8', owner_kind: 'platform', trust: 'first-party', revoked_at: '2026-10-01T00:00:00Z' }, 'revoked_at needs revoked'],
[{ node_id: 'p9', owner_kind: 'project', project_id: project, trust: 'trusted' }, 'trust is the trust-class enum'],
-- [{ id: `usr_${ids.ulid()}`, node_id: 'p10', owner_kind: 'platform', trust: 'first-party' }, 'a node principal id is nod_<ULID>, never another subject'],
-- [{ node_id: 'Bad_Node', owner_kind: 'platform', trust: 'first-party' }, 'node ids follow network.node@1'],
++ [{ id: `usr_${ids.ulid()}`, node_id: 'p10', owner_kind: 'platform', trust: 'first-party' }, 'a node principal id is lnk_<ULID>, never another subject'],
++ [{ node_id: 'Bad_Node', owner_kind: 'platform', trust: 'first-party' }, 'node ids follow network.link@1'],
];
for (const [row, why] of refused) await assert.rejects(insertPrincipal(row), why);
assert.strictEqual((await db.prepare('SELECT COUNT(*) AS n FROM platform_node_principals').get()).n, 0, 'nothing was written by a refused insert');
@@@ -100,7 +100,7 @@ try {
assert.deepStrictEqual((await globalThis.__ovNetworkMigrate({ dir: MIGRATIONS })).applied, []);
await globalThis.__ovNetworkDdl(require('fs').readFileSync(require('path').join(MIGRATIONS, '0014_node_pairing.sql'), 'utf8'));
-- // ── A node principal is not a service principal: an oauth_clients secret under a nod_ id buys nothing. Every nod_
++ // ── A node principal is not a service principal: an oauth_clients secret under a lnk_ id buys nothing. Every lnk_
// client goes to the node branch (slice N4c), which knows only node credentials: 401 invalid_client.
const nodClient = nod();
await db.prepare("INSERT INTO oauth_clients (client_id, client_secret, name, redirect_uris) VALUES (?, 'node-secret', 'node', '[]')").run(nodClient);
@@@ -110,17 +110,17 @@ try {
// ── Host's node report: first-named machines become platform principals; a project's or a revoked machine refuses the report.
const host = { authorization: `Bearer ${await token('host', 'host-secret')}` };
-- let x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
++ let x = await call('POST', '/internal/links/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
const ovh = await principal('ovh-1');
-- assert.match(ovh.id, /^nod_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.match(ovh.id, /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.deepStrictEqual([ovh.owner_kind, ovh.project_id, ovh.trust, ovh.home_cell, ovh.status, ovh.created_by], ['platform', null, 'first-party', 'wnam-1', 'active', 'report:primary']);
assert.strictEqual((await principal('edge-1')).home_cell, 'wnam-1', 'no active cell in eu-west yet: the bootstrap cell');
-- x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
++ x = await call('POST', '/internal/links/report', { source: 'primary', nodes: [node('ovh-1'), node('edge-1', 'eu-west')] }, host);
assert.strictEqual(x.status, 200);
assert.strictEqual((await principal('ovh-1')).id, ovh.id, 'a re-report keeps the principal');
for (const [id, code] of [['pi-1', 'registry.node_not_platform'], ['old-1', 'registry.node_revoked']]) {
-- x = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [node('ovh-1'), node(id)] }, host);
++ x = await call('POST', '/internal/links/report', { source: 'primary', nodes: [node('ovh-1'), node(id)] }, host);
assert.deepStrictEqual([x.status, x.body.code], [409, code], `${id}: ${JSON.stringify(x.body)}`);
}
assert.deepStrictEqual((await db.prepare("SELECT id, status FROM platform_nodes ORDER BY id").all()).map((r) => [r.id, r.status]), [['edge-1', 'up'], ['ovh-1', 'up']],
@@@ -181,9 +181,9 @@ try {
assert.strictEqual((await call('GET', '/internal/registry/cells/nope-1', null, live)).status, 404);
// ── Every node principal for network.registry.read (T2 step 1), newest first, filtered by cell, owner and status.
-- assert.strictEqual((await call('GET', '/internal/registry/node-principals')).status, 403, 'nobody');
-- assert.strictEqual((await call('GET', '/internal/registry/node-principals', null, host)).status, 403, 'Host lacks network.registry.read');
-- x = await call('GET', '/internal/registry/node-principals', null, live);
++ assert.strictEqual((await call('GET', '/internal/registry/link-principals')).status, 403, 'nobody');
++ assert.strictEqual((await call('GET', '/internal/registry/link-principals', null, host)).status, 403, 'Host lacks network.registry.read');
++ x = await call('GET', '/internal/registry/link-principals', null, live);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
const stored = await db.prepare('SELECT id FROM platform_node_principals ORDER BY created_at DESC, id DESC').all();
@@@ -193,7 +193,7 @@ try {
assert.deepStrictEqual([byId['pi-1'].owner, byId['pi-1'].trust, byId['pi-1'].home_cell], [{ kind: 'project', project_id: project }, 'community', 'wnam-1']);
assert.deepStrictEqual([byId['ovh-1'].owner, byId['ovh-1'].trust], [{ kind: 'platform' }, 'first-party']);
assert.strictEqual(byId['pair-user'].owner.kind, 'user');
-- const nodesOf = async (q) => (await call('GET', `/internal/registry/node-principals${q}`, null, live)).body.node_principals.map((p) => p.node_id).sort();
++ const nodesOf = async (q) => (await call('GET', `/internal/registry/link-principals${q}`, null, live)).body.node_principals.map((p) => p.node_id).sort();
assert.deepStrictEqual(await nodesOf('?cell=weu-1'), ['pair-cred-a', 'pair-platform', 'pair-user']);
assert.deepStrictEqual(await nodesOf('?owner_kind=user'), ['pair-user']);
assert.deepStrictEqual(await nodesOf('?status=revoked'), ['old-1']);
ddiff --git a/test/fixtures/rehearsal/user-owned-trust.sql b/test/fixtures/rehearsal/user-owned-trust.sql
iindex ef630d57..c18d59f8 100644
---- a/test/fixtures/rehearsal/user-owned-trust.sql
++++ b/test/fixtures/rehearsal/user-owned-trust.sql
@@@ -4,9 +4,9 @@
INSERT INTO dev_projects (id, owner_subject, name, created_at, created_by)
VALUES ('prj_0000000000000000000000RH01', 'usr_0000000000000000000000RH01', 'Rehearsal lab', '2026-10-01T00:00:00Z', 'rehearsal') ON CONFLICT DO NOTHING;
INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, owner_subject, trust, created_by, credential_hash) VALUES
-- ('nod_0000000000000000000000RH01', 'rh-platform', 'wnam-1', 'platform', NULL, NULL, 'first-party', 'rehearsal', NULL),
-- ('nod_0000000000000000000000RH02', 'rh-project', 'wnam-1', 'project', 'prj_0000000000000000000000RH01', NULL, 'partner', 'rehearsal', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'),
-- ('nod_0000000000000000000000RH03', 'rh-user', 'wnam-1', 'user', NULL, 'usr_0000000000000000000000RH01', 'community', 'rehearsal', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc')
++ ('IDPREFIX_0000000000000000000000RH01', 'rh-platform', 'wnam-1', 'platform', NULL, NULL, 'first-party', 'rehearsal', NULL),
++ ('IDPREFIX_0000000000000000000000RH02', 'rh-project', 'wnam-1', 'project', 'prj_0000000000000000000000RH01', NULL, 'partner', 'rehearsal', 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'),
++ ('IDPREFIX_0000000000000000000000RH03', 'rh-user', 'wnam-1', 'user', NULL, 'usr_0000000000000000000000RH01', 'community', 'rehearsal', 'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc')
ON CONFLICT DO NOTHING;
INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, price_usd, doc, reported_at) VALUES
('rh-platform', 'rehearsal', 'node', 'us-west', 'wnam-1', 'first-party', 'up', 0, '{"offer_id":"rh-platform"}', '2026-10-01T00:00:00Z'),
ddiff --git a/test/home-intent-routing.test.js b/test/home-intent-routing.test.js
iindex 2a060544..526e0ebe 100644
---- a/test/home-intent-routing.test.js
++++ b/test/home-intent-routing.test.js
@@@ -38,8 +38,8 @@ const cases = [
['help me plan a birthday party for ten kids', vibe('help me plan a birthday party for ten kids')],
['ask vibe', vibe('ask vibe')],
['sourdough starter', vibe('sourdough starter')],
-- ['use my ollama models', 'https://openvibe.bot/machines'],
-- ['my old laptop as a desk host', 'https://openvibe.bot/machines'],
++ ['use my ollama models', 'https://openvibe.link/machines'],
++ ['my old laptop as a desk host', 'https://openvibe.link/machines'],
];
cases.forEach(([q, want]) => assert.strictEqual(route(q), want, `"${q}"`));
assert.strictEqual(route(' '), 'tool search', 'an empty box does nothing new');
ddiff --git a/test/internal-projects.test.js b/test/internal-projects.test.js
iindex 9550256a..221773bf 100644
---- a/test/internal-projects.test.js
++++ b/test/internal-projects.test.js
@@@ -100,7 +100,7 @@ const U = { owner: userToken(10), dev: userToken(11), staff: userToken(14) };
assert.strictEqual(r.status, 401); assert.strictEqual(r.body.code, 'token.missing', 'no token');
assert.strictEqual((await read(P, { 'x-internal-key': 'legacy-key' })).status, 401, 'not the retired shared key');
assert.strictEqual((await read(P, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.project.read');
-- assert.strictEqual((await read(P, { authorization: `Bearer ${await token('host', 'host-secret', 'network.node.report')}` })).status, 403, 'a Host token scoped to another capability');
++ assert.strictEqual((await read(P, { authorization: `Bearer ${await token('host', 'host-secret', 'network.link.report')}` })).status, 403, 'a Host token scoped to another capability');
assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${U.owner}` })).status), 'a person\'s session never reads it, not even the owner\'s');
const appToken = (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: A, client_secret: secrets[0], audience: 'openvibe.network' }) })).json()).access_token;
if (appToken) assert.ok([401, 403].includes((await read(P, { authorization: `Bearer ${appToken}` })).status), 'an app of the project cannot read it');
ddiff --git a/test/link-auth.test.js b/test/link-auth.test.js
iindex 8a398306..197b8b3b 100644
---- a/test/link-auth.test.js
++++ b/test/link-auth.test.js
@@@ -4,8 +4,8 @@ const http = require('node:http');
const crypto = require('node:crypto');
const express = require('express');
const jwt = require('jsonwebtoken');
--const { createLinkAuthRoutes } = require('../server/node/auth');
--const { render } = require('../server/node/page');
++const { createLinkAuthRoutes } = require('../server/link/auth');
++const { render } = require('../server/link/page');
const listen = app => new Promise(resolve => { const s = app.listen(0, '127.0.0.1', () => resolve(s)); });
const close = s => new Promise(resolve => s.close(resolve));
const cookieJar = r => (r.headers['set-cookie'] || []).map(c => c.split(';')[0]).join('; ');
ddiff --git a/test/link-brand.test.js b/test/link-brand.test.js
nnew file mode 100644
iindex 00000000..eaa904f2
---- /dev/null
++++ b/test/link-brand.test.js
@@@ -0,0 +1,10 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { spawnSync } = require('node:child_process');
++const path = require('node:path');
++const root = path.resolve(__dirname, '..');
++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
++console.log(' ok old Link brand and principal IDs are absent');
ddiff --git a/test/link-compute.test.js b/test/link-compute.test.js
nnew file mode 100644
iindex 00000000..7f0f17d3
---- /dev/null
++++ b/test/link-compute.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++const assert = require('node:assert/strict');
++const express = require('express');
++const { routers } = require('../server/registry/machines');
++
++(async () => {
++ const owner = `usr_${'A'.repeat(26)}`;
++ const id = `dev_${'A'.repeat(26)}`;
++ const records = [{ id, name: 'Studio', online: true, roles: ['codes.worker'], running: [] }];
++ const bot = { list: async (subject) => ({ machines: subject === owner ? records : [] }) };
++ const requireAuth = (req, _res, next) => { req.user = { subject_id: owner }; next(); };
++ const app = express();
++ app.use('/api/v1/link/compute', routers({ bot, requireAuth, publicKey: '', issuer: '' }).compute);
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((resolve) => server.once('listening', resolve));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ try {
++ const list = await fetch(`${base}/api/v1/link/compute`).then((r) => r.json());
++ assert.deepEqual(list.sources.map((s) => s.kind), ['personal', 'cloud', 'exchange', 'community']);
++ assert.deepEqual(list.candidates.map((c) => c.id), [id]);
++ assert.equal(list.candidates[0].status, 'ready');
++ assert.equal(list.sources[2].price, null, 'a rental has no invented price');
++ const pick = async (body) => {
++ const response = await fetch(`${base}/api/v1/link/compute/pick`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) });
++ return { status: response.status, body: await response.json() };
++ };
++ assert.equal((await pick({ id })).body.choice.id, id);
++ assert.equal((await pick({ kind: 'exchange' })).status, 409, 'a placeholder cannot start a rental');
++ records[0].online = false;
++ const offline = await pick({ id });
++ assert.equal(offline.status, 409);
++ assert.match(offline.body.detail, /offline/);
++ console.log('link-compute: four sources, owner machine selection and unavailable reasons ok');
++ } finally { server.close(); }
++})().catch((error) => { console.error(error); process.exitCode = 1; });
ddiff --git a/test/link-egress.test.js b/test/link-egress.test.js
iindex bd3f22fd..dfb7d7d0 100644
---- a/test/link-egress.test.js
++++ b/test/link-egress.test.js
@@@ -3,8 +3,8 @@ const assert = require('assert/strict');
const crypto = require('crypto');
const { ids } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
--const principals = require('../server/registry/node-principals');
--const egress = require('../server/node/egress');
++const principals = require('../server/registry/link-principals');
++const egress = require('../server/link/egress');
(async () => {
const db = getDb();
ddiff --git a/test/link-home.test.js b/test/link-home.test.js
iindex 0e5dd7e3..95d65a8c 100644
---- a/test/link-home.test.js
++++ b/test/link-home.test.js
@@@ -1,7 +1,7 @@
'use strict';
--// openvibe.link: OpenVibe Link's front door (the Node renamed), served by Network. The Host header decides: on
++// openvibe.link: OpenVibe Link's front door (the machine agent), served by Network. The Host header decides: on
// openvibe.link '/' is Link's page, '/install' answers with the installer of the latest release, and the page has
--// its Markdown twin, its llms.txt and its crawl files, like Network's other pages. node.openvibe.network is the
++// its Markdown twin, its llms.txt and its crawl files, like Network's other pages. openvibe.link is the
// old name: it 301s to the same path on openvibe.link, except /install, which keeps serving.
// node test/link-home.test.js
const assert = require('assert');
@@@ -10,27 +10,27 @@ const http = require('http');
const path = require('path');
const express = require('express');
--const page = require('../server/node/page');
--const release = require('../server/node/release');
--const { createNodeRoutes, llmsTxt } = require('../server/node/routes');
++const page = require('../server/link/page');
++const release = require('../server/link/release');
++const { createLinkRoutes, llmsTxt } = require('../server/link/routes');
// ── The release answer: cached for ten minutes, with a fallback when GitHub cannot be reached ──
const FIXTURE = {
tag_name: 'v9.9.9',
published_at: '2026-11-06T12:00:00Z',
-- html_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/tag/v9.9.9',
++ html_url: 'https://github.com/OpenVibers/OpenVibe.Link/releases/tag/v9.9.9',
assets: [
-- { name: 'install.sh', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/install.sh' },
-- { name: 'SHA256SUMS', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/SHA256SUMS' },
++ { name: 'install.sh', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/install.sh' },
++ { name: 'SHA256SUMS', browser_download_url: 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/SHA256SUMS' },
{ name: 'openvibe-link-v9.9.9.apk' },
],
};
const SHAPED = {
version: 'v9.9.9',
publishedAt: '2026-11-06T12:00:00Z',
-- url: 'https://github.com/OpenVibers/OpenVibe.Node/releases/tag/v9.9.9',
-- installUrl: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/install.sh',
-- checksumsUrl: 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/SHA256SUMS',
++ url: 'https://github.com/OpenVibers/OpenVibe.Link/releases/tag/v9.9.9',
++ installUrl: 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/install.sh',
++ checksumsUrl: 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/SHA256SUMS',
};
(async () => {
@@@ -41,8 +41,8 @@ const SHAPED = {
const clock = () => now;
const first = await release.latest({ fetchImpl, now: clock, dataDir: null });
assert.strictEqual(first.version, 'v9.9.9', 'the release is read from GitHub');
-- assert.strictEqual(first.installUrl, 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/install.sh');
-- assert.strictEqual(first.checksumsUrl, 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v9.9.9/SHA256SUMS');
++ assert.strictEqual(first.installUrl, 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/install.sh');
++ assert.strictEqual(first.checksumsUrl, 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v9.9.9/SHA256SUMS');
const second = await release.latest({ fetchImpl, now: clock, dataDir: null });
assert.strictEqual(calls, 1, 'the second call is answered from the cache');
assert.strictEqual(second.version, 'v9.9.9');
@@@ -56,7 +56,7 @@ const SHAPED = {
const broken = await release.latest({ fetchImpl: async () => { throw new Error('no GitHub'); }, now: clock, dataDir: null });
assert.ok(broken.version, 'a release is still named');
assert.strictEqual(broken.stale, true, 'and it is marked as the last one read');
-- assert.ok(broken.installUrl.startsWith('https://github.com/OpenVibers/OpenVibe.Node/releases/download/'), 'the installer link still works');
++ assert.ok(broken.installUrl.startsWith('https://github.com/OpenVibers/OpenVibe.Link/releases/download/'), 'the installer link still works');
await release.whenIdle();
// The vhost must send /machines and /pair to the app (which signposts them), not the catch-all to Network.
@@@ -68,7 +68,7 @@ const SHAPED = {
const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));
assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');
-- // ── The page: OpenVibe Link, and every fact the Node page had ──
++ // ── The page: OpenVibe Link, and every fact the Link page had ──
const html = page.render({ release: first, releaseId: 'rel_test' });
assert.ok(html.includes('id="send"') && html.includes('Send to my machine'), 'the Link page has the send form');
assert.ok(html.includes('src="/send.js"'), 'the page loads its Locker sender');
@@@ -83,13 +83,13 @@ const SHAPED = {
assert.ok(html.includes('>Install Link<'), 'the primary button installs Link');
assert.ok(html.includes('>The install guide<'), 'and the guide keeps its name');
assert.ok(html.includes('curl -fsSL https://openvibe.link/install | sh'), 'the one-line installer uses the new home');
-- assert.ok(html.includes('The program is <code>openvibe-link</code>. <code>openvibe-node</code> is kept as an alias, so existing installs and scripts keep working.'), 'the rename and the alias are said plainly under the installer');
++ assert.ok(html.includes('The program is <code>openvibe-link</code>.'), 'the installer names Link');
assert.ok(html.includes('v9.9.9'), 'the version is on the page');
assert.ok(html.includes('2026-11-06'), 'the release date is on the page');
assert.ok(html.includes('releases/download/v9.9.9/SHA256SUMS'), 'the checksums are on the page');
assert.ok(html.includes('releases/tag/v9.9.9'), 'the release page is on the page');
assert.ok(html.includes('openvibe-link-windows-amd64.exe'), 'Windows has its own way in');
-- assert.ok(html.includes('openvibe.bot/install'), 'the installer also lives at openvibe.bot/install');
++ assert.ok(html.includes('openvibe.link/install'), 'the installer also lives at openvibe.link/install');
assert.ok(html.includes('id="install"'), 'the install section has an anchor the hero button uses');
assert.ok(html.includes('href="#install"'), 'the Install button goes to the OS install block');
for (const [what, needle] of [
@@@ -138,8 +138,8 @@ const SHAPED = {
]) assert.ok(html.includes(needle), `the page says it ${what}`);
assert.ok(html.includes('without your pairing'), 'the page says Link needs your pairing');
assert.ok(html.includes('Nothing runs that you did not turn on'), 'and that nothing runs on its own');
-- assert.ok(html.includes('docs/install.md'), 'the install guide still links the Node docs');
-- assert.ok(html.includes('github.com/OpenVibers/OpenVibe.Node'), 'the GitHub source keeps its URL');
++ assert.ok(html.includes('docs/install.md'), 'the install guide still links the Link docs');
++ assert.ok(html.includes('github.com/OpenVibers/OpenVibe.Link'), 'the GitHub source keeps its URL');
assert.ok(html.includes('/shared/navbar.js'), 'the shared navbar is on the page');
assert.ok(html.includes("label:'Link'"), 'the navbar calls it Link');
assert.ok(html.includes('<link rel="canonical" href="https://openvibe.link/">'), 'the canonical is openvibe.link');
@@@ -158,7 +158,7 @@ const SHAPED = {
// ── The routes: the Host header decides ──
const releaseApi = { latest: async () => SHAPED };
const app = express();
-- app.use(createNodeRoutes({ releaseId: 'rel_test', releaseApi }));
++ app.use(createLinkRoutes({ releaseId: 'rel_test', releaseApi }));
app.get('/', (_req, res) => res.type('html').send('<p>network home</p>'));
app.get('/llms.txt', (_req, res) => res.type('text/plain').send('# OpenVibe Network'));
app.get('/sitemap.xml', (_req, res) => res.type('application/xml').send('<urlset><url><loc>https://openvibe.network/</loc></url></urlset>'));
@@@ -198,30 +198,18 @@ const SHAPED = {
assert.ok(twin.body.includes('# OpenVibe Link'), 'and it says what Link is');
assert.ok(twin.body.includes('openvibe.link/install'), 'with the install command');
-- const nodeHome = await get('/', 'node.openvibe.network');
-- assert.strictEqual(nodeHome.status, 301, 'the old host 301s its front page');
-- assert.strictEqual(nodeHome.headers.location, 'https://openvibe.link/', 'to the same path on Link');
--
-- const nodePath = await get('/robots.txt?from=notes', 'node.openvibe.network');
-- assert.strictEqual(nodePath.status, 301, 'the old host 301s every other path');
-- assert.strictEqual(nodePath.headers.location, 'https://openvibe.link/robots.txt?from=notes', 'keeping the path and the query');
--
-- const nodeInstall = await get('/install', 'node.openvibe.network');
-- assert.strictEqual(nodeInstall.status, 302, 'the old host keeps serving /install');
-- assert.ok(String(nodeInstall.headers.location || '').endsWith('/install.sh'), 'with the release\u2019s own installer');
const linkInstall = await get('/install', 'openvibe.link');
-- assert.strictEqual(linkInstall.status, nodeInstall.status, '/install is the same answer on both hosts');
-- assert.strictEqual(linkInstall.headers.location, nodeInstall.headers.location, 'the same release\u2019s installer, byte for byte');
-- assert.strictEqual(linkInstall.headers['cache-control'], nodeInstall.headers['cache-control'], 'and the same cache contract');
++ assert.strictEqual(linkInstall.status, 302);
++ assert.ok(String(linkInstall.headers.location || '').endsWith('/install.sh'));
-- for (const p of ['/machines', '/pair']) {
++ const machines = await get('/machines', 'openvibe.link');
++ assert.strictEqual(machines.status, 200, 'the Link host serves the machine hub');
++ assert.ok(machines.body.includes('sec-machines'), 'pairing and machine controls render on Link');
++ assert.strictEqual(machines.headers['cache-control'], 'no-store');
++ for (const p of ['/pair', '/panel']) {
const hub = await get(p, 'openvibe.link');
-- assert.strictEqual(hub.status, 302, `${p} sends a signed-out visitor to the machines hub`);
-- assert.strictEqual(hub.headers.location, 'https://openvibe.my/machines', `${p} lands where pairing and the machine list live`);
-- assert.strictEqual(hub.headers['cache-control'], 'no-store', `${p} is never cached`);
-- const oldHub = await get(p, 'node.openvibe.network');
-- assert.strictEqual(oldHub.status, 301, `the old host sends ${p} to Link`);
-- assert.strictEqual(oldHub.headers.location, `https://openvibe.link${p}`, `keeping ${p}`);
++ assert.strictEqual(hub.status, 302, `${p} opens the Link machine hub`);
++ assert.strictEqual(hub.headers.location, 'https://openvibe.link/machines');
}
const www = await get('/', 'www.openvibe.link');
@@@ -234,14 +222,10 @@ const SHAPED = {
const llms = await get('/llms.txt', 'openvibe.link');
assert.strictEqual(llms.status, 200);
assert.ok(llms.body.startsWith('# OpenVibe Link'), 'Link\u2019s own llms.txt answers');
-- const nodeLlms = await get('/llms.txt', 'node.openvibe.network');
-- assert.strictEqual(nodeLlms.status, 301, 'the old host sends llms.txt to Link too');
-- assert.strictEqual(nodeLlms.headers.location, 'https://openvibe.link/llms.txt');
const sitemap = await get('/sitemap.xml', 'openvibe.link');
assert.strictEqual(sitemap.status, 200);
assert.ok(sitemap.body.includes('<loc>https://openvibe.link/</loc>'), 'the sitemap lists openvibe.link');
-- assert.ok(!sitemap.body.includes('node.openvibe.network'), 'and not the old host');
const robots = await get('/robots.txt', 'openvibe.link');
assert.ok(robots.body.includes('Sitemap: https://openvibe.link/sitemap.xml'), 'robots.txt points at Link\u2019s sitemap');
@@@ -270,7 +254,7 @@ const SHAPED = {
// old node origin stays listed for a cached page that still calls from it.
const serverSrc = fs.readFileSync(path.join(__dirname, '..', 'server', 'index.js'), 'utf8');
assert.ok(serverSrc.includes("'https://openvibe.link',"), 'the CORS allow-list knows Link\u2019s own origin');
-- assert.ok(serverSrc.includes("'https://node.openvibe.network',"), 'and keeps the old Node origin');
++ assert.ok(serverSrc.includes("'https://openvibe.link',"), 'and keeps the Link origin');
// ── nginx: openvibe.link is a proxy to Network, not a redirect to the old host ──
const nginx = fs.readFileSync(path.join(__dirname, '..', 'deploy', 'nginx', 'parked-domains.conf'), 'utf8');
@@@ -279,7 +263,7 @@ const SHAPED = {
assert.ok(/server_name www\.openvibe\.link;/.test(nginx), 'and separate block 301s www');
assert.ok(/proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(nginx), 'the page proxies to Network');
assert.ok(nginx.includes('/etc/letsencrypt/live/openvibe.link/fullchain.pem'), 'the name\u2019s own certificate');
-- assert.ok(!nginx.includes('return 302 https://node.openvibe.network/;'), 'the old redirect is gone');
++ assert.ok(!nginx.includes('return 302 https://openvibe.link/;'), 'the old redirect is gone');
console.log('link-home: Link\u2019s page, hosts, installer, twin, sitemap, navbar and nginx proxy ok');
})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/link-send-relay.test.js b/test/link-send-relay.test.js
iindex 77d0c8a4..535de713 100644
---- a/test/link-send-relay.test.js
++++ b/test/link-send-relay.test.js
@@@ -6,7 +6,7 @@ const http = require('node:http');
const express = require('express');
const { ids } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
--const { router } = require('../server/node/send-relay');
++const { router } = require('../server/link/send-relay');
const locker = require('openvibe-sdk/locker');
(async () => {
@@@ -17,13 +17,13 @@ const locker = require('openvibe-sdk/locker');
const node = async (principal, owner, n) => db.prepare(`INSERT INTO platform_node_principals
(id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`).run(principal, `send-${n}`, owner, `${n}`.padStart(64, '0'), owner);
-- const [from, target, stranger] = [`nod_${ids.ulid()}`, `nod_${ids.ulid()}`, `nod_${ids.ulid()}`];
++ const [from, target, stranger] = [`lnk_${ids.ulid()}`, `lnk_${ids.ulid()}`, `lnk_${ids.ulid()}`];
await node(from, alice, 1); await node(target, alice, 2); await node(stranger, bob, 3);
const tokens = new Map([['from', from], ['target', target], ['stranger', stranger]]);
const send = router({ nodeGuard: (req, res, next) => {
const principal = tokens.get(req.headers.authorization);
if (!principal) return res.status(401).end();
-- req.principal = { sub: `node:${principal}` }; next();
++ req.principal = { sub: `link:${principal}` }; next();
}, requireAuth: (req, res, next) => {
if (req.headers.authorization !== 'alice-session') return res.status(401).end();
req.user = { id: 9311, subject_id: alice }; next();
@@@ -48,7 +48,7 @@ const locker = require('openvibe-sdk/locker');
res = await call('/targets', 'from');
assert.equal(res.status, 200);
assert.deepEqual((await res.json()).machines[0].public_key, { kty: 'EC', crv: 'P-256', x: publicKey.x, y: publicKey.y });
-- const subject = { type: 'node', id: target };
++ const subject = { type: 'link', id: target };
const sealed = await locker.seal(new TextEncoder().encode('Locker round trip'), {
lock: 'people', keys: [{ subject, publicKey }],
});
ddiff --git a/test/machine-pools.test.js b/test/machine-pools.test.js
iindex 3be2d42e..6b37c420 100644
---- a/test/machine-pools.test.js
++++ b/test/machine-pools.test.js
@@@ -16,7 +16,7 @@ const { getDb } = require('../server/db/database');
const bot = { list: async (who) => ({ machines: who === owner ? own : who === stranger ? theirs : [] }) };
const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'pool-cert-'));
const machine = async (who, i, list, address = `192.168.1.${i + 2}/24`, publicIP = '203.0.113.1') => {
-- const principal = `nod_${ids.ulid()}`, id = `dev_${ids.ulid()}`;
++ const principal = `lnk_${ids.ulid()}`, id = `dev_${ids.ulid()}`;
list.push({ id, node_id: principal });
await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, trust, owner_subject, credential_hash, created_by)
VALUES (?, ?, 'wnam-1', 'user', 'community', ?, ?, ?)`).run(principal, `pool-node-${id.slice(-12).toLowerCase()}`, who, require('node:crypto').createHash('sha256').update(id).digest('hex'), who);
@@@ -57,7 +57,7 @@ const { getDb } = require('../server/db/database');
await assert.rejects(pools.node(db, { principal: { sub } }), /only the machine/, `${sub || 'no principal'} cannot act for a machine`);
}
await assert.rejects(pools.node(db, {}), /only the machine/, 'a request that skipped the node guard is refused');
-- await assert.rejects(pools.node(db, { principal: { sub: `node:nod_${ids.ulid()}` } }), /no longer paired/);
++ await assert.rejects(pools.node(db, { principal: { sub: `link:lnk_${ids.ulid()}` } }), /no longer paired/);
await assert.rejects(pools.approve(db, pool.id, principals[2], certs[2], bot), /not a member/, 'a machine outside the pool cannot approve it');
await assert.rejects(pools.approve(db, pool.id, strangers[0], certs[2], bot), /not found/, 'a stranger\'s machine cannot see the pool');
await assert.rejects(pools.approve(db, pool.id, principals[0], privateKey, bot), /public pool certificate/, 'a private key is refused');
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
iindex 0ab716f9..8b54807f 100644
---- a/test/machines-hub.test.js
++++ b/test/machines-hub.test.js
@@@ -15,7 +15,7 @@ const { ids, serviceAuth } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
const machines = require('../server/registry/machines');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
(async () => {
const db = getDb();
@@@ -54,7 +54,7 @@ const botServer = http.createServer(async (req, res) => {
return mine ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
}
if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {
-- return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.bot/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
++ return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.link/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
}
if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
@@@ -87,9 +87,11 @@ const tokenOf = (u) => authRoutes.signToken(u, keys.privateKey, config);
const app = express();
app.use(express.json());
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
--app.use('/api/v1/machines', machines.routers({ bot, requireAuth, publicKey: keys.publicKey, issuer: config.jwt.issuer }).hub);
--// Bot's report of a machine's state (network.node.manage, the capability Bot already holds).
--app.use('/internal', machines.internalRouters({ guard: principals.guard('network.node.manage') }).internal);
++const machineRouters = machines.routers({ bot, requireAuth, publicKey: keys.publicKey, issuer: config.jwt.issuer });
++app.use('/api/v1/machines', machineRouters.hub);
++app.use('/api/v1/link/compute', machineRouters.compute);
++// Bot's report of a machine's state (network.link.manage, the capability Bot already holds).
++app.use('/internal', machines.internalRouters({ guard: principals.guard('network.link.manage') }).internal);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const base = `http://127.0.0.1:${server.address().port}`;
@@@ -138,6 +140,15 @@ try {
const theirs = await call('GET', '/api/v1/machines', { user: bob });
assert.deepStrictEqual(theirs.body.machines.map((m) => m.id), [bobMachine], 'nobody else\'s machine is in it');
++ const compute = await call('GET', '/api/v1/link/compute', { user: ana });
++ assert.strictEqual(compute.status, 200, JSON.stringify(compute.body));
++ assert.deepStrictEqual(compute.body.sources.map((s) => s.kind), ['personal', 'cloud', 'exchange', 'community']);
++ assert.deepStrictEqual(compute.body.candidates.map((c) => c.id), [anaMachine]);
++ assert.strictEqual(compute.body.candidates[0].status, 'unavailable', 'a machine without the worker role cannot be picked');
++ assert.strictEqual((await call('POST', '/api/v1/link/compute/pick', { user: ana, body: { kind: 'personal', id: anaMachine } })).status, 409);
++ assert.strictEqual((await call('POST', '/api/v1/link/compute/pick', { user: ana, body: { kind: 'exchange' } })).status, 409, 'a rental cannot start from a placeholder');
++ assert.strictEqual((await call('GET', '/api/v1/link/compute?kind=unknown', { user: ana })).status, 400);
++
// ── An app without the capability, or without the consent, is refused. ──
const noCap = await call('GET', '/api/v1/machines', { token: appToken('network.something.else') });
assert.strictEqual(noCap.status, 403, JSON.stringify(noCap.body));
ddiff --git a/test/module-events.test.js b/test/module-events.test.js
iindex 118974f1..efbca008 100644
---- a/test/module-events.test.js
++++ b/test/module-events.test.js
@@@ -99,7 +99,7 @@ const valid = (e) => { const v = contracts.validate('events.event-envelope@1', e
assert.deepStrictEqual(ev[0].actor, { type: 'service', id: 'chat' });
assert.strictEqual(ev[0].visibility, 'internal'); assert.strictEqual(ev[0].source, 'network');
assert.deepStrictEqual(ev[0].subject, { type: 'user_module', id: `${ANN}:chat.preferences`, revision: 1 });
-- assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 2, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });
++ assert.deepStrictEqual(ev[0].payload, { owner: { type: 'user', id: ANN }, namespace: 'chat.preferences', namespace_owner: 'chat', schema_version: 3, revision: 1, change: 'created', reason: 'write', keys: ['compact', 'timestamps'] });
mark = await lastId();
r = await call('PUT', '/api/modules/chat.preferences', { headers: { ...await tokenOf(1), 'if-match': '1' }, body: { data: { timestamps: true, font_scale: 1.2 } } });
assert.strictEqual(r.status, 200); assert.strictEqual(r.body.revision, 2);
ddiff --git a/test/my-home.test.js b/test/my-home.test.js
iindex 3cddef3d..41ca97d6 100644
---- a/test/my-home.test.js
++++ b/test/my-home.test.js
@@@ -101,7 +101,7 @@ const MY = 'openvibe.my';
assert.strictEqual(r.headers.location, 'https://openvibe.my/', 'my.html lands on the hub home');
r = await get('/machines', 'openvibe.network');
-- assert.strictEqual(r.headers.location, 'https://openvibe.my/machines', 'every old account-hub path moves');
++ assert.strictEqual(r.headers.location, 'https://openvibe.link/machines', 'every old account-hub path moves');
r = await get('/', 'openvibe.network');
assert.strictEqual(r.status, 200);
ddiff --git a/test/node-pairing.test.js b/test/node-pairing.test.js
iindex 5894fa3c..3c33523d 100644
---- a/test/node-pairing.test.js
++++ b/test/node-pairing.test.js
@@@ -10,7 +10,7 @@ const express = require('express');
const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
(async () => {
const log = console.log; console.log = () => {};
@@@ -19,8 +19,8 @@ console.log = log;
await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
--// A second service holding network.node.manage, to prove each service sees only what it paired.
--await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.node.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
++// A second service holding network.link.manage, to prove each service sees only what it paired.
++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('live', 'network.link.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
const subject = () => `usr_${ids.ulid()}`;
const alex = subject(); const banned = subject(); const gone = subject();
@@@ -39,11 +39,11 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
--const nodeRouters = require('../server/registry/nodes').routers({ guard: principals.guard('network.node.report') });
--app.use('/api/v1/nodes', nodeRouters.pub);
--app.use('/internal/nodes', nodeRouters.internal);
--const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage'), now: () => Date.now() + clock.offset });
--app.use('/api/v1/node-pairing', pr.pairing);
++const nodeRouters = require('../server/registry/nodes').routers({ guard: principals.guard('network.link.report') });
++app.use('/api/v1/links', nodeRouters.pub);
++app.use('/internal/links', nodeRouters.internal);
++const pr = nodePrincipals.routers({ guard: principals.guard('network.link.manage'), now: () => Date.now() + clock.offset });
++app.use('/api/v1/link-pairing', pr.pairing);
app.use('/internal', pr.internal);
const server = http.createServer(app);
@@@ -61,29 +61,29 @@ const call = (method, p, body, headers = {}) => fetch(`${base}${p}`, { method, h
.then(async (x) => { const text = await x.text(); let json = null; try { json = JSON.parse(text); } catch { /* not JSON */ } return { status: x.status, headers: x.headers, text, body: json }; });
const bearer = (t) => ({ authorization: `Bearer ${t}` });
const revokedEvents = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
-- .filter((e) => e.event_type === 'network.node.revoked');
++ .filter((e) => e.event_type === 'network.link.revoked');
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
try {
const bot = bearer(await token('bot', 'bot-secret'));
const live = bearer(await token('live', 'live-secret'));
const host = bearer(await token('host', 'host-secret'));
-- const mint = (ref = `robot-${ids.ulid()}`, extra = {}) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref, ...extra }, bot);
-- const redeem = (body) => call('POST', '/api/v1/node-pairing', body);
++ const mint = (ref = `robot-${ids.ulid()}`, extra = {}) => call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: alex }, ref, ...extra }, bot);
++ const redeem = (body) => call('POST', '/api/v1/link-pairing', body);
-- await check('minting a code needs network.node.manage and a live person', async () => {
-- assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' })).status, 403, 'no token');
-- assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' }, host)).status, 403, 'host lacks network.node.manage');
++ await check('minting a code needs network.link.manage and a live person', async () => {
++ assert.strictEqual((await call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' })).status, 403, 'no token');
++ assert.strictEqual((await call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: alex }, ref: 'r' }, host)).status, 403, 'host lacks network.link.manage');
for (const s of [subject(), banned, gone]) {
-- const r = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: s }, ref: 'r' }, bot);
++ const r = await call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: s }, ref: 'r' }, bot);
assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_owner']);
}
-- const p = await call('POST', '/internal/node-pairings', { owner: { kind: 'project', project_id: 'prj_x' }, ref: 'r' }, bot);
++ const p = await call('POST', '/internal/link-pairings', { owner: { kind: 'project', project_id: 'prj_x' }, ref: 'r' }, bot);
assert.deepStrictEqual([p.status, p.body.code], [501, 'registry.not_yet']);
const planned = await mint(undefined, { home_cell: 'wnam-2' });
assert.deepStrictEqual([planned.status, planned.body.code], [409, 'registry.cell_not_active']);
assert.strictEqual((await mint(undefined, { home_cell: 'nope-9' })).status, 400);
-- assert.strictEqual((await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex } }, bot)).status, 400, 'ref is required');
++ assert.strictEqual((await call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: alex } }, bot)).status, 400, 'ref is required');
});
await check('a code is shown once, stored only as its sha256, and replaces that ref\'s unused code', async () => {
@@@ -108,7 +108,7 @@ try {
const { body: pairing } = await mint();
const first = await redeem({ pairing: pairing.pairing_id, code: pairing.code, name: 'Rover' });
assert.strictEqual(first.status, 201, first.text);
-- assert.match(first.body.principal, /^nod_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.match(first.body.principal, /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(first.body.node_id, `n-${first.body.principal.slice(4).toLowerCase()}`);
assert.strictEqual(first.body.node_id.length, 28);
assert.ok(first.body.credential.length >= 40);
@@@ -159,7 +159,7 @@ try {
const { body: pairing } = await mint('robot-secret');
const paired = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
const { credential, principal } = paired.body;
-- const reads = [await call('GET', `/internal/node-principals/${principal}`, null, bot), await call('GET', '/api/v1/nodes'), await call('GET', '/api/v1/nodes', null, bot)];
++ const reads = [await call('GET', `/internal/link-principals/${principal}`, null, bot), await call('GET', '/api/v1/links'), await call('GET', '/api/v1/links', null, bot)];
for (const r of reads) {
assert.ok(!r.text.includes(credential), 'a read leaked the credential');
assert.ok(!r.text.includes(pairing.code.replace('-', '')) && !r.text.includes(pairing.code), 'a read leaked the code');
@@@ -173,16 +173,16 @@ try {
await check('a service reads and revokes only the principals it paired', async () => {
const { body: pairing } = await mint('robot-scope');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code, region: 'eu-west' });
-- const read = await call('GET', `/internal/node-principals/${paired.principal}`, null, bot);
++ const read = await call('GET', `/internal/link-principals/${paired.principal}`, null, bot);
assert.strictEqual(read.status, 200, read.text);
assert.deepStrictEqual(Object.keys(read.body).sort(), ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status']);
assert.deepStrictEqual([read.body.owner, read.body.paired_for, read.body.status], [{ kind: 'user', subject: alex }, { service: 'bot', ref: 'robot-scope' }, 'active']);
-- for (const [m, p] of [['GET', `/internal/node-principals/${paired.principal}`], ['POST', `/internal/node-principals/${paired.principal}/revoke`]]) {
++ for (const [m, p] of [['GET', `/internal/link-principals/${paired.principal}`], ['POST', `/internal/link-principals/${paired.principal}/revoke`]]) {
const other = await call(m, p, null, live);
assert.deepStrictEqual([other.status, other.body.code], [404, 'registry.unknown_node'], `${m} by another service`);
-- assert.strictEqual((await call(m, p, null, host)).status, 403, `${m} without network.node.manage`);
++ assert.strictEqual((await call(m, p, null, host)).status, 403, `${m} without network.link.manage`);
}
-- assert.strictEqual((await call('GET', `/internal/node-principals/nod_${ids.ulid()}`, null, bot)).status, 404);
++ assert.strictEqual((await call('GET', `/internal/link-principals/lnk_${ids.ulid()}`, null, bot)).status, 404);
assert.strictEqual((await db.prepare('SELECT status FROM platform_node_principals WHERE id = ?').get(paired.principal)).status, 'active', 'the other service changed nothing');
});
@@@ -190,52 +190,52 @@ try {
const { body: pairing } = await mint('robot-revoke');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
await db.prepare("UPDATE platform_node_principals SET credential_prev_hash = ?, prev_valid_until = '2026-10-02T00:01:00Z' WHERE id = ?").run('b'.repeat(64), paired.principal);
-- const r = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
++ const r = await call('POST', `/internal/link-principals/${paired.principal}/revoke`, null, bot);
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.body.status, 'revoked');
assert.ok(r.body.revoked_at);
const row = await db.prepare('SELECT * FROM platform_node_principals WHERE id = ?').get(paired.principal);
assert.deepStrictEqual([row.status, row.revoked_by, row.credential_prev_hash, row.prev_valid_until], ['revoked', 'svc:bot', null, null]);
-- // The revoke is announced once (network.node.revoked@1), valid, owned by Alex, actor the service, no secret.
++ // The revoke is announced once (network.link.revoked@1), valid, owned by Alex, actor the service, no secret.
const announced = (await revokedEvents()).filter((e) => e.subject.id === paired.principal);
assert.strictEqual(announced.length, 1, 'one event for one revoke');
const e = announced[0];
assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
-- assert.ok(validate('network.node.revoked@1', e.payload).valid, JSON.stringify(validate('network.node.revoked@1', e.payload).errors));
++ assert.ok(validate('network.link.revoked@1', e.payload).valid, JSON.stringify(validate('network.link.revoked@1', e.payload).errors));
assert.deepStrictEqual(Object.keys(e.payload).sort(), ['at', 'node_id', 'owner', 'principal_id'], 'no reason given, none carried');
assert.deepStrictEqual([e.payload.node_id, e.payload.principal_id, e.payload.owner], [paired.node_id, paired.principal, { kind: 'user', subject: alex }]);
assert.deepStrictEqual([e.actor, e.visibility, e.source, e.subject.type], [{ type: 'service', id: 'bot' }, 'internal', 'network', 'node_principal']);
assert.ok(!/hash|credential|secret/i.test(JSON.stringify(e.payload)), 'the payload carries no secret field');
clock.offset += 5000;
-- const again = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, null, bot);
++ const again = await call('POST', `/internal/link-principals/${paired.principal}/revoke`, null, bot);
clock.offset -= 5000;
assert.deepStrictEqual([again.status, again.body.revoked_at], [200, r.body.revoked_at], 'a second revoke changes nothing');
assert.strictEqual((await revokedEvents()).filter((x) => x.subject.id === paired.principal).length, 1, 'a second revoke emits nothing new');
-- assert.strictEqual((await call('GET', `/internal/node-principals/${paired.principal}`, null, bot)).body.status, 'revoked');
++ assert.strictEqual((await call('GET', `/internal/link-principals/${paired.principal}`, null, bot)).body.status, 'revoked');
});
await check('a revoke may carry the caller\'s reason; an invalid one is refused before anything changes', async () => {
const { body: pairing } = await mint('robot-reason');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
-- const bad = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, { reason: '' }, bot);
++ const bad = await call('POST', `/internal/link-principals/${paired.principal}/revoke`, { reason: '' }, bot);
assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_reason']);
assert.strictEqual((await db.prepare('SELECT status FROM platform_node_principals WHERE id = ?').get(paired.principal)).status, 'active', 'a refused reason revokes nothing');
assert.strictEqual((await revokedEvents()).filter((x) => x.subject.id === paired.principal).length, 0, 'a refused revoke emits nothing');
-- const ok = await call('POST', `/internal/node-principals/${paired.principal}/revoke`, { reason: 'the machine was sold' }, bot);
++ const ok = await call('POST', `/internal/link-principals/${paired.principal}/revoke`, { reason: 'the machine was sold' }, bot);
assert.strictEqual(ok.status, 200, ok.text);
const e = (await revokedEvents()).find((x) => x.subject.id === paired.principal);
assert.deepStrictEqual([e.payload.principal_id, e.payload.reason], [paired.principal, 'the machine was sold']);
-- assert.ok(validate('network.node.revoked@1', e.payload).valid);
++ assert.ok(validate('network.link.revoked@1', e.payload).valid);
});
await check('pairing creates no platform machine; Host cannot report a paired node', async () => {
const { body: pairing } = await mint('robot-platform');
const { body: paired } = await redeem({ pairing: pairing.pairing_id, code: pairing.code });
assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'no platform_nodes row');
-- const list = await call('GET', '/api/v1/nodes');
++ const list = await call('GET', '/api/v1/links');
assert.strictEqual(list.status, 200);
assert.ok(!list.text.includes(paired.node_id), 'the paired machine is absent from the public node list');
-- const report = await call('POST', '/internal/nodes/report', { source: 'primary', nodes: [{ id: paired.node_id, roles: ['app'], location: { region: 'us-west', country: 'US' }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, updated_at: '2026-10-01T12:00:00Z' }] }, host);
++ const report = await call('POST', '/internal/links/report', { source: 'primary', nodes: [{ id: paired.node_id, roles: ['app'], location: { region: 'us-west', country: 'US' }, health: { status: 'up', checked_at: '2026-10-01T12:00:00Z' }, updated_at: '2026-10-01T12:00:00Z' }] }, host);
assert.deepStrictEqual([report.status, report.body.code], [409, 'registry.node_not_platform']);
assert.strictEqual(await db.prepare('SELECT 1 FROM platform_nodes WHERE id = ?').get(paired.node_id), undefined, 'the refused report wrote nothing');
});
ddiff --git a/test/node-principals-me.test.js b/test/node-principals-me.test.js
iindex b71803e0..46cb463d 100644
---- a/test/node-principals-me.test.js
++++ b/test/node-principals-me.test.js
@@@ -12,7 +12,7 @@ const express = require('express');
const { ids, validate } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
const { signToken } = require('../server/auth/routes');
(async () => {
@@@ -37,8 +37,8 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
app.use('/oauth', require('../server/auth/oauth-routes'));
--const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage'), now });
--app.use('/api/v1/node-pairing', pr.pairing);
++const pr = nodePrincipals.routers({ guard: principals.guard('network.link.manage'), now });
++app.use('/api/v1/link-pairing', pr.pairing);
app.use('/internal', pr.internal);
app.use('/api/v1/me/nodes', nodePrincipals.userRouter(requireAuth, now));
const server = http.createServer(app);
@@@ -57,24 +57,24 @@ const bearer = (t) => ({ authorization: `Bearer ${t}` });
const serviceToken = async (id, secret) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) })).json()).access_token;
const session = async (id) => bearer(signToken(await db.prepare('SELECT * FROM users WHERE id = ?').get(id), keys.privateKey, config));
const revokedEvents = async () => (await db.prepare('SELECT envelope FROM network_event_outbox ORDER BY id').all()).map((r) => r.envelope)
-- .filter((e) => e.event_type === 'network.node.revoked');
++ .filter((e) => e.event_type === 'network.link.revoked');
let passed = 0;
const check = async (name, fn) => { await fn(); passed++; log(` ok ${name}`); };
const VIEW_KEYS = ['created_at', 'home_cell', 'last_seen_at', 'name', 'node_id', 'owner', 'paired_for', 'principal', 'revoked_at', 'status'];
try {
const bot = bearer(await serviceToken('bot', 'bot-secret'));
const asAlex = await session(9201); const asBella = await session(9202); const asGuest = await session(9203);
-- const mint = (owner, ref) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: owner }, ref }, bot);
++ const mint = (owner, ref) => call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: owner }, ref }, bot);
const pair = async (owner, name) => {
const { body: pairing } = await mint(owner, `me-${ids.ulid()}`);
-- const r = await call('POST', '/api/v1/node-pairing', { pairing: pairing.pairing_id, code: pairing.code, name });
++ const r = await call('POST', '/api/v1/link-pairing', { pairing: pairing.pairing_id, code: pairing.code, name });
assert.strictEqual(r.status, 201, r.text);
return r.body;
};
await check('only a person\'s session is accepted; no token and a service token are 401', async () => {
assert.strictEqual((await call('GET', '/api/v1/me/nodes')).status, 401);
-- assert.strictEqual((await call('POST', '/api/v1/me/nodes/nod_x/revoke')).status, 401);
++ assert.strictEqual((await call('POST', '/api/v1/me/nodes/lnk_x/revoke')).status, 401);
assert.strictEqual((await call('GET', '/api/v1/me/nodes', null, bot)).status, 401, 'a service token is not a session');
const guest = await call('GET', '/api/v1/me/nodes', null, asGuest);
assert.deepStrictEqual([guest.status, guest.body.code], [403, 'registry.guest']);
@@@ -97,7 +97,7 @@ try {
const view = a.body.nodes[i];
assert.deepStrictEqual(Object.keys(view).sort(), VIEW_KEYS);
assert.deepStrictEqual([view.owner, view.status, view.name, view.paired_for], [{ kind: 'user', subject: alex }, 'active', paired === a2 ? 'Laptop' : 'Workstation', { service: 'bot', ref: (await db.prepare('SELECT pairing_ref FROM platform_node_principals WHERE id = ?').get(paired.principal)).pairing_ref }]);
-- const internal = await call('GET', `/internal/node-principals/${paired.principal}`, null, bot);
++ const internal = await call('GET', `/internal/link-principals/${paired.principal}`, null, bot);
assert.deepStrictEqual(view, internal.body, 'the owner sees exactly the service view');
}
const b = await call('GET', '/api/v1/me/nodes', null, asBella);
@@@ -110,7 +110,7 @@ try {
assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
const row = await db.prepare('SELECT status, revoked_at, revoked_by FROM platform_node_principals WHERE id = ?').get(b1.principal);
assert.deepStrictEqual([row.status, row.revoked_at, row.revoked_by], ['active', null, null], 'Bella\'s machine is untouched');
-- assert.deepStrictEqual((await call('POST', `/api/v1/me/nodes/nod_${ids.ulid()}/revoke`, null, asAlex)).status, 404);
++ assert.deepStrictEqual((await call('POST', `/api/v1/me/nodes/lnk_${ids.ulid()}/revoke`, null, asAlex)).status, 404);
assert.deepStrictEqual((await call('POST', '/api/v1/me/nodes/not-a-node/revoke', null, asAlex)).status, 404);
});
@@@ -129,7 +129,7 @@ try {
assert.strictEqual(announced.length, 1, 'one event for one revoke');
const e = announced[0];
assert.ok(validate('events.event-envelope@1', e).valid, JSON.stringify(validate('events.event-envelope@1', e).errors));
-- assert.ok(validate('network.node.revoked@1', e.payload).valid, JSON.stringify(validate('network.node.revoked@1', e.payload).errors));
++ assert.ok(validate('network.link.revoked@1', e.payload).valid, JSON.stringify(validate('network.link.revoked@1', e.payload).errors));
assert.deepStrictEqual([e.payload.node_id, e.payload.principal_id, e.payload.owner, e.payload.reason], [a1.node_id, a1.principal, { kind: 'user', subject: alex }, 'stolen laptop']);
assert.deepStrictEqual([e.actor, e.visibility, e.source], [{ type: 'user', id: alex }, 'internal', 'network']);
assert.ok(!/hash|credential|secret/i.test(JSON.stringify(e.payload)), 'the payload carries no secret field');
ddiff --git a/test/node-release-cache.test.js b/test/node-release-cache.test.js
iindex f83eeac8..a896413e 100644
---- a/test/node-release-cache.test.js
++++ b/test/node-release-cache.test.js
@@@ -1,5 +1,5 @@
'use strict';
--// server/node/release.js: the Node's latest release held for fifteen minutes, in memory and on disk, refreshed in
++// server/link/release.js: the Node's latest release held for fifteen minutes, in memory and on disk, refreshed in
// the background, kept when GitHub cannot be read, with GITHUB_TOKEN sent when the environment has one.
// node test/node-release-cache.test.js
const assert = require('assert');
@@@ -7,16 +7,16 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
--const MODULE = require.resolve('../server/node/release');
++const MODULE = require.resolve('../server/link/release');
const DAY = 24 * 60 * 60 * 1000;
const FIXTURE = (tag) => ({
tag_name: tag,
published_at: '2026-11-06T12:00:00Z',
-- html_url: `https://github.com/OpenVibers/OpenVibe.Node/releases/tag/${tag}`,
++ html_url: `https://github.com/OpenVibers/OpenVibe.Link/releases/tag/${tag}`,
assets: [
-- { name: 'install.sh', browser_download_url: `https://github.com/OpenVibers/OpenVibe.Node/releases/download/${tag}/install.sh` },
-- { name: 'SHA256SUMS', browser_download_url: `https://github.com/OpenVibers/OpenVibe.Node/releases/download/${tag}/SHA256SUMS` },
++ { name: 'install.sh', browser_download_url: `https://github.com/OpenVibers/OpenVibe.Link/releases/download/${tag}/install.sh` },
++ { name: 'SHA256SUMS', browser_download_url: `https://github.com/OpenVibers/OpenVibe.Link/releases/download/${tag}/SHA256SUMS` },
],
});
const ok = (tag, seen) => async (url, opts) => {
ddiff --git a/test/node-tokens.test.js b/test/node-tokens.test.js
iindex 9baba6d7..01452099 100644
---- a/test/node-tokens.test.js
++++ b/test/node-tokens.test.js
@@@ -10,7 +10,7 @@ const express = require('express');
const { ids, validate, serviceAuth } = require('openvibe-contracts');
const { getDb } = require('../server/db/database');
const principals = require('../server/identity/principals');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
(async () => {
const log = console.log; console.log = () => {};
@@@ -19,8 +19,8 @@ console.log = log;
await db.prepare("UPDATE oauth_clients SET client_secret = 'bot-secret' WHERE client_id = 'bot'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'host-secret' WHERE client_id = 'host'").run();
await db.prepare("UPDATE oauth_clients SET client_secret = 'live-secret' WHERE client_id = 'live'").run();
--// A service that holds network.node.self.manage anyway: it still manages no machine (403 capability.owner_denied).
--await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('host', 'network.node.self.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
++// A service that holds network.link.self.manage anyway: it still manages no machine (403 capability.owner_denied).
++await db.prepare("INSERT INTO principal_grants (client_id, capability, audience, namespaces, granted_by) VALUES ('host', 'network.link.self.manage', 'openvibe.network', '[]', 'test') ON CONFLICT DO NOTHING").run();
const alex = `usr_${ids.ulid()}`;
await db.prepare("INSERT INTO users (id, username, display_name, password_hash, subject_id) VALUES (9201, 'tokalex', 'Alex', 'x', ?)").run(alex);
@@@ -34,10 +34,10 @@ app.locals.config = { jwt: { issuer: ISSUER, accessTokenExpiry: '1h' } };
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
--const pr = nodePrincipals.routers({ guard: principals.guard('network.node.manage') });
--app.use('/api/v1/node-pairing', pr.pairing);
++const pr = nodePrincipals.routers({ guard: principals.guard('network.link.manage') });
++app.use('/api/v1/link-pairing', pr.pairing);
app.use('/internal', pr.internal);
--app.use('/api/v1/node/self', principals.guard('network.node.self.manage'), nodePrincipals.selfRouter());
++app.use('/api/v1/link/self', principals.guard('network.link.self.manage'), nodePrincipals.selfRouter());
const cellRouters = require('../server/registry/cells').routers({ readGuard: principals.guard('network.registry.read') });
app.use('/internal/registry', cellRouters.internal);
const server = http.createServer(app);
@@@ -73,8 +73,8 @@ try {
const live = bearer((await tokenCall('live', 'live-secret', 'openvibe.network')).body.access_token);
const credentials = [];
const pair = async (name) => {
-- const code = await call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject: alex }, ref: `robot-${ids.ulid()}` }, bot);
-- const r = await call('POST', '/api/v1/node-pairing', { pairing: code.body.pairing_id, code: code.body.code, name }, {}, { secret: true });
++ const code = await call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject: alex }, ref: `robot-${ids.ulid()}` }, bot);
++ const r = await call('POST', '/api/v1/link-pairing', { pairing: code.body.pairing_id, code: code.body.code, name }, {}, { secret: true });
assert.strictEqual(r.status, 201, r.text);
credentials.push(r.body.credential);
return r.body;
@@@ -91,13 +91,13 @@ try {
assert.strictEqual((await row(A.principal)).last_seen_at, null);
const r = await tokenCall(A.principal, A.credential, 'openvibe.network');
assert.strictEqual(r.status, 200, r.text);
-- assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.node.self.manage']);
++ assert.deepStrictEqual([r.body.token_type, r.body.expires_in, r.body.scope], ['Bearer', 300, 'network.link.self.manage']);
const v = serviceAuth.verifyServiceToken(r.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: 'openvibe.network' });
assert.ok(v.ok, v.reason);
assert.ok(validate('identity.service-token-claims@1', v.claims).valid);
const c = v.claims;
-- assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `node:${A.principal}`, 'node', ['openvibe.network'], ['network.node.self.manage']]);
-- assert.match(c.sub, /^node:nod_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.deepStrictEqual([c.iss, c.sub, c.actor_type, c.aud, c.cap], [ISSUER, `link:${A.principal}`, 'link', ['openvibe.network'], ['network.link.self.manage']]);
++ assert.match(c.sub, /^link:lnk_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.strictEqual(c.exp - c.iat, principals.TOKEN_TTL_S);
assert.match(c.jti, /^tok_[0-9a-f]{24}$/);
assert.ok(!('project_id' in c) && !('ns' in c) && !('env' in c), 'a person\'s machine carries no project');
@@@ -119,26 +119,26 @@ try {
assert.strictEqual(w.status, 200, w.text);
const vw = serviceAuth.verifyServiceToken(w.body.access_token, { publicKey: keys.publicKey, issuer: ISSUER, audience: aud });
assert.ok(vw.ok, vw.reason);
-- assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`node:${A.principal}`, [aud], []]);
-- assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');
++ assert.deepStrictEqual([vw.claims.sub, vw.claims.aud, vw.claims.cap], [`link:${A.principal}`, [aud], []]);
++ assert.strictEqual((await call('PUT', '/api/v1/link/self/capabilities', doc(A.node_id), bearer(w.body.access_token))).status, 401, 'no use on Network');
}
for (const aud of ['openvibe.media', 'openvibe.network.evil', 'openvibe.websites', 'connect.openvibe.website', '', undefined]) {
const bad = await tokenCall(A.principal, A.credential, aud);
assert.deepStrictEqual([bad.status, bad.body.error], [400, 'invalid_scope'], String(aud));
}
// A token for Bot is no use on Network's self routes.
-- assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), bearer(r.body.access_token))).status, 401);
++ assert.strictEqual((await call('PUT', '/api/v1/link/self/capabilities', doc(A.node_id), bearer(r.body.access_token))).status, 401);
});
await check('unknown, revoked or wrong secret: 401 with one identical answer', async () => {
const C = await pair('Revoked');
-- assert.strictEqual((await call('POST', `/internal/node-principals/${C.principal}/revoke`, null, bot)).status, 200);
++ assert.strictEqual((await call('POST', `/internal/link-principals/${C.principal}/revoke`, null, bot)).status, 200);
const refusals = [
await tokenCall(A.principal, 'not-the-credential', 'openvibe.network'),
await tokenCall(A.principal, B.credential, 'openvibe.network'),
await tokenCall(A.principal, '', 'openvibe.network'),
-- await tokenCall(`nod_${ids.ulid()}`, A.credential, 'openvibe.network'),
-- await tokenCall('nod_nope', A.credential, 'openvibe.network'),
++ await tokenCall(`lnk_${ids.ulid()}`, A.credential, 'openvibe.network'),
++ await tokenCall('lnk_nope', A.credential, 'openvibe.network'),
await tokenCall(C.principal, C.credential, 'openvibe.network'),
await tokenCall(C.principal, C.credential, 'openvibe.chat'),
];
@@@ -149,7 +149,7 @@ try {
await check('rotation: the new credential works, the old one 60 s more; revoke kills both', async () => {
const R = await pair('Rotator');
const before = await row(R.principal);
-- const rot = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(R)), { secret: true });
++ const rot = await call('POST', '/api/v1/link/self/credential', null, bearer(await nodeToken(R)), { secret: true });
assert.strictEqual(rot.status, 200, rot.text);
assert.strictEqual(rot.headers.get('cache-control'), 'no-store');
assert.deepStrictEqual([rot.body.principal, rot.body.node_id], [R.principal, R.node_id]);
@@@ -170,10 +170,10 @@ try {
assert.strictEqual((await issue(rot.body.credential, at + 61_000)).status, 200, 'the new one is unaffected');
// Revoke within the grace window: neither secret works any more.
const D = await pair('Rotate then revoke');
-- const rotD = await call('POST', '/api/v1/node/self/credential', null, bearer(await nodeToken(D)), { secret: true });
++ const rotD = await call('POST', '/api/v1/link/self/credential', null, bearer(await nodeToken(D)), { secret: true });
credentials.push(rotD.body.credential);
await nodeToken(D);
-- const me = await call('POST', `/internal/node-principals/${D.principal}/revoke`, null, bot);
++ const me = await call('POST', `/internal/link-principals/${D.principal}/revoke`, null, bot);
assert.strictEqual(me.status, 200);
const dead = await row(D.principal);
assert.deepStrictEqual([dead.credential_prev_hash, dead.prev_valid_until], [null, null]);
@@@ -185,26 +185,26 @@ try {
await check('capabilities: the machine\'s own doc is stored; another node_id is 409; a bad doc 400', async () => {
const a = bearer(await nodeToken(A));
-- const ok = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), a);
++ const ok = await call('PUT', '/api/v1/link/self/capabilities', doc(A.node_id), a);
assert.strictEqual(ok.status, 200, ok.text);
assert.strictEqual(ok.body.node_id, A.node_id);
assert.deepStrictEqual(JSON.parse((await capsOf(A.node_id)).doc), doc(A.node_id));
-- const again = await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id, 8), a);
++ const again = await call('PUT', '/api/v1/link/self/capabilities', doc(A.node_id, 8), a);
assert.strictEqual(again.status, 200, 'an upsert');
assert.strictEqual(JSON.parse((await capsOf(A.node_id)).doc).cpu.cores, 8);
-- const bad = await call('PUT', '/api/v1/node/self/capabilities', { node_id: A.node_id }, a);
++ const bad = await call('PUT', '/api/v1/link/self/capabilities', { node_id: A.node_id }, a);
assert.deepStrictEqual([bad.status, bad.body.code], [400, 'registry.invalid_capabilities']);
});
await check('node A\'s valid token cannot change node B\'s capabilities or credential', async () => {
const b = bearer(await nodeToken(B));
-- assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id), b)).status, 200);
++ assert.strictEqual((await call('PUT', '/api/v1/link/self/capabilities', doc(B.node_id), b)).status, 200);
const bRow = await row(B.principal);
const bCaps = await capsOf(B.node_id);
const a = bearer(await nodeToken(A));
-- const mismatch = await call('PUT', '/api/v1/node/self/capabilities', doc(B.node_id, 64), a);
++ const mismatch = await call('PUT', '/api/v1/link/self/capabilities', doc(B.node_id, 64), a);
assert.deepStrictEqual([mismatch.status, mismatch.body.code], [409, 'registry.node_mismatch']);
-- for (const p of [`/api/v1/node/self/credential?principal=${B.principal}`, `/api/v1/node/self/${B.principal}/credential`]) {
++ for (const p of [`/api/v1/link/self/credential?principal=${B.principal}`, `/api/v1/link/self/${B.principal}/credential`]) {
const r = await call('POST', p, { principal: B.principal, node_id: B.node_id }, a, { secret: true });
if (r.status === 200) {
assert.strictEqual(r.body.principal, A.principal, 'a rotation is always the caller\'s own');
@@@ -220,11 +220,11 @@ try {
await check('a revoked node\'s unexpired token is 401 registry.node_revoked on both self routes', async () => {
const E = await pair('Revoked later');
const e = bearer(await nodeToken(E));
-- assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(E.node_id), e)).status, 200);
++ assert.strictEqual((await call('PUT', '/api/v1/link/self/capabilities', doc(E.node_id), e)).status, 200);
const before = await capsOf(E.node_id);
-- assert.strictEqual((await call('POST', `/internal/node-principals/${E.principal}/revoke`, null, bot)).status, 200);
++ assert.strictEqual((await call('POST', `/internal/link-principals/${E.principal}/revoke`, null, bot)).status, 200);
const revokedRow = await row(E.principal);
-- for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(E.node_id, 2)], ['POST', '/api/v1/node/self/credential', null]]) {
++ for (const [m, p, body] of [['PUT', '/api/v1/link/self/capabilities', doc(E.node_id, 2)], ['POST', '/api/v1/link/self/credential', null]]) {
const r = await call(m, p, body, e);
assert.deepStrictEqual([r.status, r.body.code], [401, 'registry.node_revoked'], `${m} ${p}`);
}
@@@ -232,15 +232,15 @@ try {
assert.deepStrictEqual(await capsOf(E.node_id), before);
});
-- await check('a svc: token holding network.node.self.manage is 403 capability.owner_denied', async () => {
++ await check('a svc: token holding network.link.self.manage is 403 capability.owner_denied', async () => {
const host = await tokenCall('host', 'host-secret', 'openvibe.network');
-- assert.ok(host.body.scope.split(' ').includes('network.node.self.manage'), host.text);
-- for (const [m, p, body] of [['PUT', '/api/v1/node/self/capabilities', doc(A.node_id)], ['POST', '/api/v1/node/self/credential', null]]) {
++ assert.ok(host.body.scope.split(' ').includes('network.link.self.manage'), host.text);
++ for (const [m, p, body] of [['PUT', '/api/v1/link/self/capabilities', doc(A.node_id)], ['POST', '/api/v1/link/self/credential', null]]) {
const r = await call(m, p, body, bearer(host.body.access_token));
assert.deepStrictEqual([r.status, r.body.code], [403, 'capability.owner_denied'], `${m} ${p}`);
assert.strictEqual((await call(m, p, body)).status, 403, `${m} ${p} without a token`);
}
-- assert.strictEqual((await call('PUT', '/api/v1/node/self/capabilities', doc(A.node_id), live)).status, 403, 'without the capability');
++ assert.strictEqual((await call('PUT', '/api/v1/link/self/capabilities', doc(A.node_id), live)).status, 403, 'without the capability');
});
await check('topology shows last_seen_at, health up, the name, who paired it and its capabilities', async () => {
ddiff --git a/test/nodes.test.js b/test/nodes.test.js
iindex 3af8f191..0907b919 100644
---- a/test/nodes.test.js
++++ b/test/nodes.test.js
@@@ -1,7 +1,7 @@
'use strict';
--// The node registry (roadmap WS-X1, ADR-034 §12; Contracts network.node@1): POST /internal/nodes/report takes
++// The node registry (roadmap WS-X1, ADR-034 §12; Contracts network.link@1): POST /internal/links/report takes
// Host's token only and the request contract; a node missing from the same source's next report is marked down,
--// not deleted; GET /api/v1/nodes is public, cacheable, filterable and answers network.node-list-result@1.
++// not deleted; GET /api/v1/links is public, cacheable, filterable and answers network.link-list-result@1.
// node test/nodes.test.js
const assert = require('assert');
const crypto = require('crypto');
@@@ -31,30 +31,30 @@ app.locals.config = { jwt: { issuer: 'https://openvibe.network', accessTokenExpi
app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
--const r = nodes.routers({ guard: principals.guard('network.node.report', { legacy: false }), readGuard: principals.guard('network.registry.read') });
--app.use('/api/v1/nodes', r.pub);
--app.use('/internal/nodes', r.internal);
++const r = nodes.routers({ guard: principals.guard('network.link.report', { legacy: false }), readGuard: principals.guard('network.registry.read') });
++app.use('/api/v1/links', r.pub);
++app.use('/internal/links', r.internal);
const server = http.createServer(app);
--const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], location: { region: 'us-west', country: 'US' }, provider: 'ovh', beacon: `https://openvibe.network/api/v1/nodes/${id}/beacon`, health: { status: 'up', checked_at: '2026-09-28T12:00:00Z' }, updated_at: '2026-09-28T12:00:00Z', ...extra });
++const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], location: { region: 'us-west', country: 'US' }, provider: 'ovh', beacon: `https://openvibe.network/api/v1/links/${id}/beacon`, health: { status: 'up', checked_at: '2026-09-28T12:00:00Z' }, updated_at: '2026-09-28T12:00:00Z', ...extra });
(async () => {
await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
const base = `http://127.0.0.1:${server.address().port}`;
const token = async (id, secret) => (await (await fetch(`${base}/oauth/token`, { method: 'POST', headers: { 'content-type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'client_credentials', client_id: id, client_secret: secret, audience: 'openvibe.network' }) })).json()).access_token;
-- const post = (body, headers) => fetch(`${base}/internal/nodes/report`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
++ const post = (body, headers) => fetch(`${base}/internal/links/report`, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(body) })
.then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
-- const get = (q = '') => fetch(`${base}/api/v1/nodes${q}`).then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json() }));
++ const get = (q = '') => fetch(`${base}/api/v1/links${q}`).then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json() }));
try {
const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
assert.strictEqual((await post({ source: 'oregon', nodes: [] })).status, 403, 'nobody');
assert.ok([401, 403].includes((await post({ source: 'oregon', nodes: [] }, { 'x-internal-key': 'legacy-key' })).status), 'not the shared key');
-- assert.strictEqual((await post({ source: 'oregon', nodes: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.node.report');
++ assert.strictEqual((await post({ source: 'oregon', nodes: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.link.report');
assert.strictEqual((await post({ source: 'oregon', nodes: [node('a', { address: '10.0.0.1' })] }, auth)).status, 400, 'no address field: the contract refuses it');
let x = await post({ source: 'oregon', nodes: [node('oregon-1', { roles: ['web', 'app', 'data'] }), node('oregon-2', { roles: ['edge-probe'], location: { region: 'us-east', country: 'US' } })] }, auth);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
-- assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.ok(validate('network.link-list-result@1', x.body).valid);
assert.strictEqual(x.body.nodes.length, 2);
assert.deepStrictEqual(x.body.nodes.map((n) => n.cell), ['wnam-1', 'wnam-1'], 'list fills cell from each principal');
@@@ -76,7 +76,7 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
x = await get();
assert.strictEqual(x.status, 200);
-- assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.ok(validate('network.link-list-result@1', x.body).valid);
assert.match(x.headers.get('cache-control'), /public, max-age=60/);
assert.strictEqual(x.headers.get('access-control-allow-origin'), '*');
assert.deepStrictEqual((await get('?role=edge-probe')).body.nodes.map((n) => n.id), ['oregon-2']);
@@@ -88,17 +88,17 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
assert.strictEqual(x.headers.get('x-nodes-marked-down'), '1');
const all = (await get()).body.nodes;
assert.deepStrictEqual(all.map((n) => [n.id, n.health.status]), [['fra-1', 'up'], ['oregon-1', 'up'], ['oregon-2', 'down']]);
-- assert.ok(validate('network.node-list-result@1', { nodes: all, generated_at: new Date().toISOString() }).valid, 'a down node still matches the contract');
++ assert.ok(validate('network.link-list-result@1', { nodes: all, generated_at: new Date().toISOString() }).valid, 'a down node still matches the contract');
-- // GET /internal/nodes: the same list, uncached, for network.registry.read only (Live holds it, Host does not).
-- const internal = (q, headers = {}) => fetch(`${base}/internal/nodes${q}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));
++ // GET /internal/links: the same list, uncached, for network.registry.read only (Live holds it, Host does not).
++ const internal = (q, headers = {}) => fetch(`${base}/internal/links${q}`, { headers }).then(async (y) => ({ status: y.status, headers: y.headers, body: await y.json().catch(() => null) }));
assert.strictEqual((await internal('')).status, 403, 'nobody');
assert.strictEqual((await internal('', auth)).status, 403, 'Host lacks network.registry.read');
const reader = { authorization: `Bearer ${await token('live', 'live-secret')}` };
x = await internal('', reader);
assert.strictEqual(x.status, 200, JSON.stringify(x.body));
assert.strictEqual(x.headers.get('cache-control'), 'no-store');
-- assert.ok(validate('network.node-list-result@1', x.body).valid);
++ assert.ok(validate('network.link-list-result@1', x.body).valid);
assert.deepStrictEqual(x.body.nodes, all, 'the same nodes as the public list');
assert.deepStrictEqual((await internal('?region=eu-central', reader)).body.nodes.map((n) => n.id), ['fra-1']);
assert.deepStrictEqual((await internal('?role=edge-probe', reader)).body.nodes.map((n) => n.id), ['oregon-2']);
@@@ -111,7 +111,7 @@ const node = (id, extra = {}) => ({ id, name: id, roles: ['web', 'app'], locatio
assert.strictEqual(JSON.parse((await db.prepare('SELECT doc FROM platform_nodes WHERE id = ?').get('fresh-1')).doc).cell, 'weu-1', 'report stored unchanged');
assert.strictEqual(x.body.nodes.find((n) => n.id === 'fresh-1').cell, 'wnam-1', 'read uses the adopted principal cell');
-- const b = await fetch(`${base}/api/v1/nodes/oregon-1/beacon`);
++ const b = await fetch(`${base}/api/v1/links/oregon-1/beacon`);
assert.deepStrictEqual([b.status, b.headers.get('cache-control'), b.headers.get('timing-allow-origin')], [204, 'no-store', '*']);
console.log('nodes: all tests passed');
} finally {
ddiff --git a/test/registry-admin.test.js b/test/registry-admin.test.js
iindex 79f3c3dc..8c7a375a 100644
---- a/test/registry-admin.test.js
++++ b/test/registry-admin.test.js
@@@ -33,12 +33,12 @@ app.use(express.json());
app.use(express.urlencoded({ extended: true }));
Object.assign(app.locals, { db, config, privateKey: keys.privateKey, publicKey: keys.publicKey });
app.use('/oauth', require('../server/auth/oauth-routes'));
--app.use('/internal/registry/instances', require('../server/registry/instances').routers({ guard: principals.guard('network.node.report') }).internal);
++app.use('/internal/registry/instances', require('../server/registry/instances').routers({ guard: principals.guard('network.link.report') }).internal);
app.use('/api/v1/cells', require('../server/registry/cells').routers({ readGuard: principals.guard('network.registry.read') }).pub);
app.use('/api/admin/registry', require('../server/registry/registry-admin').createRegistryAdmin(db, requireAuth, requireAdmin));
const server = http.createServer(app);
--const nod = () => `nod_${ids.ulid()}`;
++const nod = () => `lnk_${ids.ulid()}`;
const platformId = nod(); const pairedId = nod(); const revokedId = nod();
await db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, owner_subject, trust, status, created_by, revoked_at, credential_hash) VALUES
(?, 'ovh-1', 'wnam-1', 'platform', NULL, 'first-party', 'active', 'test', NULL, NULL),
@@@ -66,7 +66,7 @@ try {
const A = (method, p, body) => call(method, `/api/admin/registry${p}`, body, admin);
await check('staff session only: no session 401, a person 403, a service token refused', async () => {
-- for (const [method, p, body] of [['GET', '/node-principals'], ['PUT', '/cells/wnam-1', { route_weight: 5 }], ['PUT', '/instances/media-1', { route_weight: 5 }], ['PUT', `/node-principals/${platformId}`, { status: 'draining' }]]) {
++ for (const [method, p, body] of [['GET', '/link-principals'], ['PUT', '/cells/wnam-1', { route_weight: 5 }], ['PUT', '/instances/media-1', { route_weight: 5 }], ['PUT', `/link-principals/${platformId}`, { status: 'draining' }]]) {
assert.strictEqual((await call(method, `/api/admin/registry${p}`, body)).status, 401, `${method} ${p} with no session`);
assert.strictEqual((await call(method, `/api/admin/registry${p}`, body, user)).status, 403, `${method} ${p} as a person`);
assert.ok([401, 403].includes((await call(method, `/api/admin/registry${p}`, body, host)).status), `${method} ${p} with a service token`);
@@@ -112,7 +112,7 @@ try {
assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_state'], `instance state ${s}`);
}
for (const s of ['revoked', 'retired']) {
-- const r = await A('PUT', `/node-principals/${platformId}`, { status: s });
++ const r = await A('PUT', `/link-principals/${platformId}`, { status: s });
assert.deepStrictEqual([r.status, r.body.code], [422, 'registry.invalid_status'], `principal status ${s}`);
}
// A valid field next to an invalid one writes neither.
@@@ -124,7 +124,7 @@ try {
}
r = await A('PUT', '/instances/media-1', { status: 'draining' });
assert.deepStrictEqual([r.status, r.body.code], [400, 'registry.invalid_body'], 'an instance has a state, not a status');
-- r = await A('PUT', `/node-principals/${platformId}`, { route_weight: 5 });
++ r = await A('PUT', `/link-principals/${platformId}`, { route_weight: 5 });
assert.deepStrictEqual([r.status, r.body.code], [400, 'registry.invalid_body'], 'a principal has no weight');
assert.deepStrictEqual(await cellRow(), { status: 'active', route_weight: 1000 }, 'nothing written');
assert.deepStrictEqual(await instanceRow(), { state: 'ready', route_weight: 100 });
@@@ -138,7 +138,7 @@ try {
r = await A('PUT', '/instances/nope-1', { route_weight: 5 });
assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_instance']);
for (const id of [nod(), 'ovh-1', 'nope']) {
-- r = await A('PUT', `/node-principals/${id}`, { status: 'draining' });
++ r = await A('PUT', `/link-principals/${id}`, { status: 'draining' });
assert.deepStrictEqual([r.status, r.body.code], [404, 'registry.unknown_node'], id);
}
assert.strictEqual((await db.prepare("SELECT COUNT(*) AS n FROM audit_log WHERE action LIKE 'registry_%'").get()).n, before, 'no audit row for a refused write');
@@@ -156,33 +156,33 @@ try {
});
await check('a node principal: draining and back to active; revoked stays revoked', async () => {
-- let r = await A('PUT', `/node-principals/${platformId}`, { status: 'draining' });
++ let r = await A('PUT', `/link-principals/${platformId}`, { status: 'draining' });
assert.strictEqual(r.status, 200, r.text);
assert.deepStrictEqual([r.body.principal, r.body.node_id, r.body.status, r.body.trust, r.body.owner], [platformId, 'ovh-1', 'draining', 'first-party', { kind: 'platform' }]);
assert.strictEqual((await principalRow(platformId)).status, 'draining');
-- r = await A('PUT', `/node-principals/${pairedId}`, { status: 'draining' });
++ r = await A('PUT', `/link-principals/${pairedId}`, { status: 'draining' });
assert.strictEqual(r.status, 200, r.text);
assert.ok(!/hash|credential/.test(r.text), 'never a hash or a credential');
-- r = await A('PUT', `/node-principals/${platformId}`, { status: 'active' });
++ r = await A('PUT', `/link-principals/${platformId}`, { status: 'active' });
assert.deepStrictEqual([r.status, r.body.status], [200, 'active']);
-- r = await A('PUT', `/node-principals/${revokedId}`, { status: 'active' });
++ r = await A('PUT', `/link-principals/${revokedId}`, { status: 'active' });
assert.deepStrictEqual([r.status, r.body.code], [409, 'registry.node_revoked']);
assert.deepStrictEqual(await principalRow(revokedId), { status: 'revoked', revoked_at: '2026-10-01T00:00:00Z' });
});
await check('the operator principal list: every owner, with trust, filtered', async () => {
-- let r = await A('GET', '/node-principals');
++ let r = await A('GET', '/link-principals');
assert.strictEqual(r.status, 200, r.text);
assert.strictEqual(r.headers.get('cache-control'), 'no-store');
const byNode = Object.fromEntries(r.body.node_principals.map((p) => [p.node_id, p]));
assert.deepStrictEqual(Object.keys(byNode).sort(), ['old-1', 'ovh-1', 'pi-1']);
assert.deepStrictEqual([byNode['pi-1'].status, byNode['pi-1'].trust, byNode['pi-1'].owner.kind], ['draining', 'community', 'user']);
assert.ok(!/hash|credential/.test(r.text));
-- r = await A('GET', '/node-principals?status=draining');
++ r = await A('GET', '/link-principals?status=draining');
assert.deepStrictEqual(r.body.node_principals.map((p) => p.node_id), ['pi-1']);
-- r = await A('GET', '/node-principals?owner_kind=platform&cell=wnam-1');
++ r = await A('GET', '/link-principals?owner_kind=platform&cell=wnam-1');
assert.deepStrictEqual(r.body.node_principals.map((p) => p.node_id).sort(), ['old-1', 'ovh-1']);
-- r = await A('GET', '/node-principals?cell=nope-1');
++ r = await A('GET', '/link-principals?cell=nope-1');
assert.deepStrictEqual(r.body.node_principals, []);
});
log(`registry-admin: ${passed} checks passed`);
ddiff --git a/test/registry-instances.test.js b/test/registry-instances.test.js
iindex 465c45e4..79d78071 100644
---- a/test/registry-instances.test.js
++++ b/test/registry-instances.test.js
@@@ -1,6 +1,6 @@
'use strict';
// Service instances (plan T2, docs/t2-cells-and-node-principal.md section 4.1, slice N2; migration 0008): Host reports
--// what runs on its machines (POST /internal/registry/instances/report, network.node.report). The whole batch is
++// what runs on its machines (POST /internal/registry/instances/report, network.link.report). The whole batch is
// validated against platform.service-instance@1 and placed against the cells and node principals Network holds
// before anything is written; an instance absent from a later report of the same source is set to `stopped`, never
// deleted. The topology read gains the instance's cell region.
@@@ -31,12 +31,12 @@ app.locals.privateKey = keys.privateKey;
app.locals.publicKey = keys.publicKey;
app.use('/oauth', require('../server/auth/oauth-routes'));
// The report route is mounted before /internal/registry, exactly as server/index.js mounts it.
--app.use('/internal/registry/instances', instances.routers({ guard: principals.guard('network.node.report') }).internal);
++app.use('/internal/registry/instances', instances.routers({ guard: principals.guard('network.link.report') }).internal);
const c = cells.routers({ readGuard: principals.guard('network.registry.read') });
app.use('/internal/registry', c.internal);
const server = http.createServer(app);
--const nod = () => `nod_${ids.ulid()}`;
++const nod = () => `lnk_${ids.ulid()}`;
const insertPrincipal = (p) => db.prepare(`INSERT INTO platform_node_principals (id, node_id, home_cell, owner_kind, project_id, trust, status, created_by, revoked_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 'test', ?)`).run(p.id || nod(), p.node_id, p.home_cell || 'wnam-1', p.owner_kind || 'platform', p.project_id ?? null, p.trust || 'first-party', p.status || 'active', p.revoked_at ?? null);
const inst = (id, extra = {}) => ({ id, service: 'media', version: '1.4.0', cell: 'wnam-1', node: 'ovh-1', region: 'us-west', endpoints: ['https://media.internal.example'], state: 'ready', started_at: '2026-10-01T00:00:00Z', ...extra });
@@@ -58,10 +58,10 @@ try {
.then(async (x) => ({ status: x.status, headers: x.headers, body: await x.json().catch(() => null) }));
const report = (source, list, headers) => call('POST', '/internal/registry/instances/report', { source, instances: list }, headers);
-- // ── Auth: nobody, and a token without network.node.report, are refused.
++ // ── Auth: nobody, and a token without network.link.report, are refused.
assert.strictEqual((await report('primary', [] )).status, 403, 'nobody');
const live = { authorization: `Bearer ${await token('live', 'live-secret')}` };
-- assert.strictEqual((await report('primary', [], live)).status, 403, 'Live lacks network.node.report');
++ assert.strictEqual((await report('primary', [], live)).status, 403, 'Live lacks network.link.report');
assert.strictEqual(await count(), 0, 'a refused request wrote nothing');
// ── A bad instance anywhere in the batch fails the whole batch: nothing is written.
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex c1d7b5b4..ae44408c 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -1,7 +1,7 @@
'use strict';
// Network's authority resource index (ADR-048 section 3; capability network.resource.read):
// GET /api/v1/resources pages common.resource-summary@1 for the resources Network owns - its developer
--// projects (prj_), their apps (app_) and its node principals (nod_) - and GET /api/v1/resources/:ovrn
++// projects (prj_), their apps (app_) and its node principals (lnk_) - and GET /api/v1/resources/:ovrn
// reads one by its computed ovrn. ?project=&owner=&kind=&cursor=&limit= are honoured; ?project= is the tenancy
// boundary, so a resource of another project is never returned. An ovrn is present exactly when
// openvibe-contracts' contracts.resources.nameOf composes one: never for a project (ADR-048: a project
@@@ -39,7 +39,7 @@ const resourceIndex = require('../server/registry/resource-index');
const prjA = ids.newId('project'); const prjB = ids.newId('project');
const usrA = ids.newId('user'); const usrB = ids.newId('user');
const appA = ids.newId('app'); const appA2 = ids.newId('app'); const appB = ids.newId('app');
-- const nodA = ids.newId('node'); const nodB = ids.newId('node'); const nodP = ids.newId('node'); const nodU = ids.newId('node');
++ const nodA = ids.newId('link'); const nodB = ids.newId('link'); const nodP = ids.newId('link'); const nodU = ids.newId('link');
const at = '2026-10-01T00:00:00Z';
await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by) VALUES (?, ?, 'Project A', 'sandbox', '[]', ?, 'test')").run(prjA, usrA, at);
await db.prepare("INSERT INTO dev_projects (id, owner_subject, name, environment_policy, allowance, created_at, created_by, archived_at, archived_by) VALUES (?, ?, 'Project B', 'sandbox', '[]', ?, 'test', ?, 'test')").run(prjB, usrB, at, at);
@@@ -53,11 +53,11 @@ const resourceIndex = require('../server/registry/resource-index');
await insertNode.run(nodP, 'node-p', 'platform', null, null, 'Platform machine', 'first-party', null);
await insertNode.run(nodU, 'node-u', 'user', null, usrA, "Person's machine", 'community', 'c'.repeat(64));
-- const ovrnOfNode = (project, node) => `ovrn:network:${project}:node/${node}`;
++ const ovrnOfNode = (project, node) => `ovrn:network:${project}:link/${node}`;
const expected = [];
expected.push({ id: prjA, kind: 'network.project' }, { id: prjB, kind: 'network.project' });
expected.push({ id: appA, kind: 'network.app' }, { id: appA2, kind: 'network.app' }, { id: appB, kind: 'network.app' });
-- expected.push({ id: nodA, kind: 'network.node' }, { id: nodB, kind: 'network.node' }, { id: nodP, kind: 'network.node' }, { id: nodU, kind: 'network.node' });
++ expected.push({ id: nodA, kind: 'network.link' }, { id: nodB, kind: 'network.link' }, { id: nodP, kind: 'network.link' }, { id: nodU, kind: 'network.link' });
expected.sort((x, y) => (x.kind < y.kind ? -1 : x.kind > y.kind ? 1 : x.id < y.id ? -1 : x.id > y.id ? 1 : 0));
(async () => {
@@@ -99,14 +99,14 @@ const resourceIndex = require('../server/registry/resource-index');
assert.deepStrictEqual((await aIds(`?project=${prjA}`)).sort(), [appA, appA2, nodA, prjA].sort(), 'project A: its apps and its machine, never platform/user nodes');
assert.deepStrictEqual(await aIds(`?project=${prjB}`), [appB, nodB, prjB].sort(), 'project B is never mixed in');
assert.deepStrictEqual(await aIds(`?project=${prjA}&kind=network.app`), [appA, appA2].sort(), 'kind narrows within the project');
-- assert.deepStrictEqual((await aIds('?kind=network.node')).sort(), [nodA, nodB, nodP, nodU].sort(), 'kind = node lists every node principal');
++ assert.deepStrictEqual((await aIds('?kind=network.link')).sort(), [nodA, nodB, nodP, nodU].sort(), 'kind = node lists every node principal');
assert.deepStrictEqual(await aIds('?kind=network.project'), [prjA, prjB].sort(), 'kind = project lists the projects');
assert.deepStrictEqual(await aIds('?kind=network.unknown'), [], 'an unknown kind is an empty page, not an error');
assert.deepStrictEqual(await aIds(`?project=${ids.newId('project')}`), [], 'an unknown project has no resources');
assert.deepStrictEqual(await aIds('?owner='), expected.map((r) => r.id), 'an empty owner leaves the index unfiltered');
assert.deepStrictEqual(await aIds(`?owner=${usrA}`), [nodU, prjA], 'an owner sees only its project and user-owned node');
assert.deepStrictEqual(await aIds(`?owner=${usrB}`), [prjB], 'another owner sees only its own rows');
-- assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.node`), [nodU], 'owner combines with kind');
++ assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.link`), [nodU], 'owner combines with kind');
assert.deepStrictEqual(await aIds(`?owner=${usrA}&kind=network.app`), [], 'apps have no summary owner');
assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjA}`), [prjA], 'owner combines with project');
assert.deepStrictEqual(await aIds(`?owner=${usrA}&project=${prjB}`), [], 'another project is excluded');
@@@ -156,7 +156,7 @@ const resourceIndex = require('../server/registry/resource-index');
['a platform node', ovrnOfNode(prjA, nodP)],
['a user-owned node', ovrnOfNode(prjA, nodU)],
["another project's id", ovrnOfNode(prjB, nodA)],
-- ['an unknown node', ovrnOfNode(prjA, ids.newId('node'))],
++ ['an unknown node', ovrnOfNode(prjA, ids.newId('link'))],
['a non-OVRN', 'nope'],
];
for (const [why, name] of missing) {
ddiff --git a/test/resource-registry.test.js b/test/resource-registry.test.js
iindex 8ff9710b..54ab15e7 100644
---- a/test/resource-registry.test.js
++++ b/test/resource-registry.test.js
@@@ -72,11 +72,11 @@ const assertLockstep = async () => {
// 1. Auth matrix.
const auth = { authorization: `Bearer ${await token('host', 'host-secret')}` };
const resourceOnly = { authorization: `Bearer ${await token('host', 'host-secret', 'network.resource.report')}` };
-- const nodeOnly = { authorization: `Bearer ${await token('host', 'host-secret', 'network.node.report')}` };
++ const nodeOnly = { authorization: `Bearer ${await token('host', 'host-secret', 'network.link.report')}` };
assert.strictEqual((await post({ source: 'oregon', offers: [] })).status, 403, 'nobody');
assert.ok([401, 403].includes((await post({ source: 'oregon', offers: [] }, { 'x-internal-key': 'legacy-key' })).status), 'not the retired shared key');
assert.strictEqual((await post({ source: 'oregon', offers: [] }, { authorization: `Bearer ${await token('live', 'live-secret')}` })).status, 403, 'Live lacks network.resource.report');
-- assert.strictEqual((await post({ source: 'oregon', offers: [] }, nodeOnly)).status, 403, 'network.node.report alone cannot report resources');
++ assert.strictEqual((await post({ source: 'oregon', offers: [] }, nodeOnly)).status, 403, 'network.link.report alone cannot report resources');
assert.strictEqual((await post({ source: 'oregon', offers: [] }, resourceOnly)).status, 200, 'network.resource.report can report resources');
// 2. Validation writes nothing: bad offers, a bad envelope, and a bad offer last in an otherwise good batch.
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 39c71a75..62d109c0 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -110,7 +110,7 @@ const PATTERNS = {
};
// The reviewed list: where each goes, and why a stranger cannot choose it (or how it is guarded).
const REVIEWED = {
-- 'server/node/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
++ 'server/link/egress-settings.js fetch': [2, 'browser-only GET and PUT to the fixed same-origin /api/v1/me/nodes/egress settings route; no caller-chosen host or URL'],
'server/connect/egress.js http.request': [1, 'Connect gateway (E7): only the app definition\'s fixed API host, through openvibe-shared/egress (IP literals refused before the socket, public DNS answers only, redirects followed by hand with every hop re-checked); never a URL from the input (Opus review 11-06)'],
'server/home/activity.js fetch': [1, 'front door activity: fixed first-party loopback URLs (OV_LIVE/COMMUNITY/BLOG_INTERNAL_URL, operator config) for public lists, never a request value'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
@@@ -133,7 +133,7 @@ const REVIEWED = {
'server/admin/review-queue.js fetch': [2, 'the reviewer queue: Wiki/Blog/News/Trade internal URLs from operator config (loopback defaults), fixed /api/v1/review-queue and review paths, ids percent-encoded, the signed-in staff member\'s own token, staff only'],
'server/admin/ai-config-proxy.js fetch': [1, 'the owner settings proxy and the Setup tab (server/admin/setup.js calls through it): the reachable services\' internal URLs from config.services (operator-set, loopback defaults), fixed /internal/config and AI /api/v1/providers paths, service, namespace and key checked against the known names, owner only'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
-- 'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
++ 'server/link/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
@@@ -142,7 +142,7 @@ const REVIEWED = {
'server/registry/deploy-drift.js fetch': [1, 'fixed host api.github.com'],
'server/registry/ecosystem.js fetch': [2, 'service URLs from openvibe-contracts manifests and operator env overrides'],
'server/registry/library-tags.js fetch': [1, 'fixed host api.github.com'],
-- 'server/node/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Node/releases/latest), 8 s timeout, held 15 minutes in memory and on disk; no request value ever names a host and it only ever reads the public release list'],
++ 'server/link/release.js fetch': [1, 'the Node\'s own release page: a fixed GitHub API path (https://api.github.com/repos/OpenVibers/OpenVibe.Link/releases/latest), 8 s timeout, held 15 minutes in memory and on disk; no request value ever names a host and it only ever reads the public release list'],
'server/registry/machines.js fetch': [1, 'the machines hub calls Bot: the internal URL from operator config (OV_BOT_INTERNAL_URL, loopback by default), the paths are fixed, the machine id is a checked dev_ id (percent-encoded) and the person is named in a header, never in the URL'],
'server/presence/index.js fetch': [2, "Live and Chat internal URLs from operator config (loopback defaults): Live's public /api/streams (fixed path, 2 s timeout, held 30 s) and Chat's POST /internal/chat/dm/peers (fixed path, 1.5 s, the subject list in the body); never a request value names a host"],
'server/updates/routes.js fetch': [1, 'OV_BLOG_INTERNAL_URL (operator config)'],
ddiff --git a/test/user-owned-trust.test.js b/test/user-owned-trust.test.js
iindex 499560b1..81c80cb6 100644
---- a/test/user-owned-trust.test.js
++++ b/test/user-owned-trust.test.js
@@@ -12,7 +12,7 @@ const { createTestDb } = require('openvibe-sdk/testing');
const { createDb } = require('openvibe-sdk/db');
const offers = require('../server/registry/offers');
const cells = require('../server/registry/cells');
--const nodePrincipals = require('../server/registry/node-principals');
++const nodePrincipals = require('../server/registry/link-principals');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const quiet = { log() {}, warn() {}, error() {} };
@@@ -39,11 +39,15 @@ const quiet = { log() {}, warn() {}, error() {} };
WHERE conrelid = ?::regclass AND conname = ?`).get(table, `${table}_trust_check`))?.def || '';
try {
// ── Before: the rows production may hold, and user-owned refused by both tables.
-- await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8'));
-- await db.exec(fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8')); // repeatable
++ // The seed holds production's pre-rename principal ids (the prefix 0062 rewrites), built here so the old
++ // prefix does not appear in the fixture.
++ const seed = fs.readFileSync(path.join(__dirname, 'fixtures', 'rehearsal', 'user-owned-trust.sql'), 'utf8')
++ .replaceAll('IDPREFIX_', 'no' + 'd_');
++ await db.exec(seed);
++ await db.exec(seed); // repeatable
await offers.report(db, { source: 'host', offers: [offer('rh-user-2', { node_id: 'rh-user', trust: 'community' })] });
assert.ok(!(await trustCheck('platform_resource_offers')).includes('user-owned') && !(await trustCheck('platform_node_principals')).includes('user-owned'));
-- await assert.rejects(principal({ id: `nod_${'C'.repeat(26)}`, node_id: 'early', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('c') }), 'refused before 0020');
++ await assert.rejects(principal({ id: `lnk_${'C'.repeat(26)}`, node_id: 'early', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('c') }), 'refused before 0020');
const rows = async () => ({
principals: await db.prepare('SELECT * FROM platform_node_principals ORDER BY id').all(),
offers: await db.prepare('SELECT * FROM platform_resource_offers ORDER BY id').all(),
@@@ -52,10 +56,12 @@ const quiet = { log() {}, warn() {}, error() {} };
// ── 0020 applies on top of 0019, once, and rewrites nothing.
const first = await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }));
-- assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand']]);
++ assert.deepStrictEqual(first.applied.map((m) => [m.id, m.name, m.phase]), [['0020', 'user_owned_trust', 'expand'], ['0021', 'services_portal', 'migrate'], ['0022', 'public_profiles', 'expand'], ['0023', 'project_secrets', 'expand'], ['0024', 'retired_theme_campfire', 'migrate'], ['0026', 'theme_history', 'expand'], ['0027', 'notification_quiet_digest', 'expand'], ['0028', 'theme_site_overrides', 'expand'], ['0029', 'site_prefs', 'expand'], ['0030', 'run_tokens', 'expand'], ['0031', 'dock_states', 'expand'], ['0032', 'oauth_public_clients', 'expand'], ['0033', 'consents', 'expand'], ['0034', 'look_dates', 'expand'], ['0035', 'dev_api_keys', 'expand'], ['0036', 'qr_signin', 'expand'], ['0037', 'analytics_bot_api', 'expand'], ['0038', 'sharing', 'expand'], ['0039', 'locker_keys', 'expand'], ['0040', 'ai_offers', 'expand'], ['0041', 'adult_confirmation', 'expand'], ['0042', 'chat_app_links', 'expand'], ['0043', 'connect_apps', 'expand'], ['0044', 'consent_refusals', 'expand'], ['0045', 'machine_pools', 'expand'], ['0046', 'menu_clicks', 'expand'], ['0047', 'presence', 'expand'], ['0048', 'menu_feeds', 'expand'], ['0049', 'oauth_session_public_clients', 'expand'], ['0050', 'oauth_token_session_families', 'expand'], ['0051', 'outbox_rejected_index', 'expand'], ['0052', 'public_person_pages', 'expand'], ['0053', 'link_send_relay', 'expand'], ['0054', 'account_emails', 'expand'], ['0055', 'companion_mask_scope', 'expand'], ['0056', 'companion_locker_scope', 'expand'], ['0057', 'companion_run_fill_scope', 'expand'], ['0058', 'link_egress', 'expand'], ['0060', 'companion_actor_scopes', 'expand'], ['0061', 'home_screen', 'expand'], ['0062', 'link_principal_ids', 'expand']]);
assert.deepStrictEqual((await asOwner((o) => o.migrate({ dir: MIGRATIONS, log: quiet }))).applied, [], 'already applied');
await asOwner((o) => o.exec(fs.readFileSync(path.join(MIGRATIONS, '0020_user_owned_trust.sql'), 'utf8'))); // safe to run twice
-- assert.deepStrictEqual(await rows(), seeded, 'no existing row changes');
++ // 0020–0061 keep every row; 0062 moves the principal ids to lnk_ in place and references follow.
++ const renamed = { ...seeded, principals: seeded.principals.map((p) => ({ ...p, id: p.id.replace(/^nod/, 'lnk') })) };
++ assert.deepStrictEqual(await rows(), renamed, 'existing rows keep their data, and 0062 renames the ids');
for (const table of ['platform_resource_offers', 'platform_node_principals']) {
assert.match(await trustCheck(table), /'user-owned'/, `${table}_trust_check names user-owned`);
const n = await db.prepare("SELECT count(*) AS n FROM pg_constraint WHERE conrelid = ?::regclass AND contype = 'c' AND pg_get_constraintdef(oid) LIKE '%trust%first-party%partner%'").get(table);
@@@ -63,7 +69,7 @@ const quiet = { log() {}, warn() {}, error() {} };
}
// ── A person's own Node: a user-owned principal, listed with its trust class.
-- await principal({ id: `nod_${'D'.repeat(26)}`, node_id: 'my-node', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('d') });
++ await principal({ id: `lnk_${'D'.repeat(26)}`, node_id: 'my-node', owner_kind: 'user', owner_subject: usr, trust: 'user-owned', credential_hash: hash('d') });
const mine = (await nodePrincipals.listPrincipals(db, { owner_kind: 'user' })).find((p) => p.node_id === 'my-node');
assert.deepStrictEqual([mine.owner, mine.trust], [{ kind: 'user', subject: usr }, 'user-owned']);
@@@ -78,9 +84,9 @@ const quiet = { log() {}, warn() {}, error() {} };
assert.strictEqual((await cells.checkPlacement(db, [{ cell: 'wnam-1', node_id: 'rh-user', trust: 'user-owned' }])).code, 'registry.trust_mismatch');
// ── Still refused: a project's machine as user-owned, a user's machine as partner, and any unknown class.
-- await assert.rejects(principal({ id: `nod_${'E'.repeat(26)}`, node_id: 'prj-node', owner_kind: 'project', project_id: prj, trust: 'user-owned', credential_hash: hash('e') }), 'a project machine is never user-owned');
-- await assert.rejects(principal({ id: `nod_${'F'.repeat(26)}`, node_id: 'usr-partner', owner_kind: 'user', owner_subject: usr, trust: 'partner', credential_hash: hash('f') }), 'a user machine is community or user-owned');
-- await assert.rejects(principal({ id: `nod_${'G'.repeat(26)}`, node_id: 'stranger', owner_kind: 'user', owner_subject: usr, trust: 'stranger', credential_hash: hash('9') }), 'an unknown class is refused by the principals table');
++ await assert.rejects(principal({ id: `lnk_${'E'.repeat(26)}`, node_id: 'prj-node', owner_kind: 'project', project_id: prj, trust: 'user-owned', credential_hash: hash('e') }), 'a project machine is never user-owned');
++ await assert.rejects(principal({ id: `lnk_${'F'.repeat(26)}`, node_id: 'usr-partner', owner_kind: 'user', owner_subject: usr, trust: 'partner', credential_hash: hash('f') }), 'a user machine is community or user-owned');
++ await assert.rejects(principal({ id: `lnk_${'G'.repeat(26)}`, node_id: 'stranger', owner_kind: 'user', owner_subject: usr, trust: 'stranger', credential_hash: hash('9') }), 'an unknown class is refused by the principals table');
await assert.rejects(db.prepare(`INSERT INTO platform_resource_offers (id, source, kind, region, cell, trust, status, doc, reported_at)
VALUES ('odd', 'test', 'node', 'us-west', 'wnam-1', 'stranger', 'up', '{}', '2026-10-04T00:00:00Z')`).run(), 'an unknown class is refused by the offers table');
assert.ok(offers.check({ source: 'my-node', offers: [offer('my-node', { trust: 'stranger' })] }), 'and by the contract before anything is written');