fix(auth): close what the epic's own review found (E9.5 review round 1)
Eleven findings survived adversarial verification; these are the fixes.
**Address pre-emption.** S1 dropped the approval-time password revoke, arguing `password_login`
refuses an unverified account before it reads `is_active`. Two verifiers showed that holds only
while the address stays unverified — and S6's approval mail exists to get the real owner to end that
state, at which point the squatter's password works. While an address is unverified the NEWEST
registration now owns it: the password is replaced and every outstanding verify token deleted, so
the owner takes the row back simply by registering, and a link already in flight cannot confirm it
for anyone else. The residual (an owner who confirms and neither registers nor resets) is logged as
SEC-16 beside SEC-15, where the re-attestation path it needs already lives. The comments that
asserted the old, false invariant now say what actually guards this.
**The entry URL.** S2 moved `clearEntryUrl()` out of the signed-out auth card into a hook App mounts
unconditionally, and it blanket-stripped the whole query string, the fragment and `history.state` on
the FIRST commit. That broke first-run setup outright — the wizard mounts only after its query
resolves and then finds no `?token=` — and silently ate `?link=`/`?oauth=` (no toast, no refetch
after connecting Google), `?v=`/`?chan=` deep links, and scroll restoration. It now clears only the
fragment and the two one-time flags it owns, and preserves `history.state`.
**The env allow-list.** S3 canonicalised the Invite branch of `is_allowed` and left the
ALLOWED_EMAILS/ADMIN_EMAILS fallback matching literally — the 2026-09-03 lockout with a different
source of truth, plus a shadowing trick where an unauthenticated request under a sibling spelling
permanently masked an env grant. Canonical on both sides now, and the same for the delete dialog's
warning and the ADMIN_EMAILS role bootstrap.
**Honest states.** `send_invitation` stamped "I already wrote to them" before a fail-soft background
send; it sends inline and records only what left. The admin-side resend answered `{ok: true}` for a
dropped mail; it now says so. Both also activate/serialize consistently with the other decide paths.
**Correctness sweep.** The 7-day approval link said "expires in 168 hours" — durations render in
days once there is more than one. `_register_account`, `upsert_pending_invite` and `add_invite`
picked among canonical siblings with no ORDER BY and no exact-spelling tie-break, so which account
got the re-send, and whether a denied row was reopened over a pending one, was up to Postgres.
`purge_user` deleted every canonically-equal invite, revoking a surviving sibling account's access.
`ConfirmProvider` wrapped its ReactNode message in a `<p>`. Approving no longer leaves the
stuck-sign-ups list stale. The refusal panels announce and take focus.
**Tests.** Three mailers rewritten this epic never rendered in any test; every `send_*` now renders
in both languages. The orphan-key guard could not fail (it scanned the catalog that defines the
keys) and had no reverse direction. Newly pinned: canonical activation, the surviving sibling's
grant, a failed invitation and resend, an expired reset link, a throttled login's silence, the
per-language admin fan-out, and that the entry URL keeps what it does not own.
This commit is contained in:
@@ -17,12 +17,34 @@
|
||||
export const entrySearch = new URLSearchParams(window.location.search);
|
||||
export const entryHash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
|
||||
|
||||
/** Strip the query string and fragment from the address bar, so a one-time token doesn't linger in
|
||||
* history or get re-sent on a reload. Everything worth keeping is already in the snapshots above.
|
||||
* ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a second caller would be
|
||||
* harmless but makes it unclear who is responsible. */
|
||||
/** One-time QUERY flags: safe to drop once the snapshot above has them, because re-reading them on a
|
||||
* reload would re-show a notice about something that already happened (`access` only appears here
|
||||
* in its pre-S4 shape — it rides the fragment now). Deliberately NOT `link`,
|
||||
* `oauth`, `admin` or the setup `token` — those have their own readers, and some of them (the setup
|
||||
* wizard, the lazily-mounted module views) do not run until several commits later. */
|
||||
const ONE_TIME_QUERY = ["login", "deleted", "access"] as const;
|
||||
|
||||
/** Strip the one-time entry state from the address bar, so a token doesn't linger in history or get
|
||||
* re-sent on a reload. ONE owner calls this (useVerifyEntry, which App mounts unconditionally) — a
|
||||
* second caller would be harmless but makes it unclear who is responsible.
|
||||
*
|
||||
* SURGICAL, and that is the whole of it. This used to blank the entire query string, the fragment
|
||||
* and `history.state`, which was safe only while it lived inside the signed-out auth card; moving it
|
||||
* up to App (E9.5 S2) pointed it at every load. It then ran on the FIRST commit — before the setup
|
||||
* wizard mounts behind its own `?token=`, before App's own `?link=`/`?oauth=` effects read theirs,
|
||||
* and before a lazily-loaded module view reads its `?v=`/`?chan=` deep link — and took
|
||||
* `history.state` (scroll restoration, the channel entry) with it. Everything not listed above is
|
||||
* somebody else's to clear, and `clearParams` is how they do it. */
|
||||
export function clearEntryUrl(): void {
|
||||
if (window.location.search || window.location.hash) {
|
||||
window.history.replaceState(null, "", window.location.pathname);
|
||||
}
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const dropped = ONE_TIME_QUERY.filter((k) => params.has(k));
|
||||
for (const k of dropped) params.delete(k);
|
||||
if (!dropped.length && !window.location.hash) return;
|
||||
const qs = params.toString();
|
||||
window.history.replaceState(
|
||||
// Preserved, not nulled: it carries scroll restoration and the channel entry.
|
||||
window.history.state,
|
||||
"",
|
||||
`${window.location.pathname}${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@ afterEach(() => {
|
||||
|
||||
/** Mount the hook at a given entry URL with a stubbed /auth/verify, and hand back what it reported. */
|
||||
async function mountAt(url: string, verifyEmail: ReturnType<typeof vi.fn>) {
|
||||
window.history.replaceState(null, "", url);
|
||||
// Carries whatever state the case set up: history.state is part of what the hook must not destroy.
|
||||
window.history.replaceState(window.history.state, "", url);
|
||||
vi.resetModules();
|
||||
vi.doMock("./api", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./api")>();
|
||||
@@ -84,6 +85,27 @@ describe("confirming an emailed verification link", () => {
|
||||
expect(window.location.search).toBe("");
|
||||
expect(window.location.hash).toBe("");
|
||||
});
|
||||
|
||||
// This hook runs on the FIRST commit of App, which is several commits before the setup wizard, the
|
||||
// ?link=/?oauth= effects and the lazily-loaded module views read their own parameters. It used to
|
||||
// blank the whole query and null history.state, which silently broke first-run setup (the wizard
|
||||
// found no ?token=) and every signed-in deep link. It must only clear what it owns.
|
||||
it("leaves parameters it does not own, and keeps history.state", async () => {
|
||||
window.history.replaceState(
|
||||
{ sfScroll: 42 },
|
||||
"",
|
||||
"/?token=setup-1&page=plex&v=show:9&login=out",
|
||||
);
|
||||
|
||||
await mountAt(window.location.href, vi.fn());
|
||||
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
expect(params.get("token")).toBe("setup-1");
|
||||
expect(params.get("page")).toBe("plex");
|
||||
expect(params.get("v")).toBe("show:9");
|
||||
expect(params.get("login")).toBeNull(); // a one-time notice, ours to drop
|
||||
expect(window.history.state).toEqual({ sfScroll: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
// The link does not always arrive as a page LOAD. Found in UAT on 2026-09-03: navigating a tab that
|
||||
|
||||
Reference in New Issue
Block a user