Date: 2026-06-25 Status: Approved (design), pending implementation plan Author: Sophie + Claude
The Aleph site is a Jekyll static site hosted on GitHub Pages (also deployed via Vercel) on a custom domain over HTTPS. It already has most PWA building blocks:
/site.webmanifest (name, short_name, icons 192/512,
theme_color, background_color, display: standalone), linked from
_includes/head.html:9.android-chrome-192x192.png,
android-chrome-512x512.png, apple-touch-icon.png, favicons.The one missing piece for a real installable PWA is a service worker. Without it, browsers won’t treat the site as installable and there’s no offline support.
The site is auth-bearing: it has a login/ section, verify-email.html, a
_protected/ area, and a shared-JWT login API. In production the login frontend
calls a separate origin (https://auth.sophiebi.com), but vercel.json also
routes same-origin /api/* and /health to the login dispatcher
(vercel.json:16-17) — so auth-bearing endpoints exist on the primary origin too.
The central design constraint is therefore: never cache or serve stale
auth/sensitive content, and the service worker must fail closed (cache only
content proven safe) rather than fail open (cache everything not blocklisted).
Deliver, from a single hand-written service worker:
…without ever caching, storing, or serving-stale any auth/sensitive request.
/offline.html when
offline (/offline.html is the only cached HTML)..sophiebi.com (login/utils/auth-cookies.js:42-43), so public
static-asset requests carry cookies too. The implementation must NOT add a
blanket request.credentials/cookie guard, or logged-in users would lose static
caching.Six changes.
sw.js (new, repo root → served at /sw.js)Plain static JavaScript (not Jekyll-templated). Located at root so its scope
covers the whole origin (/). Approx 80 lines.
Cache name: aleph-v1 — a single versioned constant. Bumping the version
invalidates all precached assets.
install event:
aleph-v1, precache the core list:
/offline.html/css/bootstrap.min.css, /css/main.css, /css/customized.css/js/consent.js/android-chrome-192x192.png, /android-chrome-512x512.png,
/apple-touch-icon.png, /favicon-32x32.png, /favicon-16x16.png,
/favicon.ico/site.webmanifestself.skipWaiting().Note: precache
addAllis atomic — if any URL 404s, install fails. The list above must be kept in sync with files that actually exist at those paths.
/(home page) is intentionally NOT precached: doing so couples install success to the home page always being a public 200. At runtime/is served via network-passthrough, with/offline.htmlas the offline fallback.
activate event:
aleph-v1.self.clients.claim().Fail-closed caching model. The ONLY things ever written to the cache are (a)
the precache list at install — which includes exactly one HTML file, the static
/offline.html shell — and (b) static assets matched by the cache-first allowlist
below. No navigation/content HTML is ever cached at runtime — there is no
“cache everything not blocklisted” path. This is an allowlist, not a blocklist, so
a current or future same-origin HTML route (sensitive or not) cannot become
offline/stale by default. Consequence: full offline HTML browsing is not provided
(already a non-goal); the only offline navigation response is the precached
/offline.html.
Shared cache-write guard (isCacheable(request, response)) — applied on the
cache-first path’s write (since that is the only path that writes at runtime). A
response is only stored when ALL of:
request.method === 'GET' and the request has no Range header.response exists, response.status === 200, response.type === 'basic'
(same-origin, non-opaque). This rejects 206 partials, 3xx/4xx/5xx, and opaque
cross-origin responses.Cache-Control header does NOT contain no-store or private.fetch event — routing logic, evaluated in this order:
event.respondWith; request goes straight to
network and is never read or stored). Bypass when ANY of:
GET.url.origin !== self.location.origin) — this
covers the production login/JWT API on auth.sophiebi.com./login, /verify-email,
/_protected, /now, /journal, /theater, /api, /health. /theater
has admin auth/mutation controls (theater/index.html:240,253,483); /api
and /health cover the same-origin dispatcher routes in vercel.json:16-17./css/, /js/, /fonts/, /img/, /assets/, or is one of the root icon /
favicon files. On cache hit, return cached response and refresh the cache entry
in the background (stale-while-revalidate; the revalidation write also goes
through isCacheable). On miss, fetch, write via isCacheable, and return it.
This is the only runtime cache-write path.request.mode === 'navigate'), return the precached /offline.html;
otherwise let the failure propagate.offline.html (new, repo root)A minimal Jekyll page (front matter present so Jekyll renders it; layout: null
or a light layout to avoid pulling heavy dependencies). On-brand “You’re offline”
message with a short note that the page will work again once back online. Served
only by the SW’s offline fallback for failed navigations.
site.webmanifest (edit)Add to the existing JSON:
"start_url": "/""scope": "/""id": "/"Keep existing name, short_name, icons, theme_color, background_color,
display.
_includes/head.html (edit)Add <meta name="theme-color" content="#ffffff"> near the existing icon/manifest
links (value matches the manifest theme_color). Add a single deferred script tag
that loads the registration script (see below).
Layout coverage (verified). head.html is included via _layouts/default.html:3,
which is the base layout reached by page, post, home, default_nofooter,
etc. So login/index.html, verify-email.html, and theater/index.html (all
layout: page) already inherit the manifest link, theme-color meta, and
registration tag — they must NOT get them added directly (that would duplicate the
tags / inject a nested head). The only genuinely standalone page is
now/index.html (its own <!DOCTYPE>/<head>).
now/index.html (standalone, edit). This page currently has no manifest link
and no SW registration, so a user landing directly on /now/ before visiting any
layout-based page gets no install affordance. To keep installability genuinely
site-wide, add to its <head> the same three things head.html provides: the
<link rel="manifest" href="/site.webmanifest">, the
<meta name="theme-color" content="#ffffff">, and the deferred register-sw.js
script tag. This only affects installability/registration — /now stays in the
SW bypass list, so the page itself is still never cached. (Registering the worker
from a bypassed page registers it for the origin without caching that page.)
Net effect: registration markup lives in exactly two places — head.html (all
layout-based pages) and now/index.html (the one standalone page).
js/register-sw.js (new) + registrationSmall script, loaded defer from head.html (and from now/index.html, the one
standalone page):
if ('serviceWorker' in navigator) {
window.addEventListener('load', function () {
navigator.serviceWorker
.register('/sw.js', { scope: '/', updateViaCache: 'none' })
.catch(function () { /* no-op */ });
});
}
Registers after window load to avoid contending with first-paint resources,
guarded by feature detection. updateViaCache: 'none' forces the browser to
revalidate sw.js against the network on update checks instead of serving it from
the HTTP cache — without this, a stale sw.js could defeat the cache-versioning
scheme.
vercel.json (edit) — sw.js cache headersAdd a headers rule so /sw.js is served Cache-Control: no-cache (revalidate
every time). This ensures a freshly-deployed sw.js is picked up promptly on the
Vercel-served origin. On GitHub Pages the effective update latency is the platform
default (~10-min asset cache) combined with the browser’s built-in 24h SW-script
update rule; updateViaCache: 'none' in registration mitigates the HTTP-cache leg
on both hosts.
Browser request
│
▼
sw.js fetch handler
│
├─ non-GET / cross-origin / sensitive path
│ (/login /verify-email /_protected /now /journal /theater /api /health)?
│ └─► bypass (network only, never read or stored)
│
├─ static asset path (/css /js /fonts /img /assets /icons)?
│ └─► cache-first (return cache, revalidate in bg via isCacheable)
│ ↑ ONLY runtime cache-write path
│
└─ otherwise (HTML nav) ──► network-passthrough (NEVER cached)
├─ ok ──► return as-is
├─ fail + navigate ──► /offline.html
└─ fail + other ──► propagate error
isCacheable = GET, no Range, status 200, type 'basic',
Cache-Control without no-store/private (gate on the cache-first write)
Caches ever hold only: precache list (incl. /offline.html) + cache-first static
assets. No navigation/content HTML is ever cached at runtime.
addAll is atomic; a 404 in the core list fails install.
Mitigation: the precache list is small and each path is verified to exist during
implementation/testing..catch no-op) — the site
functions normally without the SW.main.css, consent.js), edited in place.
With cache-first + background revalidate, a returning user therefore gets the
previous version of a changed asset on first paint and the fresh one only on
the next navigation (a one-navigation staleness window). This is accepted
behavior, with one operational rule: bump aleph-v1 → aleph-v2 whenever a
precached or static asset that matters (especially consent.js / login JS)
changes, which forces a clean refetch on next activate. Navigation/content HTML
is never cached at runtime (network-passthrough), so content pages always reflect
the live server./offline.html) and the cache-first static-asset allowlist (/css/, /js/,
/fonts/, /img/, /assets/, icons) are ever stored. A current or
future same-origin HTML route — sensitive or not — therefore cannot become
offline/stale by default, even if it is missing from the bypass list. This is
the primary safety property and does not depend on the page sending the right
cache headers.auth.sophiebi.com API — are bypassed unread./login, /verify-email, /_protected, /now,
/journal, /theater, /api, /health) ensures the SW does not even intercept
those auth/admin/dispatcher routes; /theater is included because it carries
admin auth/mutation controls (theater/index.html).isCacheable guard on the static-asset path additionally rejects non-200,
opaque, partial (206), and no-store/private responses.GET static-asset requests are cached; no navigation/content HTML, POST, or
auth flow is stored (the precached /offline.html is the only cached HTML).
Caching is gated on path + response — never on cookie/credentials presence — so
it neither caches auth content nor breaks static caching for logged-in users._site/, serve over http://localhost (SWs
run on localhost). Confirm in DevTools → Application → Service Workers that
sw.js installs and activates, and precache populates under aleph-v1./login and /verify-email; confirm Network tab
shows responses are NOT “from ServiceWorker” and nothing is written to the
cache for those paths. Repeat for a same-origin /api/... GET and /health
(must NOT be served from SW or cached), for the cross-origin
auth.sophiebi.com API, and for any POST. Also confirm a response carrying
Cache-Control: no-store/private is never written to the cache./css/main.css etc. are served “from
ServiceWorker” on repeat load./offline.html.sw.js is actually refetched from the
network (not served from HTTP cache) — the production-likely silent-failure
scenario./api/..., /health, /login,
/verify-email, /now, /journal, /_protected, /theater — plus an
arbitrary same-origin HTML page, then asserts via caches.keys() +
cache.match() that NONE of those URLs was stored and that no runtime-cached
navigation/content HTML exists — the precached /offline.html is the only
permitted cached HTML entry. This converts the “must never cache sensitive
content” property from a manual DevTools check into a regression guard that runs
on every change to sw.js.SUBSCRIBE TO RECEIVE POSTS DIRECTLY TO YOUR INBOX