openvibers/openvibe.shared Commits

v1.22.0: the notification bell in realtime, off by default (roadmap WS-E task 3, WS-F task 1; openvibe-contracts 0.61.0, ADR-005 amendment 2). New browser file notification-live.js (OVNotificationLive, 2.4 KB brotli, budget 3 KB): a realtime ticket from Network (POST /api/v1/realtime/ticket, Bearer or the openvibe.network cookie) for every (re)connect, then Events' /realtime/stream?topics=network.notification.*&ticket= without credentials, resuming with last_event_id; only the ticket's person's events count; gaps reported; errors close and back off from 2 s with jitter, ten in a row stop it until online; hidden 5 min closes, shown resumes; CSP refusal, 401/403 and 503 stop it. notification-ui.js (budget 13 KB) loads it from its own directory with init({ realtime: true }) (navbar: notificationsRealtime: true): an event re-reads the count once per burst (and again after a poll in flight), a gap also reloads open lists, the 15 s poll runs every 2 min while the stream is open; setToken() restarts it; realtimeState(). files.js and exports list it; browser-harness exports openPage. Tests: linkedom (subscribe, resume from the cursor, gap -> refetch, CSP-blocked -> polling, guessed topic yields nothing) and headless Chrome with a real EventSource (drop and resume with nothing lost or repeated, retention gap, another person's event ignored)

