Files
siftlode/frontend/src/lib/useVerifyEntry.ts
T
peter fe38dfbcca fix(e12): close the eighteen findings the E12 review round left standing
The deep review of the branch raised 47 findings, 18 of which survived adversarial
verification. Grouped by what they actually were:

**The size ceiling inverted at its boundary.** `remaining_bytes` clamped to 0 for a user
already at their storage cap, and `build_ydl_opts` reads 0 as "no limit" — so the one
user over quota was the only one downloading with no ceiling at all. `check_enqueue`
could not catch it: it ran while the job was queued and the user was still under the cap.
The arithmetic moves into `quota.remaining_bytes` (where every other max_bytes decision
lives, and where it can be tested), and the worker now fails the job outright rather than
proceeding unbounded — as a non-retryable `quota_exceeded`, since retrying cannot help
until space is freed.

**Silent failures in the new account panel.** All three actions went through `quiet: true`
endpoints, which suppress the global error dialog on the promise that the caller reports
inline — with no `onError`, that promise was broken and a refusal rendered as nothing. On
the broken-SMTP instance this panel exists for, an admin would read silence as "the mail
went out". The co-invalidation set and the email-off predicate move to `lib/adminUsers`,
shared with the pending-access queue, so the two copies of these mutations cannot drift
apart again — which is how half of this was lost in the first place.

**A rollback recipe that destroyed data without working.** `alembic downgrade -1` steps
back one migration, but a release routinely ships several (0.80.0 shipped four): it drops
the newest one's columns and leaves the schema at a revision the older image also cannot
resolve, i.e. the crash loop it was meant to escape, minus the data. The dump restore is
now the recommended path, and the alembic route asks the target version for its own head
revision instead of counting backwards.

Also: redirects named in the urlguard docstring beside the DNS-rebinding residual they
share a fix with (SEC-17); a malformed port refused as `bad_port` instead of escaping as
a 500; the pull cooldown extended to the sibling route spending the same QuotaAction;
restore stops the app before dropping its tables; the PowerShell cleanup can no longer
decide the script's verdict on 5.1, nor report the wrong exit code; the documented
one-liners hardened to match the scripts; the registry's retention window stated where
pinning is advertised; the two banners made mutually exclusive so neither can shadow the
other; `hover:bg-hover` (a utility this project never generated) replaced by the
`selectable` recipe; the clipboard helper reused; and the sync cooldown's copy swept to
the two callers that discarded it.

Verified: gate green (vitest 678, pytest 949), and the panel, the row hover and the reset
banner UAT'd on localdev.
2026-09-05 03:41:58 +02:00

121 lines
6.3 KiB
TypeScript

