openvibers/openvibe.bot Commits

OpenVibe Link rename: the machine pages, commands and console links (#359)

OP OpenVibers committed 4 days ago · 18e65e9bc5 · 57 files, +459 −439

* OpenVibe Link rename, in progress (saved as it was left)

* Merge main, and send the machine console to OpenVibe Link

Keeps main's page-head structure with the Link wording and the openvibe-link
stop command. A machine's summary now links to openvibe.link/machines when
Bot's own address is https.

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

* Pin openvibe-contracts v0.373.0 and refresh the version docs

v0.373.0 carries the Link ids, principals and capabilities the tests now expect; the old 0.338.0 pin is what returned no id prefix for kind link. STATUS.json and the README versions block name the same tag.

Changes

ddiff --git a/.env.example b/.env.example
iindex 4fe80cf..c23ac15 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex d914824..c63fc66 100644
---- a/README.md
++++ b/README.md
@@@ -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 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.
--`/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 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.
<!-- versions:start -->
--- openvibe-contracts: v0.338.0
++- openvibe-contracts: v0.373.0
- openvibe-sdk: v0.123.0
- openvibe-shared: v4.173.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 83edc04..65eacad 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/deploy/nginx/openvibe.bot.conf b/deploy/nginx/openvibe.bot.conf
iindex dad388c..94e3ad9 100644
---- a/deploy/nginx/openvibe.bot.conf
++++ b/deploy/nginx/openvibe.bot.conf
@@@ -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
ddiff --git a/docs/cutover-pr-openvibe-bot-18.md b/docs/cutover-pr-openvibe-bot-18.md
iindex ca96d65..bd7a103 100644
---- a/docs/cutover-pr-openvibe-bot-18.md
++++ b/docs/cutover-pr-openvibe-bot-18.md
@@@ -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:
- `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
parameter 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 to
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.
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 investigate
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.
- **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.
- **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
ddiff --git a/docs/cutover-pr-openvibe-bot-26.md b/docs/cutover-pr-openvibe-bot-26.md
iindex a21fe89..930538a 100644
---- a/docs/cutover-pr-openvibe-bot-26.md
++++ b/docs/cutover-pr-openvibe-bot-26.md
@@@ -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
node 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 in
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.
- `GET /api/v1/jobs/:id` → `200` with the row and any captured stdout; an unknown id → `404 bot.job_not_found`.
ddiff --git a/docs/live-controls-migration.md b/docs/live-controls-migration.md
iindex d4bac12..1e8edf9 100644
---- a/docs/live-controls-migration.md
++++ b/docs/live-controls-migration.md
@@@ -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
has 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), for
launchers 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 profile
declares; the panel renders `buttons` (keyboard bindings, hold behaviour) and `video_click` (a click layer on the
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
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
@@@ -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
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
-- 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
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).
@@@ -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 new
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
`point` (`{x, y}`) commands to the script in the shape Live's hardware clients already speak. The Live
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex bf18ff4..650a2df 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -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.
**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
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
@@@ -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 its
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
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,
@@@ -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.
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
socket 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 every
command carries the epoch current when it is sent. An owner's command right after a takeover carries the epoch its
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.
- 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
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
--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 is
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
| `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 |
ddiff --git a/package-lock.json b/package-lock.json
iindex 1f09cdd..385e743 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c5819fe..68f0e44 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/panel.css b/public/panel.css
iindex 8991e1a..827669e 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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. */
ddiff --git a/public/panel.js b/public/panel.js
iindex c24c4e4..8bd6cde 100644
---- a/public/panel.js
++++ b/public/panel.js
@@@ -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]');
ddiff --git a/public/sd-card.js b/public/sd-card.js
iindex 90854eb..d9261dd 100644
---- a/public/sd-card.js
++++ b/public/sd-card.js
@@@ -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',
ddiff --git a/public/sim-world.js b/public/sim-world.js
iindex c1714f8..3daa572 100644
---- a/public/sim-world.js
++++ b/public/sim-world.js
@@@ -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
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
iindex 897d373..a55ec2e 100644
---- a/server/api/actor-limits.js
++++ b/server/api/actor-limits.js
@@@ -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);
}
ddiff --git a/server/api/auth.js b/server/api/auth.js
iindex 3fead9c..2294135 100644
---- a/server/api/auth.js
++++ b/server/api/auth.js
@@@ -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 };
ddiff --git a/server/api/resource-index.js b/server/api/resource-index.js
iindex 17e88ef..0b47d74 100644
---- a/server/api/resource-index.js
++++ b/server/api/resource-index.js
@@@ -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');
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 91e1db5..e7735c6 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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, subm
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);
-- /** 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, subm
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.
async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
fence(req);
@@@ -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,
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, subm
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
// 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');
ddiff --git a/server/app.js b/server/app.js
iindex 78f790f..520db16 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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`);
ddiff --git a/server/claims.js b/server/claims.js
iindex 5985566..ab02e8b 100644
---- a/server/claims.js
++++ b/server/claims.js
@@@ -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,
ddiff --git a/server/config.js b/server/config.js
iindex e2e1154..4353d85 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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,
},
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex dcf19bb..96d2624 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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();
ddiff --git a/server/index.js b/server/index.js
iindex 35b8727..1cfeed7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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,
});
ddiff --git a/server/jobs/metering.js b/server/jobs/metering.js
iindex 65ff6d4..f521726 100644
---- a/server/jobs/metering.js
++++ b/server/jobs/metering.js
@@@ -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
ddiff --git a/server/network.js b/server/network.js
iindex 27c5ad4..f3c4ec7 100644
---- a/server/network.js
++++ b/server/network.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 = console
function 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),
};
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
iindex 2f7845b..0fbdad4 100644
---- a/server/profiles/index.js
++++ b/server/profiles/index.js
@@@ -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']);
ddiff --git a/server/profiles/relay.generic.json b/server/profiles/relay.generic.json
iindex 7231b92..94630a8 100644
---- a/server/profiles/relay.generic.json
++++ b/server/profiles/relay.generic.json
@@@ -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": {
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 9fac317..8538a25 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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 needs
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). */
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 = () => Dat
if (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 = () => Dat
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) {
-- 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 = () => Dat
estop_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 = () => 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' });
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 = () => 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);
}
} 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 = () => Dat
function 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;
ddiff --git a/server/submissions.js b/server/submissions.js
iindex ba84d9a..9609964 100644
---- a/server/submissions.js
++++ b/server/submissions.js
@@@ -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.
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 722005a..4268742 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -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 });
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex d4e40a6..b04b036 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -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: "&lt;path&gt;"</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 });
}
ddiff --git a/server/web/home.js b/server/web/home.js
iindex c685aad..73028d8 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -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>`,
ddiff --git a/server/web/render.js b/server/web/render.js
iindex ff53c92..3f8bcc0 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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 = false
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>`
+ `<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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 6c89958..99bd449 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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, limi
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.
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,
ddiff --git a/test/adeept-dry-run.test.js b/test/adeept-dry-run.test.js
iindex 8ba723a..2fbebef 100644
---- a/test/adeept-dry-run.test.js
++++ b/test/adeept-dry-run.test.js
@@@ -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
ddiff --git a/test/community-ai.test.js b/test/community-ai.test.js
iindex 1ff4b30..da63f2e 100644
---- a/test/community-ai.test.js
++++ b/test/community-ai.test.js
@@@ -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'));
ddiff --git a/test/community.test.js b/test/community.test.js
iindex 2f6d566..2a3ecc3 100644
---- a/test/community.test.js
++++ b/test/community.test.js
@@@ -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);
ddiff --git a/test/desk-leases.test.js b/test/desk-leases.test.js
iindex 0f953a6..01bbe6b 100644
---- a/test/desk-leases.test.js
++++ b/test/desk-leases.test.js
@@@ -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)}` } });
ddiff --git a/test/device-safety.test.js b/test/device-safety.test.js
iindex c3cdf8b..86fe37b 100644
---- a/test/device-safety.test.js
++++ b/test/device-safety.test.js
@@@ -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 });
ddiff --git a/test/device.test.js b/test/device.test.js
iindex c57aa8b..ec7aa5d 100644
---- a/test/device.test.js
++++ b/test/device.test.js
@@@ -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);
ddiff --git a/test/docs-pages.test.js b/test/docs-pages.test.js
iindex 6193d09..88efdd3 100644
---- a/test/docs-pages.test.js
++++ b/test/docs-pages.test.js
@@@ -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/);
});
ddiff --git a/test/first-run.test.js b/test/first-run.test.js
iindex 1e3263a..d858ed0 100644
---- a/test/first-run.test.js
++++ b/test/first-run.test.js
@@@ -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);
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
iindex b5d96ab..f6ae4a5 100644
---- a/test/helpers/stubs.js
++++ b/test/helpers/stubs.js
@@@ -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' });
ddiff --git a/test/install.test.js b/test/install.test.js
iindex d2d25a8..d677983 100644
---- a/test/install.test.js
++++ b/test/install.test.js
@@@ -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$/);
ddiff --git a/test/link-brand.test.js b/test/link-brand.test.js
nnew file mode 100644
iindex 0000000..eaa904f
---- /dev/null
++++ b/test/link-brand.test.js
@@@ -0,0 +1,10 @@
++'use strict';
++const assert = require('node:assert/strict');
++const { spawnSync } = require('node:child_process');
++const path = require('node:path');
++const root = path.resolve(__dirname, '..');
++const oldName = ['OpenVibe[ .-]No' + 'de', 'openvibe-' + 'node', 'no' + 'd_', 'node[.]openvibe[.]network', 'node:lnk_', 'openvibe[.](bot|my|network)/mach' + 'ines'].join('|');
++const result = spawnSync('git', ['grep', '-n', '-I', '-i', '-E', oldName, '--', '.', ':!migrations/**', ':!test/link-brand.test.js'], { cwd: root, encoding: 'utf8' });
++assert.ok(result.status === 0 || result.status === 1, result.stderr || 'git grep failed');
++assert.equal(result.status, 1, `Old Link brand or principal returned:\n${(result.stdout || '').split('\n').slice(0, 10).join('\n')}`);
++console.log(' ok old Link brand and principal IDs are absent');
ddiff --git a/test/link-principal.test.js b/test/link-principal.test.js
nnew file mode 100644
iindex 0000000..c5f603c
---- /dev/null
++++ b/test/link-principal.test.js
@@@ -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();
++})();
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 58d2f9b..ce91752 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -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);
ddiff --git a/test/machine-fleet.test.js b/test/machine-fleet.test.js
iindex 83e59f0..b39a2a8 100644
---- a/test/machine-fleet.test.js
++++ b/test/machine-fleet.test.js
@@@ -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)}` } });
ddiff --git a/test/machine-screen.test.js b/test/machine-screen.test.js
iindex e432009..4535f0a 100644
---- a/test/machine-screen.test.js
++++ b/test/machine-screen.test.js
@@@ -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);
ddiff --git a/test/network-pairing.test.js b/test/network-pairing.test.js
iindex 4d46ddd..a3573e9 100644
---- a/test/network-pairing.test.js
++++ b/test/network-pairing.test.js
@@@ -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);
});
ddiff --git a/test/node-principal.test.js b/test/node-principal.test.js
ddeleted file mode 100644
iindex 1c8bd60..0000000
---- a/test/node-principal.test.js
++++ /dev/null
@@@ -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();
--})();
ddiff --git a/test/phone-pairing.test.js b/test/phone-pairing.test.js
iindex b2e966a..be1aa11 100644
---- a/test/phone-pairing.test.js
++++ b/test/phone-pairing.test.js
@@@ -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/);
ddiff --git a/test/resource-index.test.js b/test/resource-index.test.js
iindex a2725a8..d89b2c1 100644
---- a/test/resource-index.test.js
++++ b/test/resource-index.test.js
@@@ -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, []);
ddiff --git a/test/run-jobs.test.js b/test/run-jobs.test.js
iindex f486e21..468fde8 100644
---- a/test/run-jobs.test.js
++++ b/test/run-jobs.test.js
@@@ -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);
}
ddiff --git a/test/setup-keys.test.js b/test/setup-keys.test.js
iindex e8eb98e..5bc012b 100644
---- a/test/setup-keys.test.js
++++ b/test/setup-keys.test.js
@@@ -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/);
ddiff --git a/test/watch-links.test.js b/test/watch-links.test.js
iindex cac8ecd..41f0080 100644
---- a/test/watch-links.test.js
++++ b/test/watch-links.test.js
@@@ -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)}` } });
ddiff --git a/test/worker-machines.test.js b/test/worker-machines.test.js
iindex 9b7682d..7398255 100644
---- a/test/worker-machines.test.js
++++ b/test/worker-machines.test.js
@@@ -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 () => {