openvibers/openvibe.sdk Commits
openvibe-sdk 0.39.0: openvibe-sdk/notifications (push a notification through Network) (#35)
Changes
@@@ -3,7 +3,16 @@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.38.1 (2026-10-10)++## 0.39.0 (2026-10-10)++++- **New: `openvibe-sdk/notifications`** (server). `createNotificationsClient(client).push({ subjectId | userId, type, title,++ message, url, category, priority, icon, rich_content })` sends a person one notification through OpenVibe.Network++ (`POST /internal/notifications/push`, network.notification-push-request@1; capabilities `network.notifications.push`++ and, for a subject, `identity.subject.resolve`). It answers `{ sent: true, skipped }` (skipped: the person switched the++ category off) or `{ sent: false, reason: 'unknown_subject' }` for a subject Network does not know or a deleted account.++ The push is attempted once, because Network keeps no idempotency record for it: callers retry from their own queue.++ Search and Watch each had (or were about to write) their own copy.++- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be@@@ -165,6 +165,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` || `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` || `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |++| `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 || `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services || `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`) || `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` |@@@ -3,7 +3,7 @@"stage": "alpha","code": true,"wave": "W2",-- "version": "0.38.1",++ "version": "0.39.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.38.1, MIT). Do not edit.++// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.39.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.38.1';++const SDK_VERSION = '0.39.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,7 @@++// Generated by scripts/esm.js from src/notifications.js. Do not edit.++import cjs from '../src/notifications.js';++++export const {++ createNotificationsClient,++} = cjs;++export default cjs;@@@ -1,12 +1,12 @@{"name": "openvibe-sdk",-- "version": "0.38.1",++ "version": "0.39.0","lockfileVersion": 3,"requires": true,"packages": {"": {"name": "openvibe-sdk",-- "version": "0.38.1",++ "version": "0.39.0","license": "MIT","devDependencies": {"@electric-sql/pglite": "^0.5.8",@@@ -1,6 +1,6 @@{"name": "openvibe-sdk",-- "version": "0.38.1",++ "version": "0.39.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",@@@ -39,6 +39,12 @@"import": "./esm/identity.mjs","default": "./src/identity.js"},++ "./notifications": {++ "types": "./types/notifications.d.ts",++ "browser": null,++ "import": "./esm/notifications.mjs",++ "default": "./src/notifications.js"++ },"./modules": {"types": "./types/modules.d.ts","import": "./esm/modules.mjs",@@@ -18,6 +18,7 @@ const ENTRIES = {'auth-browser.mjs': '../src/auth/browser.js','registry.mjs': '../src/registry.js','identity.mjs': '../src/identity.js',++ 'notifications.mjs': '../src/notifications.js','modules.mjs': '../src/modules.js','events.mjs': '../src/events.js','sso.mjs': '../src/sso/index.js',@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');const semver = require('./semver');/** The package version (test/version.test.js keeps it equal to package.json). */--const SDK_VERSION = '0.38.1';++const SDK_VERSION = '0.39.0';module.exports = {createClient,nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * openvibe-sdk/notifications (server): tell a person something through OpenVibe.Network's notifications.++ * Needs a service token with network.notifications.push, plus identity.subject.resolve to address a person by subject++ * (audience openvibe.network). The routes are under /internal, served on the Network's host-internal address: pass that++ * as baseUrl (or baseUrls.network) when calling from the same host.++ *++ * const notifications = createNotificationsClient(client);++ * await notifications.push({ subjectId: 'usr_…', type: 'WATCH_TRIGGERED', title, message, url, category, priority, icon,++ * rich_content })++ * -> { sent: true, skipped } Network made its decision; skipped: the person switched that category off++ * -> { sent: false, reason: 'unknown_subject' } a subject Network does not know, or a deleted account++ *++ * The body is network.notification-push-request@1 (POST /internal/notifications/push): Network creates one notification++ * for its own user id, which push() resolves from the subject (pass { userId } when you already hold it). Anything else++ * Network refuses throws the client's OpenVibeError. The push is attempted once (Network keeps no idempotency record for++ * it): retry from your own queue, so a person is notified at least once and you decide how a lost answer is handled.++ */++const { createIdentityClient } = require('./identity');++++function createNotificationsClient(client, { baseUrl } = {}) {++ const identity = createIdentityClient(client, { baseUrl });++++ async function push({ subjectId, userId, ...notification } = {}) {++ let id = userId;++ if (id == null) {++ if (!subjectId) throw new TypeError('push: pass { subjectId } or { userId }');++ const who = await identity.resolve({ subjectId });++ if (!who || who.deleted || who.network_user_id == null) return { sent: false, reason: 'unknown_subject' };++ id = who.network_user_id;++ }++ const out = await client.json({++ service: 'network', baseUrl, audience: 'openvibe.network',++ method: 'POST', path: '/internal/notifications/push', json: { ...notification, user_id: id },++ // Network's push route keeps no idempotency record, so an automatic retry could notify twice: the caller owns++ // the retry (and its dedupe, e.g. one queued row per trigger), so this call is attempted once.++ retries: 0, idempotencyKey: false,++ });++ return { sent: true, skipped: Boolean(out && out.skipped) };++ }++++ return { push };++}++++module.exports = { createNotificationsClient };@@@ -76,7 +76,7 @@ run([for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);}assert.deepEqual(problems, []);-- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);++ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);const rel = [...scanned].map((f) => path.relative(ROOT, f));for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {assert.ok(rel.includes(must), `${must} is scanned`);nnew file mode 100644@@@ -0,0 +1,51 @@++'use strict';++/** Notifications: a subject resolves to Network's user id, then one push; unknown subjects are not errors. */++const assert = require('node:assert/strict');++const { stubServer, send, run } = require('./helpers');++const { createClient } = require('../src/core');++const { createNotificationsClient } = require('../src/notifications');++++const ANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';++const GONE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';++++run([++ ['push resolves the subject, then posts network.notification-push-request@1 with the Network user id', async () => {++ const srv = await stubServer((req, res, body) => {++ if (req.url.startsWith('/internal/identity/resolve')) {++ const subject = new URL(req.url, 'http://x').searchParams.get('subject_id');++ if (subject === ANA) return send(res, 200, { subject: { type: 'user', id: ANA }, network_user_id: 5, username: 'ana' });++ return send(res, 404, { type: 'about:blank', title: 'Not found', status: 404, code: 'identity.subject_not_found' });++ }++ if (req.url === '/internal/notifications/push') {++ const b = JSON.parse(body.toString());++ return send(res, 200, { ok: true, skipped: b.category === 'social' });++ }++ return send(res, 404, {});++ });++ const notifications = createNotificationsClient(createClient({ baseUrls: { network: srv.url }, token: 'svc' }));++ const out = await notifications.push({ subjectId: ANA, type: 'WATCH_TRIGGERED', title: 'Price dropped', message: '19.99', url: 'https://openvibe.watch/watches/wch_1', category: 'service', priority: 'high' });++ assert.deepEqual(out, { sent: true, skipped: false });++ const pushed = srv.requests.find((r) => r.url === '/internal/notifications/push');++ assert.equal(pushed.method, 'POST');++ assert.equal(pushed.headers.authorization, 'Bearer svc');++ assert.deepEqual(JSON.parse(pushed.body.toString()), { type: 'WATCH_TRIGGERED', title: 'Price dropped', message: '19.99', url: 'https://openvibe.watch/watches/wch_1', category: 'service', priority: 'high', user_id: 5 });++ assert.deepEqual(await notifications.push({ subjectId: ANA, title: 'x', category: 'social' }), { sent: true, skipped: true }, 'a category the person switched off is delivered-and-skipped');++ assert.deepEqual(await notifications.push({ subjectId: GONE, title: 'x' }), { sent: false, reason: 'unknown_subject' });++ const before = srv.requests.length;++ assert.deepEqual(await notifications.push({ userId: 7, title: 'direct' }), { sent: true, skipped: false });++ assert.equal(srv.requests.length, before + 1, 'a user id skips the resolve');++ await assert.rejects(notifications.push({ title: 'nobody' }), TypeError);++ await srv.close();++ }],++++ ['a push Network refuses throws (the caller retries); a deleted account is unknown, not an error', async () => {++ const srv = await stubServer((req, res) => {++ if (req.url.startsWith('/internal/identity/resolve')) return send(res, 200, { subject: { type: 'user', id: ANA }, network_user_id: 5, deleted: req.url.includes('PR') });++ return send(res, 503, { type: 'about:blank', title: 'Unavailable', status: 503, code: 'notifications.unavailable' });++ });++ const notifications = createNotificationsClient(createClient({ baseUrls: { network: srv.url }, token: 'svc' }));++ await assert.rejects(notifications.push({ subjectId: ANA, title: 'x' }), (err) => err.status === 503);++ assert.equal(srv.requests.filter((r) => r.url === '/internal/notifications/push').length, 1, 'attempted once: no automatic retry that could notify twice');++ await srv.close();++ }],++]);@@@ -66,7 +66,7 @@ run([})().catch((err) => { console.error(err); process.exit(1); });`;const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));-- assert.equal(subpaths.length, 43);++ assert.equal(subpaths.length, 44);assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');} finally {fs.rmSync(tmp, { recursive: true, force: true });nnew file mode 100644@@@ -0,0 +1,22 @@++import type { OpenVibeClient } from './core';++++/** network.notification-push-request@1 fields, minus the recipient (push() resolves it). */++export interface NotificationFields {++ type?: string;++ title?: string;++ message?: string | null;++ url?: string;++ category?: 'service' | 'social' | 'system' | string;++ priority?: 'low' | 'normal' | 'high' | 'critical' | string;++ icon?: string;++ rich_content?: Record<string, unknown>;++ service?: string;++ [field: string]: unknown;++}++export type PushInput = ({ subjectId: string; userId?: undefined } | { userId: number | string; subjectId?: undefined }) & NotificationFields;++export type PushResult = { sent: true; skipped: boolean } | { sent: false; reason: 'unknown_subject' };++++export interface NotificationsClient {++ push(input: PushInput): Promise<PushResult>;++}++export declare function createNotificationsClient(client: OpenVibeClient, opts?: { baseUrl?: string }): NotificationsClient;
@@ -3,7 +3,16 @@
@@ -3,7 +3,16 @@
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.38.1 (2026-10-10)
+## 0.39.0 (2026-10-10)
+
+- **New: `openvibe-sdk/notifications`** (server). `createNotificationsClient(client).push({ subjectId | userId, type, title,
+ message, url, category, priority, icon, rich_content })` sends a person one notification through OpenVibe.Network
+ (`POST /internal/notifications/push`, network.notification-push-request@1; capabilities `network.notifications.push`
+ and, for a subject, `identity.subject.resolve`). It answers `{ sent: true, skipped }` (skipped: the person switched the
+ category off) or `{ sent: false, reason: 'unknown_subject' }` for a subject Network does not know or a deleted account.
+ The push is attempted once, because Network keeps no idempotency record for it: callers retry from their own queue.
+ Search and Watch each had (or were about to write) their own copy.
+
- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in
- **`migrate`: a fresh database applies its contract migrations at once.** When a run starts with no row in
`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be
`ov_migrations` (a new install, a test database, a restore drill into an empty schema), no previous release can be
@@ -165,6 +165,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
@@ -165,6 +165,7 @@ Until OpenVibe.Tools serves the gateway facade (Tools S6), point the jobs client
| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
| `openvibe-sdk/auth` | server (browser build: PKCE only) | `createServiceTokenClient()` (+ `getTokenInfo()`), `verifyUserToken()`, `verifyAppToken()`, `verifyServiceToken()`, `exchangeCode()`, `refreshUserToken()`; `startAuthorization()`, `buildAuthorizeUrl()`, `createPkcePair()`, `pkceChallenge()`, `readCallback()`, `decodeUnverified()`, `unverifiedClaims()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/registry` | both | `createRegistryClient(client)`: `services({status})`, `service(id)`, `capabilities({owner})`, `capability(id)`, `namespaces()`, `contracts()`, `topics()`, `domain(host)`, `descriptor()` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
| `openvibe-sdk/identity` | server | `createIdentityClient(client)`: `resolve({subjectId} \| {system,type,id})`, `resolveBatch({subjectIds} \| {system,type,ids})` |
+| `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 |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `openvibe-sdk/modules` | both | `createModulesClient(client)`: `get`, `put(ns, data, {revision})`, `delete`, `list`, `update(ns, fn)`, `publicGet`; `forSubject.get/put/update` for services |
| `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`) |
| `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`) |
| `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` |
| `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` |
@@ -3,7 +3,7 @@
@@ -3,7 +3,7 @@
"stage": "alpha",
"stage": "alpha",
"code": true,
"code": true,
"wave": "W2",
"wave": "W2",
- "version": "0.38.1",
+ "version": "0.39.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.38.1, MIT). Do not edit.
+// Generated by scripts/browser-bundle.js from browser.js (openvibe-sdk 0.39.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.38.1';
+const SDK_VERSION = '0.39.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+// Generated by scripts/esm.js from src/notifications.js. Do not edit.
+import cjs from '../src/notifications.js';
+
+export const {
+ createNotificationsClient,
+} = cjs;
+export default cjs;
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.38.1",
+ "version": "0.39.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-sdk",
"name": "openvibe-sdk",
- "version": "0.38.1",
+ "version": "0.39.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.38.1",
+ "version": "0.39.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",
@@ -39,6 +39,12 @@
@@ -39,6 +39,12 @@
"import": "./esm/identity.mjs",
"import": "./esm/identity.mjs",
"default": "./src/identity.js"
"default": "./src/identity.js"
},
},
+ "./notifications": {
+ "types": "./types/notifications.d.ts",
+ "browser": null,
+ "import": "./esm/notifications.mjs",
+ "default": "./src/notifications.js"
+ },
"./modules": {
"./modules": {
"types": "./types/modules.d.ts",
"types": "./types/modules.d.ts",
"import": "./esm/modules.mjs",
"import": "./esm/modules.mjs",
@@ -18,6 +18,7 @@ const ENTRIES = {
@@ -18,6 +18,7 @@ const ENTRIES = {
'auth-browser.mjs': '../src/auth/browser.js',
'auth-browser.mjs': '../src/auth/browser.js',
'registry.mjs': '../src/registry.js',
'registry.mjs': '../src/registry.js',
'identity.mjs': '../src/identity.js',
'identity.mjs': '../src/identity.js',
+ 'notifications.mjs': '../src/notifications.js',
'modules.mjs': '../src/modules.js',
'modules.mjs': '../src/modules.js',
'events.mjs': '../src/events.js',
'events.mjs': '../src/events.js',
'sso.mjs': '../src/sso/index.js',
'sso.mjs': '../src/sso/index.js',
@@ -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.38.1';
+const SDK_VERSION = '0.39.0';
module.exports = {
module.exports = {
createClient,
createClient,
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * openvibe-sdk/notifications (server): tell a person something through OpenVibe.Network's notifications.
+ * Needs a service token with network.notifications.push, plus identity.subject.resolve to address a person by subject
+ * (audience openvibe.network). The routes are under /internal, served on the Network's host-internal address: pass that
+ * as baseUrl (or baseUrls.network) when calling from the same host.
+ *
+ * const notifications = createNotificationsClient(client);
+ * await notifications.push({ subjectId: 'usr_…', type: 'WATCH_TRIGGERED', title, message, url, category, priority, icon,
+ * rich_content })
+ * -> { sent: true, skipped } Network made its decision; skipped: the person switched that category off
+ * -> { sent: false, reason: 'unknown_subject' } a subject Network does not know, or a deleted account
+ *
+ * The body is network.notification-push-request@1 (POST /internal/notifications/push): Network creates one notification
+ * for its own user id, which push() resolves from the subject (pass { userId } when you already hold it). Anything else
+ * Network refuses throws the client's OpenVibeError. The push is attempted once (Network keeps no idempotency record for
+ * it): retry from your own queue, so a person is notified at least once and you decide how a lost answer is handled.
+ */
+const { createIdentityClient } = require('./identity');
+
+function createNotificationsClient(client, { baseUrl } = {}) {
+ const identity = createIdentityClient(client, { baseUrl });
+
+ async function push({ subjectId, userId, ...notification } = {}) {
+ let id = userId;
+ if (id == null) {
+ if (!subjectId) throw new TypeError('push: pass { subjectId } or { userId }');
+ const who = await identity.resolve({ subjectId });
+ if (!who || who.deleted || who.network_user_id == null) return { sent: false, reason: 'unknown_subject' };
+ id = who.network_user_id;
+ }
+ const out = await client.json({
+ service: 'network', baseUrl, audience: 'openvibe.network',
+ method: 'POST', path: '/internal/notifications/push', json: { ...notification, user_id: id },
+ // Network's push route keeps no idempotency record, so an automatic retry could notify twice: the caller owns
+ // the retry (and its dedupe, e.g. one queued row per trigger), so this call is attempted once.
+ retries: 0, idempotencyKey: false,
+ });
+ return { sent: true, skipped: Boolean(out && out.skipped) };
+ }
+
+ return { push };
+}
+
+module.exports = { createNotificationsClient };
@@ -76,7 +76,7 @@ run([
@@ -76,7 +76,7 @@ run([
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
for (const [re, why] of FORBIDDEN) if (re.test(src)) problems.push(`${path.relative(ROOT, file)}: ${why}`);
}
}
assert.deepEqual(problems, []);
assert.deepEqual(problems, []);
- assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
+ assert.deepEqual(blocked.sort(), ['./account-data', './cache', './commerce', './cost', './db', './delivery', './events', './govern', './identity', './limits', './node', './notifications', './openre', './placement', './pubsub', './queue', './resources', './runtime', './service', './sso', './storage', './telemetry', './testing', './usage', './valkey', './vip']);
const rel = [...scanned].map((f) => path.relative(ROOT, f));
const rel = [...scanned].map((f) => path.relative(ROOT, f));
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
for (const must of ['browser.js', 'src/core/client.js', 'src/auth/browser.js', 'src/realtime.js', 'src/media.js', 'src/community.js', 'src/bot.js', 'src/modules.js', 'src/registry.js', 'src/jobs.js', 'src/tools.js', 'src/core/form.js', 'src/projects.js', 'browser/openvibe-sdk.mjs']) {
assert.ok(rel.includes(must), `${must} is scanned`);
assert.ok(rel.includes(must), `${must} is scanned`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+'use strict';
+/** Notifications: a subject resolves to Network's user id, then one push; unknown subjects are not errors. */
+const assert = require('node:assert/strict');
+const { stubServer, send, run } = require('./helpers');
+const { createClient } = require('../src/core');
+const { createNotificationsClient } = require('../src/notifications');
+
+const ANA = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
+const GONE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
+
+run([
+ ['push resolves the subject, then posts network.notification-push-request@1 with the Network user id', async () => {
+ const srv = await stubServer((req, res, body) => {
+ if (req.url.startsWith('/internal/identity/resolve')) {
+ const subject = new URL(req.url, 'http://x').searchParams.get('subject_id');
+ if (subject === ANA) return send(res, 200, { subject: { type: 'user', id: ANA }, network_user_id: 5, username: 'ana' });
+ return send(res, 404, { type: 'about:blank', title: 'Not found', status: 404, code: 'identity.subject_not_found' });
+ }
+ if (req.url === '/internal/notifications/push') {
+ const b = JSON.parse(body.toString());
+ return send(res, 200, { ok: true, skipped: b.category === 'social' });
+ }
+ return send(res, 404, {});
+ });
+ const notifications = createNotificationsClient(createClient({ baseUrls: { network: srv.url }, token: 'svc' }));
+ const out = await notifications.push({ subjectId: ANA, type: 'WATCH_TRIGGERED', title: 'Price dropped', message: '19.99', url: 'https://openvibe.watch/watches/wch_1', category: 'service', priority: 'high' });
+ assert.deepEqual(out, { sent: true, skipped: false });
+ const pushed = srv.requests.find((r) => r.url === '/internal/notifications/push');
+ assert.equal(pushed.method, 'POST');
+ assert.equal(pushed.headers.authorization, 'Bearer svc');
+ assert.deepEqual(JSON.parse(pushed.body.toString()), { type: 'WATCH_TRIGGERED', title: 'Price dropped', message: '19.99', url: 'https://openvibe.watch/watches/wch_1', category: 'service', priority: 'high', user_id: 5 });
+ assert.deepEqual(await notifications.push({ subjectId: ANA, title: 'x', category: 'social' }), { sent: true, skipped: true }, 'a category the person switched off is delivered-and-skipped');
+ assert.deepEqual(await notifications.push({ subjectId: GONE, title: 'x' }), { sent: false, reason: 'unknown_subject' });
+ const before = srv.requests.length;
+ assert.deepEqual(await notifications.push({ userId: 7, title: 'direct' }), { sent: true, skipped: false });
+ assert.equal(srv.requests.length, before + 1, 'a user id skips the resolve');
+ await assert.rejects(notifications.push({ title: 'nobody' }), TypeError);
+ await srv.close();
+ }],
+
+ ['a push Network refuses throws (the caller retries); a deleted account is unknown, not an error', async () => {
+ const srv = await stubServer((req, res) => {
+ if (req.url.startsWith('/internal/identity/resolve')) return send(res, 200, { subject: { type: 'user', id: ANA }, network_user_id: 5, deleted: req.url.includes('PR') });
+ return send(res, 503, { type: 'about:blank', title: 'Unavailable', status: 503, code: 'notifications.unavailable' });
+ });
+ const notifications = createNotificationsClient(createClient({ baseUrls: { network: srv.url }, token: 'svc' }));
+ await assert.rejects(notifications.push({ subjectId: ANA, title: 'x' }), (err) => err.status === 503);
+ assert.equal(srv.requests.filter((r) => r.url === '/internal/notifications/push').length, 1, 'attempted once: no automatic retry that could notify twice');
+ await srv.close();
+ }],
+]);
@@ -66,7 +66,7 @@ run([
@@ -66,7 +66,7 @@ run([
})().catch((err) => { console.error(err); process.exit(1); });`;
})().catch((err) => { console.error(err); process.exit(1); });`;
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
const res = execFileSync(process.execPath, ['-e', script], { cwd: app, encoding: 'utf8' });
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
assert.match(res, new RegExp(`consumer ok: ${subpaths.length} subpaths \\+ the browser bundle`));
- assert.equal(subpaths.length, 43);
+ assert.equal(subpaths.length, 44);
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
assert.ok(!fs.existsSync(path.join(app, 'node_modules/openvibe-contracts')), 'the optional peer is not installed');
} finally {
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
fs.rmSync(tmp, { recursive: true, force: true });
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+import type { OpenVibeClient } from './core';
+
+/** network.notification-push-request@1 fields, minus the recipient (push() resolves it). */
+export interface NotificationFields {
+ type?: string;
+ title?: string;
+ message?: string | null;
+ url?: string;
+ category?: 'service' | 'social' | 'system' | string;
+ priority?: 'low' | 'normal' | 'high' | 'critical' | string;
+ icon?: string;
+ rich_content?: Record<string, unknown>;
+ service?: string;
+ [field: string]: unknown;
+}
+export type PushInput = ({ subjectId: string; userId?: undefined } | { userId: number | string; subjectId?: undefined }) & NotificationFields;
+export type PushResult = { sent: true; skipped: boolean } | { sent: false; reason: 'unknown_subject' };
+
+export interface NotificationsClient {
+ push(input: PushInput): Promise<PushResult>;
+}
+export declare function createNotificationsClient(client: OpenVibeClient, opts?: { baseUrl?: string }): NotificationsClient;