openvibers/openvibe.sdk Commits

openvibe-sdk 0.39.0: openvibe-sdk/notifications (push a notification through Network) (#35)

OP OpenVibers committed 10 Oct 2026 · fc4f39f6ba · 14 files, +152 −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.

- It resolves the subject with `identity.subject.resolve`, then `POST /internal/notifications/push` (network.notification-push-request@1, capability `network.notifications.push`) with Network's user id.
- It answers `{ sent: true, skipped }`, where skipped means the person switched that category off and Network decided, or `{ sent: false, reason: 'unknown_subject' }` for a subject Network does not know or a deleted account. Any other refusal throws the client's OpenVibeError.
- **Attempted once:** Network keeps no idempotency record for a push, so an automatic retry could notify twice. The caller retries from its own queue and owns the dedupe.

Why now: OpenVibe.Watch's notification action (plan T18 step 4) is next. Today a watch with "Notify me" fires and nobody is told. Search already carries its own copy of this client; it can move to the shared one.

0.39.0 (minor: a new subpath). Exports map, ESM wrapper, types, README table and changelog are updated. `test/notifications.test.js` covers the resolve then push body, delivered-but-skipped, unknown subject, a direct user id, and a refusal that is not retried. The subpath count and the browser-blocked list include it. `ov test`: green.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 1a11726..a73f3e9 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -3,7 +3,16 @@
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.
--## 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
ddiff --git a/README.md b/README.md
iindex cffcad7..1737c9f 100644
---- a/README.md
++++ b/README.md
@@@ -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` |
ddiff --git a/STATUS.json b/STATUS.json
iindex fc6d6d6..587c04f 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/browser/openvibe-sdk.mjs b/browser/openvibe-sdk.mjs
iindex e0de004..5ff1de9 100644
---- a/browser/openvibe-sdk.mjs
++++ b/browser/openvibe-sdk.mjs
@@@ -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,
ddiff --git a/esm/notifications.mjs b/esm/notifications.mjs
nnew file mode 100644
iindex 0000000..1889647
---- /dev/null
++++ b/esm/notifications.mjs
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex bf28f31..5287aeb 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 063f81c..8e741c5 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/scripts/esm.js b/scripts/esm.js
iindex 66fbd0d..998832b 100644
---- a/scripts/esm.js
++++ b/scripts/esm.js
@@@ -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',
ddiff --git a/src/core/index.js b/src/core/index.js
iindex b16f481..7d54ddf 100644
---- a/src/core/index.js
++++ b/src/core/index.js
@@@ -8,7 +8,7 @@ const { paginate, offsetPager } = require('./paginate');
const semver = require('./semver');
/** The package version (test/version.test.js keeps it equal to package.json). */
--const SDK_VERSION = '0.38.1';
++const SDK_VERSION = '0.39.0';
module.exports = {
createClient,
ddiff --git a/src/notifications.js b/src/notifications.js
nnew file mode 100644
iindex 0000000..68d86f7
---- /dev/null
++++ b/src/notifications.js
@@@ -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 };
ddiff --git a/test/browser-entries.test.js b/test/browser-entries.test.js
iindex 1dba6d0..e7e6221 100644
---- a/test/browser-entries.test.js
++++ b/test/browser-entries.test.js
@@@ -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`);
ddiff --git a/test/notifications.test.js b/test/notifications.test.js
nnew file mode 100644
iindex 0000000..6b744cc
---- /dev/null
++++ b/test/notifications.test.js
@@@ -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();
++ }],
++]);
ddiff --git a/test/pack.test.js b/test/pack.test.js
iindex a89930b..37aebbc 100644
---- a/test/pack.test.js
++++ b/test/pack.test.js
@@@ -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 });
ddiff --git a/types/notifications.d.ts b/types/notifications.d.ts
nnew file mode 100644
iindex 0000000..be02d3f
---- /dev/null
++++ b/types/notifications.d.ts
@@@ -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;