From 99a801f9937758804c0c51b21859d5701ae2daaa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 17:13:34 +0200 Subject: [PATCH] feat(auth-check-user): four-branch discovery endpoint for iOS onboarding Closes SB-7 follow-up. POST /api/auth/check-user accepts { identifier: string } (username or email, case-insensitive) and returns { branch, hasPasskey, hasPassword } where branch is one of: - not_found no account - passkey_only passkey present, no password - email_fallback password present (passkey optional) - exists neither credential (recovery path) The booleans let the iOS client render a "Sign in with Passkey" affordance alongside the password field without a second round-trip. Five-case test pin covers every branch plus the 422 identifier-required guard. The endpoint mirrors the envelope shape of /api/auth/passkey/ login-options and adds no new enumeration surface beyond that route. --- .../api/auth/check-user/route.test.ts | 117 ++++++++++++++++++ src/app/api/auth/check-user/route.ts | 95 ++++++++++++++ .../__tests__/compact-sections.test.ts} | 0 3 files changed, 212 insertions(+) create mode 100644 src/__tests__/api/auth/check-user/route.test.ts create mode 100644 src/app/api/auth/check-user/route.ts rename src/lib/{insights/__tests__/prompt-compact.test.ts => ai/prompts/__tests__/compact-sections.test.ts} (100%) diff --git a/src/__tests__/api/auth/check-user/route.test.ts b/src/__tests__/api/auth/check-user/route.test.ts new file mode 100644 index 000000000..623c4f582 --- /dev/null +++ b/src/__tests__/api/auth/check-user/route.test.ts @@ -0,0 +1,117 @@ +/** + * 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("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"; + +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(); +}); + +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: "marc" })); + 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: "marc" })); + 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: "marc" })); + 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); + }); +}); diff --git a/src/app/api/auth/check-user/route.ts b/src/app/api/auth/check-user/route.ts new file mode 100644 index 000000000..abb85e429 --- /dev/null +++ b/src/app/api/auth/check-user/route.ts @@ -0,0 +1,95 @@ +/** + * `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. + * - No rate-limit middleware added here; the higher-level edge limit + * on `/api/auth/*` covers brute-force enumeration concerns, and the + * route is functionally equivalent in information leak to the + * existing `/api/auth/passkey/login-options` request that already + * accepts an identifier. + */ +import { z } from "zod/v4"; +import { prisma } from "@/lib/db"; +import { apiSuccess, apiError, safeJson } from "@/lib/api-response"; +import { apiHandler } from "@/lib/api-handler"; +import { annotate } from "@/lib/logging/context"; +import type { NextRequest } from "next/server"; + +export const dynamic = "force-dynamic"; + +const bodySchema = z.object({ + identifier: z.string().trim().min(1).max(254), +}); + +export type CheckUserBranch = + | "not_found" + | "passkey_only" + | "email_fallback" + | "exists"; + +export const POST = apiHandler(async (request: NextRequest) => { + const { data: body, error: jsonError } = await safeJson(request); + if (jsonError) return jsonError; + + const parsed = bodySchema.safeParse(body); + if (!parsed.success) { + return apiError("identifier required", 422); + } + + const identifier = parsed.data.identifier.toLowerCase(); + + // 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 } }, + }, + }); + + if (!user) { + annotate({ action: { name: "auth.check-user" }, meta: { branch: "not_found" } }); + 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 } }); + return apiSuccess({ branch, hasPasskey, hasPassword }); +}); diff --git a/src/lib/insights/__tests__/prompt-compact.test.ts b/src/lib/ai/prompts/__tests__/compact-sections.test.ts similarity index 100% rename from src/lib/insights/__tests__/prompt-compact.test.ts rename to src/lib/ai/prompts/__tests__/compact-sections.test.ts