- 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]>
175 lines
5.6 KiB
TypeScript
175 lines
5.6 KiB
TypeScript
/**
|
||
* 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.
|
||
}
|
||
}
|