From 6ef139c7b2b35c923aec172a3487689bf852804b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 13:56:49 +0200 Subject: [PATCH 1/9] Remove the anonymous account-discovery endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `POST /api/auth/check-user` answered, for any typed identifier and with no credential at all, whether an account exists and whether it carries a passkey or a password. Its header called that the intended contract with the native client; it is not one any more. The native client never calls it, and no web surface in this repository does either. Removed without a replacement: the route, its two test files, the OpenAPI operation and its two schemas, the surface's mention in the rate-limiter doc comment, the bucket expectation in the auth-surface rate-limit test, and the login route's comment pointing at it. No stub, no deprecation shim, no 410 arm — there is no caller to keep. --- docs/api/openapi.yaml | 79 -------- .../api/auth/check-user/route.test.ts | 191 ------------------ .../auth/check-user/__tests__/route.test.ts | 154 -------------- src/app/api/auth/check-user/route.ts | 149 -------------- src/app/api/auth/login/route.ts | 9 +- .../__tests__/rate-limit-auth-surface.test.ts | 1 - src/lib/openapi/routes/auth.ts | 60 ------ src/lib/rate-limit.ts | 2 +- 8 files changed, 5 insertions(+), 640 deletions(-) delete mode 100644 src/__tests__/api/auth/check-user/route.test.ts delete mode 100644 src/app/api/auth/check-user/__tests__/route.test.ts delete mode 100644 src/app/api/auth/check-user/route.ts 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 From 6a5fb338b15f736d25679e74472e1f9863c0774f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 13:59:42 +0200 Subject: [PATCH 2/9] Make every login refusal cost the same verification Sign-in returned the moment it found no account, or an account carrying no password hash, before it reached the verifier. An account that did have a password paid a full Argon2id verification at 19 MiB and t=2. The three refusals answered with the identical 401 body and three very different durations, so the clock told an anonymous caller whether an address is registered and whether it carries a password. `verifyPasswordOrDummy` verifies against a stand-in hash minted once per process at the same cost parameters real hashes use, and returns false whenever there is nothing to verify, so the passkey-only account takes the same path as one with a password. The dummy arm discards its own verdict and swallows a throw, so no outcome there can be read as a success or answer with a different status. --- .../api/auth/login/mfa-branch.test.ts | 6 +- .../login/__tests__/constant-work.test.ts | 155 ++++++++++++++++++ .../auth/login/__tests__/native-token.test.ts | 6 +- .../api/auth/login/__tests__/route.test.ts | 8 +- .../login/__tests__/trusted-device.test.ts | 10 +- src/app/api/auth/login/route.ts | 15 +- .../__tests__/password-dummy-verify.test.ts | 93 +++++++++++ src/lib/auth/password.ts | 56 +++++++ 8 files changed, 332 insertions(+), 17 deletions(-) create mode 100644 src/app/api/auth/login/__tests__/constant-work.test.ts create mode 100644 src/lib/auth/__tests__/password-dummy-verify.test.ts diff --git a/src/__tests__/api/auth/login/mfa-branch.test.ts b/src/__tests__/api/auth/login/mfa-branch.test.ts index e59dffb9d..79de4e666 100644 --- a/src/__tests__/api/auth/login/mfa-branch.test.ts +++ b/src/__tests__/api/auth/login/mfa-branch.test.ts @@ -12,7 +12,7 @@ vi.mock("@/lib/db", () => ({ }, })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn().mockResolvedValue(true), + verifyPasswordOrDummy: vi.fn().mockResolvedValue(true), })); vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn().mockResolvedValue(undefined), @@ -142,8 +142,8 @@ describe("login MFA branch", () => { }); it("wrong password never reaches the MFA branch", async () => { - const { verifyPassword } = await import("@/lib/auth/password"); - vi.mocked(verifyPassword).mockResolvedValueOnce(false); + const { verifyPasswordOrDummy } = await import("@/lib/auth/password"); + vi.mocked(verifyPasswordOrDummy).mockResolvedValueOnce(false); vi.mocked(prisma.user.findFirst).mockResolvedValue({ ...BASE_USER, totpConfirmedAt: new Date(), diff --git a/src/app/api/auth/login/__tests__/constant-work.test.ts b/src/app/api/auth/login/__tests__/constant-work.test.ts new file mode 100644 index 000000000..87be3506b --- /dev/null +++ b/src/app/api/auth/login/__tests__/constant-work.test.ts @@ -0,0 +1,155 @@ +/** + * Every login outcome reaches the verifier. + * + * The three refusals sign-in can produce — no such account, an account with + * no password hash, a wrong password — return the identical 401 body, and + * used to take three very different amounts of time to produce it, because + * the first two returned before any hashing happened. Argon2id at 19 MiB and + * t=2 against one indexed SELECT is a gap a caller can read over a network. + * + * The passkey-only account is the case that matters most. If it kept the + * cheap path, the channel would still answer "this account exists and has no + * password" — which is exactly what the removed discovery endpoint published + * in a field. + * + * What this proves is that the call happens on all three arms, not that the + * three durations match: the verifier is mocked here, and wall-clock equality + * is not something a unit test can assert. `password-dummy-verify.test.ts` + * carries the other half, that the verifier really does Argon2id work at the + * real cost parameters when there is no stored hash. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findFirst: vi.fn() }, + webauthnMfaCredential: { count: vi.fn().mockResolvedValue(0) }, + }, +})); + +vi.mock("@/lib/auth/password", () => ({ + verifyPasswordOrDummy: vi.fn(), +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkAuthSurfaceRateLimit: vi.fn().mockResolvedValue({ + allowed: true, + remaining: 5, + reset: 0, + ip: "1.2.3.4", + }), + rateLimitHeaders: vi.fn(() => ({})), +})); + +vi.mock("@/lib/db-compat", () => ({ + ensureDbCompatibility: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/auth/hmac", () => ({ + hashToken: vi.fn(() => "aa".repeat(32)), +})); + +vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() })); + +vi.mock("next/headers", () => ({ + headers: vi.fn(async () => ({ get: () => null })), + cookies: vi.fn(async () => ({ + get: () => undefined, + set: () => {}, + delete: () => {}, + })), +})); + +import { POST } from "../route"; +import { prisma } from "@/lib/db"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; + +function makeRequest(): NextRequest { + return new NextRequest("http://localhost/api/auth/login", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + email: "someone@example.com", + password: "a-typed-password", + }), + }); +} + +beforeEach(() => { + vi.mocked(verifyPasswordOrDummy).mockReset(); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); +}); + +describe("POST /api/auth/login — constant work across the refusals", () => { + it("verifies even when no account matches the identifier", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + undefined, + "a-typed-password", + ); + }); + + it("verifies for a passkey-only account, which carries no password hash", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-1", + passwordHash: null, + totpConfirmedAt: null, + mfaEnforced: false, + } as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + null, + "a-typed-password", + ); + }); + + it("verifies for an account that does carry a password hash", async () => { + vi.mocked(prisma.user.findFirst).mockResolvedValue({ + id: "user-2", + passwordHash: "$argon2id$stored", + totpConfirmedAt: null, + mfaEnforced: false, + } as never); + + const res = await POST(makeRequest()); + + expect(res.status).toBe(401); + expect(verifyPasswordOrDummy).toHaveBeenCalledTimes(1); + expect(verifyPasswordOrDummy).toHaveBeenCalledWith( + "$argon2id$stored", + "a-typed-password", + ); + }); + + it("answers the three refusals with the identical status and body", async () => { + const bodies: string[] = []; + const statuses: number[] = []; + const accounts = [ + null, + { id: "user-1", passwordHash: null }, + { id: "user-2", passwordHash: "$argon2id$stored" }, + ]; + for (const account of accounts) { + vi.mocked(prisma.user.findFirst).mockResolvedValue(account as never); + const res = await POST(makeRequest()); + statuses.push(res.status); + bodies.push(await res.text()); + } + expect(new Set(statuses)).toEqual(new Set([401])); + expect(new Set(bodies).size).toBe(1); + }); +}); diff --git a/src/app/api/auth/login/__tests__/native-token.test.ts b/src/app/api/auth/login/__tests__/native-token.test.ts index fc1212912..7089dbff9 100644 --- a/src/app/api/auth/login/__tests__/native-token.test.ts +++ b/src/app/api/auth/login/__tests__/native-token.test.ts @@ -14,7 +14,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn(), + verifyPasswordOrDummy: vi.fn(), })); vi.mock("@/lib/auth/mfa-enrollment", () => ({ @@ -73,7 +73,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { checkRateLimit, checkAuthSurfaceRateLimit, @@ -108,7 +108,7 @@ beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(FAKE_USER as never); vi.mocked(prisma.webauthnMfaCredential.count).mockResolvedValue(0 as never); - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(prisma.apiToken.create).mockResolvedValue({ id: "tok-1" } as never); vi.mocked(prisma.refreshToken.create).mockResolvedValue({ id: "rt-1", diff --git a/src/app/api/auth/login/__tests__/route.test.ts b/src/app/api/auth/login/__tests__/route.test.ts index c82732f95..fb2df339b 100644 --- a/src/app/api/auth/login/__tests__/route.test.ts +++ b/src/app/api/auth/login/__tests__/route.test.ts @@ -17,7 +17,7 @@ vi.mock("@/lib/db", () => ({ })); vi.mock("@/lib/auth/password", () => ({ - verifyPassword: vi.fn(), + verifyPasswordOrDummy: vi.fn(), })); vi.mock("@/lib/auth/session", () => ({ @@ -71,7 +71,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { checkRateLimit, @@ -93,7 +93,7 @@ function makeRequest(): NextRequest { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.findFirst).mockResolvedValue(null as never); - vi.mocked(verifyPassword).mockResolvedValue(false); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); vi.mocked(auditLog).mockResolvedValue(undefined as never); vi.mocked(checkRateLimit).mockResolvedValue({ allowed: true, @@ -175,7 +175,7 @@ describe("POST /api/auth/login — OIDC_ONLY server-side enforcement", () => { const res = await POST(makeRequest()); // Falls through to normal password verification (which fails here - // since verifyPassword is mocked to return false) rather than 403 — + // since verifyPasswordOrDummy is mocked to return false) rather than 403 — // a half-set OIDC group must never lock everyone out. expect(res.status).toBe(401); }); diff --git a/src/app/api/auth/login/__tests__/trusted-device.test.ts b/src/app/api/auth/login/__tests__/trusted-device.test.ts index cb895be6b..f8eab01a4 100644 --- a/src/app/api/auth/login/__tests__/trusted-device.test.ts +++ b/src/app/api/auth/login/__tests__/trusted-device.test.ts @@ -8,7 +8,7 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/lib/auth/password", () => ({ verifyPassword: vi.fn() })); +vi.mock("@/lib/auth/password", () => ({ verifyPasswordOrDummy: vi.fn() })); vi.mock("@/lib/auth/login-response", () => ({ finishLogin: vi.fn(async () => new Response(null, { status: 200 })), @@ -58,7 +58,7 @@ vi.mock("next/headers", () => ({ import { POST } from "../route"; import { prisma } from "@/lib/db"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { finishLogin } from "@/lib/auth/login-response"; import { createMfaChallenge } from "@/lib/auth/mfa/challenge"; import { consumeTrustedDevice } from "@/lib/auth/trusted-device"; @@ -88,7 +88,7 @@ beforeEach(() => { describe("POST /api/auth/login — trusted device", () => { it("skips the second factor when the device is trusted (no challenge minted)", async () => { - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(consumeTrustedDevice).mockResolvedValue(true); const res = await POST(req()); @@ -103,7 +103,7 @@ describe("POST /api/auth/login — trusted device", () => { }); it("falls back to the MFA challenge when the device is not trusted", async () => { - vi.mocked(verifyPassword).mockResolvedValue(true); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(true); vi.mocked(consumeTrustedDevice).mockResolvedValue(false); const res = await POST(req()); @@ -115,7 +115,7 @@ describe("POST /api/auth/login — trusted device", () => { }); it("still requires the password — a wrong password never consults the trusted device", async () => { - vi.mocked(verifyPassword).mockResolvedValue(false); + vi.mocked(verifyPasswordOrDummy).mockResolvedValue(false); const res = await POST(req()); expect(res.status).toBe(401); diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index 5a291aa09..5c163c0dd 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,6 +1,6 @@ import { prisma } from "@/lib/db"; import { loginPasswordSchema } from "@/lib/validations/auth"; -import { verifyPassword } from "@/lib/auth/password"; +import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { auditLog } from "@/lib/auth/audit"; import { hashToken } from "@/lib/auth/hmac"; import { @@ -84,6 +84,18 @@ export const POST = apiHandler(async (request: NextRequest) => { }, }); + // One verification for every outcome. An unknown identifier and an + // account carrying no password hash (a passkey-only account) used to + // return here before any hashing, so the three answers cost one indexed + // SELECT, one indexed SELECT, and a full Argon2id verification + // respectively — identical bodies, three different durations, which is a + // readable answer to "is this address registered, and does it have a + // password". `verifyPasswordOrDummy` verifies against a fixed dummy hash + // minted at the same cost parameters when there is no stored hash, and + // returns false. Keep the call ahead of the branch: moving it back under + // an `if` restores the channel. + const valid = await verifyPasswordOrDummy(user?.passwordHash, password); + if (!user || !user.passwordHash) { // v1.4.43 W3-SECURITY (H-1): never write the typed identifier into // the audit row — `reason` already tells the operator what @@ -98,7 +110,6 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiError("Invalid credentials", 401); } - const valid = await verifyPassword(user.passwordHash, password); if (!valid) { await auditLog("auth.login.failed", { userId: user.id, diff --git a/src/lib/auth/__tests__/password-dummy-verify.test.ts b/src/lib/auth/__tests__/password-dummy-verify.test.ts new file mode 100644 index 000000000..9035bdc8a --- /dev/null +++ b/src/lib/auth/__tests__/password-dummy-verify.test.ts @@ -0,0 +1,93 @@ +/** + * Signing in must cost the same whether or not the account exists. + * + * The login route used to return the moment it found no account, or an + * account with no password hash, before it reached the verifier. An account + * that did have a password paid a full Argon2id verification. Same status, + * same body, very different durations — so the clock answered "is this + * address registered, and does it carry a password", which is the question + * the removed discovery endpoint used to answer out loud. + * + * What this file pins is the work, not the wall clock: that the empty arms + * reach the verifier at all, that the hash they verify against was minted at + * the same cost parameters every real hash carries, and that no outcome on + * those arms can be read as a successful verification. A unit test cannot + * prove two durations are equal — it can only prove the same call is made. + * + * `@node-rs/argon2` is mocked so the assertions are about which calls happen + * with which arguments, and so the suite does not spend real Argon2 time. + */ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Hoisted: `password.ts` mints the dummy hash at module load, so the mock +// has to exist before the import below runs. +const { hashMock, verifyMock } = vi.hoisted(() => ({ + hashMock: vi.fn(async (input: string) => `hashed:${input}`), + verifyMock: vi.fn(async (_hash: string, _password: string) => false), +})); + +vi.mock("@node-rs/argon2", () => ({ hash: hashMock, verify: verifyMock })); + +import { verifyPasswordOrDummy } from "@/lib/auth/password"; +import { ARGON2_HASH_OPTIONS } from "@/lib/auth/argon2-params.mjs"; + +beforeEach(() => { + verifyMock.mockReset(); + verifyMock.mockResolvedValue(false); +}); + +describe("verifyPasswordOrDummy", () => { + it("mints the dummy hash under the same Argon2id parameters real hashes use", () => { + // The module mints it once at load, so the call has already happened. + expect(hashMock).toHaveBeenCalledWith( + expect.any(String), + ARGON2_HASH_OPTIONS, + ); + }); + + it("verifies against the stored hash when the account has one", async () => { + verifyMock.mockResolvedValueOnce(true); + await expect( + verifyPasswordOrDummy("$argon2id$stored", "correct horse"), + ).resolves.toBe(true); + expect(verifyMock).toHaveBeenCalledTimes(1); + expect(verifyMock).toHaveBeenCalledWith( + "$argon2id$stored", + "correct horse", + ); + }); + + it("still verifies when there is no account at all (null hash)", async () => { + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + expect(verifyMock).toHaveBeenCalledTimes(1); + // Against the dummy, not against nothing. + expect(verifyMock.mock.calls[0][0]).toBe( + await hashMock.mock.results[0].value, + ); + }); + + it("still verifies for a passkey-only account (undefined hash)", async () => { + // The passkey-only case is the one that matters most: if it took the + // cheap path the channel would still answer "this account has no + // password", which is half of what the removed endpoint published. + await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe( + false, + ); + expect(verifyMock).toHaveBeenCalledTimes(1); + }); + + it("never reports success from the dummy arm, whatever the verifier says", async () => { + verifyMock.mockResolvedValue(true); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + await expect(verifyPasswordOrDummy(undefined, "anything")).resolves.toBe( + false, + ); + }); + + it("swallows a throw from the dummy arm rather than answering differently", async () => { + // A 500 only the unknown-account arm can produce is the same oracle in + // a different field. + verifyMock.mockRejectedValueOnce(new Error("malformed hash")); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + }); +}); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index 570700b88..de5665c49 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -20,6 +20,62 @@ export async function verifyPassword( return verify(storedHash, password); } +/** + * A stand-in stored hash for the login arms that have nothing to verify. + * + * Sign-in used to return the moment it found no account, or an account with + * no password — one indexed SELECT — while an account that did have a + * password paid a full Argon2id verification at 19 MiB and t=2. The two + * answers were byte-identical and the two costs were not, so the clock told + * a caller which addresses are registered and which of those carry a + * password. Verifying against this hash on the empty arms makes the work + * comparable. + * + * Minted once per process from `ARGON2_HASH_OPTIONS`, the same parameters + * every real hash carries, because Argon2 encodes its cost in the hash + * string and the verifier pays whatever the string says. A dummy minted at + * cheaper settings would leave the gap it was added to close. + * + * The input is a fixed constant rather than a random string so the module + * has no hidden state, and it is never compared to anything: the arm that + * verifies against it discards the verdict and returns false. There is no + * path on which a caller can make this hash accept. + */ +const DUMMY_VERIFY_INPUT = "healthlog: not a credential, never accepted"; +const DUMMY_PASSWORD_HASH: Promise = hash( + DUMMY_VERIFY_INPUT, + ARGON2_HASH_OPTIONS, +); +// Keep a mint failure from surfacing as an unhandled rejection at import +// time; `verifyPasswordOrDummy` still awaits and still sees it. +void DUMMY_PASSWORD_HASH.catch(() => undefined); + +/** + * Verify `password` against `storedHash`, or against the dummy hash when + * there is no stored hash to verify against. + * + * Returns false whenever `storedHash` is absent — an account without a + * password, or no account at all. The dummy verification's own verdict is + * thrown away, so a false here is never a rejected credential being + * mistaken for anything else, and a true can only come from a real stored + * hash. + * + * Callers on an anonymous surface should use this rather than branching on + * the absent hash themselves: the branch is what the clock reads. + */ +export async function verifyPasswordOrDummy( + storedHash: string | null | undefined, + password: string, +): Promise { + if (storedHash) return verify(storedHash, password); + // Same verifier, same cost profile, verdict discarded. A throw here (a + // malformed dummy, a mint that failed) must not turn into a 500 that only + // the unknown-account arm can produce — that would be the oracle again, + // in the status code this time. + await verify(await DUMMY_PASSWORD_HASH, password).catch(() => false); + return false; +} + export interface PasswordStrength { score: number; // 0-4 feedback: string[]; From fe420f76d89a6b4736746f6518248b36cbabd0cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:05:25 +0200 Subject: [PATCH 3/9] Cap the profile email-address conflict answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changing the profile address asks whether another account already holds it, and the answer is 409 versus 200 — an existence check over every address on the instance, open to any signed-in caller. Neither route that reaches it had a ceiling of any kind and the API wrapper supplies no default, so the only bound on a sweep was the network. Both routes now share one per-account bucket, ten an hour, charged before the question is asked so a refused caller learns nothing. Only a request whose address actually differs from the one on file is counted, so re-saving a settings form that carries the unchanged address stays free. The refusal is the standard 429 with Retry-After and the X-RateLimit triple, carries `profile.update.emailRateLimited`, and is published on both operations. --- docs/api/openapi.yaml | 347 +++++++++--------- messages/de.json | 3 +- messages/en.json | 3 +- messages/es.json | 3 +- messages/fr.json | 3 +- messages/it.json | 3 +- messages/ko.json | 3 +- messages/pl.json | 3 +- .../profile-email-rate-limit-contract.test.ts | 132 +++++++ .../profile-update-email-limit.test.ts | 148 ++++++++ src/lib/auth/__tests__/profile-update.test.ts | 7 + src/lib/auth/profile-update.ts | 49 ++- src/lib/openapi/error-codes.ts | 1 + src/lib/openapi/routes/auth.ts | 2 + src/lib/openapi/routes/profile.ts | 7 + src/lib/openapi/routes/shared.ts | 26 ++ src/lib/rate-limit.ts | 37 ++ 17 files changed, 596 insertions(+), 181 deletions(-) create mode 100644 src/__tests__/profile-email-rate-limit-contract.test.ts create mode 100644 src/lib/auth/__tests__/profile-update-email-limit.test.ts diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index bb1072f7d..7e5d2455a 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3098,7 +3098,19 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": *a5 + "429": &a14 + description: "Too many email-address changes. The address change shares one per-account bucket across both profile + routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an + account cannot collect a fresh allowance by probing a different address. Nothing was written. + `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from + the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any + update that does not touch `email` at all. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` + and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing." + headers: *a8 + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/oidc/login: get: security: [] @@ -4214,7 +4226,7 @@ paths: schema: $ref: "#/components/schemas/GetWorkoutResponse" "401": *a1 - "403": &a15 + "403": &a16 description: 'Module disabled for this account: the user (or operator) has the module turned off. `meta.errorCode` = `module.disabled` and `meta.module` carries the disabled module key (e.g. "sleep"). Returned even for a valid Bearer token. Clients hide the whole module surface end-to-end rather than retry.' @@ -7692,8 +7704,15 @@ paths: schema: $ref: "#/components/schemas/PatchProfileResponse" "401": *a1 + "409": + description: The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing + was written. Shared with PUT /api/auth/profile, which runs the same handler. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" "422": *a4 - "429": *a5 + "429": *a14 /api/auth/me/notification-prefs: get: tags: @@ -9093,7 +9112,7 @@ paths: schema: $ref: "#/components/schemas/CorrectAnamnesisFactResponse" "401": *a1 - "404": &a14 + "404": &a15 description: No current revision with this id exists for the authenticated account. content: application/json: @@ -9129,7 +9148,7 @@ paths: schema: $ref: "#/components/schemas/RemoveAnamnesisFactResponse" "401": *a1 - "404": *a14 + "404": *a15 "409": description: Anamnesis fact changed since it was loaded (optimistic-concurrency conflict). No write happened. Re-read the resource, re-apply the user's change against the fresh state, and resend with the new token. @@ -10253,7 +10272,7 @@ paths: schema: $ref: "#/components/schemas/CoachSeededQuestionEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/insights/narrative: @@ -11023,7 +11042,7 @@ paths: schema: $ref: "#/components/schemas/EcgIngestCreatedEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/insights/ecg/{id}: @@ -11250,7 +11269,7 @@ paths: schema: $ref: "#/components/schemas/MoodDailySeriesEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/mood/insights: @@ -11272,7 +11291,7 @@ paths: schema: $ref: "#/components/schemas/MoodAggregatesEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/mood/prognosis: @@ -11405,7 +11424,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagPatchEnvelope" "401": *a1 - "404": &a16 + "404": &a17 description: Not found / not owned by the caller. content: application/json: @@ -11440,7 +11459,7 @@ paths: schema: $ref: "#/components/schemas/MoodCustomTagDeleteEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/{key}/hidden: @@ -11476,7 +11495,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/groups: @@ -11529,7 +11548,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupPatchEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 delete: @@ -11560,7 +11579,7 @@ paths: schema: $ref: "#/components/schemas/MoodTagGroupDeleteEnvelope" "401": *a1 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood/tags/layout: @@ -11725,7 +11744,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "409": &a17 + "409": &a18 description: A mood entry with the same `(date, moodLoggedAt)` instant already exists. `meta.errorCode` = `mood.duplicate_timestamp`. Only a request without `externalId` can answer this — with one, the write upserts in place. @@ -11757,7 +11776,7 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 put: @@ -11792,8 +11811,8 @@ paths: $ref: "#/components/schemas/MoodEntryEnvelope" "401": *a1 "403": *a9 - "404": *a16 - "409": *a17 + "404": *a17 + "409": *a18 "422": *a4 "429": *a5 delete: @@ -11819,7 +11838,7 @@ paths: $ref: "#/components/schemas/MoodEntryDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a16 + "404": *a17 "422": *a4 "429": *a5 /api/mood-entries/restore: @@ -11969,7 +11988,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutNtfySettingsResponse" - "400": &a18 + "400": &a19 description: Body is not parseable JSON. content: application/json: @@ -11982,13 +12001,13 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": &a19 + "413": &a20 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a20 + "415": &a21 description: Content-Type is not `application/json`. content: application/json: @@ -12048,7 +12067,7 @@ paths: application/json: schema: $ref: "#/components/schemas/PutTelegramSettingsResponse" - "400": *a18 + "400": *a19 "401": *a1 "403": description: The operator switched Telegram off for this instance and the body asked to enable it. No webhook was @@ -12063,8 +12082,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "413": *a19 - "415": *a20 + "413": *a20 + "415": *a21 "422": description: Body matched neither accepted shape; or `enabled` is true without both a bot token and a chat id; or the deployment's `APP_URL` is not a public HTTPS host on port 80/88/443/8443; or Telegram refused the webhook @@ -12118,10 +12137,10 @@ paths: application/json: schema: $ref: "#/components/schemas/PutWebhookSettingsResponse" - "400": *a18 + "400": *a19 "401": *a1 - "413": *a19 - "415": *a20 + "413": *a20 + "415": *a21 "422": description: Body matched neither accepted shape, or `enabled` is true while no webhook URL is stored; or the URL is on a private network the operator has not listed in `NOTIFICATION_PRIVATE_ORIGINS` (`meta.errorCode` = @@ -12620,7 +12639,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentLatestForKindEnvelope" - $ref: "#/components/schemas/ConsentLatestByKindEnvelope" - "400": &a21 + "400": &a22 description: "`kind` was present but is not one of `ai_full`, `ai_insights_only`, `ai_coach`. Multi-issue envelope; nothing was read or revoked. The consent family answers 400 here where the rest of the API answers 422." content: @@ -12669,7 +12688,7 @@ paths: anyOf: - $ref: "#/components/schemas/ConsentRevokeForKindEnvelope" - $ref: "#/components/schemas/ConsentRevokeAllEnvelope" - "400": *a21 + "400": *a22 "401": *a1 "422": *a4 "429": *a5 @@ -12955,7 +12974,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderEnvelope" "401": *a1 "403": *a9 - "404": &a22 + "404": &a23 description: Measurement reminder not found / not owned. content: application/json: @@ -12990,7 +13009,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderPatchEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 delete: @@ -13013,7 +13032,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderDeleteEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/satisfy: @@ -13038,7 +13057,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSatisfyEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/complete: @@ -13066,7 +13085,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderCompleteEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/skip: @@ -13096,7 +13115,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSkipEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/snooze: @@ -13130,7 +13149,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderSnoozeEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/measurement-reminders/{id}/history: @@ -13171,7 +13190,7 @@ paths: $ref: "#/components/schemas/MeasurementReminderHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a22 + "404": *a23 "422": *a4 "429": *a5 /api/labs: @@ -13295,7 +13314,7 @@ paths: $ref: "#/components/schemas/GetLabResultResponse" "401": *a1 "403": *a9 - "404": &a23 + "404": &a24 description: Lab result not found (or owned by another user). content: application/json: @@ -13330,7 +13349,7 @@ paths: $ref: "#/components/schemas/UpdateLabResultResponse" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 delete: @@ -13353,7 +13372,7 @@ paths: $ref: "#/components/schemas/DeleteLabResultResponse" "401": *a1 "403": *a9 - "404": *a23 + "404": *a24 "422": *a4 "429": *a5 /api/labs/restore: @@ -13509,7 +13528,7 @@ paths: $ref: "#/components/schemas/CreateBiomarkerResponse" "401": *a1 "403": *a9 - "409": &a25 + "409": &a26 description: A biomarker with this name already exists for the caller. content: application/json: @@ -13538,7 +13557,7 @@ paths: $ref: "#/components/schemas/GetBiomarkerResponse" "401": *a1 "403": *a9 - "404": &a24 + "404": &a25 description: Biomarker not found (or owned by another user). content: application/json: @@ -13573,8 +13592,8 @@ paths: $ref: "#/components/schemas/UpdateBiomarkerResponse" "401": *a1 "403": *a9 - "404": *a24 - "409": *a25 + "404": *a25 + "409": *a26 "422": *a4 "429": *a5 delete: @@ -13597,7 +13616,7 @@ paths: schema: $ref: "#/components/schemas/DeleteBiomarkerResponse" "401": *a1 - "404": *a24 + "404": *a25 "422": *a4 "429": *a5 /api/encounters: @@ -13736,7 +13755,7 @@ paths: $ref: "#/components/schemas/GetEncounterEnvelope" "401": *a1 "403": *a9 - "404": &a26 + "404": &a27 description: Visit not found (or owned by another user). content: application/json: @@ -13770,7 +13789,7 @@ paths: $ref: "#/components/schemas/UpdateEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13795,7 +13814,7 @@ paths: $ref: "#/components/schemas/DeleteEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/encounters/{id}/restore: @@ -13821,7 +13840,7 @@ paths: $ref: "#/components/schemas/RestoreEncounterEnvelope" "401": *a1 "403": *a9 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/encounters/{id}/links: @@ -13851,7 +13870,7 @@ paths: schema: $ref: "#/components/schemas/LinkEncounterEnvelope" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 delete: @@ -13880,7 +13899,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkEncounterEnvelope" "401": *a1 - "404": *a26 + "404": *a27 "422": *a4 "429": *a5 /api/practitioners: @@ -13959,7 +13978,7 @@ paths: $ref: "#/components/schemas/GetPractitionerEnvelope" "401": *a1 "403": *a9 - "404": &a27 + "404": &a28 description: Practitioner not found (or owned by another user). content: application/json: @@ -13993,7 +14012,7 @@ paths: $ref: "#/components/schemas/UpdatePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 delete: @@ -14017,7 +14036,7 @@ paths: $ref: "#/components/schemas/DeletePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/practitioners/{id}/restore: @@ -14043,7 +14062,7 @@ paths: $ref: "#/components/schemas/RestorePractitionerEnvelope" "401": *a1 "403": *a9 - "404": *a27 + "404": *a28 "422": *a4 "429": *a5 /api/vaccinations: @@ -14138,7 +14157,7 @@ paths: $ref: "#/components/schemas/GetVaccinationEnvelope" "401": *a1 "403": *a9 - "404": &a28 + "404": &a29 description: Vaccination not found (or owned by another user). content: application/json: @@ -14173,7 +14192,7 @@ paths: $ref: "#/components/schemas/UpdateVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14198,7 +14217,7 @@ paths: $ref: "#/components/schemas/DeleteVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/{id}/restore: @@ -14223,7 +14242,7 @@ paths: $ref: "#/components/schemas/RestoreVaccinationEnvelope" "401": *a1 "403": *a9 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/{id}/booster: @@ -14289,7 +14308,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/vaccinations/suggest: @@ -14347,7 +14366,7 @@ paths: schema: $ref: "#/components/schemas/LinkVaccinationEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 delete: @@ -14376,7 +14395,7 @@ paths: schema: $ref: "#/components/schemas/UnlinkVaccinationEnvelope" "401": *a1 - "404": *a28 + "404": *a29 "422": *a4 "429": *a5 /api/illness/episodes: @@ -14460,7 +14479,7 @@ paths: $ref: "#/components/schemas/GetIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": &a29 + "404": &a30 description: Illness episode not found (or owned by another user). content: application/json: @@ -14495,7 +14514,7 @@ paths: $ref: "#/components/schemas/UpdateIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 delete: @@ -14519,7 +14538,7 @@ paths: $ref: "#/components/schemas/DeleteIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/restore: @@ -14547,7 +14566,7 @@ paths: $ref: "#/components/schemas/RestoreIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "409": *a7 "422": *a4 "429": *a5 @@ -14580,7 +14599,7 @@ paths: $ref: "#/components/schemas/ResolveIllnessEpisodeEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/day-logs: @@ -14631,7 +14650,7 @@ paths: $ref: "#/components/schemas/GetIllnessDayLogEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 post: @@ -14668,7 +14687,7 @@ paths: $ref: "#/components/schemas/UpsertIllnessDayLogCreatedEnvelope" "401": *a1 "403": *a9 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/episodes/{id}/correlation: @@ -14695,7 +14714,7 @@ paths: schema: $ref: "#/components/schemas/IllnessCorrelationEnvelope" "401": *a1 - "404": *a29 + "404": *a30 "422": *a4 "429": *a5 /api/illness/insights: @@ -14911,7 +14930,7 @@ paths: $ref: "#/components/schemas/GetAllergyEnvelope" "401": *a1 "403": *a9 - "404": &a30 + "404": &a31 description: Allergy not found (or owned by another user). content: application/json: @@ -14945,7 +14964,7 @@ paths: $ref: "#/components/schemas/UpdateAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 delete: @@ -14969,7 +14988,7 @@ paths: $ref: "#/components/schemas/DeleteAllergyEnvelope" "401": *a1 "403": *a9 - "404": *a30 + "404": *a31 "422": *a4 "429": *a5 /api/family-history: @@ -15043,7 +15062,7 @@ paths: $ref: "#/components/schemas/GetFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": &a31 + "404": &a32 description: Family history entry not found (or owned by another user). content: application/json: @@ -15077,7 +15096,7 @@ paths: $ref: "#/components/schemas/UpdateFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 delete: @@ -15101,7 +15120,7 @@ paths: $ref: "#/components/schemas/DeleteFamilyHistoryEnvelope" "401": *a1 "403": *a9 - "404": *a31 + "404": *a32 "422": *a4 "429": *a5 /api/documents/inbound: @@ -15996,7 +16015,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricResponse" "401": *a1 - "409": &a33 + "409": &a34 description: A custom metric with this name already exists for the caller. content: application/json: @@ -16025,7 +16044,7 @@ paths: $ref: "#/components/schemas/GetCustomMetricResponse" "401": *a1 "403": *a9 - "404": &a32 + "404": &a33 description: Custom metric not found (or owned by another user). content: application/json: @@ -16059,8 +16078,8 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricResponse" "401": *a1 - "404": *a32 - "409": *a33 + "404": *a33 + "409": *a34 "422": *a4 "429": *a5 delete: @@ -16083,7 +16102,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricResponse" "401": *a1 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries: @@ -16127,7 +16146,7 @@ paths: $ref: "#/components/schemas/ListCustomMetricEntriesEnvelope" "401": *a1 "403": *a9 - "404": *a32 + "404": *a33 "422": *a4 "429": *a5 post: @@ -16156,7 +16175,7 @@ paths: schema: $ref: "#/components/schemas/CreateCustomMetricEntryResponse" "401": *a1 - "404": *a32 + "404": *a33 "409": *a7 "422": *a4 "429": *a10 @@ -16192,7 +16211,7 @@ paths: schema: $ref: "#/components/schemas/UpdateCustomMetricEntryResponse" "401": *a1 - "404": &a34 + "404": &a35 description: Custom metric entry not found (or owned by another user). content: application/json: @@ -16225,7 +16244,7 @@ paths: schema: $ref: "#/components/schemas/DeleteCustomMetricEntryResponse" "401": *a1 - "404": *a34 + "404": *a35 "422": *a4 "429": *a5 /api/custom-metrics/{id}/entries/restore: @@ -16634,7 +16653,7 @@ paths: schema: $ref: "#/components/schemas/NutrientOverviewEnvelope" "401": *a1 - "403": &a35 + "403": &a36 description: >- The opt-in `nutrients` module is off for this account (errorCode `module.disabled`). Stop syncing and do not retry; offer the enable-in-settings hint. Only run the HealthKit read-authorization flow while the module is @@ -16689,7 +16708,7 @@ paths: schema: $ref: "#/components/schemas/NutrientDailySeriesEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "422": *a4 "429": *a5 /api/nutrients/water: @@ -16718,7 +16737,7 @@ paths: schema: $ref: "#/components/schemas/NutrientWaterWriteEnvelope" "401": *a1 - "403": *a35 + "403": *a36 "409": *a7 "422": *a4 "429": *a5 @@ -17220,26 +17239,26 @@ paths: application/json: schema: $ref: "#/components/schemas/WithingsCredentialsUpdateEnvelope" - "400": &a36 + "400": &a37 description: Body is not parseable JSON. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "413": &a37 + "413": &a38 description: Body exceeds 64 KiB. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "415": &a38 + "415": &a39 description: Content-Type is not `application/json`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": &a39 + "422": &a40 description: "`clientId` and `clientSecret` are both required and each must be 1–200 characters. The envelope carries a single fixed message, not per-field issues." content: @@ -17425,11 +17444,11 @@ paths: application/json: schema: $ref: "#/components/schemas/FitbitCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17572,11 +17591,11 @@ paths: application/json: schema: $ref: "#/components/schemas/GoogleHealthCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17729,11 +17748,11 @@ paths: application/json: schema: $ref: "#/components/schemas/OuraCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17809,21 +17828,21 @@ paths: schema: $ref: "#/components/schemas/OuraTestEnvelope" "401": *a1 - "422": &a40 + "422": &a41 description: No usable connection for this provider — nothing stored, or the stored grant could no longer be resolved into an access token. `meta.errorCode` = `not_configured`. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "429": &a41 + "429": &a42 description: More than 5 requests in 60 s from this user. `meta.errorCode` = `rate_limited_self` — this is the local limiter, distinct from a `rate_limited` 502 which is the provider's. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "502": &a42 + "502": &a43 description: "The probe did not complete. `meta.errorCode` names the class: `credentials_rejected` (the provider answered 401/403 — the stored grant is gone), `rate_limited` (the PROVIDER answered 429, which is not this endpoint's own limiter), `upstream_error` (5xx), `timeout` (no answer within 5 s), `connection_failed` @@ -17868,11 +17887,11 @@ paths: application/json: schema: $ref: "#/components/schemas/PolarCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -17948,9 +17967,9 @@ paths: schema: $ref: "#/components/schemas/PolarTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/strava/credentials: get: tags: @@ -17986,11 +18005,11 @@ paths: application/json: schema: $ref: "#/components/schemas/StravaCredentialsUpdateEnvelope" - "400": *a36 + "400": *a37 "401": *a1 - "413": *a37 - "415": *a38 - "422": *a39 + "413": *a38 + "415": *a39 + "422": *a40 "429": *a5 delete: tags: @@ -18068,9 +18087,9 @@ paths: schema: $ref: "#/components/schemas/StravaTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/status: get: tags: @@ -18106,7 +18125,7 @@ paths: $ref: "#/components/schemas/FitbitResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/fitbit/test: post: tags: @@ -18124,9 +18143,9 @@ paths: schema: $ref: "#/components/schemas/FitbitIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/google-health/resume: post: tags: @@ -18143,7 +18162,7 @@ paths: $ref: "#/components/schemas/GoogleHealthResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/google-health/test: post: tags: @@ -18176,9 +18195,9 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/whoop/resume: post: tags: @@ -18195,7 +18214,7 @@ paths: $ref: "#/components/schemas/WhoopResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/whoop/test: post: tags: @@ -18212,9 +18231,9 @@ paths: schema: $ref: "#/components/schemas/WhoopIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 - "502": *a42 + "422": *a41 + "429": *a42 + "502": *a43 /api/integrations/withings/resume: post: tags: @@ -18232,7 +18251,7 @@ paths: $ref: "#/components/schemas/WithingsResumeEnvelope" "401": *a1 "422": *a4 - "429": *a41 + "429": *a42 /api/integrations/withings/test: post: tags: @@ -18250,8 +18269,8 @@ paths: schema: $ref: "#/components/schemas/WithingsIntegrationTestEnvelope" "401": *a1 - "422": *a40 - "429": *a41 + "422": *a41 + "429": *a42 "502": description: "The probe did not complete. The shared codes apply (`credentials_rejected`, `rate_limited`, `upstream_error`, `timeout`, `connection_failed`) and this path adds one of its own: @@ -18315,7 +18334,7 @@ paths: schema: $ref: "#/components/schemas/DismissPriorityItemEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/dashboard/chart-overlay-prefs: @@ -18488,7 +18507,7 @@ paths: schema: $ref: "#/components/schemas/RecordActivityEnvelope" "401": *a1 - "403": &a43 + "403": &a44 description: Refused. `meta.errorCode` is `sharing.not_permitted` when the request was made while acting on another account (grant management is never delegable), or `sharing.access.denied` when the caller may not act as the account they named. The latter is byte-identical for an account that does not exist and one that granted @@ -18513,7 +18532,7 @@ paths: schema: $ref: "#/components/schemas/AccountGrantListEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "422": *a4 "429": *a5 post: @@ -18608,7 +18627,7 @@ paths: schema: $ref: "#/components/schemas/AcceptedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such invitation for this account. content: @@ -18652,7 +18671,7 @@ paths: schema: $ref: "#/components/schemas/RevokedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such grant on this account's record. content: @@ -18693,7 +18712,7 @@ paths: schema: $ref: "#/components/schemas/RenouncedAccountGrantEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "404": description: No such grant held by this account. content: @@ -19075,7 +19094,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a43 + "403": *a44 "409": description: "The record this browser session is on is not the record the request asserted. `meta.errorCode` is `sharing.session.changed`. It is not a refusal of access — the caller may well be entitled to both records — @@ -19405,7 +19424,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionListEnvelope" "401": *a1 - "403": &a44 + "403": &a45 description: The operator has disabled the API instance-wide. Every route in this module refuses while that setting is off, for a cookie session as well as for a Bearer token; it is not a per-user permission and retrying will not help. @@ -19438,7 +19457,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectionRevokeEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No live connection with this id for this caller — also the answer for an already-revoked one, so a repeat revoke is 404 rather than an idempotent 200. @@ -19465,7 +19484,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenListEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "422": *a4 "429": *a5 post: @@ -19495,7 +19514,7 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "413": description: Body exceeds 16 KiB. content: @@ -19538,7 +19557,7 @@ paths: schema: $ref: "#/components/schemas/McpConnectorTokenRevokeEnvelope" "401": *a1 - "403": *a44 + "403": *a45 "404": description: No `health:read` token with this id for this caller. An already-revoked token is still found, so a repeat revoke answers 200. @@ -19596,7 +19615,7 @@ paths: description: Returns the named family's resolved settings for the record the session is acting on. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - &a45 + - &a46 name: family in: path required: true @@ -19640,7 +19659,7 @@ paths: ride the audit row. Body capped at 64 KiB. Cookie session or Bearer, while acting on a managed record the caller guards. parameters: - - *a45 + - *a46 requestBody: required: true content: @@ -19792,7 +19811,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentOverviewEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": *a4 "429": *a5 /api/environment/home: @@ -19829,7 +19848,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentHomeEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -19883,7 +19902,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentBackfillEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "409": description: No home location is set, so there is nothing to resolve the days against. `meta.errorCode` = `environment.no_home`. @@ -19942,7 +19961,7 @@ paths: schema: $ref: "#/components/schemas/EnvironmentGeocodeEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "422": description: "`q` was missing, empty after trimming, or longer than 120 characters. `meta.errorCode` = `environment.invalid`." @@ -19981,7 +20000,7 @@ paths: schema: $ref: "#/components/schemas/CreateEnvironmentTravelEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "413": description: Body exceeds 4 KiB. content: @@ -20034,7 +20053,7 @@ paths: schema: $ref: "#/components/schemas/DeleteEnvironmentTravelEnvelope" "401": *a1 - "403": *a15 + "403": *a16 "404": description: No such override for this account. `meta.errorCode` = `environment.travel.not_found`. Also the answer for an id owned by somebody else — existence is not leaked. @@ -20175,13 +20194,13 @@ paths: application/json: schema: $ref: "#/components/schemas/AdminBackupDocument" - "403": &a46 + "403": &a47 description: Caller is not an admin. `requireAdmin()` is cookie-only, so a Bearer token lands here whatever its scope. content: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "404": &a47 + "404": &a48 description: No stored copy with this id. content: application/json: @@ -20240,8 +20259,8 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" - "403": *a46 - "404": *a47 + "403": *a47 + "404": *a48 "409": description: Either the payload declares a different owner than the stored row, or a request under the same `Idempotency-Key` is still in flight. Nothing was changed. @@ -29966,9 +29985,9 @@ components: onboarding.complete.recordNotFound, onboarding.complete.recordTargetMismatch, onboarding.disclaimer.invalid, onboarding.restart.invalid, onboarding.restart.rateLimited, onboarding.tour.invalid; personal_records — personal_records.invalid_query; practitioner — - practitioner.invalid; profile — profile.update.emailInUse, profile.update.invalidBody, - profile.update.nothingSaved; record_write — record_write.rate_limited; report-selection — - report-selection.body.invalid_json, report-selection.body.invalid_shape, + practitioner.invalid; profile — profile.update.emailInUse, profile.update.emailRateLimited, + profile.update.invalidBody, profile.update.nothingSaved; record_write — record_write.rate_limited; + report-selection — report-selection.body.invalid_json, report-selection.body.invalid_shape, report-selection.leaves.unknown; route — route.retired; share-link — share-link.document.decryptFailed, share-link.document.notFound, share-link.document.rateLimited, share-link.documents.invalid, share-link.report.notFound, share-link.report.rateLimited, share-link.selection.forbidden_leaf, diff --git a/messages/de.json b/messages/de.json index 0248279e8..ec56cb656 100644 --- a/messages/de.json +++ b/messages/de.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Es wurde nichts gespeichert. Korrigiere das markierte Feld und versuche es erneut.", "invalidBody": "Die Profilaktualisierung konnte nicht gelesen werden.", - "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet." + "emailInUse": "Diese E-Mail-Adresse wird bereits verwendet.", + "emailRateLimited": "Zu viele Änderungen der E-Mail-Adresse. Versuche es später erneut." } } }, diff --git a/messages/en.json b/messages/en.json index c2914500e..74b9370da 100644 --- a/messages/en.json +++ b/messages/en.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Nothing was saved. Check the highlighted field and try again.", "invalidBody": "The profile update could not be read.", - "emailInUse": "This email address is already in use." + "emailInUse": "This email address is already in use.", + "emailRateLimited": "Too many email-address changes. Try again later." } } }, diff --git a/messages/es.json b/messages/es.json index 6333bee7e..3bfb7f622 100644 --- a/messages/es.json +++ b/messages/es.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "No se guardó nada. Revisa el campo marcado e inténtalo de nuevo.", "invalidBody": "No se pudo leer la actualización del perfil.", - "emailInUse": "Esta dirección de correo ya está en uso." + "emailInUse": "Esta dirección de correo ya está en uso.", + "emailRateLimited": "Demasiados cambios de dirección de correo. Inténtalo más tarde." } } }, diff --git a/messages/fr.json b/messages/fr.json index f1ac4a8f8..479150e26 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Rien n'a été enregistré. Corrigez le champ signalé et réessayez.", "invalidBody": "La mise à jour du profil n'a pas pu être lue.", - "emailInUse": "Cette adresse e-mail est déjà utilisée." + "emailInUse": "Cette adresse e-mail est déjà utilisée.", + "emailRateLimited": "Trop de changements d'adresse e-mail. Réessayez plus tard." } } }, diff --git a/messages/it.json b/messages/it.json index 39910e728..17321d575 100644 --- a/messages/it.json +++ b/messages/it.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Non è stato salvato nulla. Controlla il campo evidenziato e riprova.", "invalidBody": "Impossibile leggere l'aggiornamento del profilo.", - "emailInUse": "Questo indirizzo email è già in uso." + "emailInUse": "Questo indirizzo email è già in uso.", + "emailRateLimited": "Troppi cambi di indirizzo email. Riprova più tardi." } } }, diff --git a/messages/ko.json b/messages/ko.json index a6a81dc1c..7c49082af 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "저장된 내용이 없어요. 표시된 항목을 확인하고 다시 시도해 주세요.", "invalidBody": "프로필 변경 내용을 읽지 못했어요.", - "emailInUse": "이미 사용 중인 이메일 주소예요." + "emailInUse": "이미 사용 중인 이메일 주소예요.", + "emailRateLimited": "이메일 주소를 너무 자주 변경했어요. 잠시 후 다시 시도해 주세요." } } }, diff --git a/messages/pl.json b/messages/pl.json index 405ec5665..9ac7a0414 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -125,7 +125,8 @@ "update": { "nothingSaved": "Nic nie zostało zapisane. Sprawdź oznaczone pole i spróbuj ponownie.", "invalidBody": "Nie udało się odczytać aktualizacji profilu.", - "emailInUse": "Ten adres e-mail jest już używany." + "emailInUse": "Ten adres e-mail jest już używany.", + "emailRateLimited": "Zbyt wiele zmian adresu e-mail. Spróbuj ponownie później." } } }, diff --git a/src/__tests__/profile-email-rate-limit-contract.test.ts b/src/__tests__/profile-email-rate-limit-contract.test.ts new file mode 100644 index 000000000..5245581c1 --- /dev/null +++ b/src/__tests__/profile-email-rate-limit-contract.test.ts @@ -0,0 +1,132 @@ +/** + * The profile email-address ceiling is the one the contract describes. + * + * Changing the address asks whether another account already holds it, and the + * answer is a 409 versus a 200 — an existence check over every address on the + * instance, available to any signed-in caller, including the native client's + * own wildcard token. Neither route that reaches it had a ceiling of any kind, + * and `apiHandler` supplies no default, so the sweep was bounded only by the + * network. + * + * A published number is worth reading only if it cannot drift from the code, + * so this pulls the bucket key and the window out of `prisma.$queryRaw` — the + * technique `rate-limit-auth-surface.test.ts` and the record-write contract + * both use — and holds the emitted OpenAPI description to what came back. + * Reading both from the same constant would prove nothing: the sentence is + * hand-written, deliberately, and this is what keeps the hand-written half + * honest. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { + PROFILE_EMAIL_BUCKET_PREFIX, + PROFILE_EMAIL_LIMIT, + PROFILE_EMAIL_WINDOW_MS, + checkProfileEmailRateLimit, +} from "@/lib/rate-limit"; +import { buildOpenApiDocument } from "@/lib/openapi/registry"; +import { profileEmailRateLimitResponse } from "@/lib/openapi/routes/shared"; +import { ERROR_CODE_CATALOGUE } from "@/lib/openapi/error-codes"; + +vi.mock("@/lib/db", () => ({ prisma: { $queryRaw: vi.fn() } })); + +import { prisma } from "@/lib/db"; + +/** The published sentence, read from the object the route modules spread. */ +const published = profileEmailRateLimitResponse["429"].description; + +/** Both doors onto the one handler that answers the conflict. */ +const OPERATIONS: Array<[path: string, method: "put" | "patch"]> = [ + ["/api/auth/profile", "put"], + ["/api/user/profile", "patch"], +]; + +beforeEach(() => { + vi.mocked(prisma.$queryRaw).mockReset(); + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); +}); + +describe("the profile email-address bucket", () => { + it("keys on the acting account under the name the contract publishes", async () => { + await checkProfileEmailRateLimit("account-7"); + + const call = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)!; + // `$queryRaw`INSERT … VALUES (${key}, …)`` lands the key at index 1 and + // the window interval at index 2. Read from the query rather than from + // the constant, so a renamed prefix that never reached the SQL fails. + expect(call[1]).toBe("profile-email:account-7"); + expect(call[2]).toBe(`${PROFILE_EMAIL_WINDOW_MS} milliseconds`); + expect(`${PROFILE_EMAIL_BUCKET_PREFIX}:account-7`).toBe(call[1]); + expect(published).toContain("`profile-email:`"); + }); + + it("gives a different account a different bucket, not a shared one", async () => { + await checkProfileEmailRateLimit("account-7"); + const first = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1]; + await checkProfileEmailRateLimit("account-8"); + const second = vi.mocked(prisma.$queryRaw).mock.calls.at(-1)![1]; + expect(first).not.toBe(second); + }); + + it("refuses at the cap the contract publishes", async () => { + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { count: PROFILE_EMAIL_LIMIT, reset_at: new Date(Date.now() + 60_000) }, + ] as never); + await expect( + checkProfileEmailRateLimit("account-7"), + ).resolves.toMatchObject({ + allowed: true, + limit: PROFILE_EMAIL_LIMIT, + remaining: 0, + }); + + vi.mocked(prisma.$queryRaw).mockResolvedValueOnce([ + { + count: PROFILE_EMAIL_LIMIT + 1, + reset_at: new Date(Date.now() + 60_000), + }, + ] as never); + await expect( + checkProfileEmailRateLimit("account-7"), + ).resolves.toMatchObject({ allowed: false }); + + const windowSeconds = PROFILE_EMAIL_WINDOW_MS / 1000; + expect(published).toContain( + `${PROFILE_EMAIL_LIMIT} requests per ${windowSeconds} seconds`, + ); + }); + + it("says the headers describe that bucket, and declares them", () => { + expect(Object.keys(profileEmailRateLimitResponse["429"].headers)).toEqual([ + "Retry-After", + "X-RateLimit-Limit", + "X-RateLimit-Remaining", + "X-RateLimit-Reset", + ]); + expect(published).toContain("Retry-After"); + }); + + it("publishes a code a client can branch on, and registers it", () => { + expect(published).toContain("profile.update.emailRateLimited"); + expect(ERROR_CODE_CATALOGUE.profile).toContain( + "profile.update.emailRateLimited", + ); + }); + + it("is published on both operations that answer it", () => { + const doc = buildOpenApiDocument() as unknown as { + paths: Record< + string, + Record }> + >; + }; + const unpublished = OPERATIONS.filter( + ([path, method]) => + doc.paths[path]?.[method]?.responses?.["429"]?.description !== + published, + ).map(([path]) => path); + expect(unpublished).toEqual([]); + }); +}); diff --git a/src/lib/auth/__tests__/profile-update-email-limit.test.ts b/src/lib/auth/__tests__/profile-update-email-limit.test.ts new file mode 100644 index 000000000..0a87fadc7 --- /dev/null +++ b/src/lib/auth/__tests__/profile-update-email-limit.test.ts @@ -0,0 +1,148 @@ +/** + * The email-address conflict answer is metered, and metered before it answers. + * + * `applyProfileUpdate` is the one place both profile routes reach, and its + * 409 tells any signed-in caller whether a given address belongs to an + * account on this instance. Until this bucket existed neither route had a + * ceiling of any kind — `apiHandler` supplies none — so one account could + * walk an address book at whatever rate the network allowed. + * + * Three things are pinned here. The charge happens BEFORE the uniqueness + * probe, so a refused request never learns the answer it was refused for. + * A save that carries the address already on file is not charged at all, so + * a settings form that re-posts the whole profile does not spend an honest + * afternoon of edits on the ceiling. And the refusal carries the code the + * contract publishes. + */ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/db", () => ({ + prisma: { + user: { findUnique: vi.fn(), update: vi.fn() }, + }, +})); + +vi.mock("@/lib/auth/audit", () => ({ + auditLog: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@/lib/rate-limit", () => ({ + checkProfileEmailRateLimit: vi.fn(), +})); + +import { applyProfileUpdate } from "../profile-update"; +import { prisma } from "@/lib/db"; +import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; + +const USER_ID = "user-1"; +const ON_FILE = "mine@example.test"; + +/** The `where` clauses the helper can send, told apart by their shape. */ +function selfLookups() { + return vi + .mocked(prisma.user.findUnique) + .mock.calls.filter((c) => "id" in (c[0]!.where as object)); +} +function emailProbes() { + return vi + .mocked(prisma.user.findUnique) + .mock.calls.filter((c) => "email" in (c[0]!.where as object)); +} + +beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(prisma.user.update).mockResolvedValue({ + id: USER_ID, + username: "u", + email: ON_FILE, + } as never); + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: true, + limit: 10, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return null; + }) as never); +}); + +describe("applyProfileUpdate — the email conflict is metered", () => { + it("charges the acting account before asking about the address", async () => { + await applyProfileUpdate(USER_ID, { email: "someone@example.test" }); + + expect(checkProfileEmailRateLimit).toHaveBeenCalledWith(USER_ID); + expect(emailProbes()).toHaveLength(1); + // Ordering, not just presence: the probe must not have run first. + const chargedAt = vi.mocked(checkProfileEmailRateLimit).mock + .invocationCallOrder[0]; + const probedAt = vi.mocked(prisma.user.findUnique).mock.invocationCallOrder[ + selfLookups().length + ]; + expect(chargedAt).toBeLessThan(probedAt); + }); + + it("refuses with 429 and the published code, without asking about the address", async () => { + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + + const result = await applyProfileUpdate(USER_ID, { + email: "someone@example.test", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(429); + expect(result.errorCode).toBe("profile.update.emailRateLimited"); + } + // The whole point: a refused caller is told nothing about the address. + expect(emailProbes()).toHaveLength(0); + expect(prisma.user.update).not.toHaveBeenCalled(); + }); + + it("does not charge a save that carries the address already on file", async () => { + const result = await applyProfileUpdate(USER_ID, { + // Same address, differently typed — normalisation is what decides. + email: " Mine@Example.Test ", + heightCm: 180, + }); + + expect(result.ok).toBe(true); + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + expect(emailProbes()).toHaveLength(0); + expect(prisma.user.update).toHaveBeenCalledTimes(1); + }); + + it("does not charge an update that never touches the address", async () => { + await applyProfileUpdate(USER_ID, { heightCm: 181 }); + + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + }); + + it("still answers 409 when the bucket has room and the address is taken", async () => { + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return { id: "someone-else" }; + }) as never); + + const result = await applyProfileUpdate(USER_ID, { + email: "taken@example.test", + }); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(409); + expect(result.errorCode).toBe("profile.update.emailInUse"); + } + expect(checkProfileEmailRateLimit).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/lib/auth/__tests__/profile-update.test.ts b/src/lib/auth/__tests__/profile-update.test.ts index 89c75b2cb..eb89c0ff4 100644 --- a/src/lib/auth/__tests__/profile-update.test.ts +++ b/src/lib/auth/__tests__/profile-update.test.ts @@ -6,6 +6,9 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), update: vi.fn(), }, + // The email-address change is metered before it asks whether another + // account holds the address; the limiter talks to Postgres directly. + $queryRaw: vi.fn(), }, })); @@ -34,6 +37,10 @@ const STUB_USER = { beforeEach(() => { vi.resetAllMocks(); vi.mocked(prisma.user.update).mockResolvedValue(STUB_USER as never); + // A bucket with room left, unless a test says otherwise. + vi.mocked(prisma.$queryRaw).mockResolvedValue([ + { count: 1, reset_at: new Date(Date.now() + 60_000) }, + ] as never); }); describe("applyProfileUpdate timezone validation", () => { diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 58c4adbf3..8c5257caf 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -13,6 +13,7 @@ import { invalidateUserProfile, } from "@/lib/cache/invalidate"; import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response"; +import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -238,17 +239,45 @@ export async function applyProfileUpdate( const normalizedEmail = data.email ? data.email.trim().toLowerCase() : null; if (data.email !== undefined && normalizedEmail) { - const existing = await prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true }, + // A settings form re-posts the whole profile, so the address arrives + // with every save whether or not it changed. Those saves ask nothing + // about anyone else's account — the address on file already belongs to + // this caller — so they skip both the uniqueness probe and the budget. + // Charging them would spend an honest afternoon of edits on the + // ceiling and lock the account out of its own settings page. + const current = await prisma.user.findUnique({ + where: { id: userId }, + select: { email: true }, }); - if (existing && existing.id !== userId) { - return { - ok: false, - status: 409, - message: "Email already in use", - errorCode: "profile.update.emailInUse", - }; + + if (current?.email !== normalizedEmail) { + // Charged BEFORE the probe, not after: a refused request must not + // learn the answer it was refused for. The 409 below tells any + // signed-in caller whether an address is registered here, and until + // this bucket existed neither route that reaches it had a ceiling of + // any kind — `apiHandler` supplies no default. + const rl = await checkProfileEmailRateLimit(userId); + if (!rl.allowed) { + return { + ok: false, + status: 429, + message: "Too many email-address changes. Try again later.", + errorCode: "profile.update.emailRateLimited", + }; + } + + const existing = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing && existing.id !== userId) { + return { + ok: false, + status: 409, + message: "Email already in use", + errorCode: "profile.update.emailInUse", + }; + } } } diff --git a/src/lib/openapi/error-codes.ts b/src/lib/openapi/error-codes.ts index ade5c0dec..7b1f82e55 100644 --- a/src/lib/openapi/error-codes.ts +++ b/src/lib/openapi/error-codes.ts @@ -250,6 +250,7 @@ export const ERROR_CODE_CATALOGUE: Readonly> = practitioner: ["practitioner.invalid"], profile: [ "profile.update.emailInUse", + "profile.update.emailRateLimited", "profile.update.invalidBody", "profile.update.nothingSaved", ], diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index c617de77d..da312160e 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -38,6 +38,7 @@ import { } from "@/lib/validations/step-up"; import { dataEnvelope, + profileEmailRateLimitResponse, stdResponses, errorEnvelope, loginPasswordSchema, @@ -1766,6 +1767,7 @@ export const authPaths: NonNullable = { content: { "application/json": { schema: errorEnvelope } }, }, ...stdResponses, + ...profileEmailRateLimitResponse, "422": { description: "Either the body was not a usable object (`meta.errorCode` = `profile.update.invalidBody`) or every supplied field was rejected (`profile.update.nothingSaved`). Both carry the per-field issue list. A body where SOME fields validate never reaches this response — it succeeds with `rejectedFields`.", diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index f8ee62338..b230d82e5 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -46,6 +46,7 @@ import { errorEnvelope, invalidBaseTokenResponse, malformedJsonResponse, + profileEmailRateLimitResponse, recordRefusal, stdResponses, updatedAtTokenField, @@ -1476,7 +1477,13 @@ export const profilePaths: NonNullable = { }, }, }, + "409": { + description: + "The requested email belongs to another account (`meta.errorCode` = `profile.update.emailInUse`). Nothing was written. Shared with PUT /api/auth/profile, which runs the same handler.", + content: { "application/json": { schema: errorEnvelope } }, + }, ...stdResponses, + ...profileEmailRateLimitResponse, }, }, }, diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index a97b71a82..324e165cd 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -318,6 +318,32 @@ export const recordWriteRateLimitResponse = { }, }; +/** + * The 429 the profile email-address bucket answers with. + * + * Changing the address asks whether another account already holds it, and the + * answer is a 409 versus a 200 — an existence check over every address on the + * instance, available to any signed-in caller. Neither route that reaches it + * had a ceiling of any kind, and `apiHandler` supplies no default, so the + * sweep was bounded only by the network. This is the response that names the + * bucket now capping it. + * + * Spread AFTER `...stdResponses` so it replaces the generic 429 on those two + * operations. The headers block is the one the standard 429 already declares. + * + * `profile-email-rate-limit-contract.test.ts` holds the numbers in this + * sentence to the constants in `src/lib/rate-limit.ts`, so the paragraph + * cannot drift away from the bucket it describes. + */ +export const profileEmailRateLimitResponse = { + "429": { + description: + "Too many email-address changes. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. Nothing was written. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.", + content: { "application/json": { schema: errorEnvelope } }, + headers: stdResponses["429"].headers, + }, +}; + // ── Idempotent writes ──────────────────────────────────────────────── /** diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5379eb2bc..5021c28f3 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -160,6 +160,43 @@ export async function checkRecordWriteRateLimit( ); } +/** + * Per-account ceiling for the profile email-address change. + * + * Changing the address means asking whether another account already holds + * it, and the answer to that question is 409 versus 200 — an existence check + * over every address on the instance, available to any signed-in caller. The + * two routes that reach it (`PUT /api/auth/profile`, `PATCH + * /api/user/profile`) had no ceiling at all and `apiHandler` supplies none, + * so a single account could walk an address book at whatever rate it liked. + * + * Keyed on the acting account rather than on the address, the frozen + * precedent from the record writes: an attacker cannot collect a fresh + * allowance by switching the address they probe, and one account's sweep + * cannot lock a stranger out of their own settings page. + * + * Ten an hour is far above honest use — an address is changed once in a + * while, not ten times a day — and it is only charged when the submitted + * address actually differs from the one on file, so re-saving a settings + * form that carries the unchanged address never spends the budget. What it + * ends is the unbounded sweep: two hundred and forty questions a day per + * account, each with an audit row beside it, instead of as many as the + * network will carry. + */ +export const PROFILE_EMAIL_BUCKET_PREFIX = "profile-email"; +export const PROFILE_EMAIL_LIMIT = 10; +export const PROFILE_EMAIL_WINDOW_MS = 60 * 60 * 1000; + +export async function checkProfileEmailRateLimit( + accountId: string, +): Promise { + return checkRateLimit( + `${PROFILE_EMAIL_BUCKET_PREFIX}:${accountId}`, + PROFILE_EMAIL_LIMIT, + PROFILE_EMAIL_WINDOW_MS, + ); +} + /** * v1.22.0 — per-credential ceiling for the remote MCP endpoint (`/mcp`). * The bucket is keyed by the `:` binding the Bearer From 80426697db77a9eb9fe4de91b09394b290cc82f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:36:19 +0200 Subject: [PATCH 4/9] Record why registration checks duplicates before hashing With the discovery endpoint gone, this is the remaining anonymous path that answers whether an address is registered. The 409 already gives that away, so the work asymmetry ahead of it costs nothing today. It would become the whole answer the moment somebody unified the status code without moving the check after the hash, so the ordering now says it is load-bearing. --- src/app/api/auth/register/route.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index d3221afbc..d89662cbd 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -143,6 +143,16 @@ export const POST = apiHandler(async (request: NextRequest) => { } // Check if email or username already taken (unified message to prevent enumeration) + // + // The ordering is deliberate and load-bearing. This runs BEFORE the password + // strength check, the breach lookup and the Argon2id hash below, so a taken + // address answers after one indexed SELECT and a free one answers after + // zxcvbn plus an outbound round-trip plus tens of milliseconds of hashing. + // The status code already gives the answer away, so today the gap tells a + // caller nothing extra. It would be the whole answer the moment somebody + // unified the 409 into the success shape to close the oracle: the clock + // would then say what the status code no longer does. Anyone doing that has + // to move this block after the hash in the same change. const [existingEmail, existingUsername] = await Promise.all([ prisma.user.findUnique({ where: { email } }), prisma.user.findUnique({ where: { username } }), From 10d6dc8099b6148bf8d5fcaf25ab2c3b60b03311 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:36:27 +0200 Subject: [PATCH 5/9] Guard the dummy mint, not only the dummy verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dummy hash was awaited as an argument to the verifier, so a rejected mint threw before the verifier was reached and before the catch on it could apply. The promise was created once at import, and a rejected promise stays rejected, so one failed mint under memory pressure would have made every unknown identifier answer 500 while a real account answered 401 — a cleaner oracle than the timing gap this closes, and passkey-only accounts locked out of the password form until restart. The mint is now awaited inside the guarded region, is lazy rather than an import-time cost in every process that touches this module, and is dropped on failure so the next refusal mints again instead of inheriting a cached rejection. A failure leaves a log line; the answer is false either way. The tests cover a rejecting mint, not only a rejecting verify. --- .../__tests__/password-dummy-verify.test.ts | 88 ++++++++++++++++--- src/lib/auth/password.ts | 60 +++++++++---- 2 files changed, 121 insertions(+), 27 deletions(-) diff --git a/src/lib/auth/__tests__/password-dummy-verify.test.ts b/src/lib/auth/__tests__/password-dummy-verify.test.ts index 9035bdc8a..482dbeb50 100644 --- a/src/lib/auth/__tests__/password-dummy-verify.test.ts +++ b/src/lib/auth/__tests__/password-dummy-verify.test.ts @@ -19,8 +19,7 @@ */ import { describe, it, expect, vi, beforeEach } from "vitest"; -// Hoisted: `password.ts` mints the dummy hash at module load, so the mock -// has to exist before the import below runs. +// Hoisted so the mock is in place before `password.ts` is imported. const { hashMock, verifyMock } = vi.hoisted(() => ({ hashMock: vi.fn(async (input: string) => `hashed:${input}`), verifyMock: vi.fn(async (_hash: string, _password: string) => false), @@ -28,23 +27,45 @@ const { hashMock, verifyMock } = vi.hoisted(() => ({ vi.mock("@node-rs/argon2", () => ({ hash: hashMock, verify: verifyMock })); -import { verifyPasswordOrDummy } from "@/lib/auth/password"; import { ARGON2_HASH_OPTIONS } from "@/lib/auth/argon2-params.mjs"; -beforeEach(() => { +/** + * The dummy hash is minted once per module instance, so each test gets a + * fresh instance — otherwise the first successful mint is cached and the + * mint-failure cases could never reach the mint at all. + */ +let verifyPasswordOrDummy: ( + storedHash: string | null | undefined, + password: string, +) => Promise; + +beforeEach(async () => { verifyMock.mockReset(); verifyMock.mockResolvedValue(false); + hashMock.mockReset(); + hashMock.mockImplementation(async (input: string) => `hashed:${input}`); + vi.resetModules(); + ({ verifyPasswordOrDummy } = await import("@/lib/auth/password")); }); describe("verifyPasswordOrDummy", () => { - it("mints the dummy hash under the same Argon2id parameters real hashes use", () => { - // The module mints it once at load, so the call has already happened. + it("mints the dummy hash under the same Argon2id parameters real hashes use", async () => { + await verifyPasswordOrDummy(null, "anything"); expect(hashMock).toHaveBeenCalledWith( expect.any(String), ARGON2_HASH_OPTIONS, ); }); + it("mints once and reuses it for every later refusal", async () => { + await verifyPasswordOrDummy(null, "anything"); + await verifyPasswordOrDummy(null, "anything"); + await verifyPasswordOrDummy(undefined, "anything"); + // Three refusals, three verifications, one mint. + expect(verifyMock).toHaveBeenCalledTimes(3); + expect(hashMock).toHaveBeenCalledTimes(1); + }); + it("verifies against the stored hash when the account has one", async () => { verifyMock.mockResolvedValueOnce(true); await expect( @@ -61,9 +82,7 @@ describe("verifyPasswordOrDummy", () => { await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); expect(verifyMock).toHaveBeenCalledTimes(1); // Against the dummy, not against nothing. - expect(verifyMock.mock.calls[0][0]).toBe( - await hashMock.mock.results[0].value, - ); + expect(verifyMock.mock.calls[0][0]).toMatch(/^hashed:/); }); it("still verifies for a passkey-only account (undefined hash)", async () => { @@ -87,7 +106,54 @@ describe("verifyPasswordOrDummy", () => { it("swallows a throw from the dummy arm rather than answering differently", async () => { // A 500 only the unknown-account arm can produce is the same oracle in // a different field. - verifyMock.mockRejectedValueOnce(new Error("malformed hash")); - await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe(false); + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + verifyMock.mockRejectedValueOnce(new Error("malformed hash")); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it("swallows a failed MINT too, not only a failed verify", async () => { + // The mint is awaited as an argument to the verifier, so an unguarded + // `await` on a rejected mint throws before the verifier is reached and + // before any `.catch` on it can apply. That answers 500 on exactly the + // arm that must look ordinary — and a passkey-only account could not + // sign in at all. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + hashMock.mockRejectedValueOnce(new Error("out of memory")); + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(verifyMock).not.toHaveBeenCalled(); + expect(logged).toHaveBeenCalled(); + } finally { + logged.mockRestore(); + } + }); + + it("re-mints after a failure instead of caching the rejection forever", async () => { + // A rejected promise stays rejected for the life of the process. Caching + // one would send every later refusal back down the cheap path with the + // timing channel reopened and nothing to notice it by. + const logged = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + hashMock.mockRejectedValueOnce(new Error("out of memory")); + await verifyPasswordOrDummy(null, "anything"); + expect(verifyMock).not.toHaveBeenCalled(); + + await expect(verifyPasswordOrDummy(null, "anything")).resolves.toBe( + false, + ); + expect(hashMock).toHaveBeenCalledTimes(2); + expect(verifyMock).toHaveBeenCalledTimes(1); + } finally { + logged.mockRestore(); + } }); }); diff --git a/src/lib/auth/password.ts b/src/lib/auth/password.ts index de5665c49..eb3f4ae8e 100644 --- a/src/lib/auth/password.ts +++ b/src/lib/auth/password.ts @@ -31,10 +31,10 @@ export async function verifyPassword( * password. Verifying against this hash on the empty arms makes the work * comparable. * - * Minted once per process from `ARGON2_HASH_OPTIONS`, the same parameters - * every real hash carries, because Argon2 encodes its cost in the hash - * string and the verifier pays whatever the string says. A dummy minted at - * cheaper settings would leave the gap it was added to close. + * Minted from `ARGON2_HASH_OPTIONS`, the same parameters every real hash + * carries, because Argon2 encodes its cost in the hash string and the + * verifier pays whatever the string says. A dummy minted at cheaper settings + * would leave the gap it was added to close. * * The input is a fixed constant rather than a random string so the module * has no hidden state, and it is never compared to anything: the arm that @@ -42,13 +42,33 @@ export async function verifyPassword( * path on which a caller can make this hash accept. */ const DUMMY_VERIFY_INPUT = "healthlog: not a credential, never accepted"; -const DUMMY_PASSWORD_HASH: Promise = hash( - DUMMY_VERIFY_INPUT, - ARGON2_HASH_OPTIONS, -); -// Keep a mint failure from surfacing as an unhandled rejection at import -// time; `verifyPasswordOrDummy` still awaits and still sees it. -void DUMMY_PASSWORD_HASH.catch(() => undefined); + +let dummyHashPromise: Promise | null = null; + +/** + * The dummy hash, minted on first use and kept for the process afterwards. + * + * Lazy rather than minted at import: every process that imports this module + * would otherwise spend 19 MiB and a thread-pool slot at boot, including the + * queue worker and each build worker, none of which ever authenticates + * anybody. From the second refusal onward the cost profile is identical + * either way. + * + * A rejected promise stays rejected for the life of the process, so caching + * one would degrade every later refusal back to the cheap path and leave the + * timing channel open with no way to notice. A failed mint is therefore + * dropped and the next call mints again. + */ +function dummyHash(): Promise { + if (!dummyHashPromise) { + const pending = hash(DUMMY_VERIFY_INPUT, ARGON2_HASH_OPTIONS); + dummyHashPromise = pending; + pending.catch(() => { + if (dummyHashPromise === pending) dummyHashPromise = null; + }); + } + return dummyHashPromise; +} /** * Verify `password` against `storedHash`, or against the dummy hash when @@ -68,11 +88,19 @@ export async function verifyPasswordOrDummy( password: string, ): Promise { if (storedHash) return verify(storedHash, password); - // Same verifier, same cost profile, verdict discarded. A throw here (a - // malformed dummy, a mint that failed) must not turn into a 500 that only - // the unknown-account arm can produce — that would be the oracle again, - // in the status code this time. - await verify(await DUMMY_PASSWORD_HASH, password).catch(() => false); + try { + // Same verifier, same cost profile, verdict discarded. + await verify(await dummyHash(), password); + } catch (error) { + // Both the mint and the verify are inside the guard, and the mint has to + // be: an unguarded `await` on a rejected mint would throw out of here and + // answer 500 on exactly the arm this function exists to make ordinary — + // an unknown identifier answering 500 while a real account answers 401 is + // the oracle again, in the status code, and cheaper to read than the + // timing gap was. The breadcrumb makes a degraded instance visible; the + // answer below is the same either way. + console.error("[auth] dummy password verification failed", error); + } return false; } From 3ace3aad35608bd6792e914a3e60fd9594848c89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:36:47 +0200 Subject: [PATCH 6/9] Answer 410 for the removed account-discovery path The path stood in the published contract, so a caller outside this repository can hold a reference to it whether or not we can name one. A bare 404 reads as a broken deploy or a misrouted proxy, which is exactly the reading that kept a client retrying a removed path for twelve days once before. Registering the retirement makes the edge answer 410 with the removing version and no replacement, and republishes the path as gone rather than erasing it from the document. --- docs/api/openapi.yaml | 30 ++++++++++++++++++++++++++++++ src/lib/http/retired-routes.ts | 27 +++++++++++++++++++-------- 2 files changed, 49 insertions(+), 8 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 7e5d2455a..b852045d7 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20416,6 +20416,36 @@ paths: application/json: schema: $ref: "#/components/schemas/ErrorEnvelope" + /api/auth/check-user: + post: + security: [] + deprecated: true + tags: + - Retired + summary: POST /api/auth/check-user — removed in v1.38.19 + description: >- + Removed in v1.38.19. Every request to this path answers 410 Gone, whatever the method and whatever credential it + carries. There is no replacement; the capability is gone rather than moved. + + + Took an email address or a username and answered, to any caller and with no credential at all, whether an + account existed here and whether it carried a passkey or a password. It was built to decide which field a + sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST + /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the + account exists. + + + Retiring a path is deliberate, so the answer says so rather than leaving a client to read a 404 as a broken + deployment and retry forever. Treat 410 as terminal: drop the call, degrade the surface that depended on it, and + do not offer a retry. + responses: + "410": + description: The path was removed in v1.38.19 and will not answer again. `meta.errorCode` = `route.retired`, + `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorEnvelope" /api/auth/me/research-mode: get: security: [] diff --git a/src/lib/http/retired-routes.ts b/src/lib/http/retired-routes.ts index 0f38cc2a0..11eb825fd 100644 --- a/src/lib/http/retired-routes.ts +++ b/src/lib/http/retired-routes.ts @@ -55,14 +55,17 @@ * client's repository, not in this one, and an inference drawn from this side * would be a guess wearing a citation. Nothing below is a claim about a caller. * - * How the set was found: every route path under `src/app` at every release tag, - * unioned, then differenced against the current tree. Twenty-seven paths have - * been removed across the project's history. The five here are the ones that - * were published or client-facing. The other twenty-two are admin-console - * routes, operator diagnostics, aggregators superseded in place, and the - * moodLog bridge — surfaces reachable only from the operator's own browser, - * which ships with the server and reads no contract to find out what it talks - * to. `moodlog-removal-guard.test.ts` separately holds that bridge removed. + * How the set was seeded: every route path under `src/app` at every release + * tag, unioned, then differenced against the current tree. Twenty-seven paths + * had been removed across the project's history up to that point. Five of them + * were published or client-facing and are the first five entries below; the + * other twenty-two are admin-console routes, operator diagnostics, aggregators + * superseded in place, and the moodLog bridge — surfaces reachable only from + * the operator's own browser, which ships with the server and reads no + * contract to find out what it talks to. `moodlog-removal-guard.test.ts` + * separately holds that bridge removed. Every entry after those five was + * written by the release that removed the path, which is where a tombstone + * belongs. * * Sample tags instead of all of them and this undercounts badly: six evenly * spaced tags find fourteen of the twenty-seven, because a path added and @@ -141,6 +144,14 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [ "The whole-record operation moved under the resource type it operates on, which is where FHIR R4 defines it and where the capability statement points.", methods: ["GET"], }, + { + path: "/api/auth/check-user", + removedIn: "1.38.19", + replacedBy: null, + reason: + "Took an email address or a username and answered, to any caller and with no credential at all, whether an account existed here and whether it carried a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the account exists.", + methods: ["POST"], + }, { path: "/api/auth/me/research-mode", removedIn: "1.37.2", From 625763982bfb7fc9595ec5c3ef2548ad95666628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:36:58 +0200 Subject: [PATCH 7/9] Narrow a spent email budget to the field, and record both refusals A refused address change returned before the update was built, so every other field in the same request went with it. The settings form posts the whole profile at once, so somebody who had spent the budget could not save a timezone or a height for an hour and was told it was about email addresses. The address is now dropped and the rest of the save lands, reported through the existing rejectedFields key with the code `rate_limited`; the 429 is kept for a request that asked for nothing else, where a partial success would be a lie. The comment claiming an audit row beside each question was inverted: both refusals returned before the row is written, so only the answers that told the caller nothing were being recorded. The two answers worth detecting write their own rows now. The stored address is normalised before the "has it changed" comparison. Registration writes it as typed, so an account created with capitals did not match its own address and paid for its first ordinary save. --- docs/api/openapi.yaml | 43 +++++-- messages/de.json | 1 + messages/en.json | 1 + messages/es.json | 1 + messages/fr.json | 1 + messages/it.json | 1 + messages/ko.json | 1 + messages/pl.json | 1 + .../profile-email-rate-limit-contract.test.ts | 8 ++ .../profile-update-email-limit.test.ts | 106 ++++++++++++++++-- src/lib/auth/profile-update.ts | 89 +++++++++++---- src/lib/openapi/routes/auth.ts | 2 +- src/lib/openapi/routes/profile.ts | 12 ++ src/lib/openapi/routes/shared.ts | 2 +- src/lib/profile/rejected-fields.ts | 5 + src/lib/rate-limit.ts | 7 +- 16 files changed, 243 insertions(+), 38 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index b852045d7..9b509f22b 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -3099,13 +3099,16 @@ paths: schema: $ref: "#/components/schemas/ErrorEnvelope" "429": &a14 - description: "Too many email-address changes. The address change shares one per-account bucket across both profile - routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an - account cannot collect a fresh allowance by probing a different address. Nothing was written. + description: "Too many email-address changes, and the request asked for nothing else. The address change shares one + per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — + keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any - update that does not touch `email` at all. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` - and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing." + update that does not touch `email` at all. A request that carries other fields beside the refused address + does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with + code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The + `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that + bucket; back off on `Retry-After` rather than guessing." headers: *a8 content: application/json: @@ -37916,9 +37919,11 @@ components: description: Whether an insurance number is stored. The number itself is encrypted at rest and is never returned — only its presence. rejectedFields: - description: "Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of - the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the - response as a clean save." + description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A + 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. + `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address + change that has spent the account's hourly budget; that one is not a bad value and the same address will be + accepted once the window rolls over." type: array items: type: object @@ -46812,6 +46817,28 @@ components: hasInsuranceNumber: type: boolean description: Whether a KVNR is on file (the value itself is not echoed). + rejectedFields: + description: "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A + 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. + `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address + change that has spent the account's hourly budget; that one is not a bad value and the same address will be + accepted once the window rolls over. The handler has always been able to answer with this key on this path; + it was published only on PUT /api/auth/profile." + type: array + items: + type: object + properties: + path: + type: string + code: + type: string + message: + type: string + required: + - path + - code + - message + additionalProperties: false required: - username - email diff --git a/messages/de.json b/messages/de.json index ec56cb656..2da2614a2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -5509,6 +5509,7 @@ "invalidValue": "Das ist keine der Antworten, die dieses Feld annimmt.", "invalidFormat": "Das ist nicht in einer Form geschrieben, die dieses Feld lesen kann.", "invalidType": "Der Wert kam nicht in einer Form an, die dieses Feld speichern kann.", + "rateLimited": "Zu oft geändert. Warte kurz und versuche es dann erneut.", "other": "Dieser Wert wurde nicht angenommen. Bitte prüfen und erneut versuchen." }, "avatar": { diff --git a/messages/en.json b/messages/en.json index 74b9370da..966b3d2d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -5509,6 +5509,7 @@ "invalidValue": "That is not one of the answers this field accepts.", "invalidFormat": "That is not written in a form this field can read.", "invalidType": "That value did not arrive in a form this field can store.", + "rateLimited": "Changed too often. Wait a little and try again.", "other": "This value was not accepted. Check it and try again." }, "avatar": { diff --git a/messages/es.json b/messages/es.json index 3bfb7f622..7f078e580 100644 --- a/messages/es.json +++ b/messages/es.json @@ -5509,6 +5509,7 @@ "invalidValue": "Esa no es una de las respuestas que acepta este campo.", "invalidFormat": "Eso no está escrito en un formato que este campo pueda leer.", "invalidType": "El valor no llegó en un formato que este campo pueda guardar.", + "rateLimited": "Se ha cambiado demasiadas veces. Espera un poco e inténtalo de nuevo.", "other": "No se aceptó este valor. Revísalo e inténtalo de nuevo." }, "avatar": { diff --git a/messages/fr.json b/messages/fr.json index 479150e26..f9ac58f8a 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -5509,6 +5509,7 @@ "invalidValue": "Ce n'est pas une des réponses acceptées par ce champ.", "invalidFormat": "Ce n'est pas écrit dans un format que ce champ peut lire.", "invalidType": "La valeur n'est pas arrivée dans un format que ce champ peut enregistrer.", + "rateLimited": "Modifié trop souvent. Patientez un peu et réessayez.", "other": "Cette valeur n'a pas été acceptée. Vérifiez-la et réessayez." }, "avatar": { diff --git a/messages/it.json b/messages/it.json index 17321d575..0d1618d85 100644 --- a/messages/it.json +++ b/messages/it.json @@ -5509,6 +5509,7 @@ "invalidValue": "Questa non è una delle risposte accettate da questo campo.", "invalidFormat": "Non è scritto in un formato che questo campo possa leggere.", "invalidType": "Il valore non è arrivato in un formato che questo campo possa salvare.", + "rateLimited": "Modificato troppe volte. Aspetta un momento e riprova.", "other": "Questo valore non è stato accettato. Controllalo e riprova." }, "avatar": { diff --git a/messages/ko.json b/messages/ko.json index 7c49082af..2bb7cd6dd 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -5509,6 +5509,7 @@ "invalidValue": "이 항목이 받는 답변이 아니에요.", "invalidFormat": "이 항목이 읽을 수 있는 형식이 아니에요.", "invalidType": "이 항목이 저장할 수 있는 형식으로 오지 않았어요.", + "rateLimited": "너무 자주 바꿨어요. 잠시 후 다시 시도해 주세요.", "other": "이 값은 저장하지 못했어요. 확인한 뒤 다시 시도해 주세요." }, "avatar": { diff --git a/messages/pl.json b/messages/pl.json index 9ac7a0414..b65185705 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -5509,6 +5509,7 @@ "invalidValue": "To nie jest jedna z odpowiedzi, które przyjmuje to pole.", "invalidFormat": "To nie jest zapisane w formacie, który to pole może odczytać.", "invalidType": "Wartość nie dotarła w formacie, który to pole może zapisać.", + "rateLimited": "Zmieniane zbyt często. Odczekaj chwilę i spróbuj ponownie.", "other": "Ta wartość nie została przyjęta. Sprawdź ją i spróbuj ponownie." }, "avatar": { diff --git a/src/__tests__/profile-email-rate-limit-contract.test.ts b/src/__tests__/profile-email-rate-limit-contract.test.ts index 5245581c1..fbcf6d450 100644 --- a/src/__tests__/profile-email-rate-limit-contract.test.ts +++ b/src/__tests__/profile-email-rate-limit-contract.test.ts @@ -115,6 +115,14 @@ describe("the profile email-address bucket", () => { ); }); + it("says this response is only for a request that asked for nothing else", () => { + // The refusal narrows to the field when the request carries other + // fields, so a client reading the 429 as "the whole save failed" would + // be wrong. The sentence has to carry that or it is worse than silence. + expect(published).toContain("rejectedFields"); + expect(published).toContain("rate_limited"); + }); + it("is published on both operations that answer it", () => { const doc = buildOpenApiDocument() as unknown as { paths: Record< diff --git a/src/lib/auth/__tests__/profile-update-email-limit.test.ts b/src/lib/auth/__tests__/profile-update-email-limit.test.ts index 0a87fadc7..7693c5ca3 100644 --- a/src/lib/auth/__tests__/profile-update-email-limit.test.ts +++ b/src/lib/auth/__tests__/profile-update-email-limit.test.ts @@ -7,12 +7,15 @@ * ceiling of any kind — `apiHandler` supplies none — so one account could * walk an address book at whatever rate the network allowed. * - * Three things are pinned here. The charge happens BEFORE the uniqueness - * probe, so a refused request never learns the answer it was refused for. - * A save that carries the address already on file is not charged at all, so - * a settings form that re-posts the whole profile does not spend an honest - * afternoon of edits on the ceiling. And the refusal carries the code the - * contract publishes. + * What is pinned here. The charge happens BEFORE the uniqueness probe, so a + * refused request never learns the answer it was refused for. A save that + * carries the address already on file is not charged at all, so a settings + * form that re-posts the whole profile does not spend an honest afternoon of + * edits on the ceiling. A refusal drops the address and keeps the rest of the + * save, so one spent budget cannot block somebody setting their timezone; the + * published 429 is for the request that asked for nothing else. And both + * answers worth detecting leave an audit row, because neither reaches the + * ordinary `profile.update` row written after the save. */ import { beforeEach, describe, expect, it, vi } from "vitest"; @@ -32,6 +35,7 @@ vi.mock("@/lib/rate-limit", () => ({ import { applyProfileUpdate } from "../profile-update"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; const USER_ID = "user-1"; @@ -85,7 +89,7 @@ describe("applyProfileUpdate — the email conflict is metered", () => { expect(chargedAt).toBeLessThan(probedAt); }); - it("refuses with 429 and the published code, without asking about the address", async () => { + it("refuses with 429 and the published code when the address was all that was asked for", async () => { vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ allowed: false, limit: 10, @@ -126,6 +130,94 @@ describe("applyProfileUpdate — the email conflict is metered", () => { expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); }); + it("keeps the rest of the save when the budget is spent, dropping only the address", async () => { + // The web form posts the whole profile in one request. Failing all of it + // would stop somebody setting their timezone for an hour and tell them it + // was about email addresses. + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + + const result = await applyProfileUpdate(USER_ID, { + email: "someone@example.test", + timezone: "Europe/Lisbon", + heightCm: 180, + }); + + expect(result.ok).toBe(true); + const written = vi.mocked(prisma.user.update).mock.calls[0]![0]! + .data as Record; + expect(Object.keys(written).sort()).toEqual(["heightCm", "timezone"]); + expect("email" in written).toBe(false); + if (result.ok) { + expect(result.rejectedFields).toEqual([ + expect.objectContaining({ path: "email", code: "rate_limited" }), + ]); + } + // Still no probe: the refused caller learns nothing about the address. + expect(emailProbes()).toHaveLength(0); + }); + + it("records both answers worth detecting in the audit ledger", async () => { + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: false, + limit: 10, + remaining: 0, + resetAt: Date.now() + 3_600_000, + }); + await applyProfileUpdate(USER_ID, { email: "someone@example.test" }); + expect(auditLog).toHaveBeenCalledWith( + "profile.email.rate_limited", + expect.objectContaining({ userId: USER_ID }), + ); + + vi.mocked(auditLog).mockClear(); + vi.mocked(checkProfileEmailRateLimit).mockResolvedValue({ + allowed: true, + limit: 10, + remaining: 9, + resetAt: Date.now() + 3_600_000, + }); + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: ON_FILE }; + return { id: "someone-else" }; + }) as never); + + await applyProfileUpdate(USER_ID, { email: "taken@example.test" }); + expect(auditLog).toHaveBeenCalledWith( + "profile.email.conflict", + expect.objectContaining({ userId: USER_ID }), + ); + // Neither path reaches the ordinary post-save row. + expect(auditLog).not.toHaveBeenCalledWith( + "profile.update", + expect.anything(), + ); + }); + + it("does not charge an account whose stored address is spelled differently", async () => { + // Registration writes the address as typed, so an account created as + // `Mine@Example.Test` has that in the column. Comparing it raw against + // the normalised submission would charge its first ordinary save. + vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { + where: Record; + }) => { + if ("id" in args.where) return { email: "Mine@Example.Test" }; + return null; + }) as never); + + const result = await applyProfileUpdate(USER_ID, { email: ON_FILE }); + + expect(result.ok).toBe(true); + expect(checkProfileEmailRateLimit).not.toHaveBeenCalled(); + expect(emailProbes()).toHaveLength(0); + }); + it("still answers 409 when the bucket has room and the address is taken", async () => { vi.mocked(prisma.user.findUnique).mockImplementation((async (args: { where: Record; diff --git a/src/lib/auth/profile-update.ts b/src/lib/auth/profile-update.ts index 8c5257caf..1891c1575 100644 --- a/src/lib/auth/profile-update.ts +++ b/src/lib/auth/profile-update.ts @@ -14,6 +14,7 @@ import { } from "@/lib/cache/invalidate"; import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response"; import { checkProfileEmailRateLimit } from "@/lib/rate-limit"; +import { annotate } from "@/lib/logging/context"; import { z } from "zod/v4"; const extendedProfileSchema = profileSchema.extend({ @@ -245,12 +246,18 @@ export async function applyProfileUpdate( // this caller — so they skip both the uniqueness probe and the budget. // Charging them would spend an honest afternoon of edits on the // ceiling and lock the account out of its own settings page. + // + // The stored side is normalised for the comparison too. Registration + // writes the address exactly as typed, so an account registered as + // `MixedCase@Example.com` would otherwise fail to match its own address + // and pay for its first ordinary save. const current = await prisma.user.findUnique({ where: { id: userId }, select: { email: true }, }); + const addressOnFile = current?.email?.trim().toLowerCase() ?? null; - if (current?.email !== normalizedEmail) { + if (addressOnFile !== normalizedEmail) { // Charged BEFORE the probe, not after: a refused request must not // learn the answer it was refused for. The 409 below tells any // signed-in caller whether an address is registered here, and until @@ -258,25 +265,69 @@ export async function applyProfileUpdate( // any kind — `apiHandler` supplies no default. const rl = await checkProfileEmailRateLimit(userId); if (!rl.allowed) { - return { - ok: false, - status: 429, - message: "Too many email-address changes. Try again later.", - errorCode: "profile.update.emailRateLimited", - }; - } + annotate({ action: { name: "profile.email.throttled" } }); + await auditLog("profile.email.rate_limited", { + userId, + ipAddress: ipAddress ?? null, + }); - const existing = await prisma.user.findUnique({ - where: { email: normalizedEmail }, - select: { id: true }, - }); - if (existing && existing.id !== userId) { - return { - ok: false, - status: 409, - message: "Email already in use", - errorCode: "profile.update.emailInUse", - }; + // Narrow the refusal to the field, the way the demo narrowing above + // does: the web form posts the whole profile in one request, so + // failing all of it would stop somebody setting their timezone for + // an hour and tell them it was about email addresses. The address is + // dropped, everything else is saved, and `rejectedFields` says which + // field did not go through. + const { email: _refusedAddress, ...keepable } = data; + if (Object.keys(keepable).length > 0) { + data = keepable; + rejectedFields = [ + ...rejectedFields, + { + path: "email", + code: "rate_limited", + message: "Too many email-address changes.", + }, + ]; + } else { + // Nothing else was asked for, so there is nothing to salvage and a + // partial success would be a lie — the same rule the all-fields- + // rejected 422 above follows. This is the arm the published 429 + // describes. + return { + ok: false, + status: 429, + message: "Too many email-address changes. Try again later.", + errorCode: "profile.update.emailRateLimited", + }; + } + } else { + // Case-sensitive, because the column is: registration writes the + // address as typed, so `Mixed@Example.com` and `mixed@example.com` + // are two rows this probe cannot see as one. Pre-existing and not + // widened here — the fix is a normalisation pass on the register + // write, which is where the two spellings are created. + const existing = await prisma.user.findUnique({ + where: { email: normalizedEmail }, + select: { id: true }, + }); + if (existing && existing.id !== userId) { + // The informative answer is the one worth recording: it says an + // account holds that address. The ordinary save's `profile.update` + // row is written further down and this path never reaches it, so + // without a row here the only probes in the ledger would be the + // ones that came back negative. + annotate({ action: { name: "profile.email.conflict" } }); + await auditLog("profile.email.conflict", { + userId, + ipAddress: ipAddress ?? null, + }); + return { + ok: false, + status: 409, + message: "Email already in use", + errorCode: "profile.update.emailInUse", + }; + } } } } diff --git a/src/lib/openapi/routes/auth.ts b/src/lib/openapi/routes/auth.ts index da312160e..26a80fa82 100644 --- a/src/lib/openapi/routes/auth.ts +++ b/src/lib/openapi/routes/auth.ts @@ -527,7 +527,7 @@ const authProfileUpdateResponse = z ) .optional() .describe( - "Present only on a PARTIAL success: the fields that failed validation and were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save.", + "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over.", ), }) .meta({ diff --git a/src/lib/openapi/routes/profile.ts b/src/lib/openapi/routes/profile.ts index b230d82e5..96dd3d464 100644 --- a/src/lib/openapi/routes/profile.ts +++ b/src/lib/openapi/routes/profile.ts @@ -483,6 +483,18 @@ const profileUpdateResponse = z hasInsuranceNumber: z .boolean() .describe("Whether a KVNR is on file (the value itself is not echoed)."), + rejectedFields: z + .array( + z.object({ + path: z.string(), + code: z.string(), + message: z.string(), + }), + ) + .optional() + .describe( + "Present only on a PARTIAL success: the fields that were skipped while the rest of the patch was written. A 200 carrying this key means the save was incomplete — surface it, do not treat the response as a clean save. `code` is the validator code for a field that failed validation, or `rate_limited` for an email-address change that has spent the account's hourly budget; that one is not a bad value and the same address will be accepted once the window rolls over. The handler has always been able to answer with this key on this path; it was published only on PUT /api/auth/profile.", + ), }) .meta({ id: "ProfileUpdateResponse", diff --git a/src/lib/openapi/routes/shared.ts b/src/lib/openapi/routes/shared.ts index 324e165cd..b0a6149b1 100644 --- a/src/lib/openapi/routes/shared.ts +++ b/src/lib/openapi/routes/shared.ts @@ -338,7 +338,7 @@ export const recordWriteRateLimitResponse = { export const profileEmailRateLimitResponse = { "429": { description: - "Too many email-address changes. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. Nothing was written. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.", + "Too many email-address changes, and the request asked for nothing else. The address change shares one per-account bucket across both profile routes — `profile-email:`, 10 requests per 3600 seconds — keyed on the ACTING account, so an account cannot collect a fresh allowance by probing a different address. `meta.errorCode` is `profile.update.emailRateLimited`. Only a request whose `email` actually differs from the address on file is counted: re-saving a form that carries the unchanged address is free, and so is any update that does not touch `email` at all. A request that carries other fields beside the refused address does NOT get this response — it answers 200, saves those fields, and names `email` in `rejectedFields` with code `rate_limited`, so one spent budget cannot block an unrelated profile edit for an hour. The `Retry-After`, `X-RateLimit-Limit`, `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers describe that bucket; back off on `Retry-After` rather than guessing.", content: { "application/json": { schema: errorEnvelope } }, headers: stdResponses["429"].headers, }, diff --git a/src/lib/profile/rejected-fields.ts b/src/lib/profile/rejected-fields.ts index da0a3e604..87225925e 100644 --- a/src/lib/profile/rejected-fields.ts +++ b/src/lib/profile/rejected-fields.ts @@ -73,6 +73,11 @@ export function describeRejectedProfileField( * degrade to something honest instead of to silence. */ const REJECTION_REASON_KEYS: Record = { + // Not a Zod code: the profile helper refuses an email-address change that + // has spent the account's hourly budget this way, so the rest of the save + // still lands. Without an entry it would fall back to "check it and try + // again", which is wrong — the value was fine, the timing was not. + rate_limited: "settings.profileRejection.rateLimited", too_big: "settings.profileRejection.tooBig", too_small: "settings.profileRejection.tooSmall", invalid_value: "settings.profileRejection.invalidValue", diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 5021c28f3..5a8816b4b 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -180,8 +180,11 @@ export async function checkRecordWriteRateLimit( * address actually differs from the one on file, so re-saving a settings * form that carries the unchanged address never spends the budget. What it * ends is the unbounded sweep: two hundred and forty questions a day per - * account, each with an audit row beside it, instead of as many as the - * network will carry. + * account instead of as many as the network will carry. Both answers worth + * detecting write an audit row of their own — `profile.email.conflict` when + * an address turns out to be taken, `profile.email.rate_limited` when the + * budget runs out. The ordinary `profile.update` row is written after the + * save, which neither of those paths reaches. */ export const PROFILE_EMAIL_BUCKET_PREFIX = "profile-email"; export const PROFILE_EMAIL_LIMIT = 10; From 93874af99b60f3fba840dc1a6644414771a15e57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 14:39:30 +0200 Subject: [PATCH 8/9] fix(api): record the retirement against the release that carries it v1.38.19 was cut before this change and still answers on the path, so naming it as the version that removed the route would be wrong in the published contract from the day it appears. --- docs/api/openapi.yaml | 6 +++--- src/lib/http/retired-routes.ts | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index 9b509f22b..669d00803 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -20425,9 +20425,9 @@ paths: deprecated: true tags: - Retired - summary: POST /api/auth/check-user — removed in v1.38.19 + summary: POST /api/auth/check-user — removed in v1.38.20 description: >- - Removed in v1.38.19. Every request to this path answers 410 Gone, whatever the method and whatever credential it + Removed in v1.38.20. Every request to this path answers 410 Gone, whatever the method and whatever credential it carries. There is no replacement; the capability is gone rather than moved. @@ -20443,7 +20443,7 @@ paths: do not offer a retry. responses: "410": - description: The path was removed in v1.38.19 and will not answer again. `meta.errorCode` = `route.retired`, + description: The path was removed in v1.38.20 and will not answer again. `meta.errorCode` = `route.retired`, `meta.removedIn` carries the version, and `meta.replacedBy` carries the replacement path or null. content: application/json: diff --git a/src/lib/http/retired-routes.ts b/src/lib/http/retired-routes.ts index 11eb825fd..8ed40ff6d 100644 --- a/src/lib/http/retired-routes.ts +++ b/src/lib/http/retired-routes.ts @@ -146,7 +146,7 @@ export const RETIRED_ROUTES: readonly RetiredRoute[] = [ }, { path: "/api/auth/check-user", - removedIn: "1.38.19", + removedIn: "1.38.20", replacedBy: null, reason: "Took an email address or a username and answered, to any caller and with no credential at all, whether an account existed here and whether it carried a passkey or a password. It was built to decide which field a sign-in screen shows first, which is not worth telling the world who has an account. Send the credential to POST /api/auth/login or run the passkey ceremony and read the answer; both refuse identically whether or not the account exists.", From 0568c76076b977458174bfc1c240dbc857260e5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Fri, 11 Sep 2026 16:15:58 +0200 Subject: [PATCH 9/9] =?UTF-8?q?chore(release):=20v1.38.20=20=E2=80=94=20si?= =?UTF-8?q?gning=20in=20no=20longer=20says=20who=20has=20an=20account=20he?= =?UTF-8?q?re?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the version, the service-worker fallback and the OpenAPI document, and add the CHANGELOG entry for v1.38.20. --- CHANGELOG.md | 42 ++++++++++++++++++++++++++++++++++++++++++ docs/api/openapi.yaml | 2 +- package.json | 2 +- public/sw.js | 2 +- 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecd50e0c2..828e226ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # Changelog +## [1.38.20] — 2026-09-11 + +Signing in no longer tells anybody which addresses have an account here. + +### Security + +- **Signing in now takes the same time whether or not the account exists.** + A wrong password and an address nobody has registered here have always + given you the same answer on screen, but the server used to reach that + answer much faster when there was no account to check, and the same when + the account signs in with a passkey and has no password at all. Someone + probing the sign-in page could read that difference and learn which + addresses are registered, and which of those have a password. Every + refusal now does the same work before it answers. +- **Changing your email address is limited to ten changes an hour.** The + server tells you when the address you typed already belongs to another + account, which is the only sensible thing to say to someone who typed one. + Nothing stopped a signed-in caller from asking that question over and over + about addresses that were not theirs. Ten an hour per account is far above + any real use of the field, and a save that leaves your address as it is + does not count against it. If you hit the limit while saving other things + on the settings page, those are saved and only the address is held back, + with a note under the field. If you write against the API, a request that + asked for nothing but the address gets a 429 on `PUT /api/auth/profile` + and `PATCH /api/user/profile` with `meta.errorCode` set to + `profile.update.emailRateLimited` and a `Retry-After` header saying how + long to wait; a request that carried other fields gets 200 with `email` + listed in `rejectedFields` under the code `rate_limited`. + +### Removed + +- **The endpoint that answered whether an address has an account here is + gone.** `POST /api/auth/check-user` took an email address or a username + and said, to anyone who asked and without any sign-in, whether an account + existed and whether it had a passkey or a password. It was built to decide + which field a sign-in screen shows first, which is not worth telling the + world who has an account here. The path now answers 410 and says so in the + API reference rather than disappearing, so anything still calling it gets + a clear "this is gone, stop retrying" instead of a 404 that looks like a + broken server. If you wrote something against it, the sign-in request + itself is the replacement: send the credential and read the answer. + ## [1.38.19] — 2026-09-11 The first run tells you what is actually true about your account, an diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index d2a1d1c88..fb07ebd4e 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.1.0 info: title: HealthLog API - version: 1.38.19 + version: 1.38.20 description: >- Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest. diff --git a/package.json b/package.json index d4de393df..b3ea6e51f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.38.19", + "version": "1.38.20", "description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.", "license": "PolyForm-Noncommercial-1.0.0", "homepage": "https://healthlog.dev", diff --git a/public/sw.js b/public/sw.js index 99bfccfd2..8c4284924 100644 --- a/public/sw.js +++ b/public/sw.js @@ -36,7 +36,7 @@ try { // v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild. const CACHE_VERSION = (typeof self !== "undefined" && self.__APP_VERSION__) || - /* @sw-version-fallback */ "v1.38.19"; + /* @sw-version-fallback */ "v1.38.20"; const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`; const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`; // v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`