fix(02-12.1): give an unprovable record context a way out

A selector pointed at a record whose grant has lapsed reports the same
disagreement on every /api/auth/me, so routing it to the hydration gate
rendered a permanent spinner with no controls. Expiry reaches this state
on its own: revocation clears the selector inside its transaction and
expiry does not.

- treat an unprovable context as a refusal, which paints the door with
  the button back to one's own record
- check the refusal ahead of the hydration gate, so neither trigger can
  render behind a spinner again
This commit is contained in:
Marc-André Bombeck committed 2026-08-06 20:40:31 +02:00
1 parent ffe38c9bb6
commit 0fee005683
4 files changed
+111 -21

No files matched your search

@@ -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("<AuthShell> — 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("<RecordScopeHydrationGate");
// Non-zero proof: a renamed branch must fail here rather than agree with
// two -1s.
expect(refusal).toBeGreaterThan(-1);
expect(gate).toBeGreaterThan(-1);
expect(refusal).toBeLessThan(gate);
});
it("gives the refusal door an actual way out", () => {
// 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\)/);
});
});
+30 -17
View File
@@ -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 (
<div
data-slot="invalid-record-access-refusal"
className="flex min-h-dvh flex-col"
>
<MaintainershipBanner />
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
<SharedRecordUnavailable />
</main>
</div>
);
}
// 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 (
<div
data-slot="invalid-record-access-refusal"
className="flex min-h-dvh flex-col"
>
<MaintainershipBanner />
<main className="mx-auto w-full max-w-screen-xl px-4 py-10 md:px-6">
<SharedRecordUnavailable />
</main>
</div>
);
}
// Onboarding page — minimal shell, no sidebar/nav
if (isOnboardingPage) {
return (