fix(api): setup 链接在同一事务里消费并建会话,存储故障回 500 且链接仍可重试

This commit is contained in:
Lemon-miaow committed 2026-09-27 13:55:56 +08:00
1 parent 26d997cb40
commit 1124413876
13 files changed
+312 -47

No files matched your search

+3
View File
@@ -42,6 +42,9 @@
"setup_invalid_subtitle": "This setup link is invalid or has already been used",
"setup_invalid_hint_prefix": "Re-run ",
"setup_invalid_hint_suffix": " on the server to get a fresh setup link, or head to the sign-in page.",
"setup_failed_title": "Setup didn't start",
"setup_failed_subtitle": "The server couldn't sign you in just now. The setup link is kept, so you can try again.",
"setup_retry": "Try again",
"setup_goto_login": "Go to sign in",
"setup_email_step": "Step 1 · Add your email",
"setup_email_desc": "We'll save this address for your account. You can configure email delivery (SMTP) and verify it later from Settings — it isn't required to finish setup.",
+3
View File
@@ -42,6 +42,9 @@
"setup_invalid_subtitle": "这个设置链接无效或已被使用",
"setup_invalid_hint_prefix": "请在服务器上重新运行 ",
"setup_invalid_hint_suffix": " 获取新的设置链接,或直接前往登录页。",
"setup_failed_title": "设置没能开始",
"setup_failed_subtitle": "服务器暂时没能让你登录。设置链接仍然有效,可以再试一次。",
"setup_retry": "重试",
"setup_goto_login": "前往登录",
"setup_email_step": "第一步 · 填写邮箱",
"setup_email_desc": "我们会为你的账户保存这个邮箱地址。发信服务(SMTP)和邮箱验证可稍后在设置中配置——完成初始化并不需要它。",
+2 -1
View File
@@ -964,7 +964,7 @@ export interface paths {
put?: never;
/**
* Redeem a one-time setup token into a lockdown session (spec §B).
* @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled.
* @description Public, pre-session first-run door: consumes the one-time setup token minted by the felis TUI (stored and looked up by SHA-256 hash, like session cookies), mints a host-only felis_session, and returns the remaining setup steps so the SPA can drive the wizard. An unknown, consumed, or expired token returns a uniform 400 setup_token_invalid. Gated on local_auth_enabled. The token is spent in the same transaction that stores the session, so a redemption that fails with 500 leaves the link working for another try.
*/
post: operations["setupRedeem"];
delete?: never;
@@ -5345,6 +5345,7 @@ export interface operations {
};
};
429: components["responses"]["RateLimited"];
500: components["responses"]["InternalError"];
};
};
setupStatus: {
+78
View File
@@ -0,0 +1,78 @@
// @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import i18next from "i18next";
import { Setup } from "./Setup";
const calls = vi.hoisted(() => ({
setupRedeem: vi.fn(),
setupStatus: vi.fn(),
refresh: vi.fn(),
}));
vi.mock("@/lib/tier", () => ({
useTier: () => ({ loading: false, identity: null, refresh: calls.refresh }),
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
api: { ...actual.api, setupRedeem: calls.setupRedeem, setupStatus: calls.setupStatus },
};
});
const t = (key: string, opts?: Record<string, unknown>) => i18next.t(key, opts);
const fresh = {
user_id: "o1",
username: "owner",
role: "owner",
email: "",
email_verified: false,
has_passkey: false,
setup_required: true,
};
function renderSetup() {
return render(
<MemoryRouter initialEntries={["/setup?token=raw-token"]}>
<Setup />
</MemoryRouter>,
);
}
beforeEach(() => {
for (const fn of Object.values(calls)) fn.mockReset();
// No session survives a failed redeem.
calls.setupStatus.mockRejectedValue({ status: 401, code: "unauthenticated", message: "" });
});
describe("Setup", () => {
it.each([
[500, "internal"],
[503, "service_unavailable"],
[429, "rate_limited"],
[0, "network_error"],
])("offers another try with the same link after a %i %s", async (status, code) => {
calls.setupRedeem.mockRejectedValueOnce({ status, code, message: "" });
calls.setupRedeem.mockResolvedValueOnce(fresh);
renderSetup();
expect(await screen.findByText(t("auth:setup_failed_title"))).toBeTruthy();
expect(screen.queryByText(t("auth:setup_invalid_subtitle"))).toBeNull();
await userEvent.click(screen.getByRole("button", { name: t("auth:setup_retry") }));
expect(await screen.findByText(t("auth:setup_welcome", { name: "owner" }))).toBeTruthy();
expect(calls.setupRedeem.mock.calls).toEqual([["raw-token"], ["raw-token"]]);
});
it("sends a spent link back to felis setup, with nothing to retry", async () => {
calls.setupRedeem.mockRejectedValue({ status: 400, code: "setup_token_invalid", message: "" });
renderSetup();
expect(await screen.findByText(t("auth:setup_invalid_subtitle"))).toBeTruthy();
expect(await screen.findByText(t("errors:setup_token_invalid"))).toBeTruthy();
expect(screen.queryByRole("button", { name: t("auth:setup_retry") })).toBeNull();
});
});
+36 -4
View File
@@ -8,6 +8,7 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { api, clientError, humanizeError, type SetupState } from "@/lib/api";
import type { ApiError } from "@/lib/types";
import { base64urlToBytes, bytesToBase64url } from "@/lib/utils";
import { useTier } from "@/lib/tier";
import { InlineError } from "@/components/MessageLine";
@@ -27,6 +28,15 @@ import { InlineError } from "@/components/MessageLine";
// a spent token. The step endpoints and /me are all SetupAllowed, so the lockdown
// session can complete the wizard; the backend lifts the lockdown once a passkey is
// enrolled, and we hand off to / once nothing remains.
// retryable tells a failure worth another try with the same link (the server or
// the network failed, or asked to slow down) from a link that cannot work. The
// redeem spends the token only together with the session it mints, so a failed
// try leaves the link as it was.
function retryable(e: unknown): boolean {
const status = (e as Partial<ApiError> | undefined)?.status;
return status === 0 || status === 429 || (typeof status === "number" && status >= 500);
}
export function Setup() {
const [params] = useSearchParams();
const navigate = useNavigate();
@@ -35,7 +45,8 @@ export function Setup() {
const [state, setState] = useState<SetupState | null>(null);
const [booting, setBooting] = useState(true);
const [fatal, setFatal] = useState<string | null>(null);
const [fatal, setFatal] = useState<{ message: string; retryable: boolean } | null>(null);
const [attempt, setAttempt] = useState(0);
const finishing = useRef(false);
// Boot: redeem the URL token, or resume from the session if the token is already
@@ -63,7 +74,7 @@ export function Setup() {
}
if (alive) setState(st);
} catch (e) {
if (alive) setFatal(humanizeError(e));
if (alive) setFatal({ message: humanizeError(e), retryable: retryable(e) });
} finally {
if (alive) setBooting(false);
}
@@ -71,7 +82,13 @@ export function Setup() {
return () => {
alive = false;
};
}, [params]);
}, [params, attempt]);
const retry = () => {
setFatal(null);
setBooting(true);
setAttempt((n) => n + 1);
};
// reload re-reads progress after a wizard step so the view advances to the next.
const reload = useCallback(async () => {
@@ -103,12 +120,27 @@ export function Setup() {
);
}
if (fatal?.retryable) {
return (
<AuthLayout title={t("setup_failed_title")} subtitle={t("setup_failed_subtitle")}>
<Card>
<CardContent className="space-y-4 pt-6 text-sm">
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
<Button className="w-full" onClick={retry}>
{t("setup_retry")}
</Button>
</CardContent>
</Card>
</AuthLayout>
);
}
if (fatal) {
return (
<AuthLayout title={t("setup_invalid_title")} subtitle={t("setup_invalid_subtitle")}>
<Card>
<CardContent className="space-y-4 pt-6 text-sm">
<p role="alert" className="text-muted-foreground">{fatal}</p>
<p role="alert" className="text-muted-foreground">{fatal.message}</p>
<p className="text-muted-foreground">
{t("setup_invalid_hint_prefix")}
<code className="rounded bg-muted px-1.5 py-0.5 font-mono text-xs text-foreground">