diff --git a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx index 44cee5e3d..ba87832d5 100644 --- a/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx +++ b/src/components/layout/__tests__/auth-shell-shared-record-doors.test.tsx @@ -39,6 +39,8 @@ * "removed". */ import { describe, expect, it, vi } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; @@ -290,3 +292,48 @@ describe(" — the owner-only doors", () => { expect(html).not.toContain("sentinel-unavailable"); }); }); + +/** + * v1.37.0 — a refusal must never render behind the hydration gate. + * + * The two branches look interchangeable and are not. `RecordScopeHydrationGate` + * is a bare spinner with no controls, which is correct for a transition that + * ends on its own. A refusal does not end on its own — `/api/auth/me` reports + * the same disagreement on every boot — so behind the gate it renders as a + * permanent "Loading…" with no way out, which is what an audit found on the + * grant-expiry path. + * + * Asserted on source order rather than by rendering, because the shell's + * branches are early returns: whichever `if` comes first wins, and that is + * exactly the fact worth freezing. Break it by moving the `accessRefused` + * block back below the gate. + */ +describe("the refusal door is reachable", () => { + const SHELL = readFileSync( + join(process.cwd(), "src/components/layout/auth-shell.tsx"), + "utf8", + ); + + it("checks accessRefused before the hydration gate", () => { + const refusal = SHELL.indexOf("if (accessRefused) {"); + const gate = SHELL.indexOf(" { + // A door with no handle is the same wedge wearing different paint. + const door = readFileSync( + join( + process.cwd(), + "src/components/layout/shared-record-unavailable.tsx", + ), + "utf8", + ); + expect(door).toContain("shared-record-unavailable-leave"); + expect(door).toMatch(/switchAccount\.mutate\(null\)/); + }); +}); diff --git a/src/components/layout/auth-shell.tsx b/src/components/layout/auth-shell.tsx index 968e7733c..aeb9bddba 100644 --- a/src/components/layout/auth-shell.tsx +++ b/src/components/layout/auth-shell.tsx @@ -234,6 +234,36 @@ export function AuthShell({ ); } + // A refused record context comes FIRST, ahead of the hydration gate. + // + // Both used to be checked in the other order, and it mattered: the gate is a + // bare spinner with no controls, which is right for a transition that ends on + // its own and wrong for a state that does not. A refusal — a malformed access + // block, or a selector pointed at a record whose grant has lapsed — reports + // the same thing on every `/api/auth/me`, so behind the gate it renders as a + // permanent "Loading…" with no way out. Ahead of it, the same state renders + // the refusal door, which carries the button back to one's own record. + // + // Safe against the loading race by construction: every input to + // `accessRefused` comes from a resolved account payload, so it cannot be true + // while `isLoading` is. + // A present but malformed access block is not the older-server case. The + // server may still be switched, so mounting children here could fetch target + // data under owner controls. Offer only the actor-scoped way out. + if (accessRefused) { + return ( +
+ +
+ +
+
+ ); + } + // Every protected route waits for `/api/auth/me`, not only Settings and // Admin. The payload resolves the active record as well as identity, so a // child mounted before it could issue an actor-scoped read before the shell @@ -257,23 +287,6 @@ export function AuthShell({ ); } - // A present but malformed access block is not the older-server case. The - // server may still be switched, so mounting children here could fetch target - // data under owner controls. Offer only the actor-scoped way out. - if (accessRefused) { - return ( -
- -
- -
-
- ); - } - // Onboarding page — minimal shell, no sidebar/nav if (isOnboardingPage) { return ( diff --git a/src/hooks/__tests__/use-record-capabilities.test.ts b/src/hooks/__tests__/use-record-capabilities.test.ts index 4b59e2648..256e3c487 100644 --- a/src/hooks/__tests__/use-record-capabilities.test.ts +++ b/src/hooks/__tests__/use-record-capabilities.test.ts @@ -179,7 +179,30 @@ describe("an unprovable record context withholds every control", () => { const held = resolveRecordCapabilities(null, false, false, true); expect(held.canAdd).toBe(false); expect(held.canManage).toBe(false); - expect(held.recordSessionPending).toBe(true); + // REFUSED, not pending. `recordSessionPending` renders a bare spinner with + // no controls, which is right for a switch in flight and a wedge here: this + // state reports the same thing on every `/api/auth/me`, so it never ends on + // its own. `accessRefused` renders the door with the way back out. + expect(held.accessRefused).toBe(true); + expect(held.recordSessionPending).toBeUndefined(); + }); + + it("routes an EXPIRED grant to the refusal door, not the spinner", () => { + // The second trigger, and the one that arrives without anybody doing + // anything: revocation clears the selector inside its transaction, expiry + // does NOT. So the session stays pointed at a record whose grant has + // lapsed, `/api/auth/me` resolves `active: null` because the entry no + // longer survives the live-grant pass, and `recordSession.scope` still + // names the owner. Every delegable read 403s from here. + const expired = recordContextIsUnproven( + { epoch: 3, scope: "acct-owner" }, + null, + ); + expect(expired).toBe(true); + const capabilities = resolveRecordCapabilities(null, false, false, expired); + expect(capabilities.accessRefused).toBe(true); + expect(capabilities.recordSessionPending).toBeUndefined(); + expect(capabilities.inSharedRecord).toBe(true); }); it("holds when the resolved entry names a record the selector has left", () => { diff --git a/src/hooks/use-record-capabilities.ts b/src/hooks/use-record-capabilities.ts index f1991e3df..ddfdbcf90 100644 --- a/src/hooks/use-record-capabilities.ts +++ b/src/hooks/use-record-capabilities.ts @@ -122,9 +122,16 @@ export function resolveRecordCapabilities( ): RecordCapabilities { if (accessRefused || recordSessionPending || contextUnproven) { return { - accessRefused: accessRefused || undefined, - recordSessionPending: - recordSessionPending || contextUnproven || undefined, + // An unprovable context is a REFUSAL, not a transient hold, and the + // difference is whether there is a way out. `recordSessionPending` + // renders `RecordScopeHydrationGate` — a bare spinner with no controls, + // correct while a switch is genuinely in flight because it ends on its + // own. This state does not end on its own: `/api/auth/me` reports the + // same disagreement on every boot, so a spinner here is a wedge with no + // exit. `accessRefused` renders `SharedRecordUnavailable`, which carries + // a "leave this record" button that posts `switch(null)`. + accessRefused: accessRefused || contextUnproven || undefined, + recordSessionPending: recordSessionPending || undefined, inSharedRecord: true, canWrite: false, canAdd: false,