OP OpenVibers committed 26 Sept 2026 · 3282427ca5 · 11 files, +738 −8


Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 9d55e35..1a6e14e 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,22 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 1.22.0 — 2026-09-26
++
++**The notification bell in realtime** (roadmap WS-E task 3, WS-F task 1; openvibe-contracts 0.61.0, ADR-005 amendment 2). It is off by default. A site turns it on with `OpenVibeNavbar.init({ notificationsRealtime: true })`, or with `OpenVibeNotifications.init({ realtime: true })`.
++- **New browser file `notification-live.js`** (`OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState })`), in `files.js`, 2.4 KB brotli (budget 3 KB). For every (re)connect it asks Network for a realtime ticket (`POST /api/v1/realtime/ticket`), then opens Events' `/realtime/stream?topics=network.notification.*&ticket=…` without credentials, resuming with `last_event_id`.
++ - Only events whose subject is the ticket's person count; another person's, other types and replays are ignored.
++ - `event: gap` is reported.
++ - Errors close the stream (a ticket opens one stream) and back off from 2 s, with jitter. After ten failures in a row it stops, until the browser is `online` again.
++ - Hidden 5 minutes, it closes; shown, it resumes.
++ - A Content-Security-Policy refusal, 401/403 or 503 (`REALTIME_TICKETS=off` on Network) stop it.
++- **`notification-ui.js`** loads it from its own directory when realtime is on. An event re-reads the count and toasts new items, once per burst, and again after a poll already in flight. A gap also reloads the open lists. The 15 s poll runs only every 2 min while the stream is open. `setToken()` restarts the feed for the new person, and `realtimeState()` reports it. notification-ui.js gets a 13 KB brotli budget (12.7 KB).
++- **navbar.js** passes `notificationsRealtime` through.
++
++`test/notification-live.test.js` covers the above in a linkedom page: subscribe, resume from the cursor with a fresh ticket, a gap re-reading the count, CSP-blocked falling back to 15 s polling, a guessed `user:<other>` topic yielding nothing, and the ticket never being kept.
++
++`test/notification-live-chrome.test.js` runs it in headless Chrome, with a real EventSource, against a stand-in for the ticket route and Events' stream. A dropped connection resumes from the cursor with a fresh ticket, with nothing lost or repeated; a retention gap is reported; another person's event is ignored. It is skipped without Chrome. `browser-harness` now exports `openPage` for it.
++
## 1.21.0 — 2026-09-26
**Browser harness: an unreachable host** (ADR-024, roadmap WS-E task 1).
ddiff --git a/README.md b/README.md
iindex ebd26ff..2ea05d5 100644
---- a/README.md
++++ b/README.md
@@@ -23,7 +23,7 @@ were reconciled first.
| Kind | Files |
|---|---|
--| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
++| Browser scripts, served at `/shared/<file>` (listed in `files.js`) | `navbar.js`, `nav-icons.js`, `theme-loader.js`, `footer.js`, `notification-ui.js`, `notification-live.js`, `account-switcher.js`, `user-card.js`, `ov-mark.js`, `ov-icons.js`, `history.js`, `sso-client.js`, `panels.js`, `ui.js`, `island.js`, `tooltip.js`, `release-watch.js`, `release-update.js`, `openvibe-sw.js` |
| Node modules (`require('openvibe-shared/<name>')`) | `index` (`.`), `analytics` (+ `analytics/{privacy,tracker,retention,schema,event,prune-cli}`), `app-icon`, `auth-client`, `brand`, `builtin-themes`, `frame` (the OpenVibe Frame on the server; `chrome-ssr` is a deprecated alias), `legal`, `middleware`, `notifications`, `seo`, `theme-sync`, `url-resolver`, `files`, `egress` (SSRF-safe addresses and connect-time DNS for outbound fetches of user-chosen hosts), `trace` (the request's W3C trace on outbound calls inside the network), `release`, `release-compat` (tests), `metrics`, `ready`, `config` (the configuration model: revisioned, validated, classified settings with last-known-good and `/api/admin/config`), `perf-budget` (size budgets for a page's first load, measured from the running server: HTML, same-origin scripts and stylesheets, raw and brotli; for `npm test`), `browser-harness` (real-Chrome checks of a running site: status, console errors, overflow, duplicate scripts, no-JS text, canonical, JSON-LD against visible text, axe-core, repeated-navigation growth and idle work; Node 22, Chrome); `footer`, `shipped` (the shared "shipped X ago" pill, recent list and `/updates` log, from the network changelog) and `icons` (= `ov-icons.js`) work on both sides, and `release-update` gives Node its pure `plan()` |
| Schemas | `docs/schemas/analytics-event.v1.json` (`analytics/event.v1`, exported as `openvibe-shared/analytics/event.v1.json`) |
| Generators | `scripts/build-nav-icons.py` (Font Awesome glyphs → `nav-icons.js`, `ov-icons.js`), `scripts/build-navbar-icons.js` (navbar.js's built-in glyphs), `scripts/build-theme-loader.js`, `scripts/build-app-icons.js` |
@@@ -322,6 +322,17 @@ can't load, those icons fall back to `<i class="fa-solid …">`. Two options cha
- `OpenVibeNavbar.init({ iconsUrl })` points navbar.js at another URL.
- Loading `nav-icons.js` *before* navbar.js puts every glyph in the first frame.
++**The notification bell in realtime (1.22.0, off by default).** `OpenVibeNavbar.init({ notificationsRealtime: true })`, or `OpenVibeNotifications.init({ realtime: true })` where a site mounts the bell itself, makes the bell hear the person's new notifications as they happen. It uses Network's `network.notification.created` over OpenVibe.Events' realtime stream (ADR-005 amendment 2).
++- **Loading.** notification-ui.js then loads `notification-live.js` from its own directory, so serve it beside notification-ui.js (it is in `files.js`).
++- **Each (re)connect.** It asks Network for a two-minute ticket (`POST https://openvibe.network/api/v1/realtime/ticket`) with the page's Bearer token, or with the session cookie on openvibe.network itself. It then opens `https://events.openvibe.network/realtime/stream?topics=network.notification.*&ticket=…` without credentials, resuming with `last_event_id`.
++- **What it reacts to.** On an event whose subject is the person, it re-reads the unread count and toasts what is new: one request per burst. On `event: gap` it also reloads the open lists.
++- **Polling stays:** every 15 s without a stream, and every 2 min while the stream is open, as a safety net for reads made on other sites.
++- **Retries and giving up.**
++ - Errors back off from 2 s, with jitter. After ten failures in a row the bell polls only, until the browser comes back `online`.
++ - A tab hidden 5 minutes closes the stream and resumes it when shown.
++ - A Content-Security-Policy refusal, a signed-out 401/403, or Network's `REALTIME_TICKETS=off` (503) stop the stream, and the bell polls.
++- **The site's CSP** needs `connect-src https://events.openvibe.network` (and `https://openvibe.network` for the ticket, which the bell already calls). `OpenVibeNotifications.realtimeState()` reports the feed's state.
++
## Development
```bash
ddiff --git a/browser-harness.js b/browser-harness.js
iindex 57f6bb4..4a76881 100644
---- a/browser-harness.js
++++ b/browser-harness.js
@@@ -863,7 +863,7 @@ async function run(opts = {}) {
}
module.exports = {
-- run, launch, format, summarize, findChrome, axeSource, checkUnreachable,
++ run, launch, openPage, format, summarize, findChrome, axeSource, checkUnreachable,
// pure helpers, exported for tests and for the Host CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
ddiff --git a/files.js b/files.js
iindex e5d3296..635d075 100644
---- a/files.js
++++ b/files.js
@@@ -19,6 +19,7 @@ const BROWSER = Object.freeze([
'footer.js', // shared footer (also required on the server by frame.js)
'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'notification-ui.js', // bell + notification panel
++ 'notification-live.js', // the bell's realtime feed over OpenVibe.Events; notification-ui loads it on demand (realtime on)
'account-switcher.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
ddiff --git a/navbar.js b/navbar.js
iindex c8d088a..7de301b 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -806,7 +806,7 @@
const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
const go = () => {
const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
-- try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network', realtime: _config.notificationsRealtime === true }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
};
if (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
ddiff --git a/notification-live.js b/notification-live.js
nnew file mode 100644
iindex 0000000..fdb708a
---- /dev/null
++++ b/notification-live.js
@@@ -0,0 +1,118 @@
++/*
++ * openvibe-shared/notification-live.js — the notification badge's realtime feed (roadmap WS-E task 3, WS-F task 1;
++ * ADR-005 amendment 2). notification-ui.js loads it on demand when a site turns it on (navbar notificationsRealtime).
++ * For every (re)connect it asks Network for a realtime ticket (POST <api>/api/v1/realtime/ticket: Bearer token, or the
++ * cookie on openvibe.network itself) and opens Events' stream with it, without credentials:
++ * <stream_url>?topics=network.notification.*&ticket=<ticket>[&last_event_id=<seq>]
++ * Events streams only the person's own network.notification.created events (subject visibility); one whose subject is
++ * not the ticket's is ignored all the same. Each calls onNotification(payload); `event: gap` calls onGap.
++ * A ticket opens one stream: errors close it and reconnect with a fresh ticket from the cursor, backing off 2 s to 15 min
++ * with jitter. Stops (the caller's polling is all there is) after 10 failures in a row, on a Content-Security-Policy
++ * refusal, when signed out (401/403) or when Network has realtime off (404/503). Hidden 5 min: closed; shown: resumed.
++ * OVNotificationLive.create({ ticketUrl, token, credentials, onNotification, onGap, onState }) → { start, stop, restart, state }
++ */
++(function (root) {
++ 'use strict';
++ if (typeof document === 'undefined' || root.OVNotificationLive) return;
++ const TYPE_RE = /^network\.notification\./;
++ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const MAX_FAILURES = 10; // 2 s, 4 s, … 512 s: about 9 to 17 minutes of retries before polling only
++
++ function create(o) {
++ const rt = { state: 'off', failures: 0, lastSeq: null, subject: null, origin: null, events: 0, ignored: 0, gaps: 0, tickets: 0 };
++ let es = null; let stopped = true; let gen = 0;
++ const t = {}; // timers: retry, hide
++ const later = (k, f, ms) => { root.clearTimeout(t[k]); t[k] = root.setTimeout(() => { t[k] = null; f(); }, ms); };
++ const call = (f, ...a) => { try { if (typeof f === 'function') f(...a); } catch { /* the page's handler must not break the feed */ } };
++ function set(s) { if (rt.state !== s) { rt.state = s; call(o.onState, s); } }
++ function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
++ function halt(s) { close(); root.clearTimeout(t.retry); t.retry = null; set(s); }
++
++ async function open() {
++ if (stopped || es || rt.state === 'ticket') return;
++ if (document.hidden) { set('hidden'); return; }
++ const g = gen;
++ set('ticket');
++ let tk;
++ try {
++ const tok = typeof o.token === 'function' ? o.token() : o.token;
++ const r = await root.fetch(o.ticketUrl, { method: 'POST', credentials: o.credentials || 'omit', cache: 'no-store', headers: tok ? { Authorization: `Bearer ${tok}` } : {} });
++ if (g !== gen) return;
++ if (r.status === 401 || r.status === 403) return halt('signed-out');
++ if (r.status === 404 || r.status === 501 || r.status === 503) return halt('unavailable');
++ if (!r.ok) throw new Error('ticket');
++ tk = await r.json();
++ if (!tk || typeof tk.ticket !== 'string' || !SUBJECT_RE.test(String(tk.subject)) || !Array.isArray(tk.topics)) throw new Error('ticket');
++ } catch {
++ if (g === gen) fail();
++ return;
++ }
++ if (g !== gen) return;
++ let u;
++ try {
++ u = new URL(tk.stream_url);
++ if (!/^https?:$/.test(u.protocol)) throw new Error('url');
++ u.searchParams.set('topics', tk.topics.join(','));
++ u.searchParams.set('ticket', tk.ticket);
++ if (rt.lastSeq != null) u.searchParams.set('last_event_id', String(rt.lastSeq));
++ } catch { fail(); return; }
++ rt.tickets++; rt.origin = u.origin;
++ rt.subject = tk.subject;
++ set('connecting');
++ let s;
++ try { s = es = new root.EventSource(u.href); } catch { es = null; fail(); return; }
++ s.onopen = () => { if (es === s) { rt.failures = 0; set('open'); } };
++ s.onmessage = (e) => { if (es === s) heard(e); };
++ s.onerror = () => { if (es === s) fail(); };
++ s.addEventListener('gap', (e) => {
++ if (es !== s) return;
++ let gap = null; try { gap = JSON.parse(e.data); } catch { /* */ }
++ rt.gaps++;
++ call(o.onGap, gap);
++ });
++ }
++ function heard(e) {
++ let m; try { m = JSON.parse(e.data); } catch { return; }
++ const seq = m && typeof m.seq === 'number' ? m.seq : Number(e.lastEventId);
++ if (Number.isFinite(seq)) { if (rt.lastSeq != null && seq <= rt.lastSeq) return; rt.lastSeq = seq; }
++ const ev = m && m.event;
++ // Events already sends only this person's own; anything else is never shown.
++ if (!ev || !TYPE_RE.test(String(ev.event_type)) || !ev.subject || ev.subject.type !== 'user' || ev.subject.id !== rt.subject) { rt.ignored++; return; }
++ rt.events++;
++ call(o.onNotification, ev.payload && typeof ev.payload === 'object' ? ev.payload : {}, ev);
++ }
++ // A ticket opens one stream, so the browser's own reconnect (same URL) is never used: close, back off, new ticket.
++ function fail() {
++ close();
++ if (stopped || rt.state === 'blocked') return;
++ if (++rt.failures >= MAX_FAILURES) { set('failed'); return; }
++ set('backoff');
++ later('retry', () => { if (!stopped) { if (document.hidden) set('hidden'); else open(); } }, Math.min(9e5, 2e3 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
++ }
++
++ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
++ document.addEventListener('securitypolicyviolation', (e) => {
++ if (stopped || !rt.origin || String(e.blockedURI || '').indexOf(rt.origin) !== 0) return;
++ halt('blocked');
++ });
++ document.addEventListener('visibilitychange', () => {
++ if (stopped) return;
++ if (document.hidden) { if (es) later('hide', () => { if (document.hidden && es) { close(); set('hidden'); } }, 5 * 60 * 1000); return; }
++ root.clearTimeout(t.hide); t.hide = null;
++ if (rt.state === 'hidden') open();
++ });
++ root.addEventListener('online', () => { if (!stopped && (rt.state === 'failed' || rt.state === 'backoff')) { rt.failures = 0; root.clearTimeout(t.retry); set('off'); open(); } });
++
++ const api = {
++ start() { if (!stopped) return api; stopped = false; set('off'); open(); return api; },
++ stop() { stopped = true; gen++; close(); Object.keys(t).forEach((k) => { root.clearTimeout(t[k]); t[k] = null; }); set('off'); return api; },
++ /** Another account (or a new token): forget the cursor and the subject, start again. */
++ restart() { api.stop(); rt.lastSeq = null; rt.subject = null; rt.failures = 0; return api.start(); },
++ state: () => ({ ...rt }),
++ };
++ return api;
++ }
++
++ root.OVNotificationLive = { create };
++ if (typeof module !== 'undefined' && module.exports) module.exports = root.OVNotificationLive;
++})(typeof window !== 'undefined' ? window : globalThis);
ddiff --git a/notification-ui.js b/notification-ui.js
iindex 3d2ab8f..7741c33 100644
---- a/notification-ui.js
++++ b/notification-ui.js
@@@ -11,6 +11,9 @@
//
// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
// GET /api/notifications/unread-count
++// Realtime (init({ realtime: true }), off by default): notification-live.js, loaded from beside this file, hears the
++// person's network.notification.created over OpenVibe.Events with a ticket from POST /api/v1/realtime/ticket;
++// each event or gap re-reads the count. Polling stays: every 15 s without a stream, every 2 min with one.
// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
// ═══════════════════════════════════════════════════════════════
@@@ -18,6 +21,8 @@
'use strict';
const POLL_INTERVAL = 15_000;
++ const REALTIME_POLL = 120_000; // with an open realtime stream the poll is only a safety net (reads made on other sites)
++ const SELF_SRC = (typeof document !== 'undefined' && document.currentScript && document.currentScript.src) || '';
const PAGE_SIZE = 30;
const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
const MAX_TOASTS = 3;
@@@ -38,6 +43,9 @@
let _audioCache = {};
let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
let _channel = null; // BroadcastChannel for same-origin tab sync
++ let _live = null; // OVNotificationLive feed (realtime on)
++ let _lastPoll = 0;
++ let _soon = null; let _soonAll = false;
const _views = new Map(); // container → view state (panel + inboxes)
// ── Styles ──────────────────────────────────────────────
@@@ -519,6 +527,7 @@
async function pollUnread() {
if (_polling || !hasSession()) return;
_polling = true;
++ _lastPoll = Date.now();
try {
const data = await apiJson('/api/notifications/unread-count');
const prev = _unreadCount;
@@@ -545,9 +554,56 @@
stopPolling();
if (!hasSession()) return;
newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
-- _pollTimer = setInterval(pollUnread, POLL_INTERVAL);
++ _pollTimer = setInterval(() => { if (!liveOpen() || Date.now() - _lastPoll >= REALTIME_POLL) pollUnread(); }, POLL_INTERVAL);
}
function stopPolling() { if (_pollTimer) clearInterval(_pollTimer); _pollTimer = null; }
++
++ // ── Realtime (notification-live.js) ─────────────────────
++ function liveOpen() { return !!(_live && _live.state().state === 'open'); }
++ /** One re-read for a burst of events (a go-live fan-out, a replay after a reconnect); a gap re-reads open lists too. */
++ function soon(all) {
++ _soonAll = _soonAll || all;
++ if (_soon) return;
++ _soon = setTimeout(() => {
++ _soon = null;
++ if (_polling) { soon(false); return; } // a poll in flight may predate the event: read again after it
++ const lists = _soonAll; _soonAll = false;
++ pollUnread();
++ if (lists) for (const v of _views.values()) reload(v);
++ }, 300);
++ }
++ function liveSrc() {
++ if (_config.liveSrc) return _config.liveSrc;
++ const m = /^(https?:\/\/[^?#]*\/)notification-ui\.js(?:[?#]|$)/.exec(SELF_SRC);
++ return `${m ? m[1] : 'https://openvibe.network/shared/'}notification-live.js`;
++ }
++ let _liveLoading = null;
++ function loadLive() {
++ if (root.OVNotificationLive) return Promise.resolve(root.OVNotificationLive);
++ return _liveLoading || (_liveLoading = new Promise((ok) => {
++ const sc = document.createElement('script');
++ sc.src = liveSrc(); sc.async = true;
++ sc.onload = () => ok(root.OVNotificationLive || null);
++ sc.onerror = () => { _liveLoading = null; ok(null); };
++ document.head.appendChild(sc);
++ }));
++ }
++ function startRealtime() {
++ if (!_config.realtime || !hasSession() || typeof root.EventSource !== 'function') return;
++ if (_live) { _live.restart(); return; }
++ loadLive().then((L) => {
++ if (!L || _live || !_config.realtime || !hasSession()) return;
++ _live = L.create({
++ ticketUrl: `${_config.apiBase}/api/v1/realtime/ticket`,
++ token: () => _config.token,
++ credentials: isCrossOrigin() ? 'omit' : 'include',
++ onNotification: () => soon(false),
++ onGap: () => soon(true),
++ });
++ _live.start();
++ });
++ }
++ function stopRealtime() { if (_live) _live.stop(); }
document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
// ── Panel ───────────────────────────────────────────────
@@@ -652,11 +708,13 @@
if (opts.preferences) Object.assign(_preferences, opts.preferences);
channel();
startPolling();
++ startRealtime();
// If the user enabled push on this device before, make sure the worker is registered.
try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
},
destroy() {
stopPolling();
++ stopRealtime();
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
},
@@@ -692,8 +750,10 @@
setPreferences(prefs) { Object.assign(_preferences, prefs); },
setToken(token) {
_config.token = token;
-- if (token || !isCrossOrigin()) startPolling(); else { stopPolling(); setBadge(0, { quiet: true }); }
++ if (token || !isCrossOrigin()) { startPolling(); startRealtime(); } else { stopPolling(); stopRealtime(); setBadge(0, { quiet: true }); }
},
++ /** The realtime feed's state ({ state: 'off' | 'ticket' | 'connecting' | 'open' | 'backoff' | 'hidden' | 'blocked' | 'failed' | 'signed-out' | 'unavailable', … }), or null when off. */
++ realtimeState() { return _live ? _live.state() : null; },
togglePanel, closePanel,
enablePush, disablePush, pushStatus,
get unreadCount() { return _unreadCount; },
ddiff --git a/package-lock.json b/package-lock.json
iindex 9ab0cac..e54605e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "1.21.0",
++ "version": "1.22.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "1.21.0",
++ "version": "1.22.0",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 94cf629..21aad1e 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "1.21.0",
++ "version": "1.22.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"exports": {
@@@ -33,6 +33,7 @@
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
"./navbar": "./navbar.js",
++ "./notification-live": "./notification-live.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
ddiff --git a/test/notification-live-chrome.test.js b/test/notification-live-chrome.test.js
nnew file mode 100644
iindex 0000000..c5a4347
---- /dev/null
++++ b/test/notification-live-chrome.test.js
@@@ -0,0 +1,159 @@
++'use strict';
++// notification-live.js in a real headless Chrome (a real EventSource) against a local stand-in for Network's
++// ticket route and Events' /realtime/stream (single-use tickets, subject filtering, replay from last_event_id,
++// `event: gap` past retention). Roadmap WS-F task 1's browser tests: a disconnect resumes from the cursor with a
++// fresh ticket and nothing is lost or repeated; a gap is reported; an event for another person yields nothing.
++// Events' own refusal of a guessed user:<other> topic and of other people's events is OpenVibe.Events
++// test/realtime-tickets.test.js. Skipped (exit 0) when Chrome is not installed or OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('notification-live-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ALICE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
++
++// ── The stand-in: a ticket route and an SSE stream with Events' rules, in memory ──
++const log = []; // { seq, subject, id }
++let oldest = 1; // retention: seqs below are gone
++let seq = 0;
++const issued = new Map(); // ticket -> subject (unused)
++const streams = new Set(); // { res, subject }
++const streamRequests = [];
++let ticketN = 0;
++const PAGE = `<!doctype html><html><head><meta charset="utf-8"><title>bell</title><link rel="icon" href="data:,"></head><body><p>bell</p>
++<script src="/notification-live.js"></script>
++<script>
++ window.heard = []; window.gaps = []; window.states = [];
++ window.live = OVNotificationLive.create({
++ ticketUrl: '/api/v1/realtime/ticket', token: 'jwt-alice', credentials: 'omit',
++ onNotification: (p, e) => heard.push(e.payload.notification_id), onGap: (g) => gaps.push(g.reason), onState: (s) => states.push(s),
++ }).start();
++</script></body></html>`;
++
++function sendEvent(res, e) {
++ const event = { event_id: `evt_${e.seq}`, event_type: 'network.notification.created', visibility: 'subject', subject: { type: 'user', id: e.subject }, payload: { notification_id: e.id, unread_count: e.seq } };
++ res.write(`id: ${e.seq}\ndata: ${JSON.stringify({ seq: e.seq, event })}\n\n`);
++}
++/** A new notification: stored, then streamed to its person (or, `leak`, to everyone: a misbehaving server). */
++function publish(subject, id, { leak = false } = {}) {
++ const e = { seq: ++seq, subject, id };
++ log.push(e);
++ for (const s of streams) if (leak || s.subject === subject) sendEvent(s.res, e);
++ return e.seq;
++}
++function dropAll() { for (const s of [...streams]) { streams.delete(s); s.res.socket.destroy(); } }
++
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ const origin = `http://127.0.0.1:${server.address().port}`;
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ if (u.pathname === '/notification-live.js') { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(__dirname, '..', 'notification-live.js'))); }
++ if (u.pathname === '/api/v1/realtime/ticket') {
++ if (req.method !== 'POST' || req.headers.authorization !== 'Bearer jwt-alice' || req.headers.cookie) { res.writeHead(401); return res.end('{}'); }
++ const ticket = `tk-${++ticketN}`;
++ issued.set(ticket, ALICE);
++ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' });
++ return res.end(JSON.stringify({ ticket, expires_at: new Date(Date.now() + 120000).toISOString(), expires_in: 120, stream_url: `${origin}/realtime/stream`, topics: ['network.notification.*'], subject: ALICE }));
++ }
++ if (u.pathname === '/realtime/stream') {
++ const ticket = u.searchParams.get('ticket');
++ const last = u.searchParams.get('last_event_id');
++ streamRequests.push({ ticket, last, topics: u.searchParams.get('topics'), cookie: req.headers.cookie || null });
++ if (!/^[a-z0-9_.*,]+$/.test(u.searchParams.get('topics') || '')) { res.writeHead(400); return res.end('{}'); }
++ const subject = issued.get(ticket);
++ if (!subject) { res.writeHead(401); return res.end('{"code":"ticket.used"}'); } // unknown or already used
++ issued.delete(ticket);
++ res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' });
++ res.write('retry: 3000\n: connected user\n\n');
++ if (last != null) {
++ let cursor = Number(last);
++ if (cursor < oldest - 1) { res.write(`event: gap\ndata: ${JSON.stringify({ reason: 'retention', from_seq: cursor + 1, to_seq: oldest - 1 })}\n\n`); cursor = oldest - 1; }
++ for (const e of log) if (e.seq > cursor && e.seq >= oldest && e.subject === subject) sendEvent(res, e);
++ }
++ const s = { res, subject };
++ streams.add(s);
++ req.on('close', () => streams.delete(s));
++ return undefined;
++ }
++ res.writeHead(404); res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-notif-live-'));
++ const browser = await h.launch({ tmpDir });
++ let context = null; let page = null;
++ const read = () => page.evaluate('({ heard: window.heard, gaps: window.gaps, state: window.live.state() })');
++ const until = async (pred, what, ms = 8000) => {
++ const t0 = Date.now();
++ for (;;) {
++ const v = await read();
++ if (pred(v)) return v;
++ if (Date.now() - t0 > ms) throw new Error(`timed out waiting for ${what}: ${JSON.stringify(v)}`);
++ await sleep(100);
++ }
++ };
++ try {
++ context = await browser.newContext();
++ page = await h.openPage(browser, context, { width: 390 });
++ await page.goto(`${base}/`, { maxMs: 5000 });
++ await until((v) => v.state.state === 'open', 'the stream to open');
++ assert.deepStrictEqual(streamRequests.map((r) => [r.ticket, r.last, r.topics, r.cookie]), [['tk-1', null, 'network.notification.*', null]], 'first stream: a ticket, no cursor, no cookie');
++
++ // Alice's notifications arrive; one delivered to her stream for Bob is ignored.
++ publish(ALICE, 'a1');
++ publish(BOB, 'b1', { leak: true });
++ publish(ALICE, 'a2');
++ let v = await until((x) => x.heard.length === 2, 'a1 and a2');
++ assert.deepStrictEqual(v.heard, ['a1', 'a2']);
++ assert.strictEqual(v.state.ignored, 1, 'another person\'s event yields nothing');
++ assert.strictEqual(v.state.lastSeq, 3);
++
++ // The connection drops (Events restarts); while it is down two more notifications are stored.
++ dropAll();
++ publish(ALICE, 'a3');
++ publish(BOB, 'b2');
++ publish(ALICE, 'a4');
++ v = await until((x) => x.heard.length === 4 && x.state.state === 'open', 'the resume');
++ assert.deepStrictEqual(v.heard, ['a1', 'a2', 'a3', 'a4'], 'the missed ones, in order, nothing twice');
++ const resumed = streamRequests[streamRequests.length - 1];
++ assert.deepStrictEqual([resumed.ticket, resumed.last], ['tk-2', '3'], 'a fresh ticket and the cursor');
++ assert.strictEqual(new Set(streamRequests.filter((r) => issued.has(r.ticket) === false).map((r) => r.ticket)).size, streamRequests.length, 'a ticket is never used twice');
++ publish(ALICE, 'a5');
++ await until((x) => x.heard.length === 5, 'live again');
++
++ // Past retention: the stream says so (event: gap) before what it still has.
++ dropAll();
++ publish(ALICE, 'a6');
++ oldest = seq + 1; // everything so far is pruned
++ publish(ALICE, 'a7');
++ v = await until((x) => x.gaps.length === 1 && x.heard.includes('a7'), 'the gap and a7');
++ assert.deepStrictEqual(v.gaps, ['retention']);
++ assert.ok(!v.heard.includes('a6'), 'a6 was pruned: the gap stands for it');
++ assert.strictEqual(page.state.errors.filter((e) => !/realtime\/stream|EventSource|net::ERR/.test(`${e.text} ${e.url}`)).length, 0, JSON.stringify(page.state.errors));
++
++ // stop(): the stream closes and nothing reconnects.
++ await page.evaluate('window.live.stop(), 0');
++ const n = streamRequests.length;
++ await sleep(2500);
++ assert.strictEqual(streamRequests.length, n);
++ assert.strictEqual(streams.size, 0);
++ } finally {
++ if (page) await page.close();
++ if (context) await context.dispose();
++ await browser.close();
++ server.close(); server.closeAllConnections?.();
++ fs.rmSync(tmpDir, { recursive: true, force: true });
++ }
++ console.log('notification-live-chrome: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/notification-live.test.js b/test/notification-live.test.js
nnew file mode 100644
iindex 0000000..ed57746
---- /dev/null
++++ b/test/notification-live.test.js
@@@ -0,0 +1,364 @@
++'use strict';
++// notification-live.js and the bell (roadmap WS-E task 3, WS-F task 1; ADR-005 amendment 2), in a linkedom page
++// with a fake EventSource, fetch and timers:
++// - it asks Network for a realtime ticket (Bearer, no cookies across sites) and opens Events' stream with it,
++// topics=network.notification.*, never with credentials; the ticket is never kept;
++// - the person's own network.notification.created calls onNotification; another person's (a guessed topic),
++// other types and replayed seqs do nothing;
++// - a disconnect reconnects with a fresh ticket and last_event_id; a gap is reported; hidden 5 min closes and
++// showing resumes; 401/403, 503 and a CSP refusal stop it; ten failures in a row stop it, `online` revives it;
++// - notification-ui with realtime on loads it from beside itself, re-reads the count on an event (once per burst,
++// and again when a poll was already in flight), re-reads the lists on a gap, and polls every 2 min while the
++// stream is open, every 15 s otherwise (CSP-blocked included); with realtime off nothing of it runs.
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++const linkedom = require('linkedom');
++
++const ROOT = path.join(__dirname, '..');
++const LIVE_SRC = fs.readFileSync(path.join(ROOT, 'notification-live.js'), 'utf8');
++const UI_SRC = fs.readFileSync(path.join(ROOT, 'notification-ui.js'), 'utf8');
++const ALICE = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const BOB = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const API = 'https://openvibe.network';
++const STREAM = 'https://events.openvibe.network/realtime/stream';
++
++function page({ url = 'https://openvibe.live/', eventSource = true, random = 0.5 } = {}) {
++ const { window: lw, document } = linkedom.parseHTML('<!doctype html><html><head></head><body></body></html>');
++ const origin = new URL(url).origin;
++ const out = { requests: [], es: [], timers: new Map(), intervals: [], scripts: [], now: Date.parse('2026-09-26T18:00:00Z') };
++ const state = { ticketStatus: 200, subject: ALICE, count: 0, tickets: 0, newest: '2026-09-26 17:00:00', fresh: [], slow: null };
++ let hidden = false; let tid = 0; let currentSrc = null;
++ Object.defineProperty(document, 'hidden', { configurable: true, get: () => hidden });
++ Object.defineProperty(document, 'currentScript', { configurable: true, get: () => (currentSrc ? { src: currentSrc } : null) });
++ class FakeEventSource {
++ constructor(u, init) { this.url = u; this.init = init; this.closed = false; this.listeners = {}; out.es.push(this); }
++ addEventListener(type, f) { (this.listeners[type] = this.listeners[type] || []).push(f); }
++ close() { this.closed = true; }
++ opened() { if (this.onopen) this.onopen({}); }
++ send(seq, event) { if (this.onmessage) this.onmessage({ data: JSON.stringify({ seq, event }), lastEventId: String(seq) }); }
++ emit(type, data) { (this.listeners[type] || []).forEach((f) => f({ data: JSON.stringify(data) })); }
++ fail() { if (this.onerror) this.onerror({}); }
++ }
++ const json = (status, body) => ({ ok: status >= 200 && status < 300, status, json: async () => body, text: async () => JSON.stringify(body) });
++ const listeners = {};
++ class FakeDate extends Date {
++ constructor(...a) { if (a.length) super(...a); else super(out.now); }
++ static now() { return out.now; }
++ }
++ const ctx = {
++ document, console, URL, URLSearchParams, JSON, Promise, Math: Object.assign(Object.create(Math), { random: () => random }), Date: FakeDate,
++ Event: lw.Event || linkedom.Event, CustomEvent: lw.CustomEvent || linkedom.CustomEvent,
++ location: { href: url, origin }, navigator: {},
++ localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
++ setTimeout: (f, ms) => { out.timers.set(++tid, { f, ms: Number(ms) || 0 }); return tid; },
++ clearTimeout: (id) => { out.timers.delete(id); },
++ setInterval: (f, ms) => { out.intervals.push({ f, ms }); return out.intervals.length; },
++ clearInterval: (id) => { if (out.intervals[id - 1]) out.intervals[id - 1].f = () => {}; },
++ addEventListener: (t, f) => { (listeners[t] = listeners[t] || []).push(f); },
++ removeEventListener: () => {},
++ dispatchEvent: (e) => { for (const f of listeners[e.type] || []) f(e); return true; },
++ fetch: async (u, init = {}) => {
++ const abs = new URL(String(u), url).href;
++ const req = { url: abs, method: (init.method || 'GET').toUpperCase(), headers: init.headers || {}, credentials: init.credentials };
++ out.requests.push(req);
++ const p = new URL(abs);
++ if (p.pathname === '/api/v1/realtime/ticket') {
++ if (state.ticketStatus !== 200) return json(state.ticketStatus, { code: 'x' });
++ state.tickets++;
++ return json(200, { ticket: `hdr.T${state.tickets}.sig`, expires_at: '2026-09-26T18:02:00.000Z', expires_in: 120, stream_url: STREAM, topics: ['network.notification.*'], subject: state.subject });
++ }
++ if (p.pathname === '/api/notifications/unread-count') {
++ if (state.slow) { const s = state.slow; state.slow = null; await s; }
++ return json(200, { ok: true, count: state.count });
++ }
++ if (p.pathname === '/api/notifications/newest') return json(200, { ok: true, notification: { created_at: state.newest } });
++ if (p.pathname === '/api/notifications') return json(200, { ok: true, notifications: p.searchParams.get('since') ? state.fresh : [], has_more: false, total: 0 });
++ return json(404, {});
++ },
++ };
++ if (eventSource) ctx.EventSource = FakeEventSource;
++ ctx.window = ctx; ctx.globalThis = ctx;
++ vm.createContext(ctx);
++ // A script the page appends "loads": notification-live.js runs, then onload fires.
++ const append = document.head.appendChild.bind(document.head);
++ document.head.appendChild = (node) => {
++ const r = append(node);
++ const src = node && node.tagName === 'SCRIPT' ? String(node.src || node.getAttribute('src') || '') : '';
++ if (src) {
++ out.scripts.push(src);
++ setImmediate(() => {
++ if (!/notification-live\.js$/.test(src)) { if (node.onerror) node.onerror(new ctx.Event('error')); return; }
++ vm.runInContext(LIVE_SRC, ctx, { filename: 'notification-live.js' });
++ if (node.onload) node.onload(new ctx.Event('load'));
++ });
++ }
++ return r;
++ };
++ return {
++ ctx, document, out, state,
++ get es() { return out.es[out.es.length - 1]; },
++ run(src, file, as) { currentSrc = as || null; try { vm.runInContext(src, ctx, { filename: file }); } finally { currentSrc = null; } },
++ async settle(turns = 12) { for (let i = 0; i < turns; i++) await new Promise((r) => setImmediate(r)); },
++ timeouts() { return [...out.timers.values()].map((x) => x.ms); },
++ fire(filter) { const due = [...out.timers].filter(([, x]) => !filter || filter(x.ms)); for (const [id] of due) out.timers.delete(id); due.forEach(([, x]) => x.f()); return due.length; },
++ tick(ms = 15000) { out.now += ms; out.intervals.filter((i) => i.ms === 15000).forEach((i) => i.f()); },
++ setHidden(h) { hidden = !!h; document.dispatchEvent(new ctx.Event('visibilitychange')); },
++ dispatch(type) { ctx.dispatchEvent(new ctx.Event(type)); },
++ csp(blockedURI) { const e = new ctx.Event('securitypolicyviolation'); e.blockedURI = blockedURI; document.dispatchEvent(e); },
++ tickets() { return out.requests.filter((r) => r.url === `${API}/api/v1/realtime/ticket`); },
++ counts() { return out.requests.filter((r) => r.url === `${API}/api/notifications/unread-count`).length; },
++ };
++}
++
++let seq = 0;
++const created = (subject, over = {}) => ({
++ event_id: `evt_01JAB2C3D4E5F6G7H8J9K0M${String(++seq).padStart(3, '0')}`, event_type: 'network.notification.created', version: 1, source: 'network',
++ actor: { type: 'system', id: 'network' }, visibility: 'subject', subject: { type: 'user', id: subject },
++ payload: { notification_id: `n${seq}`, type: 'STREAM_LIVE', category: 'stream', priority: 'normal', service: 'live', created_at: '2026-09-26T18:00:00.000Z', unread_count: seq },
++ ...over,
++});
++
++(async () => {
++ // ── The feed on its own ──────────────────────────────────────────────────────────────
++ {
++ const p = page();
++ p.run(LIVE_SRC, 'notification-live.js');
++ const heard = []; const gaps = []; const states = [];
++ const live = p.ctx.OVNotificationLive.create({
++ ticketUrl: `${API}/api/v1/realtime/ticket`, token: () => 'jwt-alice', credentials: 'omit',
++ onNotification: (payload, ev) => heard.push([payload.notification_id, ev.subject.id]), onGap: (g) => gaps.push(g), onState: (s) => states.push(s),
++ });
++ live.start();
++ await p.settle();
++ // A ticket from Network with the person's token, no cookies; then the stream, without credentials.
++ const tk = p.tickets();
++ assert.strictEqual(tk.length, 1);
++ assert.deepStrictEqual([tk[0].method, tk[0].headers.Authorization, tk[0].credentials], ['POST', 'Bearer jwt-alice', 'omit']);
++ assert.strictEqual(p.out.es.length, 1);
++ const s1 = p.es;
++ assert.strictEqual(s1.url.split('?')[0], STREAM);
++ assert.strictEqual(new URL(s1.url).searchParams.get('topics'), 'network.notification.*');
++ assert.strictEqual(new URL(s1.url).searchParams.get('ticket'), 'hdr.T1.sig');
++ assert.strictEqual(new URL(s1.url).searchParams.get('last_event_id'), null, 'nothing to resume on the first open');
++ assert.strictEqual(s1.init, undefined, 'never withCredentials: the ticket is the credential');
++ assert.strictEqual(live.state().state, 'connecting');
++ s1.opened();
++ assert.strictEqual(live.state().state, 'open');
++
++ // The person's own notification is heard; another person's (a guessed topic), other types and replays are not.
++ s1.send(10, created(ALICE));
++ s1.send(11, created(BOB));
++ s1.send(12, { ...created(ALICE), event_type: 'live.stream.started' });
++ s1.send(13, { ...created(ALICE), subject: { type: 'stream', id: ALICE } });
++ s1.send(10, created(ALICE));
++ assert.deepStrictEqual(heard.map((h) => h[1]), [ALICE]);
++ assert.deepStrictEqual([live.state().events, live.state().ignored, live.state().lastSeq], [1, 3, 13]);
++ assert.ok(!JSON.stringify(live.state()).includes('T1'), 'the ticket is not kept');
++
++ // A gap is reported.
++ s1.emit('gap', { reason: 'retention', from_seq: 1, to_seq: 9 });
++ assert.deepStrictEqual(gaps, [{ reason: 'retention', from_seq: 1, to_seq: 9 }]);
++
++ // A disconnect: closed at once (the browser's own retry would reuse a spent ticket), then after 1-2 s a
++ // fresh ticket and the cursor.
++ s1.fail();
++ assert.strictEqual(s1.closed, true);
++ assert.strictEqual(live.state().state, 'backoff');
++ assert.deepStrictEqual(p.timeouts(), [1500], 'the first retry after 1 to 2 s (jitter)');
++ p.fire(); await p.settle();
++ assert.strictEqual(p.tickets().length, 2, 'a new ticket for the reconnect');
++ const s2 = p.es;
++ assert.notStrictEqual(s2, s1);
++ assert.deepStrictEqual([new URL(s2.url).searchParams.get('ticket'), new URL(s2.url).searchParams.get('last_event_id')], ['hdr.T2.sig', '13']);
++ s2.opened();
++ s2.send(14, created(ALICE));
++ assert.deepStrictEqual(heard.map((h) => h[0]).length, 2);
++
++ // Failures back off (2 s doubling, jitter) and give up after ten in a row; `online` revives it.
++ const delays = [];
++ for (let i = 0; i < 10; i++) {
++ p.es.fail();
++ if (i < 9) { delays.push(p.timeouts()[0]); p.fire(); await p.settle(); }
++ }
++ assert.deepStrictEqual(delays, [1500, 3000, 6000, 12000, 24000, 48000, 96000, 192000, 384000]);
++ assert.strictEqual(live.state().state, 'failed');
++ assert.deepStrictEqual(p.timeouts(), [], 'nothing scheduled: the page polls');
++ const n = p.out.es.length;
++ p.dispatch('online'); await p.settle();
++ assert.strictEqual(p.out.es.length, n + 1, 'back online: a new stream');
++ assert.strictEqual(new URL(p.es.url).searchParams.get('last_event_id'), '14', 'from the cursor');
++ p.es.opened();
++
++ // Hidden for 5 minutes: closed; shown: resumed from the cursor with a fresh ticket.
++ p.setHidden(true);
++ assert.deepStrictEqual(p.timeouts(), [300000]);
++ const s3 = p.es;
++ p.fire(); assert.strictEqual(s3.closed, true);
++ assert.strictEqual(live.state().state, 'hidden');
++ const t3 = p.tickets().length;
++ p.setHidden(false); await p.settle();
++ assert.strictEqual(p.tickets().length, t3 + 1);
++ assert.strictEqual(new URL(p.es.url).searchParams.get('last_event_id'), '14');
++ p.es.opened();
++
++ // A Content-Security-Policy refusal of Events: closed, and nothing reopens it.
++ const s4 = p.es;
++ p.csp('https://events.openvibe.network/realtime/stream');
++ assert.deepStrictEqual([s4.closed, live.state().state], [true, 'blocked']);
++ s4.fail(); p.dispatch('online'); await p.settle();
++ assert.strictEqual(live.state().state, 'blocked');
++ assert.deepStrictEqual(p.timeouts(), []);
++ // A refusal of anything else is not ours.
++ live.restart(); await p.settle(); p.es.opened();
++ p.csp('https://cdn.example.test/x.js');
++ assert.strictEqual(live.state().state, 'open');
++ assert.strictEqual(new URL(p.es.url).searchParams.get('last_event_id'), null, 'restart (another account) forgets the cursor');
++ live.stop();
++ assert.strictEqual(p.es.closed, true);
++ assert.ok(states.includes('ticket') && states.includes('connecting') && states.includes('hidden'));
++ }
++
++ // ── Refusals from Network: signed out, realtime off, a transient error ───────────────────
++ for (const [status, want] of [[401, 'signed-out'], [403, 'signed-out'], [503, 'unavailable'], [404, 'unavailable'], [500, 'backoff'], [429, 'backoff']]) {
++ const p = page();
++ p.run(LIVE_SRC, 'notification-live.js');
++ p.state.ticketStatus = status;
++ const live = p.ctx.OVNotificationLive.create({ ticketUrl: `${API}/api/v1/realtime/ticket`, token: 'jwt' }).start();
++ await p.settle();
++ assert.strictEqual(live.state().state, want, `ticket ${status}`);
++ assert.strictEqual(p.out.es.length, 0, `no stream after a ${status}`);
++ assert.deepStrictEqual(p.timeouts(), want === 'backoff' ? [1500] : [], `ticket ${status}: retry only when it may pass`);
++ }
++ // A ticket for someone else than the stream sends is never believed over the event's subject.
++ {
++ const p = page();
++ p.run(LIVE_SRC, 'notification-live.js');
++ p.state.subject = BOB;
++ const heard = [];
++ p.ctx.OVNotificationLive.create({ ticketUrl: `${API}/api/v1/realtime/ticket`, token: 'jwt', onNotification: (x) => heard.push(x) }).start();
++ await p.settle(); p.es.opened();
++ p.es.send(1, created(ALICE));
++ assert.deepStrictEqual(heard, []);
++ p.es.send(2, created(BOB));
++ assert.strictEqual(heard.length, 1);
++ }
++
++ // ── The bell with realtime on ─────────────────────────────────────────────────────────
++ {
++ const p = page();
++ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.live/shared/notification-ui.js?v=abc123');
++ const N = p.ctx.OpenVibeNotifications;
++ p.state.count = 2;
++ N.init({ token: 'jwt-alice', apiBase: API, realtime: true });
++ const bell = N.createBell(p.document.body);
++ await p.settle();
++ assert.deepStrictEqual(p.out.scripts, ['https://openvibe.live/shared/notification-live.js'], 'loaded from beside notification-ui.js');
++ assert.strictEqual(p.tickets().length, 1);
++ assert.strictEqual(p.tickets()[0].headers.Authorization, 'Bearer jwt-alice');
++ assert.strictEqual(p.tickets()[0].credentials, 'omit', 'cross-site: the token, never cookies');
++ assert.strictEqual(bell.querySelector('.badge').textContent, '2');
++ p.es.opened();
++ assert.strictEqual(N.realtimeState().state, 'open');
++
++ // An event: one re-read of the count 300 ms later, however many arrive; new items toast.
++ const before = p.counts();
++ p.state.count = 5;
++ p.state.fresh = [{ id: 'n9', type: 'STREAM_LIVE', title: 'carol is live!', priority: 'normal', created_at: '2026-09-26 18:00:01', is_read: 0 }];
++ p.es.send(20, created(ALICE)); p.es.send(21, created(ALICE)); p.es.send(22, created(ALICE));
++ assert.deepStrictEqual(p.timeouts().filter((ms) => ms === 300), [300]);
++ p.fire((ms) => ms === 300); await p.settle();
++ assert.strictEqual(p.counts(), before + 1, 'a burst is one request');
++ assert.strictEqual(bell.querySelector('.badge').textContent, '5');
++ assert.ok(p.document.querySelector('.openvibe-toast, .ov-toast, [class*="toast"]'), 'the new item toasts');
++
++ // Another person's event (a guessed topic) re-reads nothing.
++ p.es.send(23, created(BOB));
++ assert.deepStrictEqual(p.timeouts().filter((ms) => ms === 300), []);
++
++ // A poll already in flight when the event arrives may predate it: the re-read waits for it, then runs.
++ let release; p.state.slow = new Promise((r) => { release = r; });
++ p.tick(120000); // the 2-min safety poll starts (and hangs)
++ await p.settle(2);
++ const inflight = p.counts();
++ p.state.count = 6;
++ p.es.send(24, created(ALICE));
++ p.fire((ms) => ms === 300); await p.settle(2);
++ assert.deepStrictEqual(p.timeouts().filter((ms) => ms === 300), [300], 're-armed behind the poll in flight');
++ release(); await p.settle();
++ p.fire((ms) => ms === 300); await p.settle();
++ assert.strictEqual(p.counts(), inflight + 1);
++ assert.strictEqual(bell.querySelector('.badge').textContent, '6');
++
++ // While the stream is open, the 15 s tick polls only every 2 minutes.
++ let c = p.counts();
++ p.tick(15000); p.tick(15000); await p.settle();
++ assert.strictEqual(p.counts(), c, 'no poll inside 2 minutes of the last one');
++ p.tick(120000); await p.settle();
++ assert.strictEqual(p.counts(), c + 1, 'the safety-net poll');
++
++ // A gap re-reads the count and the open lists.
++ const lists = p.out.requests.filter((r) => r.url.startsWith(`${API}/api/notifications?`)).length;
++ N.togglePanel(); await p.settle();
++ const listsOpen = p.out.requests.filter((r) => r.url.startsWith(`${API}/api/notifications?`)).length;
++ assert.ok(listsOpen > lists, 'opening the panel lists');
++ p.es.emit('gap', { reason: 'replay_limit' });
++ p.fire((ms) => ms === 300); await p.settle();
++ assert.ok(p.out.requests.filter((r) => r.url.startsWith(`${API}/api/notifications?`)).length > listsOpen, 'the gap reloaded the panel');
++
++ // Blocked by CSP: polling every 15 s again.
++ p.csp(`${STREAM}?topics=x`);
++ assert.strictEqual(N.realtimeState().state, 'blocked');
++ c = p.counts();
++ p.tick(15000); await p.settle(); p.tick(15000); await p.settle();
++ assert.strictEqual(p.counts(), c + 2, 'CSP-blocked: the 15 s poll is back');
++
++ // Sign-out stops the feed; a new token starts it again with a new ticket and no cursor.
++ N.setToken(null); await p.settle();
++ assert.strictEqual(N.realtimeState().state, 'off');
++ const t = p.tickets().length;
++ N.setToken('jwt-bob'); await p.settle();
++ assert.strictEqual(p.tickets().length, t + 1);
++ assert.strictEqual(p.tickets()[t].headers.Authorization, 'Bearer jwt-bob');
++ assert.strictEqual(new URL(p.es.url).searchParams.get('last_event_id'), null);
++ N.destroy();
++ assert.strictEqual(p.es.closed, true);
++ }
++
++ // ── Realtime off (the default), or no EventSource: nothing of it runs; on openvibe.network, cookies ──
++ {
++ const p = page();
++ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.live/shared/notification-ui.js');
++ p.ctx.OpenVibeNotifications.init({ token: 'jwt', apiBase: API });
++ await p.settle();
++ assert.deepStrictEqual([p.out.scripts.length, p.tickets().length, p.out.es.length, p.ctx.OpenVibeNotifications.realtimeState()], [0, 0, 0, null]);
++ const c = p.counts();
++ p.tick(15000); await p.settle();
++ assert.strictEqual(p.counts(), c + 1, 'polling every 15 s as before');
++ }
++ {
++ const p = page({ eventSource: false });
++ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.live/shared/notification-ui.js');
++ p.ctx.OpenVibeNotifications.init({ token: 'jwt', apiBase: API, realtime: true });
++ await p.settle();
++ assert.deepStrictEqual([p.out.scripts.length, p.tickets().length], [0, 0], 'no EventSource: polling only');
++ }
++ {
++ const p = page({ url: 'https://openvibe.network/my' });
++ p.run(UI_SRC, 'notification-ui.js', 'https://openvibe.network/shared/notification-ui.js');
++ p.ctx.OpenVibeNotifications.init({ apiBase: API, realtime: true });
++ await p.settle();
++ assert.deepStrictEqual(p.out.scripts, ['https://openvibe.network/shared/notification-live.js']);
++ assert.strictEqual(p.tickets()[0].credentials, 'include', 'on openvibe.network the session cookie asks for the ticket');
++ assert.strictEqual(p.tickets()[0].headers.Authorization, undefined);
++ }
++
++ // ── Size: loaded on demand, after sign-in ──────────────────────────────────────────────
++ const { brotli } = require('../scripts/size-report');
++ const size = (f) => brotli(fs.readFileSync(path.join(ROOT, f)));
++ assert.ok(size('notification-live.js') <= 3 * 1024, `notification-live.js is ${(size('notification-live.js') / 1024).toFixed(2)} KB brotli, budget 3 KB`);
++ assert.ok(size('notification-ui.js') <= 13 * 1024, `notification-ui.js is ${(size('notification-ui.js') / 1024).toFixed(2)} KB brotli, budget 13 KB`);
++
++ console.log('notification live: all checks passed');
++})().catch((err) => { console.error(err); process.exit(1); });