fix(web): auth token gate — synthetic 401 on missing token, unauthenticated boot state, and recovery screen (#2740)

When `gsd --web` is opened without the #token= hash fragment (manual URL
entry, bookmark, new tab), `authenticatedFetch` previously fell through to
a naked `fetch()` that always returned 401, flooding the console with
cascading errors and leaving the UI in a broken state with no recovery path.

Three changes:

1. `web/lib/auth.ts` — `authFetch()` now returns a synthetic 401 Response
   when `getAuthToken()` returns null instead of delegating to bare fetch.
   This makes missing-token failures consistent and immediately catchable
   by all callers without a network round-trip.

2. `web/lib/gsd-workspace-store.tsx` — Added `"unauthenticated"` to
   `WorkspaceStatus`. `refreshBoot()` now detects a 401 response from
   /api/boot and patches `bootStatus` to `"unauthenticated"` instead of
   throwing a generic error. This is a distinct state — not an error worth
   retrying, but a configuration problem the user must resolve.

3. `web/components/gsd/app-shell.tsx` — Added an early-return guard that
   renders a minimal "Authentication Required" screen when
   `bootStatus === "unauthenticated"`. The screen explains the problem and
   tells users to copy the full terminal URL (including `#token=…`) or
   restart with `gsd --web`.

Fixes #2731
This commit is contained in:
Andrew 2026-03-26 18:17:12 -04:00 committed by GitHub
parent 07d804588e
commit 6cc6c36a69
3 changed files with 54 additions and 2 deletions

View file

@ -235,6 +235,41 @@ function WorkspaceChrome() {
detection.kind !== "active-gsd" &&
detection.kind !== "empty-gsd"
// --- Unauthenticated gate ---
// Render a clear recovery screen before any workspace chrome is mounted so
// users who open a manually-typed URL (no #token= fragment) get actionable
// guidance instead of a cascade of 401 errors.
if (workspace.bootStatus === "unauthenticated") {
return (
<div className="flex h-dvh flex-col items-center justify-center gap-6 bg-background p-8 text-center">
<Image
src="/logo-black.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto dark:hidden"
/>
<Image
src="/logo-white.svg"
alt="GSD"
width={57}
height={16}
className="shrink-0 h-4 w-auto hidden dark:block"
/>
<div className="flex flex-col items-center gap-2">
<h1 className="text-lg font-semibold text-foreground">Authentication Required</h1>
<p className="max-w-sm text-sm text-muted-foreground">
This workspace requires an auth token. Copy the full URL from your terminal
(including the{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">#token=</code>{" "}
part) or restart with{" "}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-xs">gsd --web</code>.
</p>
</div>
</div>
)
}
return (
<div className="relative flex h-screen flex-col overflow-hidden bg-background text-foreground">
<header className="flex h-12 flex-shrink-0 items-center justify-between border-b border-border bg-card px-2 md:px-4">

View file

@ -81,10 +81,20 @@ export function authHeaders(extra?: Record<string, string>): Record<string, stri
/**
* Wrapper around `fetch()` that automatically injects the auth token.
*
* When no token is available (missing `#token=` fragment and no sessionStorage
* entry), returns a synthetic 401 Response instead of making an unauthenticated
* request that will fail server-side anyway. This lets callers handle the
* missing-token case uniformly rather than silently cascading 401s.
*/
export async function authFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {
const token = getAuthToken()
if (!token) return fetch(input, init)
if (!token) {
return new Response(JSON.stringify({ error: "No auth token available" }), {
status: 401,
headers: { "Content-Type": "application/json" },
})
}
const headers = new Headers(init?.headers)
if (!headers.has("Authorization")) {

View file

@ -66,7 +66,7 @@ import type {
} from "./session-browser-contract"
import { authFetch, appendAuthParam } from "./auth"
export type WorkspaceStatus = "idle" | "loading" | "ready" | "error"
export type WorkspaceStatus = "idle" | "loading" | "ready" | "error" | "unauthenticated"
export type WorkspaceConnectionState =
| "idle"
| "connecting"
@ -4135,6 +4135,13 @@ export class GSDWorkspaceStore {
})
if (!response.ok) {
if (response.status === 401) {
this.patchState({
bootStatus: "unauthenticated",
connectionState: "error",
})
return
}
throw new Error(`Boot request failed with ${response.status}`)
}