openvibers/openvibe.search Commits
Saved-search notifications through openvibe-sdk/notifications (no hand-rolled Network client) (#38)
Changes
@@@ -120,7 +120,8 @@ unless `SEARCH_SAVED_NOTIFY=1`; they also need `OV_OAUTH_CLIENT_SECRET` (the Net`saved_notify: "off (…)"`, never as a failure. Every `SEARCH_SAVED_NOTIFY_INTERVAL_MS` (15 min) thenotifier takes up to `SEARCH_SAVED_NOTIFY_BATCH` (100) saved searches, longest waiting first, and runseach as its owner for documents indexed (a new revision) after its watermark, `last_run_at` (else--`created_at`). A search with new hits gets one notification through OpenVibe.Network (type++`created_at`). A search with new hits gets one notification through OpenVibe.Network via++[server/network-push.js](server/network-push.js), an adapter over `openvibe-sdk/notifications` (type`SEARCH_SAVED_MATCH`, service `search`, category `service`: how many, the top title, a link to thesearch page with that query); the person is resolved to Network's user id first(`GET /internal/identity/resolve`, then `POST /internal/notifications/push`). The watermark moves to@@@ -8,7 +8,7 @@"port": 4710,"domain": "search.openvibe.network (vhost in deploy/nginx; the service manifest records the search page and query API as public since 2026-09-23)","contracts": "openvibe-contracts v0.129.0",-- "sdk": "openvibe-sdk v0.38.0",++ "sdk": "openvibe-sdk v0.39.0","shared": "openvibe-shared v3.0.0","capabilities": ["search.document.write",@@@ -14,7 +14,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"},@@@ -766,9 +766,9 @@}},"node_modules/openvibe-sdk": {-- "version": "0.38.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",-- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",++ "version": "0.39.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0",++ "integrity": "sha512-unJTjjNZszufkl8GbCu9sMnVa/9qWi2sO7cMz9YZp3jLjkznZRCSEcTMNt18gkABY/eruV4BRzoWoOK5iP8J9g==","license": "MIT","engines": {"node": ">=20"@@@ -26,7 +26,7 @@"dotenv": "^16.4.7","express": "^4.21.2","openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0","pg": "^8.23.0"},@@@ -1,76 +1,45 @@'use strict';/**-- * OpenVibe.Network client for saved-search notifications (server/saved-notifier.js):-- *-- * resolve(subject) GET /internal/identity/resolve?subject_id=usr_… identity.subject.resolve-- * → Network's numeric user id, or null (no such person, or a deleted account)-- * push(subject, notification) resolve, then POST /internal/notifications/push network.notifications.push-- * (network.notification-push-request@1) → { sent: true, skipped } | { sent: false, reason }-- *-- * Client-credentials tokens for audience openvibe.network from the Network OAuth client `search`-- * (config.oauth), one per capability; a 401 drops the cached token so the next call fetches a new one.-- * Any other refusal throws a NetworkError carrying the status only: tokens, the client secret and-- * Network's answer bodies are never logged.++ * Adapter for saved-search notifications (server/saved-notifier.js). The SDK resolves the subject,++ * sends one push through OpenVibe.Network, and manages service tokens. Only the HTTP status leaves++ * this adapter on failure; Network's response, tokens and the client secret stay out of logs.*/--const { serviceAuth } = require('openvibe-contracts');++const { createClient } = require('openvibe-sdk/core');++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const { createNotificationsClient } = require('openvibe-sdk/notifications');class NetworkError extends Error {-- constructor(message, status = null) {-- super(message);++ constructor(status = null) {++ super(status == null ? 'Network request failed' : `Network request failed (${status})`);this.name = 'NetworkError';this.status = status;}}--/**-- * createNetworkPush({ config, fetchImpl?, tokenClient?, timeoutMs? }). `tokenClient` (tests) replaces-- * both token clients.-- */++/** createNetworkPush({ config, fetchImpl?, tokenClient?, timeoutMs? }) → { push }. */function createNetworkPush({ config, fetchImpl = globalThis.fetch, tokenClient = null, timeoutMs = 5000 }) {-- const base = config.networkInternalUrl;-- const tokens = (scope) => tokenClient || serviceAuth.createTokenClient({-- tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,-- audience: 'openvibe.network', scope, fetchImpl, timeoutMs,-- });-- const resolveTokens = tokens('identity.subject.resolve');-- const pushTokens = tokens('network.notifications.push');++ const network = config.networkInternalUrl;++ // Built on first use: a Search without the Network OAuth client (development, most tests) boots fine, and only a++ // push it then attempts fails (the notifier is off unless that client is configured anyway).++ let notifications = null;++ function client() {++ if (notifications) return notifications;++ const tokenProvider = tokenClient || createServiceTokenClient({++ network, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,++ });++ notifications = createNotificationsClient(createClient({ tokenProvider, baseUrls: { network }, fetch: fetchImpl, timeoutMs }));++ return notifications;++ }-- async function call(what, tokenSource, path, body) {-- let res;++ async function push(subject, notification) {try {-- res = await fetchImpl(`${base}${path}`, {-- method: body ? 'POST' : 'GET',-- headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenSource.authHeaders()) },-- body: body ? JSON.stringify(body) : undefined,-- signal: AbortSignal.timeout(timeoutMs),-- });++ return await client().push({ subjectId: subject, ...notification });} catch (err) {-- throw new NetworkError(`${what}: ${err.name === 'TimeoutError' ? `no answer within ${timeoutMs} ms` : err.message}`, err.status || null);++ throw new NetworkError(Number.isInteger(err && err.status) && err.status > 0 ? err.status : null);}-- if (res.status === 401) tokenSource.invalidate?.();-- return res;-- }---- async function resolve(subject) {-- const res = await call('identity resolve', resolveTokens, `/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`);-- if (res.status === 404) return null;-- if (!res.ok) throw new NetworkError(`identity resolve: Network answered ${res.status}`, res.status);-- const body = await res.json().catch(() => null);-- if (!body || body.deleted || body.network_user_id == null) return null;-- return body.network_user_id;-- }---- async function push(subject, notification) {-- const userId = await resolve(subject);-- if (userId == null) return { sent: false, reason: 'unknown_subject' };-- const res = await call('notification push', pushTokens, '/internal/notifications/push', { ...notification, user_id: userId });-- if (!res.ok) throw new NetworkError(`notification push: Network answered ${res.status}`, res.status);-- const body = await res.json().catch(() => ({}));-- // skipped: the person switched the category off; Network made its decision, so it counts as delivered.-- return { sent: true, skipped: Boolean(body && body.skipped) };}-- return { resolve, push };++ return { push };}module.exports = { createNetworkPush, NetworkError };@@@ -1,9 +1,7 @@'use strict';/**-- * The Network client of the saved-search notifier: resolve a usr_ subject, then push one notification-- * (network.notification-push-request@1) with client-credentials tokens for audience openvibe.network;-- * an unknown or deleted person is not pushed to; a 401 drops the cached token; refusals and 5xx throw-- * with the status only, never a token.++ * The saved-search notification adapter: SDK subject resolution and push, with safe errors.++ * Token caching and refresh belong to openvibe-sdk/auth and are tested there.*/const assert = require('assert');const { load } = require('../server/config');@@@ -15,49 +13,49 @@ const config = load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://networkconst SUBJECT = 'usr_01j9zzzzzzzzzzzzzzzzzzzzzz';const NOTE = { type: 'SEARCH_SAVED_MATCH', service: 'search', category: 'service', priority: 'normal', title: 'New results', message: 'One', url: 'https://search.test/?q=x' };--const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) });++const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });function fakeTokens() {-- const tk = { invalidated: 0, async authHeaders() { return { Authorization: 'Bearer tok-SECRET' }; }, invalidate() { tk.invalidated++; } };-- return tk;++ return { async getToken({ audience }) { assert.strictEqual(audience, 'openvibe.network'); return 'tok-SECRET'; } };}function fakeFetch(answers) {const calls = [];const fn = async (url, opts = {}) => {calls.push({ url: String(url), opts });-- const a = answers.shift();-- if (!a) throw new Error(`unexpected call ${url}`);-- return typeof a === 'function' ? a(url, opts) : a;++ const answer = answers.shift();++ if (!answer) throw new Error(`unexpected call ${url}`);++ return typeof answer === 'function' ? answer(url, opts) : answer;};fn.calls = calls;return fn;}t('resolves the subject, then pushes one notification to the numeric user id', async () => {-- const tokens = fakeTokens();-- const fetchImpl = fakeFetch([json(200, { subject: { type: 'user', id: SUBJECT }, network_user_id: 42 }), json(200, { ok: true, notification: { id: 7 } })]);-- const out = await createNetworkPush({ config, fetchImpl, tokenClient: tokens }).push(SUBJECT, NOTE);++ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { ok: true, notification: { id: 7 } })]);++ const out = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE);assert.deepStrictEqual(out, { sent: true, skipped: false });const [resolve, push] = fetchImpl.calls;++ assert.strictEqual(fetchImpl.calls.length, 2);assert.strictEqual(resolve.url, `http://network.test/internal/identity/resolve?subject_id=${SUBJECT}`);assert.strictEqual(resolve.opts.method, 'GET');-- assert.strictEqual(resolve.opts.headers.Authorization, 'Bearer tok-SECRET');++ assert.strictEqual(resolve.opts.headers.get('Authorization'), 'Bearer tok-SECRET');assert.ok(resolve.opts.signal, 'a request timeout');assert.strictEqual(push.url, 'http://network.test/internal/notifications/push');assert.strictEqual(push.opts.method, 'POST');-- assert.strictEqual(push.opts.headers['Content-Type'], 'application/json');++ assert.strictEqual(push.opts.headers.get('Content-Type'), 'application/json');++ assert.strictEqual(push.opts.headers.get('Authorization'), 'Bearer tok-SECRET');assert.ok(push.opts.signal);assert.deepStrictEqual(JSON.parse(push.opts.body), { ...NOTE, user_id: 42 });});t('a category the person switched off (skipped) still counts as delivered', async () => {-- const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { ok: true, skipped: true })]);++ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { skipped: true })]);assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: true, skipped: true });});--t('an unknown subject (404) or a deleted account is not pushed to', async () => {-- let fetchImpl = fakeFetch([json(404, { type: 'identity.subject_not_found' })]);++t('an unknown subject or a deleted account is not pushed to', async () => {++ let fetchImpl = fakeFetch([json(404, { code: 'identity.subject_not_found' })]);assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: false, reason: 'unknown_subject' });assert.strictEqual(fetchImpl.calls.length, 1);fetchImpl = fakeFetch([json(200, { network_user_id: 42, deleted: true })]);@@@ -65,54 +63,52 @@ t('an unknown subject (404) or a deleted account is not pushed to', async () =>assert.strictEqual(fetchImpl.calls.length, 1);});--t('a 401 throws with the status, drops the cached token and never shows the token', async () => {-- const tokens = fakeTokens();-- const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(401, { error: 'invalid token tok-SECRET' })]);-- const err = await createNetworkPush({ config, fetchImpl, tokenClient: tokens }).push(SUBJECT, NOTE).then(() => null, e => e);++t('a refusal throws NetworkError with status but no Network body or credentials', async () => {++ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(403, { detail: 'private response tok-SECRET client-secret-xyz' })]);++ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);assert.ok(err instanceof NetworkError);-- assert.strictEqual(err.status, 401);-- assert.strictEqual(tokens.invalidated, 1);-- assert.ok(!/tok-SECRET|client-secret/.test(err.message), err.message);++ assert.strictEqual(err.status, 403);++ assert.ok(!/private response|tok-SECRET|client-secret-xyz/.test(err.message), err.message);++ assert.strictEqual(fetchImpl.calls.length, 2, 'one push attempt');});--t('a 5xx from resolve throws before any push; a 5xx from push throws', async () => {-- let fetchImpl = fakeFetch([json(503, {})]);-- let err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);-- assert.strictEqual(err.status, 503);++t('a resolve refusal throws NetworkError before the push', async () => {++ const fetchImpl = fakeFetch([json(403, { detail: 'private response' })]);++ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);++ assert.ok(err instanceof NetworkError);++ assert.strictEqual(err.status, 403);assert.strictEqual(fetchImpl.calls.length, 1);-- fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(500, { error: 'boom' })]);-- err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);-- assert.strictEqual(err.status, 500);-- assert.match(err.message, /notification push: Network answered 500/);});--t('a timeout or a dropped connection throws a NetworkError', async () => {-- const fetchImpl = fakeFetch([async () => { const e = new Error('aborted'); e.name = 'TimeoutError'; throw e; }]);-- const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens(), timeoutMs: 50 }).push(SUBJECT, NOTE).then(() => null, e => e);-- assert.ok(err instanceof NetworkError);-- assert.match(err.message, /no answer within 50 ms/);++t('a timeout or a dropped connection throws NetworkError without status', async () => {++ for (const failure of ['timeout', 'dropped']) {++ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), (url, opts) => failure === 'timeout'++ ? new Promise((resolve, reject) => opts.signal.addEventListener('abort', () => reject(new Error('timed out tok-SECRET')), { once: true }))++ : Promise.reject(new Error('dropped tok-SECRET'))]);++ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens(), timeoutMs: 20 }).push(SUBJECT, NOTE).then(() => null, e => e);++ assert.ok(err instanceof NetworkError);++ assert.strictEqual(err.status, null);++ assert.ok(!/tok-SECRET/.test(err.message), err.message);++ assert.strictEqual(fetchImpl.calls.length, 2, 'one push attempt');++ }});--t('without an injected client: client-credentials tokens for openvibe.network, one per capability', async () => {-- const forms = [];++t('without an injected provider, the SDK token uses the internal Network URL', async () => {++ const calls = [];const fetchImpl = async (url, opts = {}) => {-- if (String(url).endsWith('/oauth/token')) {-- const form = new URLSearchParams(String(opts.body));-- forms.push(Object.fromEntries(form));-- return json(200, { access_token: `tok-${form.get('scope')}`, expires_in: 300 });-- }-- if (String(url).includes('/identity/resolve')) {-- assert.strictEqual(opts.headers.Authorization, 'Bearer tok-identity.subject.resolve');-- return json(200, { network_user_id: 42 });-- }-- assert.strictEqual(opts.headers.Authorization, 'Bearer tok-network.notifications.push');-- return json(200, { ok: true });++ calls.push({ url: String(url), opts });++ if (String(url).endsWith('/oauth/token')) return json(200, { access_token: 'service-token', expires_in: 300 });++ if (String(url).includes('/identity/resolve')) return json(200, { network_user_id: 42 });++ return json(200, { skipped: false });};assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl }).push(SUBJECT, NOTE), { sent: true, skipped: false });-- assert.deepStrictEqual(forms.map(f => [f.client_id, f.audience, f.scope]), [-- ['search', 'openvibe.network', 'identity.subject.resolve'],-- ['search', 'openvibe.network', 'network.notifications.push'],-- ]);++ assert.strictEqual(calls[0].url, 'http://network.test/oauth/token');++ const form = new URLSearchParams(calls[0].opts.body);++ assert.strictEqual(form.get('client_id'), config.oauth.clientId);++ assert.strictEqual(form.get('client_secret'), config.oauth.clientSecret);++ assert.strictEqual(form.get('audience'), 'openvibe.network');++ assert.strictEqual(calls[1].opts.headers.get('Authorization'), 'Bearer service-token');++ assert.strictEqual(calls[2].opts.headers.get('Authorization'), 'Bearer service-token');});t.run();
@@ -120,7 +120,8 @@ unless `SEARCH_SAVED_NOTIFY=1`; they also need `OV_OAUTH_CLIENT_SECRET` (the Net
@@ -120,7 +120,8 @@ unless `SEARCH_SAVED_NOTIFY=1`; they also need `OV_OAUTH_CLIENT_SECRET` (the Net
`saved_notify: "off (…)"`, never as a failure. Every `SEARCH_SAVED_NOTIFY_INTERVAL_MS` (15 min) the
`saved_notify: "off (…)"`, never as a failure. Every `SEARCH_SAVED_NOTIFY_INTERVAL_MS` (15 min) the
notifier takes up to `SEARCH_SAVED_NOTIFY_BATCH` (100) saved searches, longest waiting first, and runs
notifier takes up to `SEARCH_SAVED_NOTIFY_BATCH` (100) saved searches, longest waiting first, and runs
each as its owner for documents indexed (a new revision) after its watermark, `last_run_at` (else
each as its owner for documents indexed (a new revision) after its watermark, `last_run_at` (else
-`created_at`). A search with new hits gets one notification through OpenVibe.Network (type
+`created_at`). A search with new hits gets one notification through OpenVibe.Network via
+[server/network-push.js](server/network-push.js), an adapter over `openvibe-sdk/notifications` (type
`SEARCH_SAVED_MATCH`, service `search`, category `service`: how many, the top title, a link to the
`SEARCH_SAVED_MATCH`, service `search`, category `service`: how many, the top title, a link to the
search page with that query); the person is resolved to Network's user id first
search page with that query); the person is resolved to Network's user id first
(`GET /internal/identity/resolve`, then `POST /internal/notifications/push`). The watermark moves to
(`GET /internal/identity/resolve`, then `POST /internal/notifications/push`). The watermark moves to
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"port": 4710,
"port": 4710,
"domain": "search.openvibe.network (vhost in deploy/nginx; the service manifest records the search page and query API as public since 2026-09-23)",
"domain": "search.openvibe.network (vhost in deploy/nginx; the service manifest records the search page and query API as public since 2026-09-23)",
"contracts": "openvibe-contracts v0.129.0",
"contracts": "openvibe-contracts v0.129.0",
- "sdk": "openvibe-sdk v0.38.0",
+ "sdk": "openvibe-sdk v0.39.0",
"shared": "openvibe-shared v3.0.0",
"shared": "openvibe-shared v3.0.0",
"capabilities": [
"capabilities": [
"search.document.write",
"search.document.write",
@@ -14,7 +14,7 @@
@@ -14,7 +14,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -766,9 +766,9 @@
@@ -766,9 +766,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"node_modules/openvibe-sdk": {
- "version": "0.38.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
- "integrity": "sha512-/Z+1kB5l4Hff0E1O1Zo+DcXaZX93wrmISOPP75sB7NqVUZVeKuJgjYu6vBwG8ufJjlk0vD3ZJ0ntBLI/3IKdpg==",
+ "version": "0.39.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0",
+ "integrity": "sha512-unJTjjNZszufkl8GbCu9sMnVa/9qWi2sO7cMz9YZp3jLjkznZRCSEcTMNt18gkABY/eruV4BRzoWoOK5iP8J9g==",
"license": "MIT",
"license": "MIT",
"engines": {
"engines": {
"node": ">=20"
"node": ">=20"
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.129.0",
- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.38.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.39.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v3.0.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1,76 +1,45 @@
@@ -1,76 +1,45 @@
'use strict';
'use strict';
/**
/**
- * OpenVibe.Network client for saved-search notifications (server/saved-notifier.js):
- *
- * resolve(subject) GET /internal/identity/resolve?subject_id=usr_… identity.subject.resolve
- * → Network's numeric user id, or null (no such person, or a deleted account)
- * push(subject, notification) resolve, then POST /internal/notifications/push network.notifications.push
- * (network.notification-push-request@1) → { sent: true, skipped } | { sent: false, reason }
- *
- * Client-credentials tokens for audience openvibe.network from the Network OAuth client `search`
- * (config.oauth), one per capability; a 401 drops the cached token so the next call fetches a new one.
- * Any other refusal throws a NetworkError carrying the status only: tokens, the client secret and
- * Network's answer bodies are never logged.
+ * Adapter for saved-search notifications (server/saved-notifier.js). The SDK resolves the subject,
+ * sends one push through OpenVibe.Network, and manages service tokens. Only the HTTP status leaves
+ * this adapter on failure; Network's response, tokens and the client secret stay out of logs.
*/
*/
-const { serviceAuth } = require('openvibe-contracts');
+const { createClient } = require('openvibe-sdk/core');
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const { createNotificationsClient } = require('openvibe-sdk/notifications');
class NetworkError extends Error {
class NetworkError extends Error {
- constructor(message, status = null) {
- super(message);
+ constructor(status = null) {
+ super(status == null ? 'Network request failed' : `Network request failed (${status})`);
this.name = 'NetworkError';
this.name = 'NetworkError';
this.status = status;
this.status = status;
}
}
}
}
-/**
- * createNetworkPush({ config, fetchImpl?, tokenClient?, timeoutMs? }). `tokenClient` (tests) replaces
- * both token clients.
- */
+/** createNetworkPush({ config, fetchImpl?, tokenClient?, timeoutMs? }) → { push }. */
function createNetworkPush({ config, fetchImpl = globalThis.fetch, tokenClient = null, timeoutMs = 5000 }) {
function createNetworkPush({ config, fetchImpl = globalThis.fetch, tokenClient = null, timeoutMs = 5000 }) {
- const base = config.networkInternalUrl;
- const tokens = (scope) => tokenClient || serviceAuth.createTokenClient({
- tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
- audience: 'openvibe.network', scope, fetchImpl, timeoutMs,
- });
- const resolveTokens = tokens('identity.subject.resolve');
- const pushTokens = tokens('network.notifications.push');
+ const network = config.networkInternalUrl;
+ // Built on first use: a Search without the Network OAuth client (development, most tests) boots fine, and only a
+ // push it then attempts fails (the notifier is off unless that client is configured anyway).
+ let notifications = null;
+ function client() {
+ if (notifications) return notifications;
+ const tokenProvider = tokenClient || createServiceTokenClient({
+ network, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl,
+ });
+ notifications = createNotificationsClient(createClient({ tokenProvider, baseUrls: { network }, fetch: fetchImpl, timeoutMs }));
+ return notifications;
+ }
- async function call(what, tokenSource, path, body) {
- let res;
+ async function push(subject, notification) {
try {
try {
- res = await fetchImpl(`${base}${path}`, {
- method: body ? 'POST' : 'GET',
- headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokenSource.authHeaders()) },
- body: body ? JSON.stringify(body) : undefined,
- signal: AbortSignal.timeout(timeoutMs),
- });
+ return await client().push({ subjectId: subject, ...notification });
} catch (err) {
} catch (err) {
- throw new NetworkError(`${what}: ${err.name === 'TimeoutError' ? `no answer within ${timeoutMs} ms` : err.message}`, err.status || null);
+ throw new NetworkError(Number.isInteger(err && err.status) && err.status > 0 ? err.status : null);
}
}
- if (res.status === 401) tokenSource.invalidate?.();
- return res;
- }
-
- async function resolve(subject) {
- const res = await call('identity resolve', resolveTokens, `/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`);
- if (res.status === 404) return null;
- if (!res.ok) throw new NetworkError(`identity resolve: Network answered ${res.status}`, res.status);
- const body = await res.json().catch(() => null);
- if (!body || body.deleted || body.network_user_id == null) return null;
- return body.network_user_id;
- }
-
- async function push(subject, notification) {
- const userId = await resolve(subject);
- if (userId == null) return { sent: false, reason: 'unknown_subject' };
- const res = await call('notification push', pushTokens, '/internal/notifications/push', { ...notification, user_id: userId });
- if (!res.ok) throw new NetworkError(`notification push: Network answered ${res.status}`, res.status);
- const body = await res.json().catch(() => ({}));
- // skipped: the person switched the category off; Network made its decision, so it counts as delivered.
- return { sent: true, skipped: Boolean(body && body.skipped) };
}
}
- return { resolve, push };
+ return { push };
}
}
module.exports = { createNetworkPush, NetworkError };
module.exports = { createNetworkPush, NetworkError };
@@ -1,9 +1,7 @@
@@ -1,9 +1,7 @@
'use strict';
'use strict';
/**
/**
- * The Network client of the saved-search notifier: resolve a usr_ subject, then push one notification
- * (network.notification-push-request@1) with client-credentials tokens for audience openvibe.network;
- * an unknown or deleted person is not pushed to; a 401 drops the cached token; refusals and 5xx throw
- * with the status only, never a token.
+ * The saved-search notification adapter: SDK subject resolution and push, with safe errors.
+ * Token caching and refresh belong to openvibe-sdk/auth and are tested there.
*/
*/
const assert = require('assert');
const assert = require('assert');
const { load } = require('../server/config');
const { load } = require('../server/config');
@@ -15,49 +13,49 @@ const config = load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://network
@@ -15,49 +13,49 @@ const config = load({ NODE_ENV: 'test', OV_NETWORK_INTERNAL_URL: 'http://network
const SUBJECT = 'usr_01j9zzzzzzzzzzzzzzzzzzzzzz';
const SUBJECT = 'usr_01j9zzzzzzzzzzzzzzzzzzzzzz';
const NOTE = { type: 'SEARCH_SAVED_MATCH', service: 'search', category: 'service', priority: 'normal', title: 'New results', message: 'One', url: 'https://search.test/?q=x' };
const NOTE = { type: 'SEARCH_SAVED_MATCH', service: 'search', category: 'service', priority: 'normal', title: 'New results', message: 'One', url: 'https://search.test/?q=x' };
-const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) });
+const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
function fakeTokens() {
function fakeTokens() {
- const tk = { invalidated: 0, async authHeaders() { return { Authorization: 'Bearer tok-SECRET' }; }, invalidate() { tk.invalidated++; } };
- return tk;
+ return { async getToken({ audience }) { assert.strictEqual(audience, 'openvibe.network'); return 'tok-SECRET'; } };
}
}
function fakeFetch(answers) {
function fakeFetch(answers) {
const calls = [];
const calls = [];
const fn = async (url, opts = {}) => {
const fn = async (url, opts = {}) => {
calls.push({ url: String(url), opts });
calls.push({ url: String(url), opts });
- const a = answers.shift();
- if (!a) throw new Error(`unexpected call ${url}`);
- return typeof a === 'function' ? a(url, opts) : a;
+ const answer = answers.shift();
+ if (!answer) throw new Error(`unexpected call ${url}`);
+ return typeof answer === 'function' ? answer(url, opts) : answer;
};
};
fn.calls = calls;
fn.calls = calls;
return fn;
return fn;
}
}
t('resolves the subject, then pushes one notification to the numeric user id', async () => {
t('resolves the subject, then pushes one notification to the numeric user id', async () => {
- const tokens = fakeTokens();
- const fetchImpl = fakeFetch([json(200, { subject: { type: 'user', id: SUBJECT }, network_user_id: 42 }), json(200, { ok: true, notification: { id: 7 } })]);
- const out = await createNetworkPush({ config, fetchImpl, tokenClient: tokens }).push(SUBJECT, NOTE);
+ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { ok: true, notification: { id: 7 } })]);
+ const out = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE);
assert.deepStrictEqual(out, { sent: true, skipped: false });
assert.deepStrictEqual(out, { sent: true, skipped: false });
const [resolve, push] = fetchImpl.calls;
const [resolve, push] = fetchImpl.calls;
+ assert.strictEqual(fetchImpl.calls.length, 2);
assert.strictEqual(resolve.url, `http://network.test/internal/identity/resolve?subject_id=${SUBJECT}`);
assert.strictEqual(resolve.url, `http://network.test/internal/identity/resolve?subject_id=${SUBJECT}`);
assert.strictEqual(resolve.opts.method, 'GET');
assert.strictEqual(resolve.opts.method, 'GET');
- assert.strictEqual(resolve.opts.headers.Authorization, 'Bearer tok-SECRET');
+ assert.strictEqual(resolve.opts.headers.get('Authorization'), 'Bearer tok-SECRET');
assert.ok(resolve.opts.signal, 'a request timeout');
assert.ok(resolve.opts.signal, 'a request timeout');
assert.strictEqual(push.url, 'http://network.test/internal/notifications/push');
assert.strictEqual(push.url, 'http://network.test/internal/notifications/push');
assert.strictEqual(push.opts.method, 'POST');
assert.strictEqual(push.opts.method, 'POST');
- assert.strictEqual(push.opts.headers['Content-Type'], 'application/json');
+ assert.strictEqual(push.opts.headers.get('Content-Type'), 'application/json');
+ assert.strictEqual(push.opts.headers.get('Authorization'), 'Bearer tok-SECRET');
assert.ok(push.opts.signal);
assert.ok(push.opts.signal);
assert.deepStrictEqual(JSON.parse(push.opts.body), { ...NOTE, user_id: 42 });
assert.deepStrictEqual(JSON.parse(push.opts.body), { ...NOTE, user_id: 42 });
});
});
t('a category the person switched off (skipped) still counts as delivered', async () => {
t('a category the person switched off (skipped) still counts as delivered', async () => {
- const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { ok: true, skipped: true })]);
+ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(200, { skipped: true })]);
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: true, skipped: true });
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: true, skipped: true });
});
});
-t('an unknown subject (404) or a deleted account is not pushed to', async () => {
- let fetchImpl = fakeFetch([json(404, { type: 'identity.subject_not_found' })]);
+t('an unknown subject or a deleted account is not pushed to', async () => {
+ let fetchImpl = fakeFetch([json(404, { code: 'identity.subject_not_found' })]);
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: false, reason: 'unknown_subject' });
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE), { sent: false, reason: 'unknown_subject' });
assert.strictEqual(fetchImpl.calls.length, 1);
assert.strictEqual(fetchImpl.calls.length, 1);
fetchImpl = fakeFetch([json(200, { network_user_id: 42, deleted: true })]);
fetchImpl = fakeFetch([json(200, { network_user_id: 42, deleted: true })]);
@@ -65,54 +63,52 @@ t('an unknown subject (404) or a deleted account is not pushed to', async () =>
@@ -65,54 +63,52 @@ t('an unknown subject (404) or a deleted account is not pushed to', async () =>
assert.strictEqual(fetchImpl.calls.length, 1);
assert.strictEqual(fetchImpl.calls.length, 1);
});
});
-t('a 401 throws with the status, drops the cached token and never shows the token', async () => {
- const tokens = fakeTokens();
- const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(401, { error: 'invalid token tok-SECRET' })]);
- const err = await createNetworkPush({ config, fetchImpl, tokenClient: tokens }).push(SUBJECT, NOTE).then(() => null, e => e);
+t('a refusal throws NetworkError with status but no Network body or credentials', async () => {
+ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(403, { detail: 'private response tok-SECRET client-secret-xyz' })]);
+ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);
assert.ok(err instanceof NetworkError);
assert.ok(err instanceof NetworkError);
- assert.strictEqual(err.status, 401);
- assert.strictEqual(tokens.invalidated, 1);
- assert.ok(!/tok-SECRET|client-secret/.test(err.message), err.message);
+ assert.strictEqual(err.status, 403);
+ assert.ok(!/private response|tok-SECRET|client-secret-xyz/.test(err.message), err.message);
+ assert.strictEqual(fetchImpl.calls.length, 2, 'one push attempt');
});
});
-t('a 5xx from resolve throws before any push; a 5xx from push throws', async () => {
- let fetchImpl = fakeFetch([json(503, {})]);
- let err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);
- assert.strictEqual(err.status, 503);
+t('a resolve refusal throws NetworkError before the push', async () => {
+ const fetchImpl = fakeFetch([json(403, { detail: 'private response' })]);
+ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);
+ assert.ok(err instanceof NetworkError);
+ assert.strictEqual(err.status, 403);
assert.strictEqual(fetchImpl.calls.length, 1);
assert.strictEqual(fetchImpl.calls.length, 1);
- fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), json(500, { error: 'boom' })]);
- err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens() }).push(SUBJECT, NOTE).then(() => null, e => e);
- assert.strictEqual(err.status, 500);
- assert.match(err.message, /notification push: Network answered 500/);
});
});
-t('a timeout or a dropped connection throws a NetworkError', async () => {
- const fetchImpl = fakeFetch([async () => { const e = new Error('aborted'); e.name = 'TimeoutError'; throw e; }]);
- const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens(), timeoutMs: 50 }).push(SUBJECT, NOTE).then(() => null, e => e);
- assert.ok(err instanceof NetworkError);
- assert.match(err.message, /no answer within 50 ms/);
+t('a timeout or a dropped connection throws NetworkError without status', async () => {
+ for (const failure of ['timeout', 'dropped']) {
+ const fetchImpl = fakeFetch([json(200, { network_user_id: 42 }), (url, opts) => failure === 'timeout'
+ ? new Promise((resolve, reject) => opts.signal.addEventListener('abort', () => reject(new Error('timed out tok-SECRET')), { once: true }))
+ : Promise.reject(new Error('dropped tok-SECRET'))]);
+ const err = await createNetworkPush({ config, fetchImpl, tokenClient: fakeTokens(), timeoutMs: 20 }).push(SUBJECT, NOTE).then(() => null, e => e);
+ assert.ok(err instanceof NetworkError);
+ assert.strictEqual(err.status, null);
+ assert.ok(!/tok-SECRET/.test(err.message), err.message);
+ assert.strictEqual(fetchImpl.calls.length, 2, 'one push attempt');
+ }
});
});
-t('without an injected client: client-credentials tokens for openvibe.network, one per capability', async () => {
- const forms = [];
+t('without an injected provider, the SDK token uses the internal Network URL', async () => {
+ const calls = [];
const fetchImpl = async (url, opts = {}) => {
const fetchImpl = async (url, opts = {}) => {
- if (String(url).endsWith('/oauth/token')) {
- const form = new URLSearchParams(String(opts.body));
- forms.push(Object.fromEntries(form));
- return json(200, { access_token: `tok-${form.get('scope')}`, expires_in: 300 });
- }
- if (String(url).includes('/identity/resolve')) {
- assert.strictEqual(opts.headers.Authorization, 'Bearer tok-identity.subject.resolve');
- return json(200, { network_user_id: 42 });
- }
- assert.strictEqual(opts.headers.Authorization, 'Bearer tok-network.notifications.push');
- return json(200, { ok: true });
+ calls.push({ url: String(url), opts });
+ if (String(url).endsWith('/oauth/token')) return json(200, { access_token: 'service-token', expires_in: 300 });
+ if (String(url).includes('/identity/resolve')) return json(200, { network_user_id: 42 });
+ return json(200, { skipped: false });
};
};
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl }).push(SUBJECT, NOTE), { sent: true, skipped: false });
assert.deepStrictEqual(await createNetworkPush({ config, fetchImpl }).push(SUBJECT, NOTE), { sent: true, skipped: false });
- assert.deepStrictEqual(forms.map(f => [f.client_id, f.audience, f.scope]), [
- ['search', 'openvibe.network', 'identity.subject.resolve'],
- ['search', 'openvibe.network', 'network.notifications.push'],
- ]);
+ assert.strictEqual(calls[0].url, 'http://network.test/oauth/token');
+ const form = new URLSearchParams(calls[0].opts.body);
+ assert.strictEqual(form.get('client_id'), config.oauth.clientId);
+ assert.strictEqual(form.get('client_secret'), config.oauth.clientSecret);
+ assert.strictEqual(form.get('audience'), 'openvibe.network');
+ assert.strictEqual(calls[1].opts.headers.get('Authorization'), 'Bearer service-token');
+ assert.strictEqual(calls[2].opts.headers.get('Authorization'), 'Bearer service-token');
});
});
t.run();
t.run();