import { useEffect, useRef, useState } from "react";
import { api } from "./api";
import { HttpError } from "./api/core";
import { clearEntryUrl, entryHash } from "./entryUrl";
/** What confirming the emailed token produced. `ok_reset` means the address is confirmed but this
* account's password had been revoked (SEC-10), so signing in would only meet the uniform credentials
* error — the user has to set a new one. `unreachable` is a TRANSPORT failure, kept apart from
* `invalid` on purpose (DX-16): the fragment is already cleared by the time we know, so telling
* someone their link is spent when the server was merely restarting sends them to request a fresh one
* on the strength of a false statement. The same rule is already written one file away, for the
* reset-link probe: "a network blip must never be cached as 'this link is dead'". */
type VerifyOutcome = "ok" | "ok_reset" | "invalid" | "unreachable";
export type VerifyEntry = {
outcome: VerifyOutcome | null;
/** The address that was confirmed, when the server named it. */
email: string | null;
/** A `#reset=` link opened in a tab that already holds a session. Only the signed-OUT auth card
* consumes a reset token, so nothing here can act on it — but silence is what made the verify half
* a bug, so this says out loud that the link needs a signed-out window. The fragment is deliberately
* left in the address bar (E9.5 review round 4), so signing out still recovers it. */
resetPending: boolean;
/** Dismiss the outcome — the banner is a one-time entry notice, not persistent state. */
dismiss: () => void;
};
/**
* Confirm a `#verify=` token from the entry URL, wherever the user opened it.
*
* App calls this ABOVE its signed-in/signed-out branch, which is the whole point: the token used to be
* consumed inside Welcome.tsx, and `App.tsx` renders <Welcome /> only when there is no session. Open
* the emailed link in a browser that already holds a Siftlode session and the token was never posted,
* the fragment was then stripped, and the user got no signal at all — the link simply did nothing.
* Incognito worked, which is why it survived so long.
*
* The link does not always arrive as a page LOAD. When the app is already open in the tab and the URL
* differs only in the fragment, the browser performs a same-document navigation — no reload, so the
* import-time snapshot never sees the new token. Pasting the emailed link into a running tab's address
* bar does exactly that, and our verification mails were plain text until 0.78.8, which makes pasting
* the normal way to use one. Without the `hashchange` listener below the token would sit unconsumed in
* the address bar: the same silence signed-in users met before, in a second disguise.
*
* Each token is posted exactly once. It is single-use, so a repeat POST (a StrictMode remount, any
* re-render) would 400 and wrongly flip a successful confirmation to "invalid".
*/
export function useVerifyEntry(): VerifyEntry {
const [token, setToken] = useState<string | null>(() => entryHash.get("verify"));
const [outcome, setOutcome] = useState<VerifyOutcome | null>(null);
const [email, setEmail] = useState<string | null>(null);
const [resetPending, setResetPending] = useState<boolean>(() => !!entryHash.get("reset"));
const consumed = useRef(new Set<string>());
useEffect(() => {
const onHashChange = () => {
const hash = new URLSearchParams(window.location.hash.replace(/^#/, ""));
const next = hash.get("verify");
if (next) setToken(next);
// Both halves of the mailed pair arrive the same way — pasted into a running tab, which is a
// same-document navigation the import-time snapshot never sees. The verify half got this
// listener in E9.5 S2; the reset half was left out, so it stayed silent (DX-17).
if (hash.get("reset")) {
setResetPending(true);
// Same rule as a second verify link below: a newly pasted link must not be shadowed by a
// banner still standing from the previous one. Without this the reset guidance is
// unreachable whenever an unread verify outcome is on screen, and `dismiss` then clears the
// pending flag along with the outcome — the DX-17 silence, one state later.
setOutcome(null);
setEmail(null);
}
};
// replaceState (how clearEntryUrl strips the fragment) does NOT fire hashchange, so this cannot
// feed itself.
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
}, []);
useEffect(() => {
// Clear the address bar even with no token to consume, so a spent one leaves no trace on a
// reload. An unspent `#reset=` is the exception and `clearEntryUrl` says why: nothing in a
// signed-in tree reads it, so erasing it there is a dead end rather than a cleanup.
clearEntryUrl();
if (!token || consumed.current.has(token)) return;
consumed.current.add(token);
// A second link arriving over an unread first outcome must not leave the OLD banner standing —
// whichever kind that outcome was. Clearing both keeps the two states mutually exclusive, which
// is what lets the single `dismiss` below clear "whatever is showing" without eating the other.
setOutcome(null);
setEmail(null);
setResetPending(false);
api
.verifyEmail(token)
.then((r) => {
setOutcome(r.password_reset_required ? "ok_reset" : "ok");
setEmail(r.email ?? null);
})
// Only the server's own verdict on the token means "invalid". `/auth/verify` refuses a spent
// or expired token with a 400 carrying {code:"verify"}; a 502 during a deploy, or a dropped
// connection (no HttpError at all), says nothing about the token and must not be reported as
// if it did. The token itself is unspent in that case — but the fragment has already been
// cleared, so the honest message is "we could not reach the server", not "request a new link".
.catch((e: unknown) => {
const spent =
e instanceof HttpError &&
e.status === 400 &&
(e.detailData as { code?: string } | undefined)?.code === "verify";
setOutcome(spent ? "invalid" : "unreachable");
});
}, [token]);
return {
outcome,
email,
resetPending,
dismiss: () => {
setOutcome(null);
setResetPending(false);
},
};
}