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
+287
-7
@@ -1,11 +1,291 @@
|
||||
/**
|
||||
* Auth — TODO(M4).
|
||||
* Auth: Passport.js local strategy behind a connect-compat adapter over
|
||||
* Bun.serve (Decisions.md D-008), with users + sessions in bun:sqlite
|
||||
* (D-009).
|
||||
*
|
||||
* Passport.js local strategy behind a connect-compat adapter over Bun.serve
|
||||
* (Decisions.md D-008): wrap Bun's Request in a minimal req/res shim, run the
|
||||
* passport authenticate chain, translate back to a Response. Sessions in
|
||||
* bun:sqlite with HttpOnly cookies (D-009). Routes:
|
||||
* POST /api/auth/register|login|logout, GET /api/auth/me.
|
||||
* Passport expects connect/express-style middleware, which Bun.serve does not
|
||||
* have. The adapter here is deliberately minimal and honest: it shims only
|
||||
* what `passport.authenticate("local", callback)` actually touches — the
|
||||
* strategy reads `req.body`/`req.query` and, because we pass a custom
|
||||
* callback with `session: false`, never writes to `res`. Session issuance,
|
||||
* cookies, and serialization are driven by us around that call rather than by
|
||||
* passport's own connect middleware.
|
||||
*
|
||||
* Routes implemented (wired in src/index.ts, shared by dev + production):
|
||||
* POST /api/auth/register → 201 {user} + Set-Cookie | 400 | 409
|
||||
* POST /api/auth/login → 200 {user} + Set-Cookie | 400 | 401
|
||||
* POST /api/auth/logout → 204 (always) + cookie cleared
|
||||
* GET /api/auth/me → 200 {user} | 401
|
||||
*
|
||||
* Cookie contract: name `sid`; HttpOnly; SameSite=Lax; Path=/; Max-Age 7 days;
|
||||
* Secure when NODE_ENV=production. Expiry is enforced server-side via
|
||||
* sessions.expires_at — a replayed cookie past that instant is rejected even
|
||||
* though the browser would still send it.
|
||||
*/
|
||||
|
||||
export {};
|
||||
import passport from "passport";
|
||||
import { Strategy as LocalStrategy } from "passport-local";
|
||||
import { type AppDb, type User, getDb } from "./db";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** The safe-to-serialize projection of a user (never the password hash). */
|
||||
export interface SessionUser {
|
||||
id: number;
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface AuthHandlers {
|
||||
register(request: Request): Promise<Response>;
|
||||
login(request: Request): Promise<Response>;
|
||||
logout(request: Request): Promise<Response>;
|
||||
me(request: Request): Promise<Response>;
|
||||
/**
|
||||
* Resolves the user behind a request's `sid` cookie, or null. Exported so
|
||||
* the M5 transcribe endpoint can guard itself with the same session logic.
|
||||
*/
|
||||
getSessionUser(request: Request): SessionUser | null;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants + small helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const COOKIE_NAME = "sid";
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
const USERNAME_RE = /^[a-zA-Z0-9_.-]{3,32}$/;
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
|
||||
function json(body: unknown, status: number, headers?: HeadersInit): Response {
|
||||
const h = new Headers(headers);
|
||||
h.set("Content-Type", "application/json");
|
||||
return new Response(JSON.stringify(body), { status, headers: h });
|
||||
}
|
||||
|
||||
/** Serializes the session cookie per the contract in the module doc comment. */
|
||||
function sessionCookie(sid: string, maxAgeSeconds: number): string {
|
||||
const attributes = [
|
||||
`${COOKIE_NAME}=${sid}`,
|
||||
"HttpOnly",
|
||||
"SameSite=Lax",
|
||||
"Path=/",
|
||||
`Max-Age=${maxAgeSeconds}`,
|
||||
];
|
||||
// Checked per-call (not at module load) so tests can flip NODE_ENV.
|
||||
if (process.env.NODE_ENV === "production") attributes.push("Secure");
|
||||
return attributes.join("; ");
|
||||
}
|
||||
|
||||
/** Max-Age=0 tells the browser to drop the cookie immediately. */
|
||||
const clearedCookie = (): string => sessionCookie("", 0);
|
||||
|
||||
/** Extracts the `sid` cookie value from a request, if any. */
|
||||
function readSessionId(request: Request): string | null {
|
||||
const header = request.headers.get("cookie");
|
||||
if (!header) return null;
|
||||
for (const part of header.split(";")) {
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) continue;
|
||||
if (part.slice(0, eq).trim() === COOKIE_NAME) {
|
||||
const value = part.slice(eq + 1).trim();
|
||||
return value.length > 0 ? value : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const toSessionUser = (user: User): SessionUser => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
});
|
||||
|
||||
interface Credentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a JSON `{username, password}` body. Returns null for anything that
|
||||
* is not that shape (bad JSON, wrong types) — callers answer 400.
|
||||
*/
|
||||
async function parseCredentials(request: Request): Promise<Credentials | null> {
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (typeof body !== "object" || body === null) return null;
|
||||
const { username, password } = body as Record<string, unknown>;
|
||||
if (typeof username !== "string" || typeof password !== "string") return null;
|
||||
return { username, password };
|
||||
}
|
||||
|
||||
/**
|
||||
* Argon2id hash used to burn comparable time when the username does not
|
||||
* exist, so login latency doesn't reveal which usernames are registered.
|
||||
* Computed lazily (hashing is deliberately slow) and cached.
|
||||
*/
|
||||
let dummyHash: Promise<string> | undefined;
|
||||
const getDummyHash = (): Promise<string> =>
|
||||
(dummyHash ??= Bun.password.hash("dummy-password-for-constant-time"));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Builds the four route handlers plus `getSessionUser` against a database
|
||||
* source. Taking a getter (not an instance) keeps the default export lazy:
|
||||
* importing this module never opens ./data/app.db, and tests inject
|
||||
* `() => createDb(":memory:")` databases without touching disk.
|
||||
*/
|
||||
export function createAuth(getDatabase: () => AppDb): AuthHandlers {
|
||||
// A private Authenticator instance (not the module-level singleton) so two
|
||||
// createAuth() calls — e.g. app + tests — can't clobber each other's
|
||||
// strategy registration.
|
||||
const authenticator = new passport.Passport();
|
||||
|
||||
authenticator.use(
|
||||
new LocalStrategy((username, password, done) => {
|
||||
// passport-local's verify is callback-style; bridge to async/await and
|
||||
// route thrown errors into done() so they surface as 500s, not hangs.
|
||||
void (async () => {
|
||||
const user = getDatabase().findUserByUsername(username);
|
||||
if (!user) {
|
||||
await Bun.password.verify(password, await getDummyHash());
|
||||
return done(null, false, { message: "invalid credentials" });
|
||||
}
|
||||
const ok = await Bun.password.verify(password, user.passwordHash);
|
||||
return ok
|
||||
? done(null, user)
|
||||
: done(null, false, { message: "invalid credentials" });
|
||||
})().catch(done);
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* The connect-compat adapter (D-008): run passport's authenticate chain
|
||||
* against a shim of exactly the surface passport-local reads. With
|
||||
* `session: false` and a custom callback, passport calls back with
|
||||
* (err, user|false) and never touches `res`, so an empty object suffices.
|
||||
*/
|
||||
function authenticateLocal(credentials: Credentials): Promise<User | false> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = {
|
||||
body: credentials,
|
||||
query: {},
|
||||
headers: {},
|
||||
method: "POST",
|
||||
url: "/api/auth/login",
|
||||
};
|
||||
const middleware = authenticator.authenticate(
|
||||
"local",
|
||||
{ session: false },
|
||||
(err: unknown, user: unknown) =>
|
||||
err ? reject(err) : resolve((user as User | false) || false),
|
||||
) as (req: unknown, res: unknown, next: (err?: unknown) => void) => void;
|
||||
// next() only fires on internal errors here (the callback intercepts
|
||||
// success and failure), so treat any call as a failure path.
|
||||
middleware(req, {}, (err?: unknown) =>
|
||||
err ? reject(err) : resolve(false),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Issues a fresh session row + Set-Cookie header for `user`. */
|
||||
function issueSession(user: User): { user: SessionUser; cookie: string } {
|
||||
const session = getDatabase().createSession(user.id, Date.now() + SESSION_TTL_MS);
|
||||
return {
|
||||
user: toSessionUser(user),
|
||||
cookie: sessionCookie(session.id, SESSION_TTL_MS / 1000),
|
||||
};
|
||||
}
|
||||
|
||||
function getSessionUser(request: Request): SessionUser | null {
|
||||
const sid = readSessionId(request);
|
||||
if (!sid) return null;
|
||||
const db = getDatabase();
|
||||
const session = db.findValidSession(sid);
|
||||
if (!session) return null;
|
||||
const user = db.findUserById(session.userId);
|
||||
return user ? toSessionUser(user) : null;
|
||||
}
|
||||
|
||||
return {
|
||||
async register(request) {
|
||||
const credentials = await parseCredentials(request);
|
||||
if (!credentials) {
|
||||
return json({ error: "expected JSON body with username and password" }, 400);
|
||||
}
|
||||
if (!USERNAME_RE.test(credentials.username)) {
|
||||
return json(
|
||||
{ error: "username must be 3-32 characters (letters, digits, _ . -)" },
|
||||
400,
|
||||
);
|
||||
}
|
||||
if (credentials.password.length < MIN_PASSWORD_LENGTH) {
|
||||
return json(
|
||||
{ error: `password must be at least ${MIN_PASSWORD_LENGTH} characters` },
|
||||
400,
|
||||
);
|
||||
}
|
||||
// Argon2id, Bun's default — memory-hard, per D-009 / WorkBreakdown M4.
|
||||
const passwordHash = await Bun.password.hash(credentials.password);
|
||||
const user = getDatabase().createUser(credentials.username, passwordHash);
|
||||
// createUser lets the UNIQUE constraint arbitrate, so concurrent
|
||||
// registrations of the same name can't both succeed.
|
||||
if (!user) return json({ error: "username taken" }, 409);
|
||||
|
||||
const issued = issueSession(user);
|
||||
return json({ user: issued.user }, 201, { "Set-Cookie": issued.cookie });
|
||||
},
|
||||
|
||||
async login(request) {
|
||||
const credentials = await parseCredentials(request);
|
||||
if (!credentials) {
|
||||
return json({ error: "expected JSON body with username and password" }, 400);
|
||||
}
|
||||
const user = await authenticateLocal(credentials);
|
||||
// One message for both unknown-user and wrong-password: no username
|
||||
// enumeration via response text (timing handled by the dummy hash).
|
||||
if (!user) return json({ error: "invalid credentials" }, 401);
|
||||
|
||||
const issued = issueSession(user);
|
||||
return json({ user: issued.user }, 200, { "Set-Cookie": issued.cookie });
|
||||
},
|
||||
|
||||
async logout(request) {
|
||||
const sid = readSessionId(request);
|
||||
if (sid) getDatabase().deleteSession(sid);
|
||||
// Idempotent by contract: 204 whether or not a session existed. No body
|
||||
// on 204, but always clear the cookie so stale clients converge.
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: { "Set-Cookie": clearedCookie() },
|
||||
});
|
||||
},
|
||||
|
||||
async me(request) {
|
||||
const user = getSessionUser(request);
|
||||
return user
|
||||
? json({ user }, 200)
|
||||
: json({ error: "not authenticated" }, 401);
|
||||
},
|
||||
|
||||
getSessionUser,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Default instance (the real app database) + convenience re-export for M5
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const auth: AuthHandlers = createAuth(getDb);
|
||||
|
||||
/** Session lookup against the app database — see AuthHandlers.getSessionUser. */
|
||||
export function getSessionUser(request: Request): SessionUser | null {
|
||||
return auth.getSessionUser(request);
|
||||
}
|
||||
+202
-5
@@ -1,9 +1,206 @@
|
||||
/**
|
||||
* Database — TODO(M4).
|
||||
* Users + sessions in bun:sqlite (Decisions.md D-009).
|
||||
*
|
||||
* bun:sqlite (Decisions.md D-009). Tables: users (id, username, password_hash
|
||||
* via Bun.password Argon2), sessions (id, user_id, expires_at). DB path from
|
||||
* env, default ./data/app.db.
|
||||
* Single-file database; path from env `DB_PATH`, default ./data/app.db. All
|
||||
* access goes through the typed helpers on {@link AppDb} — no raw SQL leaks
|
||||
* out of this module, so the (deliberately tiny) schema can evolve in one
|
||||
* place.
|
||||
*
|
||||
* Testability: `createDb(path)` is a pure factory — tests pass ":memory:" and
|
||||
* never touch the on-disk default. The shared instance is created lazily by
|
||||
* `getDb()`, so merely importing this module has no filesystem side effects.
|
||||
*/
|
||||
|
||||
export {};
|
||||
import { Database } from "bun:sqlite";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { dirname } from "node:path";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Domain types (camelCase; the snake_case row shapes stay private below)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
/** Argon2id hash produced by `Bun.password.hash` — never the plaintext. */
|
||||
passwordHash: string;
|
||||
/** Milliseconds since epoch. */
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
/** 256-bit random id (base64url); doubles as the `sid` cookie value. */
|
||||
id: string;
|
||||
userId: number;
|
||||
/** Milliseconds since epoch; sessions past this instant are invalid. */
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
export interface AppDb {
|
||||
/** Returns the new user, or null when the username is already taken. */
|
||||
createUser(username: string, passwordHash: string): User | null;
|
||||
findUserByUsername(username: string): User | null;
|
||||
findUserById(id: number): User | null;
|
||||
/** Creates a session for `userId`; also sweeps expired rows opportunistically. */
|
||||
createSession(userId: number, expiresAt: number): Session;
|
||||
/**
|
||||
* Returns the session only when it exists AND has not expired — expiry is
|
||||
* enforced here, server-side, regardless of what the cookie claims.
|
||||
*/
|
||||
findValidSession(id: string, now?: number): Session | null;
|
||||
deleteSession(id: string): void;
|
||||
/** Removes all sessions with expires_at <= now; returns the count removed. */
|
||||
deleteExpiredSessions(now?: number): number;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Factory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface UserRow {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: number;
|
||||
}
|
||||
|
||||
interface SessionRow {
|
||||
id: string;
|
||||
user_id: number;
|
||||
expires_at: number;
|
||||
}
|
||||
|
||||
const toUser = (row: UserRow): User => ({
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
passwordHash: row.password_hash,
|
||||
createdAt: row.created_at,
|
||||
});
|
||||
|
||||
const toSession = (row: SessionRow): Session => ({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
expiresAt: row.expires_at,
|
||||
});
|
||||
|
||||
/**
|
||||
* Session ids come from the CSPRNG: 32 random bytes = 256 bits, well past the
|
||||
* ~128-bit unguessability floor, encoded base64url so the value is safe in a
|
||||
* cookie without escaping.
|
||||
*/
|
||||
export function newSessionId(): string {
|
||||
return randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
/** Opens (creating if needed) a database at `path` and applies the schema. */
|
||||
export function createDb(path: string): AppDb {
|
||||
if (path !== ":memory:") {
|
||||
// bun:sqlite creates the file but not its parent directory.
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
}
|
||||
|
||||
const db = new Database(path, { create: true, strict: true });
|
||||
// WAL lets reads proceed during writes; harmless no-op for ":memory:".
|
||||
db.exec("PRAGMA journal_mode = WAL;");
|
||||
// Enforce sessions.user_id → users.id (off by default in SQLite).
|
||||
db.exec("PRAGMA foreign_keys = ON;");
|
||||
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_expires_at ON sessions(expires_at);
|
||||
`);
|
||||
|
||||
const insertUser = db.query<UserRow, [string, string, number]>(
|
||||
"INSERT INTO users (username, password_hash, created_at) VALUES (?, ?, ?) RETURNING *",
|
||||
);
|
||||
const userByUsername = db.query<UserRow, [string]>(
|
||||
"SELECT * FROM users WHERE username = ?",
|
||||
);
|
||||
const userById = db.query<UserRow, [number]>("SELECT * FROM users WHERE id = ?");
|
||||
const insertSession = db.query<SessionRow, [string, number, number]>(
|
||||
"INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?) RETURNING *",
|
||||
);
|
||||
const sessionById = db.query<SessionRow, [string, number]>(
|
||||
"SELECT * FROM sessions WHERE id = ? AND expires_at > ?",
|
||||
);
|
||||
const removeSession = db.query("DELETE FROM sessions WHERE id = ?");
|
||||
const removeExpired = db.query("DELETE FROM sessions WHERE expires_at <= ?");
|
||||
|
||||
return {
|
||||
createUser(username, passwordHash) {
|
||||
try {
|
||||
return toUser(insertUser.get(username, passwordHash, Date.now())!);
|
||||
} catch (error) {
|
||||
// The UNIQUE constraint is the source of truth for "username taken" —
|
||||
// checking first would race concurrent registrations.
|
||||
if (
|
||||
error instanceof Error &&
|
||||
(error as { code?: string }).code?.startsWith("SQLITE_CONSTRAINT")
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
findUserByUsername(username) {
|
||||
const row = userByUsername.get(username);
|
||||
return row ? toUser(row) : null;
|
||||
},
|
||||
|
||||
findUserById(id) {
|
||||
const row = userById.get(id);
|
||||
return row ? toUser(row) : null;
|
||||
},
|
||||
|
||||
createSession(userId, expiresAt) {
|
||||
// Piggyback garbage collection on the write path so stale rows never
|
||||
// accumulate unboundedly without needing a background timer.
|
||||
removeExpired.run(Date.now());
|
||||
return toSession(insertSession.get(newSessionId(), userId, expiresAt)!);
|
||||
},
|
||||
|
||||
findValidSession(id, now = Date.now()) {
|
||||
const row = sessionById.get(id, now);
|
||||
return row ? toSession(row) : null;
|
||||
},
|
||||
|
||||
deleteSession(id) {
|
||||
removeSession.run(id);
|
||||
},
|
||||
|
||||
deleteExpiredSessions(now = Date.now()) {
|
||||
return removeExpired.run(now).changes;
|
||||
},
|
||||
|
||||
close() {
|
||||
db.close();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazily-created default instance (the real app database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const DEFAULT_DB_PATH = "./data/app.db";
|
||||
|
||||
let defaultDb: AppDb | undefined;
|
||||
|
||||
/** The application database (env `DB_PATH`, default ./data/app.db — gitignored). */
|
||||
export function getDb(): AppDb {
|
||||
defaultDb ??= createDb(process.env.DB_PATH ?? DEFAULT_DB_PATH);
|
||||
return defaultDb;
|
||||
}
|
||||
Reference in new issue
Block a user