openvibers/openvibe.sdk Commits
SDK 0.37.0: openvibe-sdk/account-data startSubscriptions() — the two account-event subscriptions created at boot when missing, the half every adopter had copied (#25)
Changes
@@@ -3,6 +3,14 @@All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minorrelease may change an API and says so here.++## 0.37.0 (2026-10-08)++++- **`openvibe-sdk/account-data`: `startSubscriptions()`**, the boot-time half every adopter had copied. It creates any++ missing OpenVibe.Events subscription for the two account topics (or `topics`) to the service's loopback endpoint with++ its own token (audience openvibe.events, scope events.subscription.manage), retried with backoff in the background,++ and leaves an existing one alone, so a restart creates nothing. It returns null when the Events URL, the delivery++ secret or the client secret is unset; `stop()` ends the retries and `done` says whether every topic was subscribed.++## 0.36.0 (2026-10-08)- **`openvibe-sdk/account-data` (new, node): a service's share of account export and deletion (ADR-033).** Six services@@@ -2,7 +2,7 @@> Supported browser and server clients for the OpenVibe platform.--**Status:** alpha, v0.36.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.++**Status:** alpha, v0.37.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.**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.@@@ -174,7 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) || `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) || `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |--| `openvibe-sdk/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})`: a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |++| `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 || `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` || `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` || `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 |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.36.0",++ "version": "0.37.0","plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30","deployed": false,"deployment": "none: a package released as git tags; services and apps pin a tag",@@@ -1,4 +1,4 @@--// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.36.0, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.37.0, MIT). Do not edit.// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity./* eslint-disable */@@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);const semver = __load(6);/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.36.0';++const SDK_VERSION = '0.37.0';module.exports = {createClient,@@@ -2,6 +2,6 @@import cjs from '../src/account-data.js';export const {-- createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS,++ createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS,} = cjs;export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.36.0",++ "version": "0.37.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.36.0",++ "version": "0.37.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.36.0",++ "version": "0.37.0","description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.","main": "index.js","types": "types/index.d.ts",@@@ -17,6 +17,8 @@* await accountData.ensureSchema(); // or ACCOUNT_DATA_SCHEMA in a migration* const send = createNetworkSender({ networkInternalUrl, clientId, clientSecret });* app.use('/internal/events', accountData.consumer({ secrets: [process.env.FOOD_EVENTS_SECRET], send }));++ * startSubscriptions({ eventsUrl, endpoint: 'http://127.0.0.1:4970/internal/events', secret, networkInternalUrl,++ * clientId, clientSecret }); // the two subscriptions, created at boot when missing* // or, inside a consumer the service already has: const outcome = await accountData.apply(event, { send });** A table entry:@@@ -275,4 +277,64 @@ function createNetworkSender({ networkInternalUrl, clientId, clientSecret, fetch};}--module.exports = { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS };++/**++ * The boot-time half: create any missing Events subscription for `topics` (default the two account topics) to this++ * service's loopback `endpoint`, idempotently, retried with backoff in the background. Off (null) when the Events URL,++ * the delivery secret or the client secret is unset. The token is this service's own, audience openvibe.events, scope++ * events.subscription.manage. A subscription already there for the same topic and endpoint is left alone, so a++ * restart creates nothing; Events answers 409 for a racing duplicate, which counts as done.++ *++ * const subs = startSubscriptions({ eventsUrl, endpoint: `http://127.0.0.1:${port}/internal/events`, secret,++ * networkInternalUrl, clientId, clientSecret });++ * // graceful stop: subs && subs.stop()++ *++ * `done` resolves true once every topic is subscribed, false when the retries ran out or stop() came first.++ */++function startSubscriptions({++ eventsUrl, endpoint, secret, topics = TOPICS, networkInternalUrl, clientId, clientSecret,++ fetch: fetchImpl = globalThis.fetch, log = console, delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000],++} = {}) {++ if (!eventsUrl || !endpoint || !secret || !clientId || !clientSecret || !networkInternalUrl) return null;++ const base = String(eventsUrl).replace(/\/+$/, '');++ const tokens = createServiceTokenClient({ tokenUrl: `${String(networkInternalUrl).replace(/\/+$/, '')}/oauth/token`, clientId, clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetch: fetchImpl });++ const call = async (method, path, body) => {++ const res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(15000),++ });++ if (res.status === 401) tokens.invalidate();++ return { status: res.status, ok: res.ok, body: await res.json().catch(() => ({})) };++ };++ const attempt = async () => {++ const listed = await call('GET', '/api/v1/subscriptions');++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);++ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);++ for (const topic of topics) {++ if (mine.some((s) => s.topic_pattern === topic)) continue;++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`.trim());++ if (r.ok) log.log(`[Events] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ }++ };++ let i = 0;++ let timer = null;++ let stopped = false;++ let settle;++ const done = new Promise((resolve) => { settle = resolve; });++ const schedule = (ms) => { timer = setTimeout(run, ms); if (timer.unref) timer.unref(); };++ function run() {++ timer = null;++ if (stopped) return;++ attempt().then(() => settle(true), (err) => {++ if (stopped) return;++ if (++i < delays.length) schedule(delays[i]);++ else { log.warn(`[Events] subscriptions not created: ${err.message}`); settle(false); }++ });++ }++ schedule(delays[0] || 0);++ return { topics: [...topics], endpoint, done, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; settle(false); } };++}++++module.exports = { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS };@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.36.0';++const SDK_VERSION = '0.37.0';module.exports = {createClient,@@@ -8,7 +8,7 @@ const http = require('node:http');const { run, stubServer } = require('./helpers');const { createDb } = require('../src/db');const { signDeliveryHeaders } = require('../src/events');--const { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');++const { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');const A = 'usr_01JZ0000000000000000000AAA';const B = 'usr_01JZ0000000000000000000BBB';@@@ -173,4 +173,40 @@ run([await db.close();}}],++ ['startSubscriptions: the missing topics are created once with the events token, a failure is retried, a restart creates nothing', async () => {++ const subs = [];++ let failListing = 1;++ const tokenAsks = [];++ const stub = await stubServer(async (req, res, body) => {++ res.setHeader('Content-Type', 'application/json');++ if (req.url === '/oauth/token') { tokenAsks.push(Object.fromEntries(new URLSearchParams(body.toString()))); return res.end(JSON.stringify({ access_token: 'evtok', expires_in: 300 })); }++ assert.equal(req.headers.authorization, 'Bearer evtok');++ if (req.method === 'GET') {++ if (failListing-- > 0) { res.statusCode = 502; return res.end('{}'); }++ return res.end(JSON.stringify({ subscriptions: subs }));++ }++ const b = JSON.parse(body.toString());++ const sub = { id: `sub_${subs.length + 1}`, topic_pattern: b.topic_pattern, endpoint: b.endpoint };++ subs.push(sub);++ res.statusCode = 201;++ return res.end(JSON.stringify(sub));++ });++ const opts = { eventsUrl: `${stub.url}/`, networkInternalUrl: stub.url, endpoint: 'http://127.0.0.1:4970/internal/events', secret: SECRET, clientId: 'food', clientSecret: 'secret', log: quiet, delays: [0, 5] };++ try {++ assert.equal(startSubscriptions({ ...opts, secret: '' }), null, 'off without the delivery secret');++ assert.equal(startSubscriptions({ ...opts, eventsUrl: '' }), null, 'off without the Events URL');++ const first = startSubscriptions(opts);++ assert.equal(await first.done, true);++ assert.deepEqual(subs.map((x) => [x.topic_pattern, x.endpoint]), TOPICS.map((t) => [t, opts.endpoint]));++ assert.deepEqual([tokenAsks[0].audience, tokenAsks[0].scope, tokenAsks[0].client_id], ['openvibe.events', 'events.subscription.manage', 'food']);++ const again = startSubscriptions(opts);++ assert.equal(await again.done, true);++ assert.equal(subs.length, 2, 'a restart creates nothing');++ const stopped = startSubscriptions({ ...opts, delays: [60_000] });++ stopped.stop();++ assert.equal(await stopped.done, false, 'stop() before the first attempt');++ } finally {++ await stub.close();++ }++ }],]);@@@ -44,5 +44,12 @@ export function createAccountData(opts: {log?: { log(msg: string): void; warn(msg: string): void };}): AccountData;export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;++export interface Subscriptions { topics: string[]; endpoint: string; done: Promise<boolean>; stop(): void }++/** Create any missing Events subscription for `topics` (default TOPICS) at boot; null when a setting is missing. */++export function startSubscriptions(opts: {++ eventsUrl: string; endpoint: string; secret: string; topics?: readonly string[];++ networkInternalUrl: string; clientId: string; clientSecret: string;++ fetch?: typeof fetch; log?: { log(msg: string): void; warn(msg: string): void }; delays?: number[];++}): Subscriptions | null;export const ACCOUNT_DATA_SCHEMA: string;export const TOPICS: readonly string[];
@@ -3,6 +3,14 @@
@@ -3,6 +3,14 @@
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
All notable changes to `openvibe-sdk`. The package follows semver; while it is `0.x`, a minor
release may change an API and says so here.
release may change an API and says so here.
+## 0.37.0 (2026-10-08)
+
+- **`openvibe-sdk/account-data`: `startSubscriptions()`**, the boot-time half every adopter had copied. It creates any
+ missing OpenVibe.Events subscription for the two account topics (or `topics`) to the service's loopback endpoint with
+ its own token (audience openvibe.events, scope events.subscription.manage), retried with backoff in the background,
+ and leaves an existing one alone, so a restart creates nothing. It returns null when the Events URL, the delivery
+ secret or the client secret is unset; `stop()` ends the retries and `done` says whether every topic was subscribed.
+
## 0.36.0 (2026-10-08)
## 0.36.0 (2026-10-08)
- **`openvibe-sdk/account-data` (new, node): a service's share of account export and deletion (ADR-033).** Six services
- **`openvibe-sdk/account-data` (new, node): a service's share of account export and deletion (ADR-033).** Six services
@@ -2,7 +2,7 @@
@@ -2,7 +2,7 @@
> Supported browser and server clients for the OpenVibe platform.
> Supported browser and server clients for the OpenVibe platform.
-**Status:** alpha, v0.36.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.
+**Status:** alpha, v0.37.0 (roadmap Wave 2; developer apps from Wave 20; Media objects v2 from Wave 4; the Tools platform API; Search, Chat and AI clients from WS-F task 4; per-actor limits from WS-R task 4). 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.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**Plan:** OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30.
**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.
**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.
@@ -174,7 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -174,7 +174,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/bot` | both | `createBotClient(client, {actingSubject?})`: OpenVibe.Bot robots. `robots.list/get/create/update/delete/pairingCode`, `robots.operators.list/add/remove`, `robots.devices`, `robots.streaming.get/set`, `robots.audit`/`iterateAudit`, `robots.estop`/`clearEstop`, `robots.command` (`bot.command@1`), `devices.rotate/revoke`, public `profiles.list/get` and `kits.list/get`, `pair` (the agent's one-time code, never retried), `as(subject)`; a service acts for a person with `X-OV-Subject` (capabilities `bot.robot.read`/`manage`/`control`, `bot.device.connect`) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/search` | both | `createSearchClient(client, {actingSubject?})`: `query(text, {owner, type, lang, filter, facets, limit, cursor})`, `iterate(text, {…, max})`, `suggest(text, {owner, type, limit})`, `document(owner, type, id)` (null when missing or not visible) |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
| `openvibe-sdk/limits` | node | `createActorLimiter({limits: {minute, hour, day}, actor?, maxActors?, onLimited?})` → `limits(name, {minute?, hour?, day?})` Express middleware: per-actor fixed windows at a capability boundary (service principal, else the signed-in person, else `ip:`), 429 problem+json `rate_limited` with Retry-After before the handler; `defaultActor(req)`, `.stats()` |
-| `openvibe-sdk/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})`: a service's share of account export and deletion (ADR-033), idempotent per export/deletion id |
+| `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 |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/chat` | both | `createChatClient(client)`: `global.send/history/search`, `rooms.*` (list, create, get, update, messages, send, deleteMessage, join, leave, read, members, setMember), `dms.*` (list, create, get, messages, send, deleteMessage, read, unread, blocks, block, unblock), `exportMine()` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `openvibe-sdk/ai` | both | `createAiClient(client)`: `runs.create(workflow, input, {wait, idempotencyKey, …})`, `runs.get/list/cancel/retry/citations/addCitations/waitFor`, `chat/generate/summarize/classify/extract/enrich/embed` |
| `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 |
| `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 |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.36.0",
+ "version": "0.37.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026), §3.2; roadmap §30",
"deployed": false,
"deployed": false,
"deployment": "none: a package released as git tags; services and apps pin a tag",
"deployment": "none: a package released as git tags; services and apps pin a tag",
@@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
-// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.36.0, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.37.0, MIT). Do not edit.
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// One self-contained ES module: core at the top level plus the browser-safe subpaths as namespaces
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
// (auth, registry, modules, realtime, media, community, bot, search, chat, ai, jobs, tools, projects, geo). No server-only code: no service tokens, JWT verification, events, identity.
/* eslint-disable */
/* eslint-disable */
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
@@ -42,7 +42,7 @@ const { paginate, offsetPager } = __load(7);
const semver = __load(6);
const semver = __load(6);
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.36.0';
+const SDK_VERSION = '0.37.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -2,6 +2,6 @@
@@ -2,6 +2,6 @@
import cjs from '../src/account-data.js';
import cjs from '../src/account-data.js';
export const {
export const {
- createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS,
+ createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS,
} = cjs;
} = cjs;
export default cjs;
export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.36.0",
+ "version": "0.37.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.36.0",
+ "version": "0.37.0",
"license": "MIT",
"license": "MIT",
"devDependencies": {
"devDependencies": {
"@electric-sql/pglite": "^0.5.8",
"@electric-sql/pglite": "^0.5.8",
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.36.0",
+ "version": "0.37.0",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"description": "Supported browser and server clients for the OpenVibe platform: auth (incl. developer apps), registry discovery, identity, user modules, events, realtime, media, community, Bot robots, search, chat, AI runs, Tools (registry, run API and jobs), VIP memberships and developer projects, the async PostgreSQL data layer (db) and the Valkey cache, limits store, queue and pub/sub, with deadlines, safe retries, problem errors and trace propagation.",
"main": "index.js",
"main": "index.js",
"types": "types/index.d.ts",
"types": "types/index.d.ts",
@@ -17,6 +17,8 @@
@@ -17,6 +17,8 @@
* await accountData.ensureSchema(); // or ACCOUNT_DATA_SCHEMA in a migration
* await accountData.ensureSchema(); // or ACCOUNT_DATA_SCHEMA in a migration
* const send = createNetworkSender({ networkInternalUrl, clientId, clientSecret });
* const send = createNetworkSender({ networkInternalUrl, clientId, clientSecret });
* app.use('/internal/events', accountData.consumer({ secrets: [process.env.FOOD_EVENTS_SECRET], send }));
* app.use('/internal/events', accountData.consumer({ secrets: [process.env.FOOD_EVENTS_SECRET], send }));
+ * startSubscriptions({ eventsUrl, endpoint: 'http://127.0.0.1:4970/internal/events', secret, networkInternalUrl,
+ * clientId, clientSecret }); // the two subscriptions, created at boot when missing
* // or, inside a consumer the service already has: const outcome = await accountData.apply(event, { send });
* // or, inside a consumer the service already has: const outcome = await accountData.apply(event, { send });
*
*
* A table entry:
* A table entry:
@@ -275,4 +277,64 @@ function createNetworkSender({ networkInternalUrl, clientId, clientSecret, fetch
@@ -275,4 +277,64 @@ function createNetworkSender({ networkInternalUrl, clientId, clientSecret, fetch
};
};
}
}
-module.exports = { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS };
+/**
+ * The boot-time half: create any missing Events subscription for `topics` (default the two account topics) to this
+ * service's loopback `endpoint`, idempotently, retried with backoff in the background. Off (null) when the Events URL,
+ * the delivery secret or the client secret is unset. The token is this service's own, audience openvibe.events, scope
+ * events.subscription.manage. A subscription already there for the same topic and endpoint is left alone, so a
+ * restart creates nothing; Events answers 409 for a racing duplicate, which counts as done.
+ *
+ * const subs = startSubscriptions({ eventsUrl, endpoint: `http://127.0.0.1:${port}/internal/events`, secret,
+ * networkInternalUrl, clientId, clientSecret });
+ * // graceful stop: subs && subs.stop()
+ *
+ * `done` resolves true once every topic is subscribed, false when the retries ran out or stop() came first.
+ */
+function startSubscriptions({
+ eventsUrl, endpoint, secret, topics = TOPICS, networkInternalUrl, clientId, clientSecret,
+ fetch: fetchImpl = globalThis.fetch, log = console, delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000],
+} = {}) {
+ if (!eventsUrl || !endpoint || !secret || !clientId || !clientSecret || !networkInternalUrl) return null;
+ const base = String(eventsUrl).replace(/\/+$/, '');
+ const tokens = createServiceTokenClient({ tokenUrl: `${String(networkInternalUrl).replace(/\/+$/, '')}/oauth/token`, clientId, clientSecret, audience: 'openvibe.events', scope: 'events.subscription.manage', fetch: fetchImpl });
+ const call = async (method, path, body) => {
+ const res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(15000),
+ });
+ if (res.status === 401) tokens.invalidate();
+ return { status: res.status, ok: res.ok, body: await res.json().catch(() => ({})) };
+ };
+ const attempt = async () => {
+ const listed = await call('GET', '/api/v1/subscriptions');
+ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
+ const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
+ for (const topic of topics) {
+ if (mine.some((s) => s.topic_pattern === topic)) continue;
+ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
+ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`.trim());
+ if (r.ok) log.log(`[Events] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ }
+ };
+ let i = 0;
+ let timer = null;
+ let stopped = false;
+ let settle;
+ const done = new Promise((resolve) => { settle = resolve; });
+ const schedule = (ms) => { timer = setTimeout(run, ms); if (timer.unref) timer.unref(); };
+ function run() {
+ timer = null;
+ if (stopped) return;
+ attempt().then(() => settle(true), (err) => {
+ if (stopped) return;
+ if (++i < delays.length) schedule(delays[i]);
+ else { log.warn(`[Events] subscriptions not created: ${err.message}`); settle(false); }
+ });
+ }
+ schedule(delays[0] || 0);
+ return { topics: [...topics], endpoint, done, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; settle(false); } };
+}
+
+module.exports = { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS };
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
/** The package version (test/version.test.js keeps it equal to package.json). */
-const SDK_VERSION = '0.36.0';
+const SDK_VERSION = '0.37.0';
module.exports = {
module.exports = {
createClient,
createClient,
@@ -8,7 +8,7 @@ const http = require('node:http');
@@ -8,7 +8,7 @@ const http = require('node:http');
const { run, stubServer } = require('./helpers');
const { run, stubServer } = require('./helpers');
const { createDb } = require('../src/db');
const { createDb } = require('../src/db');
const { signDeliveryHeaders } = require('../src/events');
const { signDeliveryHeaders } = require('../src/events');
-const { createAccountData, createNetworkSender, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');
+const { createAccountData, createNetworkSender, startSubscriptions, ACCOUNT_DATA_SCHEMA, TOPICS } = require('../src/account-data');
const A = 'usr_01JZ0000000000000000000AAA';
const A = 'usr_01JZ0000000000000000000AAA';
const B = 'usr_01JZ0000000000000000000BBB';
const B = 'usr_01JZ0000000000000000000BBB';
@@ -173,4 +173,40 @@ run([
@@ -173,4 +173,40 @@ run([
await db.close();
await db.close();
}
}
}],
}],
+ ['startSubscriptions: the missing topics are created once with the events token, a failure is retried, a restart creates nothing', async () => {
+ const subs = [];
+ let failListing = 1;
+ const tokenAsks = [];
+ const stub = await stubServer(async (req, res, body) => {
+ res.setHeader('Content-Type', 'application/json');
+ if (req.url === '/oauth/token') { tokenAsks.push(Object.fromEntries(new URLSearchParams(body.toString()))); return res.end(JSON.stringify({ access_token: 'evtok', expires_in: 300 })); }
+ assert.equal(req.headers.authorization, 'Bearer evtok');
+ if (req.method === 'GET') {
+ if (failListing-- > 0) { res.statusCode = 502; return res.end('{}'); }
+ return res.end(JSON.stringify({ subscriptions: subs }));
+ }
+ const b = JSON.parse(body.toString());
+ const sub = { id: `sub_${subs.length + 1}`, topic_pattern: b.topic_pattern, endpoint: b.endpoint };
+ subs.push(sub);
+ res.statusCode = 201;
+ return res.end(JSON.stringify(sub));
+ });
+ const opts = { eventsUrl: `${stub.url}/`, networkInternalUrl: stub.url, endpoint: 'http://127.0.0.1:4970/internal/events', secret: SECRET, clientId: 'food', clientSecret: 'secret', log: quiet, delays: [0, 5] };
+ try {
+ assert.equal(startSubscriptions({ ...opts, secret: '' }), null, 'off without the delivery secret');
+ assert.equal(startSubscriptions({ ...opts, eventsUrl: '' }), null, 'off without the Events URL');
+ const first = startSubscriptions(opts);
+ assert.equal(await first.done, true);
+ assert.deepEqual(subs.map((x) => [x.topic_pattern, x.endpoint]), TOPICS.map((t) => [t, opts.endpoint]));
+ assert.deepEqual([tokenAsks[0].audience, tokenAsks[0].scope, tokenAsks[0].client_id], ['openvibe.events', 'events.subscription.manage', 'food']);
+ const again = startSubscriptions(opts);
+ assert.equal(await again.done, true);
+ assert.equal(subs.length, 2, 'a restart creates nothing');
+ const stopped = startSubscriptions({ ...opts, delays: [60_000] });
+ stopped.stop();
+ assert.equal(await stopped.done, false, 'stop() before the first attempt');
+ } finally {
+ await stub.close();
+ }
+ }],
]);
]);
@@ -44,5 +44,12 @@ export function createAccountData(opts: {
@@ -44,5 +44,12 @@ export function createAccountData(opts: {
log?: { log(msg: string): void; warn(msg: string): void };
log?: { log(msg: string): void; warn(msg: string): void };
}): AccountData;
}): AccountData;
export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;
export function createNetworkSender(opts: { networkInternalUrl: string; clientId: string; clientSecret: string; fetch?: typeof fetch; timeoutMs?: number }): NetworkSend;
+export interface Subscriptions { topics: string[]; endpoint: string; done: Promise<boolean>; stop(): void }
+/** Create any missing Events subscription for `topics` (default TOPICS) at boot; null when a setting is missing. */
+export function startSubscriptions(opts: {
+ eventsUrl: string; endpoint: string; secret: string; topics?: readonly string[];
+ networkInternalUrl: string; clientId: string; clientSecret: string;
+ fetch?: typeof fetch; log?: { log(msg: string): void; warn(msg: string): void }; delays?: number[];
+}): Subscriptions | null;
export const ACCOUNT_DATA_SCHEMA: string;
export const ACCOUNT_DATA_SCHEMA: string;
export const TOPICS: readonly string[];
export const TOPICS: readonly string[];