diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7cc1841f9..bb1072f7d 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1809,31 +1809,6 @@ paths: $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 "429": *a5 - /api/auth/check-user: - post: - tags: - - Auth - summary: Resolve the next sign-in step for a typed identifier - description: Given an email or username, returns which onboarding branch the iOS client should render plus the - `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a - second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same - whether or not the identifier matched, and the identifier is never echoed back. - requestBody: - required: true - content: - application/json: - schema: - $ref: "#/components/schemas/CheckUserRequest" - responses: - "200": - description: Discovery result. - content: - application/json: - schema: - $ref: "#/components/schemas/CheckUserEnvelope" - "401": *a1 - "422": *a4 - "429": *a5 /api/auth/login: post: security: [] @@ -21129,17 +21104,6 @@ components: server-side recovery. required: - passphrase - CheckUserRequest: - type: object - properties: - identifier: - type: string - minLength: 1 - maxLength: 254 - description: The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back. - required: - - identifier - description: "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step." LoginPasswordRequest: type: object properties: @@ -36587,49 +36551,6 @@ components: omissions there is a real, undeclared gap: roughly thirty of the models the backup plan marks as backed up have neither a reader here nor a restore branch yet, and `src/lib/export/backup-plan.ts` is the enumeration of which. `schemaVersion` is `2` on everything written today; the parser still accepts `1`." - CheckUserEnvelope: - type: object - properties: - data: - $ref: "#/components/schemas/CheckUserResponse" - error: - type: "null" - meta: - type: object - properties: - requestId: - type: string - additionalProperties: false - required: - - data - - error - additionalProperties: false - CheckUserResponse: - type: object - properties: - branch: - type: string - enum: - - not_found - - passkey_only - - email_fallback - - exists - description: "Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` - (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — - recovery path)." - hasPasskey: - type: boolean - description: The account has at least one registered passkey. - hasPassword: - type: boolean - description: The account has a password hash. - required: - - branch - - hasPasskey - - hasPassword - additionalProperties: false - description: Account-existence + credential shape. The response is identical whether or not the identifier matched - (account-existence is the explicit contract iOS needs); the identifier is never echoed. LoginResponse: type: object properties: diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts deleted file mode 100644 index 8ae3cbd40..000000000 --- a/src/__tests__/api/auth/check-user/route.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -/** - * v1.4.41 W-IOS-COORD SB-7 follow-up — pin the four-branch discovery - * contract for `POST /api/auth/check-user`. The iOS onboarding screen - * branches on `branch` to decide which credential form to render; any - * regression that drops a branch silently makes the wrong form appear. - */ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { - findFirst: vi.fn(), - }, - }, -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/db-compat", () => ({ - ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - }), - // v1.4.43 W13 M-4 — the route now routes through the auth-surface - // wrapper. Default to a clean per-IP result. - checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - }), - rateLimitHeaders: vi.fn(() => ({})), -})); - -vi.mock("@/lib/auth/hmac", () => ({ - hashToken: vi.fn( - (raw: string) => - `hash-${Buffer.from(raw, "utf-8") - .toString("hex") - .slice(0, 16) - .padStart(16, "0")}`, - ), -})); - -vi.mock("next/headers", () => ({ - headers: vi.fn(async () => ({ get: () => null })), - cookies: vi.fn(async () => ({ - get: () => undefined, - set: () => {}, - delete: () => {}, - })), -})); - -import { POST } from "@/app/api/auth/check-user/route"; -import { prisma } from "@/lib/db"; -import { checkRateLimit, checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; - -interface CheckUserBody { - data: { - branch: "not_found" | "passkey_only" | "email_fallback" | "exists"; - hasPasskey: boolean; - hasPassword: boolean; - }; -} - -function makeRequest(body: unknown) { - return new Request("http://localhost/api/auth/check-user", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }) as unknown as Parameters[0]; -} - -beforeEach(() => { - vi.resetAllMocks(); - vi.mocked(checkRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ - allowed: true, - limit: 60, - remaining: 29, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - } as never); -}); - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe("POST /api/auth/check-user", () => { - it("returns `not_found` when no account matches the identifier", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - const res = await POST(makeRequest({ identifier: "ghost@example.com" })); - const body = (await res.json()) as CheckUserBody; - expect(res.status).toBe(200); - expect(body.data.branch).toBe("not_found"); - expect(body.data.hasPasskey).toBe(false); - expect(body.data.hasPassword).toBe(false); - }); - - it("returns `passkey_only` when the user has passkeys and no password", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u1", - passwordHash: null, - _count: { passkeys: 2 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("passkey_only"); - expect(body.data.hasPasskey).toBe(true); - expect(body.data.hasPassword).toBe(false); - }); - - it("returns `email_fallback` when the user has a password (regardless of passkey count)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u2", - passwordHash: "argon2id$…", - _count: { passkeys: 1 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("email_fallback"); - expect(body.data.hasPasskey).toBe(true); - expect(body.data.hasPassword).toBe(true); - }); - - it("returns `exists` when the user has neither passkey nor password (recovery path)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "u3", - passwordHash: null, - _count: { passkeys: 0 }, - } as never); - const res = await POST(makeRequest({ identifier: "testuser" })); - const body = (await res.json()) as CheckUserBody; - expect(body.data.branch).toBe("exists"); - expect(body.data.hasPasskey).toBe(false); - expect(body.data.hasPassword).toBe(false); - }); - - it("422s when identifier is missing or blank", async () => { - const res = await POST(makeRequest({})); - expect(res.status).toBe(422); - }); - - it("queries the identifier exactly as iOS sends it (no case-fold)", async () => { - // Register stores email + username verbatim (no `.toLowerCase()` - // transform in `registerSchema`); folding here would route a - // mixed-case existing account to the sign-up branch. - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - await POST(makeRequest({ identifier: "MixedCase@Example.com" })); - expect(prisma.user.findFirst).toHaveBeenCalledWith( - expect.objectContaining({ - where: { - OR: [ - { username: "MixedCase@Example.com" }, - { email: "MixedCase@Example.com" }, - ], - }, - }), - ); - }); - - it("returns 429 when the per-IP rate-limit is exhausted", async () => { - vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValueOnce({ - allowed: false, - limit: 60, - remaining: 0, - resetAt: Date.now() + 15 * 60 * 1000, - ip: "203.0.113.1", - } as never); - const res = await POST(makeRequest({ identifier: "anyone@example.com" })); - expect(res.status).toBe(429); - expect(prisma.user.findFirst).not.toHaveBeenCalled(); - }); -}); diff --git a/src/app/api/auth/check-user/__tests__/route.test.ts b/src/app/api/auth/check-user/__tests__/route.test.ts deleted file mode 100644 index 660ee1abf..000000000 --- a/src/app/api/auth/check-user/__tests__/route.test.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest } from "next/server"; - -// --- Mocks must come before importing the route. --- - -vi.mock("@/lib/db", () => ({ - prisma: { - user: { findFirst: vi.fn() }, - }, -})); - -vi.mock("@/lib/auth/audit", () => ({ - auditLog: vi.fn().mockResolvedValue(undefined), -})); - -// The real `hashToken` is an HMAC-SHA256 (one-way + key-dependent); the -// shape only matters here, so a deterministic stand-in keeps the test -// stable without standing up the env-keyed CipherSuite. The PII check -// below asserts the raw identifier never appears in the audit payload. -vi.mock("@/lib/auth/hmac", () => ({ - hashToken: vi.fn( - (raw: string) => - `hash-${Buffer.from(raw, "utf-8") - .toString("hex") - .slice(0, 16) - .padStart(16, "0")}`, - ), -})); - -vi.mock("@/lib/rate-limit", () => ({ - checkRateLimit: vi - .fn() - .mockResolvedValue({ allowed: true, remaining: 30, resetAt: 0 }), - // v1.4.43 W13 M-4 — `check-user` now routes through the auth-surface - // wrapper so the trust-violation branch can swap to a tighter global - // bucket. Default to a clean per-IP result. - checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ - allowed: true, - remaining: 30, - resetAt: 0, - ip: "203.0.113.1", - }), - rateLimitHeaders: vi.fn(() => ({})), -})); - -vi.mock("@/lib/logging/transports", () => ({ - emitIfSampled: vi.fn(), -})); - -import { POST } from "../route"; -import { prisma } from "@/lib/db"; -import { auditLog } from "@/lib/auth/audit"; -import { hashToken } from "@/lib/auth/hmac"; - -function expectedHash(identifier: string): string { - return (hashToken as unknown as (s: string) => string)(identifier); -} - -function makeRequest(body: { identifier: string }): NextRequest { - return new NextRequest("http://localhost/api/auth/check-user", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(body), - }); -} - -beforeEach(() => { - vi.mocked(prisma.user.findFirst).mockReset(); - vi.mocked(auditLog).mockReset(); - vi.mocked(auditLog).mockResolvedValue(undefined as never); -}); - -describe("POST /api/auth/check-user — audit-log row (M-1)", () => { - it("writes an audit row for the not_found branch with identifier_hash (never raw)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); - - const res = await POST(makeRequest({ identifier: "ghost@example.com" })); - expect(res.status).toBe(200); - - expect(auditLog).toHaveBeenCalledTimes(1); - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[0]).toBe("auth.check-user"); - expect(args[1]).toEqual({ - ipAddress: "203.0.113.1", - details: { - branch: "not_found", - identifier_hash: expectedHash("ghost@example.com"), - }, - }); - // The raw identifier must not leak into the details — every other - // /api/auth/* audit row already hashes; the check-user route follows - // the same shape. - const details = args[1]?.details as Record | undefined; - expect(details).not.toHaveProperty("identifier"); - expect(JSON.stringify(details)).not.toContain("ghost@example.com"); - }); - - it("writes an audit row for the passkey_only branch with userId attached", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-1", - passwordHash: null, - _count: { passkeys: 2 }, - } as never); - - const res = await POST(makeRequest({ identifier: "testuser" })); - expect(res.status).toBe(200); - - expect(auditLog).toHaveBeenCalledTimes(1); - expect(vi.mocked(auditLog).mock.calls[0][1]).toEqual({ - userId: "user-1", - ipAddress: "203.0.113.1", - details: { - branch: "passkey_only", - identifier_hash: expectedHash("testuser"), - }, - }); - }); - - it("writes an audit row for the email_fallback branch with userId attached", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-2", - passwordHash: "$argon2id$...", - _count: { passkeys: 0 }, - } as never); - - const res = await POST(makeRequest({ identifier: "user@example.com" })); - expect(res.status).toBe(200); - - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[1]?.details).toEqual({ - branch: "email_fallback", - identifier_hash: expectedHash("user@example.com"), - }); - expect(args[1]?.userId).toBe("user-2"); - }); - - it("writes an audit row for the exists branch (no passkey + no password)", async () => { - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-3", - passwordHash: null, - _count: { passkeys: 0 }, - } as never); - - const res = await POST(makeRequest({ identifier: "stub" })); - expect(res.status).toBe(200); - - const args = vi.mocked(auditLog).mock.calls[0]; - expect(args[1]?.details).toEqual({ - branch: "exists", - identifier_hash: expectedHash("stub"), - }); - expect(args[1]?.userId).toBe("user-3"); - }); -}); diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts deleted file mode 100644 index 1aaf77170..000000000 --- a/src/app/api/auth/check-user/route.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * `POST /api/auth/check-user` — four-branch discovery for iOS onboarding. - * - * v1.4.41 W-IOS-COORD SB-7 follow-up. The iOS team's onboarding flow needs - * to know, given a typed identifier (username or email), what the next - * UX step should be: - * - * - `not_found` — no account exists; show the sign-up screen. - * - `passkey_only` — account exists, has at least one Passkey, no - * password hash. Show "Sign in with Passkey". - * - `email_fallback` — account exists, has a password hash (with or - * without a Passkey). Show password field plus - * a "Use Passkey" affordance when applicable. - * - `exists` — account exists with no usable credential - * (neither passkey nor password). Treat as - * recovery path; show "Reset access" hint. - * - * The route is intentionally narrow: - * - * - Accepts `{ identifier: string }` (either an email or a username). - * - Returns `{ branch, hasPasskey, hasPassword }` — booleans are - * included so the iOS client can render a "or sign in with Passkey" - * button alongside the password field without a second round-trip. - * - Never leaks PII. The response shape is the same regardless of - * whether the identifier matched (callers learn account-existence - * either way — that is the explicit contract iOS needs). The - * handler does NOT echo the identifier back. - * - Per-IP rate-limited (30 requests / 15 min) so the by-design - * enumeration contract cannot be brute-forced. Mirrors the - * `/api/auth/passkey/login-options` throttle shape. - * - The identifier is queried EXACTLY as iOS sends it (no - * `.toLowerCase()` normalisation). Email + username columns are - * stored verbatim by `/api/auth/register`, so case-folding here - * would route legitimate existing users to the sign-up branch. - * A future server-side normalisation pass on register write is - * the long-term fix. - */ -import { z } from "zod/v4"; -import { prisma } from "@/lib/db"; -import { - apiSuccess, - apiValidationError, - safeJson, - sanitiseZodIssues, -} from "@/lib/api-response"; -import { apiHandler } from "@/lib/api-handler"; -import { annotate } from "@/lib/logging/context"; -import { auditLog } from "@/lib/auth/audit"; -import { hashToken } from "@/lib/auth/hmac"; -import { checkAuthSurfaceRateLimit, rateLimitHeaders } from "@/lib/rate-limit"; -import { NextResponse } from "next/server"; -import type { NextRequest } from "next/server"; - -export const dynamic = "force-dynamic"; - -const bodySchema = z - .object({ - identifier: z.string().trim().min(1).max(254), - }) - .strict(); - -export type CheckUserBranch = - "not_found" | "passkey_only" | "email_fallback" | "exists"; - -export const POST = apiHandler(async (request: NextRequest) => { - // v1.4.43 W13 M-4 — tighter shared bucket on trust-chain misconfig. - const rl = await checkAuthSurfaceRateLimit( - request, - "auth:check-user", - 30, - 15 * 60 * 1000, - ); - const ip = rl.ip ?? "unknown"; - if (!rl.allowed) { - return NextResponse.json( - { data: null, error: "Too many requests. Please try again later." }, - { status: 429, headers: rateLimitHeaders(rl) }, - ); - } - - const { data: body, error: jsonError } = await safeJson(request, { - maxBytes: 64 * 1024, - }); - if (jsonError) return jsonError; - - const parsed = bodySchema.safeParse(body); - if (!parsed.success) { - return apiValidationError( - "identifier required", - sanitiseZodIssues(parsed.error.issues), - 422, - ); - } - - const identifier = parsed.data.identifier; - - // Match on either username or email — both are unique in the schema. - const user = await prisma.user.findFirst({ - where: { - OR: [{ username: identifier }, { email: identifier }], - }, - select: { - id: true, - passwordHash: true, - _count: { select: { passkeys: true } }, - }, - }); - - // v1.4.43 W13 M-1 — persist a hashed-identifier audit row so an admin - // can later answer "did anyone enumerate accounts last week?". The - // wide-event `annotate()` log is sampled and short-retention; the - // audit ledger is durable. We hash the identifier (same shape as the - // `auth.login.failed` row) so the literal email/username never lands - // in `AuditLog.details` — the v1.4.20 retroactive PII directive bars - // raw user-typed strings from operator-readable artefacts. - const identifierHash = hashToken(identifier); - - if (!user) { - annotate({ - action: { name: "auth.check-user" }, - meta: { branch: "not_found" }, - }); - await auditLog("auth.check-user", { - ipAddress: ip, - details: { branch: "not_found", identifier_hash: identifierHash }, - }); - return apiSuccess({ - branch: "not_found" satisfies CheckUserBranch, - hasPasskey: false, - hasPassword: false, - }); - } - - const hasPasskey = user._count.passkeys > 0; - const hasPassword = Boolean(user.passwordHash); - - let branch: CheckUserBranch; - if (hasPasskey && !hasPassword) branch = "passkey_only"; - else if (hasPassword) branch = "email_fallback"; - else branch = "exists"; - - annotate({ action: { name: "auth.check-user" }, meta: { branch } }); - await auditLog("auth.check-user", { - userId: user.id, - ipAddress: ip, - details: { branch, identifier_hash: identifierHash }, - }); - return apiSuccess({ branch, hasPasskey, hasPassword }); -}); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index eb3272f8f..5a291aa09 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -69,11 +69,10 @@ export const POST = apiHandler(async (request: NextRequest) => { const { email, password } = parsed.data; const identifier = email.trim(); - // HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`, - // mirrors the `/api/auth/check-user` pattern. The raw identifier - // stays out of the audit row (H-1 contract); the hash gives a - // future spray-detector a forensic anchor it can correlate across - // IPs without having to look up users by email. + // HMAC of the typed identifier — keyed by `API_TOKEN_HMAC_KEY`. The + // raw identifier stays out of the audit row (H-1 contract); the hash + // gives a future spray-detector a forensic anchor it can correlate + // across IPs without having to look up users by email. const identifierHash = hashToken(identifier); const user = await prisma.user.findFirst({ diff --git a/src/lib/__tests__/rate-limit-auth-surface.test.ts b/src/lib/__tests__/rate-limit-auth-surface.test.ts index 25558cae5..8331e5479 100644 --- a/src/lib/__tests__/rate-limit-auth-surface.test.ts +++ b/src/lib/__tests__/rate-limit-auth-surface.test.ts @@ -146,7 +146,6 @@ describe("checkAuthSurfaceRateLimit — trust-violation tightening", () => { ["auth:passkey-login-options", 10], ["auth:passkey-verify", 10], ["auth:refresh", 60], - ["auth:check-user", 30], ]; const seenKeys = new Set(); for (const [prefix, limit] of surfaces) { diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index be835e889..c617de77d 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -304,43 +304,6 @@ const successFlagResponse = z .object({ success: z.boolean() }) .meta({ id: "AuthSuccessFlagResponse" }); -// ── iOS onboarding discovery (check-user) ──────────────────────────── - -const checkUserRequest = z - .object({ - identifier: z - .string() - .trim() - .min(1) - .max(254) - .describe( - "The typed identifier — either an email or a username. Queried verbatim (no case-folding), never echoed back.", - ), - }) - .meta({ - id: "CheckUserRequest", - description: - "Discovery lookup for the iOS onboarding flow: given a typed email or username, resolve the next sign-in step.", - }); - -const checkUserResponse = z - .object({ - branch: z - .enum(["not_found", "passkey_only", "email_fallback", "exists"]) - .describe( - "Next UX step: `not_found` (show sign-up), `passkey_only` (Sign in with Passkey), `email_fallback` (password field, with a Passkey affordance when applicable), `exists` (account with no usable credential — recovery path).", - ), - hasPasskey: z - .boolean() - .describe("The account has at least one registered passkey."), - hasPassword: z.boolean().describe("The account has a password hash."), - }) - .meta({ - id: "CheckUserResponse", - description: - "Account-existence + credential shape. The response is identical whether or not the identifier matched (account-existence is the explicit contract iOS needs); the identifier is never echoed.", - }); - /** * Shared tail for every route the step-up elevation can unlock. Kept in one * place so the published contract cannot describe the set inconsistently. @@ -620,29 +583,6 @@ const codexDevicePollResponse = z .meta({ id: "CodexDevicePollResponse" }); export const authPaths: NonNullable = { - "/api/auth/check-user": { - post: { - tags: ["Auth"], - summary: "Resolve the next sign-in step for a typed identifier", - description: - "Given an email or username, returns which onboarding branch the iOS client should render plus the `hasPasskey` / `hasPassword` booleans so it can offer a Passkey affordance alongside a password field without a second round-trip. Anonymous surface; per-IP rate-limited (30 requests / 15 min). The response is the same whether or not the identifier matched, and the identifier is never echoed back.", - requestBody: { - required: true, - content: { "application/json": { schema: checkUserRequest } }, - }, - responses: { - "200": { - description: "Discovery result.", - content: { - "application/json": { - schema: dataEnvelope(checkUserResponse, "CheckUserEnvelope"), - }, - }, - }, - ...stdResponses, - }, - }, - }, "/api/auth/login": { post: { // No credential: this operation is reachable before one exists. diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index d6cc7b624..5379eb2bc 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -240,7 +240,7 @@ const TIGHT_ANON_WINDOW_MS = 15 * 60 * 1000; /** * Rate-limit helper for anonymous auth surfaces (login, register, - * passkey-login-{options,verify}, refresh, check-user). + * passkey-login-{options,verify}, refresh). * * Per-IP path: when the trust chain resolves cleanly, route the request * to a bucket keyed by `{prefix}:{ip ?? "unknown"}` with the caller's