- 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>
207 lines
6.7 KiB
TypeScript
207 lines
6.7 KiB
TypeScript
/**
|
|
* Users + sessions in bun:sqlite (Decisions.md D-009).
|
|
*
|
|
* 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.
|
|
*/
|
|
|
|
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;
|
|
}
|