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)
Changes
@@@ -4,6 +4,22 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semvebreaking 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).@@@ -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@@@ -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 CLInormalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,WIDTHS, CHECKS, BUDGETS, AXE, PROBE,@@@ -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@@@ -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;nnew file mode 100644@@@ -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);@@@ -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 = stickyconst 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; },@@@ -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"@@@ -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",nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -4,6 +4,22 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
@@ -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.
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).
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
## 1.21.0 — 2026-09-26
**Browser harness: an unreachable host** (ADR-024, roadmap WS-E task 1).
**Browser harness: an unreachable host** (ADR-024, roadmap WS-E task 1).
@@ -23,7 +23,7 @@ were reconciled first.
@@ -23,7 +23,7 @@ were reconciled first.
| Kind | Files |
| 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()` |
| 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`) |
| 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` |
| 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
@@ -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.
- `OpenVibeNavbar.init({ iconsUrl })` points navbar.js at another URL.
- Loading `nav-icons.js` *before* navbar.js puts every glyph in the first frame.
- 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
## Development
```bash
```bash
@@ -863,7 +863,7 @@ async function run(opts = {}) {
@@ -863,7 +863,7 @@ async function run(opts = {}) {
}
}
module.exports = {
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
// pure helpers, exported for tests and for the Host CLI
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
normalizeRoutes, jsonLdEntities, findText, visibleIn, growth, sitemapPaths, checkRoute, splitErrors,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
WIDTHS, CHECKS, BUDGETS, AXE, PROBE,
@@ -19,6 +19,7 @@ const BROWSER = Object.freeze([
@@ -19,6 +19,7 @@ const BROWSER = Object.freeze([
'footer.js', // shared footer (also required on the server by frame.js)
'footer.js', // shared footer (also required on the server by frame.js)
'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'shipped.js', // "Recently shipped" from the network changelog (openvibe.blog)
'notification-ui.js', // bell + notification panel
'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',
'account-switcher.js',
'user-card.js',
'user-card.js',
'ov-mark.js', // the OV brand mark drop-in
'ov-mark.js', // the OV brand mark drop-in
@@ -806,7 +806,7 @@
@@ -806,7 +806,7 @@
const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
const go = () => {
const go = () => {
const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
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 (root.OpenVibeNotifications) return go();
if (document.getElementById('ov-notify-loader')) return;
if (document.getElementById('ov-notify-loader')) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -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);
@@ -11,6 +11,9 @@
@@ -11,6 +11,9 @@
//
//
// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
// Reads: GET /api/notifications?limit&offset&unread_only&category&q&since
// GET /api/notifications/unread-count
// 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}
// Writes: POST /:id/read, /read-batch, /read-all, /:id/dismiss, /api/push/{subscribe,unsubscribe}
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
@@ -18,6 +21,8 @@
@@ -18,6 +21,8 @@
'use strict';
'use strict';
const POLL_INTERVAL = 15_000;
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 PAGE_SIZE = 30;
const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
const TOAST_DURATION = { low: 0, normal: 4200, high: 6500, critical: 0 }; // 0 = sticky
const MAX_TOASTS = 3;
const MAX_TOASTS = 3;
@@ -38,6 +43,9 @@
@@ -38,6 +43,9 @@
let _audioCache = {};
let _audioCache = {};
let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
let _preferences = { enabled: true, sound: true, toasts: true, muted_categories: [] };
let _channel = null; // BroadcastChannel for same-origin tab sync
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)
const _views = new Map(); // container → view state (panel + inboxes)
// ── Styles ──────────────────────────────────────────────
// ── Styles ──────────────────────────────────────────────
@@ -519,6 +527,7 @@
@@ -519,6 +527,7 @@
async function pollUnread() {
async function pollUnread() {
if (_polling || !hasSession()) return;
if (_polling || !hasSession()) return;
_polling = true;
_polling = true;
+ _lastPoll = Date.now();
try {
try {
const data = await apiJson('/api/notifications/unread-count');
const data = await apiJson('/api/notifications/unread-count');
const prev = _unreadCount;
const prev = _unreadCount;
@@ -545,9 +554,56 @@
@@ -545,9 +554,56 @@
stopPolling();
stopPolling();
if (!hasSession()) return;
if (!hasSession()) return;
newestCreatedAt().then(c => { _lastSeenAt = c || new Date().toISOString(); pollUnread(); });
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; }
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(); });
document.addEventListener('visibilitychange', () => { if (!document.hidden) pollUnread(); });
// ── Panel ───────────────────────────────────────────────
// ── Panel ───────────────────────────────────────────────
@@ -652,11 +708,13 @@
@@ -652,11 +708,13 @@
if (opts.preferences) Object.assign(_preferences, opts.preferences);
if (opts.preferences) Object.assign(_preferences, opts.preferences);
channel();
channel();
startPolling();
startPolling();
+ startRealtime();
// If the user enabled push on this device before, make sure the worker is registered.
// 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 { /* */ }
try { if (pushSupported() && localStorage.getItem('ov_push_enabled') === '1') navigator.serviceWorker.register(_config.swPath, { scope: '/' }).catch(() => {}); } catch { /* */ }
},
},
destroy() {
destroy() {
stopPolling();
stopPolling();
+ stopRealtime();
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer?.remove(); _panelEl?.remove();
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
_toastContainer = null; if (_panelEl) _views.delete(_panelEl); _panelEl = null;
},
},
@@ -692,8 +750,10 @@
@@ -692,8 +750,10 @@
setPreferences(prefs) { Object.assign(_preferences, prefs); },
setPreferences(prefs) { Object.assign(_preferences, prefs); },
setToken(token) {
setToken(token) {
_config.token = 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,
togglePanel, closePanel,
enablePush, disablePush, pushStatus,
enablePush, disablePush, pushStatus,
get unreadCount() { return _unreadCount; },
get unreadCount() { return _unreadCount; },
@@ -1,12 +1,12 @@
@@ -1,12 +1,12 @@
{
{
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.21.0",
+ "version": "1.22.0",
"lockfileVersion": 3,
"lockfileVersion": 3,
"requires": true,
"requires": true,
"packages": {
"packages": {
"": {
"": {
"name": "openvibe-shared",
"name": "openvibe-shared",
- "version": "1.21.0",
+ "version": "1.22.0",
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0"
"jsonwebtoken": "^9.0.0"
@@ -1,6 +1,6 @@
@@ -1,6 +1,6 @@
{
{
"name": "openvibe-shared",
"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",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"main": "index.js",
"exports": {
"exports": {
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./nav-icons": "./nav-icons.js",
"./nav-icons": "./nav-icons.js",
"./navbar": "./navbar.js",
"./navbar": "./navbar.js",
+ "./notification-live": "./notification-live.js",
"./notification-ui": "./notification-ui.js",
"./notification-ui": "./notification-ui.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./openvibe-sw": "./openvibe-sw.js",
"./openvibe-sw": "./openvibe-sw.js",
new file mode 100644
new file mode 100644
@@ -0,0 +1,159 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,364 @@
@@ -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); });