openvibers/openvibe.sdk Commits

Publish a Bot client (openvibe-sdk/bot) (#13)

OP OpenVibers committed 5 Oct 2026 · c955d8c42e · 21 files, +681 −42

## Files changed

- **`src/bot.js`** (new) — `openvibe-sdk/bot`, the OpenVibe.Bot client following `src/community.js`: `createBotClient(client, {actingSubject?})` with `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`/`kits`, `pair` (never retried), `as(subject)` and `headers()`. Service calls send `X-OV-Subject`; browser-safe (relative requires only).
- **`types/bot.d.ts`** (new) — declares every runtime export (Robots/Devices/Profiles/Kits/Command types) for `types.test.js`.
- **`test/bot.test.js`** (new) — stub-server tests for acting headers, `as()` isolation, null-on-404, commands pass-through and refusals, all manage/control/streaming/device routes, public reads, pairing's missing Idempotency-Key, browser mode, and validation of the Contracts `bot.*` fixtures (valid and invalid) against `openvibe-contracts`.
- **`esm/bot.mjs`** (new, generated by `scripts/esm.js`) — ESM entry re-exporting the CJS module.
- **`index.js` / `browser.js`** — expose the `bot` namespace on the Node and browser entries.
- **`types/index.d.ts` / `types/bundle.d.ts`** — declare the `bot` namespace (and in the bundle's `sdk` shape).
- **`scripts/esm.js`** — added `bot.mjs` to `ENTRIES`; **`scripts/browser-bundle.js`** — doc comment now lists `bot` (the bundle itself is generated from `browser.js`).
- **`package.json`** — added the `./bot` export (`types`/`import`/`default`), version `0.30.0`, description mentions Bot robots.
- **`package-lock.json`**, **`src/core/index.js`** — version kept in step with `package.json` (`SDK_VERSION`).
- **`esm/index.mjs`**, **`browser/openvibe-sdk.mjs`** (generated) — regenerated; only the new module, index shifts and version/comment changed.
- **`test/browser-entries.test.js`**, **`test/bundle.test.js`**, **`test/pack.test.js`** — added `src/bot.js` to the scanned/must lists, `bot` to the bundle namespaces, bot files to the tarball checks, and the subpath count 40 → 41.
- **`README.md`** — new `openvibe-sdk/bot` API-table row, bundle namespace lists, and a "Not wrapped yet" Bot bullet.
- **`STATUS.json`** — feature line for bot, version `0.30.0`; **`CHANGELOG.md`** — `0.30.0` entry.

## Tests run

- `ov test bot.test.js` → `1/1 passed` (run before the change was cached as green).
- `ov test bot.test.js esm.test.js bundle.test.js browser-entries.test.js types.test.js version.test.js pack.test.js` → `6 passed, 1 skipped (unchanged since green)`; the skipped one is `bot.test.js`, already green.
- Also `node --check src/bot.js test/bot.test.js`, JSON validation of `package.json`/`package-lock.json`/`STATUS.json`, and resolution of `openvibe-sdk/bot` by name.

## Notes

- I bumped the minor to **0.30.0** because the repo releases a new subpath as a minor and `version.test.js` ties the lockfile, `SDK_VERSION`, CHANGELOG and bundle together.
- Not wrapped (documented in the module, README and CHANGELOG as intentionally out of scope): the operator WebSocket `/control` (Realtime `bot.command@1`), `POST /devices/bind` (Network node token), and the internal Run→Bot `bot.job.dispatch` API (service-to-service, never delegated).
- I did not run the whole suite, per instructions; the full `npm test` runs in CI's verify after the push.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 54d406e..228e332 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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
ddiff --git a/README.md b/README.md
iindex 7ba133d..a3d17a6 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/STATUS.json b/STATUS.json
iindex 015f6e4..306b7db 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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)",
ddiff --git a/browser.js b/browser.js
iindex fbb8c62..9790455 100644
---- a/browser.js
++++ b/browser.js
@@@ -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'),
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex 9b81bd0..02dcab4 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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 = 'open
module.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 = 'ope
module.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;
ddiff --git a/esm/bot.mjs b/esm/bot.mjs
nnew file mode 100644
iindex 0000000..f4a719e
---- /dev/null
++++ b/esm/bot.mjs
@@@ -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;
ddiff --git a/esm/index.mjs b/esm/index.mjs
iindex 87533ed..4ad01c9 100644
---- a/esm/index.mjs
++++ b/esm/index.mjs
@@@ -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;
ddiff --git a/index.js b/index.js
iindex 6cff484..582c39e 100644
---- a/index.js
++++ b/index.js
@@@ -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'),
ddiff --git a/package-lock.json b/package-lock.json
iindex 67303c0..50a75b9 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex ddc691c..6d1ea9c 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/browser-bundle.js b/scripts/browser-bundle.js
iindex 1eec767..46165b6 100644
---- a/scripts/browser-bundle.js
++++ b/scripts/browser-bundle.js
@@@ -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';
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 5fab15b..f94355e 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
ddiff --git a/src/bot.js b/src/bot.js
nnew file mode 100644
iindex 0000000..63115f5
---- /dev/null
++++ b/src/bot.js
@@@ -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 };
ddiff --git a/src/core/index.js b/src/core/index.js
iindex f956bd3..c42874b 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -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,
ddiff --git a/test/bot.test.js b/test/bot.test.js
nnew file mode 100644
iindex 0000000..300f239
---- /dev/null
++++ b/test/bot.test.js
@@@ -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}`);
++ }
++ }
++ }],
++]);
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 19d1d7d..8fa5636 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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']) {
ddiff --git a/test/bundle.test.js b/test/bundle.test.js
iindex 63399a8..e62fbc7 100644
---- a/test/bundle.test.js
++++ b/test/bundle.test.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');
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex fad0281..416fbd5 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/types/bot.d.ts b/types/bot.d.ts
nnew file mode 100644
iindex 0000000..72ea486
---- /dev/null
++++ b/types/bot.d.ts
@@@ -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>;
ddiff --git a/types/bundle.d.ts b/types/bundle.d.ts
iindex 4ef620d..1904d3c 100644
---- a/types/bundle.d.ts
++++ b/types/bundle.d.ts
@@@ -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;
ddiff --git a/types/index.d.ts b/types/index.d.ts
iindex 3c8629e..e4e9569 100644
---- a/types/index.d.ts
++++ b/types/index.d.ts
@@@ -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';