openvibers/openvibe.sdk Commits
Publish a Bot client (openvibe-sdk/bot) (#13)
Changes
@@@ -7,6 +7,17 @@ release may change an API and says so here.++## 0.30.0 (2026-10-04)++++- **`openvibe-sdk/bot` (new, both; plan B8):** the OpenVibe.Bot client. `createBotClient(client, {actingSubject?})`++ wraps `robots` — `list`/`get`/`create`/`update`/`delete`/`pairingCode`, `operators.list`/`add`/`remove`,++ `devices` (listing), `streaming.get`/`set`, `audit`/`iterateAudit`, `estop`/`clearEstop` and `command`++ (`bot.command@1`, answered by Bot's own idempotency cache) — plus `devices.rotate`/`revoke`, the public++ `profiles`/`kits` reads, and `pair` (the agent redeems a one-time code; never retried). A service acts for a++ person with `X-OV-Subject` (`bot.robot.read`/`manage`/`control`, `bot.device.connect`); a browser calls as the++ signed-in person. Browser-safe, with its ESM entry, types and the browser bundle, and a test over the++ Contracts bot fixtures. The operator WebSocket and the internal Run→Bot job dispatch are not wrapped.++## 0.29.0 (2026-10-04)- **`createTestDb` reuses a migrated snapshot (`openvibe-sdk/testing`, minor):** on PGlite, the first test process@@@ -171,6 +171,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` || `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted || `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |++| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) || `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) || `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` || `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |@@@ -191,7 +192,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor || `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` || `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |--| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |++| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.@@@ -423,7 +424,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m</script>```--`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `jobs`, `tools` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.++`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `bot`, `jobs`, `tools` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.## Versioning@@@ -440,6 +441,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m- **Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.++- **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not.## Capabilities@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.29.0",++ "version": "0.30.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -15,7 +15,8 @@"events: publish, pull, app events, webhook verification (v2), outbox and inbox","realtime SSE","media files and objects v2",-- "community, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients",++ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients",++ "bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing","per-actor limiter for services (openvibe-sdk/limits)","the OpenVibe Frame for any app","mock platform for tests (openvibe-sdk/testing)",@@@ -13,6 +13,7 @@ module.exports = {realtime: require('./src/realtime'),media: require('./src/media'),community: require('./src/community'),++ bot: require('./src/bot'),search: require('./src/search'),chat: require('./src/chat'),ai: require('./src/ai'),@@@ -1,6 +1,6 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.29.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.30.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces--// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.++// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */const __defs = [// ── 0: browser.js ──────────────────────────────────────────────────@@@ -20,13 +20,14 @@ module.exports = {realtime: __load(11),media: __load(12),community: __load(14),-- search: __load(15),-- chat: __load(16),-- ai: __load(17),-- jobs: __load(18),-- tools: __load(20),-- projects: __load(21),-- geo: __load(22),++ bot: __load(15),++ search: __load(16),++ chat: __load(17),++ ai: __load(18),++ jobs: __load(19),++ tools: __load(21),++ projects: __load(22),++ geo: __load(23),};},// ── 1: src/core/index.js ───────────────────────────────────────────@@@ -41,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.29.0';++const SDK_VERSION = '0.30.0';module.exports = {createClient,@@@ -1749,7 +1750,145 @@ function createCommunityClient(client, defaults = {}) {module.exports = { createCommunityClient, actingHeaders };},--// ── 15: src/search.js ───────────────────────────────────────────────++// ── 15: src/bot.js ──────────────────────────────────────────────────++function (module, exports) {++'use strict';++/**++ * openvibe-sdk/bot: OpenVibe.Bot robots (/api/v1). Browser-safe.++ *++ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or++ * a Bearer user JWT); first-party services call with a Network service token (audience++ * openvibe.bot, bot.robot.read / bot.robot.manage / bot.robot.control) and say who they act for:++ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject++ * Bot reads X-OV-Subject on the routes a service may act for a person (create a robot, manage,++ * control, streaming). A person's own token acts only as themself: Bot refuses a mismatch rather++ * than ignoring it, so `as()` is for services.++ *++ * Public reads need no token at all: profiles and the robot kits catalogue (GET /profiles,++ * /kits). Pairing (POST /pair) is the agent redeeming a one-time code: the code is the credential,++ * and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).++ * Every other POST carries an Idempotency-Key (the client generates one when retries are on).++ *++ * Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device++ * bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch++ * (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed++ * `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are++ * `devices.rotate` / `devices.revoke`.++ */++const { isOpenVibeError } = __load(3);++const { isActingSubjectId } = __load(5);++const { paginate } = __load(7);++++const enc = encodeURIComponent;++++/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */++function actingHeaders({ actingSubject } = {}) {++ const h = {};++ if (actingSubject) {++ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');++ h['X-OV-Subject'] = actingSubject;++ }++ return h;++}++++function createBotClient(client, defaults = {}) {++ const { baseUrl } = defaults;++ const pick = (o) => ({ actingSubject: o.actingSubject, signal: o.signal });++ const base = pick(defaults);++++ function call(opts, perCall = {}) {++ const acting = { ...base };++ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;++ return client.json({++ service: 'bot', baseUrl, audience: 'openvibe.bot', ...opts,++ headers: { ...actingHeaders(acting), ...opts.headers }, signal: acting.signal,++ });++ }++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const once = { idempotencyKey: false }; // a one-time code is its own idempotency: never repeat the call++++ const robots = {++ /** { robots } — a person's own robots; a service must name owner (or act as them). */++ list: (query = {}, o = {}) => call({ path: '/api/v1/robots', query }, o),++ /** { robot, role } or null when the robot is gone/unknown. */++ get: (id, o = {}) => orNull(call({ path: `/api/v1/robots/${enc(id)}` }, o)),++ /** { owner?, name?, profile_id?, access_policy?, limits? } -> { robot, pairing } (a one-time code). */++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/robots', json: input }, o),++ /** { name?, access_policy?, limits? } -> { robot }. */++ update: (id, patch = {}, o = {}) => call({ method: 'PATCH', path: `/api/v1/robots/${enc(id)}`, json: patch }, o),++ /** Irreversible -> null (204). */++ delete: (id, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}` }, o),++ /** A fresh one-time pairing code -> { code, expires_at, installer }. */++ pairingCode: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/pairing-code`, json: {} }, o),++ operators: {++ /** { operators } — owner or operator. */++ list: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/operators` }, o),++ /** { subject, role? } -> { operators } (the owner). */++ add: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/operators`, json: input }, o),++ remove: (id, subject, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}/operators/${enc(subject)}` }, o),++ },++ /** { devices } — each with `online`. */++ devices: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/devices` }, o),++ /** { audit, next_before } — newest first, paged by `before` (an audit id). The owner, or a service with read. */++ audit: (id, query = {}, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/audit`, query }, o),++ /** Every audit entry, newest first: for await (const entry of robots.iterateAudit(id)) … */++ iterateAudit(id, query = {}, o = {}) {++ const limit = query.limit || 50;++ return paginate(async (before) => {++ const page = await robots.audit(id, { ...query, limit, before }, o);++ return { items: page.audit || [], next: page.next_before };++ }, { cursor: query.before ?? null });++ },++ /** Latch the e-stop -> { robot }. A service needs bot.robot.read and bot.robot.control. */++ estop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop`, json: {} }, o),++ /** Clear the e-stop, owner only (a service needs bot.robot.manage) -> { robot }. */++ clearEstop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop/clear`, json: {} }, o),++ /**++ * { id, kind, value?, ms? } -> { robot_id, result, … }; `id` is Bot's own idempotency key (a++ * repeated id is answered from the cache). A refused command is an OpenVibeError (`code`++ * bot.not_an_operator, bot.cooldown, …) with the HTTP status Bot gives it.++ */++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/commands`, json: input }, o),++ streaming: {++ /** The owner's OpenRe toggles (Bot stores no copy). A member read. */++ get: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/streaming` }, o),++ /** { to?, on? } (a service must also act for the owner) -> the new streaming state. */++ set: (id, input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/streaming`, json: input }, o),++ },++ };++++ return {++ robots,++ devices: {++ /** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */++ rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),++ /** Revoke a device, closing its socket at once -> { device }. Irreversible. */++ revoke: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/revoke`, json: {} }, o),++ },++ profiles: {++ /** The public robot profiles -> { profiles }. */++ list: (o = {}) => call({ path: '/api/v1/profiles' }, o),++ get: (id, o = {}) => orNull(call({ path: `/api/v1/profiles/${enc(id)}` }, o)),++ },++ kits: {++ /** The public "Get a robot" catalogue -> { kits }. */++ list: (o = {}) => call({ path: '/api/v1/kits' }, o),++ get: (id, o = {}) => orNull(call({ path: `/api/v1/kits/${enc(id)}` }, o)),++ },++ /** The agent redeems a one-time pairing code (the code is the credential; never retried) -> the pair result. */++ pair: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/pair', json: input, ...once }, o),++ /** The same client acting for someone else: as('usr_…') or as({ actingSubject }). */++ as(who) {++ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };++ return createBotClient(client, { ...defaults, ...next });++ },++ headers: () => actingHeaders(base),++ };++}++++module.exports = { createBotClient, actingHeaders };++},++// ── 16: src/search.js ───────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -1836,7 +1975,7 @@ function createSearchClient(client, defaults = {}) {module.exports = { createSearchClient, queryOf };},--// ── 16: src/chat.js ─────────────────────────────────────────────────++// ── 17: src/chat.js ─────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -1914,7 +2053,7 @@ function createChatClient(client, defaults = {}) {module.exports = { createChatClient };},--// ── 17: src/ai.js ───────────────────────────────────────────────────++// ── 18: src/ai.js ───────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -1989,7 +2128,7 @@ function createAiClient(client, defaults = {}) {module.exports = { createAiClient, TERMINAL, OPS };},--// ── 18: src/jobs.js ─────────────────────────────────────────────────++// ── 19: src/jobs.js ─────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2027,7 +2166,7 @@ function (module, exports) {*/const { OpenVibeError, isOpenVibeError } = __load(3);const { newIdempotencyKey } = __load(5);--const { appendFiles } = __load(19);++const { appendFiles } = __load(20);const { parseSSE } = __load(11);const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);@@@ -2191,7 +2330,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'openmodule.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };},--// ── 19: src/core/form.js ────────────────────────────────────────────++// ── 20: src/core/form.js ────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2225,7 +2364,7 @@ function appendFiles(form, list, where) {module.exports = { isBlob, isUpload, appendFiles };},--// ── 20: src/tools.js ────────────────────────────────────────────────++// ── 21: src/tools.js ────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2259,8 +2398,8 @@ function (module, exports) {*/const { OpenVibeError, isOpenVibeError } = __load(3);const { newIdempotencyKey } = __load(5);--const { isUpload, appendFiles } = __load(19);--const { createJobsClient } = __load(18);++const { isUpload, appendFiles } = __load(20);++const { createJobsClient } = __load(19);const enc = encodeURIComponent;const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;@@@ -2494,7 +2633,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'opemodule.exports = { createToolsClient, ToolRunError, isToolRunError };},--// ── 21: src/projects.js ─────────────────────────────────────────────++// ── 22: src/projects.js ─────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2610,7 +2749,7 @@ function createProjectsClient(client, { baseUrl } = {}) {module.exports = { createProjectsClient };},--// ── 22: src/geo.js ──────────────────────────────────────────────────++// ── 23: src/geo.js ──────────────────────────────────────────────────function (module, exports) {'use strict';/**@@@ -2689,7 +2828,8 @@ export const {createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,-- registry, modules, realtime, media, community, search,-- chat, ai, jobs, tools, projects, geo,++ registry, modules, realtime, media, community, bot,++ search, chat, ai, jobs, tools, projects,++ geo,} = sdk;export default sdk;nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/bot.js. Do not edit.++import cjs from '../src/bot.js';++++export const {++ createBotClient, actingHeaders,++} = cjs;++export default cjs;@@@ -6,8 +6,8 @@ export const {startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,registry, identity, modules, events, realtime, media,-- community, search, chat, ai, jobs, tools,-- projects, vip, openre, geo, usage, commerce,-- telemetry,++ community, bot, search, chat, ai, jobs,++ tools, projects, vip, openre, geo, usage,++ commerce, telemetry,} = cjs;export default cjs;@@@ -16,6 +16,7 @@ module.exports = {realtime: require('./src/realtime'),media: require('./src/media'),community: require('./src/community'),++ bot: require('./src/bot'),search: require('./src/search'),chat: require('./src/chat'),ai: require('./src/ai'),@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.29.0",++ "version": "0.30.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.29.0",++ "version": "0.30.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,7 +1,7 @@{"name": "openvibe-sdk",-- "version": "0.29.0",-- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",++ "version": "0.30.0",++ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts","browser": "browser.js",@@@ -71,6 +71,11 @@"import": "./esm/community.mjs","default": "./src/community.js"},++ "./bot": {++ "types": "./types/bot.d.ts",++ "import": "./esm/bot.mjs",++ "default": "./src/bot.js"++ },"./search": {"types": "./types/search.d.ts","import": "./esm/search.mjs",@@@ -3,7 +3,7 @@/*** browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK* (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,-- * community, jobs, tools, projects), for pages with no build step:++ * community, bot, jobs, tools, projects), for pages with no build step:** <script type="module">* import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';@@@ -24,6 +24,7 @@ const ENTRIES = {'realtime.mjs': '../src/realtime.js','media.mjs': '../src/media.js','community.mjs': '../src/community.js',++ 'bot.mjs': '../src/bot.js','search.mjs': '../src/search.js','limits.mjs': '../src/limits.js','govern.mjs': '../src/govern.js',nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++/**++ * openvibe-sdk/bot: OpenVibe.Bot robots (/api/v1). Browser-safe.++ *++ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or++ * a Bearer user JWT); first-party services call with a Network service token (audience++ * openvibe.bot, bot.robot.read / bot.robot.manage / bot.robot.control) and say who they act for:++ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject++ * Bot reads X-OV-Subject on the routes a service may act for a person (create a robot, manage,++ * control, streaming). A person's own token acts only as themself: Bot refuses a mismatch rather++ * than ignoring it, so `as()` is for services.++ *++ * Public reads need no token at all: profiles and the robot kits catalogue (GET /profiles,++ * /kits). Pairing (POST /pair) is the agent redeeming a one-time code: the code is the credential,++ * and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).++ * Every other POST carries an Idempotency-Key (the client generates one when retries are on).++ *++ * Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device++ * bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch++ * (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed++ * `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are++ * `devices.rotate` / `devices.revoke`.++ */++const { isOpenVibeError } = require('./core/errors');++const { isActingSubjectId } = require('./core/ids');++const { paginate } = require('./core/paginate');++++const enc = encodeURIComponent;++++/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */++function actingHeaders({ actingSubject } = {}) {++ const h = {};++ if (actingSubject) {++ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');++ h['X-OV-Subject'] = actingSubject;++ }++ return h;++}++++function createBotClient(client, defaults = {}) {++ const { baseUrl } = defaults;++ const pick = (o) => ({ actingSubject: o.actingSubject, signal: o.signal });++ const base = pick(defaults);++++ function call(opts, perCall = {}) {++ const acting = { ...base };++ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;++ return client.json({++ service: 'bot', baseUrl, audience: 'openvibe.bot', ...opts,++ headers: { ...actingHeaders(acting), ...opts.headers }, signal: acting.signal,++ });++ }++ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });++ const once = { idempotencyKey: false }; // a one-time code is its own idempotency: never repeat the call++++ const robots = {++ /** { robots } — a person's own robots; a service must name owner (or act as them). */++ list: (query = {}, o = {}) => call({ path: '/api/v1/robots', query }, o),++ /** { robot, role } or null when the robot is gone/unknown. */++ get: (id, o = {}) => orNull(call({ path: `/api/v1/robots/${enc(id)}` }, o)),++ /** { owner?, name?, profile_id?, access_policy?, limits? } -> { robot, pairing } (a one-time code). */++ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/robots', json: input }, o),++ /** { name?, access_policy?, limits? } -> { robot }. */++ update: (id, patch = {}, o = {}) => call({ method: 'PATCH', path: `/api/v1/robots/${enc(id)}`, json: patch }, o),++ /** Irreversible -> null (204). */++ delete: (id, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}` }, o),++ /** A fresh one-time pairing code -> { code, expires_at, installer }. */++ pairingCode: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/pairing-code`, json: {} }, o),++ operators: {++ /** { operators } — owner or operator. */++ list: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/operators` }, o),++ /** { subject, role? } -> { operators } (the owner). */++ add: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/operators`, json: input }, o),++ remove: (id, subject, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}/operators/${enc(subject)}` }, o),++ },++ /** { devices } — each with `online`. */++ devices: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/devices` }, o),++ /** { audit, next_before } — newest first, paged by `before` (an audit id). The owner, or a service with read. */++ audit: (id, query = {}, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/audit`, query }, o),++ /** Every audit entry, newest first: for await (const entry of robots.iterateAudit(id)) … */++ iterateAudit(id, query = {}, o = {}) {++ const limit = query.limit || 50;++ return paginate(async (before) => {++ const page = await robots.audit(id, { ...query, limit, before }, o);++ return { items: page.audit || [], next: page.next_before };++ }, { cursor: query.before ?? null });++ },++ /** Latch the e-stop -> { robot }. A service needs bot.robot.read and bot.robot.control. */++ estop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop`, json: {} }, o),++ /** Clear the e-stop, owner only (a service needs bot.robot.manage) -> { robot }. */++ clearEstop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop/clear`, json: {} }, o),++ /**++ * { id, kind, value?, ms? } -> { robot_id, result, … }; `id` is Bot's own idempotency key (a++ * repeated id is answered from the cache). A refused command is an OpenVibeError (`code`++ * bot.not_an_operator, bot.cooldown, …) with the HTTP status Bot gives it.++ */++ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/commands`, json: input }, o),++ streaming: {++ /** The owner's OpenRe toggles (Bot stores no copy). A member read. */++ get: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/streaming` }, o),++ /** { to?, on? } (a service must also act for the owner) -> the new streaming state. */++ set: (id, input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/streaming`, json: input }, o),++ },++ };++++ return {++ robots,++ devices: {++ /** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */++ rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),++ /** Revoke a device, closing its socket at once -> { device }. Irreversible. */++ revoke: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/revoke`, json: {} }, o),++ },++ profiles: {++ /** The public robot profiles -> { profiles }. */++ list: (o = {}) => call({ path: '/api/v1/profiles' }, o),++ get: (id, o = {}) => orNull(call({ path: `/api/v1/profiles/${enc(id)}` }, o)),++ },++ kits: {++ /** The public "Get a robot" catalogue -> { kits }. */++ list: (o = {}) => call({ path: '/api/v1/kits' }, o),++ get: (id, o = {}) => orNull(call({ path: `/api/v1/kits/${enc(id)}` }, o)),++ },++ /** The agent redeems a one-time pairing code (the code is the credential; never retried) -> the pair result. */++ pair: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/pair', json: input, ...once }, o),++ /** The same client acting for someone else: as('usr_…') or as({ actingSubject }). */++ as(who) {++ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };++ return createBotClient(client, { ...defaults, ...next });++ },++ headers: () => actingHeaders(base),++ };++}++++module.exports = { createBotClient, actingHeaders };@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.29.0';++const SDK_VERSION = '0.30.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,238 @@++'use strict';++/**++ * openvibe-sdk/bot: the acting header, robots/operators/devices/streaming/commands/audit, the++ * public kits and profiles, pairing, and the Contracts fixtures the requests and answers follow.++ */++const assert = require('node:assert/strict');++const fs = require('node:fs');++const path = require('node:path');++const { stubServer, send, problem, run } = require('./helpers');++const { createClient } = require('../src/core');++const { createBotClient, actingHeaders } = require('../src/bot');++++const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const GST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const ROB = 'rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X';++const DEV = 'dev_01J8Z4N0A1B2C3D4E5F6G7H8J9';++++const FIX = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures');++let contracts = null;++try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }++const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));++++/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */++function server() {++ return stubServer((req, res, body) => {++ const u = new URL(req.url, 'http://x');++ const p = u.pathname;++ const m = req.method;++ const fixture = (status, dir, file) => send(res, status, loadFixture(dir, file));++++ if (m === 'GET' && p === `/api/v1/robots/${ROB}/audit`) {++ const before = u.searchParams.get('before');++ const limit = Number(u.searchParams.get('limit')) || 50;++ const rows = [5, 4, 3, 2, 1]++ .filter((id) => before == null || id < Number(before))++ .slice(0, limit)++ .map((id) => ({ id, kind: 'drive' }));++ return send(res, 200, { audit: rows, next_before: rows.length ? rows[rows.length - 1].id : null });++ }++ if (m === 'GET' && p === `/api/v1/robots/${ROB}/streaming`) return send(res, 200, { to: 'openre', on: false });++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/streaming`) return send(res, 200, { to: 'openre', on: true });++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/commands`) {++ const input = JSON.parse(body.toString() || '{}');++ if (input.kind === 'summon') return problem(res, 403, 'bot.not_an_operator', 'you are not an operator of this robot');++ return send(res, 200, { robot_id: ROB, result: 'ack', id: input.id });++ }++ if (m === 'POST' && p === '/api/v1/robots') return fixture(201, 'bot.robot-manage-result', 'created.json');++ if (m === 'GET' && p === '/api/v1/robots') return fixture(200, 'bot.robot-read-result', 'list.json');++ if (m === 'GET' && p === `/api/v1/robots/${ROB}`) return fixture(200, 'bot.robot-read-result', 'robot.json');++ if (m === 'GET' && p === `/api/v1/robots/${ROB}/operators`) return fixture(200, 'bot.robot-manage-result', 'operators.json');++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/operators`) return fixture(201, 'bot.robot-manage-result', 'operators.json');++ if (m === 'DELETE' && p.startsWith(`/api/v1/robots/${ROB}/operators/`)) return fixture(200, 'bot.robot-manage-result', 'operators.json');++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/pairing-code`) return send(res, 201, { code: 'K7QM-3XRT', expires_at: '2026-09-29T19:20:00.000Z', installer: 'curl …' });++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/estop`) return fixture(200, 'bot.robot-control-result', 'latched.json');++ if (m === 'POST' && p === `/api/v1/robots/${ROB}/estop/clear`) return fixture(200, 'bot.robot-control-result', 'latched.json');++ if (m === 'PATCH' && p === `/api/v1/robots/${ROB}`) return send(res, 200, { robot: loadFixture('bot.robot', 'rover.json') });++ if (m === 'DELETE' && p === `/api/v1/robots/${ROB}`) { res.writeHead(204); return res.end(); }++ if (m === 'GET' && p === `/api/v1/robots/${ROB}/devices`) return send(res, 200, { devices: [loadFixture('bot.device', 'agent.json')] });++ if (m === 'POST' && p === `/api/v1/devices/${DEV}/rotate`) return fixture(200, 'bot.device-connect-result', 'rotated.json');++ if (m === 'POST' && p === `/api/v1/devices/${DEV}/revoke`) return fixture(200, 'bot.device-connect-result', 'revoked.json');++ if (m === 'POST' && p === '/api/v1/pair') return fixture(201, 'bot.pair-result', 'paired.json');++ if (m === 'GET' && p === '/api/v1/profiles') return send(res, 200, { profiles: [loadFixture('bot.robot-profile', 'adeept-file.json')] });++ if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });++ if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });++ if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });++ if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');++ if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');++ if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');++ return send(res, 200, { ok: true });++ });++}++++const hdr = (r, k) => r.headers[k.toLowerCase()];++++run([++ ['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {++ const srv = await server();++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });++ const manage = loadFixture('bot.robot-manage-request', 'create.json');++ const created = await bot.robots.create(manage);++ const r = srv.requests[0];++ assert.equal(r.method, 'POST');++ assert.equal(r.url, '/api/v1/robots');++ assert.equal(hdr(r, 'authorization'), 'Bearer svc-token');++ assert.equal(hdr(r, 'x-ov-subject'), USR);++ assert.ok(hdr(r, 'idempotency-key'), 'a mutation carries an Idempotency-Key');++ assert.equal(hdr(r, 'x-ov-origin'), undefined, 'Bot takes X-OV-Subject only, not the Community headers');++ assert.deepEqual(JSON.parse(r.body), manage, 'the manage-request fixture passes through unchanged');++ assert.deepEqual(created, loadFixture('bot.robot-manage-result', 'created.json'));++ assert.deepEqual(bot.headers(), { 'X-OV-Subject': USR });++ await srv.close();++ }],++++ ['as() scopes the subject; a bare client sends none; bad subjects are refused', async () => {++ const srv = await server();++ const base = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });++ await base.as(GST).robots.list({ owner: GST });++ assert.equal(hdr(srv.requests[0], 'x-ov-subject'), GST);++ assert.equal(srv.requests[0].url, `/api/v1/robots?owner=${GST}`);++ await base.robots.list({ owner: USR });++ assert.equal(hdr(srv.requests[1], 'x-ov-subject'), USR, 'as() did not leak into the base client');++ const plain = createBotClient(createClient({ baseUrls: { bot: srv.url } }));++ await plain.robots.list({ owner: USR });++ assert.equal(hdr(srv.requests[2], 'x-ov-subject'), undefined);++ assert.deepEqual(actingHeaders({}), {});++ assert.throws(() => actingHeaders({ actingSubject: '42' }), TypeError);++ assert.throws(() => createBotClient(createClient({ baseUrls: { bot: srv.url } }), { actingSubject: 'nope' }).headers(), TypeError);++ await srv.close();++ }],++++ ['reads: list, get, null on 404, and the audit page and its iterator', async () => {++ const srv = await server();++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url } }));++ assert.deepEqual(await bot.robots.list({ owner: USR }), loadFixture('bot.robot-read-result', 'list.json'));++ assert.deepEqual(await bot.robots.get(ROB), loadFixture('bot.robot-read-result', 'robot.json'));++ assert.equal(await bot.robots.get('rob_missing'), null);++ const page = await bot.robots.audit(ROB, { limit: 2 });++ assert.deepEqual(page, { audit: [{ id: 5, kind: 'drive' }, { id: 4, kind: 'drive' }], next_before: 4 });++ const ids = [];++ for await (const entry of bot.robots.iterateAudit(ROB, { limit: 2 })) ids.push(entry.id);++ assert.deepEqual(ids, [5, 4, 3, 2, 1], 'newest first, no repeat, no extra page');++ await srv.close();++ }],++++ ['commands: bot.command@1 passes through; a refusal is a typed error', async () => {++ const srv = await server();++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });++ const drive = loadFixture('bot.command', 'drive.json');++ const ack = await bot.robots.command(ROB, drive);++ assert.deepEqual(ack, { robot_id: ROB, result: 'ack', id: 'op-7' });++ const r = srv.requests[0];++ assert.equal(r.url, `/api/v1/robots/${ROB}/commands`);++ assert.equal(hdr(r, 'x-ov-subject'), USR);++ assert.deepEqual(JSON.parse(r.body), drive, 'the command frame passes through unchanged');++ await assert.rejects(++ () => bot.robots.command(ROB, { id: 'op-8', kind: 'summon' }),++ (err) => err.status === 403 && err.code === 'bot.not_an_operator',++ );++ await srv.close();++ }],++++ ['manage, control, streaming and device calls use the routes Bot serves', async () => {++ const srv = await server();++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });++ await bot.robots.update(ROB, { name: 'R2' });++ assert.equal(await bot.robots.delete(ROB), null, '204 -> null');++ await bot.robots.pairingCode(ROB);++ await bot.robots.operators.add(ROB, { subject: USR, role: 'operator' });++ await bot.robots.operators.remove(ROB, USR);++ await bot.robots.devices(ROB);++ await bot.robots.estop(ROB);++ await bot.robots.clearEstop(ROB);++ await bot.robots.streaming.get(ROB);++ await bot.robots.streaming.set(ROB, { to: 'openre', on: true });++ await bot.devices.rotate(DEV);++ await bot.devices.revoke(DEV);++ const seen = srv.requests.map((r) => `${r.method} ${r.url}`);++ assert.deepEqual(seen, [++ `PATCH /api/v1/robots/${ROB}`,++ `DELETE /api/v1/robots/${ROB}`,++ `POST /api/v1/robots/${ROB}/pairing-code`,++ `POST /api/v1/robots/${ROB}/operators`,++ `DELETE /api/v1/robots/${ROB}/operators/${USR}`,++ `GET /api/v1/robots/${ROB}/devices`,++ `POST /api/v1/robots/${ROB}/estop`,++ `POST /api/v1/robots/${ROB}/estop/clear`,++ `GET /api/v1/robots/${ROB}/streaming`,++ `POST /api/v1/robots/${ROB}/streaming`,++ `POST /api/v1/devices/${DEV}/rotate`,++ `POST /api/v1/devices/${DEV}/revoke`,++ ]);++ assert.deepEqual(JSON.parse(srv.requests[0].body), { name: 'R2' });++ assert.deepEqual(JSON.parse(srv.requests[3].body), { subject: USR, role: 'operator' });++ assert.deepEqual(JSON.parse(srv.requests[9].body), { to: 'openre', on: true });++ await srv.close();++ }],++++ ['public profiles and kits, and pairing (a one-time code is never given an Idempotency-Key)', async () => {++ const srv = await server();++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url } }));++ assert.deepEqual(await bot.profiles.get('adeept.adr036'), { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });++ assert.equal(await bot.profiles.get('missing'), null);++ assert.equal((await bot.profiles.list()).profiles.length, 1);++ assert.deepEqual(await bot.kits.list(), { kits: [{ id: 'starter' }] });++ assert.deepEqual(await bot.kits.get('starter'), { kit: { id: 'starter' } });++ const pair = loadFixture('bot.pair-request', 'code-only.json');++ assert.deepEqual(await bot.pair(pair), loadFixture('bot.pair-result', 'paired.json'));++ const r = srv.requests[srv.requests.length - 1];++ assert.equal(r.url, '/api/v1/pair');++ assert.deepEqual(JSON.parse(r.body), pair);++ assert.equal(hdr(r, 'idempotency-key'), undefined, 'a one-time code is its own idempotency: no retry key');++ await srv.close();++ }],++++ ['browser mode: cookie credentials, no Authorization and no X-OV headers', async () => {++ const srv = await server();++ const seen = [];++ const fetchSpy = (url, init) => { seen.push(init); return fetch(url, init); };++ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, credentials: 'include', fetch: fetchSpy }));++ await bot.robots.get(ROB);++ const r = srv.requests[0];++ assert.equal(hdr(r, 'authorization'), undefined);++ assert.ok(!Object.keys(r.headers).some((k) => k.startsWith('x-ov-')));++ assert.equal(seen[0].credentials, 'include');++ await srv.close();++ }],++++ ['the Contracts bot fixtures validate (requests and answers the client speaks)', async () => {++ if (!contracts) { console.log('bot fixtures: skipped (openvibe-contracts not installed)'); return; }++ const cases = [++ ['bot.robot', 'bot.robot@1'],++ ['bot.device', 'bot.device@1'],++ ['bot.command', 'bot.command@1'],++ ['bot.command-result', 'bot.command-result@1'],++ ['bot.robot-manage-request', 'bot.robot-manage-request@1'],++ ['bot.robot-manage-result', 'bot.robot-manage-result@1'],++ ['bot.robot-read-request', 'bot.robot-read-request@1'],++ ['bot.robot-read-result', 'bot.robot-read-result@1'],++ ['bot.robot-control-result', 'bot.robot-control-result@1'],++ ['bot.device-connect-result', 'bot.device-connect-result@1'],++ ['bot.device-message', 'bot.device-message@1'],++ ['bot.job-dispatch-result', 'bot.job-dispatch-result@1'],++ ['bot.pair-request', 'bot.pair-request@1'],++ ['bot.pair-result', 'bot.pair-result@1'],++ ['bot.robot-profile', 'bot.robot-profile@1'],++ ];++ for (const [dir, ref] of cases) {++ for (const f of fs.readdirSync(path.join(FIX, dir, 'valid'))) {++ const body = JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', f), 'utf8'));++ assert.deepEqual(contracts.validate(ref, body), { valid: true, errors: [] }, `${ref} valid/${f}`);++ }++ for (const f of fs.readdirSync(path.join(FIX, dir, 'invalid'))) {++ const body = JSON.parse(fs.readFileSync(path.join(FIX, dir, 'invalid', f), 'utf8'));++ assert.equal(contracts.validate(ref, body).valid, false, `${ref} invalid/${f}`);++ }++ }++ }],++]);@@@ -78,7 +78,7 @@ run([assert.deepEqual(problems, []);assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);const rel = [...scanned].map((f) => path.relative(ROOT, f));-- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);}for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {@@@ -29,7 +29,7 @@ run([}const ids = collect().map((m) => m.id);for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);-- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/tools.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);++ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/tools.js', 'src/projects.js', 'src/realtime.js', 'src/bot.js']) assert.ok(ids.includes(must), must);}],['exports match the browser entry', async () => {@@@ -37,7 +37,7 @@ run([const cjs = require('../browser.js');assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());-- for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'tools', 'projects']) {++ for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'bot', 'jobs', 'tools', 'projects']) {assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);}assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');@@@ -22,7 +22,7 @@ run([const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });const info = JSON.parse(out)[0];const files = info.files.map((f) => f.path);-- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {++ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {assert.ok(files.includes(must), `tarball contains ${must}`);}assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');@@@ -66,7 +66,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 40);++ assert.equal(subpaths.length, 41);assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,94 @@++import type { OpenVibeClient } from './core';++++export interface ActingOptions {++ /** usr_… or gst_… (services only). */++ actingSubject?: string;++}++export type CallOptions = ActingOptions & { signal?: AbortSignal };++++export interface Robot {++ id: string;++ name?: string;++ profile_id?: string;++ profile_version?: number;++ access_policy?: string;++ limits?: Record<string, unknown>;++ estop?: { latched: boolean; by: string | null; at: string | null };++ created_at?: string;++ updated_at?: string;++ [field: string]: unknown;++}++export interface Device {++ id: string;++ robot_ids?: string[];++ name?: string;++ kind?: string;++ agent_version?: string;++ drivers?: string[];++ capabilities?: Record<string, unknown>;++ last_seen?: string | null;++ revoked_at?: string | null;++ online?: boolean;++ [field: string]: unknown;++}++export interface Pairing { code: string; expires_at?: string; installer?: string; [field: string]: unknown; }++export interface Operator { subject: string; role?: string; [field: string]: unknown; }++export interface AuditEntry { id: number; robot_id?: string; device_id?: string | null; operator_subject?: string; operator_kind?: string; role?: string; kind?: string; value?: unknown; result?: string; reason?: string | null; latency_ms?: number | null; at?: string; [field: string]: unknown; }++export interface CommandInput { id: string; kind: string; value?: unknown; ms?: number; }++export interface CommandResult { robot_id: string; result: string; code?: string; reason?: string | null; [field: string]: unknown; }++++export interface RobotsApi {++ list(query?: { owner?: string }, opts?: CallOptions): Promise<{ robots: Robot[] }>;++ get(id: string, opts?: CallOptions): Promise<{ robot: Robot; role: string } | null>;++ create(input: { owner?: string; name?: string; profile_id?: string; access_policy?: string; limits?: Record<string, unknown> }, opts?: CallOptions): Promise<{ robot: Robot; pairing: Pairing }>;++ update(id: string, patch: { name?: string; access_policy?: string; limits?: Record<string, unknown> }, opts?: CallOptions): Promise<{ robot: Robot }>;++ delete(id: string, opts?: CallOptions): Promise<unknown>;++ pairingCode(id: string, opts?: CallOptions): Promise<Pairing>;++ operators: {++ list(id: string, opts?: CallOptions): Promise<{ operators: Operator[] }>;++ add(id: string, input: { subject: string; role?: string }, opts?: CallOptions): Promise<{ operators: Operator[] }>;++ remove(id: string, subject: string, opts?: CallOptions): Promise<{ operators: Operator[] }>;++ };++ devices(id: string, opts?: CallOptions): Promise<{ devices: Device[] }>;++ audit(id: string, query?: { before?: number; limit?: number }, opts?: CallOptions): Promise<{ audit: AuditEntry[]; next_before: number | null }>;++ iterateAudit(id: string, query?: { before?: number; limit?: number }, opts?: CallOptions): AsyncGenerator<AuditEntry, void, unknown>;++ estop(id: string, opts?: CallOptions): Promise<{ robot: Robot }>;++ clearEstop(id: string, opts?: CallOptions): Promise<{ robot: Robot }>;++ command(id: string, input: CommandInput, opts?: CallOptions): Promise<CommandResult>;++ streaming: {++ get(id: string, opts?: CallOptions): Promise<Record<string, unknown>>;++ set(id: string, input: { to?: unknown; on?: boolean; owner?: string }, opts?: CallOptions): Promise<Record<string, unknown>>;++ };++}++export interface DevicesApi {++ rotate(id: string, opts?: CallOptions): Promise<{ device: Device; credential?: string; [field: string]: unknown }>;++ revoke(id: string, opts?: CallOptions): Promise<{ device: Device }>;++}++export interface ProfilesApi {++ list(opts?: CallOptions): Promise<{ profiles: Array<Record<string, unknown>> }>;++ get(id: string, opts?: CallOptions): Promise<{ profile: Record<string, unknown> } | null>;++}++export interface KitsApi {++ list(opts?: CallOptions): Promise<{ kits: Array<Record<string, unknown>> }>;++ get(id: string, opts?: CallOptions): Promise<{ kit: Record<string, unknown> } | null>;++}++export interface PairInput {++ code: string;++ robot?: string;++ agent_version?: string;++ device_kind?: string;++ drivers?: string[];++ capabilities?: Record<string, unknown>;++ name?: string;++}++export interface BotClient {++ robots: RobotsApi;++ devices: DevicesApi;++ profiles: ProfilesApi;++ kits: KitsApi;++ pair(input: PairInput, opts?: CallOptions): Promise<{ device_id: string; credential?: string; robot_id?: string; profile?: Record<string, unknown> | null; [field: string]: unknown }>;++ as(who: string | ActingOptions): BotClient;++ headers(): Record<string, string>;++}++export declare function createBotClient(client: OpenVibeClient, defaults?: ActingOptions & { baseUrl?: string }): BotClient;++export declare function actingHeaders(opts?: ActingOptions): Record<string, string>;@@@ -5,6 +5,7 @@ import * as modules from './modules';import * as realtime from './realtime';import * as media from './media';import * as community from './community';++import * as bot from './bot';import * as search from './search';import * as chat from './chat';import * as ai from './ai';@@@ -21,6 +22,7 @@ export * as modules from './modules';export * as realtime from './realtime';export * as media from './media';export * as community from './community';++export * as bot from './bot';export * as search from './search';export * as chat from './chat';export * as ai from './ai';@@@ -31,6 +33,6 @@ export * as geo from './geo';declare const sdk: typeof core & {auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;-- media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;++ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;};export default sdk;@@@ -7,6 +7,7 @@ export * as events from './events';export * as realtime from './realtime';export * as media from './media';export * as community from './community';++export * as bot from './bot';export * as search from './search';export * as chat from './chat';export * as ai from './ai';
@@ -7,6 +7,17 @@ release may change an API and says so here.
@@ -7,6 +7,17 @@ release may change an API and says so here.
+## 0.30.0 (2026-10-04)
+
+- **`openvibe-sdk/bot` (new, both; plan B8):** the OpenVibe.Bot client. `createBotClient(client, {actingSubject?})`
+ wraps `robots` — `list`/`get`/`create`/`update`/`delete`/`pairingCode`, `operators.list`/`add`/`remove`,
+ `devices` (listing), `streaming.get`/`set`, `audit`/`iterateAudit`, `estop`/`clearEstop` and `command`
+ (`bot.command@1`, answered by Bot's own idempotency cache) — plus `devices.rotate`/`revoke`, the public
+ `profiles`/`kits` reads, and `pair` (the agent redeems a one-time code; never retried). A service acts for a
+ person with `X-OV-Subject` (`bot.robot.read`/`manage`/`control`, `bot.device.connect`); a browser calls as the
+ signed-in person. Browser-safe, with its ESM entry, types and the browser bundle, and a test over the
+ Contracts bot fixtures. The operator WebSocket and the internal Run→Bot job dispatch are not wrapped.
+
## 0.29.0 (2026-10-04)
## 0.29.0 (2026-10-04)
- **`createTestDb` reuses a migrated snapshot (`openvibe-sdk/testing`, minor):** on PGlite, the first test process
- **`createTestDb` reuses a migrated snapshot (`openvibe-sdk/testing`, minor):** on PGlite, the first test process
@@ -171,6 +171,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -171,6 +171,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/media` | both (credentials: server) | `createMediaClient(client, {app, apiKey?, actingUserId?})`: `files.upload/list/iterate/get/delete`; `mediaUrls(origin)` public URL helpers; `createObjectsClient({app, baseUrl?, tokenClient \| apiKey \| client})`: object API v2 `upload` (single or multipart by size, resumable), `resume`, `get`, `signedUrl`, `delete`, `list`, `iterate`, and `jobs.create/get/list/approve/cancel/wait`; Media's origin from the platform descriptor when `baseUrl` is omitted |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
| `openvibe-sdk/community` | both | `createCommunityClient(client, {actingSubject?, origin?, sourceRef?, staff?})`: `pastes.list/iterate/get/create/update/delete/fork/like/copy/versions/byUser/config`, `pastes.comments.list/create/delete`, `as(subject)` |
+| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
@@ -191,7 +192,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -191,7 +192,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor |
| `openvibe-sdk/node` | server | `nodeCapabilities()` and `validateNodeCapabilities()` for `platform.node-capabilities@1`; `nodeOffers()` derives resource and runtime offers from a node descriptor |
| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |
| `openvibe-sdk/service` | server | the service kit: `gracefulStop({name, server, stop, close, drainMs, deadlineMs, deadlineExitCode, beforeDrain, handles})` → `{stop, stopping}`, `within(ms, p)`; `createServiceError(name)`, `ServiceError`, `asServiceError`, `run`, `wrap`, `sendError`, `jsonBody({limit})`, `privateNoStore`, `jsonErrors()`; re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
| `openvibe-sdk/testing` | Node | `createMockPlatform()`: fake Network (incl. developer apps and projects), Events, Media, Tools jobs and the Tools platform API on an in-process `fetch` |
-| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, jobs, tools, projects |
+| `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects |
Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
Server-only subpaths are declared `"browser": null` in the exports map, so a bundler refuses them in a browser build and doesn't ship them by accident.
@@ -423,7 +424,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m
@@ -423,7 +424,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m
</script>
</script>
```
```
-`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `jobs`, `tools` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.
+`browser/openvibe-sdk.mjs` is generated by `node scripts/browser-bundle.js` from the CommonJS sources reachable from `browser.js` (no dependencies, no transpiling) and checked in. It exports core at the top level and `auth` (browser build), `registry`, `modules`, `realtime`, `media`, `community`, `bot`, `jobs`, `tools` and `projects` as namespaces. `test/bundle.test.js` fails when it is stale, and the browser secret scan covers it.
## Versioning
## Versioning
@@ -440,6 +441,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m
@@ -440,6 +441,7 @@ Moving an existing service from SQLite: [docs/migrating-to-postgresql.md](docs/m
- **Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.
- **Live, Billing and Games** from the original charter come when those services publish their capabilities in Contracts. Chat is wrapped over REST as the token's person (`openvibe-sdk/chat`, v0.11.0), but it has no app principal (`chat.message.send` is `first-party`) and the mock platform has no Chat.
- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.
- **Tools** are not wrapped one by one: `openvibe-sdk/tools` runs any tool with `api: true` by id, and its descriptor (`tools.get(id)`) gives the input schema, files and limits. `openvibe-sdk/jobs` still submits any job `type` directly. The YouTube downloader is page-only (`api: false`), so it is not in the SDK.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
- **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport.
+- **Bot:** the REST robots, public kits/profiles and pairing routes are wrapped (`openvibe-sdk/bot`). The operator WebSocket (`/control`, Realtime frames `bot.command@1`), device bootstrap (`POST /devices/bind`, a Network node token) and the internal Run→Bot job dispatch (`bot.job.dispatch`, service-to-service and never delegated to people or apps) are not.
## Capabilities
## Capabilities
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.29.0",
+ "version": "0.30.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -15,7 +15,8 @@
@@ -15,7 +15,8 @@
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"events: publish, pull, app events, webhook verification (v2), outbox and inbox",
"realtime SSE",
"realtime SSE",
"media files and objects v2",
"media files and objects v2",
- "community, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients",
+ "community, bot, search, chat, ai, jobs, tools, projects, modules, identity, openre, geo and vip clients",
+ "bot (openvibe-sdk/bot): OpenVibe.Bot robots, operators, devices, streaming, commands and audit, the public kits and profiles, and pairing",
"per-actor limiter for services (openvibe-sdk/limits)",
"per-actor limiter for services (openvibe-sdk/limits)",
"the OpenVibe Frame for any app",
"the OpenVibe Frame for any app",
"mock platform for tests (openvibe-sdk/testing)",
"mock platform for tests (openvibe-sdk/testing)",
@@ -13,6 +13,7 @@ module.exports = {
@@ -13,6 +13,7 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.29.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.30.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
-// (auth, registry, modules, realtime, media, community, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
+// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
const __defs = [
const __defs = [
// ── 0: browser.js ──────────────────────────────────────────────────
// ── 0: browser.js ──────────────────────────────────────────────────
@@ -20,13 +20,14 @@ module.exports = {
@@ -20,13 +20,14 @@ module.exports = {
realtime: __load(11),
realtime: __load(11),
media: __load(12),
media: __load(12),
community: __load(14),
community: __load(14),
- search: __load(15),
- chat: __load(16),
- ai: __load(17),
- jobs: __load(18),
- tools: __load(20),
- projects: __load(21),
- geo: __load(22),
+ bot: __load(15),
+ search: __load(16),
+ chat: __load(17),
+ ai: __load(18),
+ jobs: __load(19),
+ tools: __load(21),
+ projects: __load(22),
+ geo: __load(23),
};
};
},
},
// ── 1: src/core/index.js ───────────────────────────────────────────
// ── 1: src/core/index.js ───────────────────────────────────────────
@@ -41,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -41,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.29.0';
+const SDK_VERSION = '0.30.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -1749,7 +1750,145 @@ function createCommunityClient(client, defaults = {}) {
@@ -1749,7 +1750,145 @@ function createCommunityClient(client, defaults = {}) {
module.exports = { createCommunityClient, actingHeaders };
module.exports = { createCommunityClient, actingHeaders };
},
},
-// ── 15: src/search.js ───────────────────────────────────────────────
+// ── 15: src/bot.js ──────────────────────────────────────────────────
+function (module, exports) {
+'use strict';
+/**
+ * openvibe-sdk/bot: OpenVibe.Bot robots (/api/v1). Browser-safe.
+ *
+ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or
+ * a Bearer user JWT); first-party services call with a Network service token (audience
+ * openvibe.bot, bot.robot.read / bot.robot.manage / bot.robot.control) and say who they act for:
+ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject
+ * Bot reads X-OV-Subject on the routes a service may act for a person (create a robot, manage,
+ * control, streaming). A person's own token acts only as themself: Bot refuses a mismatch rather
+ * than ignoring it, so `as()` is for services.
+ *
+ * Public reads need no token at all: profiles and the robot kits catalogue (GET /profiles,
+ * /kits). Pairing (POST /pair) is the agent redeeming a one-time code: the code is the credential,
+ * and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
+ * Every other POST carries an Idempotency-Key (the client generates one when retries are on).
+ *
+ * Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
+ * bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
+ * (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
+ * `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
+ * `devices.rotate` / `devices.revoke`.
+ */
+const { isOpenVibeError } = __load(3);
+const { isActingSubjectId } = __load(5);
+const { paginate } = __load(7);
+
+const enc = encodeURIComponent;
+
+/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
+function actingHeaders({ actingSubject } = {}) {
+ const h = {};
+ if (actingSubject) {
+ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
+ h['X-OV-Subject'] = actingSubject;
+ }
+ return h;
+}
+
+function createBotClient(client, defaults = {}) {
+ const { baseUrl } = defaults;
+ const pick = (o) => ({ actingSubject: o.actingSubject, signal: o.signal });
+ const base = pick(defaults);
+
+ function call(opts, perCall = {}) {
+ const acting = { ...base };
+ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;
+ return client.json({
+ service: 'bot', baseUrl, audience: 'openvibe.bot', ...opts,
+ headers: { ...actingHeaders(acting), ...opts.headers }, signal: acting.signal,
+ });
+ }
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const once = { idempotencyKey: false }; // a one-time code is its own idempotency: never repeat the call
+
+ const robots = {
+ /** { robots } — a person's own robots; a service must name owner (or act as them). */
+ list: (query = {}, o = {}) => call({ path: '/api/v1/robots', query }, o),
+ /** { robot, role } or null when the robot is gone/unknown. */
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/robots/${enc(id)}` }, o)),
+ /** { owner?, name?, profile_id?, access_policy?, limits? } -> { robot, pairing } (a one-time code). */
+ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/robots', json: input }, o),
+ /** { name?, access_policy?, limits? } -> { robot }. */
+ update: (id, patch = {}, o = {}) => call({ method: 'PATCH', path: `/api/v1/robots/${enc(id)}`, json: patch }, o),
+ /** Irreversible -> null (204). */
+ delete: (id, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}` }, o),
+ /** A fresh one-time pairing code -> { code, expires_at, installer }. */
+ pairingCode: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/pairing-code`, json: {} }, o),
+ operators: {
+ /** { operators } — owner or operator. */
+ list: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/operators` }, o),
+ /** { subject, role? } -> { operators } (the owner). */
+ add: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/operators`, json: input }, o),
+ remove: (id, subject, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}/operators/${enc(subject)}` }, o),
+ },
+ /** { devices } — each with `online`. */
+ devices: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/devices` }, o),
+ /** { audit, next_before } — newest first, paged by `before` (an audit id). The owner, or a service with read. */
+ audit: (id, query = {}, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/audit`, query }, o),
+ /** Every audit entry, newest first: for await (const entry of robots.iterateAudit(id)) … */
+ iterateAudit(id, query = {}, o = {}) {
+ const limit = query.limit || 50;
+ return paginate(async (before) => {
+ const page = await robots.audit(id, { ...query, limit, before }, o);
+ return { items: page.audit || [], next: page.next_before };
+ }, { cursor: query.before ?? null });
+ },
+ /** Latch the e-stop -> { robot }. A service needs bot.robot.read and bot.robot.control. */
+ estop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop`, json: {} }, o),
+ /** Clear the e-stop, owner only (a service needs bot.robot.manage) -> { robot }. */
+ clearEstop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop/clear`, json: {} }, o),
+ /**
+ * { id, kind, value?, ms? } -> { robot_id, result, … }; `id` is Bot's own idempotency key (a
+ * repeated id is answered from the cache). A refused command is an OpenVibeError (`code`
+ * bot.not_an_operator, bot.cooldown, …) with the HTTP status Bot gives it.
+ */
+ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/commands`, json: input }, o),
+ streaming: {
+ /** The owner's OpenRe toggles (Bot stores no copy). A member read. */
+ get: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/streaming` }, o),
+ /** { to?, on? } (a service must also act for the owner) -> the new streaming state. */
+ set: (id, input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/streaming`, json: input }, o),
+ },
+ };
+
+ return {
+ robots,
+ devices: {
+ /** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
+ rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
+ /** Revoke a device, closing its socket at once -> { device }. Irreversible. */
+ revoke: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/revoke`, json: {} }, o),
+ },
+ profiles: {
+ /** The public robot profiles -> { profiles }. */
+ list: (o = {}) => call({ path: '/api/v1/profiles' }, o),
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/profiles/${enc(id)}` }, o)),
+ },
+ kits: {
+ /** The public "Get a robot" catalogue -> { kits }. */
+ list: (o = {}) => call({ path: '/api/v1/kits' }, o),
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/kits/${enc(id)}` }, o)),
+ },
+ /** The agent redeems a one-time pairing code (the code is the credential; never retried) -> the pair result. */
+ pair: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/pair', json: input, ...once }, o),
+ /** The same client acting for someone else: as('usr_…') or as({ actingSubject }). */
+ as(who) {
+ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };
+ return createBotClient(client, { ...defaults, ...next });
+ },
+ headers: () => actingHeaders(base),
+ };
+}
+
+module.exports = { createBotClient, actingHeaders };
+},
+// ── 16: src/search.js ───────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1836,7 +1975,7 @@ function createSearchClient(client, defaults = {}) {
@@ -1836,7 +1975,7 @@ function createSearchClient(client, defaults = {}) {
module.exports = { createSearchClient, queryOf };
module.exports = { createSearchClient, queryOf };
},
},
-// ── 16: src/chat.js ─────────────────────────────────────────────────
+// ── 17: src/chat.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1914,7 +2053,7 @@ function createChatClient(client, defaults = {}) {
@@ -1914,7 +2053,7 @@ function createChatClient(client, defaults = {}) {
module.exports = { createChatClient };
module.exports = { createChatClient };
},
},
-// ── 17: src/ai.js ───────────────────────────────────────────────────
+// ── 18: src/ai.js ───────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -1989,7 +2128,7 @@ function createAiClient(client, defaults = {}) {
@@ -1989,7 +2128,7 @@ function createAiClient(client, defaults = {}) {
module.exports = { createAiClient, TERMINAL, OPS };
module.exports = { createAiClient, TERMINAL, OPS };
},
},
-// ── 18: src/jobs.js ─────────────────────────────────────────────────
+// ── 19: src/jobs.js ─────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2027,7 +2166,7 @@ function (module, exports) {
@@ -2027,7 +2166,7 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { appendFiles } = __load(19);
+const { appendFiles } = __load(20);
const { parseSSE } = __load(11);
const { parseSSE } = __load(11);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled']);
@@ -2191,7 +2330,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
@@ -2191,7 +2330,7 @@ function createJobsClient(client, { baseUrl, service = 'tools', audience = 'open
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
module.exports = { createJobsClient, isTerminal, TERMINAL_STATES: [...TERMINAL] };
},
},
-// ── 19: src/core/form.js ────────────────────────────────────────────
+// ── 20: src/core/form.js ────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2225,7 +2364,7 @@ function appendFiles(form, list, where) {
@@ -2225,7 +2364,7 @@ function appendFiles(form, list, where) {
module.exports = { isBlob, isUpload, appendFiles };
module.exports = { isBlob, isUpload, appendFiles };
},
},
-// ── 20: src/tools.js ────────────────────────────────────────────────
+// ── 21: src/tools.js ────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2259,8 +2398,8 @@ function (module, exports) {
@@ -2259,8 +2398,8 @@ function (module, exports) {
*/
*/
const { OpenVibeError, isOpenVibeError } = __load(3);
const { OpenVibeError, isOpenVibeError } = __load(3);
const { newIdempotencyKey } = __load(5);
const { newIdempotencyKey } = __load(5);
-const { isUpload, appendFiles } = __load(19);
-const { createJobsClient } = __load(18);
+const { isUpload, appendFiles } = __load(20);
+const { createJobsClient } = __load(19);
const enc = encodeURIComponent;
const enc = encodeURIComponent;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
const ID_RE = /^[a-z][a-z0-9-]{0,39}$/;
@@ -2494,7 +2633,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
@@ -2494,7 +2633,7 @@ function createToolsClient(client, { baseUrl, service = 'tools', audience = 'ope
module.exports = { createToolsClient, ToolRunError, isToolRunError };
module.exports = { createToolsClient, ToolRunError, isToolRunError };
},
},
-// ── 21: src/projects.js ─────────────────────────────────────────────
+// ── 22: src/projects.js ─────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2610,7 +2749,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
@@ -2610,7 +2749,7 @@ function createProjectsClient(client, { baseUrl } = {}) {
module.exports = { createProjectsClient };
module.exports = { createProjectsClient };
},
},
-// ── 22: src/geo.js ──────────────────────────────────────────────────
+// ── 23: src/geo.js ──────────────────────────────────────────────────
function (module, exports) {
function (module, exports) {
'use strict';
'use strict';
/**
/**
@@ -2689,7 +2828,8 @@ export const {
@@ -2689,7 +2828,8 @@ export const {
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
createClient, OpenVibeError, isOpenVibeError, paginate, offsetPager, parseTraceparent,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
- registry, modules, realtime, media, community, search,
- chat, ai, jobs, tools, projects, geo,
+ registry, modules, realtime, media, community, bot,
+ search, chat, ai, jobs, tools, projects,
+ geo,
} = sdk;
} = sdk;
export default sdk;
export default sdk;
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/bot.js. Do not edit.
+import cjs from '../src/bot.js';
+
+export const {
+ createBotClient, actingHeaders,
+} = cjs;
+export default cjs;
@@ -6,8 +6,8 @@ export const {
@@ -6,8 +6,8 @@ export const {
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
startSpan, contextFromHeaders, ulid, newEventId, newIdempotencyKey, isActingSubjectId,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
satisfiesRange, compareVersions, CONTRACTS_RANGE, DEFAULT_NETWORK, SDK_VERSION, auth,
registry, identity, modules, events, realtime, media,
registry, identity, modules, events, realtime, media,
- community, search, chat, ai, jobs, tools,
- projects, vip, openre, geo, usage, commerce,
- telemetry,
+ community, bot, search, chat, ai, jobs,
+ tools, projects, vip, openre, geo, usage,
+ commerce, telemetry,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -16,6 +16,7 @@ module.exports = {
@@ -16,6 +16,7 @@ module.exports = {
realtime: require('./src/realtime'),
realtime: require('./src/realtime'),
media: require('./src/media'),
media: require('./src/media'),
community: require('./src/community'),
community: require('./src/community'),
+ bot: require('./src/bot'),
search: require('./src/search'),
search: require('./src/search'),
chat: require('./src/chat'),
chat: require('./src/chat'),
ai: require('./src/ai'),
ai: require('./src/ai'),
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.29.0",
+ "version": "0.30.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.29.0",
+ "version": "0.30.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.29.0",
- "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
+ "version": "0.30.0",
+ "description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
"browser": "browser.js",
"browser": "browser.js",
@@ -71,6 +71,11 @@
@@ -71,6 +71,11 @@
"import": "./esm/community.mjs",
"import": "./esm/community.mjs",
"default": "./src/community.js"
"default": "./src/community.js"
},
},
+ "./bot": {
+ "types": "./types/bot.d.ts",
+ "import": "./esm/bot.mjs",
+ "default": "./src/bot.js"
+ },
"./search": {
"./search": {
"types": "./types/search.d.ts",
"types": "./types/search.d.ts",
"import": "./esm/search.mjs",
"import": "./esm/search.mjs",
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
/**
/**
* browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK
* browser/openvibe-sdk.mjs: ONE self-contained ES module with the browser-safe part of the SDK
* (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,
* (everything browser.js exposes: core, auth/browser, registry, modules, realtime, media,
- * community, jobs, tools, projects), for pages with no build step:
+ * community, bot, jobs, tools, projects), for pages with no build step:
*
*
* <script type="module">
* <script type="module">
* import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';
* import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs';
@@ -24,6 +24,7 @@ const ENTRIES = {
@@ -24,6 +24,7 @@ const ENTRIES = {
'realtime.mjs': '../src/realtime.js',
'realtime.mjs': '../src/realtime.js',
'media.mjs': '../src/media.js',
'media.mjs': '../src/media.js',
'community.mjs': '../src/community.js',
'community.mjs': '../src/community.js',
+ 'bot.mjs': '../src/bot.js',
'search.mjs': '../src/search.js',
'search.mjs': '../src/search.js',
'limits.mjs': '../src/limits.js',
'limits.mjs': '../src/limits.js',
'govern.mjs': '../src/govern.js',
'govern.mjs': '../src/govern.js',
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+/**
+ * openvibe-sdk/bot: OpenVibe.Bot robots (/api/v1). Browser-safe.
+ *
+ * Browsers call as the signed-in person (Network ov_token cookie with credentials: 'include', or
+ * a Bearer user JWT); first-party services call with a Network service token (audience
+ * openvibe.bot, bot.robot.read / bot.robot.manage / bot.robot.control) and say who they act for:
+ * actingSubject: 'usr_…' | 'gst_…' -> X-OV-Subject
+ * Bot reads X-OV-Subject on the routes a service may act for a person (create a robot, manage,
+ * control, streaming). A person's own token acts only as themself: Bot refuses a mismatch rather
+ * than ignoring it, so `as()` is for services.
+ *
+ * Public reads need no token at all: profiles and the robot kits catalogue (GET /profiles,
+ * /kits). Pairing (POST /pair) is the agent redeeming a one-time code: the code is the credential,
+ * and the call is never retried (a code is one-time, so a lost answer cannot be safely repeated).
+ * Every other POST carries an Idempotency-Key (the client generates one when retries are on).
+ *
+ * Not wrapped: the operator WebSocket (/control — Realtime frames bot.command@1), device
+ * bootstrap (POST /devices/bind, a Network node token) and the internal Run -> Bot job dispatch
+ * (bot.job.dispatch is service-to-service and is never delegated to people or apps). The signed
+ * `/robots/:id/devices` listing is `robots.devices`; credential rotation and revocation are
+ * `devices.rotate` / `devices.revoke`.
+ */
+const { isOpenVibeError } = require('./core/errors');
+const { isActingSubjectId } = require('./core/ids');
+const { paginate } = require('./core/paginate');
+
+const enc = encodeURIComponent;
+
+/** The header a service uses to say who it acts for: X-OV-Subject. Bot ignores the other X-OV-* ones. */
+function actingHeaders({ actingSubject } = {}) {
+ const h = {};
+ if (actingSubject) {
+ if (!isActingSubjectId(actingSubject)) throw new TypeError('actingSubject must be a usr_… or gst_… subject id');
+ h['X-OV-Subject'] = actingSubject;
+ }
+ return h;
+}
+
+function createBotClient(client, defaults = {}) {
+ const { baseUrl } = defaults;
+ const pick = (o) => ({ actingSubject: o.actingSubject, signal: o.signal });
+ const base = pick(defaults);
+
+ function call(opts, perCall = {}) {
+ const acting = { ...base };
+ for (const [k, v] of Object.entries(pick(perCall))) if (v !== undefined) acting[k] = v;
+ return client.json({
+ service: 'bot', baseUrl, audience: 'openvibe.bot', ...opts,
+ headers: { ...actingHeaders(acting), ...opts.headers }, signal: acting.signal,
+ });
+ }
+ const orNull = (p) => p.catch((err) => { if (isOpenVibeError(err) && err.status === 404) return null; throw err; });
+ const once = { idempotencyKey: false }; // a one-time code is its own idempotency: never repeat the call
+
+ const robots = {
+ /** { robots } — a person's own robots; a service must name owner (or act as them). */
+ list: (query = {}, o = {}) => call({ path: '/api/v1/robots', query }, o),
+ /** { robot, role } or null when the robot is gone/unknown. */
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/robots/${enc(id)}` }, o)),
+ /** { owner?, name?, profile_id?, access_policy?, limits? } -> { robot, pairing } (a one-time code). */
+ create: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/robots', json: input }, o),
+ /** { name?, access_policy?, limits? } -> { robot }. */
+ update: (id, patch = {}, o = {}) => call({ method: 'PATCH', path: `/api/v1/robots/${enc(id)}`, json: patch }, o),
+ /** Irreversible -> null (204). */
+ delete: (id, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}` }, o),
+ /** A fresh one-time pairing code -> { code, expires_at, installer }. */
+ pairingCode: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/pairing-code`, json: {} }, o),
+ operators: {
+ /** { operators } — owner or operator. */
+ list: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/operators` }, o),
+ /** { subject, role? } -> { operators } (the owner). */
+ add: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/operators`, json: input }, o),
+ remove: (id, subject, o = {}) => call({ method: 'DELETE', path: `/api/v1/robots/${enc(id)}/operators/${enc(subject)}` }, o),
+ },
+ /** { devices } — each with `online`. */
+ devices: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/devices` }, o),
+ /** { audit, next_before } — newest first, paged by `before` (an audit id). The owner, or a service with read. */
+ audit: (id, query = {}, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/audit`, query }, o),
+ /** Every audit entry, newest first: for await (const entry of robots.iterateAudit(id)) … */
+ iterateAudit(id, query = {}, o = {}) {
+ const limit = query.limit || 50;
+ return paginate(async (before) => {
+ const page = await robots.audit(id, { ...query, limit, before }, o);
+ return { items: page.audit || [], next: page.next_before };
+ }, { cursor: query.before ?? null });
+ },
+ /** Latch the e-stop -> { robot }. A service needs bot.robot.read and bot.robot.control. */
+ estop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop`, json: {} }, o),
+ /** Clear the e-stop, owner only (a service needs bot.robot.manage) -> { robot }. */
+ clearEstop: (id, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/estop/clear`, json: {} }, o),
+ /**
+ * { id, kind, value?, ms? } -> { robot_id, result, … }; `id` is Bot's own idempotency key (a
+ * repeated id is answered from the cache). A refused command is an OpenVibeError (`code`
+ * bot.not_an_operator, bot.cooldown, …) with the HTTP status Bot gives it.
+ */
+ command: (id, input, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/commands`, json: input }, o),
+ streaming: {
+ /** The owner's OpenRe toggles (Bot stores no copy). A member read. */
+ get: (id, o = {}) => call({ path: `/api/v1/robots/${enc(id)}/streaming` }, o),
+ /** { to?, on? } (a service must also act for the owner) -> the new streaming state. */
+ set: (id, input = {}, o = {}) => call({ method: 'POST', path: `/api/v1/robots/${enc(id)}/streaming`, json: input }, o),
+ },
+ };
+
+ return {
+ robots,
+ devices: {
+ /** Rotate a paired device's credential -> { device, credential?, …video } (the credential is shown once). */
+ rotate: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/rotate`, json: {} }, o),
+ /** Revoke a device, closing its socket at once -> { device }. Irreversible. */
+ revoke: (id, o = {}) => call({ method: 'POST', path: `/api/v1/devices/${enc(id)}/revoke`, json: {} }, o),
+ },
+ profiles: {
+ /** The public robot profiles -> { profiles }. */
+ list: (o = {}) => call({ path: '/api/v1/profiles' }, o),
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/profiles/${enc(id)}` }, o)),
+ },
+ kits: {
+ /** The public "Get a robot" catalogue -> { kits }. */
+ list: (o = {}) => call({ path: '/api/v1/kits' }, o),
+ get: (id, o = {}) => orNull(call({ path: `/api/v1/kits/${enc(id)}` }, o)),
+ },
+ /** The agent redeems a one-time pairing code (the code is the credential; never retried) -> the pair result. */
+ pair: (input = {}, o = {}) => call({ method: 'POST', path: '/api/v1/pair', json: input, ...once }, o),
+ /** The same client acting for someone else: as('usr_…') or as({ actingSubject }). */
+ as(who) {
+ const next = typeof who === 'string' ? { actingSubject: who } : { ...who };
+ return createBotClient(client, { ...defaults, ...next });
+ },
+ headers: () => actingHeaders(base),
+ };
+}
+
+module.exports = { createBotClient, actingHeaders };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.29.0';
+const SDK_VERSION = '0.30.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,238 @@
@@ -0,0 +1,238 @@
+'use strict';
+/**
+ * openvibe-sdk/bot: the acting header, robots/operators/devices/streaming/commands/audit, the
+ * public kits and profiles, pairing, and the Contracts fixtures the requests and answers follow.
+ */
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { stubServer, send, problem, run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createBotClient, actingHeaders } = require('../src/bot');
+
+const USR = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const GST = 'gst_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const ROB = 'rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X';
+const DEV = 'dev_01J8Z4N0A1B2C3D4E5F6G7H8J9';
+
+const FIX = path.join(__dirname, '..', 'node_modules', 'openvibe-contracts', 'fixtures');
+let contracts = null;
+try { contracts = require('openvibe-contracts'); } catch { /* the fixture case skips */ }
+const loadFixture = (dir, file) => JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', file), 'utf8'));
+
+/** A stub Bot: fixtures for the contract-shaped answers, plus the routes the SDK turns into calls. */
+function server() {
+ return stubServer((req, res, body) => {
+ const u = new URL(req.url, 'http://x');
+ const p = u.pathname;
+ const m = req.method;
+ const fixture = (status, dir, file) => send(res, status, loadFixture(dir, file));
+
+ if (m === 'GET' && p === `/api/v1/robots/${ROB}/audit`) {
+ const before = u.searchParams.get('before');
+ const limit = Number(u.searchParams.get('limit')) || 50;
+ const rows = [5, 4, 3, 2, 1]
+ .filter((id) => before == null || id < Number(before))
+ .slice(0, limit)
+ .map((id) => ({ id, kind: 'drive' }));
+ return send(res, 200, { audit: rows, next_before: rows.length ? rows[rows.length - 1].id : null });
+ }
+ if (m === 'GET' && p === `/api/v1/robots/${ROB}/streaming`) return send(res, 200, { to: 'openre', on: false });
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/streaming`) return send(res, 200, { to: 'openre', on: true });
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/commands`) {
+ const input = JSON.parse(body.toString() || '{}');
+ if (input.kind === 'summon') return problem(res, 403, 'bot.not_an_operator', 'you are not an operator of this robot');
+ return send(res, 200, { robot_id: ROB, result: 'ack', id: input.id });
+ }
+ if (m === 'POST' && p === '/api/v1/robots') return fixture(201, 'bot.robot-manage-result', 'created.json');
+ if (m === 'GET' && p === '/api/v1/robots') return fixture(200, 'bot.robot-read-result', 'list.json');
+ if (m === 'GET' && p === `/api/v1/robots/${ROB}`) return fixture(200, 'bot.robot-read-result', 'robot.json');
+ if (m === 'GET' && p === `/api/v1/robots/${ROB}/operators`) return fixture(200, 'bot.robot-manage-result', 'operators.json');
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/operators`) return fixture(201, 'bot.robot-manage-result', 'operators.json');
+ if (m === 'DELETE' && p.startsWith(`/api/v1/robots/${ROB}/operators/`)) return fixture(200, 'bot.robot-manage-result', 'operators.json');
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/pairing-code`) return send(res, 201, { code: 'K7QM-3XRT', expires_at: '2026-09-29T19:20:00.000Z', installer: 'curl …' });
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/estop`) return fixture(200, 'bot.robot-control-result', 'latched.json');
+ if (m === 'POST' && p === `/api/v1/robots/${ROB}/estop/clear`) return fixture(200, 'bot.robot-control-result', 'latched.json');
+ if (m === 'PATCH' && p === `/api/v1/robots/${ROB}`) return send(res, 200, { robot: loadFixture('bot.robot', 'rover.json') });
+ if (m === 'DELETE' && p === `/api/v1/robots/${ROB}`) { res.writeHead(204); return res.end(); }
+ if (m === 'GET' && p === `/api/v1/robots/${ROB}/devices`) return send(res, 200, { devices: [loadFixture('bot.device', 'agent.json')] });
+ if (m === 'POST' && p === `/api/v1/devices/${DEV}/rotate`) return fixture(200, 'bot.device-connect-result', 'rotated.json');
+ if (m === 'POST' && p === `/api/v1/devices/${DEV}/revoke`) return fixture(200, 'bot.device-connect-result', 'revoked.json');
+ if (m === 'POST' && p === '/api/v1/pair') return fixture(201, 'bot.pair-result', 'paired.json');
+ if (m === 'GET' && p === '/api/v1/profiles') return send(res, 200, { profiles: [loadFixture('bot.robot-profile', 'adeept-file.json')] });
+ if (m === 'GET' && p === '/api/v1/profiles/adeept.adr036') return send(res, 200, { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });
+ if (m === 'GET' && p === '/api/v1/kits') return send(res, 200, { kits: [{ id: 'starter' }] });
+ if (m === 'GET' && p === '/api/v1/kits/starter') return send(res, 200, { kit: { id: 'starter' } });
+ if (p.startsWith('/api/v1/profiles/')) return problem(res, 404, 'bot.profile_not_found', 'no such profile');
+ if (p.startsWith('/api/v1/kits/')) return problem(res, 404, 'bot.kit_not_found', 'no such kit');
+ if (p.startsWith('/api/v1/robots/')) return problem(res, 404, 'bot.robot_not_found', 'no such robot');
+ return send(res, 200, { ok: true });
+ });
+}
+
+const hdr = (r, k) => r.headers[k.toLowerCase()];
+
+run([
+ ['a service acting for a person sends X-OV-Subject, its token and an Idempotency-Key', async () => {
+ const srv = await server();
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc-token' }), { actingSubject: USR });
+ const manage = loadFixture('bot.robot-manage-request', 'create.json');
+ const created = await bot.robots.create(manage);
+ const r = srv.requests[0];
+ assert.equal(r.method, 'POST');
+ assert.equal(r.url, '/api/v1/robots');
+ assert.equal(hdr(r, 'authorization'), 'Bearer svc-token');
+ assert.equal(hdr(r, 'x-ov-subject'), USR);
+ assert.ok(hdr(r, 'idempotency-key'), 'a mutation carries an Idempotency-Key');
+ assert.equal(hdr(r, 'x-ov-origin'), undefined, 'Bot takes X-OV-Subject only, not the Community headers');
+ assert.deepEqual(JSON.parse(r.body), manage, 'the manage-request fixture passes through unchanged');
+ assert.deepEqual(created, loadFixture('bot.robot-manage-result', 'created.json'));
+ assert.deepEqual(bot.headers(), { 'X-OV-Subject': USR });
+ await srv.close();
+ }],
+
+ ['as() scopes the subject; a bare client sends none; bad subjects are refused', async () => {
+ const srv = await server();
+ const base = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
+ await base.as(GST).robots.list({ owner: GST });
+ assert.equal(hdr(srv.requests[0], 'x-ov-subject'), GST);
+ assert.equal(srv.requests[0].url, `/api/v1/robots?owner=${GST}`);
+ await base.robots.list({ owner: USR });
+ assert.equal(hdr(srv.requests[1], 'x-ov-subject'), USR, 'as() did not leak into the base client');
+ const plain = createBotClient(createClient({ baseUrls: { bot: srv.url } }));
+ await plain.robots.list({ owner: USR });
+ assert.equal(hdr(srv.requests[2], 'x-ov-subject'), undefined);
+ assert.deepEqual(actingHeaders({}), {});
+ assert.throws(() => actingHeaders({ actingSubject: '42' }), TypeError);
+ assert.throws(() => createBotClient(createClient({ baseUrls: { bot: srv.url } }), { actingSubject: 'nope' }).headers(), TypeError);
+ await srv.close();
+ }],
+
+ ['reads: list, get, null on 404, and the audit page and its iterator', async () => {
+ const srv = await server();
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url } }));
+ assert.deepEqual(await bot.robots.list({ owner: USR }), loadFixture('bot.robot-read-result', 'list.json'));
+ assert.deepEqual(await bot.robots.get(ROB), loadFixture('bot.robot-read-result', 'robot.json'));
+ assert.equal(await bot.robots.get('rob_missing'), null);
+ const page = await bot.robots.audit(ROB, { limit: 2 });
+ assert.deepEqual(page, { audit: [{ id: 5, kind: 'drive' }, { id: 4, kind: 'drive' }], next_before: 4 });
+ const ids = [];
+ for await (const entry of bot.robots.iterateAudit(ROB, { limit: 2 })) ids.push(entry.id);
+ assert.deepEqual(ids, [5, 4, 3, 2, 1], 'newest first, no repeat, no extra page');
+ await srv.close();
+ }],
+
+ ['commands: bot.command@1 passes through; a refusal is a typed error', async () => {
+ const srv = await server();
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
+ const drive = loadFixture('bot.command', 'drive.json');
+ const ack = await bot.robots.command(ROB, drive);
+ assert.deepEqual(ack, { robot_id: ROB, result: 'ack', id: 'op-7' });
+ const r = srv.requests[0];
+ assert.equal(r.url, `/api/v1/robots/${ROB}/commands`);
+ assert.equal(hdr(r, 'x-ov-subject'), USR);
+ assert.deepEqual(JSON.parse(r.body), drive, 'the command frame passes through unchanged');
+ await assert.rejects(
+ () => bot.robots.command(ROB, { id: 'op-8', kind: 'summon' }),
+ (err) => err.status === 403 && err.code === 'bot.not_an_operator',
+ );
+ await srv.close();
+ }],
+
+ ['manage, control, streaming and device calls use the routes Bot serves', async () => {
+ const srv = await server();
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, token: 'svc' }), { actingSubject: USR });
+ await bot.robots.update(ROB, { name: 'R2' });
+ assert.equal(await bot.robots.delete(ROB), null, '204 -> null');
+ await bot.robots.pairingCode(ROB);
+ await bot.robots.operators.add(ROB, { subject: USR, role: 'operator' });
+ await bot.robots.operators.remove(ROB, USR);
+ await bot.robots.devices(ROB);
+ await bot.robots.estop(ROB);
+ await bot.robots.clearEstop(ROB);
+ await bot.robots.streaming.get(ROB);
+ await bot.robots.streaming.set(ROB, { to: 'openre', on: true });
+ await bot.devices.rotate(DEV);
+ await bot.devices.revoke(DEV);
+ const seen = srv.requests.map((r) => `${r.method} ${r.url}`);
+ assert.deepEqual(seen, [
+ `PATCH /api/v1/robots/${ROB}`,
+ `DELETE /api/v1/robots/${ROB}`,
+ `POST /api/v1/robots/${ROB}/pairing-code`,
+ `POST /api/v1/robots/${ROB}/operators`,
+ `DELETE /api/v1/robots/${ROB}/operators/${USR}`,
+ `GET /api/v1/robots/${ROB}/devices`,
+ `POST /api/v1/robots/${ROB}/estop`,
+ `POST /api/v1/robots/${ROB}/estop/clear`,
+ `GET /api/v1/robots/${ROB}/streaming`,
+ `POST /api/v1/robots/${ROB}/streaming`,
+ `POST /api/v1/devices/${DEV}/rotate`,
+ `POST /api/v1/devices/${DEV}/revoke`,
+ ]);
+ assert.deepEqual(JSON.parse(srv.requests[0].body), { name: 'R2' });
+ assert.deepEqual(JSON.parse(srv.requests[3].body), { subject: USR, role: 'operator' });
+ assert.deepEqual(JSON.parse(srv.requests[9].body), { to: 'openre', on: true });
+ await srv.close();
+ }],
+
+ ['public profiles and kits, and pairing (a one-time code is never given an Idempotency-Key)', async () => {
+ const srv = await server();
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url } }));
+ assert.deepEqual(await bot.profiles.get('adeept.adr036'), { profile: loadFixture('bot.robot-profile', 'adeept-file.json') });
+ assert.equal(await bot.profiles.get('missing'), null);
+ assert.equal((await bot.profiles.list()).profiles.length, 1);
+ assert.deepEqual(await bot.kits.list(), { kits: [{ id: 'starter' }] });
+ assert.deepEqual(await bot.kits.get('starter'), { kit: { id: 'starter' } });
+ const pair = loadFixture('bot.pair-request', 'code-only.json');
+ assert.deepEqual(await bot.pair(pair), loadFixture('bot.pair-result', 'paired.json'));
+ const r = srv.requests[srv.requests.length - 1];
+ assert.equal(r.url, '/api/v1/pair');
+ assert.deepEqual(JSON.parse(r.body), pair);
+ assert.equal(hdr(r, 'idempotency-key'), undefined, 'a one-time code is its own idempotency: no retry key');
+ await srv.close();
+ }],
+
+ ['browser mode: cookie credentials, no Authorization and no X-OV headers', async () => {
+ const srv = await server();
+ const seen = [];
+ const fetchSpy = (url, init) => { seen.push(init); return fetch(url, init); };
+ const bot = createBotClient(createClient({ baseUrls: { bot: srv.url }, credentials: 'include', fetch: fetchSpy }));
+ await bot.robots.get(ROB);
+ const r = srv.requests[0];
+ assert.equal(hdr(r, 'authorization'), undefined);
+ assert.ok(!Object.keys(r.headers).some((k) => k.startsWith('x-ov-')));
+ assert.equal(seen[0].credentials, 'include');
+ await srv.close();
+ }],
+
+ ['the Contracts bot fixtures validate (requests and answers the client speaks)', async () => {
+ if (!contracts) { console.log('bot fixtures: skipped (openvibe-contracts not installed)'); return; }
+ const cases = [
+ ['bot.robot', 'bot.robot@1'],
+ ['bot.device', 'bot.device@1'],
+ ['bot.command', 'bot.command@1'],
+ ['bot.command-result', 'bot.command-result@1'],
+ ['bot.robot-manage-request', 'bot.robot-manage-request@1'],
+ ['bot.robot-manage-result', 'bot.robot-manage-result@1'],
+ ['bot.robot-read-request', 'bot.robot-read-request@1'],
+ ['bot.robot-read-result', 'bot.robot-read-result@1'],
+ ['bot.robot-control-result', 'bot.robot-control-result@1'],
+ ['bot.device-connect-result', 'bot.device-connect-result@1'],
+ ['bot.device-message', 'bot.device-message@1'],
+ ['bot.job-dispatch-result', 'bot.job-dispatch-result@1'],
+ ['bot.pair-request', 'bot.pair-request@1'],
+ ['bot.pair-result', 'bot.pair-result@1'],
+ ['bot.robot-profile', 'bot.robot-profile@1'],
+ ];
+ for (const [dir, ref] of cases) {
+ for (const f of fs.readdirSync(path.join(FIX, dir, 'valid'))) {
+ const body = JSON.parse(fs.readFileSync(path.join(FIX, dir, 'valid', f), 'utf8'));
+ assert.deepEqual(contracts.validate(ref, body), { valid: true, errors: [] }, `${ref} valid/${f}`);
+ }
+ for (const f of fs.readdirSync(path.join(FIX, dir, 'invalid'))) {
+ const body = JSON.parse(fs.readFileSync(path.join(FIX, dir, 'invalid', f), 'utf8'));
+ assert.equal(contracts.validate(ref, body).valid, false, `${ref} invalid/${f}`);
+ }
+ }
+ }],
+]);
@@ -78,7 +78,7 @@ run([
@@ -78,7 +78,7 @@ run([
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
assert.deepEqual(blocked.sort(), ['./cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
+ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
}
}
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/identity.js', 'src/sso/index.js', 'src/service/index.js', 'src/service-outbox.js', 'src/testing/index.js']) {
@@ -29,7 +29,7 @@ run([
@@ -29,7 +29,7 @@ run([
}
}
const ids = collect().map((m) => m.id);
const ids = collect().map((m) => m.id);
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);
for (const never of ['src/auth/tokens.js', 'src/auth/jwt.js', 'src/auth/oauth.js', 'src/events.js', 'src/outbox.js', 'src/identity.js', 'src/testing/index.js']) assert.ok(!ids.includes(never), never);
- for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/tools.js', 'src/projects.js', 'src/realtime.js']) assert.ok(ids.includes(must), must);
+ for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/jobs.js', 'src/tools.js', 'src/projects.js', 'src/realtime.js', 'src/bot.js']) assert.ok(ids.includes(must), must);
}],
}],
['exports match the browser entry', async () => {
['exports match the browser entry', async () => {
@@ -37,7 +37,7 @@ run([
@@ -37,7 +37,7 @@ run([
const cjs = require('../browser.js');
const cjs = require('../browser.js');
assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());
assert.deepEqual(Object.keys(esm).filter((k) => k !== 'default').sort(), Object.keys(cjs).sort());
assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());
assert.deepEqual(Object.keys(esm.default).sort(), Object.keys(cjs).sort());
- for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'jobs', 'tools', 'projects']) {
+ for (const ns of ['auth', 'registry', 'modules', 'realtime', 'media', 'community', 'bot', 'jobs', 'tools', 'projects']) {
assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);
assert.deepEqual(Object.keys(esm[ns]).sort(), Object.keys(cjs[ns]).sort(), ns);
}
}
assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');
assert.notEqual(esm.createClient, cjs.createClient, 'its own copy, not the CommonJS modules');
@@ -22,7 +22,7 @@ run([
@@ -22,7 +22,7 @@ run([
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const out = execFileSync(npm, ['pack', '--json', '--pack-destination', tmp], { cwd: ROOT, env, encoding: 'utf8' });
const info = JSON.parse(out)[0];
const info = JSON.parse(out)[0];
const files = info.files.map((f) => f.path);
const files = info.files.map((f) => f.path);
- for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
+ for (const must of ['package.json', 'index.js', 'browser.js', 'LICENSE', 'README.md', 'CHANGELOG.md', 'src/core/client.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'src/vip.js', 'esm/vip.mjs', 'types/vip.d.ts', 'src/bot.js', 'esm/bot.mjs', 'types/bot.d.ts', 'src/frame.js', 'esm/frame.mjs', 'types/frame.d.ts', 'src/service/index.js', 'esm/service.mjs', 'types/service.d.ts', 'docs/service.md', 'esm/core.mjs', 'esm/jobs.mjs', 'esm/tools.mjs', 'types/tools.d.ts', 'types/core.d.ts', 'types/contracts.d.ts', 'types/bundle.d.ts', 'browser/openvibe-sdk.mjs']) {
assert.ok(files.includes(must), `tarball contains ${must}`);
assert.ok(files.includes(must), `tarball contains ${must}`);
}
}
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
assert.ok(!files.some((f) => f.startsWith('test/') || f.startsWith('scripts/') || f.startsWith('.github/')), 'no tests or tooling in the tarball');
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 40);
+ assert.equal(subpaths.length, 41);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+import type { OpenVibeClient } from './core';
+
+export interface ActingOptions {
+ /** usr_… or gst_… (services only). */
+ actingSubject?: string;
+}
+export type CallOptions = ActingOptions & { signal?: AbortSignal };
+
+export interface Robot {
+ id: string;
+ name?: string;
+ profile_id?: string;
+ profile_version?: number;
+ access_policy?: string;
+ limits?: Record<string, unknown>;
+ estop?: { latched: boolean; by: string | null; at: string | null };
+ created_at?: string;
+ updated_at?: string;
+ [field: string]: unknown;
+}
+export interface Device {
+ id: string;
+ robot_ids?: string[];
+ name?: string;
+ kind?: string;
+ agent_version?: string;
+ drivers?: string[];
+ capabilities?: Record<string, unknown>;
+ last_seen?: string | null;
+ revoked_at?: string | null;
+ online?: boolean;
+ [field: string]: unknown;
+}
+export interface Pairing { code: string; expires_at?: string; installer?: string; [field: string]: unknown; }
+export interface Operator { subject: string; role?: string; [field: string]: unknown; }
+export interface AuditEntry { id: number; robot_id?: string; device_id?: string | null; operator_subject?: string; operator_kind?: string; role?: string; kind?: string; value?: unknown; result?: string; reason?: string | null; latency_ms?: number | null; at?: string; [field: string]: unknown; }
+export interface CommandInput { id: string; kind: string; value?: unknown; ms?: number; }
+export interface CommandResult { robot_id: string; result: string; code?: string; reason?: string | null; [field: string]: unknown; }
+
+export interface RobotsApi {
+ list(query?: { owner?: string }, opts?: CallOptions): Promise<{ robots: Robot[] }>;
+ get(id: string, opts?: CallOptions): Promise<{ robot: Robot; role: string } | null>;
+ create(input: { owner?: string; name?: string; profile_id?: string; access_policy?: string; limits?: Record<string, unknown> }, opts?: CallOptions): Promise<{ robot: Robot; pairing: Pairing }>;
+ update(id: string, patch: { name?: string; access_policy?: string; limits?: Record<string, unknown> }, opts?: CallOptions): Promise<{ robot: Robot }>;
+ delete(id: string, opts?: CallOptions): Promise<unknown>;
+ pairingCode(id: string, opts?: CallOptions): Promise<Pairing>;
+ operators: {
+ list(id: string, opts?: CallOptions): Promise<{ operators: Operator[] }>;
+ add(id: string, input: { subject: string; role?: string }, opts?: CallOptions): Promise<{ operators: Operator[] }>;
+ remove(id: string, subject: string, opts?: CallOptions): Promise<{ operators: Operator[] }>;
+ };
+ devices(id: string, opts?: CallOptions): Promise<{ devices: Device[] }>;
+ audit(id: string, query?: { before?: number; limit?: number }, opts?: CallOptions): Promise<{ audit: AuditEntry[]; next_before: number | null }>;
+ iterateAudit(id: string, query?: { before?: number; limit?: number }, opts?: CallOptions): AsyncGenerator<AuditEntry, void, unknown>;
+ estop(id: string, opts?: CallOptions): Promise<{ robot: Robot }>;
+ clearEstop(id: string, opts?: CallOptions): Promise<{ robot: Robot }>;
+ command(id: string, input: CommandInput, opts?: CallOptions): Promise<CommandResult>;
+ streaming: {
+ get(id: string, opts?: CallOptions): Promise<Record<string, unknown>>;
+ set(id: string, input: { to?: unknown; on?: boolean; owner?: string }, opts?: CallOptions): Promise<Record<string, unknown>>;
+ };
+}
+export interface DevicesApi {
+ rotate(id: string, opts?: CallOptions): Promise<{ device: Device; credential?: string; [field: string]: unknown }>;
+ revoke(id: string, opts?: CallOptions): Promise<{ device: Device }>;
+}
+export interface ProfilesApi {
+ list(opts?: CallOptions): Promise<{ profiles: Array<Record<string, unknown>> }>;
+ get(id: string, opts?: CallOptions): Promise<{ profile: Record<string, unknown> } | null>;
+}
+export interface KitsApi {
+ list(opts?: CallOptions): Promise<{ kits: Array<Record<string, unknown>> }>;
+ get(id: string, opts?: CallOptions): Promise<{ kit: Record<string, unknown> } | null>;
+}
+export interface PairInput {
+ code: string;
+ robot?: string;
+ agent_version?: string;
+ device_kind?: string;
+ drivers?: string[];
+ capabilities?: Record<string, unknown>;
+ name?: string;
+}
+export interface BotClient {
+ robots: RobotsApi;
+ devices: DevicesApi;
+ profiles: ProfilesApi;
+ kits: KitsApi;
+ pair(input: PairInput, opts?: CallOptions): Promise<{ device_id: string; credential?: string; robot_id?: string; profile?: Record<string, unknown> | null; [field: string]: unknown }>;
+ as(who: string | ActingOptions): BotClient;
+ headers(): Record<string, string>;
+}
+export declare function createBotClient(client: OpenVibeClient, defaults?: ActingOptions & { baseUrl?: string }): BotClient;
+export declare function actingHeaders(opts?: ActingOptions): Record<string, string>;
@@ -5,6 +5,7 @@ import * as modules from './modules';
@@ -5,6 +5,7 @@ import * as modules from './modules';
import * as realtime from './realtime';
import * as realtime from './realtime';
import * as media from './media';
import * as media from './media';
import * as community from './community';
import * as community from './community';
+import * as bot from './bot';
import * as search from './search';
import * as search from './search';
import * as chat from './chat';
import * as chat from './chat';
import * as ai from './ai';
import * as ai from './ai';
@@ -21,6 +22,7 @@ export * as modules from './modules';
@@ -21,6 +22,7 @@ export * as modules from './modules';
export * as realtime from './realtime';
export * as realtime from './realtime';
export * as media from './media';
export * as media from './media';
export * as community from './community';
export * as community from './community';
+export * as bot from './bot';
export * as search from './search';
export * as search from './search';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';
@@ -31,6 +33,6 @@ export * as geo from './geo';
@@ -31,6 +33,6 @@ export * as geo from './geo';
declare const sdk: typeof core & {
declare const sdk: typeof core & {
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
auth: typeof auth; registry: typeof registry; modules: typeof modules; realtime: typeof realtime;
- media: typeof media; community: typeof community; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
+ media: typeof media; community: typeof community; bot: typeof bot; search: typeof search; chat: typeof chat; ai: typeof ai; jobs: typeof jobs; tools: typeof tools; projects: typeof projects; geo: typeof geo;
};
};
export default sdk;
export default sdk;
@@ -7,6 +7,7 @@ export * as events from './events';
@@ -7,6 +7,7 @@ export * as events from './events';
export * as realtime from './realtime';
export * as realtime from './realtime';
export * as media from './media';
export * as media from './media';
export * as community from './community';
export * as community from './community';
+export * as bot from './bot';
export * as search from './search';
export * as search from './search';
export * as chat from './chat';
export * as chat from './chat';
export * as ai from './ai';
export * as ai from './ai';