openvibers/openvibe.search Commits

Saved-search notifications through openvibe-sdk/notifications (no hand-rolled Network client) (#38)

OP OpenVibers committed 10 Oct 2026 · 0f03b68f90 · 6 files, +84 −118

Plan T1, delete hand-rolled copies: `server/network-push.js`, Search's own Network client for saved-search notifications (identity resolve, notifications push and its own client-credentials token clients), becomes a thin adapter over the shared `openvibe-sdk/notifications` (SDK 0.39.0, which Watch's notification action uses too).

- The API is unchanged for `saved-notifier.js`: `push(subject, notification)` gives `{ sent, skipped }` or `{ sent: false, reason: 'unknown_subject' }`, and any failure is a `NetworkError` that carries only the HTTP status (never Network's body, a token or the secret).
- The SDK client is built on first use, so a Search without the Network OAuth client (development, most tests) still boots; only an attempted push fails, and the notifier is off without that client anyway.
- Token caching and refresh are `openvibe-sdk/auth`'s (tested there), and the push is attempted once: the notifier already retries on its next tick.

`test/network-push.test.js` (7 cases): the request (path, user id from the resolve, bearer), the three results, an unknown subject, failures as status-only NetworkErrors, and the internal Network URL for tokens. All 13 test files are green.

Changes

ddiff --git a/README.md b/README.md
iindex a7aa16d..aac6e74 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
--`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 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
ddiff --git a/STATUS.json b/STATUS.json
iindex 174f8de..649d935 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/package-lock.json b/package-lock.json
iindex 391dec2..85708e5 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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"
ddiff --git a/package.json b/package.json
iindex 64b516a..ce5c240 100644
---- a/package.json
++++ b/package.json
@@@ -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"
},
ddiff --git a/server/network-push.js b/server/network-push.js
iindex 58463eb..8ebe3d0 100644
---- a/server/network-push.js
++++ b/server/network-push.js
@@@ -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 };
ddiff --git a/test/network-push.test.js b/test/network-push.test.js
iindex fe1cfa4..aa0aa9e 100644
---- a/test/network-push.test.js
++++ b/test/network-push.test.js
@@@ -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://network
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 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();