Files
whisper_web/src/lib/authClient.ts
T
2026-07-10 10:13:37 +02:00

190 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Response>;
// 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<string> {
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<AuthUser> {
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<AuthUser> {
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<AuthUser> {
return requestUser(fetchFn, "api/auth/register", { username, password });
}
/** Result of the startup /me probe (fetchAuthStatus). */
export interface AuthStatus {
/**
* Whether this deployment offers login at all. False when the server 404s
* /api/auth/me (runtime feature flag AUTH_ENABLED=false, D-015) — and also
* while the server is unreachable, since signing in is impossible then
* anyway. The auth UI renders nothing until this is true.
*/
enabled: boolean;
/** The signed-in user, or null when anonymous. */
user: AuthUser | null;
}
/**
* GET /api/auth/me → auth availability + the current user. NEVER rejects:
* the app is offline-first and /api/* is network-only in the service worker,
* so 401s and malformed bodies resolve to anonymous, and 404 (feature flag
* off, D-015) and network failure resolve to `enabled: false` — none of them
* surface an error (Architecture.md §5 anonymous-first invariant).
*/
export async function fetchAuthStatus(
fetchFn: FetchLike = defaultFetch,
): Promise<AuthStatus> {
try {
const response = await fetchFn("api/auth/me", {
method: "GET",
credentials: "same-origin",
});
if (response.status === 404) return { enabled: false, user: null };
if (!response.ok) return { enabled: true, user: null };
const payload: unknown = await response.json().catch(() => null);
const user = (payload as { user?: unknown } | null)?.user;
return { enabled: true, user: isAuthUser(user) ? user : null };
} catch {
return { enabled: false, user: 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<void> {
try {
await fetchFn("api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} catch {
// Offline — nothing to do.
}
}