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 <noreply@anthropic.com>
This commit is contained in:
1 parent
eb72a2e646
commit
9f39a82982
38 files changed
+3857
-246
No files matched your search
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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.
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user