openvibers/openvibe.bot Commits
OpenVibe Link rename: the machine pages, commands and console links (#359)
Changes
@@@ -69,12 +69,12 @@ OV_OAUTH_CLIENT_SECRET=#BOT_LIMITS_HOUR=3000#BOT_JOBS=on#BOT_JOBS_INTERVAL_MS=5000--#BOT_INSTALLER_URL=https://openvibe.bot/install++#BOT_INSTALLER_URL=https://openvibe.link/install# Where GET /install redirects (302): OpenVibe Link's installer script. Defaults to the checksummed asset on# OpenVibe Link's latest release (install.sh, pinned there by its CI cmp guard so it never drifts with main).# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything# else refuses to boot.--#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh++#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.# In production this is OpenRestream's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one# OpenRestream issued (BOT_OPENRE_* below), or the ingest refuses the device.@@@ -122,4 +122,4 @@ OV_OAUTH_CLIENT_SECRET=# OpenVibe Link for Android: the published download (the signed release APK), https on GitHub releases or openvibe.link.# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code, and# /machines does not link it.--# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/download/vX.Y.Z/openvibe-link.apk++# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Link/releases/download/vX.Y.Z/openvibe-link.apk@@@ -42,8 +42,8 @@ Sitemap entries use the release commit time for these code-backed pages.- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe Link `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe Link `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe Link `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.--- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).--- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.++- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.link/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).++- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.link/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).- `GET /api/v1/desk-hosts` lists where a desk may go for that person:- their own machines where "Let my agent use this computer" is on (trust `user-owned`);@@@ -53,7 +53,7 @@ Sitemap entries use the release commit time for these code-backed pages.- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.- A full machine answers `409 bot.machine_busy`; anyone else gets 404.- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).--- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).++- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-link approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.@@@ -203,7 +203,7 @@ A person acts on their own robots with a Network user token; a service acts with## Deploy--Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.++Production runs the Link process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.@@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URLSet `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.--`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).++`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).## Deploy files@@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.338.0++- openvibe-contracts: v0.373.0- openvibe-sdk: v0.123.0- openvibe-shared: v4.173.0<!-- versions:end -->@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.338.0",++ "contracts": "openvibe-contracts v0.373.0","sdk": "openvibe-sdk v0.123.0","shared": "openvibe-shared v4.173.0","packages": [-- "openvibe-contracts v0.338.0",++ "openvibe-contracts v0.373.0","openvibe-sdk v0.123.0","openvibe-shared v4.173.0"],@@@ -2,7 +2,7 @@## openvibe.bot — Nginx configuration (REFERENCE)#### Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx--## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and++## after changing it. The Link app (port 4630, systemd unit openvibe-bot) serves the REST API and## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in, the API and the pages,## proxies the front page, /shared/, /robots.txt and /release.json to the app, and the app itself serves@@@ -10,8 +10,8 @@ PR #18 changes the default target of the `GET /install` redirect so it points atinstaller asset instead of a path on OpenVibe Link's `main` branch:- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes-- `https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh` (was-- `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh`).++ `https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh` (was++ `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh`).- `test/install.test.js` — asserts the new default, keeps the allow-list checks, and still proves no queryparameter can change the redirect target.- `.env.example` and `README.md` — document the new default and why a released asset is used.@@@ -66,9 +66,9 @@ adds it from this file at the verified head; it resolves to:1. `openvibe-bot.service` is active after the restart (no crash loop).2. `GET http://127.0.0.1:4630/api/health` answers 2xx (the process, online devices and outbox status).3. `GET http://127.0.0.1:4630/api/ready` answers 2xx: database, Valkey, Network readiness and the outbox relay.--4. `GET https://openvibe.bot/install` **without following redirects** answers HTTP 302 and `Location` equal to++4. `GET https://openvibe.link/install` **without following redirects** answers HTTP 302 and `Location` equal tothe configured `BOT_INSTALLER_SOURCE_URL`; with `BOT_INSTALLER_SOURCE_URL` unset the default is-- `https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh`. Repeat with++ `https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh`. Repeat with`?url=https://evil.test/x` and `?to=https://evil.test/x`: both must still answer the same 302 target.5. The installer command a pairing returns ends in `--driver adeept` for `adeept.adr036`, `--driver adeept-mecanum`for `adeept.adr036.mecanum` and `--driver cozmo` for `cozmo`, and has no `--driver` for `sim.rover`.@@@ -83,7 +83,7 @@ If any of 1–4 fails, the deploy is not good: roll back (below) and investigatesha. Nothing in this PR needs a data rollback, so the previous release serves immediately.- **Manual:** `ovhost rollback bot --to <previous sha>` on `openvibe-ovh`, then re-run the verification above.- **Configuration:** the only knob is `BOT_INSTALLER_SOURCE_URL`. Unset it to fall back to the new default, or-- set it back to `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh` to return++ set it back to `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh` to returnto the previous target without a code change; both hosts are on the allow-list, so the service restarts clean.- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). Restore from thepgBackRest backup taken in the backup step **only** if an unrelated data incident requires it, coordinating@@@ -83,7 +83,7 @@ adds it from this file at the verified head; it resolves to:- `POST /api/v1/jobs` with no token → `401 bot.sign_in`; with a person's token → `403 bot.forbidden`; with anode token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;without `project_id` → `422 bot.invalid_input`.-- - A valid dispatch of a job whose class the target Node advertises → `201`, `sent: true`, a `run_jobs` row in++ - A valid dispatch of a job whose class the target Link advertises → `201`, `sent: true`, a `run_jobs` row instate `queued`, and the Link receives one `job` frame; the same body again → the same row, `sent: false`,no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.@@@ -17,7 +17,7 @@ one priority"):- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when ithas no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,Chat#194).--- **The Link's lifecycle:** `openvibe-node quit` ends a running Link cleanly with nothing latched (Link 0.16.0), for++- **The Link's lifecycle:** `openvibe-link quit` ends a running Link cleanly with nothing latched (Link 0.16.0), forlaunchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.## What Live has today@@@ -62,7 +62,7 @@ lines) let viewers drive a streamer's hardware from the channel page:3. **Built — Bot gate and panel**: the gate accepts `button`/`point` only for names and shapes the profiledeclares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on thecamera tile, normalised coordinates).--4. **Built — Node, the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live's++4. **Built — Link, the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live'shardware clients already understand — one JSON line per command on stdout of a supervised child process, or aloopback WebSocket that speaks Live's `{type:'command'|'key_held'|'key_released'|'video_click'}` messages — so anexisting Pi script keeps working with a one-line change of where it connects. Local policy and the kill switch@@@ -79,7 +79,7 @@ lines) let viewers drive a streamer's hardware from the channel page:ONVIF control and a disabled button are reported and left out; an owner with no Network subject is reportedand skipped (they must sign in to Network once). Idempotent on the Live config id (`live_conversions`,migration 0007): a second `--apply` changes nothing and says "already converted". The owner then pairs a-- Node (the five-minute path) and moves the script onto the relay plugin.++ Link (the five-minute path) and moves the script onto the relay plugin.6. **Done — Live**: `LIVE_BOT_EMBED=1`; the channel page shows the robot's embedded panel for a stream whose owner has arobot; then delete `server/controls/*`, the hardware WebSocket path and its stream-key auth, the controls tablesand `public/js/controls.js` / `onvif-dashboard.js` (the embed stays).@@@ -115,7 +115,7 @@ if anything, needs redoing by hand.### What each owner does next--1. Bot robot page → **Pair a Node** (the five-minute path): run the installer command Bot prints for the new++1. Bot robot page → **Pair a Link** (the five-minute path): run the installer command Bot prints for the newrobot on the machine that drives the hardware.2. Move the local script onto the **relay plugin**: it forwards `button` (`{name, state}` for a hold) and`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live@@@ -32,7 +32,7 @@ header; such a socket may only send `pair` until it is answered (anything elseso a reconnecting agent cannot be shadowed by a stale socket.**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the--pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service++pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/link-pairings` with its own servicetoken, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer commandcarries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network andconnects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its@@@ -44,9 +44,9 @@ Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe Link's `install/install.sh`).--**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its++**A Network-paired machine** (a node principal `lnk_…`, paired on OpenVibe.Network for one of itsowner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`--`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from++`node`, `sub` `link:lnk_…` and audience `openvibe.bot`. Bot binds it to a device on first use (fromNetwork's record: the robot the machine was paired for, which must be one of the principal's owner'srobots, and the principal must be `active`), then speaks first exactly as for a credential — `hello`,`config` — with **no** `paired` frame. A refused node token, or a principal Bot will not bind (revoked,@@@ -112,7 +112,7 @@ Bot sent them (one WebSocket), and OpenVibe Link handles them in that order. Twoa late one (`expired`) before any driver sees it, and gives a driver only the time left.Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never--queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Node++queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Linkanswers a repeated id from its dedup cache instead of running it again). A command in flight when thesocket drops is lost, and its operator gets `expired`.@@@ -121,7 +121,7 @@ across a Bot restart:- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and everycommand carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch itshalt moved to.--- OpenVibe Link (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,++- OpenVibe Link (Link#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.- It is absent until a robot's first turn change since Bot started. A command without it runs as before.@@@ -214,8 +214,8 @@ only (`robot_state.state.device_estop`); it never clears Bot's latch — clearinand audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine--itself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine--(`openvibe-node resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`++itself: `openvibe-link stop`, or a driver's own stop button through it. Only a release on that machine++(`openvibe-link resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack``local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot isstopped on the robot and holds back its Clear button until the device reports the release. After the release@@@ -335,7 +335,7 @@ States (`run_jobs.state`): `queued` → `placed` (ack) → `running` (`job_start| `service` / `operation` / `unit` | `run` / `function.invoke` / `s` || `quantity` | `1`; the partial last second `(wall_ms mod 1000)/1000`, never 0 || `at` | `new Date(started_ms + n*1000).toISOString()` |--| `resource` / `node` / `source` | the job id / the device id (`dev_…`) / `openvibe-node.worker` |++| `resource` / `node` / `source` | the job id / the device id (`dev_…`) / `openvibe-link.worker` || `project`, `subject`, `provider` | from Bot's record of the job (what `dispatch` was given); left out when unset |No field depends on when or how often a frame arrived, and no rating field is set. `job_exit` writes seconds@@@ -554,7 +554,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) || `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered || `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) |--| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` |++| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.link_token_required`, `403 bot.link_not_bound` || `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` || `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` || `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.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.173.0","pg": "^8.23.0",@@@ -1120,9 +1120,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.338.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",-- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",++ "version": "0.373.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",++ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.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.173.0","pg": "^8.23.0",@@@ -214,7 +214,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.robot-name .icon { color: var(--accent); }.robot-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; }.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }--/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */++/* A machine's computer plugin (Link docs/computer.md): its level, what waits on it, and the agent switch, full width. */.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }.machine-ai, .machine-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */@@@ -902,7 +902,7 @@if (!code || QUIET_NACKS.has(code)) return '';return NACK_TEXT[code] || `The robot refused that (${code}).`;}-- /** The device's own faults (status.faults from the Node: { code, driver, message }), while it is online. */++ /** The device's own faults (status.faults from the Link: { code, driver, message }), while it is online. */function paintFaults(state) {const el = $('[data-device-fault]');if (!el) return;@@@ -942,7 +942,7 @@function paint(state) {if (!state) return;const latched = !!(state.estop && state.estop.latched);-- // The robot's own stop (openvibe-node stop, or its driver's stop button) holds until it is released on the++ // The robot's own stop (openvibe-link stop, or its driver's stop button) holds until it is released on the// robot: clearing here cannot lift it, so the button waits and the note says where to release it.const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);const banner = $('[data-estop-banner]');@@@ -952,7 +952,7 @@const note = $('.estop-note');if (note) {const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';-- note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-node resume); then the owner clears it here.`++ note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner clears it here.`: latched ? ' — the robot stays still until the owner clears it.' : '';}const clear = $('[data-estop-clear]');@@@ -9,7 +9,7 @@* Imager writes too) and both go only into the files you download. openvibe.bot is sent the robot's name, model, who* may drive, Live and the hostname, and answers a setup key for the card (server/setup-keys.js).*-- * In Node (tests) this file exports the pure parts: sha512Crypt, yamlQuote, userData, networkConfig.++ * In Node.js (tests) this file exports the pure parts: sha512Crypt, yamlQuote, userData, networkConfig.*/(function (root, factory) {'use strict';@@@ -82,12 +82,13 @@/*** user-data. Mirrors what Raspberry Pi Imager 2 writes (its customization_generator.cpp): the `user:` block, avahi* so <hostname>.local answers, SSH on, I2C and SPI for the robot's boards. Then the one thing Imager cannot do: a-- * first-boot service that waits for the network and runs openvibe.bot's installer with the card's setup key, so the++ * first-boot service that waits for the network and runs OpenVibe Link's installer with the card's setup key, so the* robot adds itself with nothing typed on it. Its output: `journalctl -u openvibe-setup`.*/-- function userData({ hostname, username, passwordHash, sshKeys = [], timezone = '', setupKey, server = 'https://openvibe.bot', robotName = '' }) {++ function userData({ hostname, username, passwordHash, sshKeys = [], timezone = '', setupKey, server = 'https://openvibe.bot', installer = 'https://openvibe.link/install', robotName = '' }) {if (!/^osk_[A-Za-z0-9_-]{43}$/.test(setupKey || '')) throw new Error('no setup key');if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(server)) throw new Error('bad server');++ if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?\/install$/.test(installer)) throw new Error('bad installer');const keys = sshKeys.map((k) => k.trim()).filter(Boolean);const script = ['#!/bin/sh',@@@ -95,11 +96,11 @@'# Its output: journalctl -u openvibe-setup','key="$(cat /etc/openvibe/setup-key)"','for i in $(seq 1 90); do',-- ` curl -fsS -o /dev/null --max-time 10 ${server}/install && break`,++ ` curl -fsS -o /dev/null --max-time 10 ${installer} && break`,' echo "waiting for the network ($i of 90)"; sleep 10','done','for try in 1 2 3; do',-- ` if curl -fsSL ${server}/install | sh -s -- --setup "$key"; then rm -f /etc/openvibe/setup-key; exit 0; fi`,++ ` if curl -fsSL ${installer} | sh -s -- --setup "$key"; then rm -f /etc/openvibe/setup-key; exit 0; fi`,' echo "the installer stopped (try $try of 3): trying again in a minute"; sleep 60','done','exit 1',@@@ -3,7 +3,7 @@/*** The simulated rover's world (sim.rover): one small arena that Bot's in-process simulator (server/sim) and the* browser (the /try page's local rover, and the camera of every sim robot's panel) share, so the picture a panel-- * draws and the pose the server keeps always agree. No DOM, no Node API: it loads as a CommonJS module on the++ * draws and the pose the server keeps always agree. No DOM, no Node.js API: it loads as a CommonJS module on the* server and as `window.OVSimWorld` in a page.** Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and@@@ -13,7 +13,7 @@ function actor(req) {// person, exactly as their own token would — never against the shared service principal.if (req.limitSubject) return `user:${req.limitSubject}`;const p = req.principal;-- if (p && (p.kind === 'service' || p.kind === 'node') && p.sub) return p.sub;++ if (p && (p.kind === 'service' || p.kind === 'link') && p.sub) return p.sub;if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;return defaultActor(req);}@@@ -5,7 +5,7 @@** { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience* openvibe.bot; each route checks ONE capability-- * { kind: 'node', principal: 'nod_…', sub: 'node:nod_…' } a Network node token (actor_type node, audience++ * { kind: 'link', principal: 'lnk_…', sub: 'link:lnk_…' } a Network Link token (actor_type node, audience* openvibe.bot): a machine paired for Bot; it may only bind* { kind: 'user', subject: 'usr_…', username, name, avatar, role }* { kind: 'anonymous' }@@@ -17,8 +17,8 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');const { capabilities, http, ids } = contracts;--const PRINCIPAL_SUB = /^(svc|app|mod|node):/;--const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;++const PRINCIPAL_SUB = /^(svc|app|mod|link):/;++const LINK_SUB = /^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/;const ANON = Object.freeze({ kind: 'anonymous' });function decodePayload(token) {@@@ -57,21 +57,21 @@ async function verifyService(token, opts) {return { ok: true, claims: r.claims };}--/** A Bearer that claims to be a node token (actor_type node); verifyNode decides whether it is one. */--function isNodeToken(token) {++/** A Bearer that claims to be a Link token (actor_type node); verifyLink decides whether it is one. */++function isLinkToken(token) {const payload = decodePayload(token);-- return !!payload && payload.actor_type === 'node';++ return !!payload && payload.actor_type === 'link';}/**-- * Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…-- * Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.++ * Verify a Link token: signed by Network, audience openvibe.bot, actor_type node, sub link:lnk_…++ * Returns { ok, principal: 'lnk_…', claims } or { ok:false, code, reason }.*/--async function verifyNode(token, opts) {++async function verifyLink(token, opts) {const r = await principalToken(token, opts);if (!r.ok) return { ok: false, code: r.code, reason: r.reason };-- const m = NODE_SUB.exec(String(r.claims.sub || ''));-- if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };++ const m = LINK_SUB.exec(String(r.claims.sub || ''));++ if (r.claims.actor_type !== 'link' || !m) return { ok: false, code: 'token.invalid', reason: 'not a Link token' };return { ok: true, principal: m[1], claims: r.claims };}@@@ -84,10 +84,10 @@ function createApiAuth({ config, keys, userAuth }) {const token = header.slice(7).trim();if (!keys.loaded()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };const payload = decodePayload(token);-- if (payload && payload.actor_type === 'node') {-- const r = await verifyNode(token, opts());++ if (payload && payload.actor_type === 'link') {++ const r = await verifyLink(token, opts());if (!r.ok) return { error: [401, r.code, r.reason] };-- return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };++ return { principal: { kind: 'link', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };}if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {const r = await principalToken(token, opts());@@@ -114,4 +114,4 @@ function createApiAuth({ config, keys, userAuth }) {return { middleware, resolve, granted };}--module.exports = { createApiAuth, userPrincipal, verifyService, verifyNode, isNodeToken, decodePayload, PRINCIPAL_SUB };++module.exports = { createApiAuth, userPrincipal, verifyService, verifyLink, isLinkToken, decodePayload, PRINCIPAL_SUB };@@@ -50,7 +50,7 @@ function machineSummary(row, online, baseUrl = null) {updated_at: new Date(row.updated_at).toISOString(),};if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };-- return { ...summary, ...consoleOf(baseUrl, '/machines') };++ return { ...summary, ...(/^https:\/\//.test(String(baseUrl || '')) ? { links: { console: 'https://openvibe.link/machines' } } : {}) };}const encodeCursor = (id, kind = KIND) => Buffer.from(JSON.stringify([kind, id])).toString('base64url');@@@ -25,7 +25,7 @@* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |* | GET /robots/:id/devices | bot.robot.read | a member |* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |-- * | POST /devices/bind | — (a node token) | a machine paired for Bot on Network |++ * | POST /devices/bind | — (a Link token) | a machine paired for Bot on Network |* | POST /robots/:id/estop | bot.robot.control | owner or operator |* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |* | POST /robots/:id/commands | bot.robot.control | owner or operator |@@@ -75,7 +75,7 @@ const CAP = {const AGENT_SERVICES = new Set(['svc:actor']);/*** Services that use a person's machine, each only for the kinds it is allowed and only where the owner turned on its-- * switch on openvibe.bot/machines: the agent (any computer kind) and Watch's node source (read-only probes).++ * switch on openvibe.link/machines: the agent (any computer kind) and Watch's node source (read-only probes).*/const MACHINE_SERVICES = {'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },@@@ -139,7 +139,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);const me = (req) => (req.principal.kind === 'user' ? req.principal.subject : null);-- /** Only a person or a service acts for an owner: no token → 401, any other kind (a node token) → 403. */++ /** Only a person or a service acts for an owner: no token → 401, any other kind (a Link token) → 403. */const fence = (req) => {if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');if (req.principal.kind !== 'user' && req.principal.kind !== 'service') fail(403, 'bot.forbidden', 'this token may not act for an owner');@@@ -168,7 +168,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submif (!apiAuth.granted(req.principal, CAP.dispatch)) fail(403, 'capability.denied', `${CAP.dispatch} not granted`);};-- // Both fence before the lookup: without a token (or with a node token) a real robot id and a made-up one++ // Both fence before the lookup: without a token (or with a Link token) a real robot id and a made-up one// get the same answer.async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {fence(req);@@@ -552,7 +552,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submos: (st.status && typeof st.status.os === 'string' && st.status.os) || null,node_version: device.agent_version || null,last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,-- // The machine's id on Network (`nod_…`) and the OpenVibe Link's name for it, so a caller that knows the++ // The machine's id on Network (`lnk_…`) and the OpenVibe Link's name for it, so a caller that knows the// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).node_id: device.node_principal || null,// What its coding agents have left, as its Link last reported it, and when Bot stored that reading@@@ -910,11 +910,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json(await claims.poll(req.params.id, String(req.headers['x-ov-claim-secret'] || '')));}));-- // ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────++ // ── Bootstrap of a Network-paired machine (its Link token, once after pairing) ────────────────// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the// old one stops working) for the same device. POST /pair's answer without a credential.r.post('/devices/bind', pair, wrap(async (req, res) => {-- if (req.principal.kind !== 'node') fail(401, 'bot.node_token_required', 'a Network node token (audience openvibe.bot) is required');++ if (req.principal.kind !== 'link') fail(401, 'bot.link_token_required', 'a Network Link token (audience openvibe.bot) is required');const device = await domain.devices.bindNode(req.principal.principal);const issued = await domain.devices.issuePublishKey(device.id);res.setHeader('Cache-Control', 'no-store');@@@ -151,12 +151,12 @@ function createApp(opts = {}) {const accountSend = opts.accountSend || (config.oauth.clientSecret? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }): async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });-- // network.node.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is++ // network.link.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is// closed at once, instead of at the machine's next reauth deadline.const onEvent = async (ev) => {-- if (ev && ev.event_type === 'network.node.revoked') {++ if (ev && ev.event_type === 'network.link.revoked') {const principal = String((ev.payload && ev.payload.principal_id) || '');-- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';++ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';const revoked = await domain.devices.revokeByNode(principal);for (const d of revoked) hub.closeDevice(d.id, 'machine revoked on OpenVibe.Network');if (revoked.length) log.log(`[Bot] node ${principal} revoked on Network: ${revoked.length} device(s) revoked`);@@@ -3,7 +3,7 @@* A device that asks to be added: the one command on the machine (plan T15 "five-minute path", owner 2026-10-17: "I* should be able to run a single command on my raspi … ready to go live and controllable").*-- * curl -fsSL https://openvibe.bot/install | sh (no robot, no code)++ * curl -fsSL https://openvibe.link/install | sh (no robot, no code)** The installer looks at the machine (the kit on its I2C bus, the camera), then starts a claim here and shows the* person a code and openvibe.bot/link. Signed in there, they see what the device found and approve it (name, model,@@@ -107,7 +107,7 @@ function loadConfig(env = process.env) {if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);}-- const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh').trim();++ const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh').trim();checkInstallerSource(installerSource);const embedOrigins = parseEmbedOrigins(env.BOT_EMBED_ORIGINS, isProduction);return {@@@ -160,7 +160,7 @@ function loadConfig(env = process.env) {// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,// single use, 5 wrong tries end it. Only the hash is stored. `authority` network (plan T15 B2): the-- // code is minted by Network (POST /internal/node-pairings), Bot stores none, and POST /pair and the++ // code is minted by Network (POST /internal/link-pairings), Bot stores none, and POST /pair and the// `pair` frame answer 410 bot.pairing_moved. An unknown value refuses to boot.pairing: {authority: pairingAuthority,@@@ -279,7 +279,7 @@ function loadConfig(env = process.env) {maxPerRobot: Math.max(1, int(env.BOT_WATCH_MAX_PER_ROBOT, 500)),},installer: {-- scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),++ scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.link/install'),sourceUrl: installerSource,sourceHosts: INSTALLER_SOURCE_HOSTS,},@@@ -386,7 +386,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/*** BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST-- * /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and++ * /internal/link-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.*/async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {@@@ -666,7 +666,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/**-- * Network revoked a node principal (network.node.revoked: its owner, the pairing service or an account deletion):++ * Network revoked a node principal (network.link.revoked: its owner, the pairing service or an account deletion):* every live device bound to it is revoked here too, without asking Network again. → the revoked rows*/async function revokeByNode(principalId) {@@@ -679,9 +679,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =// ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────/*** The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:-- * a live row is returned as it is. Otherwise Network's record decides (GET /internal/node-principals/:id++ * a live row is returned as it is. Otherwise Network's record decides (GET /internal/link-principals/:id* with Bot's service token): the principal must be active, paired for Bot, for a robot of the principal's-- * own owner; anything else is 403 bot.node_not_bound. A principal whose device the owner revoked here++ * own owner; anything else is 403 bot.link_not_bound. A principal whose device the owner revoked here* never binds again. The row starts from Network's record and safe values only; what the device declares* arrives later in its `status` frames.*/@@@ -689,7 +689,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =const known = await db.maybe('SELECT * FROM devices WHERE node_principal = $1 ORDER BY (revoked_at IS NULL) DESC, created_at DESC LIMIT 1', [principalId]);if (known && !known.revoked_at) return known;-- const refuse = (why) => fail(403, 'bot.node_not_bound', why);++ const refuse = (why) => fail(403, 'bot.link_not_bound', why);if (known) refuse('this machine was revoked on Bot; pair it again');if (!nodes) refuse('Network-paired machines are not configured here');const p = await nodes.get(principalId);@@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =WHERE id = $1 AND revoked_at IS NULL RETURNING *`,[id, JSON.stringify(fleet), iso(now())]);}-- // ── The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run) ──────────────-- // One row per device and step, from the `node.first_run` telemetry events the Link already sends. The fields are++ // ── The guided first run (OpenVibe Link's `openvibe-link`, docs/install.md#first-run) ──────────────++ // One row per device and step, from the `link.first_run` telemetry events the Link already sends. The fields are// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);@@@ -1011,13 +1011,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/*** Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event-- * that is not `node.first_run` with a known step is ignored, never an error for the caller.++ * that is not `link.first_run` with a known step is ignored, never an error for the caller.*/async function recordFirstRun(deviceId, events) {if (!Array.isArray(events) || !deviceId) return 0;let stored = 0;for (const event of events) {-- if (!event || event.name !== 'node.first_run' || !event.fields || typeof event.fields !== 'object') continue;++ if (!event || event.name !== 'link.first_run' || !event.fields || typeof event.fields !== 'object') continue;const step = firstRunText(event.fields.step, 20);if (!FIRST_RUN_STEPS.has(step)) continue;const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();@@@ -80,9 +80,9 @@ async function main() {// kit's 5000 ms default is the right value.// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,// BOT_EVENTS_SECRET or the client secret.-- // Plus network.node.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).++ // Plus network.link.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).const subscriptions = startSubscriptions({-- topics: [...TOPICS, 'network.node.revoked'],++ topics: [...TOPICS, 'network.link.revoked'],eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,});@@@ -7,7 +7,7 @@** Every field of a reading is derived from the job and its second: id = idempotency_key = run:<job id>:<n>,* service run, operation function.invoke, unit s, quantity 1 (the partial last second (wall_ms mod 1000)/1000,-- * never 0), at = started_ms + n*1000, resource the job id, node the device id, source openvibe-node.worker, and++ * never 0), at = started_ms + n*1000, resource the job id, node the device id, source openvibe-link.worker, and* project, subject and provider from Bot's own record of the job. Nothing depends on when a frame arrived, so a* resend, a job_usage and the job_exit backfill of one second are one key: the outbox keeps the first* (ON CONFLICT DO NOTHING) and Billing replays an identical reading (200) and refuses a different one (409).@@@ -29,7 +29,7 @@ const SCHEMA = 'platform.usage-sample@1';const SERVICE = 'run';const OPERATION = 'function.invoke';const UNIT = 's';--const SOURCE = 'openvibe-node.worker';++const SOURCE = 'openvibe-link.worker';const REFUSED = new Set([400, 409, 413, 422]);// Network's grant to the `bot` client on audience openvibe.billing (Network server/identity/principals.js@@@ -5,13 +5,13 @@** keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on an-- * unknown kid). They verify service and node tokens (audience openvibe.bot) and the browser's Network++ * unknown kid). They verify service and Link tokens (audience openvibe.bot) and the browser's Network* user JWT, all offline (openvibe-sdk/auth).-- * nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing-- * code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id, POST …/:id/revoke and++ * nodes Network's node principals (machines paired for Bot): POST /internal/link-pairings (a pairing++ * code, BOT_PAIRING_AUTHORITY=network), GET /internal/link-principals/:id, POST …/:id/revoke and* POST /internal/machines/status (the machine's online state, which Network publishes as the* hub's network.machine.online and network.machine.offline events) with Bot's own svc:bot token-- * (audience openvibe.network, network.node.manage).++ * (audience openvibe.network, network.link.manage).* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the* names of subjects the robot's owner added.*/@@@ -28,15 +28,15 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = consolefunction createUserAuth(config, keys) {/*** Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals-- * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)++ * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a Link token (a machine paired for Bot)* is refused here too.*/async function verify(token) {if (!token) return null;let claims;try { claims = await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }-- if (typeof claims.sub === 'string' && /^node:/.test(claims.sub)) return null;-- if (claims.actor_type === 'node') return null;++ if (typeof claims.sub === 'string' && /^link:/.test(claims.sub)) return null;++ if (claims.actor_type === 'link') return null;return claims;}return { verify };@@@ -48,13 +48,13 @@ function createUserAuth(config, keys) {* get(id) → that view, or null when Network knows no such principal paired by Bot (it answers 404, never 403).* revoke(id) → the revoked view, or null for the same 404; idempotent on Network's side.* pair({subject, ref}) → { pairing_id, code, expires_at }: a one-time code for the person `subject` to pair a-- * machine for robot `ref` (POST /internal/node-pairings; Network keeps one live code per ref).++ * machine for robot `ref` (POST /internal/link-pairings; Network keeps one live code per ref).* Any other failure throws 503 bot.network_unavailable.*/function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {const tokens = serviceAuth.createTokenClient({tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,-- audience: 'openvibe.network', scope: 'network.node.manage', fetchImpl,++ audience: 'openvibe.network', scope: 'network.link.manage', fetchImpl,});async function call(method, path, body) {let res;@@@ -72,11 +72,11 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the machine`);return out;}-- const principal = (id) => `/internal/node-principals/${encodeURIComponent(id)}`;++ const principal = (id) => `/internal/link-principals/${encodeURIComponent(id)}`;return {get: (id) => call('GET', principal(id)),revoke: (id) => call('POST', `${principal(id)}/revoke`),-- pair: ({ subject, ref }) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject }, ref }),++ pair: ({ subject, ref }) => call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject }, ref }),// The machine's online state (plan L34): Network publishes it as the hub's machine events.status: (body) => call('POST', '/internal/machines/status', body),};@@@ -40,7 +40,7 @@ const CAPABILITIES = new Set(['display.text','sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup', 'sensor.tilt','battery', 'ptz', 'camera',-- // A robot that carries no hardware of its own: the owner's Node forwards the panel's commands to a++ // A robot that carries no hardware of its own: the owner's Link forwards the panel's commands to a// local script with the relay plugin (relay.generic, plan T15 R9).'relay',]);@@@ -50,7 +50,7 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake/** Device drivers a mapping may name. `relay` is a Link plugin with no hardware map of its own. */// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe Link docs/hardware-looi.md).--// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.++// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Link and nothing installed.// `plugin`: a community OpenVibe Link plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;// community profiles are reviewed and signed before robots can use them, server/submissions.js).const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);@@@ -4,7 +4,7 @@"name": "Generic relay","vendor": "OpenVibe","kind": "bridge",-- "description": "A robot with no hardware of its own: the owner's Node runs the relay plugin and forwards the panel's button and video-click commands to a local script. The shape OpenVibe.Live's camp controls convert to (plan T15 R9 step 5); the buttons live in the robot's local profile.",++ "description": "A robot with no hardware of its own: the owner's Link runs the relay plugin and forwards the panel's button and video-click commands to a local script. The shape OpenVibe.Live's camp controls convert to (plan T15 R9 step 5); the buttons live in the robot's local profile.","capabilities": ["relay"],"mapping": { "driver": "relay", "plugin": "relay" },"commands": {@@@ -5,7 +5,7 @@** wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`* on the upgrade, never a query string; an unauthenticated socket may only send-- * `pair`. A Network-paired machine presents its node token instead (bound on first++ * `pair`. A Network-paired machine presents its Link token instead (bound on first* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are* never queued for an offline device and never replayed after a reconnect. Jobs* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`@@@ -25,7 +25,7 @@ const { WebSocketServer } = require('ws');const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');const { capabilities, ids, validate } = require('openvibe-contracts');const { json, iso, prefixedId } = require('./util');--const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');++const { userPrincipal, verifyService, verifyLink, isLinkToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');const { getProfile } = require('./profiles');const { createVoice } = require('./voice');const { computerCaps } = require('./machine-ai');@@@ -57,7 +57,7 @@ const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needsconst deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');--// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with++// The screen and the mouse and keyboard of a desk are the desk's own (Link's desk.screen, desk.input): a desk host with// no screen of its own (no one signed in, Link v0.20.0 --desk-host) offers them on its desks only.const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a@@@ -104,7 +104,7 @@ function parseCookies(header) {const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);const DEVICE_PROTOCOL = 'ov.device';--const CREDENTIAL_RE = /^[A-Za-z0-9_-]{43}$/; // a device credential (32 random bytes, base64url); never a node token++const CREDENTIAL_RE = /^[A-Za-z0-9_-]{43}$/; // a device credential (32 random bytes, base64url); never a Link token/** A browser device's credential, offered as `ov.device.<credential>` beside `ov.device`; anything else → null. */function deviceCredentialFromProtocols(header) {const offered = String(header || '').split(',').map((s) => s.trim()).filter(Boolean);@@@ -227,7 +227,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (cred) {conn.backlog = [];try {-- if (isNodeToken(cred)) await authenticateNode(conn, cred);++ if (isLinkToken(cred)) await authenticateNode(conn, cred);else await authenticateDevice(conn, cred);sendHello(conn);await sendConfig(conn);@@@ -265,17 +265,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => DatattachDevice(conn, device);}-- /** A node token: verified offline, then bound to its device (created on first use from Network's record). */++ /** A Link token: verified offline, then bound to its device (created on first use from Network's record). */async function authenticateNode(conn, nodeToken) {-- const r = await verifyNode(nodeToken, tokenOpts());-- if (!r.ok) throw new Error(`node token refused: ${r.code}`);++ const r = await verifyLink(nodeToken, tokenOpts());++ if (!r.ok) throw new Error(`Link token refused: ${r.code}`);const device = await domain.devices.bindNode(r.principal);conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };attachDevice(conn, device);}-- /** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */++ /** `reauth`: a fresh Link token for the same principal moves the deadline; anything else leaves it. */async function onReauth(conn, msg) {-- const r = await verifyNode(typeof msg.token === 'string' ? msg.token : '', tokenOpts());++ const r = await verifyLink(typeof msg.token === 'string' ? msg.token : '', tokenOpts());if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');conn.node.reauthBy = now() + config.device.nodeReauthMs;@@@ -343,7 +343,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datestop_latched: robot ? !!robot.estop_latched : false,// Vibe by voice: the machine listens for its call word only while its owner has voice on....(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),-- // What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Node++ // What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Link// turns them on at its next check-in (and at once when they change while it is connected).roles: json(conn.device.roles, []),});@@@ -530,7 +530,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datawait domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');}-- // local_stop: the latch is held on the machine itself (openvibe-node stop, a driver's own stop button), which++ // local_stop: the latch is held on the machine itself (openvibe-link stop, a driver's own stop button), which// only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.const local = latched && msg.local_stop === true;const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;@@@ -824,7 +824,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datreturn refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session', summary);}} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {-- return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);++ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-link, --computer)`, summary);}// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,// one per agent task) needs a machine that runs several (Link v0.19.0: desk.named). Kind desk names its desk in@@@ -1074,7 +1074,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datfunction checkHeartbeats() {for (const conn of [...deviceConns.values()]) {if (conn.node && now() > conn.node.reauthBy) {-- // The node token lapsed with no valid reauth: the machine must authenticate again.++ // The Link token lapsed with no valid reauth: the machine must authenticate again.try { conn.ws.close(4002, 'reauth required'); } catch { /* gone */ }dropDevice(conn);continue;@@@ -2,7 +2,7 @@/*** Community robot profiles (plan T15 "people submit bots and guides and Bot connects them": submissions are reviewed-- * and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,++ * and signed before they run on anyone's Link). Anyone signed in submits a profile whose id is under `community.`,* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so* robots can be added with it; the signature and Bot's public key are published so a Link or anyone can check it.@@@ -20,7 +20,7 @@ const uiKit = require('openvibe-shared/kit');const { esc } = require('./render');const { framePage } = require('./home');--const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';++const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Link/blob/main';const CONNECT = {browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },onboard: { label: 'On-board', text: 'The OpenVibe Link runs on the robot\'s own computer and drives its hardware.' },@@@ -204,7 +204,7 @@ ${parts}${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p><ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>--<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>++<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-link profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul><p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });@@@ -21,7 +21,7 @@ const { framePage } = require('./home');const simRover = require('../profiles/sim.rover.json');// The driver protocol and the bundled drivers live in OpenVibe Link (the Link core and its plugins/ tree).--const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';++const NODE = 'https://github.com/OpenVibers/OpenVibe.Link';const TREE = `${NODE}/tree/main`;const BLOB = `${NODE}/blob/main`;@@@ -41,7 +41,7 @@ function docsPage({ config, path, title, description, content }) {<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav><main id="main" class="docs-main">${content}--<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>++<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Link')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p></main></div>`;return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });@@@ -70,8 +70,8 @@ ${kit.pageHead({ title: `Build for OpenVibe.Bot`, icon: 'code', back: { href: '/<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section><h2>Where things live</h2><ul>--<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>--<li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>++<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Link')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>++<li>Drivers are Link <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li><li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li><li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li></ul>@@@ -96,7 +96,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label<ul><li><b>on-board</b> (<code>kind: "onboard"</code>) — the Link runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li><li><b>bridge</b> (<code>kind: "bridge"</code>) — the Link runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>--<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>++<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Link on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li></ul><p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>@@@ -104,7 +104,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label<ol><li>The core starts the process and sends <code>hello</code>.</li><li>The driver answers <code>describe</code> <b>before touching any hardware</b>.</li>--<li>Unless <code>hello.probe</code> is true, the driver opens its hardware with every actuator stopped and sends <code>ready</code> (or <code>fault</code> if it cannot). Probe mode is used by <code>openvibe-node pair</code> and <code>openvibe-node plugins</code>: describe, then wait for EOF without opening anything.</li>++<li>Unless <code>hello.probe</code> is true, the driver opens its hardware with every actuator stopped and sends <code>ready</code> (or <code>fault</code> if it cannot). Probe mode is used by <code>openvibe-link pair</code> and <code>openvibe-link plugins</code>: describe, then wait for EOF without opening anything.</li><li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li><li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li></ol>@@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '<li>when its own sensors say so (for example a cliff or a pick-up).</li></ul><p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>--<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>++<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-link resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section><section class="card"><h2>A minimal driver in Python</h2><p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>@@@ -221,10 +221,10 @@ stop()`)}</section><section class="card"><h2>Test it</h2><p><b>With the <code>dryrun</code> driver</b> (${out(`${TREE}/plugins/dryrun`, 'plugins/dryrun')}): it accepts every kind, logs it, reports simulated battery and distance, and asks the core for the test-pattern camera. Add it to your <code>config.json</code> plugins and set <code>config.record: "<path>"</code> to append one JSON line per <code>setup</code>, <code>command</code> and <code>stop</code> — end-to-end tests read that file to check what reached the "robot".</p>${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.jsonl" } } ] }')}--<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section>++<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Link. Use it to see exactly what your profile shows before wiring a driver.</p></section><section class="card"><h2>Contribute it</h2>--<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>++<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Link')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>`;return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Link: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });}@@@ -369,11 +369,11 @@ ${code(COMMISSION_CALL)}const COMPUTER_INSTALL = `curl -fsSL https://openvibe.link/install | sh -s -- \\--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;--const COMPUTER_APPROVE = `openvibe-node approvals # what waits: id, kind, what it would do--openvibe-node approve cmd_01J… # let it run--openvibe-node deny cmd_01J… # refuse it--openvibe-node stop # the kill switch: observe only until resume--openvibe-node tray # the same in your notification area (Linux, Windows)`;++const COMPUTER_APPROVE = `openvibe-link approvals # what waits: id, kind, what it would do++openvibe-link approve cmd_01J… # let it run++openvibe-link deny cmd_01J… # refuse it++openvibe-link stop # the kill switch: observe only until resume++openvibe-link tray # the same in your notification area (Linux, Windows)`;const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…/commands \\-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\-d '{"kind": "exec", "value": {"argv": ["git", "status", "--short"], "cwd": "/home/you/site"}}'@@@ -386,7 +386,7 @@ ${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { h<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2><p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>${code(COMPUTER_INSTALL)}--<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">node.openvibe.network</a>.</p>++<p class="muted">The installer is also at <a href="https://openvibe.link/install">openvibe.link/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">openvibe.link</a>.</p><p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section><section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2>@@@ -401,7 +401,7 @@ ${code(COMPUTER_INSTALL)}<section class="card" aria-labelledby="approve-h"><h2 id="approve-h">Approving on the computer</h2><p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p><ul>--<li><b>The tray</b> (Linux and Windows). <code>openvibe-node tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>++<li><b>The tray</b> (Linux and Windows). <code>openvibe-link tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li><li><b>A notification or dialog</b> from the Link itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li><li><b>The command line</b>:</li></ul>@@@ -422,7 +422,7 @@ ${code(COMPUTER_APPROVE)}<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li><li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li><li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>--<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-node stop</code>) stops them all.</li>++<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-link stop</code>) stops them all.</li></ul><p>How many run at once depends on the machine's memory (one whole desk per 2 GB by default; a browser desk counts as half). The details are in ${out(`${BLOB}/docs/computer.md`, 'computer.md')}.</p></section>@@@ -442,8 +442,8 @@ ${code(COMPUTER_APPROVE)}<ul><li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li><li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li>--<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>--<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>++<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-link status</code> shows it while it runs.</li>++<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-link stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li></ul><p>From the API, with your own token: <code>POST /api/v1/machines/:id/screen</code> (<code>seconds</code>, <code>fps</code> from 1 to 5) starts it, <code>GET</code> answers <code>signaling_url</code> once the computer is sending, and <code>DELETE</code> stops it.</p></section>@@@ -468,7 +468,7 @@ The page answers with its URL, title, text and links. The browser is the agent's<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li><li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li></ul>--<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>++<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>lnk_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>`;return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Link: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });}@@@ -188,7 +188,7 @@ function moreSection({ config, kit: k, models }) {const model = k ? models.find((m) => m.id === k.profile_id) : null;const kitBody = k && k.parts && k.parts.length? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Link drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>--<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for Link', 'sc-btn')}</p>`++<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/hardware-adeept.md', 'Setup guide for Link', 'sc-btn')}</p>`: '';const build = rows([['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Link: every message and the safety rules.'],@@@ -202,7 +202,7 @@ function moreSection({ config, kit: k, models }) {html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}--${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}++${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Link', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${kit.button({ href: '/docs', label: 'All build pages', icon: 'code', kind: 'quiet' })}</p>` })}</section>`,@@@ -719,11 +719,11 @@ ${kit.pageHead({ title: `Sign in to continue`, icon: 'gear', back: { href: '/',}/**-- * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.bot/install | sh` with no code.++ * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.link/install | sh` with no code.* renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;* renderClaimApproved follows the device as it pairs on its own.*/--function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {++function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.link/install | sh' } = {}) {const body = `${topbar('<span class="crumb">Add a device</span>')}<main class="page link">${kit.pageHead({ title: `Add a device`, icon: 'gear', back: { href: '/', label: 'Home' } })}@@@ -949,7 +949,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = falseconst list = machines.length? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`-- + `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`++ + `${m.agent_version ? ` <span class="pill">Link ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`+ firstRunRow(m) + computerRow(m) + voiceRow(m)+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;@@@ -963,9 +963,9 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p><ul class="choices" aria-labelledby="add-h">${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns Link\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}--${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}--${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}--${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}++${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-link approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md">How it works</a>.')}++${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}++${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/ai.md">How it works</a>.')}<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p><p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul><p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`@@@ -973,8 +973,8 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;const body = `${topbar('<span class="crumb">Machines</span>')}<main class="page">--${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-node stop) always win.` })}--<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>++${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-link stop) always win.` })}++<p class="muted"><a href="https://openvibe.link/machines">All your machines are on openvibe.link/machines</a></p>${list}${add}${phoneReady ? '<p class="muted">In your pocket: <a href="/link/phone">Link on your phone</a> adds the Android app, which shows your agents\' approvals and takes files from any share sheet.</p>' : ''}@@@ -1112,7 +1112,7 @@ ${takeOver}<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing it</button></form><form method="post" action="/machines/${esc(machine.id)}/screen">${desk !== 'main' ? `<input type="hidden" name="desk" value="${esc(desk)}">` : ''}<button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form></div>--<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>++<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-link stop</code> on the machine ends it at once, and it ends on its own after its time.</p>${sharesBlock(machine, shares, madeLink, madeShare, hasDesk, hasScreen)}</main>`;return page(`${name}: screen`, body, { scripts: ['/panel/panel.js', '/panel/screen-share.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });@@@ -1152,7 +1152,7 @@ ${kit.pageHead({ title: `Make this phone a robot`, icon: 'gear', back: { href: '}/**-- * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (node.first_run), so++ * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (link.first_run), so* the number that reach each step and the median time between them answer whether installs get to a working machine.*/function renderFirstRunPage({ rows = [] }) {@@@ -1200,12 +1200,12 @@ function renderFirstRunPage({ rows = [] }) {? `<ul class="dir-list">${latest.map(({ id, last, at }) => {const row = byStep.get(last).find((r) => r.device_id === id);return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`-- + `${row && row.agent_version ? ` · Node ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`++ + `${row && row.agent_version ? ` · Link ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`+ `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;}).join('')}</ul>`: '<p class="muted">No machine has reported a first run yet.</p>';const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>--<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (node.first_run); nothing here names a person, folder or address.` })}</div>++<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (link.first_run); nothing here names a person, folder or address.` })}</div><section class="dir-sec"><h2>The funnel</h2><table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption><thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>@@@ -47,7 +47,7 @@ const kits = require('../kits');// testing). Read on each request, so setting it in the env file and restarting is all a release needs.function linkApkUrl() {const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();-- return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Node\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;++ return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;}const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');const { createMachineScreen } = require('../machine-screen');@@@ -127,8 +127,8 @@ const LLMS_PAGES = [},{path: '/docs/computers', title: 'Your computer for your agents',-- note: 'The Node\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',-- text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',++ note: 'The Link\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',++ text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-link approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.link/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',},{path: '/updates', title: 'What shipped on OpenVibe.Bot',@@@ -475,7 +475,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi}));// The guided first run's funnel, reviewers only (OpenVibe Link docs/install.md): the Link reports one-- // node.first_run telemetry event per step, and Bot stores one row per device and step.++ // link.first_run telemetry event per step, and Bot stores one row per device and step.r.get('/admin/first-run', page(async (req, res) => {const me = requireUser(req);if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');@@@ -615,7 +615,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};// Read online first: the first-run query must not make a machine look offline because the page took longer.const online = !!(hub && hub.isOnline(d.id));-- // What the guided first run's one job found on this machine (Node's node.first_run first_job event).++ // What the guided first run's one job found on this machine (Link's link.first_run first_job event).const firstRun = await domain.devices.firstRunOf(d.id);return {...d, online, robot: robot ? robot.name : null,@@@ -26,7 +26,7 @@ const WHIP_BASE = 'https://whip.test/ingest';};const result = (id) => op.waitFor((m) => m.type === 'command_result' && m.id === id);-- // ── 1. the owner creates the robot; the fake device pairs over HTTP like `openvibe-node pair` ────++ // ── 1. the owner creates the robot; the fake device pairs over HTTP like `openvibe-link pair` ────await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {const created = await t.robot(alex, { profile_id: 'adeept.adr036' });robot = created.robot;@@@ -61,7 +61,7 @@ const WHIP_BASE = 'https://whip.test/ingest';assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);// Only now does the Link report: its link holds every frame until hello.-- dev.send({ type: 'status', firmware: 'openvibe-node-0.1.0', capabilities: {}, agent_version: '0.1.0',++ dev.send({ type: 'status', firmware: 'openvibe-link-0.1.0', capabilities: {}, agent_version: '0.1.0',device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');@@@ -101,7 +101,7 @@ const WHIP_BASE = 'https://whip.test/ingest';}});-- // ── 4. a Node-shaped battery object becomes a numeric fraction on the robot state ────────────────++ // ── 4. a Link-shaped battery object becomes a numeric fraction on the robot state ────────────────await check("step 4: a Link {volts, percent} battery shows a non-null numeric battery", async () => {dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery@@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };-- /** A Network-paired machine whose fake Node advertises an ai offer and answers one ai command. */++ /** A Network-paired machine whose fake Link advertises an ai offer and answers one ai command. */async function machine() {const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual(offer.health.status, 'up');await t.app.locals.offers.publish();assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');-- assert.ok(reports.at(-1).some((o) => o.offer_id === `node:${mine.deviceId}`) === false, 'the worker report is separate');++ assert.ok(reports.at(-1).some((o) => o.offer_id === `link:${mine.deviceId}`) === false, 'the worker report is separate');const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');assert.ok(page.includes('Offering its models to the network'));@@@ -105,7 +105,7 @@ const PROFILE = {assert.ok(models.includes('From the community') && models.includes('href="/models/community.ballbot"'));const page = await (await get('/models/community.ballbot')).text();assert.ok(page.includes('Submitted by @mia') && page.includes('https://github.com/mia/ballbot-driver') && page.includes(key.key_id));-- assert.ok(page.includes(`openvibe-node profile verify community.ballbot --key-id ${key.key_id}`), 'the model page says how to check it on a machine');++ assert.ok(page.includes(`openvibe-link profile verify community.ballbot --key-id ${key.key_id}`), 'the model page says how to check it on a machine');assert.match(await (await get('/robots/new?profile=community.ballbot', sam)).text(), /value="community\.ballbot" checked/);const made = await t.call('POST', '/api/v1/robots', { user: sam, body: { name: 'My ballbot', profile_id: 'community.ballbot' } });assert.strictEqual(made.status, 201, made.text);@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');const seen = [];const DESK = (isolation, max = 1) => ({ level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: max, kinds: ['browser'], profiles: false, ...(isolation ? { isolation } : {}) });-- /** A machine of `owner` whose fake Node answers desk and browser commands like the computer plugin would. */++ /** A machine of `owner` whose fake Link answers desk and browser commands like the computer plugin would. */async function machine(owner, computer) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desks' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -54,7 +54,7 @@ const { boot, check, done } = require('./helpers/app');op.close(); dev.close();});-- await check('a local stop on the device (openvibe-node stop) is shown as one, and the owner clear cannot lift it', async () => {++ await check('a local stop on the device (openvibe-link stop) is shown as one, and the owner clear cannot lift it', async () => {const { robot, dev } = await connected();const op = await ownerJoined(robot.id);dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });@@@ -152,7 +152,7 @@ const { boot, check, done } = require('./helpers/app');op.close(); dev.close();});-- await check('a Node-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {++ await check('a Link-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {const { robot, pairing } = await t.robot(alex);const { dev } = await pairOverWs(robot.id, pairing.code);const op = await ownerJoined(robot.id);@@@ -60,8 +60,8 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');assert.match(html, /no_heartbeat/);assert.match(html, /e-stop/);// Links out to the full protocol and the contributor path.-- assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/plugins\.md/);-- assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/protocol\.md/);++ assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/plugins\.md/);++ assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/protocol\.md/);assert.match(html, /pull request/);});@@@ -1,5 +1,5 @@'use strict';--// The guided first run, measured (OpenVibe Link's `openvibe-node`): the Link sends one `node.first_run` telemetry++// The guided first run, measured (OpenVibe Link's `openvibe-link`): the Link sends one `link.first_run` telemetry// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.const assert = require('assert');@@@ -36,10 +36,10 @@ const { boot, check, done } = require('./helpers/app');const { robot, pairing } = await t.robot(alex);const { dev, paired } = await pairOverWs(robot.id, pairing.code);const at = Date.now() - 60_000;-- const event = (step, extra = {}) => ({ name: 'node.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,++ const event = (step, extra = {}) => ({ name: 'link.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });-- await check('one node.first_run event per step is stored, with the machine counts and no personal fields', async () => {++ await check('one link.first_run event per step is stored, with the machine counts and no personal fields', async () => {dev.send({ type: 'telemetry', events: [event('installed'),event('paired'),@@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');assert.ok(failed, 'the failure replaced the pass');assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },-- { name: 'node.first_run', ts: at, fields: { step: 'not_a_step' } }] });++ { name: 'link.first_run', ts: at, fields: { step: 'not_a_step' } }] });const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });assert.ok(cleaned, 'control characters are cleaned from the reason');const rows = await rowsFor(paired.device_id);@@@ -5,7 +5,7 @@* startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); POST /oauth/token mints* client-credentials tokens and records every request in tokenCalls ({client_id, audience,* scope}); node principals as-- * GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,++ * GET /internal/link-principals/:id and POST …/:id/revoke answer them (addNode, signNode,* nodes, revokes), scoped to the calling service like Network's own routes; the machine's* online state (POST /internal/machines/status) is recorded (statuses). Identity* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=@@@ -57,12 +57,21 @@ async function startNetwork() {return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user', ...extra },privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });}-- // Node principals: the view Network's principalView answers (server/registry/node-principals.js).++ // Link principals: the view Network's principalView answers (server/registry/node-principals.js).// `unscoped: true` answers a principal to any service, so Bot's own paired_for check is exercised too.const nodes = new Map();const revokes = [];++ // A principal id (lnk_…): the contracts package mints it once its 'link' kind ships; until it does, this builds++ // the same shape, so the tests do not wait for that release.++ function linkId() {++ try { return ids.newId('link'); } catch { /* the pinned contracts has no 'link' kind yet */ }++ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++ let s = '';++ for (const b of crypto.randomBytes(26)) s += alphabet[b % alphabet.length];++ return 'lnk_' + s;++ }function addNode({ owner, ref, service = 'bot', status = 'active', name = 'garage-pi', unscoped = false } = {}) {-- const id = ids.newId('node');++ const id = linkId();nodes.set(id, {unscoped,view: {@@@ -75,17 +84,17 @@ async function startNetwork() {}function signNode(principal, { aud = ['openvibe.bot'], expSec = 300 } = {}) {const now = Math.floor(Date.now() / 1000);-- return serviceAuth.signServiceToken({ iss: issuer, sub: `node:${principal}`, actor_type: 'node', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);++ return serviceAuth.signServiceToken({ iss: issuer, sub: `link:${principal}`, actor_type: 'link', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);}-- /** The calling service (svc:<id> → <id>) of a valid network.node.manage token for openvibe.network, or null. */++ /** The calling service (svc:<id> → <id>) of a valid network.link.manage token for openvibe.network, or null. */function nodeManager(req) {const h = String(req.headers.authorization || '');const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });-- if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.node.manage')) return null;++ if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.link.manage')) return null;return r.claims.sub.replace(/^svc:/, '');}-- // Node pairings (POST /internal/node-pairings): every call is kept; failPairings(status) answers that status++ // Link pairings (POST /internal/link-pairings): every call is kept; failPairings(status) answers that status// instead (null: mint again).const pairings = [];let pairingFailure = null;@@@ -152,7 +161,7 @@ async function startNetwork() {}return send(res, 200, { results });}-- if (req.url === '/internal/node-pairings' && req.method === 'POST') {++ if (req.url === '/internal/link-pairings' && req.method === 'POST') {const service = nodeManager(req);if (!service) return send(res, 401, { code: 'token.invalid' });if (pairingFailure) return send(res, pairingFailure, { code: 'registry.failed' });@@@ -171,7 +180,7 @@ async function startNetwork() {statuses.push({ service, body });return send(res, 200, { ok: true });}-- const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);++ const np = /^\/internal\/link-principals\/([^/]+)(\/revoke)?$/.exec(req.url);if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {const service = nodeManager(req);if (!service) return send(res, 401, { code: 'token.invalid' });@@@ -8,13 +8,13 @@ const { loadConfig } = require('../server/config');const { driverForProfile } = require('../server/domain');(async () => {-- const NODE_INSTALLER = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';++ const NODE_INSTALLER = 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh';-- await check('BOT_INSTALLER_SOURCE_URL: Node\'s script by default, https on an allow-listed host or no boot', async () => {++ await check('BOT_INSTALLER_SOURCE_URL: Link\'s script by default, https on an allow-listed host or no boot', async () => {assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);-- const release = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';++ const release = 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh';assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);-- for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh', 'https://evil.test/install.sh',++ for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh', 'https://evil.test/install.sh','https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);}@@@ -30,7 +30,7 @@ const { driverForProfile } = require('../server/domain');for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));});-- const source = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v1.2.3/install.sh';++ const source = 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v1.2.3/install.sh';const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });const alex = t.network.newUser('alex');@@@ -45,13 +45,13 @@ const { driverForProfile } = require('../server/domain');await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {const { robot, pairing } = await t.robot(alex, { profile_id });-- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });assert.strictEqual(again.status, 201, again.text);assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));}const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });-- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);});await t.close();@@@ -60,7 +60,7 @@ const { driverForProfile } = require('../server/domain');const sam = n.network.newUser('sam');const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });const asked = n.network.pairings.at(-1);-- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });assert.strictEqual(again.status, 201, again.text);assert.match(again.json.installer, / --code \S+ --driver cozmo$/);nnew file mode 100644@@@ -0,0 +1,10 @@++'use strict';++const assert = require('node:assert/strict');++const { spawnSync } = require('node:child_process');++const path = require('node:path');++const root = path.resolve(__dirname, '..');++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);++console.log(' ok old Link brand and principal IDs are absent');nnew file mode 100644@@@ -0,0 +1,230 @@++'use strict';++// Network-paired machines (T2 §9.2 B1, dual-accept): a Link token binds a device from Network's record,++// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly++// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke++// and rotate reach Network and the machine. Credential devices keep today's behaviour.++const assert = require('assert');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/app');++++const WHIP_BASE = 'https://whip.test/ingest';++const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;++++(async () => {++ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE, BOT_EVENTS_SECRET: EVENTS_SECRET } });++ const alex = t.network.newUser('alex');++ const sam = t.network.newUser('sam');++++ const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });++ const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);++ const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });++ /** A robot of `owner` and a machine Network paired for it. */++ async function paired(owner = alex, node = {}) {++ const { robot } = await t.robot(owner);++ return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };++ }++++ await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {++ const { robot, principal } = await paired();++ const r = await bind(t.network.signNode(principal));++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.headers.get('cache-control'), 'no-store');++ assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);++ assert.match(r.json.device_id, /^dev_/);++ assert.strictEqual(r.json.robot_id, robot.id);++ assert.strictEqual(r.json.profile.id, 'sim.rover');++ assert.ok(r.json.publish_key.length >= 40);++ assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);++ const d = await deviceRow(r.json.device_id);++ assert.deepStrictEqual(d.robot_ids, [robot.id]);++ assert.strictEqual(d.name, 'garage-pi');++ assert.strictEqual(d.kind, 'onboard');++ assert.deepStrictEqual(d.drivers, []);++ assert.deepStrictEqual(d.capabilities, {});++ assert.strictEqual(d.agent_version, null);++ assert.strictEqual(d.credential_hash, null);++ assert.strictEqual(d.node_principal, principal);++ assert.strictEqual(d.publish_key_hint, r.json.publish_key.slice(-4), 'only the hint of the OpenRestream key is kept');++ assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no key of its own');++ assert.ok(t.openre.admits(r.json.publish_key), 'the publish key is one OpenRestream admits');++ });++++ await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {++ const { principal } = await paired();++ const first = await bind(t.network.signNode(principal));++ const second = await bind(t.network.signNode(principal));++ assert.strictEqual(second.status, 201, second.text);++ assert.strictEqual(second.json.device_id, first.json.device_id);++ assert.notStrictEqual(second.json.publish_key, first.json.publish_key);++ const d = await deviceRow(first.json.device_id);++ assert.strictEqual(d.publish_key_hint, second.json.publish_key.slice(-4));++ assert.ok(t.openre.admits(second.json.publish_key), 'the new key is admitted');++ assert.ok(!t.openre.admits(first.json.publish_key), 'the old key stops working');++ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);++ });++++ await check('bind refuses anything but a Link token for openvibe.bot', async () => {++ const { principal } = await paired();++ const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));++ assert.strictEqual(network.status, 401, network.text);++ const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });++ assert.strictEqual(user.status, 401, user.text);++ assert.strictEqual(user.json.code, 'bot.link_token_required');++ const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });++ assert.strictEqual(service.status, 401, service.text);++ });++++ await check('an upgrade with a Link token and no prior bind gets hello then config first, and no paired frame', async () => {++ const { robot, principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const config = await c.waitFor((m) => m.type === 'config');++ assert.ok(config);++ assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);++ assert.ok(!c.messages.some((m) => m.type === 'paired'));++ const hello = c.messages[0];++ assert.deepStrictEqual(hello.robot_ids, [robot.id]);++ const d = await deviceRow(hello.device_id);++ assert.strictEqual(d.node_principal, principal);++ assert.strictEqual(d.publish_key_hint, null, 'no publish key until the machine calls bind');++ assert.ok(t.hub.isOnline(d.id));++ // A later bind finds the same device.++ const b = await bind(t.network.signNode(principal));++ assert.strictEqual(b.json.device_id, d.id);++ c.close();++ });++++ await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });++ let d;++ for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }++ assert.strictEqual(d.kind, 'bridge');++ assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);++ assert.deepStrictEqual(d.capabilities, { camera: true });++ assert.strictEqual(d.agent_version, '0.3.1');++ for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { drivers: [{ state: 'up' }] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {++ const before = c.messages.filter((m) => m.type === 'error').length;++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });++ await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);++ const err = c.messages.filter((m) => m.type === 'error').pop();++ assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));++ }++ const after = await deviceRow(hello.device_id);++ assert.strictEqual(after.kind, 'bridge');++ assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);++ assert.strictEqual(after.agent_version, '0.3.1');++ // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.++ const errors = c.messages.filter((m) => m.type === 'error').length;++ c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });++ let named;++ for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }++ assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);++ assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');++ c.close();++ });++++ await check('a credential device\'s status stays memory-only', async () => {++ const { robot, pairing } = await t.robot(alex);++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });++ const c = await connect(p.json.credential);++ await c.waitFor((m) => m.type === 'config');++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });++ const err = await c.waitFor((m) => m.type === 'error');++ assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');++ const d = await deviceRow(p.json.device_id);++ assert.strictEqual(d.kind, 'onboard');++ assert.deepStrictEqual(d.drivers, ['a']);++ c.close();++ });++++ await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {++ const { robot: samsRobot } = await t.robot(sam);++ const cases = {++ 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),++ revoked: (await paired(alex, { status: 'revoked' })).principal,++ 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,++ 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,++ 'unknown on Network': 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAV',++ };++ for (const [name, principal] of Object.entries(cases)) {++ const r = await bind(t.network.signNode(principal));++ assert.strictEqual(r.status, 403, `${name}: ${r.text}`);++ assert.strictEqual(r.json.code, 'bot.link_not_bound', name);++ const c = await connect(t.network.signNode(principal));++ assert.strictEqual(await c.waitForClose(), 4002, name);++ assert.ok(!c.messages.some((m) => m.type === 'hello'), name);++ assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);++ }++ });++++ await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {++ const { principal } = await paired();++ const other = (await paired()).principal;++ const c = await connect(t.network.signNode(principal));++ await c.waitFor((m) => m.type === 'config');++ t.clock.offset += 200 * 1000;++ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });++ assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');++ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });++ await t.wait(100);++ t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth++ await t.wait(400);++ assert.ok(!c.ended, 'a valid reauth moved the deadline');++ t.clock.offset += 131 * 1000; // 331 s after the reauth++ assert.strictEqual(await c.waitForClose(), 4002);++ t.clock.offset = 0;++ });++++ await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(t.network.revokes.includes(principal));++ assert.strictEqual(await c.waitForClose(), 4003);++ const again = await bind(t.network.signNode(principal));++ assert.strictEqual(again.status, 403);++ assert.strictEqual(again.json.code, 'bot.link_not_bound');++ });++++ await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json.sent, true);++ assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);++ assert.ok(await c.waitFor((m) => m.type === 'rotate'));++ assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);++ c.close();++ });++++ await check('network.link.revoked revokes the machine\'s devices and closes its live socket at once', async () => {++ const { principal } = await paired();++ const c = await connect(t.network.signNode(principal));++ const hello = await c.waitFor((m) => m.type === 'hello');++ assert.ok(t.hub.isOnline(hello.device_id));++ const event = {++ event_id: 'evt_01JZ0000000000000000000N01', event_type: 'network.link.revoked', version: 1, source: 'network',++ actor: { type: 'user', id: alex.subject }, timestamp: new Date().toISOString(), visibility: 'internal',++ subject: { type: 'link', id: principal },++ payload: { node_id: `n-${'a'.repeat(26)}`, principal_id: principal, owner: { kind: 'user', subject: alex.subject }, reason: 'owner', at: new Date().toISOString() },++ };++ const body = JSON.stringify({ event }); // Events delivers { cursor?, event }++ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });++ assert.strictEqual(res.status < 300, true, `${res.status} ${await res.text()}`);++ assert.strictEqual(await c.waitForClose(), 4003, 'the socket closes at once, not at the reauth deadline');++ assert.ok((await deviceRow(hello.device_id)).revoked_at, 'the device is revoked');++ assert.ok(!t.hub.isOnline(hello.device_id));++ const again = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });++ assert.strictEqual(again.status < 300, true, 'a redelivery is harmless');++ });++++ await t.close();++ done();++})();@@@ -17,7 +17,7 @@ const { boot, check, done } = require('./helpers/app');const COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true }, input: { level: 'trusted', tool: 'xdotool', scroll: true }, browser: { level: 'trusted', browser: 'chromium', headless: true } };const seen = [];-- /** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */++ /** A Network-paired machine of `owner` whose fake Link answers computer commands like the plugin would. */async function machine(owner, computer = COMPUTER) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -40,7 +40,7 @@ const { boot, check, done } = require('./helpers/app');else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));});-- // Beat like a real Node while the socket is open, so a longer check never sees its machine go offline.++ // Beat like a real Link while the socket is open, so a longer check never sees its machine go offline.const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));c.ws.on('close', () => clearInterval(beat));await poll(() => t.hub.computerOf(deviceId) !== null || !computer);@@@ -1,7 +1,7 @@'use strict';// The fleet (Codes C9c): a machine's OpenVibe Link reports what its coding agents have left with each heartbeat// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and--// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine++// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, lnk_…), which is the same machine// Run pins a job to by its device id (dev_…): one row, both names.//// What this checks: a reading is stored and answered; it is bounded exactly as the Link bounds it (16 agents, 10 windows,@@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');const list = (v) => (typeof v === 'string' ? JSON.parse(v) : v);const machines = async (user) => { const r = await t.call('GET', '/api/v1/machines', { user }); assert.strictEqual(r.status, 200, r.text); return r.json.machines; };-- /** A Network-paired machine of `owner`, beating like a real Node (its own timer, no fleet) until closed. */++ /** A Network-paired machine of `owner`, beating like a real Link (its own timer, no fleet) until closed. */async function machine(owner) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };const seen = [];-- /** A machine of `owner` whose fake Node answers screen_stream like Link v0.12.0. */++ /** A machine of `owner` whose fake Link answers screen_stream like Link v0.12.0. */async function machine(owner, computer = SCREEN) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -36,7 +36,7 @@ const { boot, check, done } = require('./helpers/app');else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));});-- // Heartbeats keep it online, as a real Node's do.++ // Heartbeats keep it online, as a real Link's do.const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));c.ws.on('close', () => clearInterval(beat));await poll(() => t.hub.computerOf(deviceId) !== null);@@@ -1,6 +1,6 @@'use strict';// BOT_PAIRING_AUTHORITY (plan T15 B2). bot (the default): Bot mints and stores the pairing code, as before.--// network: POST /robots and POST /robots/:id/pairing-code ask Network (POST /internal/node-pairings) for the++// network: POST /robots and POST /robots/:id/pairing-code ask Network (POST /internal/link-pairings) for the// code and Bot stores none; POST /pair and the `pair` frame answer 410 bot.pairing_moved; Network not// answering is 503 bot.network_unavailable and creates no robot. An unknown value refuses to boot.const assert = require('assert');@@@ -44,12 +44,12 @@ const { boot, check, done } = require('./helpers/app');await check('network: POST /robots asks Network for the code and stores none', async () => {const { robot, pairing } = await t.robot(alex);const asked = t.network.pairings.at(-1);-- assert.strictEqual(asked.service, 'bot', "Bot's own svc:bot token with network.node.manage");++ assert.strictEqual(asked.service, 'bot', "Bot's own svc:bot token with network.link.manage");assert.deepStrictEqual(asked.body, { owner: { kind: 'user', subject: alex.subject }, ref: robot.id });assert.strictEqual(pairing.code, asked.code);assert.strictEqual(pairing.pairing_id, asked.pairing_id);assert.strictEqual(pairing.expires_at, asked.expires_at);-- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${t.network.url} --pairing ${asked.pairing_id} --code ${asked.code}`);++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --network ${t.network.url} --pairing ${asked.pairing_id} --code ${asked.code}`);assert.strictEqual(await codesFor(t, robot.id), 0);});ddeleted file mode 100644@@@ -1,230 +0,0 @@--'use strict';--// Network-paired machines (T2 §9.2 B1, dual-accept): a node token binds a device from Network's record,--// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly--// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke--// and rotate reach Network and the machine. Credential devices keep today's behaviour.--const assert = require('assert');--const { signDeliveryHeaders } = require('openvibe-sdk/events');--const { boot, check, done } = require('./helpers/app');----const WHIP_BASE = 'https://whip.test/ingest';--const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;----(async () => {-- const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE, BOT_EVENTS_SECRET: EVENTS_SECRET } });-- const alex = t.network.newUser('alex');-- const sam = t.network.newUser('sam');---- const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });-- const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);-- const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });-- /** A robot of `owner` and a machine Network paired for it. */-- async function paired(owner = alex, node = {}) {-- const { robot } = await t.robot(owner);-- return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };-- }---- await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {-- const { robot, principal } = await paired();-- const r = await bind(t.network.signNode(principal));-- assert.strictEqual(r.status, 201, r.text);-- assert.strictEqual(r.headers.get('cache-control'), 'no-store');-- assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);-- assert.match(r.json.device_id, /^dev_/);-- assert.strictEqual(r.json.robot_id, robot.id);-- assert.strictEqual(r.json.profile.id, 'sim.rover');-- assert.ok(r.json.publish_key.length >= 40);-- assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);-- const d = await deviceRow(r.json.device_id);-- assert.deepStrictEqual(d.robot_ids, [robot.id]);-- assert.strictEqual(d.name, 'garage-pi');-- assert.strictEqual(d.kind, 'onboard');-- assert.deepStrictEqual(d.drivers, []);-- assert.deepStrictEqual(d.capabilities, {});-- assert.strictEqual(d.agent_version, null);-- assert.strictEqual(d.credential_hash, null);-- assert.strictEqual(d.node_principal, principal);-- assert.strictEqual(d.publish_key_hint, r.json.publish_key.slice(-4), 'only the hint of the OpenRestream key is kept');-- assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no key of its own');-- assert.ok(t.openre.admits(r.json.publish_key), 'the publish key is one OpenRestream admits');-- });---- await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {-- const { principal } = await paired();-- const first = await bind(t.network.signNode(principal));-- const second = await bind(t.network.signNode(principal));-- assert.strictEqual(second.status, 201, second.text);-- assert.strictEqual(second.json.device_id, first.json.device_id);-- assert.notStrictEqual(second.json.publish_key, first.json.publish_key);-- const d = await deviceRow(first.json.device_id);-- assert.strictEqual(d.publish_key_hint, second.json.publish_key.slice(-4));-- assert.ok(t.openre.admits(second.json.publish_key), 'the new key is admitted');-- assert.ok(!t.openre.admits(first.json.publish_key), 'the old key stops working');-- assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);-- });---- await check('bind refuses anything but a node token for openvibe.bot', async () => {-- const { principal } = await paired();-- const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));-- assert.strictEqual(network.status, 401, network.text);-- const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });-- assert.strictEqual(user.status, 401, user.text);-- assert.strictEqual(user.json.code, 'bot.node_token_required');-- const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });-- assert.strictEqual(service.status, 401, service.text);-- });---- await check('an upgrade with a node token and no prior bind gets hello then config first, and no paired frame', async () => {-- const { robot, principal } = await paired();-- const c = await connect(t.network.signNode(principal));-- const config = await c.waitFor((m) => m.type === 'config');-- assert.ok(config);-- assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);-- assert.ok(!c.messages.some((m) => m.type === 'paired'));-- const hello = c.messages[0];-- assert.deepStrictEqual(hello.robot_ids, [robot.id]);-- const d = await deviceRow(hello.device_id);-- assert.strictEqual(d.node_principal, principal);-- assert.strictEqual(d.publish_key_hint, null, 'no publish key until the machine calls bind');-- assert.ok(t.hub.isOnline(d.id));-- // A later bind finds the same device.-- const b = await bind(t.network.signNode(principal));-- assert.strictEqual(b.json.device_id, d.id);-- c.close();-- });---- await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {-- const { principal } = await paired();-- const c = await connect(t.network.signNode(principal));-- const hello = await c.waitFor((m) => m.type === 'hello');-- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });-- let d;-- for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }-- assert.strictEqual(d.kind, 'bridge');-- assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);-- assert.deepStrictEqual(d.capabilities, { camera: true });-- assert.strictEqual(d.agent_version, '0.3.1');-- for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { drivers: [{ state: 'up' }] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {-- const before = c.messages.filter((m) => m.type === 'error').length;-- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });-- await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);-- const err = c.messages.filter((m) => m.type === 'error').pop();-- assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));-- }-- const after = await deviceRow(hello.device_id);-- assert.strictEqual(after.kind, 'bridge');-- assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);-- assert.strictEqual(after.agent_version, '0.3.1');-- // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.-- const errors = c.messages.filter((m) => m.type === 'error').length;-- c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });-- let named;-- for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }-- assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);-- assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');-- c.close();-- });---- await check('a credential device\'s status stays memory-only', async () => {-- const { robot, pairing } = await t.robot(alex);-- const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });-- const c = await connect(p.json.credential);-- await c.waitFor((m) => m.type === 'config');-- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });-- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });-- const err = await c.waitFor((m) => m.type === 'error');-- assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');-- const d = await deviceRow(p.json.device_id);-- assert.strictEqual(d.kind, 'onboard');-- assert.deepStrictEqual(d.drivers, ['a']);-- c.close();-- });---- await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {-- const { robot: samsRobot } = await t.robot(sam);-- const cases = {-- 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),-- revoked: (await paired(alex, { status: 'revoked' })).principal,-- 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,-- 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,-- 'unknown on Network': 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV',-- };-- for (const [name, principal] of Object.entries(cases)) {-- const r = await bind(t.network.signNode(principal));-- assert.strictEqual(r.status, 403, `${name}: ${r.text}`);-- assert.strictEqual(r.json.code, 'bot.node_not_bound', name);-- const c = await connect(t.network.signNode(principal));-- assert.strictEqual(await c.waitForClose(), 4002, name);-- assert.ok(!c.messages.some((m) => m.type === 'hello'), name);-- assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);-- }-- });---- await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {-- const { principal } = await paired();-- const other = (await paired()).principal;-- const c = await connect(t.network.signNode(principal));-- await c.waitFor((m) => m.type === 'config');-- t.clock.offset += 200 * 1000;-- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });-- assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');-- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });-- await t.wait(100);-- t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth-- await t.wait(400);-- assert.ok(!c.ended, 'a valid reauth moved the deadline');-- t.clock.offset += 131 * 1000; // 331 s after the reauth-- assert.strictEqual(await c.waitForClose(), 4002);-- t.clock.offset = 0;-- });---- await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {-- const { principal } = await paired();-- const c = await connect(t.network.signNode(principal));-- const hello = await c.waitFor((m) => m.type === 'hello');-- const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });-- assert.strictEqual(r.status, 200, r.text);-- assert.ok(t.network.revokes.includes(principal));-- assert.strictEqual(await c.waitForClose(), 4003);-- const again = await bind(t.network.signNode(principal));-- assert.strictEqual(again.status, 403);-- assert.strictEqual(again.json.code, 'bot.node_not_bound');-- });---- await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {-- const { principal } = await paired();-- const c = await connect(t.network.signNode(principal));-- const hello = await c.waitFor((m) => m.type === 'hello');-- const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });-- assert.strictEqual(r.status, 200, r.text);-- assert.strictEqual(r.json.sent, true);-- assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);-- assert.ok(await c.waitFor((m) => m.type === 'rotate'));-- assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);-- c.close();-- });---- await check('network.node.revoked revokes the machine\'s devices and closes its live socket at once', async () => {-- const { principal } = await paired();-- const c = await connect(t.network.signNode(principal));-- const hello = await c.waitFor((m) => m.type === 'hello');-- assert.ok(t.hub.isOnline(hello.device_id));-- const event = {-- event_id: 'evt_01JZ0000000000000000000N01', event_type: 'network.node.revoked', version: 1, source: 'network',-- actor: { type: 'user', id: alex.subject }, timestamp: new Date().toISOString(), visibility: 'internal',-- subject: { type: 'node', id: principal },-- payload: { node_id: `n-${'a'.repeat(26)}`, principal_id: principal, owner: { kind: 'user', subject: alex.subject }, reason: 'owner', at: new Date().toISOString() },-- };-- const body = JSON.stringify({ event }); // Events delivers { cursor?, event }-- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });-- assert.strictEqual(res.status < 300, true, `${res.status} ${await res.text()}`);-- assert.strictEqual(await c.waitForClose(), 4003, 'the socket closes at once, not at the reauth deadline');-- assert.ok((await deviceRow(hello.device_id)).revoked_at, 'the device is revoked');-- assert.ok(!t.hub.isOnline(hello.device_id));-- const again = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });-- assert.strictEqual(again.status < 300, true, 'a redelivery is harmless');-- });---- await t.close();-- done();--})();@@@ -43,7 +43,7 @@ const { renderPhoneLinkPage } = require('../server/web/render');const guest = await t.call('GET', '/link/phone');assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };-- const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v0.46.0/openvibe-link.apk';++ const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v0.46.0/openvibe-link.apk';const html = renderPhoneLinkPage({ pairing, apkUrl });assert.match(html, /ABCD-1234/);assert.match(html, /qr-box/);@@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');// Its page on openvibe.bot (links.console) when Bot's address is https, for Services to link to.const { machineSummary, robotSummary } = require('../server/api/resource-index');const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };-- assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.bot/machines' });++ assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.link/machines' });assert.deepStrictEqual(robotSummary({ ...row, id: first.id, estop_latched: false }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);@@@ -42,7 +42,7 @@ const PROJECT = `prj_${ids.ulid()}`;await n.connect();n.c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [n.deviceId]); return d && d.capabilities.worker; });-- /** A frame from the Node, then the Bot frame that answers it (or the settle of a no-answer frame). */++ /** A frame from the Link, then the Bot frame that answers it (or the settle of a no-answer frame). */n.say = (type, fields) => n.c.send(frame(type, fields));n.exit = async (id, fields) => {const before = n.c.messages.filter((m) => m.type === 'job_exit_ack' && m.id === id).length;@@@ -131,7 +131,7 @@ const PROJECT = `prj_${ids.ulid()}`;assert.deepStrictEqual({ ...r, idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined }, {idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined,service: 'run', operation: 'function.invoke', unit: 's', resource: job.id, node: n.deviceId,-- source: 'openvibe-node.worker', project: PROJECT, subject: SUBJECT,++ source: 'openvibe-link.worker', project: PROJECT, subject: SUBJECT,});assert.strictEqual(r.id, r.idempotency_key);}@@@ -118,7 +118,7 @@ const card = require('../public/sd-card');setupKey: made.key, server: 'https://openvibe.bot', robotName: 'Mecanum rover' });assert.ok(ud.startsWith('#cloud-config\n'), 'cloud-init reads only a file that starts with #cloud-config');assert.ok(ud.includes(` content: "${made.key}"`) && ud.includes('/etc/openvibe/setup-key') && ud.includes('permissions: "0600"'));-- assert.ok(ud.includes('curl -fsSL https://openvibe.bot/install | sh -s -- --setup "$key"'));++ assert.ok(ud.includes('curl -fsSL https://openvibe.link/install | sh -s -- --setup "$key"'));assert.ok(ud.includes(' - [ systemctl, enable, --now, ssh ]') && ud.includes('systemd-run, --unit=openvibe-setup'));assert.ok(/\nruncmd:\n/.test(ud) && ud.split('\nruncmd:').length === 2, 'one runcmd list (a second would replace the first)');assert.throws(() => card.userData({ hostname: 'x', username: 'y', passwordHash: 'z', setupKey: 'nope' }), /setup key/);@@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } };const seen = [];-- /** A Network-paired machine of `owner` whose fake Node answers screen and input like the plugin would. */++ /** A Network-paired machine of `owner` whose fake Link answers screen and input like the plugin would. */async function machine(owner, computer = COMPUTER) {const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });@@@ -33,7 +33,7 @@ const { boot, check, done } = require('./helpers/app');c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });}-- // Beat like a real Node while the socket is open: the later checks take longer than two missed heartbeats.++ // Beat like a real Link while the socket is open: the later checks take longer than two missed heartbeats.const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);c.ws.on('close', () => clearInterval(beat));return { principal, c, deviceId };@@@ -49,7 +49,7 @@ const { boot, check, done } = require('./helpers/app');const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });assert.strictEqual(bound.status, 403, bound.text);-- assert.strictEqual(bound.json.code, 'bot.node_not_bound');++ assert.strictEqual(bound.json.code, 'bot.link_not_bound');});await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {
@@ -69,12 +69,12 @@ OV_OAUTH_CLIENT_SECRET=
@@ -69,12 +69,12 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_LIMITS_HOUR=3000
#BOT_LIMITS_HOUR=3000
#BOT_JOBS=on
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_JOBS_INTERVAL_MS=5000
-#BOT_INSTALLER_URL=https://openvibe.bot/install
+#BOT_INSTALLER_URL=https://openvibe.link/install
# Where GET /install redirects (302): OpenVibe Link's installer script. Defaults to the checksummed asset on
# Where GET /install redirects (302): OpenVibe Link's installer script. Defaults to the checksummed asset on
# OpenVibe Link's latest release (install.sh, pinned there by its CI cmp guard so it never drifts with main).
# OpenVibe Link's latest release (install.sh, pinned there by its CI cmp guard so it never drifts with main).
# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything
# https on raw.githubusercontent.com, github.com, objects.githubusercontent.com or openvibe.bot only; anything
# else refuses to boot.
# else refuses to boot.
-#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh
+#BOT_INSTALLER_SOURCE_URL=https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
# In production this is OpenRestream's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one
# In production this is OpenRestream's WHIP ingest, https://ingest.openre.stream/whip (O30); the key must be one
# OpenRestream issued (BOT_OPENRE_* below), or the ingest refuses the device.
# OpenRestream issued (BOT_OPENRE_* below), or the ingest refuses the device.
@@ -122,4 +122,4 @@ OV_OAUTH_CLIENT_SECRET=
@@ -122,4 +122,4 @@ OV_OAUTH_CLIENT_SECRET=
# OpenVibe Link for Android: the published download (the signed release APK), https on GitHub releases or openvibe.link.
# OpenVibe Link for Android: the published download (the signed release APK), https on GitHub releases or openvibe.link.
# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code, and
# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code, and
# /machines does not link it.
# /machines does not link it.
-# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/download/vX.Y.Z/openvibe-link.apk
+# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Link/releases/download/vX.Y.Z/openvibe-link.apk
@@ -42,8 +42,8 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -42,8 +42,8 @@ Sitemap entries use the release commit time for these code-backed pages.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe Link `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe Link `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe Link `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe Link `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe Link `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe Link `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
-- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
-- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.network/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.
+- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.link/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
+- **The machines hub** (plan L34, step M2; contracts 0.261.0). Bot stays the registry of the machines; OpenVibe.Network fronts it on `openvibe.link/machines` for the person and for the apps the person consented to. The hub's routes are `GET /api/v1/machines`, `GET /api/v1/machines/:id`, `POST /api/v1/machines/pairings` (a one-time code and the command to run), `PATCH /api/v1/machines/:id` (a new name), `PUT /api/v1/machines/:id/roles` and `POST /api/v1/machines/:id/revoke`, guarded by `bot.machine.manage`: the network's own service (`svc:network`, and only that one — the capability alone would let any service name any person) acts for the person it names in `X-OV-Subject`, and the machine must be that person's own or it is not there at all (404, never 403, so nobody can probe whose machine an id is). The roles (`devices.roles`, migration 0028) come from the catalogue in Contracts (`codes.worker`, `desks.host`, `ai.host`, `bot.robot`); a role outside the catalogue is refused (`422 bot.unknown_role`) and nothing is stored. The roles reach the machine in its `config` frame, so the Link turns them on at its next check-in. Each machine's online state is reported to Network (`POST /internal/machines/status`) once per change, and Network publishes it as `network.machine.online` and `network.machine.offline`.
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
@@ -53,7 +53,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -53,7 +53,7 @@ Sitemap entries use the release commit time for these code-backed pages.
- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
- A full machine answers `409 bot.machine_busy`; anyone else gets 404.
- A full machine answers `409 bot.machine_busy`; anyone else gets 404.
- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
-- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
+- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-link approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
@@ -203,7 +203,7 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -203,7 +203,7 @@ A person acts on their own robots with a Network user token; a service acts with
## Deploy
## Deploy
-Production runs the Node process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
+Production runs the Link process under systemd as `openvibe-bot.service` (unit reference: `deploy/systemd/openvibe-bot.service`), with `WorkingDirectory=/opt/openvibe.bot`, `ExecStart` running `server/index.js`, and secrets/config read from the environment file `/etc/openvibe/bot.env`. The app listens on port **4630** behind nginx (`deploy/nginx/openvibe.bot.conf`), which terminates TLS and upgrades the `/device`, `/control` and `/watch` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
To run the schema migrations before the service serves, start it once with the direct owner connection in `/etc/openvibe/bot.env` (`DATABASE_DIRECT_URL`); the boot applies `migrations/` and then serves on the pooled `DATABASE_URL`.
@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URL
@@ -225,7 +225,7 @@ Set `BOT_BILLING_URL` in `/etc/openvibe/bot.env` to OpenVibe.Billing's base URL
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot may connect to: a map from a robot id (each key names exactly one robot; there is no wildcard) to `{ "url": "http://<host>/onvif/ptz_service", "username_ref": "CAM_USER", "password_ref": "CAM_PASSWORD" }`. The refs are the *names* of the secrets (env vars, or keys a deployment resolves through a secret store), read per request; the URL must be http(s), on one host and carry no credentials, and `BOT_ONVIF_TIMEOUT_MS` (default 5000) bounds each request. Anything else — including a key that is not a robot id — refuses to boot. A robot whose profile is `camera.onvif` and that has a configured camera attaches server-side at boot and when its panel is opened.
-`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
+`/install` is proxied to the app, which answers a 302 to OpenVibe Link's installer script (`BOT_INSTALLER_SOURCE_URL`, by default the `install.sh` asset on OpenVibe Link's [latest release](https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/) — a released, checksummed installer that never drifts with the repo's `main`, whose CI keeps `dist/install.sh` byte-identical to `install/install.sh`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
## Deploy files
## Deploy files
@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -235,7 +235,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.338.0
+- openvibe-contracts: v0.373.0
- openvibe-sdk: v0.123.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.338.0",
+ "contracts": "openvibe-contracts v0.373.0",
"sdk": "openvibe-sdk v0.123.0",
"sdk": "openvibe-sdk v0.123.0",
"shared": "openvibe-shared v4.173.0",
"shared": "openvibe-shared v4.173.0",
"packages": [
"packages": [
- "openvibe-contracts v0.338.0",
+ "openvibe-contracts v0.373.0",
"openvibe-sdk v0.123.0",
"openvibe-sdk v0.123.0",
"openvibe-shared v4.173.0"
"openvibe-shared v4.173.0"
],
],
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
## openvibe.bot — Nginx configuration (REFERENCE)
## openvibe.bot — Nginx configuration (REFERENCE)
##
##
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
## Deployed to /etc/nginx/sites-available/openvibe.bot.conf — keep them in sync and reload nginx
-## after changing it. The Node app (port 4630, systemd unit openvibe-bot) serves the REST API and
+## after changing it. The Link app (port 4630, systemd unit openvibe-bot) serves the REST API and
## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,
## terminates the device (`/device`), operator (`/control`) and watch (`/watch`) WebSockets; nginx terminates TLS,
## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in, the API and the pages,
## redirects www → apex, upgrades the three WebSocket routes, rate-limits sign-in, the API and the pages,
## proxies the front page, /shared/, /robots.txt and /release.json to the app, and the app itself serves
## proxies the front page, /shared/, /robots.txt and /release.json to the app, and the app itself serves
@@ -10,8 +10,8 @@ PR #18 changes the default target of the `GET /install` redirect so it points at
@@ -10,8 +10,8 @@ PR #18 changes the default target of the `GET /install` redirect so it points at
installer asset instead of a path on OpenVibe Link's `main` branch:
installer asset instead of a path on OpenVibe Link's `main` branch:
- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes
- `server/config.js` — the `BOT_INSTALLER_SOURCE_URL` fallback becomes
- `https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh` (was
- `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh`).
+ `https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh` (was
+ `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh`).
- `test/install.test.js` — asserts the new default, keeps the allow-list checks, and still proves no query
- `test/install.test.js` — asserts the new default, keeps the allow-list checks, and still proves no query
parameter can change the redirect target.
parameter can change the redirect target.
- `.env.example` and `README.md` — document the new default and why a released asset is used.
- `.env.example` and `README.md` — document the new default and why a released asset is used.
@@ -66,9 +66,9 @@ adds it from this file at the verified head; it resolves to:
@@ -66,9 +66,9 @@ adds it from this file at the verified head; it resolves to:
1. `openvibe-bot.service` is active after the restart (no crash loop).
1. `openvibe-bot.service` is active after the restart (no crash loop).
2. `GET http://127.0.0.1:4630/api/health` answers 2xx (the process, online devices and outbox status).
2. `GET http://127.0.0.1:4630/api/health` answers 2xx (the process, online devices and outbox status).
3. `GET http://127.0.0.1:4630/api/ready` answers 2xx: database, Valkey, Network readiness and the outbox relay.
3. `GET http://127.0.0.1:4630/api/ready` answers 2xx: database, Valkey, Network readiness and the outbox relay.
-4. `GET https://openvibe.bot/install` **without following redirects** answers HTTP 302 and `Location` equal to
+4. `GET https://openvibe.link/install` **without following redirects** answers HTTP 302 and `Location` equal to
the configured `BOT_INSTALLER_SOURCE_URL`; with `BOT_INSTALLER_SOURCE_URL` unset the default is
the configured `BOT_INSTALLER_SOURCE_URL`; with `BOT_INSTALLER_SOURCE_URL` unset the default is
- `https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh`. Repeat with
+ `https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh`. Repeat with
`?url=https://evil.test/x` and `?to=https://evil.test/x`: both must still answer the same 302 target.
`?url=https://evil.test/x` and `?to=https://evil.test/x`: both must still answer the same 302 target.
5. The installer command a pairing returns ends in `--driver adeept` for `adeept.adr036`, `--driver adeept-mecanum`
5. The installer command a pairing returns ends in `--driver adeept` for `adeept.adr036`, `--driver adeept-mecanum`
for `adeept.adr036.mecanum` and `--driver cozmo` for `cozmo`, and has no `--driver` for `sim.rover`.
for `adeept.adr036.mecanum` and `--driver cozmo` for `cozmo`, and has no `--driver` for `sim.rover`.
@@ -83,7 +83,7 @@ If any of 1–4 fails, the deploy is not good: roll back (below) and investigate
@@ -83,7 +83,7 @@ If any of 1–4 fails, the deploy is not good: roll back (below) and investigate
sha. Nothing in this PR needs a data rollback, so the previous release serves immediately.
sha. Nothing in this PR needs a data rollback, so the previous release serves immediately.
- **Manual:** `ovhost rollback bot --to <previous sha>` on `openvibe-ovh`, then re-run the verification above.
- **Manual:** `ovhost rollback bot --to <previous sha>` on `openvibe-ovh`, then re-run the verification above.
- **Configuration:** the only knob is `BOT_INSTALLER_SOURCE_URL`. Unset it to fall back to the new default, or
- **Configuration:** the only knob is `BOT_INSTALLER_SOURCE_URL`. Unset it to fall back to the new default, or
- set it back to `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh` to return
+ set it back to `https://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh` to return
to the previous target without a code change; both hosts are on the allow-list, so the service restarts clean.
to the previous target without a code change; both hosts are on the allow-list, so the service restarts clean.
- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). Restore from the
- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). Restore from the
pgBackRest backup taken in the backup step **only** if an unrelated data incident requires it, coordinating
pgBackRest backup taken in the backup step **only** if an unrelated data incident requires it, coordinating
@@ -83,7 +83,7 @@ adds it from this file at the verified head; it resolves to:
@@ -83,7 +83,7 @@ adds it from this file at the verified head; it resolves to:
- `POST /api/v1/jobs` with no token → `401 bot.sign_in`; with a person's token → `403 bot.forbidden`; with a
- `POST /api/v1/jobs` with no token → `401 bot.sign_in`; with a person's token → `403 bot.forbidden`; with a
node token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;
node token → `403 bot.forbidden`; with a service token lacking the capability → `403 capability.denied`;
without `project_id` → `422 bot.invalid_input`.
without `project_id` → `422 bot.invalid_input`.
- - A valid dispatch of a job whose class the target Node advertises → `201`, `sent: true`, a `run_jobs` row in
+ - A valid dispatch of a job whose class the target Link advertises → `201`, `sent: true`, a `run_jobs` row in
state `queued`, and the Link receives one `job` frame; the same body again → the same row, `sent: false`,
state `queued`, and the Link receives one `job` frame; the same body again → the same row, `sent: false`,
no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.
no second frame; a class the device does not advertise → `409 bot.class_unadvertised` with **no** row.
- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.
- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.
@@ -17,7 +17,7 @@ one priority"):
@@ -17,7 +17,7 @@ one priority"):
- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when it
- **Chat commands reach a button robot.** `!forward`, `!left` … press the robot's buttons of the same name when it
has no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,
has no drive, and any other `!word` that is not a channel sound presses the button of that name (Live#383,
Chat#194).
Chat#194).
-- **The Link's lifecycle:** `openvibe-node quit` ends a running Link cleanly with nothing latched (Link 0.16.0), for
+- **The Link's lifecycle:** `openvibe-link quit` ends a running Link cleanly with nothing latched (Link 0.16.0), for
launchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.
launchers that start and stop it with the rest of a stream setup. `stop` stays the kill switch.
## What Live has today
## What Live has today
@@ -62,7 +62,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
@@ -62,7 +62,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
3. **Built — Bot gate and panel**: the gate accepts `button`/`point` only for names and shapes the profile
3. **Built — Bot gate and panel**: the gate accepts `button`/`point` only for names and shapes the profile
declares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on the
declares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on the
camera tile, normalised coordinates).
camera tile, normalised coordinates).
-4. **Built — Node, the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live's
+4. **Built — Link, the relay plugin**: forwards `button`/`point` commands to the owner's local script in the shape Live's
hardware clients already understand — one JSON line per command on stdout of a supervised child process, or a
hardware clients already understand — one JSON line per command on stdout of a supervised child process, or a
loopback WebSocket that speaks Live's `{type:'command'|'key_held'|'key_released'|'video_click'}` messages — so an
loopback WebSocket that speaks Live's `{type:'command'|'key_held'|'key_released'|'video_click'}` messages — so an
existing Pi script keeps working with a one-line change of where it connects. Local policy and the kill switch
existing Pi script keeps working with a one-line change of where it connects. Local policy and the kill switch
@@ -79,7 +79,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
@@ -79,7 +79,7 @@ lines) let viewers drive a streamer's hardware from the channel page:
ONVIF control and a disabled button are reported and left out; an owner with no Network subject is reported
ONVIF control and a disabled button are reported and left out; an owner with no Network subject is reported
and skipped (they must sign in to Network once). Idempotent on the Live config id (`live_conversions`,
and skipped (they must sign in to Network once). Idempotent on the Live config id (`live_conversions`,
migration 0007): a second `--apply` changes nothing and says "already converted". The owner then pairs a
migration 0007): a second `--apply` changes nothing and says "already converted". The owner then pairs a
- Node (the five-minute path) and moves the script onto the relay plugin.
+ Link (the five-minute path) and moves the script onto the relay plugin.
6. **Done — Live**: `LIVE_BOT_EMBED=1`; the channel page shows the robot's embedded panel for a stream whose owner has a
6. **Done — Live**: `LIVE_BOT_EMBED=1`; the channel page shows the robot's embedded panel for a stream whose owner has a
robot; then delete `server/controls/*`, the hardware WebSocket path and its stream-key auth, the controls tables
robot; then delete `server/controls/*`, the hardware WebSocket path and its stream-key auth, the controls tables
and `public/js/controls.js` / `onvif-dashboard.js` (the embed stays).
and `public/js/controls.js` / `onvif-dashboard.js` (the embed stays).
@@ -115,7 +115,7 @@ if anything, needs redoing by hand.
@@ -115,7 +115,7 @@ if anything, needs redoing by hand.
### What each owner does next
### What each owner does next
-1. Bot robot page → **Pair a Node** (the five-minute path): run the installer command Bot prints for the new
+1. Bot robot page → **Pair a Link** (the five-minute path): run the installer command Bot prints for the new
robot on the machine that drives the hardware.
robot on the machine that drives the hardware.
2. Move the local script onto the **relay plugin**: it forwards `button` (`{name, state}` for a hold) and
2. Move the local script onto the **relay plugin**: it forwards `button` (`{name, state}` for a hold) and
`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
@@ -32,7 +32,7 @@ header; such a socket may only send `pair` until it is answered (anything else
@@ -32,7 +32,7 @@ header; such a socket may only send `pair` until it is answered (anything else
so a reconnecting agent cannot be shadowed by a stale socket.
so a reconnecting agent cannot be shadowed by a stale socket.
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
-pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service
+pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/link-pairings` with its own service
token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network and
carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network and
connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
@@ -44,9 +44,9 @@ Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the
@@ -44,9 +44,9 @@ Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the
`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe Link's `install/install.sh`).
`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe Link's `install/install.sh`).
-**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
+**A Network-paired machine** (a node principal `lnk_…`, paired on OpenVibe.Network for one of its
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
-`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
+`node`, `sub` `link:lnk_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
Network's record: the robot the machine was paired for, which must be one of the principal's owner's
Network's record: the robot the machine was paired for, which must be one of the principal's owner's
robots, and the principal must be `active`), then speaks first exactly as for a credential — `hello`,
robots, and the principal must be `active`), then speaks first exactly as for a credential — `hello`,
`config` — with **no** `paired` frame. A refused node token, or a principal Bot will not bind (revoked,
`config` — with **no** `paired` frame. A refused node token, or a principal Bot will not bind (revoked,
@@ -112,7 +112,7 @@ Bot sent them (one WebSocket), and OpenVibe Link handles them in that order. Two
@@ -112,7 +112,7 @@ Bot sent them (one WebSocket), and OpenVibe Link handles them in that order. Two
a late one (`expired`) before any driver sees it, and gives a driver only the time left.
a late one (`expired`) before any driver sees it, and gives a driver only the time left.
Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never
Across a reconnect nothing is replayed. Bot never queues commands for an offline device ("commands are never
-queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Node
+queued"), `seq` starts again on each connection, and the device sees only server-minted command ids (Link
answers a repeated id from its dedup cache instead of running it again). A command in flight when the
answers a repeated id from its dedup cache instead of running it again). A command in flight when the
socket drops is lost, and its operator gets `expired`.
socket drops is lost, and its operator gets `expired`.
@@ -121,7 +121,7 @@ across a Bot restart:
@@ -121,7 +121,7 @@ across a Bot restart:
- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and every
- It moves when a turn ends, is left or is taken over. The halt for that change carries the new value, and every
command carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch its
command carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch its
halt moved to.
halt moved to.
-- OpenVibe Link (Node#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,
+- OpenVibe Link (Link#55) keeps the newest epoch a halt carried per robot, nacks `expired` any command older than it,
and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.
and passes the epoch to drivers (`cmd.turn_epoch`), so a driver can check it too.
- It is absent until a robot's first turn change since Bot started. A command without it runs as before.
- It is absent until a robot's first turn change since Bot started. A command without it runs as before.
@@ -214,8 +214,8 @@ only (`robot_state.state.device_estop`); it never clears Bot's latch — clearin
@@ -214,8 +214,8 @@ only (`robot_state.state.device_estop`); it never clears Bot's latch — clearin
and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.
and audited (`kind: estop_state`, `result: refused`); nothing changes on that robot.
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
**A local stop** (`latched: true, local_stop: true`, with an optional `reason`) is one held on the machine
-itself: `openvibe-node stop`, or a driver's own stop button through it. Only a release on that machine
-(`openvibe-node resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`
+itself: `openvibe-link stop`, or a driver's own stop button through it. Only a release on that machine
+(`openvibe-link resume`) lifts it; the owner's clear does not. The Link keeps refusing motion (`nack`
`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
`local_stop`) and reports the latch again after the owner's clear, which latches the robot again. Bot keeps
`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
`local` and `reason` (plain text, at most 120 characters) in `device_estop`, and the panel says the robot is
stopped on the robot and holds back its Clear button until the device reports the release. After the release
stopped on the robot and holds back its Clear button until the device reports the release. After the release
@@ -335,7 +335,7 @@ States (`run_jobs.state`): `queued` → `placed` (ack) → `running` (`job_start
@@ -335,7 +335,7 @@ States (`run_jobs.state`): `queued` → `placed` (ack) → `running` (`job_start
| `service` / `operation` / `unit` | `run` / `function.invoke` / `s` |
| `service` / `operation` / `unit` | `run` / `function.invoke` / `s` |
| `quantity` | `1`; the partial last second `(wall_ms mod 1000)/1000`, never 0 |
| `quantity` | `1`; the partial last second `(wall_ms mod 1000)/1000`, never 0 |
| `at` | `new Date(started_ms + n*1000).toISOString()` |
| `at` | `new Date(started_ms + n*1000).toISOString()` |
-| `resource` / `node` / `source` | the job id / the device id (`dev_…`) / `openvibe-node.worker` |
+| `resource` / `node` / `source` | the job id / the device id (`dev_…`) / `openvibe-link.worker` |
| `project`, `subject`, `provider` | from Bot's record of the job (what `dispatch` was given); left out when unset |
| `project`, `subject`, `provider` | from Bot's record of the job (what `dispatch` was given); left out when unset |
No field depends on when or how often a frame arrived, and no rating field is set. `job_exit` writes seconds
No field depends on when or how often a frame arrived, and no rating field is set. `job_exit` writes seconds
@@ -554,7 +554,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -554,7 +554,7 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
| `GET /robots/:id/devices` | member, or `bot.robot.read` | `{ devices[] }` (no hashes, `online`) |
| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
| `POST /devices/:id/rotate` | the robot's owner, `bot.device.connect` | `{ device, credential, publish_key, whip_url }` (once; `video: "not_configured"` instead of the key without OpenRestream); for a Network-paired machine `{ device, sent }` — the machine is sent `rotate`, no credential is answered |
| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) |
| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once. A Network-paired machine's principal is revoked on Network too (`503 bot.network_unavailable` if Network did not answer: revoked here, retry) and never binds again. The OpenRestream key the device holds is revoked and its live session ended (`502`/`503 bot.openre_*` if OpenRestream did not: revoked here, retry) |
-| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.node_token_required`, `403 bot.node_not_bound` |
+| `POST /devices/bind` | a Network node token (audience `openvibe.bot`), no body | `201 { device_id, publish_key, whip_url, robot_id, profile }` — `POST /pair`'s answer without `credential`; again → the same device and a new publish key (the old one stops working); without OpenRestream `video: "not_configured"` instead of the key. Refused: `401 bot.link_token_required`, `403 bot.link_not_bound` |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.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.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1120,9 +1120,9 @@
@@ -1120,9 +1120,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.338.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
- "integrity": "sha512-75FD6IxMsXoud3NFXKDs0/D0rhJtF2D+WL8M92ArIyabkUnM/sZvw6E6L97EqwQ3O7M/kB+uwsNSxGudbil+ng==",
+ "version": "0.373.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
+ "integrity": "sha512-ddW6yG5deFxRgS/MQLgV6pyYx8vco49Nt83DrE+m9XAPwuHxFSeSZgvZ5CznK0cjul3CMmB5NaVXA6jdEbXAfQ==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.338.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.373.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.123.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.173.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.173.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -214,7 +214,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -214,7 +214,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.robot-name .icon { color: var(--accent); }
.robot-name .icon { color: var(--accent); }
.robot-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.robot-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
-/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
+/* A machine's computer plugin (Link docs/computer.md): its level, what waits on it, and the agent switch, full width. */
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-ai, .machine-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
.machine-ai, .machine-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
@@ -902,7 +902,7 @@
@@ -902,7 +902,7 @@
if (!code || QUIET_NACKS.has(code)) return '';
if (!code || QUIET_NACKS.has(code)) return '';
return NACK_TEXT[code] || `The robot refused that (${code}).`;
return NACK_TEXT[code] || `The robot refused that (${code}).`;
}
}
- /** The device's own faults (status.faults from the Node: { code, driver, message }), while it is online. */
+ /** The device's own faults (status.faults from the Link: { code, driver, message }), while it is online. */
function paintFaults(state) {
function paintFaults(state) {
const el = $('[data-device-fault]');
const el = $('[data-device-fault]');
if (!el) return;
if (!el) return;
@@ -942,7 +942,7 @@
@@ -942,7 +942,7 @@
function paint(state) {
function paint(state) {
if (!state) return;
if (!state) return;
const latched = !!(state.estop && state.estop.latched);
const latched = !!(state.estop && state.estop.latched);
- // The robot's own stop (openvibe-node stop, or its driver's stop button) holds until it is released on the
+ // The robot's own stop (openvibe-link stop, or its driver's stop button) holds until it is released on the
// robot: clearing here cannot lift it, so the button waits and the note says where to release it.
// robot: clearing here cannot lift it, so the button waits and the note says where to release it.
const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
const held = !!(state.device_estop && state.device_estop.latched && state.device_estop.local);
const banner = $('[data-estop-banner]');
const banner = $('[data-estop-banner]');
@@ -952,7 +952,7 @@
@@ -952,7 +952,7 @@
const note = $('.estop-note');
const note = $('.estop-note');
if (note) {
if (note) {
const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';
const why = held && state.device_estop.reason ? ` (${state.device_estop.reason})` : '';
- note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-node resume); then the owner clears it here.`
+ note.textContent = held ? ` — its local stop${why} holds until it is released on the robot (openvibe-link resume); then the owner clears it here.`
: latched ? ' — the robot stays still until the owner clears it.' : '';
: latched ? ' — the robot stays still until the owner clears it.' : '';
}
}
const clear = $('[data-estop-clear]');
const clear = $('[data-estop-clear]');
@@ -9,7 +9,7 @@
@@ -9,7 +9,7 @@
* Imager writes too) and both go only into the files you download. openvibe.bot is sent the robot's name, model, who
* Imager writes too) and both go only into the files you download. openvibe.bot is sent the robot's name, model, who
* may drive, Live and the hostname, and answers a setup key for the card (server/setup-keys.js).
* may drive, Live and the hostname, and answers a setup key for the card (server/setup-keys.js).
*
*
- * In Node (tests) this file exports the pure parts: sha512Crypt, yamlQuote, userData, networkConfig.
+ * In Node.js (tests) this file exports the pure parts: sha512Crypt, yamlQuote, userData, networkConfig.
*/
*/
(function (root, factory) {
(function (root, factory) {
'use strict';
'use strict';
@@ -82,12 +82,13 @@
@@ -82,12 +82,13 @@
/**
/**
* user-data. Mirrors what Raspberry Pi Imager 2 writes (its customization_generator.cpp): the `user:` block, avahi
* user-data. Mirrors what Raspberry Pi Imager 2 writes (its customization_generator.cpp): the `user:` block, avahi
* so <hostname>.local answers, SSH on, I2C and SPI for the robot's boards. Then the one thing Imager cannot do: a
* so <hostname>.local answers, SSH on, I2C and SPI for the robot's boards. Then the one thing Imager cannot do: a
- * first-boot service that waits for the network and runs openvibe.bot's installer with the card's setup key, so the
+ * first-boot service that waits for the network and runs OpenVibe Link's installer with the card's setup key, so the
* robot adds itself with nothing typed on it. Its output: `journalctl -u openvibe-setup`.
* robot adds itself with nothing typed on it. Its output: `journalctl -u openvibe-setup`.
*/
*/
- function userData({ hostname, username, passwordHash, sshKeys = [], timezone = '', setupKey, server = 'https://openvibe.bot', robotName = '' }) {
+ function userData({ hostname, username, passwordHash, sshKeys = [], timezone = '', setupKey, server = 'https://openvibe.bot', installer = 'https://openvibe.link/install', robotName = '' }) {
if (!/^osk_[A-Za-z0-9_-]{43}$/.test(setupKey || '')) throw new Error('no setup key');
if (!/^osk_[A-Za-z0-9_-]{43}$/.test(setupKey || '')) throw new Error('no setup key');
if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(server)) throw new Error('bad server');
if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?$/.test(server)) throw new Error('bad server');
+ if (!/^https?:\/\/[A-Za-z0-9.-]+(:\d+)?\/install$/.test(installer)) throw new Error('bad installer');
const keys = sshKeys.map((k) => k.trim()).filter(Boolean);
const keys = sshKeys.map((k) => k.trim()).filter(Boolean);
const script = [
const script = [
'#!/bin/sh',
'#!/bin/sh',
@@ -95,11 +96,11 @@
@@ -95,11 +96,11 @@
'# Its output: journalctl -u openvibe-setup',
'# Its output: journalctl -u openvibe-setup',
'key="$(cat /etc/openvibe/setup-key)"',
'key="$(cat /etc/openvibe/setup-key)"',
'for i in $(seq 1 90); do',
'for i in $(seq 1 90); do',
- ` curl -fsS -o /dev/null --max-time 10 ${server}/install && break`,
+ ` curl -fsS -o /dev/null --max-time 10 ${installer} && break`,
' echo "waiting for the network ($i of 90)"; sleep 10',
' echo "waiting for the network ($i of 90)"; sleep 10',
'done',
'done',
'for try in 1 2 3; do',
'for try in 1 2 3; do',
- ` if curl -fsSL ${server}/install | sh -s -- --setup "$key"; then rm -f /etc/openvibe/setup-key; exit 0; fi`,
+ ` if curl -fsSL ${installer} | sh -s -- --setup "$key"; then rm -f /etc/openvibe/setup-key; exit 0; fi`,
' echo "the installer stopped (try $try of 3): trying again in a minute"; sleep 60',
' echo "the installer stopped (try $try of 3): trying again in a minute"; sleep 60',
'done',
'done',
'exit 1',
'exit 1',
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* The simulated rover's world (sim.rover): one small arena that Bot's in-process simulator (server/sim) and the
* The simulated rover's world (sim.rover): one small arena that Bot's in-process simulator (server/sim) and the
* browser (the /try page's local rover, and the camera of every sim robot's panel) share, so the picture a panel
* browser (the /try page's local rover, and the camera of every sim robot's panel) share, so the picture a panel
- * draws and the pose the server keeps always agree. No DOM, no Node API: it loads as a CommonJS module on the
+ * draws and the pose the server keeps always agree. No DOM, no Node.js API: it loads as a CommonJS module on the
* server and as `window.OVSimWorld` in a page.
* server and as `window.OVSimWorld` in a page.
*
*
* Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and
* Units are metres, seconds and degrees, with +y "up" a top-down view and +x to its right. Heading 0 faces +y and
@@ -13,7 +13,7 @@ function actor(req) {
@@ -13,7 +13,7 @@ function actor(req) {
// person, exactly as their own token would — never against the shared service principal.
// person, exactly as their own token would — never against the shared service principal.
if (req.limitSubject) return `user:${req.limitSubject}`;
if (req.limitSubject) return `user:${req.limitSubject}`;
const p = req.principal;
const p = req.principal;
- if (p && (p.kind === 'service' || p.kind === 'node') && p.sub) return p.sub;
+ if (p && (p.kind === 'service' || p.kind === 'link') && p.sub) return p.sub;
if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
return defaultActor(req);
return defaultActor(req);
}
}
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience
* { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience
* openvibe.bot; each route checks ONE capability
* openvibe.bot; each route checks ONE capability
- * { kind: 'node', principal: 'nod_…', sub: 'node:nod_…' } a Network node token (actor_type node, audience
+ * { kind: 'link', principal: 'lnk_…', sub: 'link:lnk_…' } a Network Link token (actor_type node, audience
* openvibe.bot): a machine paired for Bot; it may only bind
* openvibe.bot): a machine paired for Bot; it may only bind
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* { kind: 'user', subject: 'usr_…', username, name, avatar, role }
* { kind: 'anonymous' }
* { kind: 'anonymous' }
@@ -17,8 +17,8 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');
@@ -17,8 +17,8 @@ const { verifyServiceToken } = require('openvibe-sdk/auth');
const { capabilities, http, ids } = contracts;
const { capabilities, http, ids } = contracts;
-const PRINCIPAL_SUB = /^(svc|app|mod|node):/;
-const NODE_SUB = /^node:(nod_[0-9A-HJKMNP-TV-Z]{26})$/;
+const PRINCIPAL_SUB = /^(svc|app|mod|link):/;
+const LINK_SUB = /^link:(lnk_[0-9A-HJKMNP-TV-Z]{26})$/;
const ANON = Object.freeze({ kind: 'anonymous' });
const ANON = Object.freeze({ kind: 'anonymous' });
function decodePayload(token) {
function decodePayload(token) {
@@ -57,21 +57,21 @@ async function verifyService(token, opts) {
@@ -57,21 +57,21 @@ async function verifyService(token, opts) {
return { ok: true, claims: r.claims };
return { ok: true, claims: r.claims };
}
}
-/** A Bearer that claims to be a node token (actor_type node); verifyNode decides whether it is one. */
-function isNodeToken(token) {
+/** A Bearer that claims to be a Link token (actor_type node); verifyLink decides whether it is one. */
+function isLinkToken(token) {
const payload = decodePayload(token);
const payload = decodePayload(token);
- return !!payload && payload.actor_type === 'node';
+ return !!payload && payload.actor_type === 'link';
}
}
/**
/**
- * Verify a node token: signed by Network, audience openvibe.bot, actor_type node, sub node:nod_…
- * Returns { ok, principal: 'nod_…', claims } or { ok:false, code, reason }.
+ * Verify a Link token: signed by Network, audience openvibe.bot, actor_type node, sub link:lnk_…
+ * Returns { ok, principal: 'lnk_…', claims } or { ok:false, code, reason }.
*/
*/
-async function verifyNode(token, opts) {
+async function verifyLink(token, opts) {
const r = await principalToken(token, opts);
const r = await principalToken(token, opts);
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
- const m = NODE_SUB.exec(String(r.claims.sub || ''));
- if (r.claims.actor_type !== 'node' || !m) return { ok: false, code: 'token.invalid', reason: 'not a node token' };
+ const m = LINK_SUB.exec(String(r.claims.sub || ''));
+ if (r.claims.actor_type !== 'link' || !m) return { ok: false, code: 'token.invalid', reason: 'not a Link token' };
return { ok: true, principal: m[1], claims: r.claims };
return { ok: true, principal: m[1], claims: r.claims };
}
}
@@ -84,10 +84,10 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -84,10 +84,10 @@ function createApiAuth({ config, keys, userAuth }) {
const token = header.slice(7).trim();
const token = header.slice(7).trim();
if (!keys.loaded()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
if (!keys.loaded()) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
const payload = decodePayload(token);
const payload = decodePayload(token);
- if (payload && payload.actor_type === 'node') {
- const r = await verifyNode(token, opts());
+ if (payload && payload.actor_type === 'link') {
+ const r = await verifyLink(token, opts());
if (!r.ok) return { error: [401, r.code, r.reason] };
if (!r.ok) return { error: [401, r.code, r.reason] };
- return { principal: { kind: 'node', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };
+ return { principal: { kind: 'link', principal: r.principal, sub: r.claims.sub, jti: r.claims.jti } };
}
}
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const r = await principalToken(token, opts());
const r = await principalToken(token, opts());
@@ -114,4 +114,4 @@ function createApiAuth({ config, keys, userAuth }) {
@@ -114,4 +114,4 @@ function createApiAuth({ config, keys, userAuth }) {
return { middleware, resolve, granted };
return { middleware, resolve, granted };
}
}
-module.exports = { createApiAuth, userPrincipal, verifyService, verifyNode, isNodeToken, decodePayload, PRINCIPAL_SUB };
+module.exports = { createApiAuth, userPrincipal, verifyService, verifyLink, isLinkToken, decodePayload, PRINCIPAL_SUB };
@@ -50,7 +50,7 @@ function machineSummary(row, online, baseUrl = null) {
@@ -50,7 +50,7 @@ function machineSummary(row, online, baseUrl = null) {
updated_at: new Date(row.updated_at).toISOString(),
updated_at: new Date(row.updated_at).toISOString(),
};
};
if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };
if (USER_SUBJECT_RE.test(String(row.owner_subject || ''))) summary.owner = { type: 'user', id: row.owner_subject };
- return { ...summary, ...consoleOf(baseUrl, '/machines') };
+ return { ...summary, ...(/^https:\/\//.test(String(baseUrl || '')) ? { links: { console: 'https://openvibe.link/machines' } } : {}) };
}
}
const encodeCursor = (id, kind = KIND) => Buffer.from(JSON.stringify([kind, id])).toString('base64url');
const encodeCursor = (id, kind = KIND) => Buffer.from(JSON.stringify([kind, id])).toString('base64url');
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |
* | DELETE /robots/:id/operators/:subject | bot.robot.manage | owner |
* | GET /robots/:id/devices | bot.robot.read | a member |
* | GET /robots/:id/devices | bot.robot.read | a member |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
* | POST /devices/:id/rotate, /revoke | bot.device.connect | the robot's owner |
- * | POST /devices/bind | — (a node token) | a machine paired for Bot on Network |
+ * | POST /devices/bind | — (a Link token) | a machine paired for Bot on Network |
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | POST /robots/:id/estop | bot.robot.control | owner or operator |
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
* | POST /robots/:id/commands | bot.robot.control | owner or operator |
@@ -75,7 +75,7 @@ const CAP = {
@@ -75,7 +75,7 @@ const CAP = {
const AGENT_SERVICES = new Set(['svc:actor']);
const AGENT_SERVICES = new Set(['svc:actor']);
/**
/**
* Services that use a person's machine, each only for the kinds it is allowed and only where the owner turned on its
* Services that use a person's machine, each only for the kinds it is allowed and only where the owner turned on its
- * switch on openvibe.bot/machines: the agent (any computer kind) and Watch's node source (read-only probes).
+ * switch on openvibe.link/machines: the agent (any computer kind) and Watch's node source (read-only probes).
*/
*/
const MACHINE_SERVICES = {
const MACHINE_SERVICES = {
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
@@ -139,7 +139,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -139,7 +139,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const me = (req) => (req.principal.kind === 'user' ? req.principal.subject : null);
const me = (req) => (req.principal.kind === 'user' ? req.principal.subject : null);
- /** Only a person or a service acts for an owner: no token → 401, any other kind (a node token) → 403. */
+ /** Only a person or a service acts for an owner: no token → 401, any other kind (a Link token) → 403. */
const fence = (req) => {
const fence = (req) => {
if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
if (req.principal.kind !== 'user' && req.principal.kind !== 'service') fail(403, 'bot.forbidden', 'this token may not act for an owner');
if (req.principal.kind !== 'user' && req.principal.kind !== 'service') fail(403, 'bot.forbidden', 'this token may not act for an owner');
@@ -168,7 +168,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -168,7 +168,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
if (!apiAuth.granted(req.principal, CAP.dispatch)) fail(403, 'capability.denied', `${CAP.dispatch} not granted`);
if (!apiAuth.granted(req.principal, CAP.dispatch)) fail(403, 'capability.denied', `${CAP.dispatch} not granted`);
};
};
- // Both fence before the lookup: without a token (or with a node token) a real robot id and a made-up one
+ // Both fence before the lookup: without a token (or with a Link token) a real robot id and a made-up one
// get the same answer.
// get the same answer.
async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
fence(req);
fence(req);
@@ -552,7 +552,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -552,7 +552,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,
os: (st.status && typeof st.status.os === 'string' && st.status.os) || null,
node_version: device.agent_version || null,
node_version: device.agent_version || null,
last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,
last_seen_at: device.last_seen ? new Date(device.last_seen).toISOString() : null,
- // The machine's id on Network (`nod_…`) and the OpenVibe Link's name for it, so a caller that knows the
+ // The machine's id on Network (`lnk_…`) and the OpenVibe Link's name for it, so a caller that knows the
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
// machine from Network (Codes' fleet, a Run pin) finds this row (Codes C9c).
node_id: device.node_principal || null,
node_id: device.node_principal || null,
// What its coding agents have left, as its Link last reported it, and when Bot stored that reading
// What its coding agents have left, as its Link last reported it, and when Bot stored that reading
@@ -910,11 +910,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -910,11 +910,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json(await claims.poll(req.params.id, String(req.headers['x-ov-claim-secret'] || '')));
res.json(await claims.poll(req.params.id, String(req.headers['x-ov-claim-secret'] || '')));
}));
}));
- // ── Bootstrap of a Network-paired machine (its node token, once after pairing) ────────────────
+ // ── Bootstrap of a Network-paired machine (its Link token, once after pairing) ────────────────
// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the
// Binds the machine to its robot and issues its WHIP publish key; calling again re-issues the key (the
// old one stops working) for the same device. POST /pair's answer without a credential.
// old one stops working) for the same device. POST /pair's answer without a credential.
r.post('/devices/bind', pair, wrap(async (req, res) => {
r.post('/devices/bind', pair, wrap(async (req, res) => {
- if (req.principal.kind !== 'node') fail(401, 'bot.node_token_required', 'a Network node token (audience openvibe.bot) is required');
+ if (req.principal.kind !== 'link') fail(401, 'bot.link_token_required', 'a Network Link token (audience openvibe.bot) is required');
const device = await domain.devices.bindNode(req.principal.principal);
const device = await domain.devices.bindNode(req.principal.principal);
const issued = await domain.devices.issuePublishKey(device.id);
const issued = await domain.devices.issuePublishKey(device.id);
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Cache-Control', 'no-store');
@@ -151,12 +151,12 @@ function createApp(opts = {}) {
@@ -151,12 +151,12 @@ function createApp(opts = {}) {
const accountSend = opts.accountSend || (config.oauth.clientSecret
const accountSend = opts.accountSend || (config.oauth.clientSecret
? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
? createNetworkSender({ networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
: async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Bot cannot answer account events'); });
- // network.node.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is
+ // network.link.revoked (a person's paired machine revoked on Network): its devices are revoked and a live session is
// closed at once, instead of at the machine's next reauth deadline.
// closed at once, instead of at the machine's next reauth deadline.
const onEvent = async (ev) => {
const onEvent = async (ev) => {
- if (ev && ev.event_type === 'network.node.revoked') {
+ if (ev && ev.event_type === 'network.link.revoked') {
const principal = String((ev.payload && ev.payload.principal_id) || '');
const principal = String((ev.payload && ev.payload.principal_id) || '');
- if (!/^nod_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';
+ if (!/^lnk_[0-9A-HJKMNP-TV-Z]{26}$/.test(principal)) return 'applied';
const revoked = await domain.devices.revokeByNode(principal);
const revoked = await domain.devices.revokeByNode(principal);
for (const d of revoked) hub.closeDevice(d.id, 'machine revoked on OpenVibe.Network');
for (const d of revoked) hub.closeDevice(d.id, 'machine revoked on OpenVibe.Network');
if (revoked.length) log.log(`[Bot] node ${principal} revoked on Network: ${revoked.length} device(s) revoked`);
if (revoked.length) log.log(`[Bot] node ${principal} revoked on Network: ${revoked.length} device(s) revoked`);
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
* A device that asks to be added: the one command on the machine (plan T15 "five-minute path", owner 2026-10-17: "I
* A device that asks to be added: the one command on the machine (plan T15 "five-minute path", owner 2026-10-17: "I
* should be able to run a single command on my raspi … ready to go live and controllable").
* should be able to run a single command on my raspi … ready to go live and controllable").
*
*
- * curl -fsSL https://openvibe.bot/install | sh (no robot, no code)
+ * curl -fsSL https://openvibe.link/install | sh (no robot, no code)
*
*
* The installer looks at the machine (the kit on its I2C bus, the camera), then starts a claim here and shows the
* The installer looks at the machine (the kit on its I2C bus, the camera), then starts a claim here and shows the
* person a code and openvibe.bot/link. Signed in there, they see what the device found and approve it (name, model,
* person a code and openvibe.bot/link. Signed in there, they see what the device found and approve it (name, model,
@@ -107,7 +107,7 @@ function loadConfig(env = process.env) {
@@ -107,7 +107,7 @@ function loadConfig(env = process.env) {
if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
}
}
- const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh').trim();
+ const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh').trim();
checkInstallerSource(installerSource);
checkInstallerSource(installerSource);
const embedOrigins = parseEmbedOrigins(env.BOT_EMBED_ORIGINS, isProduction);
const embedOrigins = parseEmbedOrigins(env.BOT_EMBED_ORIGINS, isProduction);
return {
return {
@@ -160,7 +160,7 @@ function loadConfig(env = process.env) {
@@ -160,7 +160,7 @@ function loadConfig(env = process.env) {
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
// Pairing (ADR-043 decision 2): an 8-character Crockford base32 code, XXXX-XXXX, 10 minutes,
// single use, 5 wrong tries end it. Only the hash is stored. `authority` network (plan T15 B2): the
// single use, 5 wrong tries end it. Only the hash is stored. `authority` network (plan T15 B2): the
- // code is minted by Network (POST /internal/node-pairings), Bot stores none, and POST /pair and the
+ // code is minted by Network (POST /internal/link-pairings), Bot stores none, and POST /pair and the
// `pair` frame answer 410 bot.pairing_moved. An unknown value refuses to boot.
// `pair` frame answer 410 bot.pairing_moved. An unknown value refuses to boot.
pairing: {
pairing: {
authority: pairingAuthority,
authority: pairingAuthority,
@@ -279,7 +279,7 @@ function loadConfig(env = process.env) {
@@ -279,7 +279,7 @@ function loadConfig(env = process.env) {
maxPerRobot: Math.max(1, int(env.BOT_WATCH_MAX_PER_ROBOT, 500)),
maxPerRobot: Math.max(1, int(env.BOT_WATCH_MAX_PER_ROBOT, 500)),
},
},
installer: {
installer: {
- scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
+ scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.link/install'),
sourceUrl: installerSource,
sourceUrl: installerSource,
sourceHosts: INSTALLER_SOURCE_HOSTS,
sourceHosts: INSTALLER_SOURCE_HOSTS,
},
},
@@ -386,7 +386,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -386,7 +386,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/**
/**
* BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
* BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
- * /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
+ * /internal/link-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
*/
*/
async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {
async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {
@@ -666,7 +666,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -666,7 +666,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/**
/**
- * Network revoked a node principal (network.node.revoked: its owner, the pairing service or an account deletion):
+ * Network revoked a node principal (network.link.revoked: its owner, the pairing service or an account deletion):
* every live device bound to it is revoked here too, without asking Network again. → the revoked rows
* every live device bound to it is revoked here too, without asking Network again. → the revoked rows
*/
*/
async function revokeByNode(principalId) {
async function revokeByNode(principalId) {
@@ -679,9 +679,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -679,9 +679,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────
// ── Network-paired devices (node principals, T2 §9.2 B1) ──────────────────────────────────────
/**
/**
* The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:
* The live device bound to Network node principal `principalId`, creating it on first use. Idempotent:
- * a live row is returned as it is. Otherwise Network's record decides (GET /internal/node-principals/:id
+ * a live row is returned as it is. Otherwise Network's record decides (GET /internal/link-principals/:id
* with Bot's service token): the principal must be active, paired for Bot, for a robot of the principal's
* with Bot's service token): the principal must be active, paired for Bot, for a robot of the principal's
- * own owner; anything else is 403 bot.node_not_bound. A principal whose device the owner revoked here
+ * own owner; anything else is 403 bot.link_not_bound. A principal whose device the owner revoked here
* never binds again. The row starts from Network's record and safe values only; what the device declares
* never binds again. The row starts from Network's record and safe values only; what the device declares
* arrives later in its `status` frames.
* arrives later in its `status` frames.
*/
*/
@@ -689,7 +689,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -689,7 +689,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const known = await db.maybe(
const known = await db.maybe(
'SELECT * FROM devices WHERE node_principal = $1 ORDER BY (revoked_at IS NULL) DESC, created_at DESC LIMIT 1', [principalId]);
'SELECT * FROM devices WHERE node_principal = $1 ORDER BY (revoked_at IS NULL) DESC, created_at DESC LIMIT 1', [principalId]);
if (known && !known.revoked_at) return known;
if (known && !known.revoked_at) return known;
- const refuse = (why) => fail(403, 'bot.node_not_bound', why);
+ const refuse = (why) => fail(403, 'bot.link_not_bound', why);
if (known) refuse('this machine was revoked on Bot; pair it again');
if (known) refuse('this machine was revoked on Bot; pair it again');
if (!nodes) refuse('Network-paired machines are not configured here');
if (!nodes) refuse('Network-paired machines are not configured here');
const p = await nodes.get(principalId);
const p = await nodes.get(principalId);
@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -993,8 +993,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
WHERE id = $1 AND revoked_at IS NULL RETURNING *`,
[id, JSON.stringify(fleet), iso(now())]);
[id, JSON.stringify(fleet), iso(now())]);
}
}
- // ── The guided first run (OpenVibe Link's `openvibe-node`, docs/install.md#first-run) ──────────────
- // One row per device and step, from the `node.first_run` telemetry events the Link already sends. The fields are
+ // ── The guided first run (OpenVibe Link's `openvibe-link`, docs/install.md#first-run) ──────────────
+ // One row per device and step, from the `link.first_run` telemetry events the Link already sends. The fields are
// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
// an allowlist: a path, a username or an address is never stored even if a buggy client sends one.
const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
const FIRST_RUN_STEPS = new Set(['installed', 'paired', 'self_check', 'first_job']);
const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
const firstRunNumber = (v) => (Number.isFinite(v) && v >= 0 && v <= Number.MAX_SAFE_INTEGER ? Math.floor(v) : undefined);
@@ -1011,13 +1011,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1011,13 +1011,13 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/**
/**
* Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event
* Store the first-run steps in a telemetry frame. A resent step updates its time (the newest run wins); an event
- * that is not `node.first_run` with a known step is ignored, never an error for the caller.
+ * that is not `link.first_run` with a known step is ignored, never an error for the caller.
*/
*/
async function recordFirstRun(deviceId, events) {
async function recordFirstRun(deviceId, events) {
if (!Array.isArray(events) || !deviceId) return 0;
if (!Array.isArray(events) || !deviceId) return 0;
let stored = 0;
let stored = 0;
for (const event of events) {
for (const event of events) {
- if (!event || event.name !== 'node.first_run' || !event.fields || typeof event.fields !== 'object') continue;
+ if (!event || event.name !== 'link.first_run' || !event.fields || typeof event.fields !== 'object') continue;
const step = firstRunText(event.fields.step, 20);
const step = firstRunText(event.fields.step, 20);
if (!FIRST_RUN_STEPS.has(step)) continue;
if (!FIRST_RUN_STEPS.has(step)) continue;
const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();
const ts = Number.isFinite(event.ts) && event.ts > 0 ? event.ts : now();
@@ -80,9 +80,9 @@ async function main() {
@@ -80,9 +80,9 @@ async function main() {
// kit's 5000 ms default is the right value.
// kit's 5000 ms default is the right value.
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// The two account subscriptions at OpenVibe.Events (ADR-033), created when missing; off without EVENTS_URL,
// BOT_EVENTS_SECRET or the client secret.
// BOT_EVENTS_SECRET or the client secret.
- // Plus network.node.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).
+ // Plus network.link.revoked: a machine revoked on Network is revoked here at once (app.js onEvent).
const subscriptions = startSubscriptions({
const subscriptions = startSubscriptions({
- topics: [...TOPICS, 'network.node.revoked'],
+ topics: [...TOPICS, 'network.link.revoked'],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
networkInternalUrl: config.network.internalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
});
@@ -7,7 +7,7 @@
@@ -7,7 +7,7 @@
*
*
* Every field of a reading is derived from the job and its second: id = idempotency_key = run:<job id>:<n>,
* Every field of a reading is derived from the job and its second: id = idempotency_key = run:<job id>:<n>,
* service run, operation function.invoke, unit s, quantity 1 (the partial last second (wall_ms mod 1000)/1000,
* service run, operation function.invoke, unit s, quantity 1 (the partial last second (wall_ms mod 1000)/1000,
- * never 0), at = started_ms + n*1000, resource the job id, node the device id, source openvibe-node.worker, and
+ * never 0), at = started_ms + n*1000, resource the job id, node the device id, source openvibe-link.worker, and
* project, subject and provider from Bot's own record of the job. Nothing depends on when a frame arrived, so a
* project, subject and provider from Bot's own record of the job. Nothing depends on when a frame arrived, so a
* resend, a job_usage and the job_exit backfill of one second are one key: the outbox keeps the first
* resend, a job_usage and the job_exit backfill of one second are one key: the outbox keeps the first
* (ON CONFLICT DO NOTHING) and Billing replays an identical reading (200) and refuses a different one (409).
* (ON CONFLICT DO NOTHING) and Billing replays an identical reading (200) and refuses a different one (409).
@@ -29,7 +29,7 @@ const SCHEMA = 'platform.usage-sample@1';
@@ -29,7 +29,7 @@ const SCHEMA = 'platform.usage-sample@1';
const SERVICE = 'run';
const SERVICE = 'run';
const OPERATION = 'function.invoke';
const OPERATION = 'function.invoke';
const UNIT = 's';
const UNIT = 's';
-const SOURCE = 'openvibe-node.worker';
+const SOURCE = 'openvibe-link.worker';
const REFUSED = new Set([400, 409, 413, 422]);
const REFUSED = new Set([400, 409, 413, 422]);
// Network's grant to the `bot` client on audience openvibe.billing (Network server/identity/principals.js
// Network's grant to the `bot` client on audience openvibe.billing (Network server/identity/principals.js
@@ -5,13 +5,13 @@
@@ -5,13 +5,13 @@
*
*
* keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,
* keys the Network's RS256 signing keys: openvibe-sdk/auth createNetworkKeys (OV_NETWORK_PUBLIC_KEY pinned,
* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on an
* else the JWKS, retried every 30 s until it loads, refreshed every 15 minutes, a rotation followed on an
- * unknown kid). They verify service and node tokens (audience openvibe.bot) and the browser's Network
+ * unknown kid). They verify service and Link tokens (audience openvibe.bot) and the browser's Network
* user JWT, all offline (openvibe-sdk/auth).
* user JWT, all offline (openvibe-sdk/auth).
- * nodes Network's node principals (machines paired for Bot): POST /internal/node-pairings (a pairing
- * code, BOT_PAIRING_AUTHORITY=network), GET /internal/node-principals/:id, POST …/:id/revoke and
+ * nodes Network's node principals (machines paired for Bot): POST /internal/link-pairings (a pairing
+ * code, BOT_PAIRING_AUTHORITY=network), GET /internal/link-principals/:id, POST …/:id/revoke and
* POST /internal/machines/status (the machine's online state, which Network publishes as the
* POST /internal/machines/status (the machine's online state, which Network publishes as the
* hub's network.machine.online and network.machine.offline events) with Bot's own svc:bot token
* hub's network.machine.online and network.machine.offline events) with Bot's own svc:bot token
- * (audience openvibe.network, network.node.manage).
+ * (audience openvibe.network, network.link.manage).
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* identity Network's people (identity.subject.resolve): resolve a username to a subject and look up the
* names of subjects the robot's owner added.
* names of subjects the robot's owner added.
*/
*/
@@ -28,15 +28,15 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
@@ -28,15 +28,15 @@ function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console
function createUserAuth(config, keys) {
function createUserAuth(config, keys) {
/**
/**
* Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals
* Claims of a valid user token, or null. openvibe-sdk/auth verifyUserToken refuses service, app and mod principals
- * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a node token (a machine paired for Bot)
+ * and typed tokens (a realtime ticket, a FedCM assertion, an export token); a Link token (a machine paired for Bot)
* is refused here too.
* is refused here too.
*/
*/
async function verify(token) {
async function verify(token) {
if (!token) return null;
if (!token) return null;
let claims;
let claims;
try { claims = await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
try { claims = await verifyUserToken(String(token), { ...keys.verifyOptions, issuer: config.network.issuer }); } catch { return null; }
- if (typeof claims.sub === 'string' && /^node:/.test(claims.sub)) return null;
- if (claims.actor_type === 'node') return null;
+ if (typeof claims.sub === 'string' && /^link:/.test(claims.sub)) return null;
+ if (claims.actor_type === 'link') return null;
return claims;
return claims;
}
}
return { verify };
return { verify };
@@ -48,13 +48,13 @@ function createUserAuth(config, keys) {
@@ -48,13 +48,13 @@ function createUserAuth(config, keys) {
* get(id) → that view, or null when Network knows no such principal paired by Bot (it answers 404, never 403).
* get(id) → that view, or null when Network knows no such principal paired by Bot (it answers 404, never 403).
* revoke(id) → the revoked view, or null for the same 404; idempotent on Network's side.
* revoke(id) → the revoked view, or null for the same 404; idempotent on Network's side.
* pair({subject, ref}) → { pairing_id, code, expires_at }: a one-time code for the person `subject` to pair a
* pair({subject, ref}) → { pairing_id, code, expires_at }: a one-time code for the person `subject` to pair a
- * machine for robot `ref` (POST /internal/node-pairings; Network keeps one live code per ref).
+ * machine for robot `ref` (POST /internal/link-pairings; Network keeps one live code per ref).
* Any other failure throws 503 bot.network_unavailable.
* Any other failure throws 503 bot.network_unavailable.
*/
*/
function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
const tokens = serviceAuth.createTokenClient({
const tokens = serviceAuth.createTokenClient({
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
- audience: 'openvibe.network', scope: 'network.node.manage', fetchImpl,
+ audience: 'openvibe.network', scope: 'network.link.manage', fetchImpl,
});
});
async function call(method, path, body) {
async function call(method, path, body) {
let res;
let res;
@@ -72,11 +72,11 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -72,11 +72,11 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the machine`);
if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the machine`);
return out;
return out;
}
}
- const principal = (id) => `/internal/node-principals/${encodeURIComponent(id)}`;
+ const principal = (id) => `/internal/link-principals/${encodeURIComponent(id)}`;
return {
return {
get: (id) => call('GET', principal(id)),
get: (id) => call('GET', principal(id)),
revoke: (id) => call('POST', `${principal(id)}/revoke`),
revoke: (id) => call('POST', `${principal(id)}/revoke`),
- pair: ({ subject, ref }) => call('POST', '/internal/node-pairings', { owner: { kind: 'user', subject }, ref }),
+ pair: ({ subject, ref }) => call('POST', '/internal/link-pairings', { owner: { kind: 'user', subject }, ref }),
// The machine's online state (plan L34): Network publishes it as the hub's machine events.
// The machine's online state (plan L34): Network publishes it as the hub's machine events.
status: (body) => call('POST', '/internal/machines/status', body),
status: (body) => call('POST', '/internal/machines/status', body),
};
};
@@ -40,7 +40,7 @@ const CAPABILITIES = new Set([
@@ -40,7 +40,7 @@ const CAPABILITIES = new Set([
'display.text',
'display.text',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup', 'sensor.tilt',
'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup', 'sensor.tilt',
'battery', 'ptz', 'camera',
'battery', 'ptz', 'camera',
- // A robot that carries no hardware of its own: the owner's Node forwards the panel's commands to a
+ // A robot that carries no hardware of its own: the owner's Link forwards the panel's commands to a
// local script with the relay plugin (relay.generic, plan T15 R9).
// local script with the relay plugin (relay.generic, plan T15 R9).
'relay',
'relay',
]);
]);
@@ -50,7 +50,7 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake
@@ -50,7 +50,7 @@ const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speake
/** Device drivers a mapping may name. `relay` is a Link plugin with no hardware map of its own. */
/** Device drivers a mapping may name. `relay` is a Link plugin with no hardware map of its own. */
// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe Link docs/hardware-looi.md).
// `looi`: the community's Generic LOOI driver, a Bluetooth bridge plugin (OpenVibe Link docs/hardware-looi.md).
-// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Node and nothing installed.
+// `phone`: a phone that is the device itself, from its browser (public/phone-device.js), no Link and nothing installed.
// `plugin`: a community OpenVibe Link plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;
// `plugin`: a community OpenVibe Link plugin, named in mapping.plugin, that the robot's owner installs (Contracts 0.160.0;
// community profiles are reviewed and signed before robots can use them, server/submissions.js).
// community profiles are reviewed and signed before robots can use them, server/submissions.js).
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);
const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'looi', 'onvif', 'sim', 'relay', 'phone', 'plugin']);
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
"name": "Generic relay",
"name": "Generic relay",
"vendor": "OpenVibe",
"vendor": "OpenVibe",
"kind": "bridge",
"kind": "bridge",
- "description": "A robot with no hardware of its own: the owner's Node runs the relay plugin and forwards the panel's button and video-click commands to a local script. The shape OpenVibe.Live's camp controls convert to (plan T15 R9 step 5); the buttons live in the robot's local profile.",
+ "description": "A robot with no hardware of its own: the owner's Link runs the relay plugin and forwards the panel's button and video-click commands to a local script. The shape OpenVibe.Live's camp controls convert to (plan T15 R9 step 5); the buttons live in the robot's local profile.",
"capabilities": ["relay"],
"capabilities": ["relay"],
"mapping": { "driver": "relay", "plugin": "relay" },
"mapping": { "driver": "relay", "plugin": "relay" },
"commands": {
"commands": {
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
*
*
* wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`
* wss://…/device one outbound connection per device. Auth by `Authorization: Bearer <credential>`
* on the upgrade, never a query string; an unauthenticated socket may only send
* on the upgrade, never a query string; an unauthenticated socket may only send
- * `pair`. A Network-paired machine presents its node token instead (bound on first
+ * `pair`. A Network-paired machine presents its Link token instead (bound on first
* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
* use) and renews it with `reauth` before the 330 s deadline, else close 4002. Heartbeats every 1 s; offline after 2 missed + 3 s grace. Commands are
* never queued for an offline device and never replayed after a reconnect. Jobs
* never queued for an offline device and never replayed after a reconnect. Jobs
* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`
* (platform.job-frame@1, server/jobs/dispatch.js) are the exception: an unacked `job`
@@ -25,7 +25,7 @@ const { WebSocketServer } = require('ws');
@@ -25,7 +25,7 @@ const { WebSocketServer } = require('ws');
const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
const { capabilities, ids, validate } = require('openvibe-contracts');
const { capabilities, ids, validate } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { json, iso, prefixedId } = require('./util');
-const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
+const { userPrincipal, verifyService, verifyLink, isLinkToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
const { getProfile } = require('./profiles');
const { createVoice } = require('./voice');
const { createVoice } = require('./voice');
const { computerCaps } = require('./machine-ai');
const { computerCaps } = require('./machine-ai');
@@ -57,7 +57,7 @@ const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs
@@ -57,7 +57,7 @@ const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs
const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);
const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);
/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */
/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */
const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');
const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');
-// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
+// The screen and the mouse and keyboard of a desk are the desk's own (Link's desk.screen, desk.input): a desk host with
// no screen of its own (no one signed in, Link v0.20.0 --desk-host) offers them on its desks only.
// no screen of its own (no one signed in, Link v0.20.0 --desk-host) offers them on its desks only.
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a
// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a
@@ -104,7 +104,7 @@ function parseCookies(header) {
@@ -104,7 +104,7 @@ function parseCookies(header) {
const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };
const bearer = (req) => { const h = String(req.headers.authorization || ''); return h.startsWith('Bearer ') ? h.slice(7).trim() : null; };
const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
const isSubject = (v) => typeof v === 'string' && ids.isSubjectId('user', v);
const DEVICE_PROTOCOL = 'ov.device';
const DEVICE_PROTOCOL = 'ov.device';
-const CREDENTIAL_RE = /^[A-Za-z0-9_-]{43}$/; // a device credential (32 random bytes, base64url); never a node token
+const CREDENTIAL_RE = /^[A-Za-z0-9_-]{43}$/; // a device credential (32 random bytes, base64url); never a Link token
/** A browser device's credential, offered as `ov.device.<credential>` beside `ov.device`; anything else → null. */
/** A browser device's credential, offered as `ov.device.<credential>` beside `ov.device`; anything else → null. */
function deviceCredentialFromProtocols(header) {
function deviceCredentialFromProtocols(header) {
const offered = String(header || '').split(',').map((s) => s.trim()).filter(Boolean);
const offered = String(header || '').split(',').map((s) => s.trim()).filter(Boolean);
@@ -227,7 +227,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -227,7 +227,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (cred) {
if (cred) {
conn.backlog = [];
conn.backlog = [];
try {
try {
- if (isNodeToken(cred)) await authenticateNode(conn, cred);
+ if (isLinkToken(cred)) await authenticateNode(conn, cred);
else await authenticateDevice(conn, cred);
else await authenticateDevice(conn, cred);
sendHello(conn);
sendHello(conn);
await sendConfig(conn);
await sendConfig(conn);
@@ -265,17 +265,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -265,17 +265,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
attachDevice(conn, device);
attachDevice(conn, device);
}
}
- /** A node token: verified offline, then bound to its device (created on first use from Network's record). */
+ /** A Link token: verified offline, then bound to its device (created on first use from Network's record). */
async function authenticateNode(conn, nodeToken) {
async function authenticateNode(conn, nodeToken) {
- const r = await verifyNode(nodeToken, tokenOpts());
- if (!r.ok) throw new Error(`node token refused: ${r.code}`);
+ const r = await verifyLink(nodeToken, tokenOpts());
+ if (!r.ok) throw new Error(`Link token refused: ${r.code}`);
const device = await domain.devices.bindNode(r.principal);
const device = await domain.devices.bindNode(r.principal);
conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };
conn.node = { principal: r.principal, reauthBy: now() + config.device.nodeReauthMs };
attachDevice(conn, device);
attachDevice(conn, device);
}
}
- /** `reauth`: a fresh node token for the same principal moves the deadline; anything else leaves it. */
+ /** `reauth`: a fresh Link token for the same principal moves the deadline; anything else leaves it. */
async function onReauth(conn, msg) {
async function onReauth(conn, msg) {
- const r = await verifyNode(typeof msg.token === 'string' ? msg.token : '', tokenOpts());
+ const r = await verifyLink(typeof msg.token === 'string' ? msg.token : '', tokenOpts());
if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);
if (!r.ok) return sendError(conn, 'bot.reauth_refused', r.reason);
if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');
if (r.principal !== conn.node.principal) return sendError(conn, 'bot.reauth_refused', 'the token is for another machine');
conn.node.reauthBy = now() + config.device.nodeReauthMs;
conn.node.reauthBy = now() + config.device.nodeReauthMs;
@@ -343,7 +343,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -343,7 +343,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
estop_latched: robot ? !!robot.estop_latched : false,
estop_latched: robot ? !!robot.estop_latched : false,
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
// Vibe by voice: the machine listens for its call word only while its owner has voice on.
...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
- // What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Node
+ // What the machine does for the network (plan L34): the roles the owner gave it on the hub. The Link
// turns them on at its next check-in (and at once when they change while it is connected).
// turns them on at its next check-in (and at once when they change while it is connected).
roles: json(conn.device.roles, []),
roles: json(conn.device.roles, []),
});
});
@@ -530,7 +530,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -530,7 +530,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
await domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });
await domain.audit.record({ robotId: String(msg.robot_id).slice(0, 64), deviceId: conn.device.id, operatorKind: 'device', kind: 'estop_state', value: { latched }, result: 'refused', reason: 'bot.forbidden' });
return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');
return sendError(conn, 'bot.forbidden', 'this device is not attached to that robot');
}
}
- // local_stop: the latch is held on the machine itself (openvibe-node stop, a driver's own stop button), which
+ // local_stop: the latch is held on the machine itself (openvibe-link stop, a driver's own stop button), which
// only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.
// only a release on the machine lifts; the owner's clear does not. Shown on the panel, never trusted for more.
const local = latched && msg.local_stop === true;
const local = latched && msg.local_stop === true;
const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;
const reason = local && typeof msg.reason === 'string' ? msg.reason.replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, 120) || null : null;
@@ -824,7 +824,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -824,7 +824,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
}
}
} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {
} else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {
- return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
+ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Link v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-link, --computer)`, summary);
}
}
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
// one per agent task) needs a machine that runs several (Link v0.19.0: desk.named). Kind desk names its desk in
// one per agent task) needs a machine that runs several (Link v0.19.0: desk.named). Kind desk names its desk in
@@ -1074,7 +1074,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -1074,7 +1074,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
function checkHeartbeats() {
function checkHeartbeats() {
for (const conn of [...deviceConns.values()]) {
for (const conn of [...deviceConns.values()]) {
if (conn.node && now() > conn.node.reauthBy) {
if (conn.node && now() > conn.node.reauthBy) {
- // The node token lapsed with no valid reauth: the machine must authenticate again.
+ // The Link token lapsed with no valid reauth: the machine must authenticate again.
try { conn.ws.close(4002, 'reauth required'); } catch { /* gone */ }
try { conn.ws.close(4002, 'reauth required'); } catch { /* gone */ }
dropDevice(conn);
dropDevice(conn);
continue;
continue;
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
/**
/**
* Community robot profiles (plan T15 "people submit bots and guides and Bot connects them": submissions are reviewed
* Community robot profiles (plan T15 "people submit bots and guides and Bot connects them": submissions are reviewed
- * and signed before they run on anyone's Node). Anyone signed in submits a profile whose id is under `community.`,
+ * and signed before they run on anyone's Link). Anyone signed in submits a profile whose id is under `community.`,
* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the
* with the driver's source, a setup guide and a licence; an OpenVibe admin approves or rejects it. Approving signs the
* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so
* profile (Ed25519 over its canonical JSON, BOT_PROFILE_SIGNING_KEY) and puts it in the catalogue (robot_profiles), so
* robots can be added with it; the signature and Bot's public key are published so a Link or anyone can check it.
* robots can be added with it; the signature and Bot's public key are published so a Link or anyone can check it.
@@ -20,7 +20,7 @@ const uiKit = require('openvibe-shared/kit');
@@ -20,7 +20,7 @@ const uiKit = require('openvibe-shared/kit');
const { esc } = require('./render');
const { esc } = require('./render');
const { framePage } = require('./home');
const { framePage } = require('./home');
-const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Node/blob/main';
+const NODE_BLOB = 'https://github.com/OpenVibers/OpenVibe.Link/blob/main';
const CONNECT = {
const CONNECT = {
browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },
browser: { label: 'Simulated', text: 'Runs inside Bot and in your browser: nothing to buy or install.' },
onboard: { label: 'On-board', text: 'The OpenVibe Link runs on the robot\'s own computer and drives its hardware.' },
onboard: { label: 'On-board', text: 'The OpenVibe Link runs on the robot\'s own computer and drives its hardware.' },
@@ -204,7 +204,7 @@ ${parts}
@@ -204,7 +204,7 @@ ${parts}
${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>
${model.community ? `<section class="dir-sec card"><h2>From the community</h2><p>Submitted by ${esc(ownerName(model.community.by))} and reviewed by an OpenVibe admin.${model.community.license ? ` Licence: ${esc(model.community.license)}.` : ''}</p>
<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}
<ul>${model.community.driver_url ? `<li>Driver: ${link(model.community.driver_url, model.community.driver_url)}</li>` : ''}${model.community.guide_url ? `<li>Setup guide: ${link(model.community.guide_url, model.community.guide_url)}</li>` : ''}
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
<li>Signed by OpenVibe.Bot (Ed25519, key <code>${esc(model.community.key_id || '')}</code>): ${link(`/api/v1/profiles/${model.id}`, 'the profile and its signature')} · ${link('/api/v1/profiles/signing-key', 'the public key')}</li>
-<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-node profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
+<li>Check it on your machine before you install the driver (OpenVibe Link v0.6.0 or later): <code>openvibe-link profile verify ${esc(model.id)}${model.community.key_id ? ` --key-id ${esc(model.community.key_id)}` : ''}</code></li></ul>
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<p class="muted">Install the driver from its source on the robot's computer; OpenVibe.Bot never runs it.</p></section>` : ''}
<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
<section class="dir-sec card"><h2>Safety</h2><p class="muted">Every command passes Bot's gate: roles, the owner's limits and cooldowns, and a latched e-stop only the owner clears. The device stops the motors by itself when commands stop arriving, without asking the network.</p></section>`;
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
return shell({ config, path: `/models/${model.id}`, title: model.name, description: `${model.name} on OpenVibe.Bot: how it connects, the panel it gets and how to set it up.`, body });
@@ -21,7 +21,7 @@ const { framePage } = require('./home');
@@ -21,7 +21,7 @@ const { framePage } = require('./home');
const simRover = require('../profiles/sim.rover.json');
const simRover = require('../profiles/sim.rover.json');
// The driver protocol and the bundled drivers live in OpenVibe Link (the Link core and its plugins/ tree).
// The driver protocol and the bundled drivers live in OpenVibe Link (the Link core and its plugins/ tree).
-const NODE = 'https://github.com/OpenVibers/OpenVibe.Node';
+const NODE = 'https://github.com/OpenVibers/OpenVibe.Link';
const TREE = `${NODE}/tree/main`;
const TREE = `${NODE}/tree/main`;
const BLOB = `${NODE}/blob/main`;
const BLOB = `${NODE}/blob/main`;
@@ -41,7 +41,7 @@ function docsPage({ config, path, title, description, content }) {
@@ -41,7 +41,7 @@ function docsPage({ config, path, title, description, content }) {
<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
<nav class="docs-side" aria-label="Build for OpenVibe.Bot"><p class="docs-side-title">Build</p>${side}<div class="docs-side-rule"></div><a href="/robots">Your robots</a></nav>
<main id="main" class="docs-main">
<main id="main" class="docs-main">
${content}
${content}
-<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Node')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
+<p class="docs-foot muted">The Link and its bundled drivers are in ${out(`${TREE}/plugins`, 'OpenVibers/OpenVibe.Link')}; the full protocol is ${out(`${BLOB}/docs/protocol.md`, 'protocol.md')} and the plugin contract is ${out(`${BLOB}/docs/plugins.md`, 'plugins.md')}.</p>
</main>
</main>
</div>`;
</div>`;
return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
return framePage({ config, title: title.includes('OpenVibe.Bot') ? title : `${title} · OpenVibe.Bot`, description, canonicalPath: path, body, head: '<link rel="stylesheet" href="/docs/docs.css">' });
@@ -70,8 +70,8 @@ ${kit.pageHead({ title: `Build for OpenVibe.Bot`, icon: 'code', back: { href: '/
@@ -70,8 +70,8 @@ ${kit.pageHead({ title: `Build for OpenVibe.Bot`, icon: 'code', back: { href: '/
<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
<p>${inLink('/robots', 'Your robots')} · ${inLink('/install', 'Install a device')}</p></section>
<h2>Where things live</h2>
<h2>Where things live</h2>
<ul>
<ul>
-<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Node')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>
-<li>Drivers are Node <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>
+<li>The <b>OpenVibe Link</b> (${out(NODE, 'OpenVibers/OpenVibe.Link')}) is the agent on the robot's machine: it pairs, holds the control link, and runs the drivers.</li>
+<li>Drivers are Link <b>plugins</b> (${out(`${TREE}/plugins`, 'plugins/')}), written against the Python runtime ${out(`${TREE}/plugins/sdk`, 'openvibe_plugin')} or any language over the raw protocol.</li>
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li>
<li>Bundled drivers: ${out(`${TREE}/plugins/dryrun`, 'dryrun')}, ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')}, ${out(`${TREE}/plugins/cozmo`, 'cozmo')}, ${out(`${TREE}/plugins/relay`, 'relay')} and ${out(`${TREE}/plugins/ros2`, 'ros2')} (any ROS 2 robot on <code>cmd_vel</code>, through rosbridge or rclpy; profiles <code>ros2.diffdrive</code> and <code>ros2.holonomic</code>, installer <code>--driver ros2</code>). LOOI's driver is written by LOOI owners against the contract in ${out(`${TREE}/docs/hardware-looi.md`, 'docs/hardware-looi.md')} (profile <code>looi</code>, installer <code>--driver looi</code>).</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
<li>Profiles ship in this repository (${inLink('/api/v1/profiles', 'GET /api/v1/profiles')}) and are validated by the same loader the pages use.</li>
</ul>
</ul>
@@ -96,7 +96,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
@@ -96,7 +96,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
<ul>
<ul>
<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Link runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>
<li><b>on-board</b> (<code>kind: "onboard"</code>) — the Link runs on the robot itself, over its own GPIO/I²C bus. Example: ${out(`${TREE}/plugins/adeept_adr036`, 'adeept_adr036')} on a Raspberry Pi.</li>
<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Link runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>
<li><b>bridge</b> (<code>kind: "bridge"</code>) — the Link runs on a computer next to the robot and drives it over the robot's own link. Example: ${out(`${TREE}/plugins/cozmo`, 'cozmo')} through PyCozmo, where the computer joins the robot's Wi-Fi on one interface and the internet on another.</li>
-<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Node on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>
+<li><b>server-side</b> (<code>kind: "server"</code>) — there is no Link on the robot at all: Bot attaches the device itself from the server. Examples: the ${inLink('/docs/profiles', 'simulated rover')} and an ONVIF camera.</li>
</ul>
</ul>
<p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>
<p>The pairing's <code>device_kind</code> is <code>onboard</code> or <code>bridge</code>; a server-side robot is not installed on a machine.</p></section>
@@ -104,7 +104,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
@@ -104,7 +104,7 @@ ${kit.pageHead({ title: `Write a driver`, icon: 'code', back: { href: '/', label
<ol>
<ol>
<li>The core starts the process and sends <code>hello</code>.</li>
<li>The core starts the process and sends <code>hello</code>.</li>
<li>The driver answers <code>describe</code> <b>before touching any hardware</b>.</li>
<li>The driver answers <code>describe</code> <b>before touching any hardware</b>.</li>
-<li>Unless <code>hello.probe</code> is true, the driver opens its hardware with every actuator stopped and sends <code>ready</code> (or <code>fault</code> if it cannot). Probe mode is used by <code>openvibe-node pair</code> and <code>openvibe-node plugins</code>: describe, then wait for EOF without opening anything.</li>
+<li>Unless <code>hello.probe</code> is true, the driver opens its hardware with every actuator stopped and sends <code>ready</code> (or <code>fault</code> if it cannot). Probe mode is used by <code>openvibe-link pair</code> and <code>openvibe-link plugins</code>: describe, then wait for EOF without opening anything.</li>
<li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li>
<li>The core sends <code>heartbeat</code> every 250 ms and commands as they arrive; the driver answers each command with <code>ack</code> or <code>nack</code>, and sends <code>telemetry</code>, <code>event</code> and <code>video</code> whenever it has them.</li>
<li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li>
<li>On shutdown the core sends <code>stop</code> and closes stdin; the driver stops everything and exits. After 2 s it is killed.</li>
</ol>
</ol>
@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
@@ -140,7 +140,7 @@ ${message('video', 'a frame written to a file; the core reads and encodes it', '
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
<li>when its own sensors say so (for example a cliff or a pick-up).</li>
</ul>
</ul>
<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
<p><code>stop</code> must be idempotent and safe at any time, including before setup. <code>estop</code> latches: keep refusing motion until the core sends <code>resume</code>. The e-stop is the owner's and is latched across reconnects by Bot; a driver never clears it.</p>
-<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-node stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-node resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
+<p><b>A stop on the robot itself.</b> If your robot or bridge has its own stop (a button, a hotkey, a sensor you trust), send <code>{"op": "local_stop", "reason": "…"}</code> (Python: <code>self.local_stop("reason")</code>), or run <code>openvibe-link stop</code> from a terminal. That sets the Link's <i>local</i> latch: every actuator stops within a quarter second, the panel shows the robot as stopped on the robot, and the owner's remote clear cannot lift it. Only <code>openvibe-link resume</code> on that machine releases it, and the owner then clears the panel's e-stop. It works even when the Link is not answering and survives restarts. Handlers must not block — slow work (speech synthesis, file I/O) goes on a thread.</p></section>
<section class="card"><h2>A minimal driver in Python</h2>
<section class="card"><h2>A minimal driver in Python</h2>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
<p>The Python runtime (<code>openvibe_plugin</code>, ${out(`${TREE}/plugins/sdk`, 'plugins/sdk')}) implements every safety rule above, so a driver only says how to move its hardware. <code>motion_kinds</code> is the set of kinds the runtime stops at their deadline.</p>
@@ -221,10 +221,10 @@ stop()`)}</section>
@@ -221,10 +221,10 @@ stop()`)}</section>
<section class="card"><h2>Test it</h2>
<section class="card"><h2>Test it</h2>
<p><b>With the <code>dryrun</code> driver</b> (${out(`${TREE}/plugins/dryrun`, 'plugins/dryrun')}): it accepts every kind, logs it, reports simulated battery and distance, and asks the core for the test-pattern camera. Add it to your <code>config.json</code> plugins and set <code>config.record: "<path>"</code> to append one JSON line per <code>setup</code>, <code>command</code> and <code>stop</code> — end-to-end tests read that file to check what reached the "robot".</p>
<p><b>With the <code>dryrun</code> driver</b> (${out(`${TREE}/plugins/dryrun`, 'plugins/dryrun')}): it accepts every kind, logs it, reports simulated battery and distance, and asks the core for the test-pattern camera. Add it to your <code>config.json</code> plugins and set <code>config.record: "<path>"</code> to append one JSON line per <code>setup</code>, <code>command</code> and <code>stop</code> — end-to-end tests read that file to check what reached the "robot".</p>
${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.jsonl" } } ] }')}
${code('{ "plugins": [ { "name": "dryrun", "config": { "record": "/tmp/dryrun.jsonl" } } ] }')}
-<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Node. Use it to see exactly what your profile shows before wiring a driver.</p></section>
+<p><b>With the simulated rover</b> on this site: add a robot and pick the <b>Simulated rover</b> profile (${inLink('/robots', 'Your robots')}). Bot runs it in-process, so the panel renders from the profile and drives with no hardware and no Link. Use it to see exactly what your profile shows before wiring a driver.</p></section>
<section class="card"><h2>Contribute it</h2>
<section class="card"><h2>Contribute it</h2>
-<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Node')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
+<p>Open a pull request to ${out(NODE, 'OpenVibers/OpenVibe.Link')} adding your driver under ${out(`${TREE}/plugins`, 'plugins/')}, with the robot profile it pairs with. The profile is validated by Bot's loader (<code>bot.robot-profile@1</code> plus the capability, driver, widget and command registries), and the driver is reviewed before it runs on anyone's Link. A genuinely new capability, driver or widget name is one line in Bot's registries — never a schema change.</p></section>
`;
`;
return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Link: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
return docsPage({ config, path: '/docs/drivers', title: 'Write a driver', description: 'How to write a driver for the OpenVibe Link: JSON lines over stdio, the lifecycle, every message, the safety rules, a Python example and how to test it.', content });
}
}
@@ -369,11 +369,11 @@ ${code(COMMISSION_CALL)}
@@ -369,11 +369,11 @@ ${code(COMMISSION_CALL)}
const COMPUTER_INSTALL = `curl -fsSL https://openvibe.link/install | sh -s -- \\
const COMPUTER_INSTALL = `curl -fsSL https://openvibe.link/install | sh -s -- \\
--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;
--network https://openvibe.network --pairing pair_… --code ABCD-1234 --computer ask`;
-const COMPUTER_APPROVE = `openvibe-node approvals # what waits: id, kind, what it would do
-openvibe-node approve cmd_01J… # let it run
-openvibe-node deny cmd_01J… # refuse it
-openvibe-node stop # the kill switch: observe only until resume
-openvibe-node tray # the same in your notification area (Linux, Windows)`;
+const COMPUTER_APPROVE = `openvibe-link approvals # what waits: id, kind, what it would do
+openvibe-link approve cmd_01J… # let it run
+openvibe-link deny cmd_01J… # refuse it
+openvibe-link stop # the kill switch: observe only until resume
+openvibe-link tray # the same in your notification area (Linux, Windows)`;
const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…/commands \\
const COMPUTER_CALL = `curl -X POST https://openvibe.bot/api/v1/machines/dev_…/commands \\
-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
-H "Authorization: Bearer $OPENVIBE_TOKEN" -H "Content-Type: application/json" \\
-d '{"kind": "exec", "value": {"argv": ["git", "status", "--short"], "cwd": "/home/you/site"}}'
-d '{"kind": "exec", "value": {"argv": ["git", "status", "--short"], "cwd": "/home/you/site"}}'
@@ -386,7 +386,7 @@ ${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { h
@@ -386,7 +386,7 @@ ${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { h
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
${code(COMPUTER_INSTALL)}
${code(COMPUTER_INSTALL)}
-<p class="muted">The installer is also at <a href="https://openvibe.bot/install">openvibe.bot/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">node.openvibe.network</a>.</p>
+<p class="muted">The installer is also at <a href="https://openvibe.link/install">openvibe.link/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">openvibe.link</a>.</p>
<p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>
<p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>
<section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2>
<section class="card" aria-labelledby="levels-h"><h2 id="levels-h">Levels, set on the computer</h2>
@@ -401,7 +401,7 @@ ${code(COMPUTER_INSTALL)}
@@ -401,7 +401,7 @@ ${code(COMPUTER_INSTALL)}
<section class="card" aria-labelledby="approve-h"><h2 id="approve-h">Approving on the computer</h2>
<section class="card" aria-labelledby="approve-h"><h2 id="approve-h">Approving on the computer</h2>
<p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p>
<p>At level <b>ask</b>, a request waits up to two minutes for your answer. You can answer in three places:</p>
<ul>
<ul>
-<li><b>The tray</b> (Linux and Windows). <code>openvibe-node tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>
+<li><b>The tray</b> (Linux and Windows). <code>openvibe-link tray</code> lists what waits, each with <b>Approve</b> and <b>Deny</b>, beside the stop and resume. It raises a notification with the same two buttons, and its icon turns amber while something waits. The installer starts it when you sign in.</li>
<li><b>A notification or dialog</b> from the Link itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li>
<li><b>A notification or dialog</b> from the Link itself when it runs in your session: buttons on Linux, a dialog whose default is Deny on macOS.</li>
<li><b>The command line</b>:</li>
<li><b>The command line</b>:</li>
</ul>
</ul>
@@ -422,7 +422,7 @@ ${code(COMPUTER_APPROVE)}
@@ -422,7 +422,7 @@ ${code(COMPUTER_APPROVE)}
<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>
<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>
<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>
<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>
<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
-<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-node stop</code>) stops them all.</li>
+<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-link stop</code>) stops them all.</li>
</ul>
</ul>
<p>How many run at once depends on the machine's memory (one whole desk per 2 GB by default; a browser desk counts as half). The details are in ${out(`${BLOB}/docs/computer.md`, 'computer.md')}.</p></section>
<p>How many run at once depends on the machine's memory (one whole desk per 2 GB by default; a browser desk counts as half). The details are in ${out(`${BLOB}/docs/computer.md`, 'computer.md')}.</p></section>
@@ -442,8 +442,8 @@ ${code(COMPUTER_APPROVE)}
@@ -442,8 +442,8 @@ ${code(COMPUTER_APPROVE)}
<ul>
<ul>
<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
-<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>
-<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>
+<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-link status</code> shows it while it runs.</li>
+<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-link stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>
</ul>
</ul>
<p>From the API, with your own token: <code>POST /api/v1/machines/:id/screen</code> (<code>seconds</code>, <code>fps</code> from 1 to 5) starts it, <code>GET</code> answers <code>signaling_url</code> once the computer is sending, and <code>DELETE</code> stops it.</p></section>
<p>From the API, with your own token: <code>POST /api/v1/machines/:id/screen</code> (<code>seconds</code>, <code>fps</code> from 1 to 5) starts it, <code>GET</code> answers <code>signaling_url</code> once the computer is sending, and <code>DELETE</code> stops it.</p></section>
@@ -468,7 +468,7 @@ The page answers with its URL, title, text and links. The browser is the agent's
@@ -468,7 +468,7 @@ The page answers with its URL, title, text and links. The browser is the agent's
<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li>
<li><code>ai</code>: <code>model</code> (one of its models), <code>messages</code> (<code>role</code> and <code>content</code>), <code>max_tokens</code>, <code>temperature</code> → <code>message</code>, <code>usage</code> and <code>ms</code>, from the computer's own model server. <code>POST /api/v1/machines/:id/ai-models</code> with <code>on</code> is the switch above; OpenVibe's own AI uses the models only while it is on.</li>
<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
</ul>
</ul>
-<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>nod_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
+<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level, its <code>node_id</code> on Network (<code>lnk_…</code>) and, for a machine running OpenVibe.Codes agents, the <code>fleet</code> its Link last reported (<code>fleet_at</code> says when): each agent's id, whether it is installed and signed in, and each limit window's label, used and limit and when it frees — labels, numbers and times only, never a key, a token or an account name. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
`;
`;
return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Link: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });
return docsPage({ config, path: '/docs/computers', title: 'Your computer for your agents', description: 'Let your agents use your own computer through the OpenVibe Link: a desk of its own for each task (watch it and take it over), its terminal, files, screen, mouse and keyboard, at the level you set on the computer, with approvals there and a kill switch; and let your AI think with the models you run on it.', content });
}
}
@@ -188,7 +188,7 @@ function moreSection({ config, kit: k, models }) {
@@ -188,7 +188,7 @@ function moreSection({ config, kit: k, models }) {
const model = k ? models.find((m) => m.id === k.profile_id) : null;
const model = k ? models.find((m) => m.id === k.profile_id) : null;
const kitBody = k && k.parts && k.parts.length
const kitBody = k && k.parts && k.parts.length
? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Link drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>
? `<p class="hm-fold-lede">What to get for the robot OpenVibe.Bot was built around. The OpenVibe Link drives its Robot HAT directly, publishes the Pi camera and stops the motors the moment commands stop.</p><ul class="hm-parts">${k.parts.map((p) => `<li><b>${esc(p.qty || 1)}×</b><span>${esc(p.name)}${p.note ? `<small>${esc(p.note)}</small>` : ''}</span></li>`).join('')}</ul>
-<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/hardware-adeept.md', 'Setup guide for Link', 'sc-btn')}</p>`
+<p class="hm-kit-actions">${link(k.build_guide_url, 'Adeept\'s build guide', 'sc-btn')}${model ? ` ${link(`/models/${model.id}`, 'Its model page', 'sc-btn')}` : ''} ${link('https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/hardware-adeept.md', 'Setup guide for Link', 'sc-btn')}</p>`
: '';
: '';
const build = rows([
const build = rows([
['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Link: every message and the safety rules.'],
['/docs/drivers', 'Write a driver', 'A separate program in any language that speaks JSON lines with the OpenVibe Link: every message and the safety rules.'],
@@ -202,7 +202,7 @@ function moreSection({ config, kit: k, models }) {
@@ -202,7 +202,7 @@ function moreSection({ config, kit: k, models }) {
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>
html: `<section class="sc-sec hm-sec hm-more-sec" id="more" aria-labelledby="more-h"><h2 class="ovk-titled hm-h">${sectionIcon('grid')}More on OpenVibe.Bot</h2>
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'panel', title: 'What the panel does', meta: 'controls, safety, sharing', body: panel })}
${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
${kit.fold({ id: 'automate', title: 'Let it run without you', meta: 'routines, webhooks, your agent', body: `<p class="hm-fold-lede">They all go through the gate a person does: the same limits, the same e-stop, the same audit.</p>${automate}` })}
-${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Node', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
+${kit.fold({ id: 'computers', title: 'Your computers, for your agents and your AI', meta: 'the same Link', body: `<p class="hm-fold-lede">The same OpenVibe Link runs on your own computers. It connects out, nobody else's work reaches them, and the limits you set on each computer always win.</p>${computers}` })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kit.fold({ id: 'code', title: 'The same robot from your own code', meta: 'REST and the SDK', body: api })}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
${kitBody ? kit.fold({ id: 'kit', title: `The first kit: ${k.name}`, meta: plural(k.parts.length, 'part', 'parts'), body: kitBody }) : ''}
${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${kit.button({ href: '/docs', label: 'All build pages', icon: 'code', kind: 'quiet' })}</p>` })}</section>`,
${kit.fold({ id: 'build', title: 'Build for OpenVibe.Bot', meta: 'drivers, profiles, the source', body: `${build}<p class="hm-fold-lede">${kit.button({ href: '/docs', label: 'All build pages', icon: 'code', kind: 'quiet' })}</p>` })}</section>`,
@@ -719,11 +719,11 @@ ${kit.pageHead({ title: `Sign in to continue`, icon: 'gear', back: { href: '/',
@@ -719,11 +719,11 @@ ${kit.pageHead({ title: `Sign in to continue`, icon: 'gear', back: { href: '/',
}
}
/**
/**
- * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.bot/install | sh` with no code.
+ * /link (server/claims.js): the code a device shows after `curl -fsSL https://openvibe.link/install | sh` with no code.
* renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;
* renderLinkPage asks for it; renderClaimPage shows what that device found and the choices its robot starts with;
* renderClaimApproved follows the device as it pairs on its own.
* renderClaimApproved follows the device as it pairs on its own.
*/
*/
-function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.bot/install | sh' } = {}) {
+function renderLinkPage({ code = '', error = null, installer = 'curl -fsSL https://openvibe.link/install | sh' } = {}) {
const body = `${topbar('<span class="crumb">Add a device</span>')}
const body = `${topbar('<span class="crumb">Add a device</span>')}
<main class="page link">
<main class="page link">
${kit.pageHead({ title: `Add a device`, icon: 'gear', back: { href: '/', label: 'Home' } })}
${kit.pageHead({ title: `Add a device`, icon: 'gear', back: { href: '/', label: 'Home' } })}
@@ -949,7 +949,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -949,7 +949,7 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
const list = machines.length
const list = machines.length
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
- + `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
+ + `${m.agent_version ? ` <span class="pill">Link ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
+ firstRunRow(m) + computerRow(m) + voiceRow(m)
+ firstRunRow(m) + computerRow(m) + voiceRow(m)
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
@@ -963,9 +963,9 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
@@ -963,9 +963,9 @@ function renderMachinesPage({ machines = [], pairing = null, phoneReady = false
<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>
<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>
<ul class="choices" aria-labelledby="add-h">
<ul class="choices" aria-labelledby="add-h">
${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns Link\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}
${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns Link\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}
-${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.')}
-${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}
-${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/ai.md">How it works</a>.')}
+${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-link approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md">How it works</a>.')}
+${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}
+${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/ai.md">How it works</a>.')}
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul>
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
@@ -973,8 +973,8 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
@@ -973,8 +973,8 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
const body = `${topbar('<span class="crumb">Machines</span>')}
const body = `${topbar('<span class="crumb">Machines</span>')}
<main class="page">
<main class="page">
-${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-node stop) always win.` })}
-<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
+${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-link stop) always win.` })}
+<p class="muted"><a href="https://openvibe.link/machines">All your machines are on openvibe.link/machines</a></p>
${list}
${list}
${add}
${add}
${phoneReady ? '<p class="muted">In your pocket: <a href="/link/phone">Link on your phone</a> adds the Android app, which shows your agents\' approvals and takes files from any share sheet.</p>' : ''}
${phoneReady ? '<p class="muted">In your pocket: <a href="/link/phone">Link on your phone</a> adds the Android app, which shows your agents\' approvals and takes files from any share sheet.</p>' : ''}
@@ -1112,7 +1112,7 @@ ${takeOver}
@@ -1112,7 +1112,7 @@ ${takeOver}
<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing it</button></form>
<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing it</button></form>
<form method="post" action="/machines/${esc(machine.id)}/screen">${desk !== 'main' ? `<input type="hidden" name="desk" value="${esc(desk)}">` : ''}<button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
<form method="post" action="/machines/${esc(machine.id)}/screen">${desk !== 'main' ? `<input type="hidden" name="desk" value="${esc(desk)}">` : ''}<button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
</div>
</div>
-<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
+<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-link stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
${sharesBlock(machine, shares, madeLink, madeShare, hasDesk, hasScreen)}
${sharesBlock(machine, shares, madeLink, madeShare, hasDesk, hasScreen)}
</main>`;
</main>`;
return page(`${name}: screen`, body, { scripts: ['/panel/panel.js', '/panel/screen-share.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
return page(`${name}: screen`, body, { scripts: ['/panel/panel.js', '/panel/screen-share.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
@@ -1152,7 +1152,7 @@ ${kit.pageHead({ title: `Make this phone a robot`, icon: 'gear', back: { href: '
@@ -1152,7 +1152,7 @@ ${kit.pageHead({ title: `Make this phone a robot`, icon: 'gear', back: { href: '
}
}
/**
/**
- * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (node.first_run), so
+ * /admin/first-run: the measured first run, reviewers only. One row per step the Link reported (link.first_run), so
* the number that reach each step and the median time between them answer whether installs get to a working machine.
* the number that reach each step and the median time between them answer whether installs get to a working machine.
*/
*/
function renderFirstRunPage({ rows = [] }) {
function renderFirstRunPage({ rows = [] }) {
@@ -1200,12 +1200,12 @@ function renderFirstRunPage({ rows = [] }) {
@@ -1200,12 +1200,12 @@ function renderFirstRunPage({ rows = [] }) {
? `<ul class="dir-list">${latest.map(({ id, last, at }) => {
? `<ul class="dir-list">${latest.map(({ id, last, at }) => {
const row = byStep.get(last).find((r) => r.device_id === id);
const row = byStep.get(last).find((r) => r.device_id === id);
return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`
return `<li class="dir-card"><div class="dir-card-link"><code>${esc(id)}</code><span class="dir-card-text">${esc(last.replace('_', ' '))}`
- + `${row && row.agent_version ? ` · Node ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`
+ + `${row && row.agent_version ? ` · Link ${esc(row.agent_version)}` : ''}${row && row.os ? ` · ${esc(row.os)}` : ''}`
+ `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;
+ `${at ? ` · <time datetime="${esc(new Date(at).toISOString())}" data-local-time>${esc(new Date(at).toISOString().slice(0, 16).replace('T', ' '))}</time> UTC` : ''}</span></div></li>`;
}).join('')}</ul>`
}).join('')}</ul>`
: '<p class="muted">No machine has reported a first run yet.</p>';
: '<p class="muted">No machine has reported a first run yet.</p>';
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
const body = `<p class="dir-crumb"><a href="/robots/submissions">Review</a> / First runs</p>
-<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (node.first_run); nothing here names a person, folder or address.` })}</div>
+<div class="dir-hero">${kit.pageHead({ title: `First run`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `How many machines reach each step of OpenVibe Link's guided first run, and the median time from the step before. A machine reports one event per step over its own device link (link.first_run); nothing here names a person, folder or address.` })}</div>
<section class="dir-sec"><h2>The funnel</h2>
<section class="dir-sec"><h2>The funnel</h2>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<table class="dir-table"><caption class="sr-only">First-run steps, machines and median time</caption>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
<thead><tr><th scope="col">Step</th><th scope="col">Machines</th><th scope="col">Median from the step before</th></tr></thead>
@@ -47,7 +47,7 @@ const kits = require('../kits');
@@ -47,7 +47,7 @@ const kits = require('../kits');
// testing). Read on each request, so setting it in the env file and restarting is all a release needs.
// testing). Read on each request, so setting it in the env file and restarting is all a release needs.
function linkApkUrl() {
function linkApkUrl() {
const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
- return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Node\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
+ return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
}
}
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
@@ -127,8 +127,8 @@ const LLMS_PAGES = [
@@ -127,8 +127,8 @@ const LLMS_PAGES = [
},
},
{
{
path: '/docs/computers', title: 'Your computer for your agents',
path: '/docs/computers', title: 'Your computer for your agents',
- note: 'The Node\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',
- text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-node approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.bot/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',
+ note: 'The Link\'s computer plugin: levels set on the computer, approvals there, the agent switch and the API.',
+ text: 'How to let your agents use your own computer through the OpenVibe Link\'s computer plugin. Installed with --computer, it runs as your own account, never root, and its only folder is your home. The level is set on the computer only: observe (screenshots, listing and reading files), ask (programs and writes each approved on the computer with openvibe-link approve), trusted (inside its folders without asking) or full. Programs are argv, never a shell line, time-limited and output-capped. The local stop drops it to observe. The switch Let my agent use this computer on openvibe.link/machines lets your OpenVibe.Actor agent use it as you. Bot\'s machine commands take exec, fs and screen, and the computer\'s answer comes back in data.',
},
},
{
{
path: '/updates', title: 'What shipped on OpenVibe.Bot',
path: '/updates', title: 'What shipped on OpenVibe.Bot',
@@ -475,7 +475,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -475,7 +475,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
}));
}));
// The guided first run's funnel, reviewers only (OpenVibe Link docs/install.md): the Link reports one
// The guided first run's funnel, reviewers only (OpenVibe Link docs/install.md): the Link reports one
- // node.first_run telemetry event per step, and Bot stores one row per device and step.
+ // link.first_run telemetry event per step, and Bot stores one row per device and step.
r.get('/admin/first-run', page(async (req, res) => {
r.get('/admin/first-run', page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');
if (!isReviewer(me)) throw new BotError(404, 'bot.not_found', 'no such page');
@@ -615,7 +615,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -615,7 +615,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
// Read online first: the first-run query must not make a machine look offline because the page took longer.
// Read online first: the first-run query must not make a machine look offline because the page took longer.
const online = !!(hub && hub.isOnline(d.id));
const online = !!(hub && hub.isOnline(d.id));
- // What the guided first run's one job found on this machine (Node's node.first_run first_job event).
+ // What the guided first run's one job found on this machine (Link's link.first_run first_job event).
const firstRun = await domain.devices.firstRunOf(d.id);
const firstRun = await domain.devices.firstRunOf(d.id);
return {
return {
...d, online, robot: robot ? robot.name : null,
...d, online, robot: robot ? robot.name : null,
@@ -26,7 +26,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -26,7 +26,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
};
};
const result = (id) => op.waitFor((m) => m.type === 'command_result' && m.id === id);
const result = (id) => op.waitFor((m) => m.type === 'command_result' && m.id === id);
- // ── 1. the owner creates the robot; the fake device pairs over HTTP like `openvibe-node pair` ────
+ // ── 1. the owner creates the robot; the fake device pairs over HTTP like `openvibe-link pair` ────
await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {
await check('step 1: an owner robot pairs over HTTP with --robot/--name and gets a whip_url', async () => {
const created = await t.robot(alex, { profile_id: 'adeept.adr036' });
const created = await t.robot(alex, { profile_id: 'adeept.adr036' });
robot = created.robot;
robot = created.robot;
@@ -61,7 +61,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -61,7 +61,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.deepStrictEqual(config.allowed_commands, ['drive', 'actuator', 'halt'], 'the adeept profile takes drive/actuator/halt');
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
assert.ok(Number.isInteger(config.heartbeat_ms) && config.heartbeat_ms > 0, `a heartbeat interval: ${config.heartbeat_ms}`);
// Only now does the Link report: its link holds every frame until hello.
// Only now does the Link report: its link holds every frame until hello.
- dev.send({ type: 'status', firmware: 'openvibe-node-0.1.0', capabilities: {}, agent_version: '0.1.0',
+ dev.send({ type: 'status', firmware: 'openvibe-link-0.1.0', capabilities: {}, agent_version: '0.1.0',
device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });
device_kind: 'onboard', os: 'linux', arch: 'arm64', drivers: [], faults: [], estop_latched: false, local_stop: false });
dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
dev.send({ type: 'estop_state', latched: false, robot_id: robot.id, local_stop: false });
assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');
assert.ok(await poll(() => t.hub.isOnline(deviceId)), 'the device is online');
@@ -101,7 +101,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
@@ -101,7 +101,7 @@ const WHIP_BASE = 'https://whip.test/ingest';
}
}
});
});
- // ── 4. a Node-shaped battery object becomes a numeric fraction on the robot state ────────────────
+ // ── 4. a Link-shaped battery object becomes a numeric fraction on the robot state ────────────────
await check("step 4: a Link {volts, percent} battery shows a non-null numeric battery", async () => {
await check("step 4: a Link {volts, percent} battery shows a non-null numeric battery", async () => {
dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });
dev.send({ type: 'telemetry', battery: { volts: 7.42, percent: 59 }, voltage: 7.42, sensors: { ultrasonic: 118, line: [0, 1, 1, 0] } });
const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery
const frame = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery
@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -21,7 +21,7 @@ const { boot, check, done } = require('./helpers/app');
const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,
const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,
price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };
price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };
- /** A Network-paired machine whose fake Node advertises an ai offer and answers one ai command. */
+ /** A Network-paired machine whose fake Link advertises an ai offer and answers one ai command. */
async function machine() {
async function machine() {
const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });
const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -56,7 +56,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(offer.health.status, 'up');
assert.strictEqual(offer.health.status, 'up');
await t.app.locals.offers.publish();
await t.app.locals.offers.publish();
assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');
assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');
- assert.ok(reports.at(-1).some((o) => o.offer_id === `node:${mine.deviceId}`) === false, 'the worker report is separate');
+ assert.ok(reports.at(-1).some((o) => o.offer_id === `link:${mine.deviceId}`) === false, 'the worker report is separate');
const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();
const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();
assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');
assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');
assert.ok(page.includes('Offering its models to the network'));
assert.ok(page.includes('Offering its models to the network'));
@@ -105,7 +105,7 @@ const PROFILE = {
@@ -105,7 +105,7 @@ const PROFILE = {
assert.ok(models.includes('From the community') && models.includes('href="/models/community.ballbot"'));
assert.ok(models.includes('From the community') && models.includes('href="/models/community.ballbot"'));
const page = await (await get('/models/community.ballbot')).text();
const page = await (await get('/models/community.ballbot')).text();
assert.ok(page.includes('Submitted by @mia') && page.includes('https://github.com/mia/ballbot-driver') && page.includes(key.key_id));
assert.ok(page.includes('Submitted by @mia') && page.includes('https://github.com/mia/ballbot-driver') && page.includes(key.key_id));
- assert.ok(page.includes(`openvibe-node profile verify community.ballbot --key-id ${key.key_id}`), 'the model page says how to check it on a machine');
+ assert.ok(page.includes(`openvibe-link profile verify community.ballbot --key-id ${key.key_id}`), 'the model page says how to check it on a machine');
assert.match(await (await get('/robots/new?profile=community.ballbot', sam)).text(), /value="community\.ballbot" checked/);
assert.match(await (await get('/robots/new?profile=community.ballbot', sam)).text(), /value="community\.ballbot" checked/);
const made = await t.call('POST', '/api/v1/robots', { user: sam, body: { name: 'My ballbot', profile_id: 'community.ballbot' } });
const made = await t.call('POST', '/api/v1/robots', { user: sam, body: { name: 'My ballbot', profile_id: 'community.ballbot' } });
assert.strictEqual(made.status, 201, made.text);
assert.strictEqual(made.status, 201, made.text);
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
const seen = [];
const seen = [];
const DESK = (isolation, max = 1) => ({ level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: max, kinds: ['browser'], profiles: false, ...(isolation ? { isolation } : {}) });
const DESK = (isolation, max = 1) => ({ level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: max, kinds: ['browser'], profiles: false, ...(isolation ? { isolation } : {}) });
- /** A machine of `owner` whose fake Node answers desk and browser commands like the computer plugin would. */
+ /** A machine of `owner` whose fake Link answers desk and browser commands like the computer plugin would. */
async function machine(owner, computer) {
async function machine(owner, computer) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desks' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desks' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -54,7 +54,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -54,7 +54,7 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
op.close(); dev.close();
});
});
- await check('a local stop on the device (openvibe-node stop) is shown as one, and the owner clear cannot lift it', async () => {
+ await check('a local stop on the device (openvibe-link stop) is shown as one, and the owner clear cannot lift it', async () => {
const { robot, dev } = await connected();
const { robot, dev } = await connected();
const op = await ownerJoined(robot.id);
const op = await ownerJoined(robot.id);
dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });
dev.send({ type: 'estop_state', latched: true, local_stop: true, reason: 'stop\u0007 button\n on the robot', robot_id: robot.id });
@@ -152,7 +152,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -152,7 +152,7 @@ const { boot, check, done } = require('./helpers/app');
op.close(); dev.close();
op.close(); dev.close();
});
});
- await check('a Node-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {
+ await check('a Link-style battery object becomes a 0..1 fraction, raw frame kept in telemetry', async () => {
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const { dev } = await pairOverWs(robot.id, pairing.code);
const { dev } = await pairOverWs(robot.id, pairing.code);
const op = await ownerJoined(robot.id);
const op = await ownerJoined(robot.id);
@@ -60,8 +60,8 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
@@ -60,8 +60,8 @@ const { HOME_CSP: SITE_CSP } = require('../server/web/home');
assert.match(html, /no_heartbeat/);
assert.match(html, /no_heartbeat/);
assert.match(html, /e-stop/);
assert.match(html, /e-stop/);
// Links out to the full protocol and the contributor path.
// Links out to the full protocol and the contributor path.
- assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/plugins\.md/);
- assert.match(html, /OpenVibe\.Node\/blob\/main\/docs\/protocol\.md/);
+ assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/plugins\.md/);
+ assert.match(html, /OpenVibe\.Link\/blob\/main\/docs\/protocol\.md/);
assert.match(html, /pull request/);
assert.match(html, /pull request/);
});
});
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
'use strict';
'use strict';
-// The guided first run, measured (OpenVibe Link's `openvibe-node`): the Link sends one `node.first_run` telemetry
+// The guided first run, measured (OpenVibe Link's `openvibe-link`): the Link sends one `link.first_run` telemetry
// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
// event per step (installed, paired, self_check with ok or the reason, first_job with the machine's counts). Bot stores
// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
// one row per device and step, the machine's page shows what its first job found, and a reviewer sees the funnel.
const assert = require('assert');
const assert = require('assert');
@@ -36,10 +36,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -36,10 +36,10 @@ const { boot, check, done } = require('./helpers/app');
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const { dev, paired } = await pairOverWs(robot.id, pairing.code);
const { dev, paired } = await pairOverWs(robot.id, pairing.code);
const at = Date.now() - 60_000;
const at = Date.now() - 60_000;
- const event = (step, extra = {}) => ({ name: 'node.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,
+ const event = (step, extra = {}) => ({ name: 'link.first_run', ts: at + ['installed', 'paired', 'self_check', 'first_job'].indexOf(step) * 1000,
fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });
fields: { step, version: 'v0.39.0', os: 'linux', arch: 'amd64', ...extra } });
- await check('one node.first_run event per step is stored, with the machine counts and no personal fields', async () => {
+ await check('one link.first_run event per step is stored, with the machine counts and no personal fields', async () => {
dev.send({ type: 'telemetry', events: [
dev.send({ type: 'telemetry', events: [
event('installed'),
event('installed'),
event('paired'),
event('paired'),
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -70,7 +70,7 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(failed, 'the failure replaced the pass');
assert.ok(failed, 'the failure replaced the pass');
assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');
assert.strictEqual(failed.reason, 'OpenVibe.Bot answered HTTP 503');
dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },
dev.send({ type: 'telemetry', events: [event('self_check', { ok: false, reason: 'bad\u0000line' }), { name: 'other', ts: at, fields: { step: 'installed' } },
- { name: 'node.first_run', ts: at, fields: { step: 'not_a_step' } }] });
+ { name: 'link.first_run', ts: at, fields: { step: 'not_a_step' } }] });
const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });
const cleaned = await poll(async () => { const r = await rowsFor(paired.device_id); const s = r.find((x) => x.step === 'self_check'); return s && s.reason === 'bad line' ? s : null; });
assert.ok(cleaned, 'control characters are cleaned from the reason');
assert.ok(cleaned, 'control characters are cleaned from the reason');
const rows = await rowsFor(paired.device_id);
const rows = await rowsFor(paired.device_id);
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); POST /oauth/token mints
* startNetwork() JWKS and user/service JWTs (signUser, signService, newUser); POST /oauth/token mints
* client-credentials tokens and records every request in tokenCalls ({client_id, audience,
* client-credentials tokens and records every request in tokenCalls ({client_id, audience,
* scope}); node principals as
* scope}); node principals as
- * GET /internal/node-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
+ * GET /internal/link-principals/:id and POST …/:id/revoke answer them (addNode, signNode,
* nodes, revokes), scoped to the calling service like Network's own routes; the machine's
* nodes, revokes), scoped to the calling service like Network's own routes; the machine's
* online state (POST /internal/machines/status) is recorded (statuses). Identity
* online state (POST /internal/machines/status) is recorded (statuses). Identity
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
* resolution for people (identity.subject.resolve): GET /internal/identity/resolve?username=
@@ -57,12 +57,21 @@ async function startNetwork() {
@@ -57,12 +57,21 @@ async function startNetwork() {
return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user', ...extra },
return jwt.sign({ sub: String(u.networkId || ++n), subject_id: u.subject, username: u.username, display_name: u.display_name || u.username, role: u.role || 'user', ...extra },
privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
}
}
- // Node principals: the view Network's principalView answers (server/registry/node-principals.js).
+ // Link principals: the view Network's principalView answers (server/registry/node-principals.js).
// `unscoped: true` answers a principal to any service, so Bot's own paired_for check is exercised too.
// `unscoped: true` answers a principal to any service, so Bot's own paired_for check is exercised too.
const nodes = new Map();
const nodes = new Map();
const revokes = [];
const revokes = [];
+ // A principal id (lnk_…): the contracts package mints it once its 'link' kind ships; until it does, this builds
+ // the same shape, so the tests do not wait for that release.
+ function linkId() {
+ try { return ids.newId('link'); } catch { /* the pinned contracts has no 'link' kind yet */ }
+ const alphabet = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+ let s = '';
+ for (const b of crypto.randomBytes(26)) s += alphabet[b % alphabet.length];
+ return 'lnk_' + s;
+ }
function addNode({ owner, ref, service = 'bot', status = 'active', name = 'garage-pi', unscoped = false } = {}) {
function addNode({ owner, ref, service = 'bot', status = 'active', name = 'garage-pi', unscoped = false } = {}) {
- const id = ids.newId('node');
+ const id = linkId();
nodes.set(id, {
nodes.set(id, {
unscoped,
unscoped,
view: {
view: {
@@ -75,17 +84,17 @@ async function startNetwork() {
@@ -75,17 +84,17 @@ async function startNetwork() {
}
}
function signNode(principal, { aud = ['openvibe.bot'], expSec = 300 } = {}) {
function signNode(principal, { aud = ['openvibe.bot'], expSec = 300 } = {}) {
const now = Math.floor(Date.now() / 1000);
const now = Math.floor(Date.now() / 1000);
- return serviceAuth.signServiceToken({ iss: issuer, sub: `node:${principal}`, actor_type: 'node', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
+ return serviceAuth.signServiceToken({ iss: issuer, sub: `link:${principal}`, actor_type: 'link', aud, cap: [], iat: now, exp: now + expSec, jti: `tok_${crypto.randomBytes(12).toString('hex')}` }, privatePem);
}
}
- /** The calling service (svc:<id> → <id>) of a valid network.node.manage token for openvibe.network, or null. */
+ /** The calling service (svc:<id> → <id>) of a valid network.link.manage token for openvibe.network, or null. */
function nodeManager(req) {
function nodeManager(req) {
const h = String(req.headers.authorization || '');
const h = String(req.headers.authorization || '');
const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
const r = serviceAuth.verifyServiceToken(h.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
- if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.node.manage')) return null;
+ if (!h.startsWith('Bearer ') || !r.ok || !(r.claims.cap || []).includes('network.link.manage')) return null;
return r.claims.sub.replace(/^svc:/, '');
return r.claims.sub.replace(/^svc:/, '');
}
}
- // Node pairings (POST /internal/node-pairings): every call is kept; failPairings(status) answers that status
+ // Link pairings (POST /internal/link-pairings): every call is kept; failPairings(status) answers that status
// instead (null: mint again).
// instead (null: mint again).
const pairings = [];
const pairings = [];
let pairingFailure = null;
let pairingFailure = null;
@@ -152,7 +161,7 @@ async function startNetwork() {
@@ -152,7 +161,7 @@ async function startNetwork() {
}
}
return send(res, 200, { results });
return send(res, 200, { results });
}
}
- if (req.url === '/internal/node-pairings' && req.method === 'POST') {
+ if (req.url === '/internal/link-pairings' && req.method === 'POST') {
const service = nodeManager(req);
const service = nodeManager(req);
if (!service) return send(res, 401, { code: 'token.invalid' });
if (!service) return send(res, 401, { code: 'token.invalid' });
if (pairingFailure) return send(res, pairingFailure, { code: 'registry.failed' });
if (pairingFailure) return send(res, pairingFailure, { code: 'registry.failed' });
@@ -171,7 +180,7 @@ async function startNetwork() {
@@ -171,7 +180,7 @@ async function startNetwork() {
statuses.push({ service, body });
statuses.push({ service, body });
return send(res, 200, { ok: true });
return send(res, 200, { ok: true });
}
}
- const np = /^\/internal\/node-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
+ const np = /^\/internal\/link-principals\/([^/]+)(\/revoke)?$/.exec(req.url);
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
if (np && (req.method === (np[2] ? 'POST' : 'GET'))) {
const service = nodeManager(req);
const service = nodeManager(req);
if (!service) return send(res, 401, { code: 'token.invalid' });
if (!service) return send(res, 401, { code: 'token.invalid' });
@@ -8,13 +8,13 @@ const { loadConfig } = require('../server/config');
@@ -8,13 +8,13 @@ const { loadConfig } = require('../server/config');
const { driverForProfile } = require('../server/domain');
const { driverForProfile } = require('../server/domain');
(async () => {
(async () => {
- const NODE_INSTALLER = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';
+ const NODE_INSTALLER = 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh';
- await check('BOT_INSTALLER_SOURCE_URL: Node\'s script by default, https on an allow-listed host or no boot', async () => {
+ await check('BOT_INSTALLER_SOURCE_URL: Link\'s script by default, https on an allow-listed host or no boot', async () => {
assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);
assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);
- const release = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';
+ const release = 'https://github.com/OpenVibers/OpenVibe.Link/releases/latest/download/install.sh';
assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);
assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);
- for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh', 'https://evil.test/install.sh',
+ for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Link/main/install/install.sh', 'https://evil.test/install.sh',
'https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {
'https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {
assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);
assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);
}
}
@@ -30,7 +30,7 @@ const { driverForProfile } = require('../server/domain');
@@ -30,7 +30,7 @@ const { driverForProfile } = require('../server/domain');
for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
});
});
- const source = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v1.2.3/install.sh';
+ const source = 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v1.2.3/install.sh';
const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });
const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });
const alex = t.network.newUser('alex');
const alex = t.network.newUser('alex');
@@ -45,13 +45,13 @@ const { driverForProfile } = require('../server/domain');
@@ -45,13 +45,13 @@ const { driverForProfile } = require('../server/domain');
await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {
await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {
for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {
for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {
const { robot, pairing } = await t.robot(alex, { profile_id });
const { robot, pairing } = await t.robot(alex, { profile_id });
- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);
const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
assert.strictEqual(again.status, 201, again.text);
assert.strictEqual(again.status, 201, again.text);
assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));
assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));
}
}
const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });
const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });
- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);
});
});
await t.close();
await t.close();
@@ -60,7 +60,7 @@ const { driverForProfile } = require('../server/domain');
@@ -60,7 +60,7 @@ const { driverForProfile } = require('../server/domain');
const sam = n.network.newUser('sam');
const sam = n.network.newUser('sam');
const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });
const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });
const asked = n.network.pairings.at(-1);
const asked = n.network.pairings.at(-1);
- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);
const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });
const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });
assert.strictEqual(again.status, 201, again.text);
assert.strictEqual(again.status, 201, again.text);
assert.match(again.json.installer, / --code \S+ --driver cozmo$/);
assert.match(again.json.installer, / --code \S+ --driver cozmo$/);
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+'use strict';
+const assert = require('node:assert/strict');
+const { spawnSync } = require('node:child_process');
+const path = require('node:path');
+const root = path.resolve(__dirname, '..');
+const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
+const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
+assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
+assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
+console.log(' ok old Link brand and principal IDs are absent');
new file mode 100644
new file mode 100644
@@ -0,0 +1,230 @@
@@ -0,0 +1,230 @@
+'use strict';
+// Network-paired machines (T2 §9.2 B1, dual-accept): a Link token binds a device from Network's record,
+// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly
+// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke
+// and rotate reach Network and the machine. Credential devices keep today's behaviour.
+const assert = require('assert');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/app');
+
+const WHIP_BASE = 'https://whip.test/ingest';
+const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;
+
+(async () => {
+ const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE, BOT_EVENTS_SECRET: EVENTS_SECRET } });
+ const alex = t.network.newUser('alex');
+ const sam = t.network.newUser('sam');
+
+ const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });
+ const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);
+ const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });
+ /** A robot of `owner` and a machine Network paired for it. */
+ async function paired(owner = alex, node = {}) {
+ const { robot } = await t.robot(owner);
+ return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };
+ }
+
+ await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {
+ const { robot, principal } = await paired();
+ const r = await bind(t.network.signNode(principal));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.headers.get('cache-control'), 'no-store');
+ assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);
+ assert.match(r.json.device_id, /^dev_/);
+ assert.strictEqual(r.json.robot_id, robot.id);
+ assert.strictEqual(r.json.profile.id, 'sim.rover');
+ assert.ok(r.json.publish_key.length >= 40);
+ assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);
+ const d = await deviceRow(r.json.device_id);
+ assert.deepStrictEqual(d.robot_ids, [robot.id]);
+ assert.strictEqual(d.name, 'garage-pi');
+ assert.strictEqual(d.kind, 'onboard');
+ assert.deepStrictEqual(d.drivers, []);
+ assert.deepStrictEqual(d.capabilities, {});
+ assert.strictEqual(d.agent_version, null);
+ assert.strictEqual(d.credential_hash, null);
+ assert.strictEqual(d.node_principal, principal);
+ assert.strictEqual(d.publish_key_hint, r.json.publish_key.slice(-4), 'only the hint of the OpenRestream key is kept');
+ assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no key of its own');
+ assert.ok(t.openre.admits(r.json.publish_key), 'the publish key is one OpenRestream admits');
+ });
+
+ await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {
+ const { principal } = await paired();
+ const first = await bind(t.network.signNode(principal));
+ const second = await bind(t.network.signNode(principal));
+ assert.strictEqual(second.status, 201, second.text);
+ assert.strictEqual(second.json.device_id, first.json.device_id);
+ assert.notStrictEqual(second.json.publish_key, first.json.publish_key);
+ const d = await deviceRow(first.json.device_id);
+ assert.strictEqual(d.publish_key_hint, second.json.publish_key.slice(-4));
+ assert.ok(t.openre.admits(second.json.publish_key), 'the new key is admitted');
+ assert.ok(!t.openre.admits(first.json.publish_key), 'the old key stops working');
+ assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);
+ });
+
+ await check('bind refuses anything but a Link token for openvibe.bot', async () => {
+ const { principal } = await paired();
+ const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));
+ assert.strictEqual(network.status, 401, network.text);
+ const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });
+ assert.strictEqual(user.status, 401, user.text);
+ assert.strictEqual(user.json.code, 'bot.link_token_required');
+ const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });
+ assert.strictEqual(service.status, 401, service.text);
+ });
+
+ await check('an upgrade with a Link token and no prior bind gets hello then config first, and no paired frame', async () => {
+ const { robot, principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const config = await c.waitFor((m) => m.type === 'config');
+ assert.ok(config);
+ assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);
+ assert.ok(!c.messages.some((m) => m.type === 'paired'));
+ const hello = c.messages[0];
+ assert.deepStrictEqual(hello.robot_ids, [robot.id]);
+ const d = await deviceRow(hello.device_id);
+ assert.strictEqual(d.node_principal, principal);
+ assert.strictEqual(d.publish_key_hint, null, 'no publish key until the machine calls bind');
+ assert.ok(t.hub.isOnline(d.id));
+ // A later bind finds the same device.
+ const b = await bind(t.network.signNode(principal));
+ assert.strictEqual(b.json.device_id, d.id);
+ c.close();
+ });
+
+ await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });
+ let d;
+ for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }
+ assert.strictEqual(d.kind, 'bridge');
+ assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);
+ assert.deepStrictEqual(d.capabilities, { camera: true });
+ assert.strictEqual(d.agent_version, '0.3.1');
+ for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { drivers: [{ state: 'up' }] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {
+ const before = c.messages.filter((m) => m.type === 'error').length;
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });
+ await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);
+ const err = c.messages.filter((m) => m.type === 'error').pop();
+ assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));
+ }
+ const after = await deviceRow(hello.device_id);
+ assert.strictEqual(after.kind, 'bridge');
+ assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);
+ assert.strictEqual(after.agent_version, '0.3.1');
+ // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.
+ const errors = c.messages.filter((m) => m.type === 'error').length;
+ c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });
+ let named;
+ for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }
+ assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);
+ assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');
+ c.close();
+ });
+
+ await check('a credential device\'s status stays memory-only', async () => {
+ const { robot, pairing } = await t.robot(alex);
+ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });
+ const c = await connect(p.json.credential);
+ await c.waitFor((m) => m.type === 'config');
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });
+ const err = await c.waitFor((m) => m.type === 'error');
+ assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');
+ const d = await deviceRow(p.json.device_id);
+ assert.strictEqual(d.kind, 'onboard');
+ assert.deepStrictEqual(d.drivers, ['a']);
+ c.close();
+ });
+
+ await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {
+ const { robot: samsRobot } = await t.robot(sam);
+ const cases = {
+ 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),
+ revoked: (await paired(alex, { status: 'revoked' })).principal,
+ 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,
+ 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,
+ 'unknown on Network': 'lnk_01ARZ3NDEKTSV4RRFFQ69G5FAV',
+ };
+ for (const [name, principal] of Object.entries(cases)) {
+ const r = await bind(t.network.signNode(principal));
+ assert.strictEqual(r.status, 403, `${name}: ${r.text}`);
+ assert.strictEqual(r.json.code, 'bot.link_not_bound', name);
+ const c = await connect(t.network.signNode(principal));
+ assert.strictEqual(await c.waitForClose(), 4002, name);
+ assert.ok(!c.messages.some((m) => m.type === 'hello'), name);
+ assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);
+ }
+ });
+
+ await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {
+ const { principal } = await paired();
+ const other = (await paired()).principal;
+ const c = await connect(t.network.signNode(principal));
+ await c.waitFor((m) => m.type === 'config');
+ t.clock.offset += 200 * 1000;
+ c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });
+ assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');
+ c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });
+ await t.wait(100);
+ t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth
+ await t.wait(400);
+ assert.ok(!c.ended, 'a valid reauth moved the deadline');
+ t.clock.offset += 131 * 1000; // 331 s after the reauth
+ assert.strictEqual(await c.waitForClose(), 4002);
+ t.clock.offset = 0;
+ });
+
+ await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(t.network.revokes.includes(principal));
+ assert.strictEqual(await c.waitForClose(), 4003);
+ const again = await bind(t.network.signNode(principal));
+ assert.strictEqual(again.status, 403);
+ assert.strictEqual(again.json.code, 'bot.link_not_bound');
+ });
+
+ await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json.sent, true);
+ assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);
+ assert.ok(await c.waitFor((m) => m.type === 'rotate'));
+ assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);
+ c.close();
+ });
+
+ await check('network.link.revoked revokes the machine\'s devices and closes its live socket at once', async () => {
+ const { principal } = await paired();
+ const c = await connect(t.network.signNode(principal));
+ const hello = await c.waitFor((m) => m.type === 'hello');
+ assert.ok(t.hub.isOnline(hello.device_id));
+ const event = {
+ event_id: 'evt_01JZ0000000000000000000N01', event_type: 'network.link.revoked', version: 1, source: 'network',
+ actor: { type: 'user', id: alex.subject }, timestamp: new Date().toISOString(), visibility: 'internal',
+ subject: { type: 'link', id: principal },
+ payload: { node_id: `n-${'a'.repeat(26)}`, principal_id: principal, owner: { kind: 'user', subject: alex.subject }, reason: 'owner', at: new Date().toISOString() },
+ };
+ const body = JSON.stringify({ event }); // Events delivers { cursor?, event }
+ const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
+ assert.strictEqual(res.status < 300, true, `${res.status} ${await res.text()}`);
+ assert.strictEqual(await c.waitForClose(), 4003, 'the socket closes at once, not at the reauth deadline');
+ assert.ok((await deviceRow(hello.device_id)).revoked_at, 'the device is revoked');
+ assert.ok(!t.hub.isOnline(hello.device_id));
+ const again = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
+ assert.strictEqual(again.status < 300, true, 'a redelivery is harmless');
+ });
+
+ await t.close();
+ done();
+})();
@@ -17,7 +17,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -17,7 +17,7 @@ const { boot, check, done } = require('./helpers/app');
const COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true }, input: { level: 'trusted', tool: 'xdotool', scroll: true }, browser: { level: 'trusted', browser: 'chromium', headless: true } };
const COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true }, input: { level: 'trusted', tool: 'xdotool', scroll: true }, browser: { level: 'trusted', browser: 'chromium', headless: true } };
const seen = [];
const seen = [];
- /** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
+ /** A Network-paired machine of `owner` whose fake Link answers computer commands like the plugin would. */
async function machine(owner, computer = COMPUTER) {
async function machine(owner, computer = COMPUTER) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -40,7 +40,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -40,7 +40,7 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
});
});
- // Beat like a real Node while the socket is open, so a longer check never sees its machine go offline.
+ // Beat like a real Link while the socket is open, so a longer check never sees its machine go offline.
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
c.ws.on('close', () => clearInterval(beat));
c.ws.on('close', () => clearInterval(beat));
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
// The fleet (Codes C9c): a machine's OpenVibe Link reports what its coding agents have left with each heartbeat
// The fleet (Codes C9c): a machine's OpenVibe Link reports what its coding agents have left with each heartbeat
// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and
// (bot.device-message@1's `fleet`), and Bot keeps the latest reading per machine for Codes' Agents page, its router and
-// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, nod_…), which is the same machine
+// its MCP. GET /api/v1/machines answers it with the machine's Network id (`node_id`, lnk_…), which is the same machine
// Run pins a job to by its device id (dev_…): one row, both names.
// Run pins a job to by its device id (dev_…): one row, both names.
//
//
// What this checks: a reading is stored and answered; it is bounded exactly as the Link bounds it (16 agents, 10 windows,
// What this checks: a reading is stored and answered; it is bounded exactly as the Link bounds it (16 agents, 10 windows,
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -23,7 +23,7 @@ const { boot, check, done } = require('./helpers/app');
const list = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
const list = (v) => (typeof v === 'string' ? JSON.parse(v) : v);
const machines = async (user) => { const r = await t.call('GET', '/api/v1/machines', { user }); assert.strictEqual(r.status, 200, r.text); return r.json.machines; };
const machines = async (user) => { const r = await t.call('GET', '/api/v1/machines', { user }); assert.strictEqual(r.status, 200, r.text); return r.json.machines; };
- /** A Network-paired machine of `owner`, beating like a real Node (its own timer, no fleet) until closed. */
+ /** A Network-paired machine of `owner`, beating like a real Link (its own timer, no fleet) until closed. */
async function machine(owner) {
async function machine(owner) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };
const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };
const seen = [];
const seen = [];
- /** A machine of `owner` whose fake Node answers screen_stream like Link v0.12.0. */
+ /** A machine of `owner` whose fake Link answers screen_stream like Link v0.12.0. */
async function machine(owner, computer = SCREEN) {
async function machine(owner, computer = SCREEN) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -36,7 +36,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -36,7 +36,7 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));
else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));
});
});
- // Heartbeats keep it online, as a real Node's do.
+ // Heartbeats keep it online, as a real Link's do.
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
c.ws.on('close', () => clearInterval(beat));
c.ws.on('close', () => clearInterval(beat));
await poll(() => t.hub.computerOf(deviceId) !== null);
await poll(() => t.hub.computerOf(deviceId) !== null);
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
'use strict';
'use strict';
// BOT_PAIRING_AUTHORITY (plan T15 B2). bot (the default): Bot mints and stores the pairing code, as before.
// BOT_PAIRING_AUTHORITY (plan T15 B2). bot (the default): Bot mints and stores the pairing code, as before.
-// network: POST /robots and POST /robots/:id/pairing-code ask Network (POST /internal/node-pairings) for the
+// network: POST /robots and POST /robots/:id/pairing-code ask Network (POST /internal/link-pairings) for the
// code and Bot stores none; POST /pair and the `pair` frame answer 410 bot.pairing_moved; Network not
// code and Bot stores none; POST /pair and the `pair` frame answer 410 bot.pairing_moved; Network not
// answering is 503 bot.network_unavailable and creates no robot. An unknown value refuses to boot.
// answering is 503 bot.network_unavailable and creates no robot. An unknown value refuses to boot.
const assert = require('assert');
const assert = require('assert');
@@ -44,12 +44,12 @@ const { boot, check, done } = require('./helpers/app');
@@ -44,12 +44,12 @@ const { boot, check, done } = require('./helpers/app');
await check('network: POST /robots asks Network for the code and stores none', async () => {
await check('network: POST /robots asks Network for the code and stores none', async () => {
const { robot, pairing } = await t.robot(alex);
const { robot, pairing } = await t.robot(alex);
const asked = t.network.pairings.at(-1);
const asked = t.network.pairings.at(-1);
- assert.strictEqual(asked.service, 'bot', "Bot's own svc:bot token with network.node.manage");
+ assert.strictEqual(asked.service, 'bot', "Bot's own svc:bot token with network.link.manage");
assert.deepStrictEqual(asked.body, { owner: { kind: 'user', subject: alex.subject }, ref: robot.id });
assert.deepStrictEqual(asked.body, { owner: { kind: 'user', subject: alex.subject }, ref: robot.id });
assert.strictEqual(pairing.code, asked.code);
assert.strictEqual(pairing.code, asked.code);
assert.strictEqual(pairing.pairing_id, asked.pairing_id);
assert.strictEqual(pairing.pairing_id, asked.pairing_id);
assert.strictEqual(pairing.expires_at, asked.expires_at);
assert.strictEqual(pairing.expires_at, asked.expires_at);
- assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${t.network.url} --pairing ${asked.pairing_id} --code ${asked.code}`);
+ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.link/install | sh -s -- --network ${t.network.url} --pairing ${asked.pairing_id} --code ${asked.code}`);
assert.strictEqual(await codesFor(t, robot.id), 0);
assert.strictEqual(await codesFor(t, robot.id), 0);
});
});
deleted file mode 100644
deleted file mode 100644
@@ -1,230 +0,0 @@
@@ -1,230 +0,0 @@
-'use strict';
-// Network-paired machines (T2 §9.2 B1, dual-accept): a node token binds a device from Network's record,
-// POST /devices/bind issues its publish key without a credential, the /device socket speaks first exactly
-// as for a credential, `status` persists what the machine declares, `reauth` keeps the socket, and revoke
-// and rotate reach Network and the machine. Credential devices keep today's behaviour.
-const assert = require('assert');
-const { signDeliveryHeaders } = require('openvibe-sdk/events');
-const { boot, check, done } = require('./helpers/app');
-
-const WHIP_BASE = 'https://whip.test/ingest';
-const EVENTS_SECRET = `whsec_${'noderevoked'.repeat(4)}`;
-
-(async () => {
- const t = await boot({ env: { BOT_WHIP_BASE: WHIP_BASE, BOT_EVENTS_SECRET: EVENTS_SECRET } });
- const alex = t.network.newUser('alex');
- const sam = t.network.newUser('sam');
-
- const bind = (token) => t.call('POST', '/api/v1/devices/bind', { token });
- const deviceRow = (id) => t.db.maybe('SELECT * FROM devices WHERE id = $1', [id]);
- const connect = (token) => t.ws('/device', { headers: { Authorization: `Bearer ${token}` } });
- /** A robot of `owner` and a machine Network paired for it. */
- async function paired(owner = alex, node = {}) {
- const { robot } = await t.robot(owner);
- return { robot, principal: t.network.addNode({ owner: owner.subject, ref: robot.id, ...node }) };
- }
-
- await check('bind creates the device from Network\'s record with safe initial values, and answers no credential', async () => {
- const { robot, principal } = await paired();
- const r = await bind(t.network.signNode(principal));
- assert.strictEqual(r.status, 201, r.text);
- assert.strictEqual(r.headers.get('cache-control'), 'no-store');
- assert.deepStrictEqual(Object.keys(r.json).sort(), ['device_id', 'profile', 'publish_key', 'robot_id', 'whip_url']);
- assert.match(r.json.device_id, /^dev_/);
- assert.strictEqual(r.json.robot_id, robot.id);
- assert.strictEqual(r.json.profile.id, 'sim.rover');
- assert.ok(r.json.publish_key.length >= 40);
- assert.strictEqual(r.json.whip_url, `${WHIP_BASE}/${r.json.publish_key}`);
- const d = await deviceRow(r.json.device_id);
- assert.deepStrictEqual(d.robot_ids, [robot.id]);
- assert.strictEqual(d.name, 'garage-pi');
- assert.strictEqual(d.kind, 'onboard');
- assert.deepStrictEqual(d.drivers, []);
- assert.deepStrictEqual(d.capabilities, {});
- assert.strictEqual(d.agent_version, null);
- assert.strictEqual(d.credential_hash, null);
- assert.strictEqual(d.node_principal, principal);
- assert.strictEqual(d.publish_key_hint, r.json.publish_key.slice(-4), 'only the hint of the OpenRestream key is kept');
- assert.strictEqual(d.publish_key_hash, null, 'Bot mints and hashes no key of its own');
- assert.ok(t.openre.admits(r.json.publish_key), 'the publish key is one OpenRestream admits');
- });
-
- await check('bind twice answers the same device and a new publish key; the old key stops working', async () => {
- const { principal } = await paired();
- const first = await bind(t.network.signNode(principal));
- const second = await bind(t.network.signNode(principal));
- assert.strictEqual(second.status, 201, second.text);
- assert.strictEqual(second.json.device_id, first.json.device_id);
- assert.notStrictEqual(second.json.publish_key, first.json.publish_key);
- const d = await deviceRow(first.json.device_id);
- assert.strictEqual(d.publish_key_hint, second.json.publish_key.slice(-4));
- assert.ok(t.openre.admits(second.json.publish_key), 'the new key is admitted');
- assert.ok(!t.openre.admits(first.json.publish_key), 'the old key stops working');
- assert.strictEqual(Number((await t.db.maybe('SELECT count(*) AS n FROM devices WHERE node_principal = $1', [principal])).n), 1);
- });
-
- await check('bind refuses anything but a node token for openvibe.bot', async () => {
- const { principal } = await paired();
- const network = await bind(t.network.signNode(principal, { aud: ['openvibe.network'] }));
- assert.strictEqual(network.status, 401, network.text);
- const user = await t.call('POST', '/api/v1/devices/bind', { user: alex });
- assert.strictEqual(user.status, 401, user.text);
- assert.strictEqual(user.json.code, 'bot.node_token_required');
- const service = await t.call('POST', '/api/v1/devices/bind', { cap: ['bot.*'] });
- assert.strictEqual(service.status, 401, service.text);
- });
-
- await check('an upgrade with a node token and no prior bind gets hello then config first, and no paired frame', async () => {
- const { robot, principal } = await paired();
- const c = await connect(t.network.signNode(principal));
- const config = await c.waitFor((m) => m.type === 'config');
- assert.ok(config);
- assert.deepStrictEqual(c.messages.slice(0, 2).map((m) => m.type), ['hello', 'config']);
- assert.ok(!c.messages.some((m) => m.type === 'paired'));
- const hello = c.messages[0];
- assert.deepStrictEqual(hello.robot_ids, [robot.id]);
- const d = await deviceRow(hello.device_id);
- assert.strictEqual(d.node_principal, principal);
- assert.strictEqual(d.publish_key_hint, null, 'no publish key until the machine calls bind');
- assert.ok(t.hub.isOnline(d.id));
- // A later bind finds the same device.
- const b = await bind(t.network.signNode(principal));
- assert.strictEqual(b.json.device_id, d.id);
- c.close();
- });
-
- await check('a status with device_kind, drivers, capabilities and agent_version updates the row; invalid values do not', async () => {
- const { principal } = await paired();
- const c = await connect(t.network.signNode(principal));
- const hello = await c.waitFor((m) => m.type === 'hello');
- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: ['pca9685', 'picamera2'], capabilities: { camera: true }, agent_version: '0.3.1' });
- let d;
- for (let i = 0; i < 40; i++) { d = await deviceRow(hello.device_id); if (d.kind === 'bridge') break; await t.wait(25); }
- assert.strictEqual(d.kind, 'bridge');
- assert.deepStrictEqual(d.drivers, ['pca9685', 'picamera2']);
- assert.deepStrictEqual(d.capabilities, { camera: true });
- assert.strictEqual(d.agent_version, '0.3.1');
- for (const bad of [{ device_kind: 'toaster' }, { drivers: 'pca9685' }, { drivers: [1] }, { drivers: [{ state: 'up' }] }, { agent_version: 'x'.repeat(41) }, { capabilities: [] }]) {
- const before = c.messages.filter((m) => m.type === 'error').length;
- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'status', ...bad });
- await c.waitFor(() => c.messages.filter((m) => m.type === 'error').length > before);
- const err = c.messages.filter((m) => m.type === 'error').pop();
- assert.strictEqual(err.code, 'bot.bad_frame', JSON.stringify(bad));
- }
- const after = await deviceRow(hello.device_id);
- assert.strictEqual(after.kind, 'bridge');
- assert.deepStrictEqual(after.drivers, ['pca9685', 'picamera2']);
- assert.strictEqual(after.agent_version, '0.3.1');
- // The Link's own status frame lists drivers as objects (OpenVibe Link internal/protocol Status): the names stay.
- const errors = c.messages.filter((m) => m.type === 'error').length;
- c.send({ v: 1, seq: 3, ts: Date.now(), type: 'status', firmware: 'x', device_kind: 'bridge', drivers: [{ name: 'adeept', driver: 'adeept_adr036', state: 'up' }, { name: 'dryrun', state: 'down' }], faults: [] });
- let named;
- for (let i = 0; i < 40; i++) { named = await deviceRow(hello.device_id); if (named.drivers.length && named.drivers[0] === 'adeept_adr036') break; await t.wait(25); }
- assert.deepStrictEqual(named.drivers, ['adeept_adr036', 'dryrun']);
- assert.strictEqual(c.messages.filter((m) => m.type === 'error').length, errors, 'no bad_frame for the Link\'s own status');
- c.close();
- });
-
- await check('a credential device\'s status stays memory-only', async () => {
- const { robot, pairing } = await t.robot(alex);
- const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code, drivers: ['a'] } });
- const c = await connect(p.json.credential);
- await c.waitFor((m) => m.type === 'config');
- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'status', device_kind: 'server', drivers: ['b'], agent_version: '9' });
- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: 'x' });
- const err = await c.waitFor((m) => m.type === 'error');
- assert.strictEqual(err.code, 'bot.unknown_message', 'reauth is not a credential device\'s frame');
- const d = await deviceRow(p.json.device_id);
- assert.strictEqual(d.kind, 'onboard');
- assert.deepStrictEqual(d.drivers, ['a']);
- c.close();
- });
-
- await check('a principal paired for another owner\'s robot, revoked, or not for Bot → 403 and close 4002', async () => {
- const { robot: samsRobot } = await t.robot(sam);
- const cases = {
- 'another owner\'s robot': t.network.addNode({ owner: alex.subject, ref: samsRobot.id }),
- revoked: (await paired(alex, { status: 'revoked' })).principal,
- 'paired for another service (Network answers 404)': (await paired(alex, { service: 'live' })).principal,
- 'paired for another service (seen)': (await paired(alex, { service: 'live', unscoped: true })).principal,
- 'unknown on Network': 'nod_01ARZ3NDEKTSV4RRFFQ69G5FAV',
- };
- for (const [name, principal] of Object.entries(cases)) {
- const r = await bind(t.network.signNode(principal));
- assert.strictEqual(r.status, 403, `${name}: ${r.text}`);
- assert.strictEqual(r.json.code, 'bot.node_not_bound', name);
- const c = await connect(t.network.signNode(principal));
- assert.strictEqual(await c.waitForClose(), 4002, name);
- assert.ok(!c.messages.some((m) => m.type === 'hello'), name);
- assert.strictEqual(await t.db.maybe('SELECT id FROM devices WHERE node_principal = $1', [principal]), null, name);
- }
- });
-
- await check('no valid reauth within 330 s of the last token → close 4002; a valid one keeps the socket', async () => {
- const { principal } = await paired();
- const other = (await paired()).principal;
- const c = await connect(t.network.signNode(principal));
- await c.waitFor((m) => m.type === 'config');
- t.clock.offset += 200 * 1000;
- c.send({ v: 1, seq: 1, ts: Date.now(), type: 'reauth', token: t.network.signNode(other) });
- assert.strictEqual((await c.waitFor((m) => m.type === 'error')).code, 'bot.reauth_refused', 'another machine\'s token is no reauth');
- c.send({ v: 1, seq: 2, ts: Date.now(), type: 'reauth', token: t.network.signNode(principal) });
- await t.wait(100);
- t.clock.offset += 200 * 1000; // 400 s after connect, 200 s after the reauth
- await t.wait(400);
- assert.ok(!c.ended, 'a valid reauth moved the deadline');
- t.clock.offset += 131 * 1000; // 331 s after the reauth
- assert.strictEqual(await c.waitForClose(), 4002);
- t.clock.offset = 0;
- });
-
- await check('revoke of a Network-paired device revokes the principal on Network and closes the socket; it never binds again', async () => {
- const { principal } = await paired();
- const c = await connect(t.network.signNode(principal));
- const hello = await c.waitFor((m) => m.type === 'hello');
- const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/revoke`, { user: alex });
- assert.strictEqual(r.status, 200, r.text);
- assert.ok(t.network.revokes.includes(principal));
- assert.strictEqual(await c.waitForClose(), 4003);
- const again = await bind(t.network.signNode(principal));
- assert.strictEqual(again.status, 403);
- assert.strictEqual(again.json.code, 'bot.node_not_bound');
- });
-
- await check('rotate of a Network-paired device asks the machine to rotate and answers no credential', async () => {
- const { principal } = await paired();
- const c = await connect(t.network.signNode(principal));
- const hello = await c.waitFor((m) => m.type === 'hello');
- const r = await t.call('POST', `/api/v1/devices/${hello.device_id}/rotate`, { user: alex });
- assert.strictEqual(r.status, 200, r.text);
- assert.strictEqual(r.json.sent, true);
- assert.ok(!('credential' in r.json) && !('publish_key' in r.json), r.text);
- assert.ok(await c.waitFor((m) => m.type === 'rotate'));
- assert.strictEqual((await deviceRow(hello.device_id)).credential_hash, null);
- c.close();
- });
-
- await check('network.node.revoked revokes the machine\'s devices and closes its live socket at once', async () => {
- const { principal } = await paired();
- const c = await connect(t.network.signNode(principal));
- const hello = await c.waitFor((m) => m.type === 'hello');
- assert.ok(t.hub.isOnline(hello.device_id));
- const event = {
- event_id: 'evt_01JZ0000000000000000000N01', event_type: 'network.node.revoked', version: 1, source: 'network',
- actor: { type: 'user', id: alex.subject }, timestamp: new Date().toISOString(), visibility: 'internal',
- subject: { type: 'node', id: principal },
- payload: { node_id: `n-${'a'.repeat(26)}`, principal_id: principal, owner: { kind: 'user', subject: alex.subject }, reason: 'owner', at: new Date().toISOString() },
- };
- const body = JSON.stringify({ event }); // Events delivers { cursor?, event }
- const res = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
- assert.strictEqual(res.status < 300, true, `${res.status} ${await res.text()}`);
- assert.strictEqual(await c.waitForClose(), 4003, 'the socket closes at once, not at the reauth deadline');
- assert.ok((await deviceRow(hello.device_id)).revoked_at, 'the device is revoked');
- assert.ok(!t.hub.isOnline(hello.device_id));
- const again = await fetch(`${t.base}/internal/events`, { method: 'POST', body, headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(body, EVENTS_SECRET) } });
- assert.strictEqual(again.status < 300, true, 'a redelivery is harmless');
- });
-
- await t.close();
- done();
-})();
@@ -43,7 +43,7 @@ const { renderPhoneLinkPage } = require('../server/web/render');
@@ -43,7 +43,7 @@ const { renderPhoneLinkPage } = require('../server/web/render');
const guest = await t.call('GET', '/link/phone');
const guest = await t.call('GET', '/link/phone');
assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);
assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);
const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };
const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };
- const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v0.46.0/openvibe-link.apk';
+ const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Link/releases/download/v0.46.0/openvibe-link.apk';
const html = renderPhoneLinkPage({ pairing, apkUrl });
const html = renderPhoneLinkPage({ pairing, apkUrl });
assert.match(html, /ABCD-1234/);
assert.match(html, /ABCD-1234/);
assert.match(html, /qr-box/);
assert.match(html, /qr-box/);
@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -120,7 +120,7 @@ const { boot, check, done } = require('./helpers/app');
// Its page on openvibe.bot (links.console) when Bot's address is https, for Services to link to.
// Its page on openvibe.bot (links.console) when Bot's address is https, for Services to link to.
const { machineSummary, robotSummary } = require('../server/api/resource-index');
const { machineSummary, robotSummary } = require('../server/api/resource-index');
const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };
const row = { id: deviceId, owner_subject: alice.subject, name: 'desk-pc', created_at: new Date(), updated_at: new Date() };
- assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.bot/machines' });
+ assert.deepStrictEqual(machineSummary(row, true, 'https://openvibe.bot/').links, { console: 'https://openvibe.link/machines' });
assert.deepStrictEqual(robotSummary({ ...row, id: first.id, estop_latched: false }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });
assert.deepStrictEqual(robotSummary({ ...row, id: first.id, estop_latched: false }, 'https://openvibe.bot').links, { console: `https://openvibe.bot/r/${first.id}` });
assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');
assert.strictEqual(machineSummary(row, true, 'http://localhost:4630').links, undefined, 'https only');
assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);
assert.deepStrictEqual(contracts.validate('common.resource-summary@1', machineSummary(row, true, 'https://openvibe.bot')).errors, []);
@@ -42,7 +42,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -42,7 +42,7 @@ const PROJECT = `prj_${ids.ulid()}`;
await n.connect();
await n.connect();
n.c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
n.c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [n.deviceId]); return d && d.capabilities.worker; });
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [n.deviceId]); return d && d.capabilities.worker; });
- /** A frame from the Node, then the Bot frame that answers it (or the settle of a no-answer frame). */
+ /** A frame from the Link, then the Bot frame that answers it (or the settle of a no-answer frame). */
n.say = (type, fields) => n.c.send(frame(type, fields));
n.say = (type, fields) => n.c.send(frame(type, fields));
n.exit = async (id, fields) => {
n.exit = async (id, fields) => {
const before = n.c.messages.filter((m) => m.type === 'job_exit_ack' && m.id === id).length;
const before = n.c.messages.filter((m) => m.type === 'job_exit_ack' && m.id === id).length;
@@ -131,7 +131,7 @@ const PROJECT = `prj_${ids.ulid()}`;
@@ -131,7 +131,7 @@ const PROJECT = `prj_${ids.ulid()}`;
assert.deepStrictEqual({ ...r, idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined }, {
assert.deepStrictEqual({ ...r, idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined }, {
idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined,
idempotency_key: undefined, id: undefined, quantity: undefined, at: undefined,
service: 'run', operation: 'function.invoke', unit: 's', resource: job.id, node: n.deviceId,
service: 'run', operation: 'function.invoke', unit: 's', resource: job.id, node: n.deviceId,
- source: 'openvibe-node.worker', project: PROJECT, subject: SUBJECT,
+ source: 'openvibe-link.worker', project: PROJECT, subject: SUBJECT,
});
});
assert.strictEqual(r.id, r.idempotency_key);
assert.strictEqual(r.id, r.idempotency_key);
}
}
@@ -118,7 +118,7 @@ const card = require('../public/sd-card');
@@ -118,7 +118,7 @@ const card = require('../public/sd-card');
setupKey: made.key, server: 'https://openvibe.bot', robotName: 'Mecanum rover' });
setupKey: made.key, server: 'https://openvibe.bot', robotName: 'Mecanum rover' });
assert.ok(ud.startsWith('#cloud-config\n'), 'cloud-init reads only a file that starts with #cloud-config');
assert.ok(ud.startsWith('#cloud-config\n'), 'cloud-init reads only a file that starts with #cloud-config');
assert.ok(ud.includes(` content: "${made.key}"`) && ud.includes('/etc/openvibe/setup-key') && ud.includes('permissions: "0600"'));
assert.ok(ud.includes(` content: "${made.key}"`) && ud.includes('/etc/openvibe/setup-key') && ud.includes('permissions: "0600"'));
- assert.ok(ud.includes('curl -fsSL https://openvibe.bot/install | sh -s -- --setup "$key"'));
+ assert.ok(ud.includes('curl -fsSL https://openvibe.link/install | sh -s -- --setup "$key"'));
assert.ok(ud.includes(' - [ systemctl, enable, --now, ssh ]') && ud.includes('systemd-run, --unit=openvibe-setup'));
assert.ok(ud.includes(' - [ systemctl, enable, --now, ssh ]') && ud.includes('systemd-run, --unit=openvibe-setup'));
assert.ok(/\nruncmd:\n/.test(ud) && ud.split('\nruncmd:').length === 2, 'one runcmd list (a second would replace the first)');
assert.ok(/\nruncmd:\n/.test(ud) && ud.split('\nruncmd:').length === 2, 'one runcmd list (a second would replace the first)');
assert.throws(() => card.userData({ hostname: 'x', username: 'y', passwordHash: 'z', setupKey: 'nope' }), /setup key/);
assert.throws(() => card.userData({ hostname: 'x', username: 'y', passwordHash: 'z', setupKey: 'nope' }), /setup key/);
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -18,7 +18,7 @@ const { boot, check, done } = require('./helpers/app');
desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } };
desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } };
const seen = [];
const seen = [];
- /** A Network-paired machine of `owner` whose fake Node answers screen and input like the plugin would. */
+ /** A Network-paired machine of `owner` whose fake Link answers screen and input like the plugin would. */
async function machine(owner, computer = COMPUTER) {
async function machine(owner, computer = COMPUTER) {
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
@@ -33,7 +33,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -33,7 +33,7 @@ const { boot, check, done } = require('./helpers/app');
c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
}
}
- // Beat like a real Node while the socket is open: the later checks take longer than two missed heartbeats.
+ // Beat like a real Link while the socket is open: the later checks take longer than two missed heartbeats.
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);
c.ws.on('close', () => clearInterval(beat));
c.ws.on('close', () => clearInterval(beat));
return { principal, c, deviceId };
return { principal, c, deviceId };
@@ -49,7 +49,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -49,7 +49,7 @@ const { boot, check, done } = require('./helpers/app');
const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });
const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });
const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });
const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });
assert.strictEqual(bound.status, 403, bound.text);
assert.strictEqual(bound.status, 403, bound.text);
- assert.strictEqual(bound.json.code, 'bot.node_not_bound');
+ assert.strictEqual(bound.json.code, 'bot.link_not_bound');
});
});
await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {
await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {