From a1aeb324d7bbaeb4b210e3e6c8dc6c905cb97b23 Mon Sep 17 00:00:00 2001 From: ScreenTinker Date: Thu, 6 Aug 2026 16:26:43 -0500 Subject: [PATCH] Stop the worker claiming credit for offline widgets it never sees MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sw.js said its cache-first widget branch "is what lets a widget keep rendering when the network is gone". It is not. The player mounts widgets in an iframe sandboxed to `allow-scripts` with no allow-same-origin, so the frame is an opaque-origin client, and a service worker does not control those — the navigation never reaches the handler. Measured rather than reasoned: a clock widget mounted five times over 25 seconds of real playback in Chrome while the shell cache held zero widget entries, and a plain fetch() of the identical URL from the controlled page was intercepted and stored on the first try. The branch works; the player's own widgets are simply not what reaches it. What actually holds widgets through an outage today is the HTTP cache plus the server's `max-age=31536000, immutable` on a rev-pinned render. That is sound in a desktop browser and is exactly the store this module's own header says is NOT persistent on BrightSign, which is why content caching had to exist at all. So the comment now records the limit and names the two ways out — route the render through a same-origin fetch and mount it as srcdoc, or grant allow-same-origin and hand widget scripts the player's origin, which is not a trade worth making for an offline nicety. The test pins the security property so nobody buys the cache with it, and pins the Cache-Control header, which is now known to be load-bearing on its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL --- server/player/sw.js | 25 +++++++-- .../test/player-widget-frame-origin.test.js | 56 +++++++++++++++++++ 2 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 server/test/player-widget-frame-origin.test.js diff --git a/server/player/sw.js b/server/player/sw.js index c369ae4..8e6b04f 100644 --- a/server/player/sw.js +++ b/server/player/sw.js @@ -54,11 +54,26 @@ self.addEventListener('fetch', (event) => { const url = new URL(event.request.url); // Widget renders pinned to a revision: cache-FIRST, because those exact bytes cannot change - // without the rev changing. This is what lets a widget keep rendering when the network is gone — - // previously the server sent no-store for every render, so widgets were the one thing the - // player's offline cache could never hold, and a display that lost its uplink lost them. - // ignoreSearch is deliberately NOT used here: the query string carries the rev, and ignoring it - // would match a different revision's entry, which is the staleness we are trying to remove. + // without the rev changing. ignoreSearch is deliberately NOT used here: the query string carries + // the rev, and ignoring it would match a different revision's entry, which is the staleness we + // are trying to remove. + // + // MEASURED LIMIT, do not read more into this branch than it delivers. The player mounts widgets in + // an iframe sandboxed to `allow-scripts` with NO allow-same-origin (index.html, + // renderWidgetBuffered), so that frame is an OPAQUE-origin client — and a service worker does not + // control opaque-origin clients. Its navigation request never reaches this handler. Driven in + // Chrome against a live server: a clock widget mounted five times over 25s and the shell cache + // held zero widget entries, while a plain fetch() of the identical URL from the controlled page + // was intercepted and stored. So this branch serves anything that reaches it — a same-origin + // fetch, a future non-sandboxed mount — and today the player's own widgets are not that. + // + // What actually keeps widgets rendering offline right now is the HTTP cache plus the server's + // `max-age=31536000, immutable` on a rev-pinned render (routes/widgets.js). That is sound in a + // desktop browser and is NOT a documented-persistent store on BrightSign, which guarantees + // survival across reboots for IndexedDB, localStorage and SQLite only — the same gap that made + // content caching necessary. Closing it properly means routing the render through a same-origin + // fetch and mounting it as srcdoc; granting the frame allow-same-origin instead would hand widget + // scripts the player's origin, which is not a trade worth making for an offline nicety. if (url.pathname.startsWith('/api/widgets/') && url.pathname.endsWith('/render') && url.searchParams.has('rev')) { event.respondWith( caches.match(event.request).then(cached => { diff --git a/server/test/player-widget-frame-origin.test.js b/server/test/player-widget-frame-origin.test.js new file mode 100644 index 0000000..fd71dac --- /dev/null +++ b/server/test/player-widget-frame-origin.test.js @@ -0,0 +1,56 @@ +'use strict'; + +// The widget iframe's origin, and what it costs. +// +// The player mounts a widget in an iframe sandboxed to `allow-scripts` with NO allow-same-origin, +// which gives it an OPAQUE origin: widget scripts cannot read the player's window, its localStorage, +// or its device token. That is deliberate and worth keeping — widget HTML is operator-authored and +//, for the webpage widget, third-party. +// +// It has a consequence that is easy to forget and was in fact forgotten: a service worker does not +// control opaque-origin clients, so the widget frame's navigation NEVER reaches sw.js. The +// cache-first widget branch there is real code that this player's own widgets do not use — measured +// in Chrome, a clock widget mounted five times over 25 seconds while the shell cache held zero +// widget entries, and a plain fetch() of the same URL from the controlled page was cached +// immediately. Widgets survive an outage today on the HTTP cache and the server's immutable +// Cache-Control, not on the worker. +// +// So this test pins the security property, and pins the fact that the offline story for widgets +// rests on the HTTP header. Someone who "fixes" offline widgets by adding allow-same-origin trades +// the isolation for a cache — the wrong direction, and the reason this is asserted rather than left +// as a comment. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const HTML = fs.readFileSync(path.join(__dirname, '..', 'player', 'index.html'), 'utf8'); +const SW = fs.readFileSync(path.join(__dirname, '..', 'player', 'sw.js'), 'utf8'); +const WIDGETS = fs.readFileSync(path.join(__dirname, '..', 'routes', 'widgets.js'), 'utf8'); + +test('the widget iframe is sandboxed into an opaque origin', () => { + const sandboxes = [...HTML.matchAll(/setAttribute\('sandbox',\s*'([^']*)'\)/g)].map((m) => m[1]); + assert.ok(sandboxes.length > 0, 'the player must sandbox its widget frames'); + for (const s of sandboxes) { + assert.match(s, /allow-scripts/, 'a widget needs scripts to be a widget'); + assert.doesNotMatch(s, /allow-same-origin/, + 'allow-same-origin would give widget scripts the player origin — its storage, its device token'); + } +}); + +test('the offline guarantee for widgets is the HTTP header, and the server still sets it', () => { + // If this regresses to no-store, widgets stop surviving an outage everywhere — and the service + // worker will NOT quietly cover for it, because it never sees the request. + assert.match(WIDGETS, /max-age=31536000, immutable/, + 'a rev-pinned render must stay hard-cacheable: it is the only thing holding widgets offline'); + assert.match(WIDGETS, /no-store/, 'a render with no rev must stay uncacheable — nothing distinguishes one from the next'); +}); + +test('the worker does not claim to be what keeps widgets offline', () => { + // The comment above that branch used to say it was. A worker cannot control an opaque-origin + // client, so the claim was false in exactly the deployment it was written for. + const branch = SW.slice(0, SW.indexOf("url.pathname.startsWith('/api/widgets/')")); + assert.match(branch, /opaque-origin|opaque origin/i, + 'sw.js must record that the widget frame is opaque-origin and bypasses it'); +});