openvibers/openvibe.sdk README.md
README.md
| 1 |  |
| 2 | |
| 3 | # OpenVibe.SDK |
| 4 | |
| 5 | **Open source JavaScript/TypeScript SDK and API clients for the OpenVibe network** — docs at [openvibe.services](https://openvibe.services). |
| 6 | |
| 7 | **Docs:** https://openvibe.services · **For AI:** https://openvibe.services/llms.txt |
| 8 | **Install and test:** `npm ci && npm test` (Node 20 or newer) |
| 9 | |
| 10 | ```bash |
| 11 | npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.166.0 |
| 12 | ``` |
| 13 | |
| 14 | > Supported browser and server clients for the OpenVibe platform. |
| 15 | |
| 16 | **Status:** alpha, v0.166.0 (the five 1.0 core entries — `core`, `auth`, `events`, `db` and `media` — are frozen: their exported names change only with a major release; roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat, AI, Vision and consent clients; sign-in with recovery and a client health check). Every release is tagged (see [CHANGELOG.md](CHANGELOG.md)). Tested against local stub servers and the built-in mock platform only, never against the live platform. Services pin release tags; each repository's `STATUS.json` names the one it uses. |
| 17 | **Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30. |
| 18 | **License:** MIT ([LICENSE](LICENSE)). This package is a library that apps outside the network embed, so it uses MIT. The OpenVibe services themselves stay AGPL-3.0. |
| 19 | |
| 20 | **If a capability is not in the SDK, it is not public.** Apps call services through `openvibe-sdk` and never build internal routes themselves. A route with no SDK wrapper is internal, even when you can reach it, and it can change without notice. To make a capability public, first define it in OpenVibe.Contracts, then wrap it here. |
| 21 | |
| 22 | After v0.97.1 is tagged: |
| 23 | |
| 24 | ```bash |
| 25 | npm install https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.166.0 |
| 26 | ``` |
| 27 | |
| 28 | It has no runtime dependencies. It needs Node ≥ 20, or any browser with `fetch`, Web Crypto and `TextDecoder`. There is no build step. The package is CommonJS with ESM entry points (`import` works). Each subpath has its own `.d.ts`. For a page with no bundler, `browser/openvibe-sdk.mjs` is one self-contained ES module of the browser-safe subpaths (see [Browser without a bundler](#browser-without-a-bundler)). It does not depend on `openvibe-contracts` at runtime: it copies the contract types it uses (from the temporary Contracts sharing branch, a devDependency the tests check them against). |
| 29 | |
| 30 | ## Purpose |
| 31 | |
| 32 | The one supported way to call OpenVibe from an app, first-party or not: clients for each public |
| 33 | capability, token handling, discovery and version checks, webhook verification, the outbox and inbox |
| 34 | services use, the per-actor limiter, the OpenVibe Frame, and a mock platform to test against. |
| 35 | |
| 36 | ## Quick start |
| 37 | |
| 38 | ### Browser app (signed-in user) |
| 39 | |
| 40 | ```js |
| 41 | import { createClient } from 'openvibe-sdk/core'; |
| 42 | import { startAuthorization, readCallback } from 'openvibe-sdk/auth'; // browser build: PKCE only |
| 43 | import { createCommunityClient } from 'openvibe-sdk/community'; |
| 44 | import { subscribe } from 'openvibe-sdk/realtime'; |
| 45 | |
| 46 | // Sign in: send the browser to the Network with a PKCE challenge. |
| 47 | const { url, state, codeVerifier } = await startAuthorization({ clientId: 'my-app', redirectUri: 'https://my.app/auth/callback' }); |
| 48 | sessionStorage.setItem('ov_pkce', JSON.stringify({ state, codeVerifier })); |
| 49 | location.assign(url); |
| 50 | |
| 51 | // On /auth/callback: check state, then hand the code and verifier to YOUR server (see below). |
| 52 | const saved = JSON.parse(sessionStorage.getItem('ov_pkce')); |
| 53 | const { code } = readCallback(location.href, { expectedState: saved.state }); |
| 54 | await fetch('/auth/exchange', { method: 'POST', body: JSON.stringify({ code, codeVerifier: saved.codeVerifier }) }); |
| 55 | |
| 56 | // Call services as the signed-in person (Network session cookie). |
| 57 | const client = createClient({ credentials: 'include' }); |
| 58 | const community = createCommunityClient(client); |
| 59 | const { pastes } = await community.pastes.list({ limit: 20 }); |
| 60 | |
| 61 | // Live updates, resumable across reloads. |
| 62 | const sub = subscribe(['live.stream.*'], (event, { cursor }) => { render(event); localStorage.setItem('cursor', cursor); }, { |
| 63 | client, lastEventId: localStorage.getItem('cursor'), onGap: () => refetchEverything(), |
| 64 | }); |
| 65 | ``` |
| 66 | |
| 67 | **The token exchange happens on your server.** First-party clients and confidential developer apps have a client secret, and a secret must never reach a browser. A public developer app has no secret, but its server should still do the exchange and keep the token in an HttpOnly session. The browser entry points contain no exchange code. A test scans every file a browser bundle can reach (and the browser bundle itself) and fails on `client_secret`, `node:` imports, `require('crypto')` or `process.env`. |
| 68 | |
| 69 | ### Server service (service principal) |
| 70 | |
| 71 | ```js |
| 72 | const { createClient } = require('openvibe-sdk/core'); |
| 73 | const { createServiceTokenClient, exchangeCode, verifyUserToken } = require('openvibe-sdk/auth'); |
| 74 | const { createEventsClient } = require('openvibe-sdk/events'); |
| 75 | const { createIdentityClient } = require('openvibe-sdk/identity'); |
| 76 | |
| 77 | const tokens = createServiceTokenClient({ clientId: 'live', clientSecret: process.env.OV_CLIENT_SECRET }); |
| 78 | const client = createClient({ |
| 79 | tokenProvider: tokens, // one cached token per audience (openvibe.events, openvibe.media, …) |
| 80 | baseUrls: { network: 'http://127.0.0.1:4000', events: 'http://127.0.0.1:4300' }, // same-host services; others come from discovery |
| 81 | }); |
| 82 | |
| 83 | // Continue the incoming request's trace on every outbound call. |
| 84 | app.use((req, _res, next) => { req.ov = client.fromRequest(req); next(); }); |
| 85 | |
| 86 | const events = createEventsClient(client, { source: 'live' }); |
| 87 | await events.publish({ event_type: 'live.stream.started', actor: { type: 'service', id: 'live' }, subject: { type: 'stream', id: '12' } }); |
| 88 | |
| 89 | const who = await createIdentityClient(client).resolve({ system: 'live', id: 123 }); // -> { subject, username, … } | null |
| 90 | |
| 91 | // The browser's /auth/exchange from above: |
| 92 | const t = await exchangeCode({ code, codeVerifier, redirectUri, clientId: 'my-app', clientSecret: process.env.OV_OAUTH_SECRET }); |
| 93 | const claims = await verifyUserToken(t.access_token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.network' }); |
| 94 | claims.subject_id; // usr_… |
| 95 | ``` |
| 96 | |
| 97 | ### External developer (a developer app) |
| 98 | |
| 99 | Developer apps belong to a project on OpenVibe.Network ([developer projects](https://github.com/OpenVibers/OpenVibe.Network/blob/main/docs/developer-projects.md)). An app is a principal `app_<ULID>`: `confidential` (has a secret, may use client credentials) or `public` (no secret, authorization code + PKCE only), in `sandbox` or `production`. Its tokens last 5 minutes, carry one audience, the approved capabilities (`cap`), `project_id`, `env` and `ns: [project_id]`, and there are no refresh tokens. |
| 100 | |
| 101 | A server app with a secret and a grant (here `media.object.upload`) uploads into its project's Media namespace: |
| 102 | |
| 103 | ```js |
| 104 | const sdk = require('openvibe-sdk'); |
| 105 | |
| 106 | const tokens = sdk.auth.createServiceTokenClient({ clientId: process.env.OV_CLIENT_ID, clientSecret: process.env.OV_CLIENT_SECRET }); |
| 107 | const client = sdk.createClient({ tokenProvider: tokens }); |
| 108 | await client.discover(); // origins + contracts version from https://openvibe.network/.well-known/openvibe |
| 109 | const { scope, unverifiedClaims } = await tokens.getTokenInfo({ audience: 'openvibe.media' }); // what Network granted (display only) |
| 110 | const media = sdk.media.createMediaClient(client, { app: unverifiedClaims.project_id }); // Media tenancy is the project id |
| 111 | const file = await media.upload(fs.readFileSync('logo.png'), { filename: 'logo.png', contentType: 'image/png' }); |
| 112 | file.public_url; // https://openvibe.media/f/<key> |
| 113 | ``` |
| 114 | |
| 115 | For large files, use Media's resumable multipart object API. The uploader uses 8 MiB parts, |
| 116 | three concurrent requests, retries missing parts, and exposes the upload reference before |
| 117 | any bytes leave so the app can persist it. Keep the credential on a server. |
| 118 | |
| 119 | ```js |
| 120 | const uploader = sdk.mediaUpload.createUploadClient({ app: unverifiedClaims.project_id, tokenClient: tokens }); |
| 121 | const object = await uploader.upload(file, { |
| 122 | onSession: ({ objectId, uploadId }) => saveUploadReference({ objectId, uploadId }), |
| 123 | onProgress: ({ uploadedBytes, totalBytes }) => reportProgress(uploadedBytes, totalBytes), |
| 124 | }); |
| 125 | // After a restart: uploader.resume(savedReference, sameFile, { onProgress }); |
| 126 | ``` |
| 127 | |
| 128 | On a page that loads the pinned `ov-upload.js` and `ov-upload.css` from |
| 129 | `openvibe-shared`, `sdk.ui.progress(el)` mounts the same progress component used |
| 130 | across OpenVibe sites. |
| 131 | |
| 132 | The token's `env` picks the tenant: a sandbox app's files go to the project's sandbox tenant (`app_id: '<prj_…>-sandbox'`) and are never served publicly. Media answers them with `sandbox: true`, a signed, expiring `url` and `url_expires_at`; the client adds `signed_url` (the same URL) and sets `public_url` to `null`. Uploading and deleting need `media.object.upload`, listing and getting `media.object.read`. |
| 133 | |
| 134 | App events (`events.app.publish | read | subscribe`) live under the project's own topic `app.<project_key>.*` (`project_key` = `p` + the lowercased project ULID) with `source: app-<lowercased app ULID>`. `createAppEvents()` fills that in, and scopes pulls and subscriptions to the project: |
| 135 | |
| 136 | ```js |
| 137 | const { createAppEvents } = require('openvibe-sdk/events'); |
| 138 | const events = createAppEvents(client, { projectId: unverifiedClaims.project_id, appId: process.env.OV_CLIENT_ID }); |
| 139 | await events.publish({ event_type: 'order.shipped', subject: { type: 'order', id: 'o1' }, payload: { n: 1 } }); |
| 140 | // -> app.p01k5….order.shipped, source app-01k5…, actor { type: 'app', id: 'app_…' } (onBehalfOf: the person instead) |
| 141 | const page = await events.pull({ topic: 'order.*', platformTopics: ['live.stream.*'], after: saved }); // + public first-party events; save page.next_cursor |
| 142 | await events.subscribe({ topicPattern: '*', endpoint: 'https://hooks.example.com/openvibe' }); // public https only |
| 143 | ``` |
| 144 | |
| 145 | Events keeps sandbox and production apart: a sandbox app sees and receives only its project's sandbox events (and public first-party ones), a production app only production ones. Realtime (SSE) never streams app events; pull or subscribe. `projectKey(projectId)` and `appSource(appId)` are exported for code that builds envelopes itself. |
| 146 | |
| 147 | "Sign in with OpenVibe" for an app: the code yields an app token that acts for the person (`on_behalf_of: usr_…`). |
| 148 | |
| 149 | ```js |
| 150 | const { startAuthorization, readCallback, exchangeCode, verifyAppToken } = require('openvibe-sdk/auth'); |
| 151 | |
| 152 | // /login: capability ids as scope, and the audience the token is for. |
| 153 | const { url, state, codeVerifier } = await startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] }); |
| 154 | // /callback: |
| 155 | const { code } = readCallback(req.url, { expectedState: state }); |
| 156 | const t = await exchangeCode({ clientId, clientSecret /* omit for a public app */, code, codeVerifier, redirectUri, audience: 'openvibe.media' }); |
| 157 | t.refresh_token; // undefined: when it expires, the person signs in again |
| 158 | |
| 159 | // A service receiving app tokens verifies them offline: |
| 160 | const claims = await verifyAppToken(token, { jwks: 'https://openvibe.network/api/.well-known/jwks', audience: 'openvibe.media' }); |
| 161 | claims.project_id; claims.env; claims.on_behalf_of; // env=sandbox is refused (token.sandbox_refused) unless acceptSandbox: true |
| 162 | ``` |
| 163 | |
| 164 | Manage projects, apps, secrets and grants with `openvibe-sdk/projects` and a Network user token: |
| 165 | |
| 166 | ```js |
| 167 | const projects = require('openvibe-sdk/projects').createProjectsClient(sdk.createClient({ token: userAccessToken })); |
| 168 | const prj = await projects.create({ name: 'My app' }); |
| 169 | const app = await projects.apps.create(prj.id, { name: 'server', environment: 'sandbox', type: 'confidential' }); |
| 170 | app.credential.client_secret; // shown ONCE: store it now |
| 171 | await projects.grants.request(prj.id, app.id, 'media.object.upload'); |
| 172 | ``` |
| 173 | |
| 174 | Run a tool, or a Tools job, and follow it across disconnects and restarts (see [Tools](#tools)): |
| 175 | |
| 176 | ```js |
| 177 | const tools = require('openvibe-sdk/tools').createToolsClient(client); |
| 178 | const done = await (await tools.run('webp', {}, { files: [{ name: 'a.png', data: bytes }] })).wait(); |
| 179 | |
| 180 | const jobs = tools.jobs; // or createJobsClient(client): the gateway's /api/v1/jobs facade (Tools S6) |
| 181 | const { job } = await jobs.submit({ type: 'img.process', input: { tool: 'convert', format: 'webp' }, files: [{ name: 'a.png', data: bytes }], idempotencyKey }); |
| 182 | for await (const e of jobs.events(job.id, { lastEventId: saved })) saveId(e.id); // reconnects with Last-Event-ID |
| 183 | const res = await jobs.file(job.id, 0); // raw Response |
| 184 | ``` |
| 185 | |
| 186 | Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client at the satellite that runs the type: `createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' })` (img, audio or docs). |
| 187 | |
| 188 | `test/testing.test.js`, `test/apps.test.js`, `test/app-events.test.js`, `test/mock-services.test.js`, `test/projects.test.js`, `test/jobs.test.js` and `test/tools.test.js` run these flows end to end against `openvibe-sdk/testing`. What the live platform allows today (sandbox audiences, allowances, which capabilities are grantable) is in the Network doc above and in OpenVibe.Examples' README. |
| 189 | |
| 190 | ## API |
| 191 | |
| 192 | | Subpath | Where | What | |
| 193 | |---|---|---| |
| 194 | | `openvibe-sdk/core` | both | `createClient()`, `OpenVibeError`, `paginate()`, `offsetPager()`, trace and id helpers, `CONTRACTS_RANGE` | |
| 195 | | `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `createNetworkKeys()`, `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` | |
| 196 | | `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` | |
| 197 | | `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})`; `createAccountEmailsClient({getToken})` manages the signed-in person's verified addresses ([guide](docs/identity-emails.md)) | |
| 198 | | `openvibe-sdk/trust` | both | `createTrustClient({networkUrl, getToken})`: `me()` reads the caller's own account or guest signals; `of(subject)` reads an account or guest with `identity.trust.read`; `isNew(signals, {hours})`, `atLeast(signals, band)`, `AGE_BANDS` | |
| 199 | | `openvibe-sdk/user-card` | both | `createUserCardClient(client)`: `get(username)` reads the public Network aggregate (display name, avatar, level, streak, badges, followers, active sites). No credentials; hidden or unavailable fields stay absent ([guide](docs/user-card.md)) | |
| 200 | | `openvibe-sdk/notifications` | server | `createNotificationsClient(client)`: `push({subjectId \| userId, type, title, message, url, category, priority, …})` through Network (`network.notifications.push`); `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`; attempted once, the caller retries | |
| 201 | | `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services | |
| 202 | | `openvibe-sdk/events` | server | `createEventsClient(client, {source})`: `publish`, `prepare`, `pull`, `iterate`, `get`, `get/setCheckpoint`, `subscriptions.create/list/get/disable/enable` (`subscribe`), `deliveries`, `replay`; `parseDelivery` (v1 and v2, `requireV2`), `verifyDeliveryV2`, `signDeliveryV2`, `signDeliveryHeaders`, `verifyDelivery`, `signDelivery`; developer apps: `createAppEvents(client, {projectId, appId, onBehalfOf?})` (same calls, scoped to `app.<project_key>.*`), `projectKey`, `appSource`; `createOutbox`, `createInbox`, `createPgOutbox`, `createPgInbox`; `createServiceOutbox({db, source, eventsUrl, clientId, clientSecret, eventTypes?, validate?})` (a service's outbox in one call: `emit`/`emitIn` inside the change's transaction, `moderationAction`, `start`/`stop`/`kick`/`status`) | |
| 203 | | `openvibe-sdk/sso` | server | `createSsoClient({site, baseUrl, clientId, clientSecret, networkUrl, networkInternalUrl, secureCookies})`: a product site's sign-in with OpenVibe.Network: `router(express)` (`/login` with state + PKCE S256, `/callback`, `/fedcm`, `/logout`, `/me`, `/refresh`), `optionalAuth()`, `requireAuth()`, `verify(token)` (offline, shared JWKS), `extractToken(req)`; `sanitizeNext`, `COOKIES` | |
| 204 | | `openvibe-sdk/realtime` | both | `subscribe(topics, onEvent, {lastEventId, onGap, …})`, `createRealtimeClient(client)`, `parseSSE(body)` | |
| 205 | | `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 | |
| 206 | | `openvibe-sdk/composer` | both | `createComposerClient(client, {mediaBaseUrl?, networkBaseUrl?})`: `attachments.upload(file, {onProgress, signal})` (multipart to Media through `XMLHttpRequest`, real browser progress), `attachments.remove(id)`, `composer.prefs.get()/put(prefs, rev)` (a stale revision is 409 `composer.rev_conflict`), `mentions.search(q, kinds)` | |
| 207 | | `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`, shared `comments.resolve/get/upload/create/edit/delete/visibility`, `as(subject)` | |
| 208 | | `openvibe-sdk/history` | both | Private history reads: cursor pages, title search, day/week summaries, recent agent reads, settings and owner-scoped deletes. | |
| 209 | | `openvibe-sdk/home-feed` | both | `createHomeFeedClient(client)` gives `ov.home.get()/set(layout)`, `ov.network.today()`, `ov.feed.list({ for: 'me', cursor })`, `ov.feed.post(text)`, and `ov.feed.contribute({ id, text, url })`. Personal calls need a user Bearer token; contribution needs an app token granted `community.pulse.contribute`. Today is public and cached by Network for five minutes. | |
| 210 | | `openvibe-sdk/feed` | both | `createFeedClient(client)` gives `ov.feed.list({ kind, cursor })` for the public cross-site Community Feed. Pass the returned `next_cursor` as `cursor` to read the next page. | |
| 211 | | `openvibe-sdk/views` | both | `createViewsClient(client)`: `record(event \| [events])` (up to 20 per call) sends a preview, watch or live heartbeat for `{ service, kind, id }` to `POST /api/v1/views`; `counts(ref)` reads `{ views, engaged, previews, preview_share, peak_concurrent }`. A signed-out viewer keeps an opaque session id; the service stores no IP or user agent and counts a preview from 1 s. | |
| 212 | | `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`), `embedUrl(robotId, mode)` (panel, overlay or compact), `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`) | |
| 213 | | `openvibe-sdk/themes` | both | `createThemesClient(client)`: `catalog({mode, search, sort, limit})` (seasonal themes carry `season`), `get(idOrSlug)` (null when missing), `export(idOrSlug)`, `active()` (`{theme_id, custom_variables, display}`), `set(themeId, {custom})`, `setDisplay({motion, text, seasonal})`, `seasons({date})`, `submissions()`, `submit(theme)`, `import(file)`. In the browser: `followTheme(fn)` calls back now and on every theme, display or season change with `readTheme()` (`{id, mode, tokens, display: {motion, text, textScale, seasonal, calm}, season, seasonMark}`) and returns `stop()`, for a canvas or game to keep up; `applyTheme(vars, {root})`, `seasonOn(date, seasons)`, `onColor(hex)`, `DISPLAY_OPTIONS`, `TOKENS` | |
| 214 | | `openvibe-sdk/bench` | both | `createBenchClient(client, {actingSubject?})`: `kinds.list/get`, `tasks.create/get`, `rounds.create/get`, `entries.submit`, `judgements.next/cast`, `boards.get`, `ratings.get` (public, at least twenty games), `models.get`, `datasets.get/iterate`, `evals.create/get/run/results`, `mcpTools()`. Audience `openvibe.bench`; service id `bench`. | |
| 215 | | `openvibe-sdk/vision` | both | `createVisionClient(client)`: image questions, OCR, private search, network tools, UI checks, consent and face privacy, and `/mcp`. Audience `openvibe.vision`; service id `vision`. | |
| 216 | | `openvibe-sdk/consent` | server | `createConsent({networkUrl, token, ttlMs?})`: the L24 check before any use of a person's content or likeness for AI or data. `allowed(subject, purpose, {kind, client, feature})` → boolean, `filter(subjects, purpose, opts)` → the allowed subset (batches of 500, input order), `onChange(event)` drops that subject's answers for the purpose on `network.consent.changed`, `mock(map)` for tests. **Fails closed** (network error, 2 s timeout, non-200 or malformed answer → false); caches 60 s; logs at most once a minute. [Example](docs/consent.md) | |
| 217 | | `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), `code(text, opts)` for merged code results or `{deep: true}` for a cited answer, `codeIterate(text, opts)` | |
| 218 | | `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()` | |
| 219 | | `openvibe-sdk/account-data` | node | `createAccountData({db, service, tables: [{table, subject, value?, file?, columns?, erase?}], extraExport?, extraErase?})` → `.apply(event, {send})`, `.consumer({secrets, send, onEvent?})` (a POST /internal/events handler), `.exportPart(subject)`, `.erase(subjects)`, `.ensureSchema()`; `createNetworkSender({networkInternalUrl, clientId, clientSecret})`; `startSubscriptions({eventsUrl, endpoint, secret, networkInternalUrl, clientId, clientSecret})` (the two Events subscriptions, created at boot when missing): a service's share of account export and deletion (ADR-033), idempotent per export/deletion id; telemetry is covered once here (the person's `telemetry_samples` exported as `telemetry.json`, its `subject` set to NULL and the sample kept) unless the table is listed; `uncovered(db, {tables, taskKeyed?, exempt?})` lists the tables with a person column (`subject`, `owner`, `created_by`, `user_id`, `person`) that nothing covers, for a service test to assert is empty | |
| 220 | | `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember, react), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `activity.*` (list, unread, readOne, read: what happened to the person in Chat), `embeds(message)` (the typed `chat.embed@1` address previews on a message), `exportMine()`, `vibe.*` (list, start, rename, remove: chats with Vibe), `inboxes.*` (list, create, get, update, remove, addMember, removeMember, conversations, messages, reply, setStatus, read, identity, replies, saveReply, deleteReply, webhooks, addWebhook, testWebhook, removeWebhook: a support inbox's team), `support.*` (list, messages, send: their own conversations with sites' chats), `feeds.*` (mine, create, update, rotateKey, remove, view, onLive). With no account: `createInboxVisitor(client, { inbox, token, user, account })` (visit, send, messages, handoff, rate: your own panel for an inbox, from any site) and `createFeedPublisher(client, { feed, key })` (hello, publish, claudeCode: vibe coding events with the feed's key) | |
| 221 | | `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, latency, deadlineHours, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `batches.create/get/list/cancel`, `creations()` (the public catalogue of what AI can make, no token sent), `chat/generate/summarize/classify/extract/enrich/embed`. A run may ask for the class it may wait in (`latency`: `interactive`, `background`, `batch`; `deadlineHours`: 1–72 for a batch run); `waitFor` gives a batch run back pending rather than throwing when the wait is over — the answer comes with the run's completion event or webhook. | |
| 222 | | `openvibe-sdk/jobs` | both | `createJobsClient(client, {baseUrl?})`: `submit` (Idempotency-Key), `get`, `cancel`, `retry`, `reference`, `unreference`, `events` (SSE, reattaches), `wait`, `file` (raw Response); `isTerminal()`. Default origin: the Tools gateway's jobs facade | |
| 223 | | `openvibe-sdk/agents/loop` | both | The light agent loop (a port of pi's, MIT): `runLoop({ model, tools, system, messages, prompts, budget, beforeToolCall, afterToolCall, transformContext, steering, followUp, finishTurn, onEvent, signal })` resolves `{ messages, stop, usage, cost, turns }` (stop: done, terminated, budget, turns, aborted, error). Tools in parallel (results in the model's order) or in sequence; an unknown tool, refused input, a throw or a timeout comes back to the model as an error; a call cut off by the output limit never runs; repeats get a reminder; idempotent tools answer from a memo; long outputs are clipped. `createStepSink({ append, update, delta, startSeq, startTurn })` turns its events into `agents.step@1` steps | |
| 224 | | `openvibe-sdk/agents/dispatch` | both | The Dispatcher, as pure functions over a person's choices: `ladder(choices, { need, pin, avoid, budgetLeft })` (their own machine, a signed-in plan, their key, the free allowance, then credits, cheapest first; what cannot do the step is listed in `.skipped` with why; a person's pick wins when it can), `pick`, `escalate(current, evidence, choices)` (only on a failed test, schema, tool or grader, or the model's own "not sure"), `harnessFor(task, harnesses, { plans })`, `placeFor(run, { machines, cloud })`, `estimate(choice, tokens)`, `slice(parentBudget, share)` | |
| 225 | | `openvibe-sdk/agents/blueprint` | both | Make your own agent (agents.blueprint@1, ten parts): `validate(doc)` (the shape and the rules a schema cannot say: no secret in it, every `{{input}}` declared, level full has a budget, machine and robot tools have somewhere to run, a served tool has its shapes, a team never contains itself), `withDefaults` (talks only, asks first, keeps nothing), `asks(doc, { describe })` (the install screen in words, with the keys `agents.install@1` records), `wider(old, new)` (what a new version asks for that the old did not), `unagreed(doc, agreed)`, `fill(doc, inputs)`, `compile(doc, { install, inputs, text, tryIt })` (run-start, the loop's system and tools, the Dispatcher's need, places, memory, team; Try it asks before every tool and spends at most 5¢), `estimate(doc, choices)` (the price of a run and where it is free), `ref` / `parseRef` | |
| 226 | | `openvibe-sdk/agents/rules` | both | Permissions as data (agents.permission-rule@1): `decide(rules, call, { attended, level })` (the last matching rule wins; with none, the level; unattended work never waits; the floor always asks, and unattended it is denied), `fromBlueprint(doc)` (only the tools it names, never `.git/hooks` or its own config, its sandbox and network), `matches` | |
| 227 | | `openvibe-sdk/agents/router` | both | Pay for the strong model only when it matters (RouteLLM's shape over OpenVibe Decisions): `question(step)`, `probability(answer)`, `route({ p, threshold, small, strong })`, `calibrate(history, { strongShare })` (the threshold that sends that share strong), `outcomes(history)` (the quality-cost points), `chain(step, { pick, pin, ask })` | |
| 228 | | `openvibe-sdk/agents/metrics` | both | What a run measured (agents.run@1 1.4.0 metrics): `createRecorder()` (charged cost first, an estimate apart, unknown is null; tokens with cache; steps; latency; a named exit; evidence), `passAtK`, `passHatK`, `flakeRate` | |
| 229 | | `openvibe-sdk/ai-meter` | both | Every product meters its AI calls the same way (ai.call.recorded@1 1.1.0, ai.call-report@1): `startCall({ system, provider, model, run_id|turn_id, operation, task, task_id, harness, place })` → `queued(ms)`, `firstToken()`, `tool()`, `end({ usage, outcome, charged_usd, fallbacks, error_class })` (TTFT, latency, tokens per second after the first token); `normalize(usage)` (OpenAI chat and Responses, Anthropic, Gemini, Ollama; the uncached input); `createMeterClient({ aiUrl, tokenClient })` (batches to AI's POST /api/v1/usage/calls, retries what can be retried, never resends a refused batch) | |
| 230 | | `openvibe-sdk/agents/api` | server | The Run API from a product's own store: `createRunApi({ product, subject, store, prefix })` returns `handle(req, res, next)` for Express or any Node http server, serving `GET/POST /runs`, `GET /runs/:id`, `…/steps`, `…/events` (server-sent events, from the store's `follow` or by reading new steps), `POST …/messages`, `…/stop`, `…/approvals/:approval`. The store has `list, get, steps, start, say, stop, answer, follow?`; a method it lacks answers 409. No subject is 401, another person's run 404, a start without words 422; every run carries its ref; a store's own `{ status, code }` errors go out as they are and anything else is a 500 that says nothing of the store; a mandate from a caller is dropped (intake only) | |
| 231 | | `openvibe-sdk/agents` | both | `createAgentsClient(client, {baseUrl?, service?, audience?})`: `run(id)` fetches an agent run snapshot (null when missing), `events(id, {lastEventId, signal})` follows the resumable AG-UI event stream, `post(id, approval)` answers an approval request | |
| 232 | | `openvibe-sdk/tools` | both | `createToolsClient(client, {baseUrl?, anonymousReads?})`: `list({family, q, execution, api, status})`, `get(id)`, `schema(id)`, `run(id, input, {files, waitMs, idempotencyKey, signal, timeoutMs, prefer})` (+ `.wait()`), `jobs`; `ToolRunError`, `isToolRunError()`; `prefer` picks the worker placement (`auto`, `home`, `host`) ([details](docs/tools-routing.md)) | |
| 233 | | `openvibe-sdk/projects` | both (user token) | `createProjectsClient(client)`: `catalog`, `list`, `create`, `get`, `update`, `archive`, `setAllowance`, `setEnvironmentPolicy`, `members.*`, `apps.*`, `credentials.list/rotate/revoke`, `grants.list/request/approve/deny/revoke`, `quotas.*`, `audit`, `iterateAudit` | |
| 234 | | `openvibe-sdk/resources` | server | `parseResourceName`/`resourceName`/`resourceNameOf` (OVRN, via the pinned `contracts.resources`) and `RESOURCE_KINDS` (only the chosen `med`/`wch` prefixes); `createResourceIndex({authorities, token, fetch, pageLimit, concurrency, timeoutMs, maxPages, maxMs})`: `list({project, kind, owner, limit})` -> `{resources, stale}` (`owner`: a subject id, each authority answering only what it owns), fanning out over the authorities' `GET /api/v1/resources`, following each opaque `next_cursor` and merging `common.resource-list-result@1` pages (an authority that fails — or whose cursor chain repeats, outruns `maxPages`, or outruns `maxMs` — is reported in `stale`, never fatal), and `iterate`; `createResourceClient({origin, token})`: `control(request)` POSTs a `common.resource-control-request@1` to `/api/v1/resources/control` — idempotency-keyed, transient failures retried with the same key and body, a thrown error carrying that `idempotencyKey` — and returns the `common.resource-control-result@1` (`done`/`pending`/`refused`/`failed`, a refused/failed one returned even with a non-retryable non-2xx) | |
| 235 | | `openvibe-sdk/geo` | both | `createGeoClient(client, {samples?, timeoutMs?})`: `nodes({role, region})` from Network's public node registry, `measure(nodes)` (beacon round trips, fastest first), `nearest({role, region, preferRegion})` → `{node, rtt_ms, measured}` (up/degraded nodes only; falls back to preferRegion when nothing can be measured) | |
| 236 | | `openvibe-sdk/openre` | server | `createOpenReClient(client, {baseUrl?, publicUrl?})`: OpenRestream, the streaming engine. `streams` (`byExternalRef`, `create`, `get`, `update`, `rotateKey`, `destinations`, `addDestination`), `destinations` (`test`, `start`, `stop`, `logs`), `sessions` (`get`, `playback` cached 10 s, `end`, `outputs`), `workers`, `manageUrl`; `{subject}` acts for a person (X-OV-Subject) | |
| 237 | | `openvibe-sdk/placement` | server | `plan(requirements, offers, {rateCards, states, now, current, minGain, weights, success})` → `platform.placement-result@1`; `place(requirements, offers, {…plan, person, step, floor, priors, outcomes, rules, names})` → `{result, line, ranked, rejected, fallback}`: the person's rules and ownership, the quality bar, cost per success and one sentence saying why; `optionKey`, `learn`, `successOf`, `escalate`, `lineFor`; `TRUST_CLASSES`, `TRUST_ORDER`, `DEFAULT_TRUST`; `planAi(requirements, deployments, {enabled, usage, evidence, rates, current})` → `{chosen, rejected, objective, estimate, explain}`: the AI Exchange face of the same planner (hard gates, the cost per success in nano-USD, sparse evidence marked), off by default ([details](docs/placement.md)) | |
| 238 | | `openvibe-sdk/play` | server | `createPlayClient(client)`: OpenVibe.Play's achievements and leaderboards for any game — `titles.define(title, {name, url?, summary?, achievements, boards})`, `scores.post(title, {board, ref, entries})`, `achievements.unlock(title, {player, achievements})`, `players.adopt(title, guest, person)`, `players.achievements(title, player)`; public `titles.list/get` and `boards.get(title, board, {period, page})` need no token | |
| 239 | | `openvibe-sdk/download` | server | `createDownloadClient({ key })`: OpenVibe.Download, a person's drive — `files.list/all/get/remove`, `folders.list/create/ensurePath(path)`, `upload({ path \| data, name, folderId })` in parts, `usage()`, and `sync(dir, { to, delete, dryRun, exclude })` backups. The `openvibe-download` command: `sync <folder> --to <drive/path>`, `ls`, `usage` (`OPENVIBE_DOWNLOAD_KEY`, a sync key from openvibe.download/sync) | |
| 240 | | `openvibe-sdk/quest` | server | `createQuestClient(client)`: `daily()`, `myDaily()`, `today()` (authenticated current-day response), `stepTypes()`, `contributeStepType(step)` for the shared daily quest and reviewable event-backed steps (`quest.steps.contribute`); `cardData({ token, timeZone })` for the shared quest card (today's quest, the catalog kept ten minutes, and the person's badges/quests), with the person's Network token; `appQuests.define/list/retire` for an app's own badge quests (`quest.app_quest.manage`); `eventType(projectId, name)` builds an app event topic. App quests pay badges only; production events count | |
| 241 | | `openvibe-sdk/live` | server | `createLiveClient(client)`: OpenVibe.Live's documented routes. `streams.list/get/recentlyOnline/mine/update/end/heartbeat/endpoint`, `slots.list/create/update/remove/regenerateKey` (a slot is a saved setup with its own stream key), `goLive(slotId, {title, category, tags})` → `{ stream, endpoint }`, `channels.get/mine/update/toggleFollow/url`, `content.feed/moments({type, sort, window, cursor, limit})`; WHIP: `whip.url(streamKey)`, `whip.publish(url, offerSdp, {key?})` → `{ answer, location }` (the client's own token never goes to `/whip`), `whip.end(location)`. Writes are never retried | |
| 242 | | `openvibe-sdk/streams` | server | `createStreamsClient(client, {baseUrl?})`: an agent's own stream on OpenVibe.Live (the Agent Streams plan, S1; `live.agent-stream@1`, an `ags_…` id with no stream key and no slot). `startAgentStream({title, task, visibility, allowSites})` → `{stream, publish}` (the WHIP url and the one-time `wpt_…` ticket with its expiry), `renewTicket(id)` and `revokeTicket(id)` (the kill switch: Live ends the WHIP session at once), `state(id, {ticket})` (what the desk polls: viewers, `encode`, the curtain, `ends_at`), `postFrame(id, bytes, {ticket, contentType})` (the last frame while Live wants no video, JPEG or WebP at most 200 KB), `setCurtain(id, up, reason)`, `setVisibility(id, v)`, `end(id)` and `viewTicket(id)`. The publish ticket is a bearer of its own on the desk routes; a bad id, visibility, curtain reason or an oversized frame is refused before anything leaves. [Docs](docs/streams.md) | |
| 243 | | `openvibe-sdk/inventory` | server | `createInventoryClient(client)`: OpenVibe.Inventory, the network-wide inventory (ADR-054). Public reads: `kinds.list/get`, `definitions.list({kind, issuer})/get`, `people.items(subject)`, `people.holdings(subject, opts)`, `people.holdingsCount(subject, opts)`, `people.holdingPages(subject, opts)`, `people.equipped(subject)`, `equipped([subjects])` (≤ 100, for chat renders). The person's own: `me.items()`, `me.equip({kind, slot, instance_id | null})`. An issuer's: `definitions.create/update`, `grants.create({definition_id, subject, idempotency_key})` → `{ instance, created }` (retry-safe on the key), `instances.consume/revoke`. `forPerson(usr_…)` sends `X-OV-Subject` for a service acting for a person. Audience `openvibe.inventory` | |
| 244 | | `openvibe-sdk/tips` | server | `createTipsClient(client)`: OpenVibe.Tips' `/api/v1`. A creator's page: `profiles.get/update/totals/sounds/supporters`. Tipping: `tip({creator, amount, message?, goal_id?, pay_with})` → `{ interaction, checkout_url, checkout_ref }`, `send.message/tts/media/sound`. Receipts: `interactions.get/list/recordExternal/hide/restore`. `goals.list/get/create/update/close`, `moderation.queue/log/moderators.*`, `overlays.tokens.*/configs.*/simulate`, `me.export/erase`. Every write carries an Idempotency-Key, which Tips replays once, so retries are safe. Audience `openvibe.tips` | |
| 245 | | `openvibe-sdk/blog` | server | `createBlogClient(client)`: OpenVibe.Blog for the person who authorized the app. `blogs.get/posts(handle, {limit, offset, all})/feed` (JSON Feed), `changelog({service, kind, area, q, limit, before})` (the network changelog's `blog.changelog-entry@2` rows), `posts.create(handle, {title, body, summary, visibility, …})` → `{ post, revision }`, `posts.get/update(id, {…, expected_revision})/publish(id, {revision})/unpublish/schedule(id, {at, revision, action})/cancelSchedule/revert/remove/revisions/revision(id, n)`. Public capabilities since openvibe-contracts 0.206.0; publish, unpublish, schedule and delete are sensitive. Creating and reverting are never retried on their own. Audience `openvibe.blog` | |
| 246 | | `openvibe-sdk/reviews` | server | `createReviewsClient(client)`: OpenVibe.Reviews. `resolve({name, url, gtin, sku, mpn, source, external})` (a match or candidates; a name alone never matches), `entities.list({q, limit, offset})/get(ref)/history/aggregate/summaryRevision(ref, n)`, `corrections.submit(ref, {body, target_type, target_id, evidence_url})` (to the editors' queue for the person, never retried on its own). Public capabilities since openvibe-contracts 0.207.0. Audience `openvibe.reviews` | |
| 247 | | `openvibe-sdk/wiki` | server | `createWikiClient(client)`: OpenVibe.Wiki's reads. `search(q, {space, limit})`, `spaces.list/get(space)`, `pages.get(id, {revision})/revisions(id, {before, limit})/revision(id, n)/diff(id, {from, to, mode})/citations(id, n)`: what the person may read (without one, public pages). Public capabilities since openvibe-contracts 0.207.0. Audience `openvibe.wiki` | |
| 248 | | `openvibe-sdk/coupons` | server | `createCouponsClient(client)`: OpenVibe.Coupons. `merchants.resolve(host)/get(id)/coupons(id)` and `get(cpn_…)` (public data), `submit({code, title, merchant_id \| host \| url, evidence_url, …})` (sensitive; never retried on its own) and `report(cpn_…, {outcome, reason})` for the person. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.coupons` | |
| 249 | | `openvibe-sdk/deals` | server | `createDealsClient(client)`: OpenVibe.Deals. Public: `offers.list({sort, page})/get/hotness`, `products.get`, `stores.get`. For the person: `offers.submit` (sensitive), `update`, `observe` (a new price observation), `vote(id, 1 \| -1)` (sensitive), `unvote`, `flag(id, {kind, reason})`, `watches.list/create/remove`. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.deals` | |
| 250 | | `openvibe-sdk/trade` | server | `createTradeClient(client)`: OpenVibe.Trade. `instruments.resolve(q)/list/get/observations(symbol, {metric, before, limit})/documents`, `sources()`; the person's `watchlists.list/get/create/rename/add/removeItem/remove` and `alerts.list/deliveries/create/remove`. Public capabilities since openvibe-contracts 0.208.0. Audience `openvibe.trade` | |
| 251 | | `openvibe-sdk/space` | server | `createSpaceClient(client)`: OpenVibe.Space, static sites for apps and CI. `deploys.uploadArchive(site, tarGz, {activate, preview, root})`, `uploadFiles(site, [{path, data}], …)`, `uploadSourceBuild(site, archive, {ref, commit_sha})`, `list/get/log/activate/rollback(site, {deploy_id})/remove`; `projects.list/create/get/remove/quota/sites/members.set(p, principal, role)/remove`; `sites.create/get/remove`, `sites.config.get/set/reset` (`{ headers, redirects, spa }`), `sites.source.get/set/remove`; `domains.list/add/verify/remove`. An app reaches only the projects a person added it to (deployer, maintainer or owner). Public capabilities since openvibe-contracts 0.209.0. Audience `openvibe.space` | |
| 252 | | `openvibe-sdk/host` | server | `createHostClient(client)`: OpenVibe.Host's public hosting catalog, sent without a token. `providers.list/get(slug)`, `plans.list({kind, min_vcpu, min_ram_mb, min_disk_gb, max_price, currency, location, provider, sort, cursor})/get(id)`, `promos.list`, `offers.list({sort, currency, max_monthly, source, priced, cursor})`, `domains.list/tld('dev')/check('example.dev')`, `benchmarks.list/get`. Any other filter is refused (422). Editing the catalog is first-party | |
| 253 | | `openvibe-sdk/redact` | any | The shared secret scrubber (Memory M2): `redact(text)` → `{ text, count }` removes the tool and service keys, PEM private key blocks (including one with no END line), authorization headers, bearer tokens, chat webhooks, connection-string passwords, credential-named assignments and Luhn card numbers, keeping the words around them (`password=[redacted]`); `onlySecret(text)` says an item was nothing but a secret. The same shapes and corpus as OpenVibe Link's `internal/memory/redact.go`, so Link and AI cannot drift | |
| 254 | | `openvibe-sdk/explicit` | any | `isExplicit(...texts)`: true when AI text says a scene shows sexual content (body parts, sex acts, pornography, nudity); such a moment, clip or paste is not published | |
| 255 | | `openvibe-sdk/no-looks` | any | **Deprecated (2026-10-31):** AI text may describe people. `noLooks`, `noLooksTags` and `hasLooks` return what they are given; `isExplicit` is re-exported from `openvibe-sdk/explicit` | |
| 256 | | `openvibe-sdk/front-page` | any | What may lead a front page (a latest or most viewed list, a quote strip, a feed): `frontPageOk(text)` is false for a built-in slur category (OpenVibe.Chat's, spaced out or leet-spelled included), strong profanity in English, Portuguese, Spanish, French or German, or a hate topic out of context; `containsCoreSlur(text)` is the slur check alone. Leave such an item off the front page and show the next one; it stays on its own page | |
| 257 | | `openvibe-sdk/news` | server | `createNewsClient(client)`: OpenVibe.News' published stories, sent without a token. `topics()`, `stories.list({topic, limit, offset})` (published and retracted, newest first) and `stories.get(id)` (sources, timeline, perspectives). Writing and the editorial reads stay first-party | |
| 258 | | `openvibe-sdk/food` | server | `createFoodClient(client)`: OpenVibe.Food. Public: `places({kind, q, lat, lon, radius})` (food banks, pantries, free meals from OpenStreetMap), `foods.list/get` (the food list with store prices). The caller's own: `plans.create({people, days, budget, title})/list/get` (deterministic meal plans with a shopping list and costs), `pantry.get/set(items)` (with "what can I make" matches), `recipes.suggest({ask, people})/list` (AI recipe ideas, 10 a day). Plans and recipe ideas are never retried on their own. Audience `openvibe.food` | |
| 259 | | `openvibe-sdk/chat-apps` | server | The three chat apps (Telegram, Discord, Slack) in one place: `verifyTelegram()`, `verifySlack()`, `verifyDiscord()`, `verifyDelivery()` (the delivery's own proof), `signCallback()` / `verifyCallback()` (a button callback: signed once, used once, expiring), `parseTelegramUpdate()`, `parseDiscordInteraction()`, `parseSlackEvent()`, `parseSlackInteraction()` (each app's payload as the same shape: who said it, where, whether it was said to the bot) and `splitText()` (an answer split the app's way) | |
| 260 | | `openvibe-sdk/actor` | server | `createActorClient(client)`: OpenVibe.Actor. `agents.list()` and `route({task, mode})` (a dry run) are public. `tasks.create({task, mode, budget, agent, idempotency_key, webhooks})` (retried only with an idempotency_key; `webhook_secret` comes back once), `tasks.list/get/cancel`, `tasks.events(id, {lastEventId})` (SSE until the task ends). A person's own agents: `me.agents.list/create/get/update/remove/run/runs` and `me.agents.memory.list/add/update/remove/clear`. Audience `openvibe.actor` | |
| 261 | | `openvibe-sdk/menu` | server | `menuCard({id, site, title, kind, items, more, origin, ttlSeconds, updatedAt})`: one validated `frame.menu-card@1` — plain text cut to the contract's lengths, at most six items (the contract's cap), https links on the site's own origin or an `openvibe.*` host, an optional item `image` (`{src, w, h, alt}`, a picture from elsewhere is dropped), and `more` (`{label, url}`). `menuRoute(getCards, {maxAgeSeconds = 300, origin, log})`: the Express/node:http `GET /.well-known/openvibe-menu.json` handler, answering an array of at most three cards with `Cache-Control: public, max-age=…` and CORS `*`; a `getCards()` failure is `[]` (never a 500) and logs once. `MENU_PATH` | |
| 262 | | `openvibe-sdk/run` | server | `createRunClient(client, {project?})`: OpenVibe.Run, jobs on worker nodes. `jobs.submit({class, artifact, args, limits, …})` (Idempotency-Key, so retried safely) → `{ created, job }`, `jobs.list({state, limit, cursor})`, `jobs.get/cancel`, `jobs.events(id, {lastEventId})` (SSE until `end`), `jobs.streamTicket(id)` (a one-use URL for a browser's EventSource); `forProject(prj_…)` sends X-OV-Project. Audience `openvibe.run` | |
| 263 | | `openvibe-sdk/video` | server | `createVideoClient(client)`: OpenVibe.Video's playlists of recordings and clips. `playlists.list/create/get/update/remove`, `playlists.items.add/reorder/remove` (`{ kind: 'vod' \| 'clip', id }`), `users.playlists(username)` (public). Private playlists are 404 to everyone but their owner. Audience `openvibe.download` (the media hub's) | |
| 264 | | `openvibe-sdk/work` | server | `createWorkClient(client)`: OpenVibe.Work. Public: `jobs.search({q, remote, type, location, cursor})`, `jobs.get`, `sources()`. A person's: `savedSearches.list/create/remove`, `applications.list/create/get/update/remove` (also an app with `work.application.manage`; tracking a listing twice answers the first), `applications.assist(id, {ask})` (their Actor agent helps). Audience `openvibe.work` | |
| 265 | | `openvibe-sdk/zone` | server | `createZoneClient(client)`: OpenVibe.Zone, a project's object zones in Media. `zones.create(prj_…, {env, name, description, quota_bytes})` (Idempotency-Key), `zones.list(prj_…, {env, include_deleted})`, `zones.get/update/remove(zon_…, {recursive})`, `zones.usage(zon_…)`. Project members and the project's apps only. Audience `openvibe.zone` | |
| 266 | | `openvibe-sdk/vip` | server | `createVipClient({baseUrl?, tokenClient \| getToken, fetch?, timeoutMs?})`: `evaluate({subject, resource, owner, fallback, mode})`, `checkEntitlement({subject, creator, product, mode})`, `isMember(subject, creator)`; every failure is a denial. `createVipCache({vip, ttlMs, denyTtlMs, unavailableTtlMs})`: `entitlement`, `peekEntitlement`, `evaluate`, `invalidate`, `handleEvent` (`vip.membership.changed` and Billing's entitlement events drop a member's answers at once), `clear`, `bounds` | |
| 267 | | `openvibe-sdk/usage` | server | `usageSample(fields)` builds a `platform.usage-sample@1` record (required `id`, `idempotency_key`, `service`, `operation`, `quantity`, `unit`; `at` defaults to now ISO, `source` to `openvibe-sdk/usage`; null fields stripped; money fields never invented); `usageKey(service, ...parts)` joins an idempotency key with `:`; `validateUsageSample(record)` → `{ok, errors}` (openvibe-contracts, required lazily). `createUsageReporter({ db, service, source, table, billingUrl, tokenClient, audience, requireProject, ... })` owns one service's readings: `key(...parts)`/`sample(fields)`, and `record(t, reading)` queues the reading idempotency-keyed in the outbox inside the caller's transaction, relayed to Billing's `billing.usage.record` with backoff (a retry is never dropped; only Billing refusing the reading itself — any 4xx but 401/403/404/408/425/429, which stay pending — marks a row rejected; a 401 drops the cached token). The reporter bills whatever reading it is given: never record first-party or sandbox traffic (pass `requireProject: true` to refuse a reading without a `prj_…` project). `openvibe-sdk/govern` builds its `onUsage` record with it | |
| 268 | | `openvibe-sdk/commerce` | server | `createCommerceClient(config, {caps, fetchImpl?, tokenClients?, timeoutMs?})`: the Billing client Tips and VIP share. `config` = `{billing: {url, audience, timeoutMs = 10000}, network: {internalUrl}, oauth: {clientId, clientSecret}}`; `caps` maps `intent`, `transfer`, `subscription`, `entitlement` (and optional `rates`, default `entitlement`) to capability strings, one lazily created `serviceAuth` token client per distinct string (`scope` = the string) unless `tokenClients` supplies one, keyed by logical key or capability. Methods: `createIntent` (`kind` purchase with `bits`, or subscription with `creator`/`tier`/`autoRenew`), `createTransfer`, `refundTransfer`, `subscribeWithCredit` (`tier`), `cancelSubscription`, `getSubscription`, `listSubscriptions`, `entitlement`, `rates`, `listTiers(creator)` and `setTier({creator, tier, priceCents, status?, key})` (a creator's tier prices, kept by Billing; optional `caps.tier` = `billing.tier.manage`, which `setTier` needs), `baseUrl`. `Idempotency-Key` is sent verbatim; a 401 invalidates the token and retries once with the same key; problem+json becomes `CommerceError` (`status`, `code`, `body`, `retryable`). `intentKey(prefix, parts)` joins the parts that are set; `receipt(kind, fields)` → `{kind, at, ...fields}` (null stripped, never posted). `openvibe-contracts` is required lazily | |
| 269 | | `openvibe-sdk/vibes` | server | `createVibesClient({appToken \| tokenProvider, billingUrl, fetchImpl?, timeoutMs?})`: Vibes Pay for developer apps (D12). `charges.create({amount, description, reference, return_url})` sends a whole number of Vibes with the reference as the `Idempotency-Key`, → `{id, pay_url, status, expires_at}`; `charges.get(id)`, `charges.refund(id, {reason?})`. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook`. A float, zero or negative amount is refused before any request; problem+json becomes `VibesError` (`status`, `code`, `body`, `retryable`), a 401 invalidates a token provider and retries once with the same key | |
| 270 | | `openvibe-sdk/exchange` | server | `createExchangeClient({developerKey, baseUrl?, networkUrl?, siteUrl?, fetchImpl?, timeoutMs?, maxRetries?, owner?, paths?})`: OpenVibe.Exchange, the buyer and the seller side of the model exchange (the plan of 2026-11-06, item 7). Buyer: `chat(body, {signal})` → the OpenAI shape (`stream: true` answers an async iterable of chunks), `models()`, `offers(model)` (the offers that serve one model, with the price, the speed and the uptime), `catalog()`, `presets.list/create/update/remove` (a preset is stored under its name; there is no PATCH), `usage({days})` (the public aggregate; with `from`/`to`/`requester` the platform usage for that requester) and `receipt(id)`. Seller: `seller.offer({model, price, hours, concurrency, endpoint, machineId, owner})` registers the offer in the Network’s user-owned AI offers (the same registry OpenVibe Link’s "Offer to the network" writes to: `POST /internal/ai/offers/report`, the capability `network.resource.report`), `seller.earnings()`, `seller.verification()` (canaries, spot re-runs, reputation, uptime) and `seller.offers()`. A 429 or a 5xx is retried with a jittered backoff (a write only when it is idempotent); every call takes an AbortSignal. `webhooks.verify(rawBody, signatureHeader, secret, {toleranceSec = 300, now?})` checks `X-OpenVibe-Signature-V2` (`t=<unix>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">`, constant time, replay window) and the legacy `sha256=<hex>` body-only form; also exported as `verifyWebhook`. Compute: `computePrices({kind: 'cpu' | 'gpu'})` reads the reviewed GPU/computer tariffs (per hour, per second, checked and review dates, the provider's price page) from Exchange's public site, keyless; `createComputePriceClient({baseUrl?, fetchImpl?})` is that reader alone | |
| 271 | | `openvibe-sdk/telemetry` | server | `telemetrySample(fields)` / `validateTelemetrySample(record)` for `platform.telemetry-sample@1`; `createTelemetry({service, instance?, sink, intervalMs = 15000, now?, log?, maxBuffered = 1000})` → `{record(name, value, labels), gauge(name, value, labels), count(name, labels), flush(), stop()}`: buffered samples flushed to `await sink(samples)` on an unref'd interval and on `stop()` (a `gracefulStop` stop step); `record` builds `{service, instance, operation, at, latency_ms, ...labels}` (a non-number value goes to `extra[name]`); a rejecting sink is logged once and its samples kept; past `maxBuffered` the oldest sample is dropped, with a warning at most once a minute; the per-service PostgreSQL sink: `telemetrySchema(table = 'telemetry_samples')` (migration DDL), `createPgTelemetrySink({db, table?, log?})` (one multi-row INSERT per flush, malformed samples dropped with a warning), `pruneTelemetry({db, table?, olderThanDays = 30})`, `startTelemetryRetention({db, table?, olderThanDays = 30, everyMs = 24 h, delayMs = 60 s, log?})` → `{run(), stop()}` (prunes soon after start and then daily on unref'd timers, so a service redeployed more than daily still prunes) | |
| 272 | | `openvibe-sdk/cost` | server | Build and validate `platform.rate-card@1` and `platform.cost-snapshot@1` records with `rateCard()`, `costSnapshot()`, `validateRateCard()`, and `validateCostSnapshot()`; `estimate()` calculates a charge from a rate card; the AI Exchange money math in integer nano-USD with `nanoUsd()`, `chargeTokens()`, `expectedCostNano()`, `costPerSuccessNano()`, `formatNanoUsd()` | |
| 273 | | `openvibe-sdk/runtime` | server | Build and validate `platform.runtime-offer@1` records with `runtimeOffer()` and `validateRuntimeOffer()`; `runtimeClass()`, `RUNTIME_CLASSES`, and `validateRuntimeClass()` handle known runtime classes | |
| 274 | | `openvibe-sdk/storage` | server | `storageOffer()` and `validateStorageOffer()` for `platform.storage-offer@1` | |
| 275 | | `openvibe-sdk/delivery` | server | `deliveryOffer()` and `validateDeliveryOffer()` for `platform.delivery-offer@1` | |
| 276 | | `openvibe-sdk/link` | server | `linkCapabilities()` and `validateLinkCapabilities()` for `platform.link-capabilities@1`; `linkOffers()` derives resource and runtime offers from a node descriptor | |
| 277 | | `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()`; the HTTP telemetry kit: `createHttpTelemetry({service, sink, intervalMs?, routeLabel?, skipPrefixes?, …})` → `{middleware(), record, gauge, count, requestStarted, requestFinished, flush, stop}`, `createTelemetryMiddleware`, `telemetrySkipped`, `defaultRouteLabel`, `registerSignals`, `telemetrySample`, `validateTelemetrySample`, and the singleton `telemetry.{init, record, gauge, count, requestStarted, requestFinished, flush, stop}` + `telemetryMiddleware` (a request is aggregated per `route\|method\|status_class` per flush into one `platform.telemetry-sample@1` `http.request` sample, plus active-requests/p95/event-loop-lag gauges once per flush); re-exports (loaded on first use) of openvibe-shared `createReadiness`, `skip`, `safeReason`, `createRegistry`, `instrument`, `metricsHandler`, `isLoopbackDirect`, `releaseInfo`, `createRelease` and openvibe-contracts `problem`, `sendProblem` | |
| 278 | | `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` | |
| 279 | | `openvibe-sdk/browser/openvibe-sdk.mjs` | browser | one self-contained ES module: core + auth (browser), registry, modules, realtime, media, community, bot, jobs, tools, projects | |
| 280 | |
| 281 | 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. |
| 282 | |
| 283 | ### How every call behaves (`core`) |
| 284 | |
| 285 | - **Where it goes.** Name a `service` and a `path`. The origin comes from `baseUrls[service]`, or from the registry: `discover()` reads `<network>/.well-known/openvibe` and caches it for 5 minutes, and concurrent callers share one fetch. Use `baseUrls` for host-internal addresses such as `/internal/*` routes on `127.0.0.1`. |
| 286 | - **Version negotiation.** The descriptor includes the contracts release the network runs. `discovery.compatible` says whether it falls within `CONTRACTS_RANGE` (`>=0.5.0 <1.0.0` for this release). By default an out-of-range version logs one warning. With `strictContracts: true` it throws `sdk.incompatible_contracts`. `client.supports(id)` checks whether a service is registered and running. |
| 287 | - **Deadlines.** `timeoutMs` (default 10 s) applies to each attempt and covers reading the body. `deadlineMs` (default 30 s) bounds the whole call, retries included. An `AbortSignal` cancels the call. |
| 288 | - **Retries.** A call is retried (default `retries: 2`, exponential backoff with jitter, `Retry-After` honoured) on network errors, timeouts, 408, 425, 429, 502, 503 and 504, and only when repeating it is safe: |
| 289 | - GET, HEAD, OPTIONS, PUT and DELETE are always safe to repeat. |
| 290 | - Any other method is retried only with an `Idempotency-Key`. When `retries > 0`, the client generates one for each mutation (`idem_<ULID>`) and sends the same key on every attempt. |
| 291 | - `idempotencyKey: false` turns this off for writes the server does not dedupe. The Community wrappers do that for create, fork, like, copy and comment. |
| 292 | - `idempotent: true` marks a mutation the server dedupes by itself. Events publishing does, because Events dedupes on `event_id`. |
| 293 | |
| 294 | A 401 invalidates the token once and repeats the call with a fresh one. |
| 295 | - **Errors.** Every failure is an `OpenVibeError` with `code`, `status`, `title`, `detail`, `requestId`, `traceId`, `errors` and `problem`: |
| 296 | - RFC 9457 problems keep their `code`. |
| 297 | - OAuth errors become their `error` (`invalid_client`). |
| 298 | - Legacy `{ error: 'text' }` bodies become `http.<status>`. |
| 299 | - Failures with no HTTP response use `sdk.timeout`, `sdk.deadline_exceeded`, `sdk.aborted`, `sdk.network_error` or `sdk.unknown_service`. |
| 300 | - **Tracing.** Every call sends `traceparent` and `X-OpenVibe-Request-Id`. The request id stays the same across retries. `client.withContext({ traceparent, requestId })` or `client.fromRequest(req)` continues the caller's trace with a new span. The `traceparent` option also accepts a getter, for example one backed by AsyncLocalStorage. |
| 301 | - **Pagination.** `paginate(fetchPage, { cursor })` is an async iterator over `{ items, next }` pages. Media and Community use offsets. Events `pull()` takes an opaque `after` cursor (a page's `next_cursor`, or `latest_cursor` to start at the head; a position is never a number, and `afterSeq` throws) and `iterate()` walks it, calling `onPage(page)` only after every item of that page was handled, so saving `page.next_cursor` there — it advances even when a page matched nothing — is crash-safe. |
| 302 | - **Streams and downloads.** `responseType: 'response'` resolves with the raw fetch `Response` (`data` and `response`, body unread). Error statuses are still read and thrown. The timeout covers the headers only, and `signal` still cancels the body. `parseSSE(res.body)` (from `openvibe-sdk/realtime`) iterates a `text/event-stream`. |
| 303 | |
| 304 | ### Auth details |
| 305 | |
| 306 | - `createServiceTokenClient()` follows the rules of openvibe-contracts `serviceAuth.createTokenClient()` but needs no dependencies. It sends `POST /oauth/token` with `grant_type=client_credentials`, `audience` and an optional `scope`, which can be a string, an array, or a map from audience to scope. It keeps one cached token per audience until 60 s before expiry and shares one in-flight request. As a `tokenProvider`, each call gets a token for the audience of the service it calls: `openvibe.<service>` by default, or the value in `audiences` if you set one. |
| 307 | - `createNetworkKeys({ network, publicKey?, log? })` holds the keys a service verifies Network tokens with: a pinned PEM |
| 308 | (`publicKey`) as is, or `<network>/api/.well-known/jwks` through the shared JWKS client (a rotation is honoured on an |
| 309 | unknown `kid`, the last good keys are kept through an outage). Call `start()` at boot (it retries every 30 s until the |
| 310 | first load, then refreshes every 15 minutes) and `stop()` at shutdown; spread `keys.verifyOptions` into the verifiers |
| 311 | below; `keys.loaded()` is your readiness check. |
| 312 | |
| 313 | ```js |
| 314 | const keys = createNetworkKeys({ network: config.networkInternalUrl, publicKey: config.networkPublicKey, log }); |
| 315 | keys.start(); |
| 316 | const claims = await verifyUserToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice' }); |
| 317 | const svc = await verifyServiceToken(token, { ...keys.verifyOptions, issuer, audience: 'openvibe.myservice', contracts }); |
| 318 | ``` |
| 319 | - `verifyUserToken(token, { jwks, issuer, audience })` checks a token offline: |
| 320 | - It accepts RS256 only. `alg: none`, HS256 and anything else fail as `token.malformed` before any key is used. |
| 321 | - It checks the signature against the JWKS document, URL or client (`createNetworkKeys().verifyOptions`). A URL is cached for 6 h and fetched again when a token carries an unknown `kid`. It also accepts the Network's legacy `public_key` field or a PEM string. |
| 322 | - It checks `exp`, `nbf` and `iat` (30 s clock skew), then `iss` and `aud`. |
| 323 | - It rejects principal tokens (a service, a developer app, a module or a node: a machine paired for a service) and typed tokens (a realtime ticket, a FedCM assertion: `typ` or `purpose` set) as `token.not_user`. |
| 324 | |
| 325 | It returns the claims, including `subject_id`. |
| 326 | - `verifyServiceToken(token, { jwks, issuer, audience, contracts })` checks a service or app token for a service that receives them: the SDK chooses the key (the `kid`'s, else each; the shared JWKS client or a pinned PEM), and every rule is your own pinned openvibe-contracts `serviceAuth.verifyServiceToken` (pass the module as `contracts`). No key answers `{ ok: false, code: 'token.unavailable' }` with a fixed reason, never the internal JWKS URL. |
| 327 | - `verifyAppToken(token, { jwks, issuer, audience, acceptSandbox })` checks a developer app's token the way openvibe-contracts (v0.26 and later) `verifyServiceToken()` does: the same signature and time rules, the audience (required), `actor_type: app`, the claim shape (`sub app:app_…`, `cap`, `jti`, `project_id prj_…`, `env`, optional `on_behalf_of usr_…`), and `env: sandbox` refused as `token.sandbox_refused` unless `acceptSandbox: true`. Opt in only when your service keeps sandbox traffic apart from real data. It does not check capabilities: test `claims.cap` for the one your route performs. |
| 328 | - `exchangeCode({ code, codeVerifier, redirectUri, clientId, clientSecret?, audience?, scope? })`: without `clientSecret` (a public app) the PKCE verifier is required; `app_…` clients must send the verifier and an `audience`. App tokens come back without a `refresh_token`. |
| 329 | - `startAuthorization()` / `buildAuthorizeUrl()` default `scope` to `'profile theme'` only for first-party sign-in (no `audience`). Apps pass `audience` and capability ids. Network refuses `prompt=none` for apps. |
| 330 | - `createServiceTokenClient().getTokenInfo({ audience })` returns `{ accessToken, scope, expiresAt, unverifiedClaims }`. `decodeUnverified()` / `unverifiedClaims()` decode a JWT without verifying it: for display and diagnostics, never for authorization. |
| 331 | - PKCE follows RFC 7636 S256. The verifier is 64 characters from the unreserved set, and the challenge is `BASE64URL(SHA-256(verifier))`. Network verifies the verifier whenever the authorization carried a challenge, and requires S256 from every developer app. |
| 332 | - `createPgRevocationStore(db)` keeps each person's token cutoff from Network's `network.user.token_valid_after` event (Contracts 0.39.0: sign out everywhere, a password change or reset, a ban, staff ending someone's sessions). Subscribe to the event, pass each delivery to `store.apply(event)` (`'revoked'` means the cutoff moved: close that person's sockets, drop their cached sessions), and after `verifyUserToken()` refuse the token when `store.isRevoked(claims)`. That is Network's rule, `iat * 1000 < valid_after`. The cutoffs live in the service's PostgreSQL (`revocationSchema()` for its migration; `await store.load()` at boot reads them into memory); the store only ever moves a cutoff forward and ignores anything not from `network`. |
| 333 | |
| 334 | - `createSsoClient()` (`openvibe-sdk/sso`) is the site side of first-party sign-in, the layer Blog, Coupons, Deals, Host, News and Trade each carried a copy of. `/auth/login` sends the browser to the Network with a random `state` and a PKCE S256 challenge kept in short-lived httpOnly cookies on `/auth`; `/auth/callback` requires the state (constant-time compare) and exchanges the code server-side with the client secret and the verifier. `next` is a same-site path, this site's https origin or the Network's; anything else, including control characters and backslashes, goes home. Sessions are verified offline with `verifyUserToken` through the shared JWKS client (the last good keys survive a Network outage), so a service principal or a typed token (a realtime ticket, a FedCM assertion) is never a session. Cookies are host-only: `ov_token` (the access JWT, readable by the shared navbar), `ov_refresh` (httpOnly, `Path=/auth`) and `ov_sso_hint`. |
| 335 | |
| 336 | ### Receiving event webhooks |
| 337 | |
| 338 | OpenVibe.Events POSTs each delivery as `{ event }` (no sequence number: dedupe on `event.event_id`) with two signatures: |
| 339 | |
| 340 | - `X-OpenVibe-Signature: sha256=<hex HMAC-SHA256 of the raw body>` (v1). It covers only the body, so a captured delivery verifies forever. |
| 341 | - `X-OpenVibe-Timestamp: <unix seconds>` and `X-OpenVibe-Signature-V2: t=<that timestamp>,v2=<hex HMAC-SHA256 of "<t>.<raw body>">` (v2). Every attempt, retries included, is signed with the time it is sent, so a delivery stops verifying 300 s later. |
| 342 | |
| 343 | The key is the subscription secret. Verify the raw bytes, not re-serialized JSON: |
| 344 | |
| 345 | ```js |
| 346 | const { parseDelivery } = require('openvibe-sdk/events'); |
| 347 | |
| 348 | app.post('/internal/events', express.raw({ type: 'application/json' }), (req, res) => { |
| 349 | const d = parseDelivery(req.body, req.headers, process.env.EVENTS_WEBHOOK_SECRET, { requireV2: true }); |
| 350 | if (!d) return res.sendStatus(401); |
| 351 | inbox.once('my-service', d.event.event_id, () => { /* synchronous db writes */ }); // still dedupe: delivery is at least once |
| 352 | res.sendStatus(204); |
| 353 | }); |
| 354 | ``` |
| 355 | |
| 356 | `parseDelivery(raw, headers, secret, { requireV2 = false, toleranceSec = 300, now })`: |
| 357 | |
| 358 | - If `X-OpenVibe-Signature-V2` is present, it must verify and its timestamp must be within ±`toleranceSec` of `now` (ms, default `Date.now()`). A bad or stale v2 returns `null`. It never falls back to v1. |
| 359 | - If there is no v2 header, v1 is accepted only while `requireV2` is false (the default, so 0.4.0 changes nothing for existing callers). |
| 360 | |
| 361 | Turn `requireV2` on once Events sends v2 to you: a replayed v1-only delivery then fails. The lower-level checks are `verifyDeliveryV2(raw, headers, secret, { toleranceSec, now })` (constant-time; also refuses a `X-OpenVibe-Timestamp` that differs from `t`) and `verifyDelivery(raw, signatureHeader, secret)` (v1, unchanged). Keep your clock in sync (NTP): the window is ±300 s both ways. In tests, `signDeliveryHeaders(raw, secret, { now })` returns the three headers as Events sends them. |
| 362 | |
| 363 | ### The OpenVibe Frame |
| 364 | |
| 365 | Every OpenVibe site uses the same navbar and footer. Your app can use them too, so people move between it and the network without a jump: |
| 366 | |
| 367 | ```js |
| 368 | // Browser (CommonJS or ESM: openvibe-sdk/frame) |
| 369 | const { navbar } = await mountFrame({ |
| 370 | service: 'myapp', |
| 371 | brand: { name: 'My App' }, |
| 372 | links: [{ label: 'Home', href: '/' }, { label: 'Docs', href: '/docs' }], |
| 373 | menu: { before: [{ label: 'My projects', href: '/projects' }] }, // rows in the account menu |
| 374 | sessionUrl: '/auth/me', // your server session: { user } |
| 375 | loginUrl: '/auth/login?next={path}', |
| 376 | logoutUrl: '/auth/logout?next={path}', // Sign out ends your session too |
| 377 | footer: { updates: '/updates' }, // or false for no footer |
| 378 | }); |
| 379 | ``` |
| 380 | |
| 381 | For server-rendered pages, `frameTags(opts)` returns `{ head, bodyStart, bodyEnd }`: put `head` in `<head>`, `bodyStart` first in `<body>` and `bodyEnd` last. Your CSP needs `script-src https://openvibe.network` and `connect-src https://openvibe.network`. |
| 382 | |
| 383 | The Frame is progressive: if openvibe.network cannot be reached, your page still renders, and the part that failed resolves to `null`. Signed-in state comes from the shared `ov_token`, and otherwise from your `sessionUrl`. To have your users recognised across the network, sign them in with OpenVibe (`openvibe-sdk/auth`). |
| 384 | |
| 385 | ### Tools |
| 386 | |
| 387 | `openvibe-sdk/tools` wraps the OpenVibe.Tools platform API (ADR-027, openvibe-contracts v0.33.0) on the gateway, `https://openvibe.tools`: every tool has a descriptor (`tools.tool@1`), and every tool with `api: true` runs through one route. **Status:** the registry routes come with Tools S3, the run API and the gateway's `/api/v1/jobs` facade with Tools S6 (the capabilities `tools.tool.read`, `tools.tool.run` and `tools.net.probe` stay `planned` until then). Until they are deployed, this client is tested against the contracts and the mock only. |
| 388 | |
| 389 | ```js |
| 390 | const { createToolsClient, isToolRunError } = require('openvibe-sdk/tools'); |
| 391 | const tools = createToolsClient(client); // client: createClient({ … }); a token is optional |
| 392 | |
| 393 | const { tools: list, count } = await tools.list({ family: 'img', api: true }); // tools.tool-list@1, schemas as $ref |
| 394 | const png = await tools.get('png'); // tools.tool@1 with schemas embedded, or null |
| 395 | const { $defs } = await tools.schema('png'); // { input, output }: what the $refs point at |
| 396 | |
| 397 | // An inline tool (execution client with a server engine, or sync) answers finished: |
| 398 | const min = await tools.run('jsonminify', { text: '{ "a": 1 }' }); |
| 399 | min.result.text; // '{"a":1}' |
| 400 | |
| 401 | // A job tool answers 202 with its job; wait() follows its events to the result: |
| 402 | const run = await tools.run('png', {}, { files: [{ name: 'photo.jpg', data: bytes, type: 'image/jpeg' }] }); |
| 403 | run.state; // 'queued' (run.job, run.location = /api/v1/jobs/job_…) |
| 404 | const done = await run.wait({ onEvent: (e) => show(e.job.progress) }); |
| 405 | done.result.files[0]; // { name: 'photo.png', mime, size, sha256, url, … } |
| 406 | const bytesOut = await (await tools.jobs.file(done.job.id, 0)).arrayBuffer(); |
| 407 | |
| 408 | // One tool's output feeds the next, without a download: |
| 409 | const webp = await (await tools.run('webp', {}, { files: [{ job_id: done.job.id, index: 0 }], waitMs: 10000 })).wait(); |
| 410 | |
| 411 | try { |
| 412 | await tools.run('jsonminify', { text: '{' }); |
| 413 | } catch (err) { |
| 414 | if (isToolRunError(err)) console.log(err.code, err.status, err.detail); // the tool's own problem+json |
| 415 | } |
| 416 | ``` |
| 417 | |
| 418 | [Worker placement and execution receipts](docs/tools-routing.md): `tools.run(tool, input, { prefer })` and `tools.jobs.get(id)`. |
| 419 | |
| 420 | - **What `run()` resolves to.** An inline tool, or a job that finished within `waitMs`, gives `{ state: 'succeeded', tool, result, took_ms, job?, location? }`. `result` carries `data` (output kind json), `text` (kind text) or `files` (kind file or files: the job's result files). A job still queued or running gives `{ state: 'queued' | 'running', tool, job, location }`. Both have `idempotencyKey`, `replayed` and a non-enumerable `wait(opts)`. On a finished run `wait()` resolves to the run itself, so `await (await tools.run(…)).wait()` works for every tool. On a job, `wait()` takes `jobs.wait`'s options (`signal`, `onEvent`, `lastEventId`), and `tools.jobs.wait(job.id)` gives the raw job. |
| 421 | - **Failures.** A run that finished `failed` or `cancelled` throws `ToolRunError`. It is an `OpenVibeError` with the tool's problem+json mapped: `code`, `status` (the problem's, e.g. 422 or 504, not the HTTP 200), `detail` and `errors`. It also carries `state`, `tool`, `job` and `run`. When `err.retryable`, `tools.jobs.retry(err.job.id)` runs the failed job again. A refusal before the tool ran is a plain `OpenVibeError`: 404 `tools.tool.not_found` or `tools.tool.not_runnable` (`api: false`: the YouTube downloader is page-only and never in the SDK), 422 `tools.input.invalid` (with `errors[]` pointers), 401 `token.*`, 403 `capability.denied`, 413/415 for files, 429 `quota.exceeded` or `tools.job.too_many_active`, 503 `tools.tool.unavailable`. |
| 422 | - **Idempotency and retries.** Every run carries an `Idempotency-Key`, generated when you pass none and returned as `idempotencyKey`. The same key and request give the same job (`replayed: true`); a different request under that key is 409 `tools.job.idempotency_conflict`. Inline tools ignore the key. Because of the key, the client retries a 429 after its `Retry-After` (capped by `maxRetryDelayMs`) and retries 5xx and timeouts without creating a second job. To survive a crash between the run and saving the job id, derive the key from the request and run again with it. |
| 423 | - **Files.** Uploads use the jobs client's shapes: a `Blob`/`File`, or `{ name, data: Buffer | Uint8Array | ArrayBuffer | Blob | string, type? }`. They go as multipart `file` parts. References are `{ media_id: 'med_…' }` (a Media object you may read) and `{ job_id: 'job_…', index }` (a result file of your own job). They go in the JSON body's `files`, or, beside uploads, as a multipart `files` part holding their JSON. The tool gets the uploads first, then the references in order, and the count must fit the descriptor's `files.min`/`files.max`. |
| 424 | - **Waiting and timeouts.** `waitMs` (0 to 60000) lets a job tool answer finished when it is quick. Each attempt's timeout is raised to `waitMs`, or to the tool's `limits.timeoutMs` once this client has read its descriptor (`get` or `list`), plus 10 s. Pass `timeoutMs` to set it yourself. `signal` aborts the run (`sdk.aborted`), and a `wait()` too. Aborting a wait leaves the job running. |
| 425 | - **Who may run what.** Callers are tiered anonymous < session < user < app/service. Without a token you run the tools whose descriptor says `auth.anonymous: true`, keyed by IP. A person's token runs every tool except network probes. An app or service token (audience `openvibe.tools`) needs the descriptor's `auth.capability`: `tools.tool.run` (public), or `tools.net.probe` (partner: staff grant it by hand). Quotas count each tool's `cost` within its `quotaClass`. Registry reads (`tools.tool.read`) are public and send no token unless you pass `anonymousReads: false`. |
| 426 | - **Jobs.** `tools.jobs` is `createJobsClient()` on the same origin and credentials: the gateway's `/api/v1/jobs` facade, which fronts every satellite. It adds `retry(id)`, which returns `{ job, replayed }` (a failed job as a new one, `retry_of`; asking again returns the same retry), and `reference(id, 'community:paste:p_123')` / `unreference(id, ref)`, which keep a succeeded result while something points at it (`expires_at: null`). Until the facade is deployed (Tools S6), use `createJobsClient(client, { baseUrl: 'https://img.openvibe.tools' })` (or `audio.`, `docs.`) for jobs. |
| 427 | |
| 428 | ### Testing your app |
| 429 | |
| 430 | ```js |
| 431 | const { createMockPlatform } = require('openvibe-sdk/testing'); |
| 432 | const platform = createMockPlatform({ |
| 433 | clients: { 'my-service': { secret: 's', grants: [{ capability: 'media.object.upload', audience: 'openvibe.media', namespaces: ['my-app'] }] } }, |
| 434 | apps: { 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR': { env: 'production', type: 'confidential', secret: 's', redirectUris: ['http://localhost:3009/callback'], grants: ['media.object.upload'] } }, |
| 435 | mediaApps: { 'my-app': {} }, |
| 436 | users: [{ username: 'ana' }], |
| 437 | jobs: true, |
| 438 | tools: true, |
| 439 | }); |
| 440 | const client = createClient({ fetch: platform.fetch, tokenProvider: createServiceTokenClient({ clientId: 'app_01K5WZX7S7Q4D2B8N3M6V1C9TR', clientSecret: 's', fetch: platform.fetch }) }); |
| 441 | ``` |
| 442 | |
| 443 | For a service's own database, `createTestDb({ migrations, seed, seedKey })` returns a migrated PGlite (or, with `store: 'pg'`, a schema of its own on the test containers). The migrated and seeded PGlite is saved once as a snapshot, keyed by the migrations' contents, and later test processes load it instead of migrating (`snapshot: 'hit' | 'built' | 'off'`, `setupMs`; `OV_TEST_SNAPSHOT=0` turns it off, `OV_TEST_SNAPSHOT_DIR` moves it). |
| 444 | |
| 445 | The mock answers at the real public origins with real RS256 tokens and checks audience, capability, namespace and sandbox the way the services do. It covers: |
| 446 | |
| 447 | - **Network:** discovery, `/oauth/token` (client credentials, authorization code with PKCE, refresh; developer apps), `GET /oauth/authorize` (consents automatically as `setAuthorization({ subjectId })`, or declines with `{ decision: 'deny' }`), the JWKS, the registry, `/api/v1/projects` (projects, members, apps, credentials, grants, quotas, audit), `/api/modules` and `/internal/modules`, and `/internal/identity`. |
| 448 | - **Developer apps:** `apps` / `projects` options, `addApp()`, `signAppToken()`. App tokens carry `sub app:…`, `cap`, `ns: [project_id]`, `project_id`, `env` and `on_behalf_of` (code flow); codes are single use, PKCE-bound and bound to the `audience` given at authorize; a sandbox app of a project with members can be authorized only by them. The capability catalog is the public + active capabilities of openvibe-contracts v0.28.0 (`DEFAULT_APP_CATALOG`, `events.app.*` included). |
| 449 | - **Sandbox:** as in production, Media (on `/api/v1/<project_id>/files`) and Events (on the `events.app.*` routes) accept sandbox app tokens and keep their data apart from production; every other route and mock service refuses `env: sandbox` tokens (`401 token.sandbox_refused`) unless `acceptSandbox` lists the audience or capability. Unlike Network, sandbox apps get tokens for any audience unless you pass `sandboxAudiences`, and projects created through the API start with the whole catalog as allowance unless you pass `defaultAllowance: []`. |
| 450 | - **Events:** publish with `event_id` dedupe, pull (with a `gap` after `pruneEvents(seq)`), checkpoints, subscriptions, `/realtime/stream` SSE with `Last-Event-ID` and gap events, and a delivery worker: `deliverEvents()` / `startDeliveries()` POST signed deliveries (v1 and v2 headers, fresh timestamp per attempt) to your local endpoint in order, retry, and mark them dead after `max_attempts`. Developer apps follow OpenVibe.Events' rules: app tokens are judged only on `events.app.publish | read | subscribe`; types `app.<project_key>.<name…>`, source `app-<ulid>`, actor the app or its `on_behalf_of` user; reads, checkpoints and subscriptions limited to the own project in the token's env plus public first-party events (every pattern starts with a literal segment, `app.*` patterns name the own key); app endpoints https and not loopback, private or local names; first-party readers never see sandbox events and see app events only through `app.*`; realtime streams neither. Not modelled: per-project quotas, revocation, and the DNS half of the endpoint check. Your delivery worker's `fetch` (`deliverEvents({ fetch })`) can route `https://hooks.example.com/…` to a local server. |
| 451 | - **Media:** the files API and `GET /f/:key`, with Media's tenant rules: `media.object.upload` uploads and deletes, `media.object.read` lists and gets (app keys, service tokens and app tokens alike); a developer app reaches only `/api/v1/<its project_id>/files`, where production uses the tenant `prj_…` and sandbox `prj_…-sandbox` (100 MB, `mediaQuotaMb`); sandbox files come back as `{ sandbox: true, url: <signed>, url_expires_at }` and `/f/<key>` serves them only with a valid signature. |
| 452 | - **Tools jobs** (`jobs: true | { stepMs, handlers }`) at `origins.tools` and the satellites `img.`, `audio.` and `docs.openvibe.tools` (`platform.toolsOrigins`; `toolsSatellites` overrides). Each satellite keeps its own jobs, and `origins.tools` is the gateway facade that also finds theirs. Covered: submit with Idempotency-Key replay, get, cancel, retry, references, SSE with `Last-Event-ID` and `204` when finished, and result files. Every view is `tools.job@1`, with `error` as problem+json. `dropJobStreams()` simulates a dropped connection. |
| 453 | - **Tools platform API** (`tools: true | { descriptors, handlers, mediaObjects, stepMs }`) on `origins.tools`: `GET /api/v1/tools[/:id[/schema]]` and `POST /api/v1/tools/:id/run`, answering as openvibe-contracts v0.33.0 says. The default tools are `dns` (sync), `jsonminify` (a client tool with a server engine), `png` (a job), `port` (a probe needing `tools.net.probe`), `yt` (page-only) and `protectpdf` (unavailable). `descriptors` adds or replaces tools, and `handlers` answers them (`{ data }` or `{ text }` inline, a job handler for job tools). Also: `addTool()`, `addMediaObject()` (what `{ media_id }` reads) and `state.tools`. The mock checks caller tiers, the refusal codes, the file count and type, and the input's top-level fields. Idempotent job runs, `wait_ms` and file references work. It has no browser sessions, so job tools need a token. It has no quotas either: to test a 429, wrap `platform.fetch`. |
| 454 | |
| 455 | Helpers: `signUserToken()`, `signServiceToken()`, `signAppToken()`, `authorize()`, `setAuthorization()`, `publishEvent(envelope, publisher, { projectId, env })` (a registered app's `app:<id>` publisher implies them), `pruneEvents()`, `deliverEvents()`, `dropRealtime()`, `dropJobStreams()`, `addTool()`, `addMediaObject()`, and `stats` and `state` for assertions. Its `fetch` rejects on an aborted signal, as `fetch` does. It is a fake. It has no persistence, its visibility rules are simplified, and it has no Chat (no WebSocket mock). |
| 456 | |
| 457 | ### Service kit (server) |
| 458 | |
| 459 | `openvibe-sdk/service` is the layer every service wrote around the shared pieces: a graceful stop and the error |
| 460 | wrappers around the problem body. It does not reimplement readiness, metrics, `/release.json` or the problem body: |
| 461 | those are re-exported from `openvibe-shared` (`/ready`, `/metrics`, `/release`) and `openvibe-contracts` (`http`), |
| 462 | loaded from the service's own `node_modules` on first use (the SDK depends on neither; without the package the name |
| 463 | throws an error that says which to install). |
| 464 | |
| 465 | ```js |
| 466 | const svc = require('openvibe-sdk/service'); |
| 467 | const ApiError = svc.createServiceError('ApiError'); // new ApiError(404, 'post.not_found', detail?, extra?) |
| 468 | const o = { name: 'Blog API' }; // log prefix; extra: 'details' for Tips/VIP |
| 469 | |
| 470 | router.get('/api/posts/:id', svc.run(async (req) => posts.get(req.params.id), 200, o)); |
| 471 | router.post('/api/posts', svc.jsonBody({ limit: '512kb' }), svc.run(create, 201, o)); // bad JSON: 400 request.invalid_json |
| 472 | app.use(svc.jsonErrors(o)); // opt-in, last: 404 + error mapping |
| 473 | |
| 474 | const server = app.listen(port); |
| 475 | const lifecycle = svc.gracefulStop({ |
| 476 | name: 'Blog', server, |
| 477 | stop: [() => worker.stop()], // nothing new starts |
| 478 | close: [() => outbox.stop(), () => db.close()], // after the HTTP drain |
| 479 | deadlineExitCode: 0, // the 5 s family exits 0 on a blown deadline |
| 480 | }); |
| 481 | ``` |
| 482 | |
| 483 | Errors follow Reviews/Wiki: a 5xx other than 503 is logged; the code is the error's, else `internal.error` at 500 and |
| 484 | `request.invalid` otherwise; a 500's detail is `'Internal error'`, never the message. On a signal `gracefulStop` runs |
| 485 | the stop steps, `beforeDrain`, then closes the server (`Connection: close` on requests in flight, idle keep-alive |
| 486 | connections closed every 50 ms, event streams destroyed), the close steps and `handles`, and exits 0; past `deadlineMs` |
| 487 | it exits `deadlineExitCode`. `stopping()` turns true at once, for a readiness check. The recipe per service family is |
| 488 | [docs/service.md](docs/service.md). |
| 489 | |
| 490 | The kit also carries the HTTP telemetry Network wrote in `server/telemetry.js` and `server/observability.js`. A |
| 491 | request is not a row: `createHttpTelemetry({ service, sink, skipPrefixes? })` aggregates per |
| 492 | `route|method|status_class` per flush interval into `count`/`sum`/`max`/`p95` and emits one `http.request` |
| 493 | `platform.telemetry-sample@1` per key, plus active-requests, p95 and event-loop-lag gauges once per flush. A request |
| 494 | that matched no route is labelled `unmatched` (never its raw path), and the per-flush key map and latency samples |
| 495 | are capped, so a scanner or a 404 flood cannot grow them without bound. Mount |
| 496 | `collector.middleware()` before the routes and add `() => collector.stop()` to `gracefulStop`'s stop steps (one |
| 497 | flush). The `telemetry.init({service, sink})` / `telemetryMiddleware` / `telemetry.stop()` singleton is the same |
| 498 | design wired process-wide: health, readiness, metrics and static assets are skipped, `skipPrefixes` defaults to |
| 499 | `['/shared']` (OpenVibe.Network passes `['/shared', '/api/chrome']`), the event-loop monitor starts in `init` and |
| 500 | stops in `stop`, and requiring the kit starts nothing. |
| 501 | |
| 502 | ### Data: PostgreSQL and Valkey (server) |
| 503 | |
| 504 | Every service stores its data in PostgreSQL 18 through PgBouncer, and shares caches, limits, queues and fan-out through Valkey (ADR-035). The data role on the host provides both; `roles/data/add-service.sh` in OpenVibe.Host sets `DATABASE_URL`, `DATABASE_DIRECT_URL`, `VALKEY_URL` and `VALKEY_PREFIX`. |
| 505 | |
| 506 | ```js |
| 507 | const { createDb, sql } = require('openvibe-sdk/db'); |
| 508 | const { createValkey } = require('openvibe-sdk/valkey'); |
| 509 | const { createCache } = require('openvibe-sdk/cache'); |
| 510 | const { createQueue } = require('openvibe-sdk/queue'); |
| 511 | |
| 512 | const db = createDb({ service: 'wiki' }); // DATABASE_URL; tests: createDb({ pglite: true }) |
| 513 | await createDb({ url: process.env.DATABASE_DIRECT_URL }).migrate({ dir: 'migrations' }); |
| 514 | const page = await db.maybe(sql`SELECT id, title FROM pages WHERE slug = ${slug}`); |
| 515 | await db.tx(async (t) => { … }, { isolation: 'serializable' }); |
| 516 | |
| 517 | const valkey = createValkey(); // null without VALKEY_URL: in-process fallbacks |
| 518 | const cache = createCache({ valkey, namespace: 'pages' }); |
| 519 | const html = await cache.getOrSet(`page:${slug}`, 300, () => render(slug)); |
| 520 | const thumbs = createQueue({ valkey, name: 'thumbs' }); |
| 521 | thumbs.process(async (job) => { … }, { concurrency: 4 }); |
| 522 | ``` |
| 523 | |
| 524 | Install the drivers the service uses: `pg` and `iovalkey` (production), `@electric-sql/pglite` (tests and small embedded apps). PostgreSQL is the only database: the better-sqlite3 outbox, inbox and revocation store were removed in 0.44.0. |
| 525 | |
| 526 | |
| 527 | ### Browser without a bundler |
| 528 | |
| 529 | ```html |
| 530 | <script type="module"> |
| 531 | import { createClient, auth, registry } from '/vendor/openvibe-sdk.mjs'; // copied or served from node_modules/openvibe-sdk/browser/ |
| 532 | const { url, state, codeVerifier } = await auth.startAuthorization({ clientId, redirectUri, audience: 'openvibe.media', scope: ['media.object.read'] }); |
| 533 | </script> |
| 534 | ``` |
| 535 | |
| 536 | `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. |
| 537 | |
| 538 | ### The OpenVibe camera element |
| 539 | |
| 540 | The browser bundle also offers `ov.cam.open()` for video, photo or screen capture in place. It loads Cam's |
| 541 | SRI-pinned recorder once, then gives your page a `File` and metadata after the person reviews and chooses Use this: |
| 542 | |
| 543 | ```js |
| 544 | import ov from 'openvibe-sdk'; |
| 545 | const result = await ov.cam.open({ mode: 'video', maxSeconds: 60 }); |
| 546 | if (result) await upload(result.file); |
| 547 | ``` |
| 548 | |
| 549 | Closing Cam resolves to `null`. The calling site owns the file and upload. `openvibe-sdk/cam` also exports |
| 550 | `open`, `loadCamKit`, `CAM_KIT_SRC` and `CAM_KIT_SRI` for direct use. |
| 551 | |
| 552 | `capture(options)` is the direct camera stream, on OpenVibe's one shared capture core: it loads the calling |
| 553 | site's own `/shared/ov-capture.js` once (every OpenVibe site serves it) and calls `OVCapture.open(options)`, |
| 554 | so `facing`, `lens`, `mic`, `resolution`, `audio` and `video` behave the same as Live's go-live page: |
| 555 | |
| 556 | ```js |
| 557 | import { capture } from 'openvibe-sdk/cam'; |
| 558 | const stream = await capture({ mode: 'camera', facing: 'environment', resolution: '1080' }); |
| 559 | video.srcObject = stream; |
| 560 | ``` |
| 561 | |
| 562 | `openvibe-sdk/cam` loads the camera app's framework-free `<ov-camera>` element from OpenVibe.Cam and types its |
| 563 | attributes, events and methods. It injects the embed script once and resolves to the element class: |
| 564 | |
| 565 | ```js |
| 566 | import { defineOvCamera } from 'openvibe-sdk/cam'; |
| 567 | |
| 568 | await defineOvCamera(); // https://openvibe.cam/embed/ov-camera.js, once |
| 569 | const camera = document.querySelector('ov-camera'); |
| 570 | camera.addEventListener('ov-camera:capture', (event) => { |
| 571 | // event.detail = { blob, width, height, filter }; nothing was uploaded |
| 572 | upload(event.detail.blob); |
| 573 | }); |
| 574 | await camera.start(); // or let the person click the element's own button |
| 575 | ``` |
| 576 | |
| 577 | ```html |
| 578 | <ov-camera facing="user" filters="none,mono,warm" timer="3" mode="photo" upload="off"></ov-camera> |
| 579 | ``` |
| 580 | |
| 581 | `upload="off"` (the default) keeps the picture in the page. `upload="openvibe"` posts it to `POST /api/v1/photos` as |
| 582 | the signed-in person, and only on openvibe.cam. `defineOvCamera({ src })` loads a self-hosted copy of the same file. |
| 583 | The full attribute, event and method reference is at [openvibe.cam/developers](https://openvibe.cam/developers). |
| 584 | |
| 585 | ### Put your content in the OpenVibe menu |
| 586 | |
| 587 | Every open site can put its own cards in the network menu every site shares. Build the card with |
| 588 | `menuCard` (it applies the house rules and validates against `frame.menu-card@1`), then mount the |
| 589 | route — Express or plain `node:http`: |
| 590 | |
| 591 | ```js |
| 592 | const { menuCard, menuRoute } = require('openvibe-sdk/menu'); |
| 593 | |
| 594 | const cards = async () => [menuCard({ |
| 595 | id: 'new-worlds', site: 'games', title: 'New worlds', kind: 'rows', |
| 596 | origin: 'https://openvibe.games', |
| 597 | items: worlds.slice(0, 6).map((w) => ({ title: w.title, meta: `by ${w.ownerName}`, url: `https://openvibe.games/worlds/${w.id}` })), |
| 598 | more: { label: 'All worlds', url: 'https://openvibe.games/worlds' }, |
| 599 | })]; |
| 600 | |
| 601 | app.get('/.well-known/openvibe-menu.json', menuRoute(cards, { maxAgeSeconds: 300 })); |
| 602 | ``` |
| 603 | |
| 604 | `items` may be empty (the card is valid and Network skips it until something is there). `origin` is |
| 605 | the site's own https origin for the link rule; without it, the host of `more.url` is used, and with |
| 606 | neither only `openvibe.*` links pass. A bad field throws a `TypeError` naming it. `menuRoute` calls |
| 607 | `getCards()` on every request, validates each card, and caps the file at three cards; it answers `[]` |
| 608 | and logs once rather than a 500 when the build fails or a card does not validate, so a transient |
| 609 | error cannot take the site's file down. `OpenVibe.Network` fetches this file every five minutes and |
| 610 | shows the cards in `GET /api/frame`. |
| 611 | |
| 612 | ## Versioning |
| 613 | |
| 614 | - `openvibe-sdk` follows semver. While it is 0.x, a minor release may break an API, and [CHANGELOG.md](CHANGELOG.md) says so. Pin a tag. |
| 615 | - **The 1.0 core surface is already frozen.** `core`, `auth`, `events`, `db` and `media` are stable now, before 1.0: removing or renaming one of their exports needs a major release. `docs/one-point-oh.json` gives each name its tier (frozen, platform, leaving), `test/surface.test.js` pins the exact export list of the five entries, and `node scripts/surface.js --check` marks a break there as `[1.0 core]`. |
| 616 | - [docs/api-surface.json](docs/api-surface.json) lists every name each entry exports and what it is (function, class, a plain object's keys, …). `test/surface.test.js` fails when the exports and the file disagree, so a removal or a change of kind is a reviewed line in a diff, never a surprise on a service's next pin round; `node scripts/surface.js --check` names each change as breaking or added, and `node scripts/surface.js` updates the file. 1.0 freezes `core`, `auth`, `events`, `db` and `media` first: a breaking change there is marked `[1.0 core]`. Inside them each name is frozen, platform (exported, not frozen) or leaving before 1.0: [docs/one-point-oh.md](docs/one-point-oh.md). |
| 617 | - Each release states the openvibe-contracts range it was tested against (`CONTRACTS_RANGE`), and `discover()` checks it at runtime. Contract types are copied into `types/contracts.d.ts`. `test/types.test.js` fails if they differ from the pinned Contracts release, the `openvibe-contracts` devDependency (temporarily the sharing branch), which the tools tests also use to validate requests and answers. It is never a runtime or peer dependency. |
| 618 | - A new public capability ships as a minor release: first the contract, then the wrapper here, then the service's route. Removing a wrapper is a major release, after the capability's deprecation window in Contracts has passed. |
| 619 | |
| 620 | ## Not wrapped yet (intentionally) |
| 621 | |
| 622 | - **Community other-site comments and forum API** remain outside this SDK surface; shared typed comment thread routes are wrapped by `community.comments`. |
| 623 | - **Media:** the v1 files API, the object API v2 (`createObjectsClient`) and the public URL helpers are wrapped. Retention holds on v2 objects are not (app keys only). VOD, clip, thumbnail and admin-storage routes are not wrapped because apps reach them only through Live and Media's own servers. Media's own paste API is not wrapped because pastes moved to Community (ADR-011). The mock platform (`openvibe-sdk/testing`) models Media's v1 files only, not v2 objects. |
| 624 | - **Media from the browser:** Media refuses user JWTs on the v1 files and v2 objects APIs, so a browser uploads through its own app server. That server holds the app credential and names the user with `actingUserId` or a subject. |
| 625 | - **Network coins, notifications and legacy-map writes** are internal service-to-service routes with no public capability, so they are not wrapped. Staff-only paste routes are also internal: admin stats, bulk, censor and the AI pass. |
| 626 | - **Billing and Games** from the original charter come when those services publish their capabilities in Contracts (Live has its module since v0.52.0). Codes' tasks (they run code on a person's own machine) stay first-party, and OpenVibe.Watch is not built yet; Host and News have modules for their public reads, while editing stays first-party. Blog opened its posts to apps (openvibe-contracts 0.206.0, `openvibe-sdk/blog`), then Reviews and the Wiki their reads (0.207.0), then Coupons, Deals and Trade what a person does for themselves (0.208.0), then Space its sites (0.209.0). News' published stories need no capability, but its editorial reads stay first-party. 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. |
| 627 | - **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. |
| 628 | - **Realtime over WebSocket and presence** don't exist in Events yet (ADR-005). SSE is the only transport. |
| 629 | - **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. |
| 630 | |
| 631 | ## Capabilities |
| 632 | |
| 633 | The SDK implements no capability and holds no grant: every client calls with the token the app gives |
| 634 | it, so the app needs the grant for what it calls (for example `media.object.upload`, |
| 635 | `events.app.publish`, `search.query.delegate`, `ai.run.create`). What each subpath wraps, and the |
| 636 | capability each call performs, is in the [API](#api) table; a capability with no wrapper is not public. |
| 637 | |
| 638 | ## Acceptance |
| 639 | |
| 640 | `npm test` runs every `test/*.test.js` against local stub servers and the mock platform, then packs |
| 641 | the package and installs it. What it proves: each client's requests and error handling (one test file |
| 642 | per subpath), token verification, PKCE and revocation cutoffs (`jwt`, `pkce`, `auth-tokens`, |
| 643 | `revocations`), webhook signatures and the outbox/inbox (`events`, `outbox`), the per-actor limiter |
| 644 | (`limits`), that an external app authenticates, discovers Media from the registry and uploads through |
| 645 | the mock platform (`testing`), that the ESM entries, types and browser bundle match the sources |
| 646 | (`esm`, `types`, `bundle`, `browser-entries`), and that the packed tarball contains every entry point |
| 647 | (`pack`). |
| 648 | |
| 649 | ## Security |
| 650 | |
| 651 | Reporting a vulnerability: [SECURITY.md](SECURITY.md). Browser-safe files never touch a client secret |
| 652 | (the browser secret scan covers the bundle); secrets and tokens are never logged; a write is retried |
| 653 | only with an idempotency key; webhook verification checks signature v2 within ±300 s (`requireV2` refuses a |
| 654 | v1-only delivery); the Frame loads only from an https origin (or `http://localhost` in development). The |
| 655 | package has no runtime dependencies. |
| 656 | |
| 657 | ## Deploy |
| 658 | |
| 659 | Nothing is deployed from this repository: it is a package. A release is a git tag (`vX.Y.Z`, with its |
| 660 | [CHANGELOG.md](CHANGELOG.md) entry); services and apps pin the tag's tarball and ship it with their own |
| 661 | deploys (`sudo ovhost deploy <service>` for OpenVibe services). A bad release is undone by pinning the |
| 662 | previous tag. |
| 663 | |
| 664 | ## Development |
| 665 | |
| 666 | ```bash |
| 667 | npm test # every test/*.test.js: stub servers + mock platform, then npm pack + install |
| 668 | fnm exec --using=22.22.1 npm test # the Node version production runs |
| 669 | node scripts/esm.js # regenerate esm/*.mjs after changing a module's exports |
| 670 | node scripts/browser-bundle.js # regenerate browser/openvibe-sdk.mjs after changing a browser-safe file |
| 671 | ``` |
| 672 | |
| 673 | Style: CommonJS, 4-space indent, single quotes, semicolons. Browser-safe files use only `fetch`, `Headers`, `FormData`, `Blob`, `URL`, `TextEncoder`/`TextDecoder` and `globalThis.crypto` (Web Crypto). Server-only files may use `node:` modules and are marked `"browser": null` in `package.json`. |
| 674 | |
| 675 | ## Owns |
| 676 | |
| 677 | Owns the supported client surface, version negotiation and feature detection, and the mock adapters for development. It replaces the hand-written per-repo clients: Live's `media-client`, Community's proxies and Tools' fetch helpers. |
| 678 | |
| 679 | ## Does not own |
| 680 | |
| 681 | Service implementations and their data (each service), the contracts (OpenVibe.Contracts, whose types it copies), and UI components (OpenVibe.Shared; the Frame module loads Shared's published files, it does not bundle them). |
| 682 | |
| 683 | ## Depends on |
| 684 | |
| 685 | Nothing at runtime. `openvibe-contracts` and `@electric-sql/pglite` are devDependencies (type checks, the database, outbox and inbox tests). At run time the clients talk to OpenVibe.Network (tokens, registry, discovery) and the service each one wraps. |
| 686 | |
| 687 | --- |
| 688 | |
| 689 | Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers). |
| 690 | |
| 691 | <!-- versions:start --> |
| 692 | - openvibe-contracts: v0.419.0 |
| 693 | <!-- versions:end --> |
| 694 | |
| 695 | ### Network-wide sharing (Node) |
| 696 | |
| 697 | `openvibe-sdk/share` provides local access enforcement, expiring memberships and confirmed ownership transfer. See [the sharing module](docs/share.md) for integration, API signatures, migrations and the uniform OpenAPI fragment. Agents use `require('openvibe-sdk').share({ base, token }).for('paste', id)`. |
| 698 | |
| 699 | ### Locker: end-to-end encryption (Node and the browser) |
| 700 | |
| 701 | `openvibe-sdk/locker` seals anything and opens it again: the Locker envelope (`locker.envelope@1`), the three locks (people, password, link), streams for files, person key pairs and the eight-word fingerprint, `wrapFor` and `rewrap` for sharing, `recoveryWords` for the phrase to write down, `inspect` for what an envelope says about itself, and opening a people lock by key. `openvibe-sdk/locker/server` gives a service the header checks without a key. WebCrypto only, in Node and the browser. See [the locker module](docs/locker.md). |
| 702 | # AI comparison and Arena |
| 703 | |
| 704 | Read Report's public AI data and create or vote on Exchange Arena rounds: |
| 705 | |
| 706 | ```js |
| 707 | const ov = require('openvibe-sdk'); |
| 708 | const client = ov.createClient(); |
| 709 | const report = ov.report.createReportClient(client); |
| 710 | const leaders = await report.ai.leaderboard({ kind: 'code', limit: 25 }); |
| 711 | const headToHead = await report.ai.compare(['openai/gpt-4o', 'anthropic/claude-sonnet']); |
| 712 | const mine = await report.me({ days: 30 }); // private by default; the switches say what is public |
| 713 | await report.publish({ sites: true, activity: true, made: false, ai: false, money: false }); |
| 714 | const theirs = await report.person('goosely'); // only the sections they published |
| 715 | |
| 716 | const exchange = require('openvibe-sdk/exchange').createExchangeClient({ developerKey }); |
| 717 | const round = await exchange.arena.start({ prompt: 'Explain a binary search.', category: 'code', entries }); |
| 718 | await exchange.arena.vote(round.id, 'left'); |
| 719 | ``` |
| 720 | ## Meme encyclopedia and renderer |
| 721 | |
| 722 | Search cited meme entries, read source and remix timelines, list reusable templates, and render a caption to raw image/video bytes: |
| 723 | |
| 724 | ```js |
| 725 | const ov = require('openvibe-sdk'); |
| 726 | const client = ov.createClient(); |
| 727 | const memes = ov.meme.createMemeClient(client); |
| 728 | const { entries } = await memes.search({ scope: 'internet', q: 'Doge' }); |
| 729 | const { templates } = await memes.templates(); |
| 730 | const png = await memes.render({ template_id: templates[0].id, lines: ['Much quality', 'such tiny SDK'] }); |
| 731 | // png.bytes is an ArrayBuffer; png.contentType is image/png (or image/gif / video/mp4). |
| 732 | ``` |
| 733 | |
| 734 | See [the Meme client](docs/meme.md) for the render options and response details. |
| 735 | |
| 736 | ## Camera settings |
| 737 | |
| 738 | Camera choices can be read or saved on behalf of a signed-in person with `ov.cam.settings.get({ token })` and |
| 739 | `ov.cam.settings.put({ token, values: { facing, lens, mic, resolution, mirror } })`. Server callers should forward |
| 740 | the person's Network token; Cam keeps these preferences per person, and a missing device id is only a hint. |
| 741 | |
| 742 | ### Pictures |
| 743 | |
| 744 | ```js |
| 745 | const { createPicsClient } = require('openvibe-sdk/pics'); |
| 746 | const pics = createPicsClient(client); |
| 747 | const gallery = await pics.gallery({ sort: 'new', tag: 'landscape' }); |
| 748 | const session = await pics.uploads.create({ size: bytes.length, content_type: 'image/png', name: 'photo.png' }); |
| 749 | // Send at most 1 MiB per chunk; read uploads.get(id).offset after an interrupted request. |
| 750 | await pics.uploads.chunk(session.id, 0, bytes); |
| 751 | const picture = await pics.uploads.complete(session.id); // private |
| 752 | await pics.publish(picture.slug, { listed: true, tags: ['landscape'] }); |
| 753 | ``` |
| 754 | |
| 755 | `pics.albums` lists, creates and manages albums; `pics.like(slug, false)` removes a |
| 756 | like. Upload and write calls require a person's Network token. Gallery reads are |
| 757 | public. Resumable completion and likes are idempotent; creation is not retried. |
| 758 |