openvibers/openvibe.shared Commits

release-watch: a CSP that leaves Events out of connect-src stops the release stream after one refusal (1.17.0, WS-P task 9)

OP OpenVibers committed 26 Sept 2026 · 2dfb257bd1 · 5 files, +30 −2

Most sites' Content-Security-Policy connect-src does not name https://events.openvibe.network yet (Live and Community do; Network, Wiki, Blog, Codes, Search, Reviews, VIP, Tips, Host, News, Deals, Coupons do not). There the browser refuses the EventSource and logs it; without this, release-watch would retry (backing off) up to six times per page. It now listens for securitypolicyviolation: one naming the Events origin closes the stream, cancels the retry and leaves state `blocked` (not reopened by `online` either), so the page logs that refusal once and stays on polling. Violations for other URLs are ignored. test/release-watch-realtime.test.js covers both. release-watch.js is 4.7 KB brotli, within its 5 KB budget. CHANGELOG and README say that a site should add the Events origin to connect-src (or set data-events="off") when it moves to 1.17.0. Not tagged.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 959f26f..81a9d97 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -17,9 +17,10 @@ A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
- **Failures.** An error closes the stream at once, rather than letting the browser retry every 3 s, and it reopens after 30 s, doubling to 15 minutes, with jitter. After 6 failures in a row only polling is left until the browser comes back online.
- **Polling.** Unchanged throughout (focus, visibility, `online`, every 10 minutes). Without EventSource, or when Events cannot be reached, nothing else changes.
- **Configuration.** The default URL applies on https pages. `OVReleaseConfig.eventsUrl` or the meta tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Events answers `https://*.openvibe.*` origins and those in its `REALTIME_CORS_ORIGINS`; elsewhere the stream fails and backs off.
++- **CSP.** A site's Content-Security-Policy must allow the Events origin in `connect-src`, as Live and Community already do. Otherwise the browser refuses the stream and logs that refusal once. release-watch sees the `securitypolicyviolation` for its URL, stops (state `blocked`) and stays on polling. Add `https://events.openvibe.network` to `connect-src`, or set `data-events="off"`, when you move a site to this release.
- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
--`release-watch.js` is 4.6 KB brotli (was 3.2 KB). Its budget in `test/release-update.test.js` moves from 3.5 KB to 5 KB for this.
++`release-watch.js` is 4.7 KB brotli (was 3.2 KB). Its budget in `test/release-update.test.js` moves from 3.5 KB to 5 KB for this.
`release-compat` `openPage()` also takes `globals` (extra window properties, such as a fake `EventSource` or a `Math` with a fixed `random`). It adds `timeouts()` (the pending delays), `fireTimeouts(filter)`, `dispatch(type)` and `timeoutsSet`.
ddiff --git a/README.md b/README.md
iindex 98bacb6..7fe3e45 100644
---- a/README.md
++++ b/README.md
@@@ -211,6 +211,8 @@ release.mount(app, { registry: m.registry }); // GET /release.json, POST /rele
tag's `data-events` sets another URL, and `false` or `"off"` turns it off. Polling (focus, visibility,
every 10 minutes) is unchanged.
- **Origins.** Events answers `https://*.openvibe.*` origins, plus those in its `REALTIME_CORS_ORIGINS`.
++ - **CSP.** The site's CSP `connect-src` must allow `https://events.openvibe.network`. Otherwise the
++ browser refuses the stream once, and release-watch stops (state `blocked`) and keeps polling.
- **State.** `OVRelease.state().realtime` reports `{ state, service, url, events, ignored, checks, failures, lastSeq }`.
- **Metrics (D46)**: `release_client_updates_total{outcome,reason}`, where outcome is `applied`,
`reloaded`, `deferred` or `failed`. The tab finds the endpoint in the manifest's `metrics_url`
ddiff --git a/release-watch.js b/release-watch.js
iindex 93f745b..40178f9 100644
---- a/release-watch.js
++++ b/release-watch.js
@@@ -207,11 +207,17 @@
function close() { const s = es; es = null; if (s) try { s.close(); } catch { /* */ } }
function fail() {
close();
++ if (rt.state === 'blocked') return;
if (++rt.failures >= 6) { rt.state = 'failed'; return; }
rt.state = 'backoff';
later('retry', () => { if (document.hidden) rt.state = 'hidden'; else open(); }, Math.min(9e5, 3e4 * 2 ** (rt.failures - 1)) * (0.5 + Math.random() / 2));
}
function hide() { if (document.hidden && es) { close(); rt.state = 'hidden'; } }
++ // A Content-Security-Policy whose connect-src leaves Events out: one refusal, then polling only.
++ document.addEventListener('securitypolicyviolation', (e) => {
++ try { if (!rt.url || String(e.blockedURI || '').indexOf(new URL(rt.url, root.location.href).origin) !== 0) return; } catch { return; }
++ close(); root.clearTimeout(t.retry); t.retry = null; rt.state = 'blocked';
++ });
function heard(e) {
let m; try { m = JSON.parse(e.data); } catch { return; }
const ev = m && m.event; const p = ev && ev.payload;
ddiff --git a/test/release-update.test.js b/test/release-update.test.js
iindex 3a94dec..3a92787 100644
---- a/test/release-update.test.js
++++ b/test/release-update.test.js
@@@ -65,7 +65,7 @@ const counts = (p) => p.beacons.reduce((acc, b) => { for (const [o, rs] of Objec
// Size: release-watch.js loads on every page after first paint; release-update.js only when a release
// can change something in place. Measured 2026-09-23: 3.2 KB and 3.0 KB brotli (release-watch was 1.7 KB).
--// 2026-09-26 (1.17.0): release-watch 4.6 KB with the release notifications over Events realtime (+1.1 KB of
++// 2026-09-26 (1.17.0): release-watch 4.7 KB with the release notifications over Events realtime (+1.1 KB of
// code, +0.4 KB of comments); its budget moved from 3.5 to 5 KB for that, and only for that.
{
const fs = require('fs'); const path = require('path'); const { brotli } = require('../scripts/size-report');
ddiff --git a/test/release-watch-realtime.test.js b/test/release-watch-realtime.test.js
iindex 419b265..dd2b8e9 100644
---- a/test/release-watch-realtime.test.js
++++ b/test/release-watch-realtime.test.js
@@@ -176,6 +176,25 @@ async function open({ url = 'https://openvibe.live/', html = PAGE(), config = nu
p.release.stop();
}
++ // ── A page whose CSP connect-src leaves Events out: one refusal, then polling only ──
++ {
++ const p = await open();
++ const s = p.es.all[0];
++ const v = new p.window.Event('securitypolicyviolation');
++ v.blockedURI = 'https://other.example/x';
++ p.document.dispatchEvent(v);
++ assert.strictEqual(p.rt().state, 'connecting', 'another URL\'s violation is not ours');
++ const mine = new p.window.Event('securitypolicyviolation');
++ mine.blockedURI = 'https://events.openvibe.network';
++ p.document.dispatchEvent(mine);
++ s.fail();
++ assert.deepStrictEqual([s.closed, p.rt().state, p.timeouts()], [true, 'blocked', []], 'no retry');
++ p.dispatch('online');
++ assert.strictEqual(p.es.all.length, 1, 'not even when back online');
++ assert.strictEqual(p.intervals.length, 2);
++ p.release.stop();
++ }
++
// ── A tab hidden for 5 minutes closes its stream; it reopens on return ──
{
const p = await open();