fix(web): tell an access refusal apart from an outage - #30
Conversation
Every loader error except AbortError lands in the same catch and returns the last-good data flagged stale, which the connection banner renders as "reconnecting". A 401 or 403 therefore looks exactly like a dead radio: the herd stays on screen, the bar keeps promising a reconnect that will never come, and nothing says what is actually wrong. It is self-sustaining, too. The escalation latch clears only via markLive(), which needs a provably live poll, so under a persistent refusal it never clears, and the root loader's navigation fast path then returns stale data without even attempting a fetch. This is not only a reverse-proxy concern, though that is where it hurts most. The bridge itself answers 403 from checkAccess and guard, for "host not allowed", "cross-origin rejected" and "device not authorised", so a misconfigured COLLIE_PUBLIC_HOSTS or a device that is not on the allowlist presents the same way: reconnecting forever, no diagnosis. The loaders now classify the failure. A 401 or 403 sets a new authError flag alongside the existing error flag, everything else is unchanged. ApiError already carried the status; it was simply never read, so the classification needs no new plumbing beyond a narrow exported predicate. The flag is also remembered per session, because the latched navigation path returns without fetching and would otherwise drop the diagnosis the moment the user navigates. The banner branches on it ahead of the connection state machine: no /api/config probe, no reconnect spinner, no escalation clock, since none of those describe a refusal. The copy deliberately does not name a cause. The flag covers 401 and 403 alike and a 403 has three quite different meanings here, so naming one would be wrong more often than right. What was missing was the single fact the old behaviour hid: this is not the network. Reload is offered because it is what lets a fronting proxy serve its sign-in page. The new field is required rather than optional so that every construction site has to state it, which is why a few unrelated test fixtures gained a line. Scope note: the service worker answers every non-/api/ navigation from the precache, so a proxy's sign-in page at / is still unreachable from an installed PWA. That is a separate question about navigation routing and is deliberately not touched here.
|
Merged and shipped in 0.15.0. Thanks. The diagnosis is exactly right, including the part most people would miss: the escalation latch only clears via a live poll, so under a persistent refusal it never clears and the navigation fast path then serves stale data without even trying. That is what made the old behaviour self-sustaining rather than merely wrong. Reusing Agreed on the wording. A 403 here can be host, origin or device, so naming a cause would be wrong more often than right, and "this is not the network" is the fact the old banner hid. This landed alongside #28, which makes the device gate fail-closed, so refusals are now more likely to be real and this banner matters more than it did last week. 1028 web tests pass on merged main. One trivial import conflict with the pane-history branch, resolved on merge. The 17 failures you saw locally do not reproduce here, so your read that they were environmental looks right. |
The problem
In
web/src/lib/loaders.tsevery error exceptAbortErrorlands in the same catch and returns thelast-good data flagged stale, which the connection banner renders as "reconnecting". A 401 or 403
therefore looks exactly like a dead radio: the herd stays on screen, the bar keeps promising a
reconnect that will never arrive, and nothing says what is actually wrong.
It is self-sustaining, too. The escalation latch clears only via
markLive(), which needs aprovably live poll, so under a persistent refusal it never clears, and
rootLoader's navigationfast path then returns stale data without even attempting a fetch.
This is not only a reverse-proxy concern, though that is where it hurts most: behind an
authenticating proxy the user is simply told "reconnecting" forever, with no hint that a sign-in is
what is needed. The bridge itself answers 403 from
checkAccessandguard, forhost not allowed,cross-origin rejectedanddevice not authorised. A misconfiguredCOLLIE_PUBLIC_HOSTS, or adevice that is not on the allowlist, presents identically.
The change
The loaders classify the failure. A 401 or 403 sets a new
authErrorflag alongside the existingerrorflag; everything else is unchanged.ApiErroralready carriedstatusand it was simplynever read anywhere, so this needs no new plumbing beyond one narrow exported predicate.
The flag is also remembered per session, because the latched navigation path returns without
fetching and would otherwise drop the diagnosis the moment the user navigates. Any real success
clears it.
The banner branches on it ahead of the connection state machine: no
/api/configprobe, noreconnect spinner, no escalation clock, since none of those describe a refusal. It reuses the
existing red treatment rather than introducing anything new.
On the wording
The banner says:
with a Reload button. The copy deliberately does not name a cause. The flag covers 401 and 403
alike, and a 403 here can equally mean "this device is not allowlisted", "host not allowed" or
"cross-origin rejected", so naming any one of them would be wrong more often than right. What was
missing was the single fact the old behaviour hid: this is not the network. Reload is the useful
action either way, because it is what lets a fronting proxy serve its sign-in page.
Scope
The service worker answers every non-
/api/navigation from the precache, so a proxy's sign-in pageat
/is still unreachable from an installed PWA. That is a separate question about navigationrouting, and I have raised it as an issue rather than guessing at it here.
authErroris a required field rather than an optional one, so every construction site has to stateit. That is why a few unrelated test fixtures gained a line.
Tests
cd web && bun run test: 910 passing before, 917 after.bun run typecheckclean.New coverage: 401 and 403 set the flag, a network error and a timeout do not,
AbortErrorstillrethrows, the flag survives the latched navigation fast path, and the banner renders the refusal
state without any of the connection treatment (no spinner, no Retry, and no
/api/configprobe).The web suite has 17 pre-existing failures on my machine, in
use-display-prefs.test.tsandagent-chat.test.tsx, which reproduce on an unmodifiedmainhere and look environmental(
localStorage is not available because --localstorage-file was not provided). Untouched by thisPR, and CI is green on
main, so I assume they are local to my setup.