diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte
index 198dd264..e8d39ab5 100644
--- a/frontend/src/routes/+layout.svelte
+++ b/frontend/src/routes/+layout.svelte
@@ -82,6 +82,64 @@
await killLegacyServiceWorker();
+ // Register the DPoP-signing Service Worker (see
+ // `src/service-worker.ts`). It attaches a DPoP proof to every
+ // same-origin `/api/*` request the browser initiates on its
+ // own — `
` thumbnails, `` downloads,
+ // `EventSource` streams — replacing the server-side content
+ // allowlist (Gate C in `docs/plan/dpop.md`).
+ //
+ // `type: 'module'` matches SvelteKit's ESM output. Failure is
+ // swallowed to `console.debug`: unbound sessions and legacy
+ // browsers without SW support keep working; only bound sessions
+ // in `DPOP=required` mode see 401s on browser-direct URLs,
+ // which is the same failure mode as before this SW existed.
+ //
+ // AWAIT `navigator.serviceWorker.ready` before allowing the
+ // splash to lift on a first-ever visit. Without this, the SPA
+ // renders while the SW is still installing → thumbnails and
+ // downloads on that very first page load fire unsigned and
+ // 401. Second visit onward the SW is already installed, so
+ // `.ready` resolves in ~1ms — the wait is a one-shot cost per
+ // browser profile.
+ if ('serviceWorker' in navigator) {
+ void navigator.serviceWorker
+ .register('/service-worker.js', { type: 'module' })
+ .catch((err) => console.debug('DPoP service worker registration failed', err));
+ try {
+ await navigator.serviceWorker.ready;
+ } catch {
+ /* SW registration failed — unbound sessions still work;
+ bound sessions in `required` mode see 401s on browser-
+ direct URLs. Same fail-open shape as pre-SW. */
+ }
+ // Soft-reload once if we landed uncontrolled. Covers two
+ // cases the SW itself can't fix:
+ // * First-ever visit — page loaded before any SW existed,
+ // so `controller` is null even after `.ready`.
+ // * Force-refresh (Cmd-Shift-R) — the browser deliberately
+ // delivers the top-level document with no SW controller;
+ // `clients.claim()` can't attach retroactively.
+ // A single reload brings the page under SW control.
+ //
+ // `sessionStorage` guard prevents an infinite loop when the
+ // reload doesn't fix it (SW registration is genuinely broken
+ // — bad URL, CSP block, quota). Cleared once `controller` is
+ // set so a LATER force-refresh in the same session can also
+ // self-heal (one reload per uncontrolled-landing).
+ const SW_RELOAD_KEY = 'sw-reload-once';
+ if (!navigator.serviceWorker.controller) {
+ if (!sessionStorage.getItem(SW_RELOAD_KEY)) {
+ sessionStorage.setItem(SW_RELOAD_KEY, '1');
+ location.reload();
+ return;
+ }
+ /* reload already attempted this session — give up gracefully */
+ } else {
+ sessionStorage.removeItem(SW_RELOAD_KEY);
+ }
+ }
+
// The instant HTML boot splash has done its job — the app is mounted, so
// the route (login renders immediately; protected routes show their own
// loading state) is already in the DOM behind it.
diff --git a/frontend/src/service-worker.ts b/frontend/src/service-worker.ts
new file mode 100644
index 00000000..8818d820
--- /dev/null
+++ b/frontend/src/service-worker.ts
@@ -0,0 +1,124 @@
+///
+///
+
+/**
+ * DPoP-signing Service Worker (RFC 9449 companion to the page-context
+ * `apiFetch` interceptor).
+ *
+ * WHY this exists. Under `OXICLOUD_DPOP_MODE=required` the server rejects
+ * bound-session requests that carry no DPoP proof. The page-context
+ * `apiFetch` interceptor covers everything the SPA drives through
+ * `fetch()` — but the browser itself makes requests JS can never touch:
+ * `
` (thumbnails, photo previews), `` /
+ * `` (file downloads / inline previews), and `EventSource`
+ * (admin log tail). Those had lived behind a middleware allowlist
+ * (Gate C) — this SW replaces that allowlist entirely by attaching a
+ * proof to every same-origin `/api/*` request the browser makes,
+ * regardless of who initiated it.
+ *
+ * Why NOT a Web Worker. Dedicated workers can't see network requests
+ * the page initiates. Only Service Workers register a `fetch` handler
+ * for their scope. This IS the browser-side hook for browser-driven
+ * requests.
+ *
+ * Shared state with the page. Both scopes share the same-origin
+ * IndexedDB (where the persistent P-256 keypair lives) and SubtleCrypto
+ * (also available in SW context). The nonce cache is per-scope — the
+ * page module holds its own in-memory nonce, the SW holds its own; on
+ * first request each scope pays a one-round-trip nonce challenge, then
+ * catches up via `DPoP-Nonce` response headers.
+ *
+ * Skip conditions:
+ * * cross-origin (privacy — never leak the user's keypair thumbprint
+ * to third parties);
+ * * anything outside `/api/*` (static assets don't hit the DPoP
+ * middleware, no need to burn crypto per request);
+ * * requests that already carry a `DPoP` header — the page context
+ * signed them via `apiFetch` (or the XHR upload path), don't
+ * double-sign;
+ * * proof unavailable (keypair inaccessible) — pass through
+ * unsigned so unbound sessions still function (fail-open contract,
+ * matches `docs/plan/dpop.md`).
+ */
+
+import { buildDpopProof, isDpopNonceChallenge, updateNonceFromResponse } from '$lib/auth/dpop-proof';
+
+// eslint-disable-next-line @typescript-eslint/consistent-type-declarations
+declare const self: ServiceWorkerGlobalScope;
+
+const ORIGIN = self.location.origin;
+
+// Fast-forward the SW lifecycle so open tabs pick up the new version
+// on the next navigation without waiting for every existing tab to
+// close (default lifecycle stalls activation until then).
+self.addEventListener('install', () => {
+ void self.skipWaiting();
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(self.clients.claim());
+});
+
+self.addEventListener('fetch', (event) => {
+ const req = event.request;
+ const url = new URL(req.url);
+ // Same-origin only — never leak a DPoP proof (which carries the
+ // user's public-key JWK) to a third-party host.
+ if (url.origin !== ORIGIN) return;
+ // Only DPoP-protected paths need signing. Static assets, locales,
+ // vendors are outside the middleware and don't need the crypto tax.
+ if (!url.pathname.startsWith('/api/')) return;
+ // Don't double-sign — page-context `apiFetch`, `fetchMe`, `tryRefresh`,
+ // and `uploadFileWithProgress` already attach a proof themselves.
+ if (req.headers.has('DPoP')) return;
+ event.respondWith(signAndFetch(req));
+});
+
+async function signAndFetch(req: Request): Promise {
+ const firstProof = await buildDpopProof(req.method, req.url).catch(() => null);
+ // No keypair (IndexedDB blocked, SubtleCrypto missing, etc.) — pass
+ // through unsigned. Unbound sessions still work; bound sessions in
+ // `required` mode will 401, matching the fail-open contract.
+ if (!firstProof) return fetch(req);
+
+ // Body-preservation contract: `new Request(existing, ...)` transfers
+ // ownership of `existing.body` (a `ReadableStream` — read once). To
+ // keep a retry option open for POST / PUT bodies we tee ahead of the
+ // first attempt via `req.clone()`. Cheap on GET (no body), one
+ // stream tee on state-changing calls.
+ const retryReq = req.clone();
+ const first = await fetch(signWith(req, firstProof));
+ updateNonceFromResponse(first);
+ if (!isDpopNonceChallenge(first)) return first;
+
+ // Fresh proof — the current call to `buildDpopProof` picks up the
+ // nonce we just harvested from `first`'s `DPoP-Nonce` header.
+ const secondProof = await buildDpopProof(retryReq.method, retryReq.url).catch(() => null);
+ if (!secondProof) return first; // couldn't sign — surface the challenge
+ return fetch(signWith(retryReq, secondProof));
+}
+
+/**
+ * Build the signed outbound `Request` from an intercepted one.
+ *
+ * `mode: 'same-origin'` is load-bearing. Browser-initiated `
`
+ * / `` requests default to `mode: 'no-cors'`, and in no-cors
+ * mode the browser silently strips any header not on the CORS-safelist
+ * (`Accept`, `Accept-Language`, `Content-Language`, `Content-Type`)
+ * BEFORE sending — so `DPoP` would never reach the wire even though
+ * `Headers.set('DPoP', …)` succeeds in JS. `same-origin` (or `cors`)
+ * lets custom headers through. Legal for our targets: every path we
+ * intercept starts with `/api/` on the same origin as the SW itself.
+ */
+function signWith(req: Request, proof: string): Request {
+ return new Request(req, {
+ headers: withDpopHeader(req.headers, proof),
+ mode: 'same-origin'
+ });
+}
+
+function withDpopHeader(existing: Headers, proof: string): Headers {
+ const h = new Headers(existing);
+ h.set('DPoP', proof);
+ return h;
+}