fix: give a fronting proxy a reachable path from an installed PWA - #43
Conversation
…lled PWA The service worker answered every navigation except /api/ from the precached app shell, so a proxy's sign-in page was invisible to an installed app — and an installed app has no address bar to fall back on. A reload re-rendered the same refused UI. The only crack in the precache was the API's own namespace, so the workaround was to serve an HTML page under /api/, squatting a path Collie could reclaim at any time. /auth/ is now reserved: the SW always passes it to the network, Collie routes nothing there, and the bridge answers with a placeholder saying so, since the SPA fallback would otherwise hand back the very UI the operator is trying to escape. The refusal banner links to it. Reload alone was never enough — the banner said it let a proxy serve its sign-in page, which the precache made false. The link is an <a> with a real href on purpose: a button's click handler is a same-document action the SW never sees as a navigation. The denylist lives in web/src/lib/sw-routes.ts, imported by both the SW and the banner, and is asserted against the built dist/sw.js contract in tests — too narrow and the sign-in page is unreachable, too wide and Collie's own deep links stop resolving offline. Both failures are silent. Fixes #31
Workbox tests the NavigationRoute denylist against `url.pathname + url.search` — verified in the vendored workbox-routing/NavigationRoute.js, where `_match` builds exactly that string. A rule anchored on a trailing slash therefore missed `/auth?rd=%2F`, which is the redirect shape Authelia and oauth2-proxy both emit, and the precache answered it: the bug this branch exists to fix, in its most likely real-world form. Every test passed because every test fed a pathname with no query string. Also reserves `/cdn-cgi/`. Cloudflare Access cannot be moved off it, so pointing that operator at `/auth/` cannot help them — their callback would be swallowed by the precache. Prefixes that CAN move (oauth2-proxy's --proxy-prefix, Authelia) are documented rather than reserved. The two implementations of the reservation — the SW denylist and the bridge's isReservedAuthPath — now have a shared test that runs one case list through both and requires them to agree. Each side already had its own tests, so either could have been edited alone and stayed green while the two drifted, and drift is silent in the way that matters. README records the two things the live test established: the passthrough covers a return-to query string, and a proxy that refuses the static bundle to a signed-out client permanently freezes that client's service worker — measured, update() throws outright — which is what strands a device that lapsed before upgrading.
|
Ran this end to end against a real Variant C topology before merging — a proxy that owns The bug, reproduced on the pre-fix build. With the old service worker the origin is sealed harder than the issue describes: not only does Reload re-render the refused UI, but navigating directly to The fix, verified on the same origin. Banner shows Sign in → tap → the proxy's page renders → sign in → back in Collie with the banner gone. Offline deep links to One real bug found in review and fixed here (aa7765e). Workbox matches the denylist against Also reserved One limitation worth knowing, measured not assumed. A client already locked out cannot receive this fix: with the proxy refusing the bundle, The |
7ad017c to
3b276cd
Compare
Closes the routing half of #31 — the half left open when #30 shipped in 0.15.0.
The problem
Every navigation except
/api/was answered from the precached app shell without touching thenetwork. In an installed PWA there is no address bar to work around that, so a proxy's sign-in page
was unreachable wherever it lived — including at
/, and including after a reload. @Optic00'sdeployment worked around it by serving an enrolment page at
/api/__enrol, because the API's ownnamespace was the only crack in the precache. That is squatting a path Collie could reclaim at any
time, which is what they filed.
What changes
/auth/is reserved. The service worker always passes it to the network, Collie routes nothingthere, and it will never become a UI route. The rules live in
web/src/lib/sw-routes.ts, importedby both the service worker and the app so the two can't drift on what the path is.
The bridge answers
/auth/*itself with a plain-HTML placeholder saying nothing is configuredthere, rather than letting the SPA fallback return the app shell. Without that, an operator whose
proxy rule is missing gets handed back the very UI they were trying to escape, with no signal at
all. The placeholder sits outside every gate — the reason to be there is that a gate refused you —
and carries a link home, because in an installed app it may be the only thing on screen.
The refusal banner from #30 now links to it. Its comment claimed "Reload is the useful action either
way, because it is what lets a fronting proxy serve its sign-in page" — the precache made that
false. Reload stays for a merely stale session on an already-enrolled device, but the escape is an
<a href="/auth/">: a real navigation the service worker sees as one, where a button's clickhandler is a same-document action it never sees at all. There's a test pinning it as a link
specifically, since swapping in a Button would silently re-brick the PWA and nothing else would
fail.
Checked
grepon builtdist/sw.jsgivesdenylist:[/^\/api\//,/^\/auth(?:\/|$)/]/auth/→ 404 placeholder,/auth/sign-in→ same,/→ 200,and
/authors→ 200 app shell (a prefix match must not be handed to the network)scripts/check-version.sh✓Not verified here: the installed-PWA path itself. Service workers need a secure context, so
confirming that a real installed app escapes the precache needs HTTPS and a device. The mechanism is
pinned statically and by unit test; the on-device behaviour is not.
Version
0.18.0 — MINOR, and it also cuts the release for #29, which merged unbumped.
🤖 Generated with Claude Code