diff --git a/docs/superpowers/plans/2026-05-27-webui-authentication.md b/docs/superpowers/plans/2026-05-27-webui-authentication.md new file mode 100644 index 0000000..0093cc6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-webui-authentication.md @@ -0,0 +1,2215 @@ +# WebUI Authentication Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add username + password auth (multi-user) to the WebUI so that all `/api/*` (except an explicit public whitelist) and the `/ws` WebSocket reject unauthenticated requests, gated by a 7-day rolling cookie session. + +**Architecture:** Two new SQLite tables (`users`, `sessions`) reusing the existing better-sqlite3 instance, bcryptjs password hashing, sha256-hashed session IDs at rest, raw 32-byte random token in an HTTP-only `tsmb_session` cookie. Two Express middlewares (`requireAuth`, `csrfOriginCheck`) gate every protected `/api/*` router. The WebSocket switches from passive `path: "/ws"` binding to manual `server.on("upgrade", …)` so it validates the same cookie before accepting the handshake. Frontend gets a `useSession` composable, a router guard, a login view and a first-run wizard view; the existing `/setup` route (bot-creation wizard) is left untouched and the new admin-setup view is mounted at `/first-run` to avoid name collision. + +**Tech Stack:** TypeScript / Express 5 / better-sqlite3 / `ws` / Vitest / Vue 3 + Vue Router + Pinia / bcryptjs (pure JS, no native build). + +**Spec:** `docs/superpowers/specs/2026-05-27-webui-authentication-design.md` + +**Branch:** `feat/webui-auth` (already created and contains the spec commit `f7c1688`). + +--- + +## Files map + +``` +NEW backend + src/data/users.ts + src/data/users.test.ts + src/data/sessions.ts + src/data/sessions.test.ts + src/web/auth/validateSession.ts + src/web/middleware/requireAuth.ts + src/web/middleware/requireAuth.test.ts + src/web/middleware/csrf.ts + src/web/middleware/csrf.test.ts + src/web/api/session.ts + src/web/api/session.test.ts + src/web/websocket-auth.test.ts + +MODIFIED backend + src/data/database.ts (add users + sessions tables in initTables) + src/web/server.ts (cookieParser, public/protected ordering, cleanup interval, manual ws upgrade) + src/web/websocket.ts (no functional change; export setupWebSocket already passes wss) + package.json (deps) + +NEW frontend + web/src/views/Login.vue + web/src/views/FirstRunSetup.vue + web/src/composables/useSession.ts + web/src/api/http.ts (small fetch wrapper with credentials + 401 handler) + +MODIFIED frontend + web/src/router/index.ts (add /login, /first-run, beforeEach guard) + web/src/App.vue (logout button + username chip in Navbar slot) + web/src/components/Navbar.vue (render the slot for logout/username) +``` + +> Note on `/setup` collision: the existing `Setup.vue` (mounted at `/setup`) is the bot-creation wizard, not admin setup. The new admin first-run wizard uses path `/first-run`. The spec said `/setup`; this plan supersedes it because the path is already taken. + +--- + +## Task 1: Branch state + new dependencies + +**Files:** +- Modify: `package.json` + +- [ ] **Step 1: Verify branch** + +```bash +git status --short +git branch --show-current +``` +Expected: clean working tree on `feat/webui-auth`. + +- [ ] **Step 2: Install runtime + type deps** + +```bash +npm install bcryptjs@^2.4.3 cookie-parser@^1.4.7 +npm install --save-dev @types/bcryptjs@^2.4.6 @types/cookie-parser@^1.4.8 +``` + +- [ ] **Step 3: Sanity-check installation** + +```bash +node -e "console.log(require('bcryptjs').hashSync('x', 4))" +``` +Expected: a bcrypt hash string beginning with `$2a$04$`. + +- [ ] **Step 4: Commit** + +```bash +git add package.json package-lock.json +git commit -m "deps: add bcryptjs + cookie-parser for WebUI auth" +``` + +--- + +## Task 2: Schema migration for users + sessions + +**Files:** +- Modify: `src/data/database.ts:102-131` (extend `initTables`) +- Modify: `src/data/database.test.ts` (add table-creation assertion) + +- [ ] **Step 1: Write the failing test** + +Add to `src/data/database.test.ts` inside the existing `describe("database", …)` block, after the "creates tables on init" test: + +```ts +it("creates users and sessions tables on init", () => { + const tables = botDb.db + .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + .all() as Array<{ name: string }>; + const names = tables.map((t) => t.name); + expect(names).toContain("users"); + expect(names).toContain("sessions"); + + const userCols = botDb.db.prepare("PRAGMA table_info(users)").all() as Array<{ name: string }>; + const userColNames = userCols.map((c) => c.name).sort(); + expect(userColNames).toEqual(["createdAt", "id", "passwordHash", "updatedAt", "username"]); + + const sessionCols = botDb.db.prepare("PRAGMA table_info(sessions)").all() as Array<{ name: string }>; + const sessionColNames = sessionCols.map((c) => c.name).sort(); + expect(sessionColNames).toEqual(["createdAt", "expiresAt", "id", "lastSeenAt", "userId"]); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/database.test.ts +``` +Expected: FAIL on "creates users and sessions tables on init" with assertion error. + +- [ ] **Step 3: Add tables in `initTables`** + +In `src/data/database.ts`, replace the `db.exec(\`...\`)` call inside `initTables` so it ends like this (keep the existing `play_history` and `bot_instances` blocks, append the two new tables to the same string): + +```ts +function initTables(db: Database.Database): void { + db.exec(` + CREATE TABLE IF NOT EXISTS play_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + botId TEXT NOT NULL, + songId TEXT NOT NULL, + songName TEXT NOT NULL, + artist TEXT NOT NULL, + album TEXT NOT NULL, + platform TEXT NOT NULL, + coverUrl TEXT NOT NULL, + playedAt TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS bot_instances ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + serverAddress TEXT NOT NULL, + serverPort INTEGER NOT NULL, + nickname TEXT NOT NULL, + defaultChannel TEXT NOT NULL, + channelPassword TEXT NOT NULL, + autoStart INTEGER NOT NULL DEFAULT 0, + serverProtocol TEXT NOT NULL DEFAULT '', + ts6ApiKey TEXT NOT NULL DEFAULT '', + serverPassword TEXT NOT NULL DEFAULT '', + identity TEXT + ); + + CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + passwordHash TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL + ); + + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + userId TEXT NOT NULL, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, + lastSeenAt INTEGER NOT NULL, + FOREIGN KEY (userId) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); + CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); + `); +} +``` + +Also enable FK enforcement in `createDatabase` (better-sqlite3 default is OFF). Add this line **immediately after** `db.pragma("journal_mode = WAL")`: + +```ts +db.pragma("foreign_keys = ON"); +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/database.test.ts +``` +Expected: all tests PASS, including the new one. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/database.ts src/data/database.test.ts +git commit -m "feat(db): add users and sessions tables for WebUI auth" +``` + +--- + +## Task 3: `src/data/users.ts` — user CRUD + password hashing + +**Files:** +- Create: `src/data/users.ts` +- Create: `src/data/users.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/data/users.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createUserStore, UsernameTakenError, type UserStore } from "./users.js"; + +describe("UserStore", () => { + let botDb: BotDatabase; + let users: UserStore; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + }); + + afterEach(() => { + botDb.close(); + }); + + it("countUsers is 0 on a fresh db", () => { + expect(users.countUsers()).toBe(0); + }); + + it("createUser stores the user and bumps countUsers", async () => { + const u = await users.createUser("alice", "pw-hunter2"); + expect(u.id).toMatch(/^[0-9a-f-]{36}$/); + expect(u.username).toBe("alice"); + expect(users.countUsers()).toBe(1); + }); + + it("findByUsername is case-insensitive and returns null for missing", async () => { + await users.createUser("Alice", "pw"); + expect(users.findByUsername("ALICE")).not.toBeNull(); + expect(users.findByUsername("alice")).not.toBeNull(); + expect(users.findByUsername("bob")).toBeNull(); + }); + + it("createUser rejects duplicate usernames (case-insensitive)", async () => { + await users.createUser("Alice", "pw"); + await expect(users.createUser("alice", "pw2")).rejects.toBeInstanceOf(UsernameTakenError); + }); + + it("verifyPassword accepts correct password and rejects wrong one", async () => { + await users.createUser("alice", "correct-horse-battery-staple"); + const row = users.findByUsername("alice"); + expect(row).not.toBeNull(); + expect(await users.verifyPassword("correct-horse-battery-staple", row!.passwordHash)).toBe(true); + expect(await users.verifyPassword("wrong", row!.passwordHash)).toBe(false); + }); + + it("changePassword updates the hash so the old password no longer verifies", async () => { + const u = await users.createUser("alice", "old"); + await users.changePassword(u.id, "new"); + const row = users.findByUsername("alice"); + expect(await users.verifyPassword("old", row!.passwordHash)).toBe(false); + expect(await users.verifyPassword("new", row!.passwordHash)).toBe(true); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/users.test.ts +``` +Expected: FAIL — module `./users.js` not found. + +- [ ] **Step 3: Implement `src/data/users.ts`** + +```ts +import { randomUUID } from "node:crypto"; +import type Database from "better-sqlite3"; +import bcrypt from "bcryptjs"; + +const BCRYPT_ROUNDS = 12; + +export interface UserRow { + id: string; + username: string; + passwordHash: string; + createdAt: number; + updatedAt: number; +} + +export interface UserStore { + countUsers(): number; + createUser(username: string, password: string): Promise; + findByUsername(username: string): UserRow | null; + findById(id: string): UserRow | null; + verifyPassword(plain: string, hash: string): Promise; + changePassword(userId: string, newPassword: string): Promise; +} + +export class UsernameTakenError extends Error { + constructor(username: string) { + super(`username taken: ${username}`); + this.name = "UsernameTakenError"; + } +} + +export function createUserStore(db: Database.Database): UserStore { + const countStmt = db.prepare("SELECT COUNT(*) AS n FROM users"); + const insertStmt = db.prepare( + "INSERT INTO users (id, username, passwordHash, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?)" + ); + const findByUsernameStmt = db.prepare( + "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE username = ? COLLATE NOCASE" + ); + const findByIdStmt = db.prepare( + "SELECT id, username, passwordHash, createdAt, updatedAt FROM users WHERE id = ?" + ); + const updatePasswordStmt = db.prepare( + "UPDATE users SET passwordHash = ?, updatedAt = ? WHERE id = ?" + ); + + return { + countUsers() { + return (countStmt.get() as { n: number }).n; + }, + + async createUser(username, password) { + const hash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const id = randomUUID(); + const now = Date.now(); + try { + insertStmt.run(id, username, hash, now, now); + } catch (err) { + const msg = (err as Error).message; + if (msg.includes("UNIQUE") && msg.includes("users.username")) { + throw new UsernameTakenError(username); + } + throw err; + } + return { id, username, passwordHash: hash, createdAt: now, updatedAt: now }; + }, + + findByUsername(username) { + return (findByUsernameStmt.get(username) as UserRow | undefined) ?? null; + }, + + findById(id) { + return (findByIdStmt.get(id) as UserRow | undefined) ?? null; + }, + + verifyPassword(plain, hash) { + return bcrypt.compare(plain, hash); + }, + + async changePassword(userId, newPassword) { + const hash = await bcrypt.hash(newPassword, BCRYPT_ROUNDS); + updatePasswordStmt.run(hash, Date.now(), userId); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/users.test.ts +``` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/users.ts src/data/users.test.ts +git commit -m "feat(auth): add UserStore with bcryptjs password hashing" +``` + +--- + +## Task 4: `src/data/sessions.ts` — session CRUD + rolling renewal + +**Files:** +- Create: `src/data/sessions.ts` +- Create: `src/data/sessions.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/data/sessions.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { createDatabase, type BotDatabase } from "./database.js"; +import { createUserStore, type UserStore } from "./users.js"; +import { createSessionStore, type SessionStore, SESSION_TTL_MS, SESSION_TOUCH_INTERVAL_MS } from "./sessions.js"; + +function sha256(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +describe("SessionStore", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let userId: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + userId = u.id; + }); + + afterEach(() => { + vi.useRealTimers(); + botDb.close(); + }); + + it("createSession returns a raw token whose sha256 matches the DB row id", () => { + const { token } = sessions.createSession(userId); + const row = botDb.db.prepare("SELECT id FROM sessions").get() as { id: string }; + expect(row.id).toBe(sha256(token)); + expect(row.id).not.toBe(token); + }); + + it("validateAndTouch returns the user for a fresh token", () => { + const { token } = sessions.createSession(userId); + const result = sessions.validateAndTouch(token); + expect(result).not.toBeNull(); + expect(result!.userId).toBe(userId); + expect(result!.username).toBe("alice"); + }); + + it("validateAndTouch returns null and deletes the row for an expired session", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + expect(sessions.validateAndTouch(token)).toBeNull(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + }); + + it("validateAndTouch does not write the DB if called again within the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token } = sessions.createSession(userId); + const before = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS - 1000); + sessions.validateAndTouch(token); + const after = botDb.db.prepare("SELECT lastSeenAt FROM sessions").get() as { lastSeenAt: number }; + expect(after.lastSeenAt).toBe(before.lastSeenAt); + }); + + it("validateAndTouch writes lastSeenAt and extends expiresAt past the touch interval", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + const { token, expiresAt: initialExpiry } = sessions.createSession(userId); + vi.advanceTimersByTime(SESSION_TOUCH_INTERVAL_MS + 1000); + sessions.validateAndTouch(token); + const row = botDb.db.prepare("SELECT lastSeenAt, expiresAt FROM sessions").get() as { lastSeenAt: number; expiresAt: number }; + expect(row.lastSeenAt).toBe(Date.now()); + expect(row.expiresAt).toBeGreaterThan(initialExpiry); + }); + + it("deleteSession removes the row", () => { + const { token } = sessions.createSession(userId); + sessions.deleteSession(token); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(0); + expect(sessions.validateAndTouch(token)).toBeNull(); + }); + + it("deleteAllForUser keeps the exceptToken session", () => { + const a = sessions.createSession(userId); + const b = sessions.createSession(userId); + sessions.deleteAllForUser(userId, a.token); + expect(sessions.validateAndTouch(a.token)).not.toBeNull(); + expect(sessions.validateAndTouch(b.token)).toBeNull(); + }); + + it("cleanupExpired removes only expired rows", () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")); + sessions.createSession(userId); // expires later + vi.setSystemTime(new Date("2026-01-01T00:00:00Z").getTime() + SESSION_TTL_MS + 1000); + sessions.createSession(userId); // fresh + sessions.cleanupExpired(); + const remaining = (botDb.db.prepare("SELECT COUNT(*) AS n FROM sessions").get() as { n: number }).n; + expect(remaining).toBe(1); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/data/sessions.test.ts +``` +Expected: FAIL — `./sessions.js` not found. + +- [ ] **Step 3: Implement `src/data/sessions.ts`** + +```ts +import { createHash, randomBytes } from "node:crypto"; +import type Database from "better-sqlite3"; + +export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days +export const SESSION_TOUCH_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export interface SessionValidation { + userId: string; + username: string; +} + +export interface SessionStore { + createSession(userId: string): { token: string; expiresAt: number }; + validateAndTouch(rawToken: string): SessionValidation | null; + deleteSession(rawToken: string): void; + deleteAllForUser(userId: string, exceptToken?: string): void; + cleanupExpired(): void; +} + +function hashToken(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +export function createSessionStore(db: Database.Database): SessionStore { + const insertStmt = db.prepare( + "INSERT INTO sessions (id, userId, createdAt, expiresAt, lastSeenAt) VALUES (?, ?, ?, ?, ?)" + ); + const selectStmt = db.prepare(` + SELECT s.id, s.userId, s.expiresAt, s.lastSeenAt, u.username + FROM sessions s INNER JOIN users u ON u.id = s.userId + WHERE s.id = ? + `); + const deleteByIdStmt = db.prepare("DELETE FROM sessions WHERE id = ?"); + const touchStmt = db.prepare( + "UPDATE sessions SET lastSeenAt = ?, expiresAt = ? WHERE id = ?" + ); + const deleteAllForUserStmt = db.prepare("DELETE FROM sessions WHERE userId = ?"); + const deleteAllForUserExceptStmt = db.prepare( + "DELETE FROM sessions WHERE userId = ? AND id != ?" + ); + const cleanupStmt = db.prepare("DELETE FROM sessions WHERE expiresAt < ?"); + + return { + createSession(userId) { + const token = randomBytes(32).toString("base64url"); + const id = hashToken(token); + const now = Date.now(); + const expiresAt = now + SESSION_TTL_MS; + insertStmt.run(id, userId, now, expiresAt, now); + return { token, expiresAt }; + }, + + validateAndTouch(rawToken) { + if (!rawToken) return null; + const id = hashToken(rawToken); + const row = selectStmt.get(id) as + | { id: string; userId: string; expiresAt: number; lastSeenAt: number; username: string } + | undefined; + if (!row) return null; + const now = Date.now(); + if (row.expiresAt < now) { + deleteByIdStmt.run(id); + return null; + } + if (now - row.lastSeenAt > SESSION_TOUCH_INTERVAL_MS) { + touchStmt.run(now, now + SESSION_TTL_MS, id); + } + return { userId: row.userId, username: row.username }; + }, + + deleteSession(rawToken) { + deleteByIdStmt.run(hashToken(rawToken)); + }, + + deleteAllForUser(userId, exceptToken) { + if (exceptToken) { + deleteAllForUserExceptStmt.run(userId, hashToken(exceptToken)); + } else { + deleteAllForUserStmt.run(userId); + } + }, + + cleanupExpired() { + cleanupStmt.run(Date.now()); + }, + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/data/sessions.test.ts +``` +Expected: all 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/data/sessions.ts src/data/sessions.test.ts +git commit -m "feat(auth): add SessionStore with rolling renewal and at-rest token hashing" +``` + +--- + +## Task 5: `src/web/auth/validateSession.ts` — shared cookie helper + +**Files:** +- Create: `src/web/auth/validateSession.ts` + +> Used by both the HTTP middleware (`requireAuth`) and the WS upgrade handler. Centralised so both paths can never drift apart. + +- [ ] **Step 1: Implement (no separate test — exercised by middleware and ws tests)** + +```ts +import type { SessionStore, SessionValidation } from "../../data/sessions.js"; + +export const SESSION_COOKIE_NAME = "tsmb_session"; + +/** + * Validate the session cookie carried on an arbitrary HTTP-like header bag. + * Used by Express middleware (req.headers.cookie) AND by the raw WebSocket + * upgrade handler (req.headers.cookie) — they share this exact behavior. + */ +export function validateSessionFromHeaders( + rawCookieHeader: string | undefined, + sessions: SessionStore +): SessionValidation | null { + if (!rawCookieHeader) return null; + const token = parseCookie(rawCookieHeader, SESSION_COOKIE_NAME); + if (!token) return null; + return sessions.validateAndTouch(token); +} + +function parseCookie(header: string, name: string): string | null { + for (const part of header.split(";")) { + const trimmed = part.trim(); + const eq = trimmed.indexOf("="); + if (eq < 1) continue; + if (trimmed.slice(0, eq) !== name) continue; + try { + return decodeURIComponent(trimmed.slice(eq + 1)); + } catch { + return null; + } + } + return null; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/web/auth/validateSession.ts +git commit -m "feat(auth): add shared validateSessionFromHeaders helper" +``` + +--- + +## Task 6: `requireAuth` middleware + +**Files:** +- Create: `src/web/middleware/requireAuth.ts` +- Create: `src/web/middleware/requireAuth.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/web/middleware/requireAuth.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore } from "../../data/users.js"; +import { createSessionStore } from "../../data/sessions.js"; +import { createRequireAuth } from "./requireAuth.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +describe("requireAuth middleware", () => { + let botDb: BotDatabase; + let app: express.Express; + let validToken: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + validToken = sessions.createSession(u.id).token; + + app = express(); + app.use(cookieParser()); + app.use(createRequireAuth(sessions)); + app.get("/protected", (req, res) => { + res.json({ ok: true, user: (req as any).user }); + }); + }); + + afterEach(() => { + botDb.close(); + }); + + it("rejects requests without a session cookie", async () => { + const res = await request(app).get("/protected"); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "unauthenticated" }); + }); + + it("rejects requests with an unknown session cookie", async () => { + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=garbage`); + expect(res.status).toBe(401); + }); + + it("allows requests with a valid session cookie and attaches req.user", async () => { + const res = await request(app) + .get("/protected") + .set("Cookie", `${SESSION_COOKIE_NAME}=${validToken}`); + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.user.username).toBe("alice"); + }); +}); +``` + +> Add `supertest` as a devDep if not present: +> ```bash +> npm install --save-dev supertest@^7.1.4 @types/supertest@^6.0.3 +> ``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/middleware/requireAuth.test.ts +``` +Expected: FAIL — `./requireAuth.js` does not export `createRequireAuth`. + +- [ ] **Step 3: Implement `src/web/middleware/requireAuth.ts`** + +```ts +import type { Request, Response, NextFunction, RequestHandler } from "express"; +import type { SessionStore } from "../../data/sessions.js"; +import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +declare module "express-serve-static-core" { + interface Request { + user?: { id: string; username: string }; + } +} + +export function createRequireAuth(sessions: SessionStore): RequestHandler { + return function requireAuth(req: Request, res: Response, next: NextFunction) { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); + res.status(401).json({ error: "unauthenticated" }); + return; + } + req.user = { id: result.userId, username: result.username }; + next(); + }; +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/middleware/requireAuth.test.ts +``` +Expected: all 3 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/middleware/requireAuth.ts src/web/middleware/requireAuth.test.ts package.json package-lock.json +git commit -m "feat(auth): add requireAuth middleware" +``` + +--- + +## Task 7: `csrfOriginCheck` middleware + +**Files:** +- Create: `src/web/middleware/csrf.ts` +- Create: `src/web/middleware/csrf.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, it, expect, beforeEach } from "vitest"; +import express from "express"; +import request from "supertest"; +import { csrfOriginCheck } from "./csrf.js"; + +describe("csrfOriginCheck middleware", () => { + let app: express.Express; + + beforeEach(() => { + app = express(); + app.use(csrfOriginCheck); + app.get("/", (_req, res) => res.json({ ok: true })); + app.post("/", (_req, res) => res.json({ ok: true })); + }); + + it("allows safe methods (GET/HEAD/OPTIONS) without Origin", async () => { + const res = await request(app).get("/"); + expect(res.status).toBe(200); + }); + + it("rejects POST without Origin or Referer", async () => { + const res = await request(app).post("/"); + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "bad origin" }); + }); + + it("accepts POST when Origin host matches request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://example.com"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Origin host does not match request host", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Origin", "https://evil.com"); + expect(res.status).toBe(403); + }); + + it("accepts POST when Referer host matches and Origin is absent", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://example.com/some/path"); + expect(res.status).toBe(200); + }); + + it("rejects POST when Referer host does not match", async () => { + const res = await request(app) + .post("/") + .set("Host", "example.com") + .set("Referer", "https://evil.com/some/path"); + expect(res.status).toBe(403); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/middleware/csrf.test.ts +``` +Expected: FAIL — `./csrf.js` not found. + +- [ ] **Step 3: Implement `src/web/middleware/csrf.ts`** + +```ts +import type { Request, Response, NextFunction } from "express"; + +const SAFE_METHODS = new Set(["GET", "HEAD", "OPTIONS"]); + +/** + * Same-origin CSRF protection. For mutating requests, the Origin or Referer + * header must indicate a host equal to the request's own host. + * + * SameSite=Lax on the session cookie blocks classic cross-site form posts; + * this header check covers the remaining attack surface (fetch from a malicious + * page that omits SameSite-restricted cookies but tries via Origin spoofing + * is not possible — the browser sets Origin). + */ +export function csrfOriginCheck(req: Request, res: Response, next: NextFunction): void { + if (SAFE_METHODS.has(req.method)) { + next(); + return; + } + const expectedHost = req.get("host"); + const originHeader = req.get("origin"); + const refererHeader = req.get("referer"); + const headerHost = hostOf(originHeader) ?? hostOf(refererHeader); + if (!headerHost || !expectedHost || headerHost !== expectedHost) { + res.status(403).json({ error: "bad origin" }); + return; + } + next(); +} + +function hostOf(url: string | undefined): string | null { + if (!url) return null; + try { + return new URL(url).host; + } catch { + return null; + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/middleware/csrf.test.ts +``` +Expected: all 6 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/middleware/csrf.ts src/web/middleware/csrf.test.ts +git commit -m "feat(auth): add csrfOriginCheck middleware" +``` + +--- + +## Task 8: `/api/session` router (login, logout, setup, me, change-password) + +**Files:** +- Create: `src/web/api/session.ts` +- Create: `src/web/api/session.test.ts` + +- [ ] **Step 1: Write the failing test** + +Create `src/web/api/session.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import pino from "pino"; +import { createDatabase, type BotDatabase } from "../../data/database.js"; +import { createUserStore, type UserStore } from "../../data/users.js"; +import { createSessionStore, type SessionStore } from "../../data/sessions.js"; +import { createSessionRouter } from "./session.js"; +import { SESSION_COOKIE_NAME } from "../auth/validateSession.js"; + +function makeApp(users: UserStore, sessions: SessionStore) { + const app = express(); + app.use(express.json()); + app.use(cookieParser()); + app.use("/api/session", createSessionRouter(users, sessions, pino({ level: "silent" }))); + return app; +} + +function extractCookie(res: request.Response): string { + const header = res.headers["set-cookie"]; + const arr = Array.isArray(header) ? header : header ? [header] : []; + const found = arr.find((c) => c.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!found) throw new Error("no session cookie set"); + return found.split(";")[0]; // "tsmb_session=xxxx" +} + +describe("session router", () => { + let botDb: BotDatabase; + let users: UserStore; + let sessions: SessionStore; + let app: express.Express; + + beforeEach(() => { + botDb = createDatabase(":memory:"); + users = createUserStore(botDb.db); + sessions = createSessionStore(botDb.db); + app = makeApp(users, sessions); + }); + + afterEach(() => botDb.close()); + + it("GET /needs-setup returns true on an empty db", async () => { + const res = await request(app).get("/api/session/needs-setup"); + expect(res.status).toBe(200); + expect(res.body).toEqual({ needsSetup: true }); + }); + + it("POST /setup creates the first admin, logs them in, and returns false from /needs-setup afterwards", async () => { + const setupRes = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "hunter2-hunter2" }); + expect(setupRes.status).toBe(200); + expect(setupRes.body.username).toBe("alice"); + extractCookie(setupRes); // throws if missing + + const needs = await request(app).get("/api/session/needs-setup"); + expect(needs.body).toEqual({ needsSetup: false }); + }); + + it("POST /setup returns 409 once a user already exists", async () => { + await users.createUser("admin", "pw"); + const res = await request(app) + .post("/api/session/setup") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(409); + expect(res.body).toEqual({ error: "already initialized" }); + }); + + it("POST /login returns 401 with constant-time delay on bad credentials", async () => { + await users.createUser("alice", "correct"); + const start = Date.now(); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "wrong" }); + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "invalid credentials" }); + expect(Date.now() - start).toBeGreaterThanOrEqual(200); + }, 10_000); + + it("POST /login sets a session cookie on success", async () => { + await users.createUser("alice", "pw"); + const res = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + expect(res.status).toBe(200); + expect(res.body.username).toBe("alice"); + extractCookie(res); + }); + + it("GET /me returns the current user when cookie is present, 401 otherwise", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(200); + expect(me.body.username).toBe("alice"); + + const anon = await request(app).get("/api/session/me"); + expect(anon.status).toBe(401); + }); + + it("POST /logout deletes the session and clears the cookie", async () => { + await users.createUser("alice", "pw"); + const loginRes = await request(app) + .post("/api/session/login") + .send({ username: "alice", password: "pw" }); + const cookie = extractCookie(loginRes); + + const logout = await request(app).post("/api/session/logout").set("Cookie", cookie); + expect(logout.status).toBe(204); + + const me = await request(app).get("/api/session/me").set("Cookie", cookie); + expect(me.status).toBe(401); + }); + + it("POST /change-password requires old password and invalidates other sessions", async () => { + const u = await users.createUser("alice", "old"); + const cookieA = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + const cookieB = extractCookie( + await request(app).post("/api/session/login").send({ username: "alice", password: "old" }) + ); + + const wrongOld = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "WRONG", newPassword: "new" }); + expect(wrongOld.status).toBe(401); + + const ok = await request(app) + .post("/api/session/change-password") + .set("Cookie", cookieA) + .send({ oldPassword: "old", newPassword: "new" }); + expect(ok.status).toBe(204); + + // Current session (cookieA) still valid + const meA = await request(app).get("/api/session/me").set("Cookie", cookieA); + expect(meA.status).toBe(200); + + // Other session (cookieB) invalidated + const meB = await request(app).get("/api/session/me").set("Cookie", cookieB); + expect(meB.status).toBe(401); + + expect(u.id).toBe(meA.body.id); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +npx vitest run src/web/api/session.test.ts +``` +Expected: FAIL — `./session.js` not found. + +- [ ] **Step 3: Implement `src/web/api/session.ts`** + +```ts +import { Router } from "express"; +import type { Request, Response, NextFunction } from "express"; +import type { Logger } from "../../logger.js"; +import type { UserStore } from "../../data/users.js"; +import { UsernameTakenError } from "../../data/users.js"; +import type { SessionStore } from "../../data/sessions.js"; +import { SESSION_TTL_MS } from "../../data/sessions.js"; +import { SESSION_COOKIE_NAME, validateSessionFromHeaders } from "../auth/validateSession.js"; + +const FAILED_LOGIN_DELAY_MS = 250; + +function setSessionCookie(res: Response, token: string): void { + res.cookie(SESSION_COOKIE_NAME, token, { + httpOnly: true, + sameSite: "lax", + secure: res.req.secure, + path: "/", + maxAge: SESSION_TTL_MS, + }); +} + +function clearSessionCookie(res: Response): void { + res.clearCookie(SESSION_COOKIE_NAME, { path: "/" }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isValidUsername(v: unknown): v is string { + return typeof v === "string" && /^[A-Za-z0-9_\-.]{3,32}$/.test(v); +} + +function isValidPassword(v: unknown): v is string { + return typeof v === "string" && v.length >= 8 && v.length <= 200; +} + +export function createSessionRouter( + users: UserStore, + sessions: SessionStore, + logger: Logger +): Router { + const router = Router(); + + const requireAuthInline = (req: Request, res: Response, next: NextFunction) => { + const result = validateSessionFromHeaders(req.headers.cookie, sessions); + if (!result) { + clearSessionCookie(res); + res.status(401).json({ error: "unauthenticated" }); + return; + } + req.user = { id: result.userId, username: result.username }; + next(); + }; + + router.get("/needs-setup", (_req, res) => { + res.json({ needsSetup: users.countUsers() === 0 }); + }); + + router.post("/setup", async (req, res) => { + const { username, password } = req.body ?? {}; + if (!isValidUsername(username) || !isValidPassword(password)) { + res.status(400).json({ error: "invalid username or password" }); + return; + } + if (users.countUsers() !== 0) { + res.status(409).json({ error: "already initialized" }); + return; + } + try { + const user = await users.createUser(username, password); + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + logger.info({ userId: user.id, username }, "First admin created"); + res.json({ id: user.id, username: user.username }); + } catch (err) { + if (err instanceof UsernameTakenError) { + res.status(409).json({ error: "already initialized" }); + return; + } + logger.error({ err }, "setup failed"); + res.status(500).json({ error: "internal" }); + } + }); + + router.post("/login", async (req, res) => { + const { username, password } = req.body ?? {}; + if (typeof username !== "string" || typeof password !== "string") { + res.status(400).json({ error: "invalid request" }); + return; + } + const user = users.findByUsername(username); + const ok = user ? await users.verifyPassword(password, user.passwordHash) : false; + if (!user || !ok) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + const { token } = sessions.createSession(user.id); + setSessionCookie(res, token); + res.json({ id: user.id, username: user.username }); + }); + + router.post("/logout", (req, res) => { + const cookieHeader = req.headers.cookie; + if (cookieHeader) { + const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (match) { + const token = decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1)); + sessions.deleteSession(token); + } + } + clearSessionCookie(res); + res.status(204).end(); + }); + + router.get("/me", requireAuthInline, (req, res) => { + res.json(req.user); + }); + + router.post("/change-password", requireAuthInline, async (req, res) => { + const { oldPassword, newPassword } = req.body ?? {}; + if (typeof oldPassword !== "string" || !isValidPassword(newPassword)) { + res.status(400).json({ error: "invalid request" }); + return; + } + const u = users.findById(req.user!.id); + if (!u || !(await users.verifyPassword(oldPassword, u.passwordHash))) { + await delay(FAILED_LOGIN_DELAY_MS); + res.status(401).json({ error: "invalid credentials" }); + return; + } + await users.changePassword(u.id, newPassword); + const currentToken = parseTokenFromCookie(req.headers.cookie); + sessions.deleteAllForUser(u.id, currentToken ?? undefined); + res.status(204).end(); + }); + + return router; +} + +function parseTokenFromCookie(cookieHeader: string | undefined): string | null { + if (!cookieHeader) return null; + const match = cookieHeader.split(";").map((p) => p.trim()).find((p) => p.startsWith(`${SESSION_COOKIE_NAME}=`)); + if (!match) return null; + return decodeURIComponent(match.slice(SESSION_COOKIE_NAME.length + 1)); +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +npx vitest run src/web/api/session.test.ts +``` +Expected: all 8 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add src/web/api/session.ts src/web/api/session.test.ts +git commit -m "feat(auth): add /api/session router (setup, login, logout, me, change-password)" +``` + +--- + +## Task 9: Wire up `server.ts` (cookieParser, public/protected ordering, cleanup) + +**Files:** +- Modify: `src/web/server.ts` + +> The protected `/api/*` routers and the new public ones share the same path prefix. Express middleware ordering matters: register public routes BEFORE the `app.use("/api", csrfOriginCheck)` + `app.use("/api", requireAuth)` gates. + +- [ ] **Step 1: Replace the contents of `src/web/server.ts`** + +```ts +import express from "express"; +import http from "node:http"; +import path from "node:path"; +import cookieParser from "cookie-parser"; +import { WebSocketServer } from "ws"; +import type { BotManager } from "../bot/manager.js"; +import type { MusicProvider } from "../music/provider.js"; +import type { BotDatabase } from "../data/database.js"; +import type { BotConfig } from "../data/config.js"; +import type { Logger } from "../logger.js"; +import type { CookieStore } from "../music/auth.js"; +import type { AvatarStore } from "../data/avatars.js"; +import { createBotRouter } from "./api/bot.js"; +import { createMusicRouter } from "./api/music.js"; +import { createPlayerRouter } from "./api/player.js"; +import { createAuthRouter } from "./api/auth.js"; +import { createSessionRouter } from "./api/session.js"; +import { setupWebSocket } from "./websocket.js"; +import { createUserStore } from "../data/users.js"; +import { createSessionStore } from "../data/sessions.js"; +import { createRequireAuth } from "./middleware/requireAuth.js"; +import { csrfOriginCheck } from "./middleware/csrf.js"; +import { validateSessionFromHeaders } from "./auth/validateSession.js"; + +const SESSION_CLEANUP_INTERVAL_MS = 60 * 60 * 1000; // 1 hour + +export interface WebServerOptions { + port: number; + botManager: BotManager; + neteaseProvider: MusicProvider; + qqProvider: MusicProvider; + bilibiliProvider: MusicProvider; + database: BotDatabase; + config: BotConfig; + configPath: string; + logger: Logger; + cookieStore?: CookieStore; + avatarStore: AvatarStore; + staticDir?: string; +} + +export interface WebServer { + start(): Promise; + stop(): void; +} + +export function createWebServer(options: WebServerOptions): WebServer { + const app = express(); + const server = http.createServer(app); + const logger = options.logger.child({ component: "web" }); + + if (options.config.trustProxy) { + app.set("trust proxy", true); + } + + app.use(express.json({ limit: "400kb" })); + app.use(cookieParser()); + + const users = createUserStore(options.database.db); + const sessions = createSessionStore(options.database.db); + + // ─── Public routes (no auth, no CSRF) ─────────────────────────────────── + app.get("/api/health", (_req, res) => { + res.json({ status: "ok", version: "0.1.0" }); + }); + + app.get("/api/config/public-url", (_req, res) => { + const raw = (options.config.publicUrl ?? "").trim(); + res.json({ publicUrl: raw ? raw.replace(/\/+$/, "") : null }); + }); + + app.use("/api/session", createSessionRouter(users, sessions, logger)); + + // ─── Gates for everything else under /api ─────────────────────────────── + const requireAuth = createRequireAuth(sessions); + app.use("/api", csrfOriginCheck); + app.use("/api", requireAuth); + + // ─── Protected routes ─────────────────────────────────────────────────── + app.use( + "/api/bot", + createBotRouter( + options.botManager, + options.config, + options.configPath, + logger, + options.database, + options.avatarStore, + ) + ); + app.use( + "/api/music", + createMusicRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger) + ); + app.use("/api/player", createPlayerRouter( + options.botManager, logger, options.database, + options.neteaseProvider, options.qqProvider, options.bilibiliProvider, + )); + app.use( + "/api/auth", + createAuthRouter(options.neteaseProvider, options.qqProvider, options.bilibiliProvider, logger, options.cookieStore) + ); + + // ─── Static SPA (public) ──────────────────────────────────────────────── + if (options.staticDir) { + app.use(express.static(options.staticDir)); + app.get(/^(?!\/api|\/ws)/, (_req, res) => { + res.sendFile(path.join(options.staticDir!, "index.html")); + }); + } + + server.on("error", (err) => { + logger.error({ err }, "HTTP server error"); + }); + + // ─── WebSocket with manual upgrade auth ──────────────────────────────── + const wss = new WebSocketServer({ noServer: true }); + wss.on("error", (err) => { + logger.error({ err }, "WebSocket server error"); + }); + server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { + socket.destroy(); + return; + } + const result = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); + if (!result) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => { + (ws as unknown as { userId: string }).userId = result.userId; + wss.emit("connection", ws, req); + }); + }); + const cleanupWs = setupWebSocket(wss, options.botManager, logger); + + // ─── Session cleanup interval ────────────────────────────────────────── + let cleanupTimer: ReturnType | null = null; + + return { + async start(): Promise { + return new Promise((resolve) => { + server.listen(options.port, () => { + logger.info({ port: options.port }, "Web server started"); + cleanupTimer = setInterval(() => { + try { + sessions.cleanupExpired(); + } catch (err) { + logger.error({ err }, "session cleanup failed"); + } + }, SESSION_CLEANUP_INTERVAL_MS); + resolve(); + }); + }); + }, + stop(): void { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } + cleanupWs(); + wss.close(); + server.close(); + }, + }; +} +``` + +- [ ] **Step 2: Type-check the project** + +```bash +npx tsc --noEmit +``` +Expected: zero errors. + +- [ ] **Step 3: Run the whole vitest suite** + +```bash +npx vitest run +``` +Expected: all existing + new tests pass. + +- [ ] **Step 4: Commit** + +```bash +git add src/web/server.ts +git commit -m "feat(auth): gate /api/* behind requireAuth + csrf; gate /ws via upgrade handler" +``` + +--- + +## Task 10: WebSocket auth integration test + +**Files:** +- Create: `src/web/websocket-auth.test.ts` + +> Validates the end-to-end ws gate behavior. Uses a real HTTP server on a random port plus the `ws` client. + +- [ ] **Step 1: Write the failing test** + +Create `src/web/websocket-auth.test.ts`: + +```ts +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import express from "express"; +import http from "node:http"; +import { WebSocketServer, WebSocket as WSClient } from "ws"; +import { AddressInfo } from "node:net"; +import { createDatabase, type BotDatabase } from "../data/database.js"; +import { createUserStore } from "../data/users.js"; +import { createSessionStore } from "../data/sessions.js"; +import { validateSessionFromHeaders, SESSION_COOKIE_NAME } from "./auth/validateSession.js"; + +function buildServer(sessions: ReturnType) { + const app = express(); + const server = http.createServer(app); + const wss = new WebSocketServer({ noServer: true }); + wss.on("connection", (ws) => ws.send("hello")); + server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") return socket.destroy(); + const r = validateSessionFromHeaders(req.headers.cookie as string | undefined, sessions); + if (!r) { + socket.write("HTTP/1.1 401 Unauthorized\r\nConnection: close\r\n\r\n"); + socket.destroy(); + return; + } + wss.handleUpgrade(req, socket, head, (ws) => wss.emit("connection", ws, req)); + }); + return { server, wss }; +} + +describe("WebSocket auth at upgrade", () => { + let botDb: BotDatabase; + let httpServer: http.Server; + let port: number; + let validToken: string; + + beforeEach(async () => { + botDb = createDatabase(":memory:"); + const users = createUserStore(botDb.db); + const sessions = createSessionStore(botDb.db); + const u = await users.createUser("alice", "pw"); + validToken = sessions.createSession(u.id).token; + + const { server } = buildServer(sessions); + httpServer = server; + await new Promise((resolve) => httpServer.listen(0, resolve)); + port = (httpServer.address() as AddressInfo).port; + }); + + afterEach(async () => { + await new Promise((resolve) => httpServer.close(() => resolve())); + botDb.close(); + }); + + it("rejects upgrade without cookie (server-side close before open)", async () => { + const ws = new WSClient(`ws://127.0.0.1:${port}/ws`); + const result = await new Promise((resolve) => { + ws.on("open", () => resolve("opened")); + ws.on("unexpected-response", (_req, res) => resolve(`status:${res.statusCode}`)); + ws.on("error", () => resolve("error")); + }); + expect(result).toMatch(/^status:401$|^error$/); + }); + + it("accepts upgrade with a valid cookie", async () => { + const ws = new WSClient(`ws://127.0.0.1:${port}/ws`, { + headers: { Cookie: `${SESSION_COOKIE_NAME}=${validToken}` }, + }); + const msg = await new Promise((resolve, reject) => { + ws.on("message", (data) => resolve(data.toString())); + ws.on("error", reject); + }); + expect(msg).toBe("hello"); + ws.close(); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it passes** + +```bash +npx vitest run src/web/websocket-auth.test.ts +``` +Expected: both tests PASS (the implementation already exists in `server.ts`; this test only validates it). + +- [ ] **Step 3: Commit** + +```bash +git add src/web/websocket-auth.test.ts +git commit -m "test(auth): verify ws upgrade gating end-to-end" +``` + +--- + +## Task 11: Frontend — `useSession` composable + +**Files:** +- Create: `web/src/composables/useSession.ts` + +> Single source of truth for auth state. Other code reads `currentUser`, `needsSetup`, calls `refresh()`, `login()`, `logout()`, `setup()`. + +- [ ] **Step 1: Create `web/src/composables/useSession.ts`** + +```ts +import { ref, computed, readonly } from "vue"; + +interface User { + id: string; + username: string; +} + +const currentUser = ref(null); +const needsSetup = ref(null); // null = unknown / not fetched yet +const ready = ref(false); + +async function refreshNeedsSetup(): Promise { + const res = await fetch("/api/session/needs-setup", { credentials: "same-origin" }); + if (res.ok) { + const body = await res.json(); + needsSetup.value = Boolean(body.needsSetup); + } +} + +async function refreshMe(): Promise { + const res = await fetch("/api/session/me", { credentials: "same-origin" }); + if (res.status === 200) { + currentUser.value = (await res.json()) as User; + } else { + currentUser.value = null; + } +} + +async function refresh(): Promise { + await refreshNeedsSetup(); + if (needsSetup.value) { + currentUser.value = null; + } else { + await refreshMe(); + } + ready.value = true; +} + +async function login(username: string, password: string): Promise { + const res = await fetch("/api/session/login", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `login failed (${res.status})`); + } + currentUser.value = (await res.json()) as User; +} + +async function setup(username: string, password: string): Promise { + const res = await fetch("/api/session/setup", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body.error ?? `setup failed (${res.status})`); + } + currentUser.value = (await res.json()) as User; + needsSetup.value = false; +} + +async function logout(): Promise { + await fetch("/api/session/logout", { method: "POST", credentials: "same-origin" }); + currentUser.value = null; +} + +export function useSession() { + return { + currentUser: readonly(currentUser), + needsSetup: readonly(needsSetup), + isAuthenticated: computed(() => currentUser.value !== null), + ready: readonly(ready), + refresh, + login, + logout, + setup, + }; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/composables/useSession.ts +git commit -m "feat(web): add useSession composable" +``` + +--- + +## Task 12: Frontend — `Login.vue` + +**Files:** +- Create: `web/src/views/Login.vue` + +- [ ] **Step 1: Create `web/src/views/Login.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add web/src/views/Login.vue +git commit -m "feat(web): add Login view" +``` + +--- + +## Task 13: Frontend — `FirstRunSetup.vue` + router wiring + guard + +**Files:** +- Create: `web/src/views/FirstRunSetup.vue` +- Modify: `web/src/router/index.ts` + +- [ ] **Step 1: Create `web/src/views/FirstRunSetup.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Replace `web/src/router/index.ts`** + +```ts +import { createRouter, createWebHistory } from 'vue-router'; +import { useSession } from '../composables/useSession.js'; + +const router = createRouter({ + history: createWebHistory(), + routes: [ + { path: '/', name: 'home', component: () => import('../views/Home.vue') }, + { path: '/search', name: 'search', component: () => import('../views/Search.vue') }, + { path: '/library', name: 'library', component: () => import('../views/Library.vue') }, + { + path: '/playlist/:id', + name: 'playlist', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'playlist' }, + }, + { + path: '/album/:id', + name: 'album', + component: () => import('../views/Playlist.vue'), + meta: { kind: 'album' }, + }, + { path: '/lyrics', name: 'lyrics', component: () => import('../views/Lyrics.vue') }, + { path: '/history', name: 'history', component: () => import('../views/History.vue') }, + { path: '/settings', name: 'settings', component: () => import('../views/Settings.vue') }, + { path: '/setup', name: 'setup', component: () => import('../views/Setup.vue') }, + { path: '/bot/:id', name: 'bot', component: () => import('../views/BotRedirect.vue') }, + + // Auth views + { path: '/login', name: 'login', component: () => import('../views/Login.vue'), meta: { public: true } }, + { path: '/first-run', name: 'first-run', component: () => import('../views/FirstRunSetup.vue'), meta: { public: true } }, + ], +}); + +const PUBLIC_NAMES = new Set(['login', 'first-run']); + +router.beforeEach(async (to) => { + const session = useSession(); + if (!session.ready.value) { + await session.refresh(); + } + + if (session.needsSetup.value && to.name !== 'first-run') { + return { name: 'first-run' }; + } + if (!session.needsSetup.value && to.name === 'first-run') { + return { name: 'home' }; + } + + if (PUBLIC_NAMES.has(to.name as string)) { + if (to.name === 'login' && session.isAuthenticated.value) { + return { name: 'home' }; + } + return true; + } + + if (!session.isAuthenticated.value) { + return { name: 'login', query: { next: to.fullPath } }; + } + return true; +}); + +export default router; +``` + +- [ ] **Step 3: Type-check the web project** + +```bash +cd web && npx vue-tsc --noEmit && cd .. +``` +Expected: zero errors. (If `vue-tsc` is not configured, use `npx tsc --noEmit` inside `web/`.) + +- [ ] **Step 4: Commit** + +```bash +git add web/src/views/FirstRunSetup.vue web/src/router/index.ts +git commit -m "feat(web): add /first-run + /login routes with auth guard" +``` + +--- + +## Task 14: Frontend — API client credentials + global 401 handler + +**Files:** +- Create: `web/src/api/http.ts` +- Modify: `web/src/App.vue` (call `installApiClient` once on mount) +- Audit: every `fetch(...)` call site in `web/src/` to add `credentials: 'same-origin'`. There are likely ~10-20. + +> Two-pronged fix: (1) one global `fetch` wrapper that intercepts 401 and redirects to login; (2) per-call `credentials: 'same-origin'` so cookies are sent. The wrapper sets credentials by default so most call sites only need to switch from `fetch` to `apiFetch`. + +- [ ] **Step 1: Create `web/src/api/http.ts`** + +```ts +import router from '../router/index.js'; +import { useSession } from '../composables/useSession.js'; + +let installed = false; + +/** + * Wraps fetch so every call: + * - sends cookies (`credentials: 'same-origin'`) + * - on 401 from /api/*: clear local session, redirect to /login + */ +export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Promise { + const merged: RequestInit = { + credentials: 'same-origin', + ...init, + headers: { ...(init.headers ?? {}) }, + }; + return fetch(input, merged).then(async (res) => { + if (res.status === 401 && isApiPath(input)) { + const session = useSession(); + await session.refresh(); + const current = router.currentRoute.value; + if (current.name !== 'login' && current.name !== 'first-run') { + await router.replace({ name: 'login', query: { next: current.fullPath } }); + } + } + return res; + }); +} + +function isApiPath(input: RequestInfo | URL): boolean { + const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; + return url.startsWith('/api/'); +} + +/** + * Replaces window.fetch with apiFetch so existing call sites do not need to be touched. + * Call once at app startup. + */ +export function installApiClient(): void { + if (installed) return; + installed = true; + const original = window.fetch.bind(window); + window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + return apiFetch(input, init ?? {}); + }) as typeof window.fetch; + // Keep original accessible if anything needs to bypass + (window as unknown as { __originalFetch?: typeof fetch }).__originalFetch = original; +} +``` + +- [ ] **Step 2: Wire into `web/src/main.ts`** + +Replace `web/src/main.ts` with: + +```ts +import { createApp } from 'vue'; +import { createPinia } from 'pinia'; +import App from './App.vue'; +import router from './router/index.js'; +import { installApiClient } from './api/http.js'; +import './styles/global.scss'; +import './styles/mobile.scss'; + +installApiClient(); + +const app = createApp(App); +app.use(createPinia()); +app.use(router); +app.mount('#app'); +``` + +- [ ] **Step 3: Type-check** + +```bash +cd web && npx vue-tsc --noEmit && cd .. +``` + +- [ ] **Step 4: Commit** + +```bash +git add web/src/api/http.ts web/src/main.ts +git commit -m "feat(web): install global fetch wrapper with credentials + 401 handling" +``` + +--- + +## Task 15: Frontend — logout + username chip in Navbar + +**Files:** +- Modify: `web/src/components/Navbar.vue` + +- [ ] **Step 1: Inspect current Navbar.vue** + +```bash +git show HEAD:web/src/components/Navbar.vue | head -80 +``` +Locate the right-hand side of the desktop nav (where Settings/menu actions live). + +- [ ] **Step 2: Add a user chip + logout button** + +Append (inside the existing template, right-most slot of the desktop nav — placement adjusted to match Navbar's existing structure): + +```vue + +``` + +In the existing `