Files
whisper_web/src/lib/authClient.ts
T
Lucas WintherandClaude Fable 5 9f39a82982 WIP: M3 PWA/offline implementation, M4 auth (backend + frontend), D-013 faster-whisper
- M3: hand-rolled service worker (precache + runtime /ort caching), precache
  manifest injection, SW registration/update toast, manifest + generated icons,
  production build script, offline indicator in ModelPicker (D-005/D-007)
- M4 backend: bun:sqlite users/sessions (D-009), Passport local strategy behind
  connect-compat adapter (D-008), /api/auth routes wired into dev+prod serving,
  Bun.password Argon2id, 38 auth tests
- M4 frontend: AuthForms + useAuth + framework-free authClient, session-aware
  header, anonymous-first fail-silent offline handling, 18 client tests
- D-013: server engine (M5) will use faster-whisper via Python sidecar;
  browser side stays Transformers.js (CTranslate2 has no WASM build)
- bun test: 83 pass / tsc --noEmit clean
- Note: M3 doc closeout (WorkBreakdown/Architecture/tests/offline.md) still in
  flight from a background agent; follow-up commit will finish docs

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-10 04:02:28 +02:00

175 lines
5.6 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 });
}
/**
* 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<AuthUser | null> {
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<void> {
try {
await fetchFn("/api/auth/logout", {
method: "POST",
credentials: "same-origin",
});
} catch {
// Offline — nothing to do.
}
}