openvibers/openvibe.bot Commits

OpenVibe.Bot service (plan T15 step 1, ADR-043): robots, devices, one-time pairing codes and device credentials (hashed, rotatable, revocable, never logged or read back), the device WebSocket (credential in the upgrade header only; hello, config, command with deadline, estop, heartbeat, telemetry, ack/nack), the operator WebSocket (private/invite/queue access, per-role allowlists, owner limits clamp values, cooldowns, owner-only e-stop latch, timed turns, service tokens acting for a viewer), audit of every command, bot.* events through the outbox, the REST API and four robot profiles (adeept.adr036 ordinary and mecanum, cozmo, camera.onvif, sim.rover). Command ids are the operator's own idempotency keys, scoped to that operator; the robot always gets a server-minted id, and a duplicate arriving while the first is in flight is answered 'pending' and never sent twice (reserved before the first await; a test proves the race). docs/protocol.md is the wire format. npm test and test:pg 5/5.

OP OpenVibers committed 29 Sept 2026 · d398445479 · 35 files, +5052 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..969ecf4
---- /dev/null
++++ b/.env.example
@@@ -0,0 +1,61 @@
++# ═══════════════════════════════════════════════════════════════
++# openvibe.bot — Environment Configuration
++# Copy to .env for development. In production this lives at /etc/openvibe/bot.env (0600).
++# Names only here; never commit a value.
++# ═══════════════════════════════════════════════════════════════
++
++# Server
++PORT=4630
++HOST=127.0.0.1
++NODE_ENV=development
++BASE_URL=http://localhost:4630
++TRUST_PROXY=1
++
++# ── Data: PostgreSQL + Valkey (ADR-035) ──────────────────────
++# In production OpenVibe.Host writes all four (roles/data/add-service.sh bot). Unset in development:
++# an embedded PGlite database in data/pglite (one process). Tests need neither.
++DATABASE_URL=
++DATABASE_DIRECT_URL=
++VALKEY_URL=
++VALKEY_PREFIX=ov:bot:
++
++# ── Identity: OpenVibe.Network ──────────────────────────────
++OV_NETWORK_URL=https://openvibe.network
++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
++#OV_NETWORK_ISSUER=https://openvibe.network
++# Optional: pin the Network's RS256 public key (PEM, \n escaped) instead of fetching the JWKS.
++#OV_NETWORK_PUBLIC_KEY=
++# Audience Bot accepts on incoming service tokens.
++BOT_AUDIENCE=openvibe.bot
++OV_OAUTH_CLIENT_ID=bot
++OV_OAUTH_CLIENT_SECRET=
++#OV_OAUTH_REDIRECT_URI=https://openvibe.bot/auth/callback
++#COOKIE_SECURE=true
++
++# ── OpenVibe.Events ─────────────────────────────────────────
++# Relay of Bot's outbox (bot.*); off when unset (rows wait in bot_event_outbox).
++#EVENTS_URL=http://127.0.0.1:4300
++#EVENTS_RELAY_INTERVAL_MS=2000
++
++# ── Pairing and device link (ADR-043) ───────────────────────
++#BOT_PAIRING_TTL_MS=600000
++#BOT_PAIRING_MAX_TRIES=5
++#BOT_HEARTBEAT_MS=1000
++#BOT_OFFLINE_MISSES=2
++#BOT_OFFLINE_GRACE_MS=3000
++#BOT_ROTATE_GRACE_MS=60000
++
++# ── Control ─────────────────────────────────────────────────
++#BOT_MAX_COMMAND_MS=300
++#BOT_HOLD_RESEND_MS=150
++#BOT_TELEMETRY_HZ=2
++#BOT_QUEUE_TURN_MS=60000
++#BOT_QUEUE_TURN_BUDGET=50
++#BOT_CONTROL_COOLDOWN_MS=0
++
++# ── Limits / jobs / installer ───────────────────────────────
++#BOT_LIMITS_MINUTE=120
++#BOT_LIMITS_HOUR=3000
++#BOT_JOBS=on
++#BOT_JOBS_INTERVAL_MS=5000
++#BOT_INSTALLER_URL=https://openvibe.bot/install
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..0f84c2e
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,12 @@
++node_modules/
++data/
++dist/
++.env
++.env.*
++!.env.example
++*.db
++*.db-wal
++*.db-shm
++*.log
++*.pem
++*.key
ddiff --git a/docs/protocol.md b/docs/protocol.md
nnew file mode 100644
iindex 0000000..f53fffd
---- /dev/null
++++ b/docs/protocol.md
@@@ -0,0 +1,248 @@
++# OpenVibe.Bot — the device and control protocol
++
++Everything here is exactly what `server/realtime.js` implements today; the device agent job builds
++against this document. Two WebSockets share one hub:
++
++- `wss://openvibe.bot/device` — one **outbound** connection per device (ADR-043 decision 4). It works
++ behind any home router and on an ESP32; video goes separately over WHIP to OpenRe.
++- `wss://openvibe.bot/control` — operators (a signed-in person, or a service acting for one).
++
++Every frame is JSON with three envelope fields on **every** message:
++
++```json
++{ "v": 1, "seq": 7, "ts": 1738065600000, "type": "…" }
++```
++
++`seq` is a per-connection monotonic counter (each side numbers its own frames); `ts` is epoch
++milliseconds. Unknown `type`s are answered with an `error` and otherwise ignored.
++
++---
++
++## 1. `/device` — the device WebSocket
++
++### Authentication
++
++The upgrade carries `Authorization: Bearer <device credential>` — **never** a query string (a
++credential in `?credential=` is ignored and the socket stays unpaired). A bad or revoked credential
++closes the socket with code **4002**. A device that has no credential yet connects **without** the
++header; such a socket may only send `pair` until it is answered (anything else → `error`
++`bot.not_paired`). One socket per device: a second connection replaces the first (close code **4000**),
++so a reconnecting agent cannot be shadowed by a stale socket.
++
++### Server → device
++
++| type | fields (besides v, seq, ts) | when |
++|---|---|---|
++| `paired` | `device_id, credential, publish_key, robot_ids, profile_id, profile` | the answer to `pair`; `credential`/`publish_key` are shown **once** |
++| `hello` | `session_id, device_id, robot_ids, server_time` | on every authenticated connection |
++| `config` | `heartbeat_ms, limits, allowed_commands, estop_latched` | right after `hello` |
++| `command` | `id, kind, value, deadline_ms, operator{subject,role}, robot_id` | an operator's command passed the gate |
++| `estop` | `latched, by, at` | the e-stop latched or the owner cleared it |
++| `heartbeat_ack` | `seq, server_time` | the answer to `heartbeat`; the device computes RTT |
++| `error` | `code, detail` | a frame the server refused |
++
++### Device → server
++
++| type | fields (besides v, seq, ts) | notes |
++|---|---|---|
++| `pair` | `robot, code, agent_version, device_kind, drivers[], capabilities{}, name` | the one-time pairing code; `robot` (from the installer command / QR) attributes a wrong try to that robot's code |
++| `heartbeat` | `seq, rtt_ms` | every `heartbeat_ms` (1 s); `rtt_ms` is the device's own measured latency |
++| `telemetry` | `battery, voltage, sensors{}, …` | at most 2 Hz; extra frames are dropped |
++| `status` | `firmware, capabilities, faults[], estop_latched` | on connect and on change |
++| `ack` | `id` | a command ran |
++| `nack` | `id, fault_code` | a command was refused on the device |
++| `estop_state` | `latched, by, at, robot_id?` | a hardware latch; `robot_id` omitted = every robot the device serves |
++
++`kind` is one of `drive`, `actuator`, `ptz`, `say`, `display`, `halt`. `deadline_ms` is an absolute
++epoch-ms instant and is present on the motion kinds only (`drive`, `actuator`, `ptz`): the device
++**must** stop the motors at that instant if no newer command arrived, and on every disconnect or crash
++path, without asking the network.
++
++### Examples
++
++Pair (unauthenticated socket):
++
++```json
++{ "v": 1, "seq": 1, "ts": 1738065600000, "type": "pair", "robot": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X",
++ "code": "7Q2M-4XZP", "agent_version": "0.1.0", "device_kind": "onboard",
++ "drivers": ["pca9685", "ads7830"], "capabilities": { "camera": { "resolution": "640x480" } }, "name": "Rover" }
++```
++
++The answer (the only time the credential appears over the wire):
++
++```json
++{ "v": 1, "seq": 1, "ts": 1738065600000, "type": "paired", "device_id": "dev_01J8Z4…",
++ "credential": "Xb3…", "publish_key": "Vt9…", "robot_ids": ["rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"],
++ "profile_id": "adeept.adr036", "profile": { "id": "adeept.adr036", "limits": { "max_command_ms": 300, "heartbeat_ms": 1000 } } }
++```
++
++```json
++{ "v": 1, "seq": 2, "ts": 1738065600000, "type": "hello", "session_id": "sess_01J8Z4…",
++ "device_id": "dev_01J8Z4…", "robot_ids": ["rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X"], "server_time": "2026-09-29T19:20:00.000Z" }
++```
++
++```json
++{ "v": 1, "seq": 3, "ts": 1738065600000, "type": "config",
++ "heartbeat_ms": 1000, "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 },
++ "allowed_commands": ["drive", "actuator", "ptz", "say", "display", "halt"], "estop_latched": false }
++```
++
++A drive command (a held control re-sends every 150 ms with a fresh `id`):
++
++```json
++{ "v": 1, "seq": 9, "ts": 1738065600000, "type": "command", "id": "cmd_01J8Z4F…", "kind": "drive",
++ "value": { "throttle": 0.6, "steer": -0.25 }, "deadline_ms": 1738065600300,
++ "operator": { "subject": "usr_01J8…", "role": "operator" }, "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X" }
++```
++
++```json
++{ "v": 1, "seq": 10, "ts": 1738065600005, "type": "ack", "id": "cmd_01J8Z4F…" }
++{ "v": 1, "seq": 11, "ts": 1738065600010, "type": "nack", "id": "cmd_01J8Z4F…", "fault_code": "obstacle" }
++```
++
++```json
++{ "v": 1, "seq": 12, "ts": 1738065600500, "type": "heartbeat", "seq": 41, "rtt_ms": 63 }
++{ "v": 1, "seq": 4, "ts": 1738065600501, "type": "heartbeat_ack", "seq": 41, "server_time": "2026-09-29T19:20:00.501Z" }
++```
++
++```json
++{ "v": 1, "seq": 13, "ts": 1738065600800, "type": "telemetry", "battery": 0.72, "voltage": 7.41,
++ "sensors": { "ultrasonic": 118 }, "rssi": -57 }
++```
++
++```json
++{ "v": 1, "seq": 14, "ts": 1738065600900, "type": "status", "firmware": "adeept-0.1.0",
++ "capabilities": { "drive": { "type": "differential", "axes": ["throttle", "steer"] } }, "faults": [], "estop_latched": false }
++```
++
++```json
++{ "v": 1, "seq": 15, "ts": 1738065601200, "type": "estop_state", "latched": true, "by": "device", "at": "2026-09-29T19:20:01.200Z" }
++{ "v": 1, "seq": 6, "ts": 1738065601201, "type": "estop", "latched": false, "by": "usr_01J8…", "at": "2026-09-29T19:20:01.201Z" }
++```
++
++Liveness: the server marks a device **offline** when no frame arrives within
++`heartbeat_ms × 2 + 3000 ms` (2 missed beats plus the grace) and never queues a command for it; a
++heartbeat or any frame brings it back. Commands are never replayed after a reconnect, and a repeated
++`id` is answered with the first result without reaching the device again.
++
++---
++
++## 2. `/control` — the operator WebSocket
++
++### Authentication
++
++`ov_token` cookie (a Network user JWT, set by `/auth`) **or** `Authorization: Bearer <user JWT>`, or a
++**service token** with capability `bot.robot.control` (audience `openvibe.bot`) plus
++`X-OV-Subject: usr_…` naming the person it acts for. Without one the socket closes with code **4002**.
++The gate then applies the **acted-for subject's** role — a service acting for a viewer is a viewer.
++
++### Client → server
++
++| type | fields | notes |
++|---|---|---|
++| `join` | `robot_id` | subscribe to a robot; refused with `error` `bot.not_an_operator` when the caller has no access |
++| `leave` | — | unsubscribe |
++| `command` | `id, kind, value, ms?` | the same `kind`s as the device; `ms` requests a deadline (capped by `max_command_ms`) |
++| `estop` | — | latch the e-stop (owner or operator) |
++| `estop_clear` | — | clear the latched e-stop (**owner only**) |
++
++### Server → client
++
++| type | fields | notes |
++|---|---|---|
++| `joined` | `robot, role, profile, allowed_commands, state` | the answer to `join` |
++| `robot_state` | `state{robot_id, online, estop{latched,by,at}, latency_ms, battery, telemetry, status, queue}` | on every change and on telemetry, ≤ 2 Hz |
++| `command_result` | `id, result, code?, reason?, latency_ms?, cached?` | `result` is `ack`, `nack`, `refused` or `expired`; a repeated `id` is answered `cached: true` |
++| `error` | `code, detail` | a frame or join the server refused |
++
++`robot_state.queue` (only for a `queue` robot) is
++`{ robot_id, subject, active, turn_ends_at, turn_subject, position, budget, used }` — a waiting
++person's `position` is 1-based (0 while they hold the turn).
++
++### Examples
++
++```json
++{ "v": 1, "seq": 1, "ts": 1738065600000, "type": "join", "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X" }
++```
++
++```json
++{ "v": 1, "seq": 1, "ts": 1738065600001, "type": "joined",
++ "robot": { "id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "name": "Rover", "access_policy": "private",
++ "estop": { "latched": false, "by": null, "at": null } },
++ "role": "operator", "profile": { "id": "adeept.adr036" },
++ "allowed_commands": ["drive", "actuator", "ptz", "say", "display", "halt"],
++ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true, "latency_ms": 63, "battery": 0.72, "queue": null } }
++```
++
++```json
++{ "v": 1, "seq": 2, "ts": 1738065600200, "type": "command", "id": "cmd_01J8Z4F…",
++ "kind": "drive", "value": { "throttle": 0.6, "steer": -0.25 }, "ms": 300 }
++```
++
++```json
++{ "v": 1, "seq": 2, "ts": 1738065600205, "type": "command_result", "id": "cmd_01J8Z4F…",
++ "result": "ack", "latency_ms": 42 }
++```
++
++```json
++{ "v": 1, "seq": 3, "ts": 1738065600206, "type": "command_result", "id": "cmd_01J8Z4F…",
++ "result": "refused", "code": "bot.device_offline", "reason": "the device is offline; commands are never queued" }
++```
++
++```json
++{ "v": 1, "seq": 3, "ts": 1738065600800, "type": "robot_state",
++ "state": { "robot_id": "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "online": true,
++ "estop": { "latched": false, "by": null, "at": null }, "latency_ms": 63, "battery": 0.71,
++ "telemetry": { "battery": 0.71, "voltage": 7.4 }, "status": null,
++ "queue": { "active": false, "position": 3, "turn_ends_at": null, "budget": 50, "used": 0 } } }
++```
++
++```json
++{ "v": 1, "seq": 4, "ts": 1738065601000, "type": "estop" }
++{ "v": 1, "seq": 4, "ts": 1738065601001, "type": "error", "code": "bot.forbidden", "detail": "only the owner clears the e-stop" }
++```
++
++Refusal codes (the gate, in order): `bot.robot_not_found`, `bot.unknown_command`, `bot.estop_latched`,
++`bot.not_an_operator`, `bot.sign_in`, `bot.read_only`, `bot.command_not_allowed`, `bot.device_offline`,
++`bot.cooldown`, `bot.not_your_turn`, `bot.turn_budget`, `bot.unknown_animation`, `bot.invalid_input`.
++
++---
++
++## 3. REST (the same gate, HTTP)
++
++Every error is RFC 9457 `application/problem+json`. Authenticate with a Network **user token**
++(`Authorization: Bearer`, people act on their own things) or a **service token** with the capability
++shown; a service acts for `X-OV-Subject` / the body's `owner`.
++
++| Method & path | Auth (capability) | Answer |
++|---|---|---|
++| `GET /profiles`, `GET /profiles/:id` | public | `{ profiles[] }` / `{ profile }` |
++| `GET /robots` | user, or `bot.robot.read` + `?owner=` | `{ robots[] }` |
++| `POST /robots` | the owner, or `bot.robot.manage` | `201 { robot, pairing{code,expires_at,installer} }` |
++| `GET /robots/:id` | a member, or `bot.robot.read` | `{ robot, role }` |
++| `PATCH /robots/:id` | owner, or `bot.robot.manage` | `{ robot }` |
++| `DELETE /robots/:id` | owner, or `bot.robot.manage` | `204` |
++| `POST /robots/:id/pairing-code` | owner, or `bot.robot.manage` | `201 { code, expires_at, installer }` |
++| `GET /robots/:id/operators` | member, or `bot.robot.read` | `{ operators[] }` |
++| `POST /robots/:id/operators` | owner, or `bot.robot.manage` | `201 { operators[] }` |
++| `DELETE /robots/:id/operators/:subject` | owner, or `bot.robot.manage` | `{ operators[] }` |
++| `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 }` (once) |
++| `POST /devices/:id/revoke` | the robot's owner, `bot.device.connect` | `{ device }`; the socket closes at once |
++| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
++| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
++| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
++| `POST /pair` | the one-time code is the credential | `201 { device_id, credential, publish_key, robot_id, profile }` |
++
++Not in `/api/v1`: `GET /api/health`, `GET /api/ready`, `GET /metrics`, `GET /release.json`, the
++`/auth/*` SSO routes, and `GET /` (a one-line text health placeholder).
++
++---
++
++## 4. Audit and events
++
++Every command — allowed or refused — is written to `command_audit` (robot, device, the operator
++principal and its kind, role, kind, the clamped value summary, result `ack|nack|refused|expired`,
++reason, latency, time), kept 30 days. The outbox publishes `bot.robot.online`, `bot.robot.offline`,
++`bot.estop.set`, `bot.estop.cleared` and `bot.command.refused` — small payloads of ids, kinds and
++results, never a credential, a pairing code or a publish key.
ddiff --git a/migrations/0001_bot.sql b/migrations/0001_bot.sql
nnew file mode 100644
iindex 0000000..1df669b
---- /dev/null
++++ b/migrations/0001_bot.sql
@@@ -0,0 +1,149 @@
++-- phase: expand
++--
++-- OpenVibe.Bot on PostgreSQL (ADR-035, ADR-043): robots, devices, pairing, operators, the command audit,
++-- the shipped profiles and the timed public turn queue. Bot is a new service, so this is written as the
++-- final schema (no expand/migrate/contract window).
++--
++-- robots what the owner sees and shares (rob_…)
++-- devices a running agent attached to one or more robots (dev_…)
++-- pairing_codes one-time pairing codes; only the hash is stored
++-- robot_operators owner / operator / viewer per robot
++-- command_audit every command, allowed or refused, pruned after 30 days
++-- robot_profiles the shipped profiles, validated before insert
++-- robot_queue the timed public turn queue (access_policy 'queue')
++-- bot_event_outbox the openvibe-sdk transactional outbox (outboxSchema('bot_event_outbox'))
++--
++-- Every query shape the code runs has an index; the comment above each index names the queries.
++-- Locks are always taken in one order: the robot row, then robot_queue, then devices (never the reverse).
++
++-- ── Robots ──────────────────────────────────────────────────────────────────────────────────────
++CREATE TABLE robots (
++ id text PRIMARY KEY, -- rob_<ULID>
++ owner_subject text NOT NULL, -- usr_…
++ name text NOT NULL,
++ profile_id text NOT NULL, -- e.g. adeept.adr036
++ profile_version integer NOT NULL DEFAULT 1,
++ access_policy text NOT NULL DEFAULT 'private' CHECK (access_policy IN ('private', 'invite', 'queue')),
++ limits jsonb NOT NULL DEFAULT '{}', -- owner-set: max_speed, max_turn, max_command_ms, turn_ms, turn_budget, cooldown_ms, allow
++ estop_latched boolean NOT NULL DEFAULT false,
++ estop_by text, -- the principal that latched it, or 'device'
++ estop_at timestamptz,
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++-- The owner's robots, newest first; robots by profile (the gallery).
++CREATE INDEX robots_owner ON robots (owner_subject, created_at DESC);
++CREATE INDEX robots_profile ON robots (profile_id, created_at DESC);
++
++-- ── Devices ─────────────────────────────────────────────────────────────────────────────────────
++CREATE TABLE devices (
++ id text PRIMARY KEY, -- dev_<ULID>
++ robot_ids jsonb NOT NULL DEFAULT '[]', -- rob_… ids this device serves (first is primary)
++ name text,
++ kind text NOT NULL CHECK (kind IN ('onboard', 'bridge', 'server')),
++ agent_version text,
++ drivers jsonb NOT NULL DEFAULT '[]', -- declared by the device at pairing
++ capabilities jsonb NOT NULL DEFAULT '{}', -- declared by the device at pairing
++ credential_hash text NOT NULL, -- sha256 of the 32-byte device credential
++ credential_prev_hash text, -- the previous credential, valid until prev_valid_until
++ prev_valid_until timestamptz,
++ publish_key_hash text, -- sha256 of the WHIP publish key (OpenRe)
++ last_seen timestamptz,
++ revoked_at timestamptz,
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++-- Credential lookup on every device upgrade; only live devices are candidates.
++CREATE UNIQUE INDEX devices_credential ON devices (credential_hash) WHERE revoked_at IS NULL;
++CREATE INDEX devices_prev_credential ON devices (credential_prev_hash) WHERE revoked_at IS NULL AND credential_prev_hash IS NOT NULL;
++-- Devices for one robot (robot_ids contains rob_…): the owner's device list.
++CREATE INDEX devices_robot ON devices USING gin (robot_ids jsonb_path_ops);
++
++-- ── Pairing codes ───────────────────────────────────────────────────────────────────────────────
++CREATE TABLE pairing_codes (
++ id text PRIMARY KEY, -- pair_<ULID>
++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
++ code_hash text NOT NULL, -- sha256 of the normalised code
++ created_by text,
++ expires_at timestamptz NOT NULL,
++ tries integer NOT NULL DEFAULT 0, -- wrong tries against this code; 5 ends it
++ used_at timestamptz,
++ created_at timestamptz NOT NULL
++);
++-- Redeem looks a code up by its hash; create replaces a robot's unused code.
++CREATE UNIQUE INDEX pairing_codes_hash ON pairing_codes (code_hash);
++CREATE INDEX pairing_codes_robot ON pairing_codes (robot_id, created_at DESC);
++
++-- ── Operators ───────────────────────────────────────────────────────────────────────────────────
++CREATE TABLE robot_operators (
++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
++ subject text NOT NULL, -- usr_… (owner, invited operator, viewer)
++ role text NOT NULL CHECK (role IN ('owner', 'operator', 'viewer')),
++ added_by text,
++ created_at timestamptz NOT NULL,
++ PRIMARY KEY (robot_id, subject)
++);
++-- The role a signed-in person has on a robot (join), and "robots I may act on".
++CREATE INDEX robot_operators_subject ON robot_operators (subject, robot_id);
++
++-- ── Command audit ───────────────────────────────────────────────────────────────────────────────
++CREATE TABLE command_audit (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ robot_id text NOT NULL,
++ device_id text,
++ operator_subject text, -- the person, or the subject a service acted for
++ operator_kind text, -- user | service
++ role text, -- owner | operator | viewer | null
++ kind text, -- drive | actuator | ptz | say | display | halt | estop
++ value jsonb NOT NULL DEFAULT '{}', -- a small summary (clamped axes, servo, text length), never a credential
++ result text NOT NULL CHECK (result IN ('ack', 'nack', 'refused', 'expired')),
++ reason text,
++ latency_ms integer,
++ at timestamptz NOT NULL
++);
++-- The owner's paged audit for one robot, newest first (keyset on (at, id)).
++CREATE INDEX command_audit_robot ON command_audit (robot_id, id DESC);
++-- The 30-day prune.
++CREATE INDEX command_audit_at ON command_audit (at);
++
++-- ── Profiles ────────────────────────────────────────────────────────────────────────────────────
++CREATE TABLE robot_profiles (
++ id text NOT NULL, -- e.g. cozmo
++ version integer NOT NULL,
++ profile jsonb NOT NULL, -- validated server/profiles/*.json
++ created_at timestamptz NOT NULL,
++ PRIMARY KEY (id, version)
++);
++
++-- ── Turn queue ──────────────────────────────────────────────────────────────────────────────────
++CREATE TABLE robot_queue (
++ robot_id text NOT NULL REFERENCES robots(id) ON DELETE CASCADE,
++ subject text NOT NULL, -- usr_…
++ joined_at timestamptz NOT NULL,
++ state text NOT NULL DEFAULT 'waiting' CHECK (state IN ('waiting', 'active', 'done')),
++ turn_started_at timestamptz,
++ turn_ends_at timestamptz,
++ commands_used integer NOT NULL DEFAULT 0,
++ PRIMARY KEY (robot_id, subject)
++);
++-- The active turn and the waiting order (promote the oldest waiting).
++CREATE INDEX robot_queue_order ON robot_queue (robot_id, joined_at);
++CREATE INDEX robot_queue_active ON robot_queue (robot_id) WHERE state = 'active';
++
++-- ── Events: the openvibe-sdk outbox (outboxSchema('bot_event_outbox')) ───────────────────────────
++CREATE TABLE IF NOT EXISTS bot_event_outbox (
++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
++ event_id text NOT NULL UNIQUE,
++ envelope jsonb NOT NULL,
++ traceparent text,
++ created_at bigint NOT NULL,
++ attempts integer NOT NULL DEFAULT 0,
++ next_attempt_at bigint NOT NULL DEFAULT 0,
++ sent_at bigint,
++ seq bigint,
++ rejected_at bigint,
++ last_error text
++);
++CREATE INDEX IF NOT EXISTS bot_event_outbox_due ON bot_event_outbox (next_attempt_at, id) WHERE sent_at IS NULL AND rejected_at IS NULL;
++CREATE INDEX IF NOT EXISTS bot_event_outbox_sent ON bot_event_outbox (sent_at) WHERE sent_at IS NOT NULL;
++CREATE INDEX IF NOT EXISTS bot_event_outbox_rejected ON bot_event_outbox (id) WHERE rejected_at IS NOT NULL;
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..7d4f737
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1395 @@
++{
++ "name": "openvibe-bot",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-bot",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "iovalkey": "^0.4.0",
++ "jsonwebtoken": "^9.0.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
++ "pg": "^8.23.0",
++ "ws": "^8.18.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.8",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.8.tgz",
++ "integrity": "sha512-n9tsbUOhwx2epK1V0ZG9Ar4SHWUju04dhmzZXiSBXwBoleOvIfals33NAaWgagQVAL4Rbvx/Ptsu3P+pA09f6Q==",
++ "devOptional": true,
++ "license": "Apache-2.0"
++ },
++ "node_modules/@iovalkey/commands": {
++ "version": "0.1.0",
++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
++ "license": "MIT"
++ },
++ "node_modules/accepts": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-types": "~2.1.34",
++ "negotiator": "0.6.3"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/array-flatten": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
++ "license": "MIT"
++ },
++ "node_modules/body-parser": {
++ "version": "1.20.8",
++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
++ "integrity": "sha512-JNcyFQ64OiijEkPzUBTCe+hyPXUD/3LEldGQ6iF5LR1w00mx9o7xtDWHXBY2iItjdCFGoilOLNQbH943ut7pHA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "content-type": "~1.0.5",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "~1.2.0",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "on-finished": "~2.4.1",
++ "qs": "~6.16.0",
++ "raw-body": "~2.5.3",
++ "type-is": "~1.6.18",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/buffer-equal-constant-time": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/bytes": {
++ "version": "3.1.2",
++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/call-bind-apply-helpers": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/call-bound": {
++ "version": "1.0.4",
++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "get-intrinsic": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/cluster-key-slot": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/content-disposition": {
++ "version": "0.5.4",
++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "5.2.1"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/content-type": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie": {
++ "version": "0.7.2",
++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/cookie-parser": {
++ "version": "1.4.7",
++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
++ "license": "MIT",
++ "dependencies": {
++ "cookie": "0.7.2",
++ "cookie-signature": "1.0.6"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/cookie-signature": {
++ "version": "1.0.6",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
++ "license": "MIT"
++ },
++ "node_modules/debug": {
++ "version": "2.6.9",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "2.0.0"
++ }
++ },
++ "node_modules/denque": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=0.10"
++ }
++ },
++ "node_modules/depd": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/destroy": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8",
++ "npm": "1.2.8000 || >= 1.4.16"
++ }
++ },
++ "node_modules/dotenv": {
++ "version": "16.6.1",
++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=12"
++ },
++ "funding": {
++ "url": "https://dotenvx.com"
++ }
++ },
++ "node_modules/dunder-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "gopd": "^1.2.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/ecdsa-sig-formatter": {
++ "version": "1.0.11",
++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/ee-first": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
++ "license": "MIT"
++ },
++ "node_modules/encodeurl": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/es-define-property": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-errors": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/es-object-atoms": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/escape-html": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
++ "license": "MIT"
++ },
++ "node_modules/etag": {
++ "version": "1.8.1",
++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/express": {
++ "version": "4.22.3",
++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
++ "license": "MIT",
++ "dependencies": {
++ "accepts": "~1.3.8",
++ "array-flatten": "1.1.1",
++ "body-parser": "~1.20.5",
++ "content-disposition": "~0.5.4",
++ "content-type": "~1.0.4",
++ "cookie": "~0.7.1",
++ "cookie-signature": "~1.0.6",
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "finalhandler": "~1.3.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.0",
++ "merge-descriptors": "1.0.3",
++ "methods": "~1.1.2",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "path-to-regexp": "~0.1.13",
++ "proxy-addr": "~2.0.7",
++ "qs": "~6.16.0",
++ "range-parser": "~1.2.1",
++ "safe-buffer": "5.2.1",
++ "send": "~0.19.0",
++ "serve-static": "~1.16.2",
++ "setprototypeof": "1.2.0",
++ "statuses": "~2.0.1",
++ "type-is": "~1.6.18",
++ "utils-merge": "1.0.1",
++ "vary": "~1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.10.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/finalhandler": {
++ "version": "1.3.2",
++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "on-finished": "~2.4.1",
++ "parseurl": "~1.3.3",
++ "statuses": "~2.0.2",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/forwarded": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/fresh": {
++ "version": "0.5.2",
++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/function-bind": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-intrinsic": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bind-apply-helpers": "^1.0.2",
++ "es-define-property": "^1.0.1",
++ "es-errors": "^1.3.0",
++ "es-object-atoms": "^1.1.1",
++ "function-bind": "^1.1.2",
++ "get-proto": "^1.0.1",
++ "gopd": "^1.2.0",
++ "has-symbols": "^1.1.0",
++ "hasown": "^2.0.2",
++ "math-intrinsics": "^1.1.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/get-proto": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
++ "license": "MIT",
++ "dependencies": {
++ "dunder-proto": "^1.0.1",
++ "es-object-atoms": "^1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/gopd": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/has-symbols": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/hasown": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
++ "license": "MIT",
++ "dependencies": {
++ "function-bind": "^1.1.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/http-errors": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
++ "license": "MIT",
++ "dependencies": {
++ "depd": "~2.0.0",
++ "inherits": "~2.0.4",
++ "setprototypeof": "~1.2.0",
++ "statuses": "~2.0.2",
++ "toidentifier": "~1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/iconv-lite": {
++ "version": "0.4.24",
++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
++ "license": "MIT",
++ "dependencies": {
++ "safer-buffer": ">= 2.1.2 < 3"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/inherits": {
++ "version": "2.0.4",
++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
++ "license": "ISC"
++ },
++ "node_modules/iovalkey": {
++ "version": "0.4.0",
++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
++ "license": "MIT",
++ "dependencies": {
++ "@iovalkey/commands": "^0.1.0",
++ "cluster-key-slot": "^1.1.0",
++ "debug": "^4.3.4",
++ "denque": "^2.1.0",
++ "lodash.defaults": "^4.2.0",
++ "lodash.isarguments": "^3.1.0",
++ "redis-errors": "^1.2.0",
++ "redis-parser": "^3.0.0",
++ "standard-as-callback": "^2.1.0"
++ },
++ "engines": {
++ "node": ">=18.12.0"
++ }
++ },
++ "node_modules/iovalkey/node_modules/debug": {
++ "version": "4.4.3",
++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
++ "license": "MIT",
++ "dependencies": {
++ "ms": "^2.1.3"
++ },
++ "engines": {
++ "node": ">=6.0"
++ },
++ "peerDependenciesMeta": {
++ "supports-color": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/iovalkey/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/ipaddr.js": {
++ "version": "1.9.1",
++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.10"
++ }
++ },
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
++ "node_modules/jsonwebtoken": {
++ "version": "9.0.3",
++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
++ "license": "MIT",
++ "dependencies": {
++ "jws": "^4.0.1",
++ "lodash.includes": "^4.3.0",
++ "lodash.isboolean": "^3.0.3",
++ "lodash.isinteger": "^4.0.4",
++ "lodash.isnumber": "^3.0.3",
++ "lodash.isplainobject": "^4.0.6",
++ "lodash.isstring": "^4.0.1",
++ "lodash.once": "^4.0.0",
++ "ms": "^2.1.1",
++ "semver": "^7.5.4"
++ },
++ "engines": {
++ "node": ">=12",
++ "npm": ">=6"
++ }
++ },
++ "node_modules/jsonwebtoken/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/jwa": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer-equal-constant-time": "^1.0.1",
++ "ecdsa-sig-formatter": "1.0.11",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/jws": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
++ "license": "MIT",
++ "dependencies": {
++ "jwa": "^2.0.1",
++ "safe-buffer": "^5.0.1"
++ }
++ },
++ "node_modules/lodash.defaults": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.includes": {
++ "version": "4.3.0",
++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isarguments": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isboolean": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isinteger": {
++ "version": "4.0.4",
++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isnumber": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isplainobject": {
++ "version": "4.0.6",
++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.isstring": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
++ "license": "MIT"
++ },
++ "node_modules/lodash.once": {
++ "version": "4.1.1",
++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
++ "license": "MIT"
++ },
++ "node_modules/math-intrinsics": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ }
++ },
++ "node_modules/media-typer": {
++ "version": "0.3.0",
++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/merge-descriptors": {
++ "version": "1.0.3",
++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/methods": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
++ "license": "MIT",
++ "bin": {
++ "mime": "cli.js"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/mime-db": {
++ "version": "1.52.0",
++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/mime-types": {
++ "version": "2.1.35",
++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
++ "license": "MIT",
++ "dependencies": {
++ "mime-db": "1.52.0"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/ms": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
++ "license": "MIT"
++ },
++ "node_modules/negotiator": {
++ "version": "0.6.3",
++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/object-inspect": {
++ "version": "1.13.4",
++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/on-finished": {
++ "version": "2.4.1",
++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
++ "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
++ "license": "MIT",
++ "dependencies": {
++ "ee-first": "1.1.1"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.79.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "integrity": "sha512-FY5GLbiOq0kDw5lUPPzX6h3ATbzvsr/mX5DUCF4GRfn6vGMP0p89rZZDhgtVDaS+yCBCYMZ8zFaZxuHEkEVwVg==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/openvibe-sdk": {
++ "version": "0.25.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "integrity": "sha512-8eurZLyFUtB8XN16hLUuYkah+qwS3tkvdzul5qdZkJTi0aZ4q8dq2QGWSq6pN5mtVPdJyuJ8Mo53r36lixmP1g==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=20"
++ },
++ "peerDependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "better-sqlite3": ">=11",
++ "iovalkey": "^0.4.0",
++ "pg": "^8.23.0"
++ },
++ "peerDependenciesMeta": {
++ "@electric-sql/pglite": {
++ "optional": true
++ },
++ "better-sqlite3": {
++ "optional": true
++ },
++ "iovalkey": {
++ "optional": true
++ },
++ "pg": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/openvibe-shared": {
++ "version": "2.2.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
++ "integrity": "sha512-1q+iUuUxzPeylTYmithW4jR++Jccvn5GEOrB1B3D18Dgn6OXQhPpvhPQAa1D1k1p+dWIa1/SmggyJwSGFnTNtw==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0"
++ }
++ },
++ "node_modules/parseurl": {
++ "version": "1.3.3",
++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/path-to-regexp": {
++ "version": "0.1.13",
++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
++ "license": "MIT"
++ },
++ "node_modules/pg": {
++ "version": "8.23.0",
++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.0.tgz",
++ "integrity": "sha512-Ip2EQCngowJLGOfCwkFhPXU7/ljlhn6Rxlmy4XYfL2Y+vyRM59+8uR2xqRWKdYmbXmxCFOAmKxBuSUCdF34qLg==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-connection-string": "^2.14.0",
++ "pg-pool": "^3.14.0",
++ "pg-protocol": "^1.16.0",
++ "pg-types": "2.2.0",
++ "pgpass": "1.0.5"
++ },
++ "engines": {
++ "node": ">= 16.0.0"
++ },
++ "optionalDependencies": {
++ "pg-cloudflare": "^1.4.0"
++ },
++ "peerDependencies": {
++ "pg-native": ">=3.0.1"
++ },
++ "peerDependenciesMeta": {
++ "pg-native": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/pg-cloudflare": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
++ "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
++ "license": "MIT",
++ "optional": true
++ },
++ "node_modules/pg-connection-string": {
++ "version": "2.14.0",
++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.0.tgz",
++ "integrity": "sha512-XwWDGcLRGCXAR8F/AM5bG7Q+A3Wm2s6QeEjlOKZLlH3UYcguiqCWKyWXVag5TLTIjR7oOJUY8kcADaZgWPyLeg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-int8": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
++ "node_modules/pg-pool": {
++ "version": "3.14.0",
++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
++ "license": "MIT",
++ "peerDependencies": {
++ "pg": ">=8.0"
++ }
++ },
++ "node_modules/pg-protocol": {
++ "version": "1.16.0",
++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.0.tgz",
++ "integrity": "sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==",
++ "license": "MIT"
++ },
++ "node_modules/pg-types": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
++ "license": "MIT",
++ "dependencies": {
++ "pg-int8": "1.0.1",
++ "postgres-array": "~2.0.0",
++ "postgres-bytea": "~1.0.0",
++ "postgres-date": "~1.0.4",
++ "postgres-interval": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/pgpass": {
++ "version": "1.0.5",
++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
++ "license": "MIT",
++ "dependencies": {
++ "split2": "^4.1.0"
++ }
++ },
++ "node_modules/postgres-array": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/postgres-bytea": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-date": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/postgres-interval": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "xtend": "^4.0.0"
++ },
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/proxy-addr": {
++ "version": "2.0.8",
++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
++ "license": "MIT",
++ "dependencies": {
++ "forwarded": "0.2.0",
++ "ipaddr.js": "1.9.1"
++ },
++ "engines": {
++ "node": ">= 0.10"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/qs": {
++ "version": "6.16.0",
++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
++ "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==",
++ "license": "BSD-3-Clause",
++ "dependencies": {
++ "es-define-property": "^1.0.1",
++ "side-channel": "^1.1.1"
++ },
++ "engines": {
++ "node": ">=0.6"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/range-parser": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/raw-body": {
++ "version": "2.5.3",
++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
++ "license": "MIT",
++ "dependencies": {
++ "bytes": "~3.1.2",
++ "http-errors": "~2.0.1",
++ "iconv-lite": "~0.4.24",
++ "unpipe": "~1.0.0"
++ },
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/redis-errors": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/redis-parser": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
++ "license": "MIT",
++ "dependencies": {
++ "redis-errors": "^1.0.0"
++ },
++ "engines": {
++ "node": ">=4"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/safe-buffer": {
++ "version": "5.2.1",
++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/safer-buffer": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
++ "license": "MIT"
++ },
++ "node_modules/semver": {
++ "version": "7.8.5",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
++ "license": "ISC",
++ "bin": {
++ "semver": "bin/semver.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
++ "node_modules/send": {
++ "version": "0.19.2",
++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
++ "license": "MIT",
++ "dependencies": {
++ "debug": "2.6.9",
++ "depd": "2.0.0",
++ "destroy": "1.2.0",
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "etag": "~1.8.1",
++ "fresh": "~0.5.2",
++ "http-errors": "~2.0.1",
++ "mime": "1.6.0",
++ "ms": "2.1.3",
++ "on-finished": "~2.4.1",
++ "range-parser": "~1.2.1",
++ "statuses": "~2.0.2"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/send/node_modules/ms": {
++ "version": "2.1.3",
++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
++ "license": "MIT"
++ },
++ "node_modules/serve-static": {
++ "version": "1.16.3",
++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
++ "license": "MIT",
++ "dependencies": {
++ "encodeurl": "~2.0.0",
++ "escape-html": "~1.0.3",
++ "parseurl": "~1.3.3",
++ "send": "~0.19.1"
++ },
++ "engines": {
++ "node": ">= 0.8.0"
++ }
++ },
++ "node_modules/setprototypeof": {
++ "version": "1.2.0",
++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
++ "license": "ISC"
++ },
++ "node_modules/side-channel": {
++ "version": "1.1.1",
++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4",
++ "side-channel-list": "^1.0.1",
++ "side-channel-map": "^1.0.1",
++ "side-channel-weakmap": "^1.0.2"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-list": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
++ "license": "MIT",
++ "dependencies": {
++ "es-errors": "^1.3.0",
++ "object-inspect": "^1.13.4"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-map": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/side-channel-weakmap": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
++ "license": "MIT",
++ "dependencies": {
++ "call-bound": "^1.0.2",
++ "es-errors": "^1.3.0",
++ "get-intrinsic": "^1.2.5",
++ "object-inspect": "^1.13.3",
++ "side-channel-map": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 0.4"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/split2": {
++ "version": "4.2.0",
++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
++ "license": "ISC",
++ "engines": {
++ "node": ">= 10.x"
++ }
++ },
++ "node_modules/standard-as-callback": {
++ "version": "2.1.0",
++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
++ "license": "MIT"
++ },
++ "node_modules/statuses": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/toidentifier": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.6"
++ }
++ },
++ "node_modules/type-is": {
++ "version": "1.6.18",
++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
++ "license": "MIT",
++ "dependencies": {
++ "media-typer": "0.3.0",
++ "mime-types": "~2.1.24"
++ },
++ "engines": {
++ "node": ">= 0.6"
++ }
++ },
++ "node_modules/unpipe": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/utils-merge": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.4.0"
++ }
++ },
++ "node_modules/vary": {
++ "version": "1.1.2",
++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ }
++ },
++ "node_modules/ws": {
++ "version": "8.22.0",
++ "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
++ "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10.0.0"
++ },
++ "peerDependencies": {
++ "bufferutil": "^4.0.1",
++ "utf-8-validate": ">=5.0.2"
++ },
++ "peerDependenciesMeta": {
++ "bufferutil": {
++ "optional": true
++ },
++ "utf-8-validate": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/xtend": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.4"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..d627c55
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,41 @@
++{
++ "name": "openvibe-bot",
++ "version": "0.1.0",
++ "private": true,
++ "description": "OpenVibe.Bot — devices, pairing and control for robots on the OpenVibe network: one outbound WebSocket per device, profiles instead of rows, safety by default.",
++ "main": "server/index.js",
++ "scripts": {
++ "start": "node server/index.js",
++ "dev": "NODE_ENV=development node server/index.js",
++ "test": "node test/run.js",
++ "test:pg": "BOT_TEST_STORE=pg node test/run.js"
++ },
++ "keywords": [
++ "openvibe",
++ "bot",
++ "robot",
++ "devices",
++ "control"
++ ],
++ "author": "OpenVibers",
++ "license": "AGPL-3.0-only",
++ "repository": "github:OpenVibers/OpenVibe.Bot",
++ "dependencies": {
++ "cookie-parser": "^1.4.7",
++ "dotenv": "^16.4.7",
++ "express": "^4.21.2",
++ "iovalkey": "^0.4.0",
++ "jsonwebtoken": "^9.0.2",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.79.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.25.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v2.2.0",
++ "pg": "^8.23.0",
++ "ws": "^8.18.0"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "devDependencies": {
++ "@electric-sql/pglite": "^0.5.8"
++ }
++}
ddiff --git a/server/api/actor-limits.js b/server/api/actor-limits.js
nnew file mode 100644
iindex 0000000..b5bc777
---- /dev/null
++++ b/server/api/actor-limits.js
@@@ -0,0 +1,36 @@
++'use strict';
++
++/**
++ * Per-actor rate limits at /api/v1 (openvibe-sdk/limits). Counted by who calls, as api/auth.js resolved
++ * req.principal. Past a limit the route answers 429 problem+json `rate_limited` with Retry-After.
++ * Never limited: /api/health, /api/ready, /release.json, /metrics and the one-line GET /.
++ */
++const { createActorLimiter, createValkeyLimitStore, defaultActor } = require('openvibe-sdk/limits');
++
++function actor(req) {
++ const p = req.principal;
++ if (p && p.kind === 'service' && p.sub) return p.sub;
++ if (p && p.kind === 'user' && p.subject) return `user:${p.subject}`;
++ return defaultActor(req);
++}
++
++function createActorLimits({ config, valkey = null, now = () => Date.now(), registry = null, log = console }) {
++ const refused = registry
++ ? registry.counter({ name: 'bot_rate_limited_total', help: 'Requests refused 429 by a per-actor limit, by limit name and window', labelNames: ['limit', 'window'] })
++ : null;
++ const limiter = createActorLimiter({
++ limits: { minute: config.actorLimits.minute, hour: config.actorLimits.hour },
++ actor, now, store: createValkeyLimitStore(valkey), log,
++ onLimited(e) {
++ log.warn(`[Bot] limit ${e.name}: ${e.actor} refused, over ${e.limit} per ${e.window}`);
++ if (refused) refused.inc({ limit: e.name, window: e.window });
++ },
++ });
++ limiter.reads = (name) => {
++ const limit = limiter(name);
++ return (req, res, next) => (req.method === 'GET' || req.method === 'HEAD' ? limit(req, res, next) : next());
++ };
++ return limiter;
++}
++
++module.exports = { createActorLimits, actor };
ddiff --git a/server/api/auth.js b/server/api/auth.js
nnew file mode 100644
iindex 0000000..34115ad
---- /dev/null
++++ b/server/api/auth.js
@@@ -0,0 +1,77 @@
++'use strict';
++
++/**
++ * Who is calling /api/v1 — resolved into req.principal:
++ *
++ * { kind: 'service', sub: 'svc:live', cap: [...] } a Network client-credentials token for audience
++ * openvibe.bot; each route checks ONE capability
++ * { kind: 'user', subject: 'usr_…', username, name, avatar, role }
++ * { kind: 'anonymous' }
++ *
++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded.
++ */
++const { serviceAuth, capabilities, http, ids } = require('openvibe-contracts');
++
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++const ANON = Object.freeze({ kind: 'anonymous' });
++
++function decodePayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
++}
++
++/** A Network user token's claims → the user principal (null when it names no subject). */
++function userPrincipal(claims) {
++ if (!claims) return null;
++ const subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ if (!subject) return null;
++ return {
++ kind: 'user', subject, username: claims.username || null, name: claims.display_name || claims.username || null,
++ avatar: claims.avatar_url || null, role: claims.role || 'user',
++ };
++}
++
++/** Verify a service token (audience openvibe.bot). Returns { ok, claims } or { ok:false, code, reason }. */
++function verifyService(token, { publicKey, issuer, audience }) {
++ if (!publicKey) return { ok: false, code: 'identity.unavailable', reason: 'the Network signing key is not loaded yet' };
++ const payload = decodePayload(token);
++ if (!payload || typeof payload.sub !== 'string' || !PRINCIPAL_SUB.test(payload.sub)) return { ok: false, code: 'token.invalid', reason: 'not a service token' };
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer, audience });
++ if (!r.ok) return { ok: false, code: r.code, reason: r.reason };
++ return { ok: true, claims: r.claims };
++}
++
++function createApiAuth({ config, keys, userAuth }) {
++ function resolve(req) {
++ const header = String(req.headers.authorization || '');
++ if (!header.startsWith('Bearer ')) return { principal: ANON };
++ const token = header.slice(7).trim();
++ const publicKey = keys.get();
++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.network.issuer, audience: config.audience });
++ if (!r.ok) return { error: [401, r.code, r.reason] };
++ return { principal: { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], jti: r.claims.jti } };
++ }
++ const claims = userAuth.verify(token);
++ if (!claims) return { error: [401, 'token.invalid', 'the user token is invalid or expired'] };
++ const p = userPrincipal(claims);
++ if (!p) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
++ return { principal: p };
++ }
++
++ function middleware(req, res, next) {
++ const r = resolve(req);
++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
++ req.principal = r.principal;
++ return next();
++ }
++
++ const granted = (p, cap) => p.kind === 'service' && capabilities.grants(p.cap, cap);
++
++ return { middleware, resolve, granted };
++}
++
++module.exports = { createApiAuth, userPrincipal, verifyService, decodePayload, PRINCIPAL_SUB };
ddiff --git a/server/api/v1.js b/server/api/v1.js
nnew file mode 100644
iindex 0000000..76c67b5
---- /dev/null
++++ b/server/api/v1.js
@@@ -0,0 +1,208 @@
++'use strict';
++
++/**
++ * /api/v1 — Bot's API for services (capability-guarded Network service tokens, audience openvibe.bot)
++ * and for people (their Network user token as a Bearer). Every POST needs an Idempotency-Key except
++ * pairing (a one-time code is its own idempotency). Errors are RFC 9457 problem+json; the owner manages
++ * robots, operators and devices, and reads the audit.
++ *
++ * | Method & path | Capability (services) | People |
++ * |------------------------------------------------|------------------------|-------------------------------------|
++ * | GET /profiles, /profiles/:id | — (public) | anyone |
++ * | GET /robots | bot.robot.read | own robots (or ?owner=usr_… if the owner) |
++ * | POST /robots | bot.robot.manage | the owner (new robot + pairing code)|
++ * | GET /robots/:id | bot.robot.read | a member (owner/operator/viewer) |
++ * | PATCH /robots/:id | bot.robot.manage | owner |
++ * | DELETE /robots/:id | bot.robot.manage | owner |
++ * | POST /robots/:id/pairing-code | bot.robot.manage | owner |
++ * | GET|POST /robots/:id/operators | bot.robot.read/manage | owner (write) / member (read) |
++ * | 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 /robots/:id/estop | bot.robot.control | owner or operator |
++ * | POST /robots/:id/estop/clear | bot.robot.control | the owner only |
++ * | GET /robots/:id/audit | bot.robot.read | owner (paged) |
++ * | POST /pair | — (one-time code) | the agent (code is the credential) |
++ */
++const express = require('express');
++const { http } = require('openvibe-contracts');
++const { fail, userSubject, isRobotId, json } = require('../util');
++const { getProfile, listProfiles } = require('../profiles');
++
++const CAP = {
++ read: 'bot.robot.read',
++ manage: 'bot.robot.manage',
++ control: 'bot.robot.control',
++ connect: 'bot.device.connect',
++};
++
++function v1Router({ domain, apiAuth, limits, hub }) {
++ const r = express.Router();
++ r.use(limits.reads('bot.read'));
++ const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
++ const control = limits('bot.control', { minute: 120, hour: 1200 });
++ const pair = limits('bot.pair', { minute: 20, hour: 200 });
++
++ 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);
++ /** The subject a call acts for: a person's own subject, or a service's X-OV-Subject / body.owner. */
++ const actedFor = (req, extra) => {
++ if (req.principal.kind === 'user') return req.principal.subject;
++ const s = req.principal.subject || extra || req.headers['x-ov-subject'] || req.body?.owner;
++ if (!s) fail(422, 'bot.invalid_input', 'a service must act for a subject (X-OV-Subject or owner)');
++ return userSubject(s, 'subject');
++ };
++ const requireManage = (req, extra) => { if (req.principal.kind === 'service' && !apiAuth.granted(req.principal, CAP.manage)) fail(403, 'capability.denied', `${CAP.manage} not granted`); return actedFor(req, extra); };
++ const requireRead = (req) => { if (req.principal.kind === 'service' && !apiAuth.granted(req.principal, CAP.read)) fail(403, 'capability.denied', `${CAP.read} not granted`); };
++ const requireControlCap = (req) => { if (req.principal.kind === 'service' && !apiAuth.granted(req.principal, CAP.control)) fail(403, 'capability.denied', `${CAP.control} not granted`); };
++
++ async function member(req, robotId, roles = ['owner', 'operator', 'viewer']) {
++ const robot = await domain.robots.get(robotId);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ if (req.principal.kind === 'service') { requireRead(req); return { robot, role: 'service' }; }
++ const subject = me(req);
++ const role = subject ? await domain.members.roleOf(robotId, subject) : null;
++ if (!role || !roles.includes(role)) fail(403, 'bot.forbidden', 'you have no access to this robot');
++ return { robot, role };
++ }
++ async function owner(req, robotId) {
++ const robot = await domain.robots.get(robotId);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ if (req.principal.kind === 'service') { requireManage(req); return robot; }
++ if (robot.owner_subject !== me(req)) fail(403, 'bot.forbidden', 'only the owner may do that');
++ return robot;
++ }
++
++ // ── Profiles (public read) ────────────────────────────────────────────────────────────────────
++ r.get('/profiles', wrap(async (req, res) => {
++ const rows = await listProfiles(domain.db);
++ res.json({ profiles: rows.map((p) => p.profile) });
++ }));
++ r.get('/profiles/:id', wrap(async (req, res) => {
++ const p = await getProfile(domain.db, req.params.id);
++ if (!p) fail(404, 'bot.profile_not_found', 'no such profile');
++ res.json({ profile: p.profile });
++ }));
++
++ // ── Robots ────────────────────────────────────────────────────────────────────────────────────
++ r.get('/robots', wrap(async (req, res) => {
++ const ownerSubject = req.principal.kind === 'service' ? null : me(req);
++ if (req.principal.kind === 'service') requireRead(req);
++ const target = ownerSubject || (req.query.owner ? userSubject(req.query.owner, 'owner') : null);
++ if (!target) fail(422, 'bot.invalid_input', 'owner is required');
++ res.json({ robots: (await domain.robots.list(target)).map(domain.present.robot) });
++ }));
++ r.post('/robots', manage, wrap(async (req, res) => {
++ const ownerSubject = requireManage(req, req.body?.owner);
++ const { robot, pairing } = await domain.robots.create({
++ owner: ownerSubject, name: req.body?.name, profile_id: req.body?.profile_id,
++ access_policy: req.body?.access_policy, limits: req.body?.limits || {},
++ });
++ res.status(201).json({ robot: domain.present.robot(robot), pairing });
++ }));
++ r.get('/robots/:id', wrap(async (req, res) => {
++ const { robot, role } = await member(req, req.params.id);
++ res.json({ robot: domain.present.robot(robot), role });
++ }));
++ r.patch('/robots/:id', manage, wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ const updated = await domain.robots.update(req.params.id, { name: req.body?.name, access_policy: req.body?.access_policy, limits: req.body?.limits });
++ res.json({ robot: domain.present.robot(updated) });
++ }));
++ r.delete('/robots/:id', manage, wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ await domain.robots.remove(req.params.id);
++ res.status(204).end();
++ }));
++ r.post('/robots/:id/pairing-code', manage, wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ res.status(201).json(await domain.pairing.create(req.params.id, me(req)));
++ }));
++
++ // ── Operators ─────────────────────────────────────────────────────────────────────────────────
++ r.get('/robots/:id/operators', wrap(async (req, res) => {
++ await member(req, req.params.id, ['owner', 'operator']);
++ res.json({ operators: await domain.members.list(req.params.id) });
++ }));
++ r.post('/robots/:id/operators', manage, wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ const subject = userSubject(req.body?.subject, 'subject');
++ if (subject === (await domain.robots.get(req.params.id)).owner_subject) fail(422, 'bot.invalid_input', 'the owner already owns this robot');
++ await domain.members.add(req.params.id, subject, req.body?.role || 'operator', me(req));
++ res.status(201).json({ operators: await domain.members.list(req.params.id) });
++ }));
++ r.delete('/robots/:id/operators/:subject', manage, wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ await domain.members.remove(req.params.id, userSubject(req.params.subject, 'subject'));
++ res.json({ operators: await domain.members.list(req.params.id) });
++ }));
++
++ // ── Devices ───────────────────────────────────────────────────────────────────────────────────
++ r.get('/robots/:id/devices', wrap(async (req, res) => {
++ await member(req, req.params.id);
++ const rows = await domain.devices.listForRobot(req.params.id);
++ res.json({ devices: rows.map((d) => ({ ...domain.present.device(d), online: hub.isOnline(d.id) })) });
++ }));
++ r.post('/devices/:id/rotate', manage, wrap(async (req, res) => {
++ const d = await domain.devices.get(req.params.id);
++ if (!d) fail(404, 'bot.device_not_found', 'no such device');
++ await owner(req, json(d.robot_ids, [])[0]);
++ const rotated = await domain.devices.rotate(req.params.id);
++ res.json({ device: domain.present.device(rotated.device), credential: rotated.credential, publish_key: rotated.publish_key });
++ }));
++ r.post('/devices/:id/revoke', manage, wrap(async (req, res) => {
++ const d = await domain.devices.get(req.params.id);
++ if (!d) fail(404, 'bot.device_not_found', 'no such device');
++ await owner(req, json(d.robot_ids, [])[0]);
++ const revoked = await domain.devices.revoke(req.params.id);
++ hub.closeDevice(req.params.id, 'revoked'); // revocation disconnects the device at once
++ res.json({ device: domain.present.device(revoked) });
++ }));
++
++ // ── E-stop ────────────────────────────────────────────────────────────────────────────────────
++ r.post('/robots/:id/estop', control, wrap(async (req, res) => {
++ const { robot } = await member(req, req.params.id, ['owner', 'operator']);
++ if (req.principal.kind === 'service') requireControlCap(req);
++ if (!robot.estop_latched) await domain.estop.set(req.params.id, { latched: true, by: me(req) || req.principal.sub, principalKind: req.principal.kind });
++ hub.sendToRobotDevices(req.params.id, { type: 'estop', latched: true, by: me(req) || req.principal.sub, at: new Date().toISOString() });
++ hub.broadcast(req.params.id);
++ res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
++ }));
++ r.post('/robots/:id/estop/clear', control, wrap(async (req, res) => {
++ const robot = await owner(req, req.params.id);
++ await domain.estop.clear(req.params.id, robot.owner_subject);
++ hub.sendToRobotDevices(req.params.id, { type: 'estop', latched: false, by: robot.owner_subject, at: new Date().toISOString() });
++ hub.broadcast(req.params.id);
++ res.json({ robot: domain.present.robot(await domain.robots.get(req.params.id)) });
++ }));
++
++ // ── Audit ─────────────────────────────────────────────────────────────────────────────────────
++ r.get('/robots/:id/audit', wrap(async (req, res) => {
++ await owner(req, req.params.id);
++ let before = null;
++ if (req.query.before != null && req.query.before !== '') {
++ before = Number(req.query.before);
++ if (!Number.isInteger(before) || before <= 0) fail(422, 'bot.invalid_input', 'before must be an audit id');
++ }
++ const rows = await domain.audit.listPage(req.params.id, { before, limit: Number(req.query.limit) || 50 });
++ res.json({ audit: rows.map(auditPresent), next_before: rows.length ? rows[rows.length - 1].id : null });
++ }));
++
++ // ── Pairing without a prior socket (the agent may POST instead of using the WS) ───────────────
++ r.post('/pair', pair, wrap(async (req, res) => {
++ const result = await domain.pairing.redeem({
++ robot: req.body?.robot || null, code: req.body?.code, agent_version: req.body?.agent_version || null,
++ device_kind: req.body?.device_kind || 'onboard', drivers: Array.isArray(req.body?.drivers) ? req.body.drivers : [],
++ capabilities: req.body?.capabilities && typeof req.body.capabilities === 'object' ? req.body.capabilities : {}, name: req.body?.name || null,
++ });
++ res.status(201).json({ device_id: result.device.id, credential: result.credential, publish_key: result.publish_key, robot_id: json(result.device.robot_ids, [])[0], profile: result.profile });
++ }));
++
++ return r;
++}
++
++function auditPresent(a) {
++ return { id: a.id, robot_id: a.robot_id, device_id: a.device_id, operator_subject: a.operator_subject, operator_kind: a.operator_kind, role: a.role, kind: a.kind, value: a.value, result: a.result, reason: a.reason, latency_ms: a.latency_ms, at: a.at };
++}
++
++module.exports = { v1Router, CAP };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..a8329be
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,105 @@
++'use strict';
++
++/**
++ * OpenVibe.Bot — devices, pairing and control for robots (ADR-043). Express app factory; server/index.js
++ * listens, tests build their own instance.
++ *
++ * GET /api/health, /api/ready, /release.json, /metrics (direct loopback callers only)
++ * /api/v1/* the API (service tokens + user tokens, see api/v1.js)
++ * /auth/* Network SSO session for people (the control WS accepts the ov_token cookie)
++ * GET / a one-line text placeholder; the panel and the site are designed separately
++ *
++ * createApp({ config, db, valkey, registry, keys, hub, outbox, now, fetchImpl, log }) — everything
++ * injectable. `db` is an openvibe-sdk/db handle with the schema migrated; `hub` the realtime WebSocket
++ * hub (server/realtime.js), whose handleUpgrade the caller attaches to the HTTP server.
++ */
++const path = require('path');
++const express = require('express');
++const cookieParser = require('cookie-parser');
++const { http } = require('openvibe-contracts');
++const { isLoopbackDirect } = require('openvibe-shared/metrics');
++const { loadConfig } = require('./config');
++const { openDb } = require('./db');
++const { createKeyProvider, createUserAuth } = require('./network');
++const { createBotOutbox } = require('./events/outbox');
++const { createDomain } = require('./domain');
++const { createRealtime } = require('./realtime');
++const { createApiAuth } = require('./api/auth');
++const { v1Router } = require('./api/v1');
++const { createActorLimits } = require('./api/actor-limits');
++const { createSessionRoutes, viewerMiddleware } = require('./web/session');
++const { createWebRoutes } = require('./web/routes');
++const { inputError } = require('./util');
++const { createBotReadiness, registerBotGauges } = require('./observability');
++
++const VERSION = require('../package.json').version;
++
++function createApp(opts = {}) {
++ const config = opts.config || loadConfig();
++ const db = opts.db || openDb(config);
++ const valkey = opts.valkey || null;
++ const fetchImpl = opts.fetchImpl || globalThis.fetch;
++ const log = opts.log || console;
++ const now = opts.now || (() => Date.now());
++ const keys = opts.keys || createKeyProvider(config, { fetchImpl, log });
++ const userAuth = createUserAuth(config, keys);
++ const outbox = opts.outbox || createBotOutbox({ db, config, fetchImpl: opts.eventsFetch, now, log });
++ const hub = opts.hub || createRealtime({ config, keys, userAuth, log, now });
++ const domain = createDomain({ db, config, outbox, link: hub, now, log });
++ hub.bindDomain(domain);
++ const apiAuth = createApiAuth({ config, keys, userAuth });
++ const release = require('openvibe-shared/release').createRelease({ service: 'bot', root: path.join(__dirname, '..') });
++
++ const app = express();
++ app.disable('x-powered-by');
++ app.set('trust proxy', config.trustProxy);
++
++ const registry = opts.registry || require('openvibe-shared/metrics').createRegistry();
++ const gauges = registerBotGauges(registry, { db, hub, now, log });
++ app.get('/metrics', (req, res, next) => (isLoopbackDirect(req) ? gauges.refresh().then(() => next(), () => next()) : next()));
++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'bot', release: release.release, registry });
++
++ app.use(http.middleware());
++ app.use((req, res, next) => {
++ res.setHeader('X-Content-Type-Options', 'nosniff');
++ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
++ res.setHeader('Content-Security-Policy', ["default-src 'self'", "frame-ancestors 'self'", "object-src 'none'", "base-uri 'self'"].join('; '));
++ next();
++ });
++ app.use(cookieParser());
++
++ app.get('/api/health', (req, res, next) => outbox.status().then((events) => res.json({
++ ok: true, service: 'bot', version: VERSION, devices_online: hub.onlineCount(), events,
++ }), next));
++ const readiness = createBotReadiness({ db, valkey, keys, config, outbox, hub, release: release.release, fetchImpl });
++ app.get('/api/ready', readiness.handler);
++ release.mount(app, { registry: metrics.registry });
++
++ const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
++ app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));
++ app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
++ app.use(viewerMiddleware(userAuth));
++ app.use(createWebRoutes());
++
++ app.use((req, res) => {
++ if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
++ return res.status(404).type('text/plain').send('Not found\n');
++ });
++ // eslint-disable-next-line no-unused-vars
++ app.use((err, req, res, next) => {
++ if (err && err.type === 'entity.parse.failed') return http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
++ if (err && err.type === 'entity.too.large') return http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov });
++ const refused = inputError(err);
++ if (refused && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, 422, refused.code, { detail: refused.detail, ctx: req.ov });
++ if (err && err.status && err.code && !res.headersSent && req.path.startsWith('/api/')) return http.sendProblem(res, err.status, err.code, { detail: err.detail, ctx: req.ov });
++ log.error('[Bot] unhandled error:', err);
++ if (res.headersSent) return undefined;
++ if (req.path.startsWith('/api/')) return http.sendProblem(res, 500, 'bot.internal', { ctx: req.ov });
++ return res.status(500).type('text/plain').send('Something went wrong\n');
++ });
++
++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, hub, userAuth, metrics });
++ return app;
++}
++
++module.exports = { createApp, VERSION };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..4bc2f49
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,108 @@
++'use strict';
++
++/**
++ * OpenVibe.Bot configuration. Everything comes from the environment (.env in development,
++ * /etc/openvibe/bot.env in production). loadConfig(env) is pure so tests build their own.
++ *
++ * Bot is its own service on PostgreSQL (ADR-043 decision 10). It owns robots, devices, pairing and
++ * control; it never moves money and never owns media (a device's camera publishes to OpenRe with a
++ * per-device publish key over WHIP).
++ */
++require('dotenv').config();
++
++const int = (v, d) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : d; };
++const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes', 'on'].includes(String(v).toLowerCase()));
++const trim = (u) => String(u || '').replace(/\/+$/, '');
++const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
++
++function loadConfig(env = process.env) {
++ const nodeEnv = env.NODE_ENV || 'development';
++ const isProduction = nodeEnv === 'production';
++ const port = int(env.PORT, 4630);
++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.bot' : `http://localhost:${port}`));
++ return {
++ nodeEnv,
++ isProduction,
++ port,
++ host: env.HOST || '127.0.0.1',
++ baseUrl,
++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 1,
++
++ // PostgreSQL (ADR-035): DATABASE_URL is the pooled runtime role through PgBouncer (transaction
++ // mode), DATABASE_DIRECT_URL the owner role on a direct connection, for migrations at boot.
++ db: {
++ url: env.DATABASE_URL || '',
++ directUrl: env.DATABASE_DIRECT_URL || '',
++ },
++ // Valkey: shared, never authoritative (per-actor limit counters). Unset: this process only.
++ valkey: {
++ url: env.VALKEY_URL || '',
++ prefix: env.VALKEY_PREFIX || 'ov:bot:',
++ },
++ // Identity: service tokens and user tokens are RS256 JWTs signed by OpenVibe.Network.
++ network: {
++ url: networkUrl,
++ internalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
++ issuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
++ publicKey: env.OV_NETWORK_PUBLIC_KEY ? env.OV_NETWORK_PUBLIC_KEY.replace(/\\n/g, '\n') : null,
++ },
++ audience: env.BOT_AUDIENCE || 'openvibe.bot',
++ // Bot's client in the Network (client `bot`): service tokens (Events) and the OAuth code flow
++ // for browser sign-in on openvibe.bot.
++ oauth: {
++ clientId: env.OV_OAUTH_CLIENT_ID || 'bot',
++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
++ scope: env.OV_OAUTH_SCOPE || 'profile',
++ },
++ cookies: { secure: env.COOKIE_SECURE != null ? bool(env.COOKIE_SECURE) : isProduction },
++
++ events: {
++ url: trim(env.EVENTS_URL || ''),
++ intervalMs: int(env.EVENTS_RELAY_INTERVAL_MS, 2000),
++ },
++
++ // 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.
++ pairing: {
++ ttlMs: int(env.BOT_PAIRING_TTL_MS, 10 * 60 * 1000),
++ maxTries: int(env.BOT_PAIRING_MAX_TRIES, 5),
++ },
++ // Device link (ADR-043 decision 6): heartbeat 1 s, offline after 2 missed + 3 s grace; the old
++ // credential stays valid 60 s after a rotation.
++ device: {
++ heartbeatMs: int(env.BOT_HEARTBEAT_MS, 1000),
++ offlineMisses: int(env.BOT_OFFLINE_MISSES, 2),
++ offlineGraceMs: int(env.BOT_OFFLINE_GRACE_MS, 3000),
++ rotateGraceMs: int(env.BOT_ROTATE_GRACE_MS, 60 * 1000),
++ },
++ // Control gate (ADR-043 decision 5/6/8). maxCommandMs is the absolute deadline cap; the queue is
++ // the only way a stranger drives a robot.
++ control: {
++ maxCommandMs: int(env.BOT_MAX_COMMAND_MS, 300),
++ holdResendMs: int(env.BOT_HOLD_RESEND_MS, 150),
++ telemetryHz: int(env.BOT_TELEMETRY_HZ, 2),
++ queueTurnMs: int(env.BOT_QUEUE_TURN_MS, 60 * 1000),
++ queueTurnBudget: int(env.BOT_QUEUE_TURN_BUDGET, 50),
++ cooldownMs: int(env.BOT_CONTROL_COOLDOWN_MS, 0),
++ },
++
++ // Per-actor limits on /api/v1 reads (server/api/actor-limits.js).
++ actorLimits: {
++ minute: Math.max(1, int(env.BOT_LIMITS_MINUTE, 120)),
++ hour: Math.max(1, int(env.BOT_LIMITS_HOUR, 3000)),
++ },
++
++ jobs: {
++ enabled: env.BOT_JOBS !== 'off',
++ intervalMs: int(env.BOT_JOBS_INTERVAL_MS, 5000),
++ },
++ // The one-line installer the owner copies next to the pairing code (the agent job builds it).
++ installer: {
++ scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
++ },
++ };
++}
++
++module.exports = { loadConfig };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..941924d
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,42 @@
++'use strict';
++
++/**
++ * Bot's own PostgreSQL database (ADR-035), through openvibe-sdk/db: async, pooled, one dialect.
++ *
++ * The schema is migrations/NNNN_*.sql, applied at boot by migrate() on the owner's direct connection
++ * (DATABASE_DIRECT_URL); the service then serves on the pooled runtime role (DATABASE_URL, PgBouncer in
++ * transaction mode). Bot is new: migrations/0001_bot.sql is written as the final schema, no window.
++ *
++ * robots, devices, pairing_codes, robot_operators, command_audit, robot_profiles, robot_queue
++ * the product tables (ADR-043)
++ * bot_event_outbox the openvibe-sdk transactional outbox (bot.* events)
++ *
++ * openDb(config) the serving handle: DATABASE_URL; in development without it, an
++ * embedded PGlite database in data/pglite (one process)
++ * migrate(config, {serving}) apply migrations/ with the owner role, then close that connection
++ */
++const fs = require('fs');
++const path = require('path');
++const { createDb } = require('openvibe-sdk/db');
++
++const MIGRATIONS = path.join(__dirname, '..', 'migrations');
++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
++
++function openDb(config, { registry, log = console } = {}) {
++ if (!config.db.url && !config.isProduction) {
++ log.warn(`[Bot] DATABASE_URL unset: embedded PGlite database in ${DEV_PGLITE} (development only, one process)`);
++ fs.mkdirSync(DEV_PGLITE, { recursive: true });
++ return createDb({ pglite: DEV_PGLITE, service: 'bot', registry, log });
++ }
++ return createDb({ url: config.db.url, service: 'bot', registry, log });
++}
++
++/** Apply pending migrations; several processes starting together are serialised by the SDK's lock. */
++async function migrate(config, { serving = null, log = console } = {}) {
++ if (serving && serving.store === 'pglite') return serving.migrate({ dir: MIGRATIONS, log });
++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
++ const owner = createDb({ url: config.db.directUrl, service: 'bot-migrate', max: 1, log });
++ try { return await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
++}
++
++module.exports = { openDb, migrate, MIGRATIONS };
ddiff --git a/server/domain/index.js b/server/domain/index.js
nnew file mode 100644
iindex 0000000..15e764e
---- /dev/null
++++ b/server/domain/index.js
@@@ -0,0 +1,486 @@
++'use strict';
++
++/**
++ * OpenVibe.Bot domain (ADR-043): robots, devices, pairing, operators, the command audit, the timed turn
++ * queue and the control gate. Everything here is database-backed and testable without a socket; the
++ * realtime layer (server/realtime.js) owns the live connections and calls into this.
++ *
++ * A robot (rob_…) is what the owner shares; a device (dev_…) is a running agent attached to one or more
++ * robots, holding one rotatable, revocable credential (32 random bytes, stored hashed, shown once). The
++ * gate applies, in order: the robot exists, the e-stop is clear, the caller's role, the per-role command
++ * allowlist, the access policy (private/invite: owner + operators; queue: the active turn holder too),
++ * the owner's limits (clamped), the per-turn budget and cooldowns, and the device being online (commands
++ * are never queued). Every decision — allowed or refused — is audited.
++ */
++const {
++ BotError, fail, prefixedId, iso, token, hashSecret, secretEquals, json, text, storable,
++} = require('../util');
++const { getProfile } = require('../profiles');
++const { ENVELOPE } = require('../events/outbox');
++
++const DEFAULT_ALLOW = {
++ owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],
++ operator: ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'],
++ queue: ['drive', 'halt'],
++};
++const KINDS = new Set(['drive', 'actuator', 'ptz', 'say', 'display', 'halt']);
++const MOTION = new Set(['drive', 'actuator', 'ptz']);
++
++// Crockford base32: no I, L, O or U, so a code read aloud cannot be mistyped into another.
++const CROCKFORD = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
++const normaliseCode = (s) => String(s || '').toUpperCase().replace(/[^0-9A-Z]/g, '').replace(/[ILO]/g, (c) => ({ I: '1', L: '1', O: '0' }[c]));
++function newCode() {
++ const b = require('crypto').randomBytes(8);
++ let s = '';
++ for (let i = 0; i < 8; i++) s += CROCKFORD[b[i] % 32];
++ return s;
++}
++const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
++const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
++
++const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
++
++function createDomain({ db, config, outbox, link = null, now = () => Date.now(), log = console }) {
++ // ── Presenters ────────────────────────────────────────────────────────────────────────────────
++ const presentRobot = (r) => (r ? {
++ id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
++ access_policy: r.access_policy, limits: json(r.limits, {}),
++ estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },
++ created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
++ } : null);
++ // A device is presented without any hash: the credential and the publish key are shown once only.
++ const presentDevice = (d) => (d ? {
++ id: d.id, robot_ids: json(d.robot_ids, []), name: d.name || null, kind: d.kind,
++ agent_version: d.agent_version || null, drivers: json(d.drivers, []), capabilities: json(d.capabilities, {}),
++ last_seen: d.last_seen ? iso(new Date(d.last_seen).getTime()) : null, revoked_at: d.revoked_at ? iso(new Date(d.revoked_at).getTime()) : null,
++ created_at: iso(new Date(d.created_at).getTime()), updated_at: iso(new Date(d.updated_at).getTime()),
++ } : null);
++
++ async function emit(t, event_type, subject, payload) {
++ await outbox.emitIn(t, { ...ENVELOPE, event_type, subject, payload });
++ }
++
++ // ── Membership and roles ──────────────────────────────────────────────────────────────────────
++ async function roleOf(robotId, subject, q = db) {
++ if (!subject) return null;
++ const r = await q.maybe('SELECT role FROM robot_operators WHERE robot_id = $1 AND subject = $2', [robotId, subject]);
++ return r ? r.role : null;
++ }
++ async function listOperators(robotId) {
++ return db.many('SELECT subject, role, added_by, created_at FROM robot_operators WHERE robot_id = $1 ORDER BY created_at', [robotId]);
++ }
++ async function addOperator(robotId, subject, role, addedBy) {
++ if (!['owner', 'operator', 'viewer'].includes(role)) fail(422, 'bot.invalid_role', 'role must be owner, operator or viewer');
++ if (role === 'owner') fail(422, 'bot.invalid_role', 'ownership is not transferred by adding an operator');
++ await db.query(
++ `INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, $3, $4, $5)
++ ON CONFLICT (robot_id, subject) DO UPDATE SET role = EXCLUDED.role, added_by = EXCLUDED.added_by`,
++ [robotId, subject, role, addedBy || null, iso(now())]);
++ }
++ async function removeOperator(robotId, subject) {
++ await db.query(`DELETE FROM robot_operators WHERE robot_id = $1 AND subject = $2 AND role <> 'owner'`, [robotId, subject]);
++ }
++
++ // ── Robots ────────────────────────────────────────────────────────────────────────────────────
++ async function getRobot(id) {
++ return db.maybe('SELECT * FROM robots WHERE id = $1', [id]);
++ }
++ async function listRobots(owner) {
++ return db.many('SELECT * FROM robots WHERE owner_subject = $1 ORDER BY created_at DESC', [owner]);
++ }
++ async function createRobot({ owner, name, profile_id, access_policy = 'private', limits = {}, installerUrl }) {
++ const profile = await getProfile(db, profile_id);
++ if (!profile) fail(422, 'bot.unknown_profile', `no profile ${profile_id}`);
++ const cleanName = text(name, 'name', 80);
++ if (!cleanName) fail(422, 'bot.invalid_input', 'name is required');
++ if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'access_policy must be private, invite or queue');
++ const id = prefixedId('rob', now());
++ const at = iso(now());
++ const created = await db.tx(async (t) => {
++ await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,
++ [id, owner, cleanName, profile.id, profile.version, access_policy, JSON.stringify(cleanLimits(limits)), at]);
++ await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);
++ return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);
++ });
++ const pairing = await createPairingCode(id, owner, installerUrl);
++ return { robot: created, pairing };
++ }
++ function cleanLimits(limits = {}) {
++ const out = {};
++ for (const k of ['max_speed', 'max_turn', 'max_command_ms', 'turn_ms', 'turn_budget', 'cooldown_ms']) {
++ const n = Number(limits[k]);
++ if (limits[k] != null && Number.isFinite(n) && n >= 0) out[k] = n;
++ }
++ if (limits.allow && typeof limits.allow === 'object') {
++ out.allow = {};
++ for (const role of ['owner', 'operator', 'queue']) {
++ if (Array.isArray(limits.allow[role])) out.allow[role] = limits.allow[role].filter((k) => KINDS.has(k));
++ }
++ }
++ return out;
++ }
++ async function updateRobot(id, { name, access_policy, limits }) {
++ const sets = []; const args = [id];
++ if (name !== undefined) { const n = text(name, 'name', 80); if (!n) fail(422, 'bot.invalid_input', 'name cannot be empty'); args.push(n); sets.push(`name = $${args.length}`); }
++ if (access_policy !== undefined) { if (!['private', 'invite', 'queue'].includes(access_policy)) fail(422, 'bot.invalid_policy', 'bad access_policy'); args.push(access_policy); sets.push(`access_policy = $${args.length}`); }
++ if (limits !== undefined) { args.push(JSON.stringify(cleanLimits(limits))); sets.push(`limits = $${args.length}::jsonb`); }
++ if (!sets.length) return getRobot(id);
++ args.push(iso(now()));
++ sets.push(`updated_at = $${args.length}`);
++ await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
++ return getRobot(id);
++ }
++ async function removeRobot(id) {
++ await db.query('DELETE FROM robots WHERE id = $1', [id]);
++ }
++
++ // ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
++ async function createPairingCode(robotId, createdBy, installerUrl) {
++ const code = newCode();
++ const at = iso(now());
++ const expires = iso(now() + config.pairing.ttlMs);
++ // One live code per robot: an older unused one is replaced.
++ await db.tx(async (t) => {
++ await t.query('DELETE FROM pairing_codes WHERE robot_id = $1 AND used_at IS NULL', [robotId]);
++ await t.query('INSERT INTO pairing_codes (id, robot_id, code_hash, created_by, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
++ [prefixedId('pair', now()), robotId, hashSecret(code), createdBy || null, expires, at]);
++ });
++ return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl) };
++ }
++ function installerCommand(robotId, code, installerUrl) {
++ const url = installerUrl || config.installer.scriptUrl;
++ return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;
++ }
++ /**
++ * Redeem a pairing code. `robot` (from the installer command/QR) attributes a wrong code to that
++ * robot's live code and counts the try (5 end it); without it the code is matched by hash across
++ * every live code. On success the credential and the publish key are returned once, never stored in
++ * the clear.
++ */
++ async function redeem({ robot = null, code, agent_version = null, device_kind = 'onboard', drivers = [], capabilities = {}, name = null }) {
++ const normal = normaliseCode(code);
++ if (!isCodeShape(normal)) fail(422, 'bot.invalid_pairing_code', 'the pairing code must be 8 characters (XXXX-XXXX)');
++ const hash = hashSecret(normal);
++ const at = iso(now());
++ // A wrong try must survive the refusal, so refusals are returned (not thrown) inside the
++ // transaction and raised only after it commits.
++ const result = await db.tx(async (t) => {
++ let row = null;
++ if (robot) {
++ row = await t.maybe('SELECT * FROM pairing_codes WHERE robot_id = $1 ORDER BY created_at DESC LIMIT 1 FOR UPDATE', [robot]);
++ if (!row) return { error: [404, 'bot.no_pairing_code', 'this robot has no pairing code; ask the owner for a new one'] };
++ if (!secretEquals(row.code_hash, hash)) {
++ const tries = row.tries + 1;
++ const dead = tries >= config.pairing.maxTries;
++ await t.query('UPDATE pairing_codes SET tries = $2, used_at = CASE WHEN $3 THEN $4 ELSE used_at END WHERE id = $1', [row.id, tries, dead, at]);
++ return { error: [403, dead ? 'bot.pairing_code_locked' : 'bot.pairing_code_invalid', dead ? 'too many wrong tries; the code is dead' : 'that is not the pairing code'] };
++ }
++ } else {
++ row = await t.maybe('SELECT * FROM pairing_codes WHERE code_hash = $1 AND used_at IS NULL ORDER BY created_at DESC LIMIT 1 FOR UPDATE', [hash]);
++ if (!row) return { error: [403, 'bot.pairing_code_invalid', 'that is not a live pairing code'] };
++ }
++ if (row.used_at) return { error: [403, 'bot.pairing_code_used', 'that pairing code has already been used'] };
++ if (new Date(row.expires_at).getTime() <= now()) return { error: [403, 'bot.pairing_code_expired', 'that pairing code has expired'] };
++ if (row.tries >= config.pairing.maxTries) return { error: [403, 'bot.pairing_code_locked', 'too many wrong tries; the code is dead'] };
++ await t.query('UPDATE pairing_codes SET used_at = $2 WHERE id = $1', [row.id, at]);
++ const deviceId = prefixedId('dev', now());
++ const credential = token(32);
++ const publishKey = token(32);
++ const device = await t.maybe(
++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, publish_key_hash, created_at, updated_at)
++ VALUES ($1, $2::jsonb, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9, $10, $10) RETURNING *`,
++ [deviceId, JSON.stringify([row.robot_id]), text(name, 'name', 80), device_kind, storable(agent_version || ''), JSON.stringify(drivers || []), JSON.stringify(capabilities || {}), hashSecret(credential), hashSecret(publishKey), at]);
++ return { device, credential, publishKey, robot_id: row.robot_id };
++ });
++ if (result.error) fail(result.error[0], result.error[1], result.error[2]);
++ const profile = await getProfile(db, (await getRobot(result.robot_id)).profile_id);
++ return { device: result.device, credential: result.credential, publish_key: result.publishKey, profile: profile ? profile.profile : null };
++ }
++ async function prunePairingCodes() {
++ await db.query('DELETE FROM pairing_codes WHERE expires_at < $1', [iso(now() - 24 * 3600 * 1000)]);
++ }
++
++ // ── Devices (ADR-043 decision 1) ──────────────────────────────────────────────────────────────
++ function byCredential(credential) {
++ if (!credential) return Promise.resolve(null);
++ const hash = hashSecret(credential);
++ return db.maybe(
++ `SELECT * FROM devices WHERE revoked_at IS NULL AND (credential_hash = $1 OR (credential_prev_hash = $1 AND prev_valid_until > $2))`,
++ [hash, iso(now())]);
++ }
++ async function getDevice(id) { return db.maybe('SELECT * FROM devices WHERE id = $1', [id]); }
++ async function listDevicesForRobot(robotId) {
++ return db.many('SELECT * FROM devices WHERE robot_ids @> $1::jsonb ORDER BY created_at DESC', [JSON.stringify([robotId])]);
++ }
++ async function rotateDevice(id) {
++ const d = await getDevice(id);
++ if (!d || d.revoked_at) fail(404, 'bot.device_not_found', 'no live device');
++ const credential = token(32);
++ const publishKey = token(32);
++ const at = iso(now());
++ const updated = await db.maybe(
++ `UPDATE devices SET credential_hash = $2, credential_prev_hash = $3, prev_valid_until = $4, publish_key_hash = $5, updated_at = $6
++ WHERE id = $1 RETURNING *`,
++ [id, hashSecret(credential), d.credential_hash, iso(now() + config.device.rotateGraceMs), hashSecret(publishKey), at]);
++ return { device: updated, credential, publish_key: publishKey };
++ }
++ async function revokeDevice(id) {
++ const d = await getDevice(id);
++ if (!d) fail(404, 'bot.device_not_found', 'no such device');
++ const at = iso(now());
++ const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
++ return updated;
++ }
++ async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
++
++ async function setOnline(device, online) {
++ const robotIds = json(device.robot_ids, []);
++ const at = iso(now());
++ await db.tx(async (t) => {
++ if (online) await t.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [device.id, at]);
++ for (const robotId of robotIds) {
++ await emit(t, online ? 'bot.robot.online' : 'bot.robot.offline', { type: 'robot', id: robotId },
++ { robot_id: robotId, device_id: device.id });
++ }
++ });
++ }
++
++ // ── E-stop (ADR-043 decision 6) ───────────────────────────────────────────────────────────────
++ async function setEstop(robotId, { latched, by, principalKind = 'device' }) {
++ const robot = await getRobot(robotId);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ const at = iso(now());
++ const updated = await db.tx(async (t) => {
++ const row = await t.maybe('UPDATE robots SET estop_latched = $2, estop_by = $3, estop_at = CASE WHEN $2 THEN $4::timestamptz ELSE NULL END, updated_at = $4 WHERE id = $1 RETURNING *',
++ [robotId, latched, latched ? (by || null) : null, at]);
++ await emit(t, latched ? 'bot.estop.set' : 'bot.estop.cleared', { type: 'robot', id: robotId },
++ { robot_id: robotId, by: latched ? (by || null) : null, principal_kind: principalKind });
++ return row;
++ });
++ return updated;
++ }
++ async function clearEstop(robotId, owner) {
++ const robot = await getRobot(robotId);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ if (robot.owner_subject !== owner) fail(403, 'bot.forbidden', 'only the owner clears the e-stop');
++ return setEstop(robotId, { latched: false, by: owner, principalKind: 'user' });
++ }
++
++ // ── Turn queue (ADR-043 decision 8) ───────────────────────────────────────────────────────────
++ const turnMs = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_ms) > 0 ? Number(l.turn_ms) : config.control.queueTurnMs; };
++ const turnBudget = (robot) => { const l = json(robot.limits, {}); return Number(l.turn_budget) > 0 ? Number(l.turn_budget) : config.control.queueTurnBudget; };
++ /** The active turn for a robot, expiring it and promoting the oldest waiting row when it has ended. */
++ async function currentTurn(robotId, q = db) {
++ const robot = await q.maybe('SELECT * FROM robots WHERE id = $1', [robotId]);
++ if (!robot) return null;
++ let active = await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'active'`, [robotId]);
++ if (active && new Date(active.turn_ends_at).getTime() > now()) return active;
++ if (active) await q.query(`UPDATE robot_queue SET state = 'done' WHERE robot_id = $1 AND subject = $2`, [robotId, active.subject]);
++ const next = await q.maybe(`SELECT * FROM robot_queue WHERE robot_id = $1 AND state = 'waiting' ORDER BY joined_at LIMIT 1 FOR UPDATE`, [robotId]);
++ if (!next) return null;
++ const at = now();
++ await q.query(`UPDATE robot_queue SET state = 'active', turn_started_at = $3, turn_ends_at = $4, commands_used = 0 WHERE robot_id = $1 AND subject = $2`,
++ [robotId, next.subject, iso(at), iso(at + turnMs(robot))]);
++ return { ...next, state: 'active', turn_started_at: iso(at), turn_ends_at: iso(at + turnMs(robot)), commands_used: 0 };
++ }
++ async function joinQueue(robotId, subject) {
++ const at = iso(now());
++ await db.query(`INSERT INTO robot_queue (robot_id, subject, joined_at, state) VALUES ($1, $2, $3, 'waiting')
++ ON CONFLICT (robot_id, subject) DO NOTHING`, [robotId, subject, at]);
++ await currentTurn(robotId);
++ return queueState(robotId, subject);
++ }
++ async function queueState(robotId, subject) {
++ const robot = await getRobot(robotId);
++ if (!robot) return null;
++ const active = await currentTurn(robotId);
++ const mine = await db.maybe('SELECT * FROM robot_queue WHERE robot_id = $1 AND subject = $2', [robotId, subject]);
++ let position = null;
++ if (mine && mine.state === 'waiting') {
++ const n = await db.value(`SELECT count(*)::int FROM robot_queue WHERE robot_id = $1 AND state = 'waiting' AND joined_at <= $2`, [robotId, mine.joined_at]);
++ position = Number(n);
++ }
++ return {
++ robot_id: robotId, subject: subject || null, active: !!(active && active.subject === subject),
++ turn_ends_at: active ? iso(new Date(active.turn_ends_at).getTime()) : null,
++ turn_subject: active ? active.subject : null,
++ position: active && active.subject === subject ? 0 : position,
++ budget: turnBudget(robot),
++ used: active && active.subject === subject ? active.commands_used : 0,
++ };
++ }
++ async function consumeTurn(robotId, subject) {
++ const active = await currentTurn(robotId);
++ if (!active || active.subject !== subject) return { ok: false, code: 'bot.not_your_turn' };
++ const robot = await getRobot(robotId);
++ if (active.commands_used >= turnBudget(robot)) return { ok: false, code: 'bot.turn_budget' };
++ await db.query(`UPDATE robot_queue SET commands_used = commands_used + 1 WHERE robot_id = $1 AND subject = $2`, [robotId, subject]);
++ return { ok: true };
++ }
++ /** Expire ended turns and promote the next waiting person; returns the robots that changed. */
++ async function sweepQueues() {
++ const changed = new Set();
++ const ended = await db.many(`SELECT DISTINCT robot_id FROM robot_queue WHERE state = 'active' AND turn_ends_at <= $1`, [iso(now())]);
++ for (const r of ended) { await currentTurn(r.robot_id); changed.add(r.robot_id); }
++ const idle = await db.many(`SELECT DISTINCT q.robot_id FROM robot_queue q WHERE q.state = 'waiting'
++ AND NOT EXISTS (SELECT 1 FROM robot_queue a WHERE a.robot_id = q.robot_id AND a.state = 'active')`);
++ for (const r of idle) { await currentTurn(r.robot_id); changed.add(r.robot_id); }
++ return [...changed];
++ }
++
++ // ── Audit (ADR-043 decision 9) ────────────────────────────────────────────────────────────────
++ async function auditCommand(entry) {
++ const at = iso(entry.at != null ? entry.at : now());
++ const row = await db.maybe(
++ `INSERT INTO command_audit (robot_id, device_id, operator_subject, operator_kind, role, kind, value, result, reason, latency_ms, at)
++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10, $11) RETURNING id, at`,
++ [entry.robotId, entry.deviceId || null, entry.subject || null, entry.operatorKind || null, entry.role || null, entry.kind || null,
++ JSON.stringify(entry.value || {}), entry.result, entry.reason || null, Number.isFinite(entry.latencyMs) ? Math.round(entry.latencyMs) : null, at]);
++ if (entry.result === 'refused') {
++ await db.tx(async (t) => emit(t, 'bot.command.refused', { type: 'robot', id: entry.robotId },
++ { robot_id: entry.robotId, kind: entry.kind || null, reason: entry.reason || null, role: entry.role || null }));
++ }
++ return row;
++ }
++ const mapAudit = (r) => ({
++ id: Number(r.id), robot_id: r.robot_id, device_id: r.device_id, operator_subject: r.operator_subject,
++ operator_kind: r.operator_kind, role: r.role, kind: r.kind, value: json(r.value, {}), result: r.result,
++ reason: r.reason, latency_ms: r.latency_ms, at: iso(new Date(r.at).getTime()),
++ });
++ async function listAudit(robotId, { limit = 50 } = {}) {
++ const rows = await db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id DESC LIMIT $2', [robotId, Math.min(200, Math.max(1, limit))]);
++ return rows.map(mapAudit);
++ }
++ /** One page of the owner's audit, newest first, keyset on id (before = the last id seen). */
++ async function listAuditPage(robotId, { before = null, limit = 50 } = {}) {
++ const n = Math.min(200, Math.max(1, limit));
++ const rows = before
++ ? await db.many('SELECT * FROM command_audit WHERE robot_id = $1 AND id < $2 ORDER BY id DESC LIMIT $3', [robotId, before, n])
++ : await db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id DESC LIMIT $2', [robotId, n]);
++ return rows.map(mapAudit);
++ }
++ async function pruneAudit(days = 30) {
++ return db.exec('DELETE FROM command_audit WHERE at < $1', [iso(now() - days * 24 * 3600 * 1000)]);
++ }
++
++ // ── The control gate (ADR-043 decisions 5, 6, 8) ──────────────────────────────────────────────
++ const effectiveLimits = (robot, profile) => {
++ const rl = json(robot.limits, {});
++ const pl = (profile && profile.limits) || {};
++ const maxSpeed = rl.max_speed != null ? Math.min(rl.max_speed, pl.max_speed) : pl.max_speed;
++ const maxTurn = rl.max_turn != null ? Math.min(rl.max_turn, pl.max_turn) : pl.max_turn;
++ const maxCommandMs = rl.max_command_ms != null ? Math.min(rl.max_command_ms, pl.max_command_ms) : pl.max_command_ms;
++ const cooldownMs = rl.cooldown_ms != null ? rl.cooldown_ms : config.control.cooldownMs;
++ return { maxSpeed, maxTurn, maxCommandMs, cooldownMs };
++ };
++ // Cooldown is measured from the last command sent (not from an acknowledgement), per operator and
++ // kind; the owner is never cooldowned. Bounded so a flood of subjects cannot grow it without end.
++ const lastCommandAt = new Map();
++ const markCooldown = (robotId, subject, kind) => {
++ lastCommandAt.set(`${robotId}|${subject}|${kind}`, now());
++ if (lastCommandAt.size > 4096) lastCommandAt.delete(lastCommandAt.keys().next().value);
++ };
++
++ const allowedFor = (robot, role) => {
++ const rl = json(robot.limits, {});
++ const fromOwner = rl.allow && Array.isArray(rl.allow[role]) ? rl.allow[role] : null;
++ return fromOwner || DEFAULT_ALLOW[role] || [];
++ };
++
++ /** Clamp a command value to the profile/owner limits; returns the value to send and its audit summary. */
++ function clampValue(kind, value, eff, profile) {
++ const v = value && typeof value === 'object' ? value : {};
++ if (kind === 'drive') {
++ const out = { throttle: clampNum(v.throttle != null ? v.throttle : v.speed, -eff.maxSpeed, eff.maxSpeed, 0), steer: clampNum(v.steer != null ? v.steer : v.turn, -eff.maxTurn, eff.maxTurn, 0) };
++ if (v.variant) out.variant = String(v.variant).slice(0, 16);
++ return out;
++ }
++ if (kind === 'actuator') {
++ const servo = String(v.servo || '').slice(0, 24);
++ return { servo, angle: clampNum(v.angle, -1, 1, 0) };
++ }
++ if (kind === 'ptz') return { pan: clampNum(v.pan, -1, 1, 0), tilt: clampNum(v.tilt, -1, 1, 0), zoom: clampNum(v.zoom, -1, 1, 0) };
++ if (kind === 'say') {
++ const t = text(v.text, 'text', 200);
++ if (!t) fail(422, 'bot.invalid_input', 'say needs text');
++ return { text: t };
++ }
++ if (kind === 'display') {
++ if (v.animation != null) {
++ const anims = profile && profile.mapping && Array.isArray(profile.mapping.animations) ? profile.mapping.animations : null;
++ const anim = storable(v.animation).slice(0, 64);
++ if (anims && !anims.includes(anim)) fail(422, 'bot.unknown_animation', 'that animation is not in the profile');
++ return { animation: anim };
++ }
++ const t = text(v.text, 'text', 80);
++ if (!t) fail(422, 'bot.invalid_input', 'display needs text or animation');
++ return { text: t };
++ }
++ return {};
++ }
++
++ /**
++ * Decide a command. Returns { ok:true, robot, profile, role, kind, value, deadlineMs } or
++ * { ok:false, code, reason, robot?, role? }. `online` is the live socket state the hub knows.
++ */
++ async function prepare({ robotId, principal, kind, value = {}, online = false, requestedMs = null }) {
++ const robot = await getRobot(robotId);
++ if (!robot) return { ok: false, code: 'bot.robot_not_found', reason: 'no such robot' };
++ const profileRow = await getProfile(db, robot.profile_id, robot.profile_version);
++ const profile = profileRow ? profileRow.profile : null;
++ if (!KINDS.has(kind)) return { ok: false, code: 'bot.unknown_command', reason: `unknown command kind ${kind}`, robot };
++ if (robot.estop_latched) return { ok: false, code: 'bot.estop_latched', reason: 'the e-stop is latched; only the owner can clear it', robot };
++
++ const subject = principal && principal.kind !== 'device' ? principal.subject : null;
++ let role = await roleOf(robotId, subject);
++ if (!role && robot.access_policy === 'queue' && subject) {
++ const turn = await currentTurn(robotId);
++ if (turn && turn.subject === subject) role = 'queue';
++ }
++ if (!role) {
++ return { ok: false, code: subject ? 'bot.not_an_operator' : 'bot.sign_in', reason: subject ? 'you are not an operator on this robot' : 'sign in to control a robot', robot };
++ }
++ if (role === 'viewer') {
++ if (robot.access_policy === 'queue') role = 'queue';
++ else return { ok: false, code: 'bot.read_only', reason: 'you are a viewer on this robot', robot, role };
++ }
++ const allow = allowedFor(robot, role);
++ if (!allow.includes(kind)) return { ok: false, code: 'bot.command_not_allowed', reason: `${kind} is not allowed for ${role}`, robot, role };
++ if (!online) return { ok: false, code: 'bot.device_offline', reason: 'the device is offline; commands are never queued', robot, role };
++
++ const eff = effectiveLimits(robot, profile);
++ if (eff.cooldownMs > 0 && role !== 'owner') {
++ const last = lastCommandAt.get(`${robotId}|${subject}|${kind}`);
++ if (last && now() - last < eff.cooldownMs) {
++ return { ok: false, code: 'bot.cooldown', reason: `wait ${eff.cooldownMs} ms between ${kind} commands`, robot, role };
++ }
++ }
++ if (role === 'queue') {
++ const budget = await consumeTurn(robotId, subject);
++ if (!budget.ok) return { ok: false, code: budget.code, reason: budget.code === 'bot.turn_budget' ? 'your turn budget is spent' : 'it is not your turn', robot, role };
++ }
++ let clamped;
++ try { clamped = clampValue(kind, value, eff, profile); } catch (e) { return { ok: false, code: e.code || 'bot.invalid_input', reason: e.detail || e.message, robot, role }; }
++ if (eff.cooldownMs > 0 && role !== 'owner') markCooldown(robotId, subject, kind);
++ const deadlineMs = MOTION.has(kind)
++ ? now() + clampNum(requestedMs != null ? requestedMs : eff.maxCommandMs, 1, eff.maxCommandMs, eff.maxCommandMs)
++ : null;
++ return { ok: true, robot, profile, role, kind, value: clamped, deadlineMs, limits: eff };
++ }
++
++ return {
++ db, now, config, log, outbox, link,
++ present: { robot: presentRobot, device: presentDevice },
++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
++ members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
++ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand },
++ devices: { byCredential, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
++ estop: { set: setEstop, clear: clearEstop },
++ queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
++ audit: { record: auditCommand, list: listAudit, listPage: listAuditPage, prune: pruneAudit },
++ control: { prepare, allowedFor, effectiveLimits, DEFAULT_ALLOW, KINDS },
++ };
++}
++
++module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
nnew file mode 100644
iindex 0000000..35128f4
---- /dev/null
++++ b/server/events/outbox.js
@@@ -0,0 +1,39 @@
++'use strict';
++
++/**
++ * Bot → OpenVibe.Events through the openvibe-sdk transactional outbox (ADR-004).
++ *
++ * bot.robot.online a device authenticated and is serving its robot
++ * bot.robot.offline no heartbeat within 2 missed intervals + the grace window
++ * bot.estop.set the e-stop latched (by the owner, an operator or the device)
++ * bot.estop.cleared the owner cleared the latched e-stop
++ * bot.command.refused the gate refused a command (role, allowlist, limits, policy, e-stop, cooldown)
++ *
++ * emitIn(t, …) runs inside the transaction that makes the change, so an event exists if and only if its
++ * change committed. The relay publishes with Bot's service token (events.event.publish) only when
++ * EVENTS_URL and OV_OAUTH_CLIENT_SECRET are set; otherwise rows wait in bot_event_outbox. Payloads carry
++ * ids, kinds and results only — never a credential, a pairing code or a publish key.
++ */
++const { createServiceOutbox } = require('openvibe-sdk/events');
++
++const TABLE = 'bot_event_outbox'; // migrations/0001_bot.sql
++const ACTOR = { type: 'service', id: 'bot' };
++const ENVELOPE = { actor: ACTOR, visibility: 'internal', priority: 'important' };
++
++function createBotOutbox({ db, config, fetchImpl, now, log = console }) {
++ return createServiceOutbox({
++ db,
++ source: 'bot',
++ table: TABLE,
++ eventsUrl: config.events.url,
++ networkInternalUrl: config.network.internalUrl,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ intervalMs: config.events.intervalMs,
++ log,
++ ...(fetchImpl ? { fetch: fetchImpl } : {}),
++ ...(now ? { now } : {}),
++ });
++}
++
++module.exports = { createBotOutbox, TABLE, ACTOR, ENVELOPE };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..d1ffee7
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,73 @@
++'use strict';
++
++/**
++ * OpenVibe.Bot — process entry. `node server/index.js`
++ * Listens on PORT (4630) behind nginx (openvibe.bot); see deploy/.
++ *
++ * Boot (ADR-035): apply migrations/ with the owner role (DATABASE_DIRECT_URL, one direct connection,
++ * closed afterwards), seed the shipped profiles, then serve on the pooled runtime role (DATABASE_URL)
++ * with Valkey (VALKEY_URL) for what the processes share. Several processes may start together: the
++ * migration run is serialised by an advisory lock.
++ *
++ * Background jobs (BOT_JOBS=off disables them): the events outbox relay, the 30-day audit prune, the
++ * pairing-code sweep and the turn-queue sweep (expire a turn, promote the next waiting person).
++ */
++const { loadConfig } = require('./config');
++const { openDb, migrate } = require('./db');
++const { seedProfiles } = require('./profiles');
++const { createApp } = require('./app');
++const { createValkey } = require('openvibe-sdk/valkey');
++const { createRegistry } = require('openvibe-shared/metrics');
++
++async function main() {
++ const config = loadConfig();
++ const registry = createRegistry();
++ const db = openDb(config, { registry });
++ const m = await migrate(config, { serving: db });
++ if (m.held.length) console.warn(`[Bot] migrations held: ${m.held.map((h) => `${h.id} (${h.reason})`).join('; ')}`);
++ await seedProfiles(db, { log: console });
++ const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
++
++ const app = createApp({ config, db, valkey, registry });
++ const { domain, keys, outbox, hub } = app.locals;
++ keys.start();
++
++ const timers = [];
++ if (config.jobs.enabled) {
++ const every = (ms, fn) => { const t = setInterval(() => { Promise.resolve().then(fn).catch((e) => console.warn('[Bot] job:', e.message)); }, ms); t.unref(); timers.push(t); };
++ every(6 * 3600 * 1000, () => domain.audit.prune(30));
++ every(30 * 60 * 1000, () => domain.pairing.prune());
++ every(1000, async () => { for (const robotId of await domain.queue.sweep()) hub.broadcast(robotId); });
++ outbox.start();
++ }
++
++ const server = app.listen(config.port, config.host, () => {
++ console.log(`[Bot] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: per process'})`);
++ console.log(`[Bot] profiles from server/profiles; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (outbox accumulates)'}; heartbeat ${config.device.heartbeatMs} ms, offline after ${config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs} ms`);
++ });
++ server.keepAliveTimeout = 65_000;
++ server.on('upgrade', (req, socket, head) => hub.handleUpgrade(req, socket, head));
++
++ let closing = false;
++ async function shutdown(signal) {
++ if (closing) return;
++ closing = true;
++ console.log(`[Bot] ${signal} — closing`);
++ setTimeout(() => process.exit(0), 5000).unref();
++ timers.forEach(clearInterval);
++ keys.stop();
++ await hub.close().catch(() => {});
++ await outbox.stop();
++ await new Promise((resolve) => server.close(resolve));
++ await db.close().catch(() => {});
++ if (valkey) await valkey.close().catch(() => {});
++ process.exit(0);
++ }
++ process.on('SIGTERM', async () => { await shutdown('SIGTERM'); });
++ process.on('SIGINT', async () => { await shutdown('SIGINT'); });
++}
++
++main().catch((e) => {
++ console.error(`[Bot] could not start: ${e.message}`);
++ process.exit(1);
++});
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..87dcc6a
---- /dev/null
++++ b/server/network.js
@@@ -0,0 +1,69 @@
++'use strict';
++
++/**
++ * OpenVibe.Network client.
++ *
++ * keys the Network's RS256 public key (OV_NETWORK_PUBLIC_KEY, else GET /api/.well-known/jwks,
++ * refreshed every 6 h and retried every 30 s until it loads). It verifies service tokens
++ * (audience openvibe.bot) and the browser's Network user JWT, both offline.
++ */
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { OpenVibeAuthClient } = require('openvibe-shared/auth-client');
++
++function createKeyProvider(config, { fetchImpl = globalThis.fetch, log = console } = {}) {
++ let pem = config.network.publicKey ? crypto.createPublicKey(config.network.publicKey).export({ type: 'spki', format: 'pem' }) : null;
++ let timer = null;
++ async function load() {
++ const url = `${config.network.internalUrl}/api/.well-known/jwks`;
++ try {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`JWKS ${res.status}`);
++ const body = await res.json();
++ const jwk = (body.keys || []).find((k) => k.kty === 'RSA');
++ if (jwk) pem = crypto.createPublicKey({ key: jwk, format: 'jwk' }).export({ type: 'spki', format: 'pem' });
++ else if (typeof body.public_key === 'string') pem = crypto.createPublicKey(body.public_key).export({ type: 'spki', format: 'pem' });
++ else throw new Error('JWKS contained no keys');
++ return pem;
++ } catch (e) {
++ log.warn(`[Bot] Network key not loaded from ${url}: ${e.message}`);
++ return null;
++ }
++ }
++ function start() {
++ if (config.network.publicKey || timer) return;
++ const retry = () => load().then((k) => { if (!k) setTimeout(retry, 30_000).unref(); });
++ retry();
++ timer = setInterval(async () => { await load(); }, 6 * 60 * 60 * 1000);
++ timer.unref();
++ }
++ function stop() { if (timer) clearInterval(timer); timer = null; }
++ return { get: () => pem, load, start, stop };
++}
++
++/** The OAuth client of the browser session layer plus offline verification of the Network user JWT. */
++function createUserAuth(config, keys) {
++ const client = new OpenVibeAuthClient({
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ redirectUri: config.oauth.redirectUri,
++ publicKey: null,
++ authBase: config.network.url,
++ internalBase: config.network.internalUrl,
++ });
++ /** Claims of a valid user token, or null. Service tokens are never user tokens. */
++ function verify(token) {
++ if (!token) return null;
++ const key = keys.get();
++ if (!key) return null;
++ let claims;
++ try { claims = jwt.verify(token, key, { algorithms: ['RS256'], issuer: config.network.issuer }); } catch { return null; }
++ if (!claims || typeof claims !== 'object') return null;
++ if (typeof claims.sub === 'string' && /^(svc|app|mod):/.test(claims.sub)) return null;
++ if (claims.actor_type === 'service') return null;
++ return claims;
++ }
++ return { client, verify };
++}
++
++module.exports = { createKeyProvider, createUserAuth };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..1196666
---- /dev/null
++++ b/server/observability.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/**
++ * Truthful readiness (GET /api/ready) and the Bot gauges (GET /metrics).
++ *
++ * db required a real round trip through the pool (db.ready(): the store that answered)
++ * valkey optional a real PING; without it per-actor limits are counted in each process
++ * network_jwks optional the Network signing key has loaded; without it no token can be verified
++ * events optional OpenVibe.Events answers /api/health; without it bot.* events wait in the outbox
++ *
++ * Gauges: robots, devices, devices online, people waiting in a turn queue, and the events outbox
++ * backlog. Counts only, never subjects; a scrape whose read failed leaves them out.
++ */
++const { sql } = require('openvibe-sdk/db');
++const { createReadiness, skip } = require('openvibe-shared/ready');
++const { TABLE: OUTBOX } = require('./events/outbox');
++
++const PING_TTL_MS = 15_000;
++
++function probe(url, fetchImpl) {
++ return async () => {
++ const res = await fetchImpl(url, { signal: AbortSignal.timeout(2000), headers: { Accept: 'application/json' } });
++ try { await res.body?.cancel(); } catch { /* not needed */ }
++ return res.ok ? { ok: true, detail: { http_status: res.status } } : { ok: false, error: `answered HTTP ${res.status}`, detail: { http_status: res.status } };
++ };
++}
++
++function createBotReadiness({ db, valkey = null, keys, config, outbox, hub = null, release = null, fetchImpl = globalThis.fetch }) {
++ const events = outbox.enabled
++ ? probe(`${config.events.url}/api/health`, fetchImpl)
++ : () => 'events relay off (EVENTS_URL or OV_OAUTH_CLIENT_SECRET unset): bot.* events wait in the outbox';
++ return createReadiness({
++ service: 'bot',
++ release,
++ checks: [
++ { name: 'db', required: true, check: () => db.ready() },
++ { name: 'valkey', required: false, check: () => (valkey ? valkey.ready() : skip('VALKEY_URL unset: per-actor limits are per process (one process only)')) },
++ { name: 'network_jwks', required: false, check: () => (keys.get() ? true : 'Network signing key not loaded yet: tokens cannot be verified') },
++ { name: 'events', required: false, cacheMs: outbox.enabled ? PING_TTL_MS : 0, timeoutMs: 2500, check: events },
++ ],
++ details: async (body) => (body.checks.db.status === 'ok'
++ ? { events_outbox: await outbox.status(), devices_online: hub ? hub.onlineCount() : 0, queue_waiting: await queueWaiting(db) }
++ : { events_outbox: null, devices_online: null, queue_waiting: null }),
++ });
++}
++
++async function queueWaiting(db) {
++ return Number(await db.value(`SELECT count(*)::int FROM robot_queue WHERE state = 'waiting'`) || 0);
++}
++
++/** Bot gauges on the openvibe-shared/metrics registry; refresh() reads them all in one query. */
++function registerBotGauges(registry, { db, hub = null, now = () => Date.now(), log = console }) {
++ let snap = null;
++ const read = (f) => () => (snap ? f(snap) : undefined);
++ registry.gauge({ name: 'bot_robots', help: 'Robots on Bot', collect: read((s) => s.robots) });
++ registry.gauge({ name: 'bot_devices', help: 'Devices that are not revoked', collect: read((s) => s.devices) });
++ registry.gauge({ name: 'bot_queue_waiting', help: 'People waiting for a turn in a queue robot', collect: read((s) => s.queue) });
++ registry.gauge({ name: 'bot_outbox_pending', help: 'bot.* events waiting in the outbox', collect: read((s) => s.outbox) });
++ return {
++ async refresh() {
++ try {
++ snap = await Promise.race([
++ db.one(sql`SELECT
++ (SELECT count(*) FROM robots)::int AS robots,
++ (SELECT count(*) FROM devices WHERE revoked_at IS NULL)::int AS devices,
++ (SELECT count(*) FROM robot_queue WHERE state = 'waiting')::int AS queue,
++ (SELECT count(*) FROM ${sql.ident(OUTBOX)} WHERE sent_at IS NULL AND rejected_at IS NULL)::int AS outbox`),
++ new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 2000).unref()),
++ ]);
++ } catch (e) {
++ snap = null;
++ log.warn(`[Bot] gauges not read: ${e.message}`);
++ }
++ },
++ };
++}
++
++/** Truthful count of online devices, read from the live hub (not the database). */
++function onlineCount(hub) { return hub ? [...hub.devices()].filter((c) => c.online).length : 0; }
++
++module.exports = { createBotReadiness, registerBotGauges };
ddiff --git a/server/profiles/adeept.adr036.json b/server/profiles/adeept.adr036.json
nnew file mode 100644
iindex 0000000..d75b71c
---- /dev/null
++++ b/server/profiles/adeept.adr036.json
@@@ -0,0 +1,43 @@
++{
++ "id": "adeept.adr036",
++ "version": 1,
++ "name": "Adeept ADR036 4WD Smart Car",
++ "vendor": "Adeept",
++ "kind": "onboard",
++ "description": "Raspberry Pi rover with the Adeept Robot HAT: four DC motors and the pan/tilt servos on a PCA9685, an ADS7830 battery readout, WS2812 lights, a buzzer, ultrasound and line tracking. Ships as both a differential and a mecanum chassis.",
++ "capabilities": [
++ "drive.differential",
++ "drive.mecanum",
++ "servo.pan_tilt",
++ "lights.rgb",
++ "speaker.horn",
++ "sensor.ultrasonic",
++ "sensor.line",
++ "battery"
++ ],
++ "variants": {
++ "differential": { "drive": "differential" },
++ "mecanum": { "drive": "mecanum" }
++ },
++ "mapping": {
++ "driver": "pca9685",
++ "i2c_bus": 1,
++ "address": "0x5f",
++ "motor_channels": [8, 9, 10, 11, 12, 13, 14, 15],
++ "servo_channels": { "pan": 0, "tilt": 1 },
++ "battery": { "driver": "ads7830", "address": "0x48" },
++ "lights": "ws2812",
++ "horn": "buzzer"
++ },
++ "widgets": [
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
++ { "type": "pan-tilt", "capability": "servo.pan_tilt", "label": "Pan / tilt", "order": 2 },
++ { "type": "lights", "capability": "lights.rgb", "label": "Lights", "order": 3 },
++ { "type": "horn", "capability": "speaker.horn", "label": "Horn", "order": 4 },
++ { "type": "telemetry", "capability": "sensor.ultrasonic", "label": "Distance", "order": 5 },
++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 6 },
++ { "type": "latency", "label": "Latency", "order": 7 }
++ ],
++ "camera": { "transport": "whip", "resolution": "640x480" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/profiles/camera.onvif.json b/server/profiles/camera.onvif.json
nnew file mode 100644
iindex 0000000..b39a7da
---- /dev/null
++++ b/server/profiles/camera.onvif.json
@@@ -0,0 +1,20 @@
++{
++ "id": "camera.onvif",
++ "version": 1,
++ "name": "ONVIF camera",
++ "vendor": "generic",
++ "kind": "server",
++ "description": "A bare ONVIF PTZ camera, no motion: Bot connects to it server-side (credentials by secret reference, never in the profile). Proves the bring-your-own-camera path needs no new frontend.",
++ "capabilities": ["ptz", "camera"],
++ "mapping": {
++ "driver": "onvif",
++ "credentials": "secret-ref"
++ },
++ "widgets": [
++ { "type": "ptz", "capability": "ptz", "label": "Pan / tilt / zoom", "order": 1 },
++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },
++ { "type": "latency", "label": "Latency", "order": 3 }
++ ],
++ "camera": { "transport": "onvif", "resolution": "1920x1080" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/profiles/cozmo.json b/server/profiles/cozmo.json
nnew file mode 100644
iindex 0000000..1702bdf
---- /dev/null
++++ b/server/profiles/cozmo.json
@@@ -0,0 +1,49 @@
++{
++ "id": "cozmo",
++ "version": 1,
++ "name": "Cozmo",
++ "vendor": "Anki",
++ "kind": "bridge",
++ "description": "Anki Cozmo over PyCozmo: differential drive, head and lift, speech and face display, backpack and cube lights, cliff and pick-up sensors, a 320x240 camera and the stock animation set. A bridge computer joins the robot's Wi-Fi on one interface and OpenVibe on another.",
++ "capabilities": [
++ "drive.differential",
++ "head",
++ "lift",
++ "speaker.say",
++ "display.text",
++ "display.animation",
++ "lights.backpack",
++ "lights.cube",
++ "battery",
++ "sensor.cliff",
++ "sensor.pickup",
++ "camera"
++ ],
++ "mapping": {
++ "driver": "cozmo",
++ "animations": [
++ "anim_freeplay_reacttoface_happy_01",
++ "anim_freeplay_reacttoface_sad_01",
++ "anim_eyepose_happy",
++ "anim_eyepose_sad",
++ "anim_eyepose_angry",
++ "anim_eyepose_excited",
++ "anim_blackjack_win",
++ "anim_bored_01",
++ "anim_fistbump_success_01"
++ ]
++ },
++ "widgets": [
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
++ { "type": "head", "capability": "head", "label": "Head", "order": 2 },
++ { "type": "lift", "capability": "lift", "label": "Lift", "order": 3 },
++ { "type": "speaker", "capability": "speaker.say", "label": "Say", "order": 4 },
++ { "type": "display", "capability": "display.text", "label": "Face", "order": 5 },
++ { "type": "lights", "capability": "lights.backpack", "label": "Lights", "order": 6 },
++ { "type": "telemetry", "capability": "sensor.cliff", "label": "Cliff / pick-up", "order": 7 },
++ { "type": "battery", "capability": "battery", "label": "Battery", "order": 8 },
++ { "type": "latency", "label": "Latency", "order": 9 }
++ ],
++ "camera": { "transport": "whip", "resolution": "320x240" },
++ "limits": { "max_speed": 0.6, "max_turn": 0.8, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/profiles/index.js b/server/profiles/index.js
nnew file mode 100644
iindex 0000000..87e210d
---- /dev/null
++++ b/server/profiles/index.js
@@@ -0,0 +1,122 @@
++'use strict';
++
++/**
++ * Robot profiles (ADR-043 decision 7): the panel is a function of the profile, and a new robot needs a
++ * profile, not code. Profiles live as server/profiles/*.json and are validated here before they are
++ * seeded into robot_profiles; the panel and the gate read them from there.
++ *
++ * Capability and widget names are open strings, but they are checked against the registries below, so a
++ * typo is refused at load and a genuinely new capability means one line here — never a schema change.
++ *
++ * Profile shape (bot.robot-profile@1):
++ * { id, version, name, vendor, kind,
++ * capabilities: ["drive.differential", …], // in CAPABILITIES
++ * variants: { differential: { drive: … }, … }, // optional: one profile, several wheel layouts
++ * mapping: { driver: "pca9685", … }, // driver + the hardware mapping (addresses, channels)
++ * widgets: [{ type: "drive", capability: …, label: … }], // types in WIDGETS
++ * camera: { transport: "whip|onvif|rtsp", resolution },
++ * limits: { max_speed, max_turn, max_command_ms, heartbeat_ms } }
++ */
++const fs = require('fs');
++const path = require('path');
++const { BotError } = require('../util');
++
++const DIR = path.join(__dirname);
++
++/** Every capability a profile may declare. A profile that names anything else is refused at load. */
++const CAPABILITIES = new Set([
++ 'drive.differential', 'drive.mecanum',
++ 'servo.pan_tilt', 'head', 'lift',
++ 'lights.rgb', 'lights.backpack', 'lights.cube',
++ 'speaker.horn', 'speaker.say',
++ 'display.text', 'display.animation',
++ 'sensor.ultrasonic', 'sensor.line', 'sensor.cliff', 'sensor.pickup',
++ 'battery', 'ptz', 'camera',
++]);
++
++/** Panel widget types (open in the profile, closed in code: a new widget is a new entry here). */
++const WIDGETS = new Set(['drive', 'pan-tilt', 'servo', 'lights', 'horn', 'speaker', 'display', 'telemetry', 'battery', 'latency', 'ptz', 'camera', 'head', 'lift']);
++
++/** Device drivers a mapping may name. */
++const DRIVERS = new Set(['pca9685', 'ads7830', 'cozmo', 'onvif', 'sim']);
++
++const ID_RE = /^[a-z][a-z0-9._-]{1,63}$/;
++const KINDS = new Set(['onboard', 'bridge', 'server']);
++const number = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
++const int = (v) => (Number.isInteger(v) ? v : null);
++
++function bad(why) { throw new BotError(500, 'bot.profile_invalid', why); }
++
++/** Validate one profile, filling the limit defaults (max_command_ms 300, heartbeat_ms 1000). */
++function validateProfile(p) {
++ if (!p || typeof p !== 'object') bad('a profile must be an object');
++ if (typeof p.id !== 'string' || !ID_RE.test(p.id)) bad(`profile id is not valid: ${p.id}`);
++ if (!int(p.version) || p.version < 1) bad(`${p.id}: version must be a positive integer`);
++ if (typeof p.name !== 'string' || !p.name.trim()) bad(`${p.id}: name is required`);
++ if (p.kind != null && !KINDS.has(p.kind)) bad(`${p.id}: kind must be one of onboard, bridge, server`);
++ if (!Array.isArray(p.capabilities) || !p.capabilities.length) bad(`${p.id}: capabilities must be a non-empty array`);
++ for (const c of p.capabilities) if (!CAPABILITIES.has(c)) bad(`${p.id}: unknown capability ${c}`);
++ if (!Array.isArray(p.widgets) || !p.widgets.length) bad(`${p.id}: widgets must be a non-empty array`);
++ for (const w of p.widgets) {
++ if (!w || typeof w.type !== 'string' || !WIDGETS.has(w.type)) bad(`${p.id}: unknown widget type ${w && w.type}`);
++ if (w.capability != null && !CAPABILITIES.has(w.capability)) bad(`${p.id}: widget ${w.type} binds unknown capability ${w.capability}`);
++ if (w.label != null && (typeof w.label !== 'string' || w.label.length > 60)) bad(`${p.id}: widget ${w.type} label is invalid`);
++ }
++ if (!p.mapping || typeof p.mapping !== 'object' || !DRIVERS.has(p.mapping.driver)) bad(`${p.id}: mapping.driver must be one of ${[...DRIVERS].join(', ')}`);
++ const l = p.limits && typeof p.limits === 'object' ? p.limits : {};
++ const limits = {
++ max_speed: number(l.max_speed) != null ? number(l.max_speed) : 1,
++ max_turn: number(l.max_turn) != null ? number(l.max_turn) : 1,
++ max_command_ms: int(l.max_command_ms) != null ? l.max_command_ms : 300,
++ heartbeat_ms: int(l.heartbeat_ms) != null ? l.heartbeat_ms : 1000,
++ };
++ if (!(limits.max_speed > 0) || !(limits.max_turn > 0)) bad(`${p.id}: max_speed and max_turn must be positive`);
++ if (!(limits.max_command_ms > 0) || !(limits.heartbeat_ms > 0)) bad(`${p.id}: max_command_ms and heartbeat_ms must be positive`);
++ return {
++ id: p.id, version: p.version, name: p.name.trim(), vendor: p.vendor || null, kind: p.kind || null,
++ description: p.description || null, capabilities: [...p.capabilities], variants: p.variants || null,
++ mapping: p.mapping, widgets: p.widgets.slice(), camera: p.camera || null, limits,
++ };
++}
++
++/** Every profile in server/profiles/*.json, validated. Throws on the first invalid file (a broken ship). */
++function loadProfiles(dir = DIR) {
++ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')).sort();
++ const out = new Map();
++ for (const f of files) {
++ let raw;
++ try { raw = JSON.parse(fs.readFileSync(path.join(dir, f), 'utf8')); } catch (e) { bad(`${f}: ${e.message}`); }
++ const profile = validateProfile(raw);
++ out.set(profile.id, profile);
++ }
++ if (!out.size) bad('no profiles found');
++ return out;
++}
++
++/** Seed (idempotently) the shipped profiles into robot_profiles. */
++async function seedProfiles(db, { now = () => Date.now(), log = console } = {}) {
++ const profiles = loadProfiles();
++ for (const p of profiles.values()) {
++ await db.query(
++ `INSERT INTO robot_profiles (id, version, profile, created_at) VALUES ($1, $2, $3, $4)
++ ON CONFLICT (id, version) DO UPDATE SET profile = EXCLUDED.profile`,
++ [p.id, p.version, JSON.stringify(p), new Date(now()).toISOString()],
++ );
++ }
++ log.log(`[Bot] ${profiles.size} profiles seeded: ${[...profiles.keys()].join(', ')}`);
++ return profiles;
++}
++
++/** The profile as the API and the panel present it (the same JSON that was validated). */
++async function getProfile(db, id, version = null) {
++ const row = version
++ ? await db.maybe('SELECT id, version, profile FROM robot_profiles WHERE id = $1 AND version = $2', [id, version])
++ : await db.maybe('SELECT id, version, profile FROM robot_profiles WHERE id = $1 ORDER BY version DESC LIMIT 1', [id]);
++ return row || null;
++}
++
++async function listProfiles(db) {
++ return db.many('SELECT DISTINCT ON (id) id, version, profile FROM robot_profiles ORDER BY id, version DESC');
++}
++
++module.exports = { validateProfile, loadProfiles, seedProfiles, getProfile, listProfiles, CAPABILITIES, WIDGETS, DRIVERS };
ddiff --git a/server/profiles/sim.rover.json b/server/profiles/sim.rover.json
nnew file mode 100644
iindex 0000000..36e7e1b
---- /dev/null
++++ b/server/profiles/sim.rover.json
@@@ -0,0 +1,17 @@
++{
++ "id": "sim.rover",
++ "version": 1,
++ "name": "Simulated rover",
++ "vendor": "OpenVibe",
++ "kind": "server",
++ "description": "The browser simulator on the openvibe.bot home: a differential-drive rover with a camera, driven by the same gate and profile as real hardware.",
++ "capabilities": ["drive.differential", "camera"],
++ "mapping": { "driver": "sim" },
++ "widgets": [
++ { "type": "drive", "capability": "drive.differential", "label": "Drive", "order": 1 },
++ { "type": "camera", "capability": "camera", "label": "Camera", "order": 2 },
++ { "type": "latency", "label": "Latency", "order": 3 }
++ ],
++ "camera": { "transport": "whip", "resolution": "640x360" },
++ "limits": { "max_speed": 1, "max_turn": 1, "max_command_ms": 300, "heartbeat_ms": 1000 }
++}
ddiff --git a/server/realtime.js b/server/realtime.js
nnew file mode 100644
iindex 0000000..4e862ea
---- /dev/null
++++ b/server/realtime.js
@@@ -0,0 +1,404 @@
++'use strict';
++
++/**
++ * The two WebSockets (ADR-043 decisions 4, 5 and 8), one hub for both.
++ *
++ * 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`. 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.
++ * wss://…/control a signed-in person (Network session cookie or Bearer) or a service token holding
++ * `bot.robot.control` acting for `X-OV-Subject`. Joins a robot, sends commands; the
++ * gate lives in the domain and every decision is audited.
++ *
++ * Every frame carries v, seq, ts. A command's `id` is an idempotency key: a repeated id answers with the
++ * first result instead of reaching the device again.
++ */
++const { WebSocketServer } = require('ws');
++const { capabilities, ids } = require('openvibe-contracts');
++const { json, iso, prefixedId } = require('./util');
++const { userPrincipal, verifyService, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
++const { getProfile } = require('./profiles');
++
++const OPEN = 1;
++const ACCESS_COOKIE = 'ov_token';
++
++function parseCookies(header) {
++ const out = {};
++ for (const part of String(header || '').split(';')) {
++ const i = part.indexOf('=');
++ if (i > 0) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
++ }
++ return out;
++}
++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);
++
++function createRealtime({ config, keys, userAuth, log = console, now = () => Date.now() }) {
++ let domain = null;
++ const deviceConns = new Map(); // device_id → conn
++ const subsByRobot = new Map(); // robot_id → Set<conn>
++ const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
++ const results = new Map(); // operator key (who + their id) → { result, reason, at }
++ const inflight = new Map(); // operator key → device-facing id, while the command is pending
++ const timers = [];
++ let closed = false;
++
++ const deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
++ const controlWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false });
++ const offlineAfterMs = config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs;
++
++ const nextSeq = (conn) => ++conn.seq;
++ function send(ws, obj) { if (ws.readyState === OPEN) { ws.send(JSON.stringify(obj)); return true; } return false; }
++ function sendFrame(conn, type, fields) { return send(conn.ws, { v: 1, seq: nextSeq(conn), ts: now(), type, ...fields }); }
++ function sendError(conn, code, detail) { return sendFrame(conn, 'error', { code, detail: detail || null }); }
++ const deviceRobotIds = (device) => json(device.robot_ids, []);
++
++ // ── Upgrade routing ───────────────────────────────────────────────────────────────────────────
++ function handleUpgrade(req, socket, head) {
++ if (closed) return socket.destroy();
++ let path;
++ try { path = new URL(req.url, 'http://localhost').pathname; } catch { return socket.destroy(); }
++ if (path === '/device') return deviceWss.handleUpgrade(req, socket, head, (ws) => onDeviceSocket(ws, req));
++ if (path === '/control') return controlWss.handleUpgrade(req, socket, head, (ws) => onControlSocket(ws, req));
++ socket.destroy();
++ }
++
++ // ── Device side ───────────────────────────────────────────────────────────────────────────────
++ async function onDeviceSocket(ws, req) {
++ const conn = { ws, seq: 0, pending: true, device: null, online: false, rttMs: null, telemetry: null, status: null, lastHeartbeat: now(), lastTelemetry: 0, sessionId: prefixedId('sess', now()) };
++ ws.conn = conn;
++ ws.on('message', (raw) => handleDeviceMessage(conn, raw).catch((e) => { log.warn(`[Bot] device ${conn.device ? conn.device.id : '?'}: ${e.message}`); sendError(conn, e.code || 'bot.internal', e.detail || e.message); }));
++ ws.on('close', () => dropDevice(conn));
++ ws.on('error', () => { /* the close event does the work */ });
++ const cred = bearer(req); // the query string is never read
++ if (cred) {
++ try {
++ await authenticateDevice(conn, cred);
++ sendHello(conn);
++ await sendConfig(conn);
++ await bringOnline(conn);
++ } catch (e) { log.warn(`[Bot] device auth: ${e.message}`); ws.close(4002, 'invalid credential'); }
++ }
++ }
++
++ async function authenticateDevice(conn, credential) {
++ const device = await domain.devices.byCredential(credential);
++ if (!device) throw new Error('unknown or revoked credential');
++ attachDevice(conn, device);
++ }
++
++ function attachDevice(conn, device) {
++ const existing = deviceConns.get(device.id);
++ if (existing && existing.ws !== conn.ws) { try { existing.ws.close(4000, 'replaced by a newer connection'); } catch { /* gone */ } dropDevice(existing, { silent: true }); }
++ conn.pending = false; conn.device = device; conn.online = true; conn.lastHeartbeat = now();
++ deviceConns.set(device.id, conn);
++ }
++
++ function sendHello(conn) {
++ sendFrame(conn, 'hello', {
++ session_id: conn.sessionId, device_id: conn.device.id, robot_ids: deviceRobotIds(conn.device),
++ server_time: iso(now()),
++ });
++ }
++ async function sendConfig(conn) {
++ const robotId = deviceRobotIds(conn.device)[0];
++ const robot = robotId ? await domain.robots.get(robotId) : null;
++ const profileRow = robot ? await getProfile(domain.db, robot.profile_id, robot.profile_version) : null;
++ const limits = profileRow ? profileRow.profile.limits : { max_command_ms: config.control.maxCommandMs, heartbeat_ms: config.device.heartbeatMs };
++ const allowed = robot ? domain.control.allowedFor(robot, 'owner') : ['drive', 'actuator', 'ptz', 'say', 'display', 'halt'];
++ sendFrame(conn, 'config', {
++ heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
++ estop_latched: robot ? !!robot.estop_latched : false,
++ });
++ }
++ async function bringOnline(conn) {
++ await domain.devices.setOnline(conn.device, true).catch((e) => log.warn(`[Bot] online event: ${e.message}`));
++ for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
++ }
++
++ function dropDevice(conn, { silent = false } = {}) {
++ if (!conn || !conn.device) return;
++ const current = deviceConns.get(conn.device.id);
++ if (current === conn) deviceConns.delete(conn.device.id);
++ if (!conn.online) return;
++ conn.online = false;
++ if (silent) return;
++ domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
++ for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
++ }
++
++ async function handleDeviceMessage(conn, raw) {
++ let msg; try { msg = JSON.parse(String(raw)); } catch { return sendError(conn, 'bot.bad_json', 'frames must be JSON'); }
++ if (!msg || typeof msg !== 'object') return sendError(conn, 'bot.bad_frame', 'a frame must be an object');
++ if (conn.pending) {
++ if (msg.type === 'pair') return handlePair(conn, msg);
++ return sendError(conn, 'bot.not_paired', 'send pair first');
++ }
++ conn.lastHeartbeat = now();
++ switch (msg.type) {
++ case 'heartbeat':
++ if (Number.isFinite(msg.rtt_ms)) conn.rttMs = Math.round(msg.rtt_ms);
++ return sendFrame(conn, 'heartbeat_ack', { seq: msg.seq, server_time: iso(now()) });
++ case 'telemetry': return onTelemetry(conn, msg);
++ case 'status': conn.status = msg; for (const r of deviceRobotIds(conn.device)) broadcast(r); return undefined;
++ case 'ack': case 'nack': return onAck(conn, msg);
++ case 'estop_state': return onEstopState(conn, msg);
++ default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
++ }
++ }
++
++ async function handlePair(conn, msg) {
++ const r = await domain.pairing.redeem({
++ robot: msg.robot || null, code: msg.code, agent_version: msg.agent_version || null,
++ device_kind: msg.device_kind || 'onboard', drivers: Array.isArray(msg.drivers) ? msg.drivers : [],
++ capabilities: msg.capabilities && typeof msg.capabilities === 'object' ? msg.capabilities : {}, name: msg.name || null,
++ });
++ attachDevice(conn, r.device);
++ sendFrame(conn, 'paired', {
++ device_id: r.device.id, credential: r.credential, publish_key: r.publish_key,
++ robot_ids: deviceRobotIds(r.device), profile_id: r.profile ? r.profile.id : null, profile: r.profile,
++ });
++ sendHello(conn);
++ await sendConfig(conn);
++ await bringOnline(conn);
++ }
++
++ async function onTelemetry(conn, msg) {
++ if (now() - conn.lastTelemetry < Math.floor(1000 / config.control.telemetryHz)) return; // ≤ 2 Hz
++ conn.lastTelemetry = now();
++ conn.telemetry = msg;
++ for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
++ }
++
++ async function onAck(conn, msg) {
++ const id = String(msg.id || '');
++ const p = pendingCmds.get(id);
++ if (!p || p.deviceId !== conn.device.id) return; // unknown, already answered, or another device's command
++ pendingCmds.delete(id);
++ inflight.delete(p.key);
++ if (p.timer) clearTimeout(p.timer);
++ const result = msg.type === 'ack' ? 'ack' : 'nack';
++ const latency = now() - p.at;
++ cacheResult(p.key, { result, reason: msg.fault_code || null });
++ await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
++ sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: msg.fault_code || null, latency_ms: latency });
++ }
++
++ async function onEstopState(conn, msg) {
++ const robotIds = msg.robot_id ? [msg.robot_id] : deviceRobotIds(conn.device);
++ for (const robotId of robotIds) {
++ const robot = await domain.robots.get(robotId);
++ if (!robot || !!robot.estop_latched === !!msg.latched) continue;
++ await domain.estop.set(robotId, { latched: !!msg.latched, by: 'device', principalKind: 'device' });
++ broadcast(robotId);
++ }
++ }
++
++ // ── Operator side ─────────────────────────────────────────────────────────────────────────────
++ function authorizeControl(req) {
++ const cookie = parseCookies(req.headers.cookie)[ACCESS_COOKIE];
++ if (cookie) { const claims = userAuth.verify(cookie); const p = userPrincipal(claims); if (p) return p; }
++ const token = bearer(req);
++ if (!token) return null;
++ const payload = decodePayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ const r = verifyService(token, { publicKey: keys.get(), issuer: config.network.issuer, audience: config.audience });
++ if (!r.ok || !capabilities.grants(r.claims.cap || [], 'bot.robot.control')) return null;
++ const subject = String(req.headers['x-ov-subject'] || '');
++ if (!isSubject(subject)) return null;
++ return { kind: 'service', sub: r.claims.sub, cap: r.claims.cap || [], subject };
++ }
++ const claims = userAuth.verify(token);
++ return userPrincipal(claims);
++ }
++
++ function onControlSocket(ws, req) {
++ const principal = authorizeControl(req);
++ if (!principal) return ws.close(4002, 'sign in required');
++ const conn = { ws, seq: 0, principal, subject: principal.subject, robotId: null, role: null };
++ ws.conn = conn;
++ ws.on('message', (raw) => handleControlMessage(conn, raw).catch((e) => { log.warn(`[Bot] control: ${e.message}`); sendError(conn, e.code || 'bot.internal', e.detail || e.message); }));
++ ws.on('close', () => unsubscribe(conn));
++ ws.on('error', () => { /* the close event does the work */ });
++ }
++
++ async function handleControlMessage(conn, raw) {
++ let msg; try { msg = JSON.parse(String(raw)); } catch { return sendError(conn, 'bot.bad_json', 'frames must be JSON'); }
++ if (!msg || typeof msg !== 'object') return sendError(conn, 'bot.bad_frame', 'a frame must be an object');
++ switch (msg.type) {
++ case 'join': return onJoin(conn, msg);
++ case 'leave': unsubscribe(conn); conn.robotId = null; return undefined;
++ case 'command': return onOperatorCommand(conn, msg);
++ case 'estop': return onOperatorEstop(conn, true);
++ case 'estop_clear': return onOperatorEstop(conn, false);
++ default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
++ }
++ }
++
++ function subscribe(conn) { let set = subsByRobot.get(conn.robotId); if (!set) { set = new Set(); subsByRobot.set(conn.robotId, set); } set.add(conn); }
++ function unsubscribe(conn) { const set = subsByRobot.get(conn.robotId); if (set) { set.delete(conn); if (!set.size) subsByRobot.delete(conn.robotId); } }
++
++ async function onJoin(conn, msg) {
++ const robotId = String(msg.robot_id || '');
++ const robot = await domain.robots.get(robotId);
++ if (!robot) return sendError(conn, 'bot.robot_not_found', 'no such robot');
++ let role = await domain.members.roleOf(robotId, conn.subject);
++ if (!role && robot.access_policy === 'queue' && conn.subject) { await domain.queue.join(robotId, conn.subject); role = 'queue'; }
++ if (!role) return sendError(conn, 'bot.not_an_operator', 'you have no access to this robot');
++ if (conn.robotId && conn.robotId !== robotId) unsubscribe(conn);
++ conn.robotId = robotId; conn.role = role;
++ subscribe(conn);
++ const profileRow = await getProfile(domain.db, robot.profile_id, robot.profile_version);
++ sendFrame(conn, 'joined', {
++ robot: domain.present.robot(robot), role,
++ profile: profileRow ? profileRow.profile : null,
++ allowed_commands: domain.control.allowedFor(robot, role === 'viewer' ? 'viewer' : role),
++ state: await robotState(robotId, conn.subject),
++ });
++ }
++
++ async function onOperatorCommand(conn, msg) {
++ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
++ // The operator's id is an idempotency key for THAT operator only (another person's id never reads or blocks
++ // theirs); the device always gets a server-minted id, so an operator can never choose what a robot sees.
++ const id = (msg.id != null ? String(msg.id) : prefixedId('op', now())).slice(0, 64);
++ const key = `${conn.subject || `${conn.principal.kind}:${conn.principal.id || ''}`}|${id}`;
++ const cached = results.get(key);
++ if (cached) return sendFrame(conn, 'command_result', { id, result: cached.result, reason: cached.reason, cached: true });
++ if (inflight.has(key)) return sendFrame(conn, 'command_result', { id, result: 'pending', cached: true });
++ // Reserved before the first await: two frames with one id are handled concurrently, and only the first may
++ // reach the robot. Every path below either hands the reservation to a pending command or releases it.
++ inflight.set(key, null);
++ try {
++ return await sendOperatorCommand(conn, msg, id, key);
++ } finally {
++ if (inflight.get(key) === null) inflight.delete(key);
++ }
++ }
++
++ async function sendOperatorCommand(conn, msg, id, key) {
++ const robotId = conn.robotId;
++ const device = deviceForRobot(robotId);
++ const decision = await domain.control.prepare({ robotId, principal: conn.principal, kind: msg.kind, value: msg.value, online: !!(device && device.online), requestedMs: msg.ms });
++ if (!decision.ok) {
++ await domain.audit.record({ robotId, deviceId: device ? device.device.id : null, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role || conn.role, kind: msg.kind, value: msg.value || {}, result: 'refused', reason: decision.code });
++ return sendFrame(conn, 'command_result', { id, result: 'refused', code: decision.code, reason: decision.reason });
++ }
++ const deviceCmdId = prefixedId('cmd', now());
++ const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind: decision.kind, value: decision.value, deadline_ms: decision.deadlineMs, operator: { subject: conn.subject, role: decision.role }, robot_id: robotId };
++ if (!send(device.ws, frame)) {
++ await domain.audit.record({ robotId, deviceId: device.device.id, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, result: 'refused', reason: 'bot.device_offline' });
++ return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.device_offline', reason: 'the device is offline' });
++ }
++ const p = { id: deviceCmdId, opId: id, key, deviceId: device.device.id, conn, robotId, subject: conn.subject, operatorKind: conn.principal.kind, role: decision.role, kind: decision.kind, value: decision.value, at: now(), timer: null };
++ pendingCmds.set(deviceCmdId, p);
++ inflight.set(key, deviceCmdId);
++ const waitMs = (decision.deadlineMs ? Math.max(0, decision.deadlineMs - now()) : 1000) + 1500;
++ p.timer = setTimeout(() => expireCommand(deviceCmdId), waitMs);
++ if (p.timer.unref) p.timer.unref();
++ }
++
++ async function expireCommand(id) {
++ const p = pendingCmds.get(id);
++ if (!p) return;
++ pendingCmds.delete(id);
++ inflight.delete(p.key);
++ cacheResult(p.key, { result: 'expired', reason: 'no acknowledgement before the deadline' });
++ await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result: 'expired', reason: 'deadline' }).catch(() => {});
++ sendFrame(p.conn, 'command_result', { id: p.opId, result: 'expired', reason: 'no acknowledgement before the deadline' });
++ }
++
++ function cacheResult(key, r) { results.set(key, { ...r, at: now() }); if (results.size > 4096) results.delete(results.keys().next().value); }
++
++ async function onOperatorEstop(conn, latched) {
++ if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
++ const robotId = conn.robotId;
++ try {
++ if (latched) {
++ const role = await domain.members.roleOf(robotId, conn.subject);
++ if (!['owner', 'operator'].includes(role)) return sendError(conn, 'bot.forbidden', 'only an operator may set the e-stop');
++ await domain.estop.set(robotId, { latched: true, by: conn.subject, principalKind: conn.principal.kind });
++ } else {
++ await domain.estop.clear(robotId, conn.subject); // owner only; throws otherwise
++ }
++ } catch (e) { return sendError(conn, e.code || 'bot.forbidden', e.detail || e.message); }
++ sendToRobotDevices(robotId, { type: 'estop', latched, by: conn.subject, at: iso(now()) });
++ broadcast(robotId);
++ }
++
++ // ── State and helpers ─────────────────────────────────────────────────────────────────────────
++ function deviceForRobot(robotId) {
++ for (const conn of deviceConns.values()) if (conn.online && deviceRobotIds(conn.device).includes(robotId)) return conn;
++ return null;
++ }
++ function isOnline(deviceId) { const c = deviceConns.get(deviceId); return !!(c && c.online); }
++ function sendToDevice(deviceId, fields) { const c = deviceConns.get(deviceId); return c ? sendFrame(c, fields.type, fields) : false; }
++ function sendToRobotDevices(robotId, fields) {
++ let any = false;
++ for (const conn of deviceConns.values()) if (conn.online && deviceRobotIds(conn.device).includes(robotId)) any = sendFrame(conn, fields.type, fields) || any;
++ return any;
++ }
++ function closeDevice(deviceId, reason = 'revoked') { const c = deviceConns.get(deviceId); if (c) { try { c.ws.close(4003, reason); } catch { /* gone */ } dropDevice(c); } }
++ function deviceState(deviceId) { const c = deviceConns.get(deviceId); return c ? { online: c.online, rtt_ms: c.rttMs, telemetry: c.telemetry, status: c.status } : { online: false, rtt_ms: null, telemetry: null, status: null }; }
++
++ async function robotState(robotId, subjectForQueue = null) {
++ const robot = await domain.robots.get(robotId);
++ const conn = deviceForRobot(robotId);
++ const online = !!(conn && conn.online);
++ const state = {
++ robot_id: robotId, online,
++ estop: robot ? { latched: !!robot.estop_latched, by: robot.estop_by || null, at: robot.estop_at ? iso(new Date(robot.estop_at).getTime()) : null } : null,
++ latency_ms: online ? (conn.rttMs != null ? conn.rttMs : null) : null,
++ battery: online && conn.telemetry ? (conn.telemetry.battery != null ? conn.telemetry.battery : null) : null,
++ telemetry: online && conn.telemetry ? conn.telemetry : null,
++ status: online && conn.status ? conn.status : null,
++ queue: null,
++ };
++ if (robot && robot.access_policy === 'queue') state.queue = await domain.queue.state(robotId, subjectForQueue);
++ return state;
++ }
++ function broadcast(robotId) {
++ const subs = subsByRobot.get(robotId);
++ if (!subs || !subs.size) return;
++ for (const sub of [...subs]) {
++ robotState(robotId, sub.subject).then((state) => sendFrame(sub, 'robot_state', { state })).catch(() => {});
++ }
++ }
++
++ function checkHeartbeats() {
++ for (const conn of [...deviceConns.values()]) {
++ if (conn.online && now() - conn.lastHeartbeat > offlineAfterMs) {
++ conn.online = false;
++ domain.devices.setOnline(conn.device, false).catch((e) => log.warn(`[Bot] offline event: ${e.message}`));
++ for (const robotId of deviceRobotIds(conn.device)) broadcast(robotId);
++ }
++ }
++ }
++
++ timers.push(setInterval(() => { try { checkHeartbeats(); } catch (e) { log.warn(`[Bot] heartbeat: ${e.message}`); } }, Math.max(200, Math.floor(config.device.heartbeatMs / 2))));
++ for (const t of timers) if (t.unref) t.unref();
++
++ return {
++ bindDomain(d) { domain = d; },
++ handleUpgrade,
++ isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState,
++ onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online) n++; return n; },
++ devices() { return [...deviceConns.values()].map((c) => ({ device_id: c.device.id, online: c.online })); },
++ async close() {
++ closed = true;
++ for (const t of timers) clearInterval(t);
++ for (const p of pendingCmds.values()) if (p.timer) clearTimeout(p.timer);
++ pendingCmds.clear();
++ inflight.clear();
++ for (const conn of [...deviceConns.values()]) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
++ for (const set of subsByRobot.values()) for (const conn of set) { try { conn.ws.close(1001, 'server closing'); } catch { /* gone */ } }
++ deviceConns.clear(); subsByRobot.clear();
++ await Promise.all([
++ new Promise((r) => deviceWss.close(r)), new Promise((r) => controlWss.close(r)),
++ ]).catch(() => {});
++ },
++ };
++}
++
++module.exports = { createRealtime, ACCESS_COOKIE };
ddiff --git a/server/util.js b/server/util.js
nnew file mode 100644
iindex 0000000..3b69d35
---- /dev/null
++++ b/server/util.js
@@@ -0,0 +1,81 @@
++'use strict';
++
++/** Small shared pieces: ids, time, the error type, input checks, hashing, cursors. */
++const crypto = require('crypto');
++const { ids, validate } = require('openvibe-contracts');
++
++class BotError extends Error {
++ constructor(status, code, detail, extra) {
++ super(detail || code);
++ this.status = status;
++ this.code = code;
++ this.detail = detail;
++ this.extra = extra;
++ }
++}
++
++function fail(status, code, detail, extra) { throw new BotError(status, code, detail, extra); }
++
++const prefixedId = (prefix, ms = Date.now()) => `${prefix}_${ids.ulid(ms)}`;
++const iso = (ms) => new Date(ms).toISOString();
++const sha256 = (s) => crypto.createHash('sha256').update(String(s)).digest('hex');
++/** A random token: `bytes` bytes, base64url (URL-safe, no padding). */
++const token = (bytes = 32) => crypto.randomBytes(bytes).toString('base64url');
++/** A secret's storage form: a salted hash is unnecessary here (32 random bytes have no dictionary). */
++const hashSecret = (s) => sha256(s);
++const json = (v, d) => { if (v == null || v === '') return d; if (typeof v === 'object') return v; try { return JSON.parse(v); } catch { return d; } };
++
++/** Constant-time equality of two hex strings (length-independent enough for stored hashes). */
++function secretEquals(a, b) {
++ const x = Buffer.from(String(a || ''), 'utf8');
++ const y = Buffer.from(String(b || ''), 'utf8');
++ if (x.length !== y.length) { crypto.timingSafeEqual(x, x); return false; }
++ return crypto.timingSafeEqual(x, y);
++}
++
++// PostgreSQL stores no NUL character in text or jsonb: every text from outside goes through storable().
++const NUL = /\u0000/g;
++const storable = (v) => String(v).toWellFormed().replace(NUL, '');
++
++/** Optional trimmed text: null when empty, refused when longer than max. */
++function text(v, field, max) {
++ if (v == null) return null;
++ const s = storable(v).replace(/\r\n?/g, '\n').trim();
++ if (!s) return null;
++ if (s.length > max) fail(422, 'bot.text_too_long', `${field} must be at most ${max} characters`);
++ return s;
++}
++
++/** A user SubjectRef or bare usr_ id → the subject id. */
++function userSubject(v, field = 'subject') {
++ const ref = typeof v === 'string' ? { type: 'user', id: v } : v;
++ if (!ref || !validate('identity.subject-ref@1', ref).valid || ref.type !== 'user' || !ids.isSubjectId('user', ref.id)) {
++ fail(422, 'bot.invalid_subject', `${field} must be a user SubjectRef ({ type: 'user', id: 'usr_…' })`);
++ }
++ return ref.id;
++}
++
++/** A robot id (rob_…) or null. */
++const isRobotId = (v) => typeof v === 'string' && /^rob_[0-9A-HJKMNP-TV-Z]{26}$/.test(v);
++
++/** A database refusal the caller caused (a value PostgreSQL cannot store) → the BotError to answer with, else null. */
++function inputError(e) {
++ const code = e && (e.code || (e.cause && e.cause.code));
++ return ['22021', '22P05', '22P02', '22007', '22008'].includes(code)
++ ? new BotError(422, 'bot.invalid_input', 'the request carries a value that cannot be stored (such as a NUL character)') : null;
++}
++
++/** A keyset cursor: base64url JSON [at ISO, id] → the pair, or 422; cursorOf(row) makes one. */
++const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,6})?Z$/;
++function readCursor(cursor) {
++ let cur = null;
++ try { cur = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8')); } catch { /* refused below */ }
++ if (!Array.isArray(cur) || typeof cur[0] !== 'string' || !ISO_RE.test(cur[0]) || !Number.isInteger(cur[1])) fail(422, 'bot.invalid_input', 'bad cursor');
++ return cur;
++}
++const cursorOf = (row) => Buffer.from(JSON.stringify([row.at, Number(row.id)])).toString('base64url');
++
++module.exports = {
++ BotError, fail, prefixedId, iso, sha256, token, hashSecret, secretEquals, json,
++ storable, text, userSubject, isRobotId, inputError, readCursor, cursorOf,
++};
ddiff --git a/server/web/routes.js b/server/web/routes.js
nnew file mode 100644
iindex 0000000..b03d71f
---- /dev/null
++++ b/server/web/routes.js
@@@ -0,0 +1,17 @@
++'use strict';
++
++/**
++ * The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and
++ * the site are designed separately), and nothing else. No pages, no CSS, no copy beyond this.
++ */
++const express = require('express');
++const VERSION = require('../../package.json').version;
++
++function createWebRoutes() {
++ const r = express.Router();
++ r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
++ r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
++ return r;
++}
++
++module.exports = { createWebRoutes };
ddiff --git a/server/web/session.js b/server/web/session.js
nnew file mode 100644
iindex 0000000..4e0a193
---- /dev/null
++++ b/server/web/session.js
@@@ -0,0 +1,211 @@
++'use strict';
++
++/**
++ * openvibe.bot — OAuth2 client session layer (identity provider: OpenVibe.Network), the same shape as
++ * OpenVibe.Tips'/Community's. Registered in the Network as client `bot` with redirect
++ * https://openvibe.bot/auth/callback. The panel (built by Opus) signs people in through this; the
++ * control WebSocket accepts the `ov_token` cookie this sets.
++ *
++ * GET /auth/login → Network /oauth/authorize (?silent=1 → prompt=none; ?next= same-site path)
++ * GET /auth/callback → server-side code exchange, session cookies
++ * POST /auth/fedcm → the shared navbar's FedCM assertion → tokens (jwt-bearer grant)
++ * GET /auth/logout → clear cookies (best-effort refresh revoke), hint=guest
++ * GET /auth/me → the signed-in user (offline JWT verification)
++ * POST /auth/refresh → rotate tokens
++ */
++const express = require('express');
++const crypto = require('crypto');
++const { userPrincipal } = require('../api/auth');
++
++const ACCESS_COOKIE = 'ov_token';
++const REFRESH_COOKIE = 'ov_refresh';
++const HINT_COOKIE = 'ov_sso_hint';
++const STATE_COOKIE = 'ov_oauth_state';
++const NEXT_COOKIE = 'ov_oauth_next';
++const SILENT_COOKIE = 'ov_oauth_silent';
++
++function claimsToUser(claims) {
++ if (!claims) return null;
++ const { iat, exp, aud, iss, nbf, jti, ...user } = claims; // eslint-disable-line no-unused-vars
++ return user;
++}
++
++function decodeJwtPayload(token) {
++ const parts = String(token || '').split('.');
++ if (parts.length !== 3) return null;
++ try { const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); return c && typeof c === 'object' ? c : null; } catch { return null; }
++}
++
++function sanitizeNext(next, config) {
++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
++ if (!next || typeof next !== 'string') return '/';
++ if (/^\/(?!\/|\\)/.test(next)) return next;
++ try {
++ const u = new URL(next);
++ if (u.protocol !== 'https:') return '/';
++ const allowed = [config.baseUrl, config.network.url].map((b) => { try { return new URL(b).hostname; } catch { return null; } }).filter(Boolean);
++ if (allowed.includes(u.hostname)) return u.toString();
++ } catch { /* fall through */ }
++ return '/';
++}
++
++function withParam(target, key, value) {
++ const hashAt = target.indexOf('#');
++ const hash = hashAt >= 0 ? target.slice(hashAt) : '';
++ const base = hashAt >= 0 ? target.slice(0, hashAt) : target;
++ return `${base}${base.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}${hash}`;
++}
++
++/** Page middleware: req.viewer = the signed-in person (principal shape) or null. Never blocks. */
++function viewerMiddleware(userAuth) {
++ return (req, _res, next) => {
++ const token = req.cookies && req.cookies[ACCESS_COOKIE];
++ const claims = token ? userAuth.verify(token) : null;
++ req.viewer = claims ? userPrincipal(claims) : null;
++ req.viewerClaims = claims ? claimsToUser(claims) : null;
++ next();
++ };
++}
++
++function createSessionRoutes(config, userAuth, { fetchImpl = globalThis.fetch } = {}) {
++ const router = express.Router();
++ const secure = config.cookies.secure;
++ const accessOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 24 * 3600 * 1000 });
++ const refreshOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 30 * 24 * 3600 * 1000 });
++ const flowOpts = () => ({ sameSite: 'lax', secure, httpOnly: true, path: '/auth', maxAge: 10 * 60 * 1000 });
++ const hintOpts = () => ({ sameSite: 'lax', secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 3600 * 1000 });
++
++ function setSession(res, access, refresh) {
++ res.cookie(ACCESS_COOKIE, access, accessOpts());
++ if (refresh) res.cookie(REFRESH_COOKIE, refresh, refreshOpts());
++ res.cookie(HINT_COOKIE, 'account', hintOpts());
++ }
++ function clearSession(res) {
++ res.clearCookie(ACCESS_COOKIE, { ...accessOpts(), maxAge: undefined });
++ res.clearCookie(REFRESH_COOKIE, { ...refreshOpts(), maxAge: undefined });
++ }
++ function clearFlow(res) { for (const c of [STATE_COOKIE, NEXT_COOKIE, SILENT_COOKIE]) res.clearCookie(c, { path: '/auth' }); }
++
++ async function tokenGrant(body) {
++ let lastErr = null;
++ for (const base of [config.network.internalUrl, config.network.url]) {
++ if (!base) continue;
++ try {
++ const res = await fetchImpl(`${base}/oauth/token`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...body }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ const data = await res.json().catch(() => ({}));
++ if (!res.ok) {
++ const err = new Error(data.error_description || data.error || `token grant failed (${res.status})`);
++ err.status = res.status; err.error = data.error || 'invalid_grant';
++ throw err;
++ }
++ return data;
++ } catch (err) {
++ lastErr = err;
++ if (err.status && err.status < 500) throw err;
++ }
++ }
++ throw lastErr || new Error('Network unreachable');
++ }
++
++ router.get('/login', (req, res) => {
++ const silent = !!req.query.silent && req.query.silent !== '0';
++ if (silent && userAuth.verify(req.cookies && req.cookies[ACCESS_COOKIE])) { clearFlow(res); return res.redirect(sanitizeNext(req.query.next, config)); }
++ const { url, state } = userAuth.client.getAuthorizationUrl(config.oauth.scope);
++ let target = url;
++ if (silent) { const u = new URL(url); u.searchParams.set('prompt', 'none'); target = u.toString(); }
++ res.cookie(STATE_COOKIE, state, flowOpts());
++ const next = sanitizeNext(req.query.next, config);
++ if (next !== '/') res.cookie(NEXT_COOKIE, next, flowOpts()); else res.clearCookie(NEXT_COOKIE, { path: '/auth' });
++ if (silent) res.cookie(SILENT_COOKIE, '1', flowOpts()); else res.clearCookie(SILENT_COOKIE, { path: '/auth' });
++ res.redirect(target);
++ });
++
++ router.get('/callback', async (req, res) => {
++ const { code, state, error } = req.query;
++ const next = sanitizeNext(req.cookies && req.cookies[NEXT_COOKIE], config);
++ const silent = req.cookies && req.cookies[SILENT_COOKIE] === '1';
++ if (error) {
++ clearFlow(res);
++ if (silent || error === 'login_required') return res.redirect(withParam(next, 'sso', 'none'));
++ return res.redirect(withParam('/', 'auth_error', String(error)));
++ }
++ if (!code) return res.status(400).type('text/plain').send('Missing authorization code');
++ const expected = req.cookies && req.cookies[STATE_COOKIE];
++ clearFlow(res);
++ if (!expected || !state || !crypto.timingSafeEqual(Buffer.from(String(state).padEnd(64).slice(0, 64)), Buffer.from(String(expected).padEnd(64).slice(0, 64)))) {
++ return res.status(400).type('text/plain').send('Sign-in state mismatch — please try signing in again.');
++ }
++ try {
++ const data = await tokenGrant({ grant_type: 'authorization_code', redirect_uri: config.oauth.redirectUri, code });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.redirect(next);
++ } catch (err) {
++ console.error('[Bot] code exchange failed:', err.message);
++ return res.status(502).type('text/plain').send('Sign-in failed — could not reach OpenVibe.Network. Please try again.');
++ }
++ });
++
++ const fedcmBody = express.json({ limit: '16kb' });
++ router.post('/fedcm', fedcmBody, async (req, res) => {
++ const { token, nonce } = req.body || {};
++ if (typeof token !== 'string' || !token || typeof nonce !== 'string' || !nonce || nonce.length > 256) {
++ return res.status(400).json({ error: 'invalid_request', error_description: 'token and nonce are required' });
++ }
++ const claims = decodeJwtPayload(token);
++ const a = Buffer.from(String((claims && claims.nonce) || '')); const b = Buffer.from(nonce);
++ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) return res.status(400).json({ error: 'invalid_request', error_description: 'nonce mismatch' });
++ try {
++ const data = await tokenGrant({ grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion: token });
++ if (!data.access_token) throw Object.assign(new Error('no token'), { status: 401, error: 'invalid_grant' });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ ok: true, user: data.user || claimsToUser(userAuth.verify(data.access_token)) });
++ } catch (err) {
++ if (err.status && err.status < 500) return res.status(401).json({ error: err.error || 'invalid_grant' });
++ return res.status(502).json({ error: 'server_error', error_description: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ router.get('/logout', async (req, res) => {
++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (refresh) {
++ try {
++ await fetchImpl(`${config.network.internalUrl}/oauth/revoke`, {
++ method: 'POST', headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: refresh }),
++ signal: AbortSignal.timeout(3000),
++ });
++ } catch { /* optional */ }
++ }
++ clearSession(res);
++ res.cookie(HINT_COOKIE, 'guest', hintOpts());
++ res.redirect(sanitizeNext(req.query.next, config));
++ });
++
++ router.get('/me', (req, res) => {
++ const token = (String(req.headers.authorization || '').startsWith('Bearer ') ? req.headers.authorization.slice(7) : null) || (req.cookies && req.cookies[ACCESS_COOKIE]);
++ const claims = userAuth.verify(token);
++ if (!claims) return res.status(401).json({ error: 'Not authenticated' });
++ res.json({ user: claimsToUser(claims), expires_at: claims.exp ? claims.exp * 1000 : null });
++ });
++
++ router.post('/refresh', async (req, res) => {
++ const refresh = req.cookies && req.cookies[REFRESH_COOKIE];
++ if (!refresh) return res.status(401).json({ error: 'No refresh token' });
++ try {
++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: refresh });
++ setSession(res, data.access_token, data.refresh_token);
++ return res.json({ token: data.access_token, user: claimsToUser(userAuth.verify(data.access_token)) });
++ } catch (err) {
++ if (err.status && err.status < 500) { clearSession(res); return res.status(401).json({ error: 'Refresh token rejected — please sign in again' }); }
++ return res.status(502).json({ error: 'Could not reach OpenVibe.Network' });
++ }
++ });
++
++ return router;
++}
++
++module.exports = { createSessionRoutes, viewerMiddleware, sanitizeNext, ACCESS_COOKIE };
ddiff --git a/test/api.test.js b/test/api.test.js
nnew file mode 100644
iindex 0000000..0180829
---- /dev/null
++++ b/test/api.test.js
@@@ -0,0 +1,108 @@
++'use strict';
++// /api/v1, /, and observability: tokens by capability, robots/operators/devices/audit, problem+json.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const stranger = t.network.newUser('stranger');
++
++ await check('health, ready, release.json and the one-line GET /', async () => {
++ const h = await t.call('GET', '/api/health', { token: null });
++ assert.strictEqual(h.json.service, 'bot');
++ assert.strictEqual(h.json.devices_online, 0);
++ const r = await t.call('GET', '/api/ready', { token: null });
++ assert.strictEqual(r.json.ready, true);
++ assert.strictEqual(r.json.checks.db.detail.store, t.store.store);
++ const rel = await t.call('GET', '/release.json', { token: null });
++ assert.strictEqual(rel.json.service, 'bot');
++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json).errors, []);
++ const home = await t.call('GET', '/', { token: null });
++ assert.strictEqual(home.status, 200);
++ assert.match(home.headers.get('content-type'), /text\/plain/);
++ assert.strictEqual(home.text.trim().split('\n').length, 1);
++ });
++
++ await check('profiles are public reads; an unknown profile is a problem+json 404', async () => {
++ const list = await t.call('GET', '/api/v1/profiles', { token: null });
++ assert.strictEqual(list.status, 200);
++ assert.deepStrictEqual(list.json.profiles.map((p) => p.id).sort(), ['adeept.adr036', 'camera.onvif', 'cozmo', 'sim.rover']);
++ const one = await t.call('GET', '/api/v1/profiles/adeept.adr036', { token: null });
++ assert.strictEqual(one.json.profile.limits.max_command_ms, 300);
++ const missing = await t.call('GET', '/api/v1/profiles/nope', { token: null });
++ assert.strictEqual(missing.status, 404);
++ assert.strictEqual(missing.headers.get('content-type'), 'application/problem+json');
++ });
++
++ await check('a stranger cannot read or manage someone else\'s robot; the owner can', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ assert.match(pairing.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
++ assert.match(pairing.installer, /--robot rob_/);
++ assert.ok(pairing.expires_at);
++ const mine = await t.call('GET', '/api/v1/robots', { user: alex });
++ assert.strictEqual(mine.json.robots.length, 1);
++ assert.strictEqual(mine.json.robots[0].access_policy, 'private');
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).status, 200);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: stranger })).status, 403);
++ assert.strictEqual((await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: stranger, body: { name: 'x' } })).status, 403);
++ const upd = await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: alex, body: { name: 'Rover 2', access_policy: 'queue', limits: { max_speed: 0.5 } } });
++ assert.strictEqual(upd.status, 200, upd.text);
++ assert.strictEqual(upd.json.robot.name, 'Rover 2');
++ assert.strictEqual(upd.json.robot.access_policy, 'queue');
++ assert.strictEqual(upd.json.robot.limits.max_speed, 0.5);
++ });
++
++ await check('a service token needs the capability; an unknown robot is 404', async () => {
++ const { robot } = await t.robot(bob);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { cap: ['bot.robot.control'] })).status, 403);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { cap: ['bot.robot.read'] })).status, 200);
++ assert.strictEqual((await t.call('GET', '/api/v1/robots/rob_00000000000000000000000000', { cap: ['bot.robot.read'] })).status, 404);
++ const wrongAud = t.network.signService({ aud: ['openvibe.tips'], cap: ['bot.*'] });
++ assert.strictEqual((await t.call('GET', '/api/v1/robots', { token: wrongAud })).status, 401);
++ });
++
++ await check('operators: the owner invites, the operator may read but not manage', async () => {
++ const { robot } = await t.robot(alex);
++ const invited = t.network.newUser('invited');
++ const add = await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: invited.subject, role: 'operator' } });
++ assert.strictEqual(add.status, 201, add.text);
++ assert.strictEqual(add.json.operators.find((o) => o.subject === invited.subject).role, 'operator');
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: invited })).status, 200);
++ assert.strictEqual((await t.call('PATCH', `/api/v1/robots/${robot.id}`, { user: invited, body: { name: 'nope' } })).status, 403);
++ const rm = await t.call('DELETE', `/api/v1/robots/${robot.id}/operators/${invited.subject}`, { user: alex });
++ assert.strictEqual(rm.status, 200);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: invited })).status, 403);
++ });
++
++ await check('pairing-code create returns a fresh code; devices list is empty before pairing', async () => {
++ const { robot } = await t.robot(alex);
++ const code = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
++ assert.strictEqual(code.status, 201, code.text);
++ assert.match(code.json.code, /^[0-9A-HJKMNP-TV-Z]{4}-[0-9A-HJKMNP-TV-Z]{4}$/);
++ const devices = await t.call('GET', `/api/v1/robots/${robot.id}/devices`, { user: alex });
++ assert.deepStrictEqual(devices.json.devices, []);
++ });
++
++ await check('audit is paged, newest first, keyset on id', async () => {
++ const { robot } = await t.robot(alex);
++ for (let i = 0; i < 3; i++) await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: { throttle: 0.1 * i, steer: 0 }, result: 'refused', reason: 'test' });
++ const page = await t.call('GET', `/api/v1/robots/${robot.id}/audit?limit=2`, { user: alex });
++ assert.strictEqual(page.json.audit.length, 2);
++ assert.ok(page.json.audit[0].id > page.json.audit[1].id);
++ assert.ok(page.json.next_before);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}/audit`, { user: stranger })).status, 403);
++ });
++
++ await check('an outbox event is written for a refused command (bot.command.refused)', async () => {
++ const { robot } = await t.robot(alex);
++ await t.domain.audit.record({ robotId: robot.id, subject: alex.subject, kind: 'drive', value: {}, result: 'refused', reason: 'bot.estop_latched' });
++ const rows = await t.outboxRows('bot.command.refused');
++ assert.ok(rows.length >= 1);
++ assert.strictEqual(rows[rows.length - 1].source, 'bot');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/control.test.js b/test/control.test.js
nnew file mode 100644
iindex 0000000..a596377
---- /dev/null
++++ b/test/control.test.js
@@@ -0,0 +1,154 @@
++'use strict';
++// The control gate (ADR-043 decisions 6 and 8): roles, the access policy, owner limits that clamp an
++// operator, cooldowns, the latched e-stop (owner-cleared only), the turn queue and a service acting for
++// a viewer. Every decision is audited.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const carol = t.network.newUser('carol');
++ const dave = t.network.newUser('dave');
++
++ const op = (user) => t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(user)}` } });
++ async function joinResult(client, robotId) {
++ client.send({ type: 'join', robot_id: robotId });
++ return client.waitFor((m) => m.type === 'joined' || m.type === 'error');
++ }
++ async function deviceFor(robotId, code) {
++ const p = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robotId, code } });
++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${p.json.credential}` } });
++ await dev.waitFor((m) => m.type === 'hello');
++ return dev;
++ }
++ const invite = (robotId, subject, role) => t.call('POST', `/api/v1/robots/${robotId}/operators`, { user: alex, body: { subject, role } });
++ const cmd = (client, o) => client.send({ type: 'command', ...o });
++ const result = (client, id) => client.waitFor((m) => m.type === 'command_result' && m.id === id);
++
++ await check('a private robot refuses a stranger', async () => {
++ const { robot } = await t.robot(alex);
++ const stranger = await op(carol);
++ const r = await joinResult(stranger, robot.id);
++ assert.strictEqual(r.type, 'error');
++ assert.strictEqual(r.code, 'bot.not_an_operator');
++ stranger.close();
++ });
++
++ await check('the owner\'s limits clamp an operator\'s values; the audit keeps the clamped value', async () => {
++ const { robot, pairing } = await t.robot(alex, { limits: { max_speed: 0.4, max_turn: 0.3 } });
++ await invite(robot.id, bob.subject, 'operator');
++ const dev = await deviceFor(robot.id, pairing.code);
++ const b = await op(bob);
++ const j = await joinResult(b, robot.id);
++ assert.strictEqual(j.role, 'operator');
++ cmd(b, { id: 'k1', kind: 'drive', value: { throttle: 5, steer: 5 } });
++ const c = await dev.waitFor((m) => m.type === 'command' && m.ref === 'k1');
++ assert.deepStrictEqual(c.value, { throttle: 0.4, steer: 0.3 });
++ dev.send({ type: 'ack', id: c.id });
++ await result(b, 'k1');
++ const audit = await t.domain.audit.list(robot.id, { limit: 1 });
++ assert.deepStrictEqual(audit[0].value, { throttle: 0.4, steer: 0.3 });
++ b.close();
++ });
++
++ await check('a cooldown refuses the operator\'s second command of a kind', async () => {
++ const { robot, pairing } = await t.robot(alex, { limits: { cooldown_ms: 1000 } });
++ await invite(robot.id, bob.subject, 'operator');
++ const dev = await deviceFor(robot.id, pairing.code);
++ const b = await op(bob);
++ await joinResult(b, robot.id);
++ cmd(b, { id: 'z1', kind: 'drive', value: { throttle: 0.2 } });
++ await dev.waitFor((m) => m.type === 'command' && m.ref === 'z1');
++ cmd(b, { id: 'z2', kind: 'drive', value: { throttle: 0.2 } });
++ const again = await result(b, 'z2');
++ assert.strictEqual(again.result, 'refused');
++ assert.strictEqual(again.code, 'bot.cooldown');
++ b.close();
++ });
++
++ await check('the e-stop latches, refuses commands, and only the owner clears it', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ await invite(robot.id, bob.subject, 'operator');
++ const dev = await deviceFor(robot.id, pairing.code);
++ const b = await op(bob);
++ await joinResult(b, robot.id);
++ b.send({ type: 'estop' });
++ assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !!r.estop_latched)), 'the e-stop latched');
++ cmd(b, { id: 'e1', kind: 'drive', value: { throttle: 0.2 } });
++ const refused = await result(b, 'e1');
++ assert.strictEqual(refused.code, 'bot.estop_latched');
++ b.send({ type: 'estop_clear' });
++ const denied = await b.waitFor((m) => m.type === 'error' && /forbidden/.test(m.code));
++ assert.strictEqual(denied.code, 'bot.forbidden');
++ const a = await op(alex);
++ await joinResult(a, robot.id);
++ a.send({ type: 'estop_clear' });
++ assert.ok(await poll(() => t.domain.robots.get(robot.id).then((r) => !r.estop_latched)), 'the owner cleared it');
++ cmd(b, { id: 'e2', kind: 'drive', value: { throttle: 0.2 } });
++ assert.ok(await dev.waitFor((m) => m.type === 'command' && m.ref === 'e2'), 'an operator may drive again');
++ a.close(); b.close();
++ });
++
++ await check('a queue robot gives a turn that expires and passes to the next waiting person', async () => {
++ const { robot, pairing } = await t.robot(alex, { access_policy: 'queue', limits: { turn_ms: 300, turn_budget: 5 } });
++ const dev = await deviceFor(robot.id, pairing.code);
++ const c = await op(carol);
++ const cj = await joinResult(c, robot.id);
++ assert.strictEqual(cj.role, 'queue');
++ const d = await op(dave);
++ await joinResult(d, robot.id);
++ cmd(c, { id: 'q1', kind: 'drive', value: { throttle: 0.2 } });
++ const q1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'q1');
++ assert.ok(q1, 'the turn holder may drive');
++ dev.send({ type: 'ack', id: q1.id });
++ await result(c, 'q1');
++ t.clock.offset += 1000; // past the 300 ms turn
++ cmd(c, { id: 'q2', kind: 'drive', value: { throttle: 0.2 } });
++ const rc = await result(c, 'q2');
++ assert.strictEqual(rc.result, 'refused', 'the expired turn no longer drives');
++ cmd(d, { id: 'q3', kind: 'drive', value: { throttle: 0.2 } });
++ assert.ok(await dev.waitFor((m) => m.type === 'command' && m.ref === 'q3'), 'the next waiting person is promoted');
++ t.clock.offset -= 1000;
++ c.close(); d.close();
++ });
++
++ await check('a service token with bot.robot.control acts for a viewer and is held to the viewer role', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const viewer = t.network.newUser('viewer');
++ await invite(robot.id, viewer.subject, 'viewer');
++ await deviceFor(robot.id, pairing.code);
++ const svc = t.network.signService({ sub: 'svc:chat', cap: ['bot.robot.control'], aud: ['openvibe.bot'] });
++ const s = await t.ws('/control', { headers: { Authorization: `Bearer ${svc}`, 'X-OV-Subject': viewer.subject } });
++ const j = await joinResult(s, robot.id);
++ assert.strictEqual(j.role, 'viewer');
++ cmd(s, { id: 'v1', kind: 'drive', value: { throttle: 0.5 } });
++ const r = await result(s, 'v1');
++ assert.strictEqual(r.result, 'refused');
++ assert.strictEqual(r.code, 'bot.read_only');
++ const audit = await t.domain.audit.list(robot.id, { limit: 5 });
++ assert.ok(audit.some((x) => x.result === 'refused' && x.reason === 'bot.read_only' && x.operator_kind === 'service'), 'the refusal is audited');
++ s.close();
++
++ const noSubject = await t.ws('/control', { headers: { Authorization: `Bearer ${svc}` } });
++ assert.strictEqual(await noSubject.waitForClose(), 4002, 'a service must name the subject it acts for');
++ const noCap = t.network.signService({ sub: 'svc:chat', cap: ['bot.robot.read'], aud: ['openvibe.bot'] });
++ const weak = await t.ws('/control', { headers: { Authorization: `Bearer ${noCap}`, 'X-OV-Subject': viewer.subject } });
++ assert.strictEqual(await weak.waitForClose(), 4002, 'without bot.robot.control a service cannot control');
++ const anon = await t.ws('/control');
++ assert.strictEqual(await anon.waitForClose(), 4002, 'an anonymous socket is refused');
++ });
++
++ await t.close();
++ done();
++})();
++
++async function poll(fn, ms = 3000) {
++ const end = Date.now() + ms;
++ for (;;) {
++ if (await fn()) return true;
++ if (Date.now() > end) return false;
++ await new Promise((r) => setTimeout(r, 25));
++ }
++}
ddiff --git a/test/device.test.js b/test/device.test.js
nnew file mode 100644
iindex 0000000..30d3ab7
---- /dev/null
++++ b/test/device.test.js
@@@ -0,0 +1,149 @@
++'use strict';
++// The device WebSocket (ADR-043 decisions 4–6): header-only auth, the pairing handshake, heartbeats and
++// offline detection, no queueing for an offline device, idempotent ids and the deadline cap. The fake
++// device is a `ws` client — the same shape the agent job will build against docs/protocol.md.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++
++ const asOwner = () => t.ws('/control', { headers: { Authorization: `Bearer ${t.network.signUser(alex)}` } });
++ async function ownerJoined(robotId) {
++ const op = await asOwner();
++ op.send({ type: 'join', robot_id: robotId });
++ const joined = await op.waitFor((m) => m.type === 'joined');
++ assert.strictEqual(joined.role, 'owner', joined.text || JSON.stringify(joined));
++ return op;
++ }
++ async function pairOverWs(robotId, code) {
++ const dev = await t.ws('/device');
++ dev.send({ type: 'pair', robot: robotId, code, agent_version: '0.1.0', device_kind: 'onboard', drivers: ['cozmo'], capabilities: { camera: { resolution: '320x240' } } });
++ const paired = await dev.waitFor((m) => m.type === 'paired');
++ await dev.waitFor((m) => m.type === 'hello');
++ await dev.waitFor((m) => m.type === 'config');
++ return { dev, paired };
++ }
++
++ await check('device auth is by header, never a query string', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const paired = await t.call('POST', '/api/v1/pair', { token: null, body: { robot: robot.id, code: pairing.code } });
++ const cred = paired.json.credential;
++ const qs = await t.ws(`/device?credential=${encodeURIComponent(cred)}`);
++ qs.send({ type: 'status', firmware: 'x' });
++ const err = await qs.waitFor((m) => m.type === 'error');
++ assert.strictEqual(err.code, 'bot.not_paired', 'a query-string credential does not authenticate');
++ assert.ok(!qs.messages.some((m) => m.type === 'hello'));
++ qs.close();
++ const bad = await t.ws('/device', { headers: { Authorization: 'Bearer not-a-credential' } });
++ assert.strictEqual(await bad.waitForClose(), 4002);
++ });
++
++ await check('a paired device gets hello + config; heartbeats are acknowledged with the RTT', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const { dev, paired } = await pairOverWs(robot.id, pairing.code);
++ dev.send({ type: 'heartbeat', seq: 1, rtt_ms: 42 });
++ const ack = await dev.waitFor((m) => m.type === 'heartbeat_ack');
++ assert.strictEqual(ack.seq, 1);
++ assert.ok(ack.server_time);
++ assert.strictEqual(t.hub.deviceState(paired.device_id).rtt_ms, 42);
++ dev.close();
++ });
++
++ await check('a device that stops heartbeating goes offline; bot.robot.offline is written', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const { dev, paired } = await pairOverWs(robot.id, pairing.code);
++ assert.strictEqual(t.hub.isOnline(paired.device_id), true);
++ t.clock.offset += 2000; // past 2 missed + 0 grace
++ const offline = await poll(() => !t.hub.isOnline(paired.device_id), 2000);
++ assert.ok(offline, 'the hub marked the device offline');
++ t.clock.offset -= 2000;
++ await t.wait(50);
++ const rows = await t.outboxRows('bot.robot.offline');
++ assert.ok(rows.some((e) => e.payload.robot_id === robot.id), 'bot.robot.offline for the robot');
++ dev.close();
++ });
++
++ await check('commands are refused, never queued, when the device is offline', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const { dev } = await pairOverWs(robot.id, pairing.code);
++ const op = await ownerJoined(robot.id);
++ dev.close();
++ await dev.waitForClose();
++ await t.wait(80);
++ op.send({ type: 'command', id: 'off1', kind: 'drive', value: { throttle: 1 } });
++ const r = await op.waitFor((m) => m.type === 'command_result' && m.id === 'off1');
++ assert.strictEqual(r.result, 'refused');
++ assert.strictEqual(r.code, 'bot.device_offline');
++ op.close();
++ });
++
++ await check('the deadline is stamped and capped by max_command_ms', async () => {
++ const { robot, pairing } = await t.robot(alex, { limits: { max_command_ms: 200 } });
++ const { dev } = await pairOverWs(robot.id, pairing.code);
++ const op = await ownerJoined(robot.id);
++ const before = Date.now() + t.clock.offset;
++ op.send({ type: 'command', id: 'd1', kind: 'drive', value: { throttle: 1, steer: 0 }, ms: 5000 });
++ const c1 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'd1');
++ const d1 = c1.deadline_ms - before;
++ assert.ok(d1 > 100 && d1 <= 200 + 60, `capped to <=200 (was ${d1})`);
++ dev.send({ type: 'ack', id: c1.id });
++ await op.waitFor((m) => m.type === 'command_result' && m.id === 'd1');
++ op.send({ type: 'command', id: 'd2', kind: 'drive', value: { throttle: 1, steer: 0 }, ms: 100 });
++ const c2 = await dev.waitFor((m) => m.type === 'command' && m.ref === 'd2');
++ assert.ok(c2.deadline_ms - before <= 100 + 60, 'a shorter request is honoured');
++ dev.send({ type: 'ack', id: c2.id });
++ op.close(); dev.close();
++ });
++
++ await check('the full fake-device round trip: pair → hello → command → ack → telemetry → robot_state', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const { dev, paired } = await pairOverWs(robot.id, pairing.code);
++ assert.strictEqual(paired.profile.id, 'sim.rover');
++ const op = await ownerJoined(robot.id);
++ op.send({ type: 'command', id: 'c1', kind: 'drive', value: { throttle: 2, steer: -2 } });
++ const cmd = await dev.waitFor((m) => m.type === 'command' && m.ref === 'c1');
++ assert.deepStrictEqual(cmd.value, { throttle: 1, steer: -1 }); // clamped to the limits
++ assert.ok(cmd.deadline_ms > Date.now() + t.clock.offset);
++ dev.send({ type: 'ack', id: cmd.id });
++ const res = await op.waitFor((m) => m.type === 'command_result' && m.id === 'c1');
++ assert.strictEqual(res.result, 'ack');
++ assert.ok(Number.isFinite(res.latency_ms));
++ // a repeated id answers with the first result and never reaches the device again
++ op.send({ type: 'command', id: 'c1', kind: 'drive', value: { throttle: 1 } });
++ const again = await op.waitFor((m) => m.type === 'command_result' && m.id === 'c1' && m.cached);
++ assert.strictEqual(again.result, 'ack');
++ // The robot never sees an operator-chosen id, and a repeat while the first is in flight is not sent twice.
++ assert.ok(/^cmd_/.test(cmd.id) && cmd.ref === 'c1', 'the device gets a server-minted id');
++ let sentToDevice = 0;
++ const counter = (m) => { if (m.type === 'command' && m.ref === 'p1') sentToDevice++; return false; };
++ dev.waitFor(counter, 800).catch(() => {});
++ op.send({ type: 'command', id: 'p1', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
++ op.send({ type: 'command', id: 'p1', kind: 'drive', value: { throttle: 0.2, steer: 0 } });
++ const dup = await op.waitFor((m) => m.type === 'command_result' && m.id === 'p1' && m.result === 'pending');
++ assert.strictEqual(dup.cached, true);
++ await new Promise((r) => setTimeout(r, 300));
++ assert.strictEqual(sentToDevice, 1, 'an in-flight duplicate is not sent to the robot');
++ assert.strictEqual(dev.messages.filter((m) => m.type === 'command' && m.ref === 'c1').length, 1, 'only one command frame');
++ dev.send({ type: 'telemetry', battery: 0.7, voltage: 7.4, sensors: { ultrasonic: 120 } });
++ const state = await op.waitFor((m) => m.type === 'robot_state' && m.state && m.state.telemetry && m.state.telemetry.battery === 0.7);
++ assert.strictEqual(state.state.online, true);
++ assert.strictEqual(state.state.battery, 0.7);
++ const audit = await t.domain.audit.list(robot.id, { limit: 5 });
++ assert.ok(audit.some((a) => a.result === 'ack' && a.kind === 'drive' && a.latency_ms != null), 'the command was audited with its latency');
++ op.close(); dev.close();
++ });
++
++ await t.close();
++ done();
++})();
++
++async function poll(fn, ms) {
++ const end = Date.now() + ms;
++ for (;;) {
++ if (await fn()) return true;
++ if (Date.now() > end) return false;
++ await new Promise((r) => setTimeout(r, 25));
++ }
++}
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
nnew file mode 100644
iindex 0000000..5677679
---- /dev/null
++++ b/test/helpers/app.js
@@@ -0,0 +1,140 @@
++'use strict';
++/**
++ * Boots Bot against the stubs on a random port with a migrated database of its own (helpers/db.js:
++ * PGlite, or the containers with BOT_TEST_STORE=pg). Jobs are off: tests drive the queue sweep and the
++ * outbox explicitly. The realtime hub is attached to the HTTP server's upgrade event, exactly as
++ * server/index.js does.
++ *
++ * t.call(method, path, { body, user, cap, sub, token, headers }) user → Bearer user JWT;
++ * otherwise a service token with `cap`
++ * t.ws(path, { headers }) a WebSocket client: .send, .waitFor
++ * t.clock.offset advance the injected clock (ms)
++ */
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const WebSocket = require('ws');
++const { startNetwork, startEvents } = require('./stubs');
++const { testDb } = require('./db');
++
++async function boot(opts = {}) {
++ const network = await startNetwork();
++ const events = await startEvents();
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bot-test-'));
++ const env = {
++ NODE_ENV: 'test',
++ BASE_URL: 'http://bot.test',
++ OV_NETWORK_URL: network.url,
++ OV_NETWORK_INTERNAL_URL: network.url,
++ OV_NETWORK_ISSUER: network.url,
++ OV_OAUTH_CLIENT_ID: 'bot',
++ OV_OAUTH_CLIENT_SECRET: 'shh',
++ EVENTS_URL: events.url,
++ EVENTS_RELAY_INTERVAL_MS: '50',
++ BOT_JOBS: 'off',
++ BOT_HEARTBEAT_MS: '200',
++ BOT_OFFLINE_MISSES: '2',
++ BOT_OFFLINE_GRACE_MS: '0',
++ BOT_ROTATE_GRACE_MS: '60000',
++ BOT_QUEUE_TURN_MS: '60000',
++ ...(opts.env || {}),
++ };
++ for (const k of Object.keys(require.cache)) if (k.includes(`${path.sep}server${path.sep}`)) delete require.cache[k];
++ const { loadConfig } = require('../../server/config');
++ const { createApp } = require('../../server/app');
++ const { seedProfiles } = require('../../server/profiles');
++ const config = loadConfig(env);
++ const clock = { offset: 0 };
++ const logs = [];
++ const log = { log: (...a) => logs.push(a.join(' ')), warn: (...a) => logs.push(a.join(' ')), error: (...a) => logs.push(a.join(' ')) };
++ const store = opts.db ? { db: opts.db, store: opts.db.store, close: async () => {} } : await testDb({ store: opts.store });
++ await seedProfiles(store.db, { log: { log() {} } });
++ const app = createApp({ config, db: store.db, valkey: opts.valkey || null, now: () => Date.now() + clock.offset, log, ...(opts.appOpts || {}) });
++ await app.locals.keys.load();
++ const server = await new Promise((resolve) => {
++ const s = http.createServer(app);
++ s.on('upgrade', (req, socket, head) => app.locals.hub.handleUpgrade(req, socket, head));
++ s.listen(0, '127.0.0.1', () => resolve(s));
++ });
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const domain = app.locals.domain;
++ const hub = app.locals.hub;
++
++ let n = 0;
++ async function call(method, p, { body, user, cap = ['bot.*'], sub = 'svc:live', key, token, headers = {} } = {}) {
++ const h = { ...headers };
++ if (token !== null) h.Authorization = `Bearer ${token || (user ? network.signUser(user) : network.signService({ sub, cap }))}`;
++ if (body !== undefined) h['Content-Type'] = 'application/json';
++ if ((method === 'POST' || method === 'PATCH') && key !== null) h['Idempotency-Key'] = key || `test-${process.pid}-${++n}-${crypto.randomBytes(4).toString('hex')}`;
++ const res = await fetch(base + p, { method, headers: h, body: body !== undefined ? JSON.stringify(body) : undefined });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, headers: res.headers, json, text };
++ }
++
++ /** A WebSocket client to /device or /control. waitFor(pred) resolves the first matching frame. */
++ function ws(p, { headers = {} } = {}) {
++ return new Promise((resolve, reject) => {
++ const socket = new WebSocket(`${base.replace('http', 'ws')}${p}`, { headers });
++ const client = { ws: socket, messages: [], ended: false, waiters: [], closeCode: null };
++ const timer = setTimeout(() => reject(new Error(`ws open timeout on ${p}`)), 4000);
++ socket.on('message', (raw) => {
++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
++ client.messages.push(m);
++ client.waiters = client.waiters.filter((w) => (w.pred(m) ? (w.resolve(m), false) : true));
++ });
++ socket.on('close', (code) => { clearTimeout(timer); client.ended = true; client.closeCode = code; client.waiters.forEach((w) => w.resolve(null)); client.waiters = []; if (client.onClose) client.onClose(code); });
++ socket.on('error', () => { /* close follows */ });
++ socket.on('open', () => { clearTimeout(timer); resolve(client); });
++ client.send = (o) => socket.send(JSON.stringify(o));
++ client.waitFor = (pred, ms = 4000) => new Promise((ok, fail) => {
++ const hit = client.messages.find(pred);
++ if (hit) return ok(hit);
++ if (client.ended) return ok(null);
++ const w = { pred, resolve: ok };
++ client.waiters.push(w);
++ setTimeout(() => { client.waiters = client.waiters.filter((x) => x !== w); fail(new Error(`waitFor timed out; got ${JSON.stringify(client.messages.map((m) => m.type))}`)); }, ms).unref();
++ });
++ client.waitForClose = (ms = 4000) => new Promise((ok) => {
++ if (client.ended) return ok(client.closeCode);
++ client.onClose = (code) => ok(code);
++ setTimeout(() => ok(null), ms).unref();
++ });
++ client.close = () => { try { socket.close(); } catch { /* gone */ } };
++ });
++ }
++
++ /** A robot owned by `ownerSubject`, created through the API so the pairing code is real. */
++ async function robot(ownerUser, body = {}) {
++ const r = await call('POST', '/api/v1/robots', { user: ownerUser, body: { name: 'Test rover', profile_id: 'sim.rover', ...body } });
++ if (r.status !== 201) throw new Error(`robot setup: ${r.status} ${r.text}`);
++ return r.json;
++ }
++
++ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
++
++ return {
++ app, base, call, ws, robot, wait, domain, hub, db: domain.db, store, config, clock, network, events, logs, dir,
++ outboxRows: async (type) => (await domain.db.many('SELECT envelope FROM bot_event_outbox ORDER BY id')).map((r) => r.envelope).filter((e) => !type || e.event_type === type),
++ close: async () => {
++ await hub.close().catch(() => {});
++ server.closeAllConnections();
++ await new Promise((r) => server.close(r));
++ await app.locals.outbox.stop();
++ await Promise.all([network.close(), events.close()]);
++ await store.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ },
++ };
++}
++
++let failures = 0;
++async function check(name, fn) {
++ try { await fn(); console.log(' ✓', name); }
++ catch (e) { failures++; console.log(' ✗', name, '\n ', (e.stack || String(e)).split('\n').slice(0, 8).join('\n ')); }
++}
++function done() { console.log(failures ? `\n${failures} failed` : '\nall passed'); process.exit(failures ? 1 : 0); }
++
++module.exports = { boot, check, done };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..1f96ba0
---- /dev/null
++++ b/test/helpers/db.js
@@@ -0,0 +1,74 @@
++'use strict';
++/**
++ * A migrated database for one test run (ADR-035).
++ *
++ * store 'pglite' (the default): real PostgreSQL in-process, migrated.
++ * store 'pg': the production-shaped containers (openvibe-sdk scripts/test-services.sh up), when
++ * OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL are set. The run gets its own roles and schema.
++ *
++ * TIPS-style: BOT_TEST_STORE=pg runs every test file on the containers instead of PGlite.
++ */
++const crypto = require('crypto');
++const { createDb } = require('openvibe-sdk/db');
++const { MIGRATIONS } = require('../../server/db');
++
++const quiet = { log() {}, warn() {}, error: (...a) => console.error(...a) };
++const pgAvailable = () => !!(process.env.OV_TEST_PG_URL && process.env.OV_TEST_PG_DIRECT_URL);
++
++async function testDb({ store = process.env.BOT_TEST_STORE || 'pglite', max = 4 } = {}) {
++ if (store !== 'pg') {
++ const db = createDb({ pglite: true, service: 'bot-test', log: quiet });
++ await db.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 });
++ return { db, store: 'pglite', close: () => db.close().catch(() => {}) };
++ }
++ if (!pgAvailable()) throw new Error('store pg needs OV_TEST_PG_URL and OV_TEST_PG_DIRECT_URL (openvibe-sdk scripts/test-services.sh up)');
++ const name = `bot_t${process.pid}_${crypto.randomBytes(4).toString('hex')}`;
++ const owner = `${name}_owner`;
++ const pw = crypto.randomBytes(16).toString('hex');
++ const su = createDb({ url: process.env.OV_TEST_PG_DIRECT_URL, service: 'bot-test-admin', max: 1, log: quiet });
++ const database = await su.value('SELECT current_database()');
++ for (const stmt of [
++ `CREATE ROLE ${owner} LOGIN PASSWORD '${pw}'`,
++ `CREATE ROLE ${name} LOGIN PASSWORD '${pw}'`,
++ `GRANT CONNECT ON DATABASE ${database} TO ${owner}, ${name}`,
++ `CREATE SCHEMA ${name} AUTHORIZATION ${owner}`,
++ `ALTER ROLE ${owner} SET search_path = ${name}`,
++ `ALTER ROLE ${name} SET search_path = ${name}`,
++ `ALTER ROLE ${name} SET statement_timeout = '15s'`,
++ `ALTER ROLE ${name} SET lock_timeout = '5s'`,
++ `GRANT USAGE ON SCHEMA ${name} TO ${name}`,
++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT SELECT, INSERT, UPDATE, DELETE ON TABLES TO ${name}`,
++ `ALTER DEFAULT PRIVILEGES FOR ROLE ${owner} IN SCHEMA ${name} GRANT USAGE, SELECT, UPDATE ON SEQUENCES TO ${name}`,
++ ]) await su.query(stmt);
++ const as = (url, user) => { const u = new URL(url); u.username = user; u.password = pw; return u.toString(); };
++ async function drop() {
++ try {
++ await su.query('SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
++ for (let i = 0; i < 100; i++) {
++ const left = await su.query('SELECT count(*)::int AS n FROM pg_stat_activity WHERE usename = ANY($1)', [[name, owner]]);
++ if (!Number(left.rows[0].n)) break;
++ await new Promise((ok) => setTimeout(ok, 50));
++ }
++ const ddl = async (stmt) => {
++ for (let i = 0; ; i++) {
++ try { return await su.query(stmt); } catch (err) { if (err.code !== 'XX000' || i >= 5) throw err; await new Promise((ok) => setTimeout(ok, 100 * (i + 1))); }
++ }
++ };
++ await ddl(`DROP SCHEMA IF EXISTS ${name} CASCADE`);
++ for (const r of [name, owner]) { await ddl(`DROP OWNED BY ${r}`); await ddl(`DROP ROLE ${r}`); }
++ } finally { await su.close(); }
++ }
++ let db;
++ try {
++ const ownerDb = createDb({ url: as(process.env.OV_TEST_PG_DIRECT_URL, owner), service: 'bot-test-migrate', max: 1, log: quiet });
++ try { await ownerDb.migrate({ dir: MIGRATIONS, log: quiet, windowDays: 0 }); } finally { await ownerDb.close(); }
++ db = createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'bot-test', max, log: quiet });
++ } catch (e) { await drop().catch(() => {}); throw e; }
++ return {
++ db, store: 'postgresql', schema: name,
++ open: (o = {}) => createDb({ url: as(process.env.OV_TEST_PG_URL, name), service: 'bot-test', max, log: quiet, ...o }),
++ async close() { await db.close().catch(() => {}); await drop(); },
++ };
++}
++
++module.exports = { testDb, pgAvailable };
ddiff --git a/test/helpers/stubs.js b/test/helpers/stubs.js
nnew file mode 100644
iindex 0000000..cca0e97
---- /dev/null
++++ b/test/helpers/stubs.js
@@@ -0,0 +1,73 @@
++'use strict';
++/**
++ * Stand-ins for the services Bot talks to, each on a random port.
++ *
++ * startNetwork() JWKS and user/service JWTs (signUser, signService, newUser)
++ * startEvents() POST /api/v1/events recording what Bot's outbox relays; GET /api/health
++ */
++const http = require('http');
++const crypto = require('crypto');
++const jwt = require('jsonwebtoken');
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function listen(server) {
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve(`http://127.0.0.1:${server.address().port}`)));
++}
++function readBody(req) {
++ return new Promise((resolve) => { const c = []; req.on('data', (d) => c.push(d)); req.on('end', () => resolve(Buffer.concat(c).toString('utf8'))); });
++}
++const send = (res, status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
++
++async function startNetwork() {
++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
++ let issuer = 'http://network.test';
++ let n = 100;
++
++ function signService({ sub = 'svc:live', aud = ['openvibe.bot'], cap = [], expSec = 300 } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, privatePem);
++ }
++ function signUser(u) {
++ 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' },
++ privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h' });
++ }
++ function newUser(username) {
++ return { subject: ids.newId('user'), username, display_name: username[0].toUpperCase() + username.slice(1) };
++ }
++
++ const server = http.createServer(async (req, res) => {
++ if (req.url === '/api/.well-known/jwks') return send(res, 200, { public_key: publicPem, algorithm: 'RS256' });
++ if (req.url === '/api/health') return send(res, 200, { ok: true });
++ if (req.url === '/oauth/token' && req.method === 'POST') {
++ const body = Object.fromEntries(new URLSearchParams(await readBody(req)));
++ if (body.client_secret !== 'shh') return send(res, 401, { error: 'invalid_client' });
++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return send(res, 200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience || 'openvibe.bot'], cap }), token_type: 'Bearer', expires_in: 300 });
++ }
++ send(res, 404, { error: 'not found' });
++ });
++ const url = await listen(server);
++ issuer = url;
++ return { url, publicPem, signService, signUser, newUser, close: () => new Promise((r) => server.close(r)) };
++}
++
++async function startEvents() {
++ const events = [];
++ const server = http.createServer(async (req, res) => {
++ if (req.url === '/api/health') return send(res, 200, { ok: true });
++ if (req.url === '/api/v1/events' && req.method === 'POST') {
++ let body = {};
++ try { body = JSON.parse(await readBody(req) || '{}'); } catch { /* ignore */ }
++ const list = Array.isArray(body) ? body : (body.events || [body]);
++ for (const e of list) if (e && e.event_id) events.push(e);
++ return send(res, 200, { results: list.map((e) => ({ event_id: e && e.event_id, seq: events.length })) });
++ }
++ send(res, 404, { error: 'not found' });
++ });
++ const url = await listen(server);
++ return { url, events, close: () => new Promise((r) => server.close(r)) };
++}
++
++module.exports = { startNetwork, startEvents };
ddiff --git a/test/pairing.test.js b/test/pairing.test.js
nnew file mode 100644
iindex 0000000..0e3f35f
---- /dev/null
++++ b/test/pairing.test.js
@@@ -0,0 +1,88 @@
++'use strict';
++// Pairing and credentials (ADR-043 decisions 1–2, acceptance tests): one use, expiry, 5 tries, never
++// logged or returned by a read, rotation with a 60 s grace, revocation that disconnects at once.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++
++ const redeem = (body) => t.call('POST', '/api/v1/pair', { token: null, body });
++
++ await check('a pairing code works exactly once', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const first = await redeem({ robot: robot.id, code: pairing.code, agent_version: '0.1.0', device_kind: 'onboard', drivers: ['pca9685'] });
++ assert.strictEqual(first.status, 201, first.text);
++ assert.match(first.json.device_id, /^dev_/);
++ assert.ok(first.json.credential && first.json.credential.length >= 40);
++ assert.ok(first.json.publish_key && first.json.publish_key.length >= 40);
++ assert.strictEqual(first.json.profile.id, 'sim.rover');
++ const again = await redeem({ robot: robot.id, code: pairing.code });
++ assert.strictEqual(again.status, 403);
++ assert.strictEqual(again.json.code, 'bot.pairing_code_used');
++ });
++
++ await check('a pairing code expires after 10 minutes', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ t.clock.offset += 10 * 60 * 1000 + 1000;
++ const late = await redeem({ robot: robot.id, code: pairing.code });
++ assert.strictEqual(late.status, 403);
++ assert.strictEqual(late.json.code, 'bot.pairing_code_expired');
++ t.clock.offset -= 10 * 60 * 1000 + 1000;
++ });
++
++ await check('five wrong tries end the code', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ for (let i = 1; i <= 5; i++) {
++ const bad = await redeem({ robot: robot.id, code: 'AAAA-AAAA' });
++ assert.strictEqual(bad.status, 403, `try ${i}`);
++ assert.strictEqual(bad.json.code, i < 5 ? 'bot.pairing_code_invalid' : 'bot.pairing_code_locked', `try ${i}`);
++ }
++ const correct = await redeem({ robot: robot.id, code: pairing.code });
++ assert.strictEqual(correct.status, 403);
++ assert.ok(['bot.pairing_code_locked', 'bot.pairing_code_used'].includes(correct.json.code));
++ });
++
++ await check('the credential and publish key are never logged and never in a read answer', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const paired = await redeem({ robot: robot.id, code: pairing.code });
++ const { credential, publish_key: publishKey } = paired.json;
++ const paths = ['/api/health', '/api/ready', '/release.json', '/api/v1/profiles', '/api/v1/robots', `/api/v1/robots/${robot.id}`,
++ `/api/v1/robots/${robot.id}/devices`, `/api/v1/robots/${robot.id}/operators`, `/api/v1/robots/${robot.id}/audit`];
++ for (const p of paths) {
++ const r = await t.call('GET', p, { user: alex });
++ assert.ok(!r.text.includes(credential), `${p} leaked the credential`);
++ assert.ok(!r.text.includes(publishKey), `${p} leaked the publish key`);
++ }
++ const logged = t.logs.join('\n');
++ assert.ok(!logged.includes(credential), 'the credential was logged');
++ assert.ok(!logged.includes(publishKey), 'the publish key was logged');
++ assert.ok(!logged.includes(pairing.code.replace('-', '')), 'the pairing code was logged');
++ const devices = await t.call('GET', `/api/v1/robots/${robot.id}/devices`, { user: alex });
++ assert.ok(!JSON.stringify(devices.json).includes('hash'), 'device read exposed a hash');
++ });
++
++ await check('rotation keeps the old credential 60 s; revocation is instant and disconnects', async () => {
++ const { robot, pairing } = await t.robot(alex);
++ const paired = await redeem({ robot: robot.id, code: pairing.code });
++ const deviceId = paired.json.device_id;
++ assert.ok(await t.domain.devices.byCredential(paired.json.credential), 'the credential authenticates');
++ const rot = await t.call('POST', `/api/v1/devices/${deviceId}/rotate`, { user: alex });
++ assert.strictEqual(rot.status, 200, rot.text);
++ assert.ok(await t.domain.devices.byCredential(rot.json.credential), 'the new credential authenticates');
++ assert.ok(await t.domain.devices.byCredential(paired.json.credential), 'the old credential still works inside the 60 s grace');
++ t.clock.offset += 61 * 1000;
++ assert.strictEqual(await t.domain.devices.byCredential(paired.json.credential), null, 'the old credential is dead after 60 s');
++ t.clock.offset -= 61 * 1000;
++
++ const dev = await t.ws('/device', { headers: { Authorization: `Bearer ${rot.json.credential}` } });
++ await dev.waitFor((m) => m.type === 'hello');
++ await t.call('POST', `/api/v1/devices/${deviceId}/revoke`, { user: alex });
++ assert.strictEqual(await dev.waitForClose(), 4003, 'revocation closes the socket');
++ assert.strictEqual(await t.domain.devices.byCredential(rot.json.credential), null, 'the credential is dead');
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/profiles.test.js b/test/profiles.test.js
nnew file mode 100644
iindex 0000000..65c40e2
---- /dev/null
++++ b/test/profiles.test.js
@@@ -0,0 +1,60 @@
++'use strict';
++// Robot profiles (ADR-043 decision 7): every shipped profile validates, the registries refuse typos.
++const assert = require('assert');
++const { check, done } = require('./helpers/app');
++const { loadProfiles, validateProfile } = require('../server/profiles');
++
++(async () => {
++ const profiles = loadProfiles();
++
++ await check('the four shipped profiles load and validate', () => {
++ assert.deepStrictEqual([...profiles.keys()].sort(), ['adeept.adr036', 'camera.onvif', 'cozmo', 'sim.rover']);
++ for (const p of profiles.values()) {
++ assert.ok(p.capabilities.length > 0, `${p.id} capabilities`);
++ assert.ok(p.widgets.length > 0, `${p.id} widgets`);
++ assert.ok(p.limits.max_speed > 0 && p.limits.max_turn > 0);
++ }
++ });
++
++ await check('adeept.adr036 maps the pca9685 at 0x5f, motor channels 8–15, servos 0/1, ADS7830 battery', () => {
++ const p = profiles.get('adeept.adr036');
++ assert.strictEqual(p.mapping.driver, 'pca9685');
++ assert.strictEqual(p.mapping.address, '0x5f');
++ assert.deepStrictEqual(p.mapping.motor_channels, [8, 9, 10, 11, 12, 13, 14, 15]);
++ assert.deepStrictEqual(p.mapping.servo_channels, { pan: 0, tilt: 1 });
++ assert.strictEqual(p.mapping.battery.driver, 'ads7830');
++ assert.strictEqual(p.mapping.battery.address, '0x48');
++ assert.ok(p.capabilities.includes('drive.differential') && p.capabilities.includes('drive.mecanum'), 'both wheel variants');
++ assert.deepStrictEqual(Object.keys(p.variants).sort(), ['differential', 'mecanum']);
++ });
++
++ await check('cozmo carries drive, head, lift, say, display/animation, lights, cliff/pick-up and a 320x240 camera', () => {
++ const p = profiles.get('cozmo');
++ for (const c of ['drive.differential', 'head', 'lift', 'speaker.say', 'display.text', 'display.animation', 'lights.backpack', 'lights.cube', 'battery', 'sensor.cliff', 'sensor.pickup', 'camera']) {
++ assert.ok(p.capabilities.includes(c), `cozmo has ${c}`);
++ }
++ assert.strictEqual(p.mapping.driver, 'cozmo');
++ assert.ok(Array.isArray(p.mapping.animations) && p.mapping.animations.length > 0);
++ assert.strictEqual(p.camera.resolution, '320x240');
++ });
++
++ await check('camera.onvif is PTZ only and sim.rover is drive + camera', () => {
++ assert.deepStrictEqual(profiles.get('camera.onvif').capabilities.slice().sort(), ['camera', 'ptz']);
++ assert.deepStrictEqual(profiles.get('sim.rover').capabilities.slice().sort(), ['camera', 'drive.differential']);
++ });
++
++ await check('limits default to max_command_ms 300 and heartbeat_ms 1000', () => {
++ const v = validateProfile({ id: 'x.y', version: 1, name: 'X', capabilities: ['camera'], mapping: { driver: 'sim' }, widgets: [{ type: 'camera', capability: 'camera' }] });
++ assert.strictEqual(v.limits.max_command_ms, 300);
++ assert.strictEqual(v.limits.heartbeat_ms, 1000);
++ });
++
++ await check('an unknown widget, capability or driver is refused', () => {
++ const base = { id: 'x.y', version: 1, name: 'X', capabilities: ['camera'], mapping: { driver: 'sim' }, widgets: [{ type: 'camera', capability: 'camera' }] };
++ assert.throws(() => validateProfile({ ...base, widgets: [{ type: 'nope' }] }), /unknown widget/);
++ assert.throws(() => validateProfile({ ...base, capabilities: ['teleport'] }), /unknown capability/);
++ assert.throws(() => validateProfile({ ...base, mapping: { driver: 'does-not-exist' } }), /mapping.driver/);
++ });
++
++ done();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..ea40d8d
---- /dev/null
++++ b/test/run.js
@@@ -0,0 +1,12 @@
++#!/usr/bin/env node
++/**
++ * Runs every test in test/ — the files named *.test.js — each in its own process, and fails if any of
++ * them fails. They use in-process stubs of OpenVibe.Network and OpenVibe.Events, temp databases and
++ * random ports; none needs the network or a running service.
++ *
++ * npm test # everything (PGlite)
++ * npm run test:pg # everything on the containers (BOT_TEST_STORE=pg)
++ * npm test -- profiles pairing # only files whose name contains one of the words
++ */
++'use strict';
++require('openvibe-shared/test-runner').main({ dir: __dirname, timeoutMs: 60000, pad: 28, parallel: 1 });