Skip to content

fix(web): tell an access refusal apart from an outage - #30

Merged
AltanS merged 1 commit into
AltanS:mainfrom
Optic00:fix/auth-vs-offline
Jul 26, 2026
Merged

fix(web): tell an access refusal apart from an outage#30
AltanS merged 1 commit into
AltanS:mainfrom
Optic00:fix/auth-vs-offline

Conversation

@Optic00

@Optic00 Optic00 commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

The problem

In web/src/lib/loaders.ts every 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 arrive, 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 rootLoader'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: 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 checkAccess and guard, for host not allowed,
cross-origin rejected and device not authorised. A misconfigured COLLIE_PUBLIC_HOSTS, or a
device that is not on the allowlist, presents identically.

The change

The loaders classify the failure. A 401 or 403 sets a new authError flag alongside the existing
error flag; everything else is unchanged. ApiError already carried status and it was simply
never 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/config probe, no
reconnect 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:

Access refused. This is not a connection problem.

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 page
at / is still unreachable from an installed PWA. That is a separate question about navigation
routing, and I have raised it as an issue rather than guessing at it here.

authError is a required field rather than an optional one, so every construction site has to state
it. That is why a few unrelated test fixtures gained a line.

Tests

cd web && bun run test: 910 passing before, 917 after. bun run typecheck clean.

New coverage: 401 and 403 set the flag, a network error and a timeout do not, AbortError still
rethrows, 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/config probe).

The web suite has 17 pre-existing failures on my machine, in use-display-prefs.test.ts and
agent-chat.test.tsx, which reproduce on an unmodified main here and look environmental
(localStorage is not available because --localstorage-file was not provided). Untouched by this
PR, and CI is green on main, so I assume they are local to my setup.

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.
@AltanS

AltanS commented Jul 26, 2026

Copy link
Copy Markdown
Owner

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 ApiError.status, which was already carried and never read, kept this to one predicate and no new plumbing. Storing the flag in a module-scoped per-session set matches how lastSnapshot and lastPaneText already work, so it fits rather than introducing a new mechanism. Splitting ConnectionBanner into a refusal branch and the state machine also keeps the hooks out of the conditional path, which is the correct shape.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants