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.
Changes
nnew file mode 100644@@@ -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/installnnew file mode 100644@@@ -0,0 +1,12 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++*.db++*.db-wal++*.db-shm++*.log++*.pem++*.keynnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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,++};nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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));++ }++}nnew file mode 100644@@@ -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));++ }++}nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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();++})();nnew file mode 100644@@@ -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 });
new file mode 100644
new file mode 100644
@@ -0,0 +1,61 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+*.db
+*.db-wal
+*.db-shm
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,248 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,1395 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,208 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,105 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,486 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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 }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,404 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,81 @@
@@ -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,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,211 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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));
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,149 @@
@@ -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));
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,140 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,74 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -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();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,12 @@
@@ -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 });