/** * Auth API client (M4). Deliberately framework-free: validation is pure and * the fetch layer is injectable, so `bun test` can exercise everything without * a DOM or a running server (tests/auth-client.test.ts). React state lives in * `useAuth` (src/lib/useAuth.ts). * * Contract (mirrored by src/server): all endpoints exchange JSON and errors * arrive as `{ error: string }`. The session cookie is HttpOnly, so * `GET /api/auth/me` is the ONLY way to learn the current auth state. */ export interface AuthUser { id: number | string; username: string; } /** Minimal fetch signature so tests can inject a mock. */ export type FetchLike = (input: string, init?: RequestInit) => Promise; // Bound lazily via globalThis: bare `fetch` references passed around as values // can throw "Illegal invocation" in some browsers. const defaultFetch: FetchLike = (input, init) => globalThis.fetch(input, init); // --- Client-side validation --------------------------------------------- // Mirrors the server rules for instant feedback; the server stays // authoritative and its `{ error }` messages are surfaced on 4xx regardless. export const USERNAME_MIN_LENGTH = 3; export const USERNAME_MAX_LENGTH = 32; export const USERNAME_PATTERN = /^[a-zA-Z0-9_.-]+$/; export const PASSWORD_MIN_LENGTH = 8; /** Returns a human-readable problem with the username, or null when valid. */ export function validateUsername(username: string): string | null { if ( username.length < USERNAME_MIN_LENGTH || username.length > USERNAME_MAX_LENGTH ) { return `Username must be ${USERNAME_MIN_LENGTH}–${USERNAME_MAX_LENGTH} characters.`; } if (!USERNAME_PATTERN.test(username)) { return "Username may only contain letters, digits, and _ . -"; } return null; } /** Returns a human-readable problem with the password, or null when valid. */ export function validatePassword(password: string): string | null { if (password.length < PASSWORD_MIN_LENGTH) { return `Password must be at least ${PASSWORD_MIN_LENGTH} characters.`; } return null; } // --- API requests --------------------------------------------------------- function isAuthUser(value: unknown): value is AuthUser { if (typeof value !== "object" || value === null) return false; const candidate = value as { id?: unknown; username?: unknown }; return ( (typeof candidate.id === "number" || typeof candidate.id === "string") && typeof candidate.username === "string" ); } /** Pull `{ error }` out of a 4xx/5xx body, falling back to a generic line. */ async function readErrorMessage( response: Response, fallback: string, ): Promise { try { const body: unknown = await response.json(); if ( typeof body === "object" && body !== null && typeof (body as { error?: unknown }).error === "string" ) { return (body as { error: string }).error; } } catch { // Non-JSON error body (proxy page, empty 500) — use the fallback. } return fallback; } /** * POST credentials to an auth endpoint and return the signed-in user. * Throws an `Error` whose message is safe to show in the UI. */ async function requestUser( fetchFn: FetchLike, path: string, credentials: { username: string; password: string }, ): Promise { let response: Response; try { response = await fetchFn(path, { method: "POST", credentials: "same-origin", headers: { "Content-Type": "application/json" }, body: JSON.stringify(credentials), }); } catch { throw new Error("Could not reach the server. Check your connection."); } if (!response.ok) { throw new Error( await readErrorMessage(response, `Request failed (${response.status}).`), ); } const payload: unknown = await response.json().catch(() => null); const user = (payload as { user?: unknown } | null)?.user; if (!isAuthUser(user)) { throw new Error("The server returned an unexpected response."); } return user; } /** POST /api/auth/login → user, or throws with the server's error message. */ export function login( username: string, password: string, fetchFn: FetchLike = defaultFetch, ): Promise { return requestUser(fetchFn, "/api/auth/login", { username, password }); } /** POST /api/auth/register → user (auto signed in), or throws (400/409). */ export function register( username: string, password: string, fetchFn: FetchLike = defaultFetch, ): Promise { return requestUser(fetchFn, "/api/auth/register", { username, password }); } /** * GET /api/auth/me → the current user, or null when anonymous. NEVER rejects: * the app is offline-first and /api/* is network-only in the service worker, * so 401s, network failures, and malformed bodies all resolve to anonymous * without surfacing an error (Architecture.md §5 anonymous-first invariant). */ export async function fetchCurrentUser( fetchFn: FetchLike = defaultFetch, ): Promise { try { const response = await fetchFn("/api/auth/me", { method: "GET", credentials: "same-origin", }); if (!response.ok) return null; const payload: unknown = await response.json(); const user = (payload as { user?: unknown } | null)?.user; return isAuthUser(user) ? user : null; } catch { return null; } } /** * POST /api/auth/logout (→ 204). Swallows failures: the caller clears local * state either way, and an unreachable server just means the cookie expires * on its own. */ export async function logout(fetchFn: FetchLike = defaultFetch): Promise { try { await fetchFn("/api/auth/logout", { method: "POST", credentials: "same-origin", }); } catch { // Offline — nothing to do. } }