openvibers/openvibe.bot Commits

Add the README sections and STATUS.json the docs check needs (#15)

OP OpenVibers committed 4 Oct 2026 · 5aa985c1dd · 4 files, +226 −0

local checks ✓ 4/4 · no CI checks · review SHIP by DeepSeek harness · 1 fix round

https://github.com/OpenVibers/OpenVibe.Bot/pull/15

Changes

ddiff --git a/README.md b/README.md
iindex 80b4393..df2a336 100644
---- a/README.md
++++ b/README.md
@@@ -2,6 +2,56 @@
The open control panel for robots (openvibe.bot). Design: OpenVibe.Contracts docs/adr/ADR-043-bot-devices-and-control.md; plan track T15.
++## Purpose
++
++OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operator command. A device opens one outbound WebSocket to `/device`; a person drives it from `/control` or the REST API, through the same gate — roles, per-robot limits, cooldowns and the turn budget — over a panel built from the robot's profile rather than per-robot code.
++
++## Owns
++
++- Robots, their profiles and each robot's limits, `allow` lists, roles and operators.
++- Devices (the machines that serve a robot) and the credentials that identify them: hashed at rest, rotated and revoked, never readable back.
++- Pairing: one-time codes with a short expiry, minted by Bot or, with `BOT_PAIRING_AUTHORITY=network`, by OpenVibe.Network.
++- The `/device` and `/control` WebSockets, the command gate and the latched e-stop.
++- The command audit (`command_audit`, kept 30 days) and the `bot.*` outbox events.
++
++## Does not own
++
++- Identity, subjects and tokens (OpenVibe.Network) and the robot's record on Network.
++- Video: a device publishes over WHIP to OpenRe.Stream; Bot only hands out the URL at pairing.
++- The agent that runs on the robot; it ships with the T15 device-agent job.
++- The public openvibe.bot front page and legal pages, built by OpenVibe.Sites.
++
++## Depends on
++
++- PostgreSQL 18: robots, devices, profile rows, pairing codes, the command audit and the event outbox; `migrations/` applies on boot.
++- Valkey (`VALKEY_URL`, optional): the shared per-actor limit counters; unset, limits are counted per process.
++- OpenVibe.Network: user and service tokens, node tokens, and pairing when the authority is `network`.
++- OpenRe.Stream: the WHIP ingest base (`BOT_WHIP_BASE`) devices publish to.
++- `openvibe-contracts` v0.85.0, `openvibe-sdk` v0.26.0 and `openvibe-shared` v2.5.0 (package.json).
++
++## Capabilities
++
++| capability | guards |
++|---|---|
++| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices and the audit, for a service acting for an owner |
++| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators |
++| `bot.robot.control` | the e-stop set and clear (clear is owner-only) and the control gate |
++| `bot.device.connect` | rotate and revoke a device credential |
++
++A person acts on their own robots with a Network user token; a service acts with a service token plus the capability, for the subject it names. The REST routes are in `docs/protocol.md` §3 and the two WebSockets in §§1–2.
++
++## Tests
++
++`npm test` runs `test/run.js`, the whole suite. `npm run test:pg` (`BOT_TEST_STORE=pg`) runs the same suite against PostgreSQL; the default store is in-process PGlite. The tests cover the device and operator WebSocket gates, the REST routes, the pairing paths, the owner fence, the e-stop and the outbox.
++
++## Security
++
++- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
++- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
++- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
++- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
++- `/api/health` and `/api/ready` are loopback-only; nginx terminates TLS and upgrades `/device` and `/control`.
++
## 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` and `/control` WebSockets to `127.0.0.1:4630`. Readiness is `http://127.0.0.1:4630/api/ready` (liveness: `/api/health`); both are loopback-only.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..ed3ed65
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,41 @@
++{
++ "repository": "OpenVibers/OpenVibe.Bot",
++ "stage": "alpha",
++ "code": true,
++ "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.85.0",
++ "packages": [
++ "openvibe-contracts v0.85.0",
++ "openvibe-sdk v0.26.0",
++ "openvibe-shared v2.5.0"
++ ],
++ "capabilities": [
++ "bot.robot.read",
++ "bot.robot.manage",
++ "bot.robot.control",
++ "bot.device.connect"
++ ],
++ "events": [
++ "bot.robot.online",
++ "bot.robot.offline",
++ "bot.estop.set",
++ "bot.estop.cleared",
++ "bot.command.refused"
++ ],
++ "deployed": true,
++ "deployment": "Production at https://openvibe.bot on openvibe-ovh since 2026-10-02: release layout /opt/openvibe.bot, unit openvibe-bot.service (WorkingDirectory /opt/openvibe.bot, EnvironmentFile /etc/openvibe/bot.env, PORT 4630, TimeoutStopSec 10), behind the nginx vhost deploy/nginx/openvibe.bot.conf, deployed with `ov deploy bot`. Liveness /api/health and readiness /api/ready are loopback-only.",
++ "features": [
++ "device WebSocket /device: one outbound socket per machine, credential or Network node token, heartbeat and telemetry, commands never replayed after a reconnect",
++ "operator WebSocket /control and the REST API (/api/v1) over one command gate: roles, per-robot limits and allowlists, cooldowns and the turn budget",
++ "pairing with one-time codes, minted by Bot or delegated to OpenVibe.Network (BOT_PAIRING_AUTHORITY=network); a node machine binds on first use and reauths every 300 s",
++ "device credentials hashed at rest and rotatable; revoke closes the socket at once and revokes the Network principal for a node machine",
++ "profiles instead of per-robot rows: adeept.adr036 (+ mecanum variant), cozmo, camera.onvif, sim.rover, seeded from server/profiles/*.json",
++ "a latched e-stop only the owner clears, with halt always allowed, and a device-side deadman deadline on the motion kinds",
++ "command audit kept 30 days, and the bot.robot.online/offline, bot.estop.set/cleared and bot.command.refused outbox events",
++ "public robot-profile catalogue (GET /profiles, /profiles/:id); robots are private to their owner, with public control only as a timed turn queue",
++ "per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)"
++ ],
++ "updated": "2026-10-03"
++}
ddiff --git a/docs/cutover-pr-openvibe-bot-15.md b/docs/cutover-pr-openvibe-bot-15.md
nnew file mode 100644
iindex 0000000..888f92c
---- /dev/null
++++ b/docs/cutover-pr-openvibe-bot-15.md
@@@ -0,0 +1,77 @@
++# OpenVibe.Bot — cutover runbook for PR #15
++
++The harness marks OpenVibe.Bot **risk data** (the repository owns `migrations/`), so a PR that merges into it
++must carry a cutover manifest and a green rehearsal even when the change itself touches no schema. This is the
++runbook for PR #15: how it is ordered, what is backed up, how the result is verified, and the way back.
++
++## Scope — what this PR actually changes
++
++PR #15 adds documentation and a test only:
++
++- `README.md` — the sections `OpenVibe.Contracts/scripts/docs-currency.js` requires (Purpose, Owns,
++ Does not own, Depends on, Capabilities, Tests, Security), plus the existing Deploy.
++- `STATUS.json` (new) — the estate record (`repository`, `stage`, `deployed`, `contracts`, `features`,
++ `updated`) that the same check reads.
++- `test/docs.test.js` (new) — asserts those two files against the check's rules.
++
++No migration, no `server/` code, no route, no configuration, no environment variable and no data change.
++Merging and deploying it writes nothing to the database. The cutover is therefore a **no-op on data**; the steps
++below are the standard deploy guards, kept so the rehearsal exercises them on a copy of the data.
++
++## What runs, in which order
++
++1. **Merge gate (harness).** Checks green, review SHIP, this runbook in the PR description
++ (`{"runbook": "docs/cutover-pr-openvibe-bot-15.md", "rehearsal": "bot-pr15"}`) and
++ `ds/deploy/rehearsals/bot-pr15.json` saying `{"ok": true}`. Only then does the squash-merge run.
++2. **Backup.** Take the standard pre-deploy backup of Bot's PostgreSQL database (pgBackRest, the nightly
++ repository in B2) and confirm the latest restore point is current. This PR cannot need it, but the guard is
++ unconditional: no deploy starts on an unbacked database.
++3. **Deploy.** Bot deploys through its own pipeline (`ov deploy bot` queues the guarded deploy on
++ `openvibe-ovh`; the unit is `openvibe-bot.service`, layout `/opt/openvibe.bot`, port 4630). On boot the
++ service applies `migrations/` with the owner role on `DATABASE_DIRECT_URL` and then serves on the pooled
++ `DATABASE_URL`.
++4. **Verify.** Run the checks in the next section before the deploy is called good. A failed readiness check
++ rolls the release back automatically.
++
++## The backup
++
++- Command: the standard pgBackRest backup of the Bot database (the same one every Bot deploy takes), then
++ `pgbackrest info` to confirm a restore point newer than the deploy start.
++- Because this PR changes no schema, there is nothing to restore for it; the backup is the guard that lets the
++ deploy proceed at all, and it is the way back for any other commit that ships in the same release.
++- No `scripts/migrate-to-postgres.js` import step applies: Bot is already on PostgreSQL (schema final in
++ `migrations/0001_bot.sql`, with `0002_node_principal.sql` additive).
++
++## How the result is verified
++
++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. `node <OpenVibe.Contracts>/scripts/docs-currency.js ~/OpenVibers --only bot` prints `✓ bot current`
++ (the fix this PR carries).
++5. The migrations table shows the same set of applied migrations as before the deploy — **0 pending** for this
++ PR — and row counts of `robots`, `devices`, `command_audit` and `bot_event_outbox` are unchanged.
++6. `https://openvibe.bot/` serves, and the `/device` and `/control` WebSocket upgrades still answer.
++
++If any of 1–4 fails, the deploy is not good: roll back (below) and investigate.
++
++## Way back (rollback)
++
++- **Automatic:** while the deploy waits for readiness, a non-2xx `/api/ready` restores the previous release's
++ 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.
++- **Data:** there is no data to roll back for this PR (no migration ran and no row changed). If a later commit
++ in the same release did move data, restore from the pgBackRest backup taken in step 2 and roll the release
++ back to the sha that matches it.
++
++## Rehearsal on a copy of the data
++
++Whoever runs this runbook rehearses it **on a copy** — never on production:
++
++1. Restore a copy of a recent Bot database backup into a scratch database and start Bot against it (a scratch
++ instance, not `openvibe-bot.service`), with the branch's `migrations/` in place.
++2. Confirm the migration step reports **0 pending** for this PR and leaves the tables and row counts as they
++ were, then run the verification list above against the scratch instance.
++3. When all of it is green, record the marker
++ `ds/deploy/rehearsals/bot-pr15.json` = `{"ok": true}` on the harness. **This runbook must not write that
++ marker itself** — it is written by whoever performed the rehearsal.
ddiff --git a/test/docs.test.js b/test/docs.test.js
nnew file mode 100644
iindex 0000000..5cf432c
---- /dev/null
++++ b/test/docs.test.js
@@@ -0,0 +1,58 @@
++'use strict';
++// The documentation contract (OpenVibe.Contracts scripts/docs-currency.js, roadmap WS-U task 3): the README
++// states what the ecosystem needs to know about Bot, and STATUS.json matches the code in the same release.
++// The Contracts check runs against this checkout; this test keeps the same rules green in Bot's own suite.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { execFileSync } = require('child_process');
++const { check, done } = require('./helpers/app');
++
++const ROOT = path.join(__dirname, '..');
++const MANIFEST_REPOSITORY = 'OpenVibers/OpenVibe.Bot';
++const STATUS_FIELDS = ['repository', 'stage', 'deployed', 'contracts', 'features', 'updated'];
++// The sections docs-currency.js requires of every service README.
++const REQUIRED = [
++ ['Purpose', /^purpose\b|^what (it|this) is\b/i],
++ ['Owns', /^owns\b/i],
++ ['Does not own', /^does not own\b|^doesn't own\b/i],
++ ['Depends on', /^depends on\b|^dependencies\b/i],
++ ['Capabilities', /^capabilities\b|^grants\b|^principal\b/i],
++ ['Acceptance', /^acceptance\b|^tests?\b|^testing\b/i],
++ ['Security', /^security\b/i],
++ ['Deploy', /^deploy|^deployment\b|^running it in production\b|^production\b/i],
++];
++
++(async () => {
++ const readme = fs.readFileSync(path.join(ROOT, 'README.md'), 'utf8');
++ const status = JSON.parse(fs.readFileSync(path.join(ROOT, 'STATUS.json'), 'utf8'));
++ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
++
++ await check('the README has every section docs-currency.js requires', () => {
++ const headings = readme.split('\n').filter((l) => /^##\s+/.test(l)).map((l) => l.replace(/^##\s+/, '').replace(/[`*_]/g, '').trim());
++ for (const [label, re] of REQUIRED) assert.ok(headings.some((h) => re.test(h)), `README: no "## ${label}" section`);
++ });
++
++ await check('STATUS.json has the required fields and the manifest repository', () => {
++ for (const f of STATUS_FIELDS) assert.ok(status[f] !== undefined, `STATUS.json: no "${f}"`);
++ assert.strictEqual(status.repository, MANIFEST_REPOSITORY);
++ assert.ok(status.features.length > 0, 'STATUS.json: features is empty');
++ });
++
++ await check('STATUS.json names the openvibe-contracts tag package.json pins', () => {
++ const spec = { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) }['openvibe-contracts'];
++ const m = /refs\/tags\/(v\d+\.\d+\.\d+)/.exec(spec) || /^\^?~?(\d+\.\d+\.\d+)$/.exec(spec);
++ assert.ok(m, `package.json does not pin a tag for openvibe-contracts: ${spec}`);
++ assert.ok(String(status.contracts).includes(m[1]), `STATUS.json: contracts "${status.contracts}", package.json pins ${m[1]}`);
++ });
++
++ await check('STATUS.json was refreshed with the newest package.json change', () => {
++ let changed;
++ try {
++ changed = execFileSync('git', ['-C', ROOT, 'log', '-1', '--format=%cI', '--', 'package.json'], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim().slice(0, 10);
++ } catch { changed = ''; } // no git (a tarball): the Contracts check has the same limit
++ if (changed) assert.ok(String(status.updated).slice(0, 10) >= changed, `STATUS.json: updated ${status.updated}, package.json changed ${changed}`);
++ });
++
++ done();
++})();