From f7c16888e75797d6bba67b0acb48263090fc8a83 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:07:51 +0800 Subject: [PATCH 01/35] docs(spec): WebUI authentication design Spec for adding username+password auth to the WebUI to close the unauthenticated-API exposure (all /api/* and /ws currently open). Design: SQLite users + sessions tables, bcryptjs, 7-day rolling HTTP-only cookie sessions, first-run setup wizard, Origin/Referer CSRF check, WebSocket upgrade gated on the same session cookie. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-27-webui-authentication-design.md | 360 ++++++++++++++++++ 1 file changed, 360 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-27-webui-authentication-design.md diff --git a/docs/superpowers/specs/2026-05-27-webui-authentication-design.md b/docs/superpowers/specs/2026-05-27-webui-authentication-design.md new file mode 100644 index 0000000..5ae331b --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-webui-authentication-design.md @@ -0,0 +1,360 @@ +# WebUI Authentication + +**Date:** 2026-05-27 +**Status:** Spec — pending implementation +**Branch:** `feat/webui-auth` + +## Problem + +WebUI 的所有后端端点和 WebSocket 当前没有任何鉴权: + +- `src/web/server.ts` 注册的 `/api/bot`、`/api/player`、`/api/music`、`/api/auth`、`/api/config/public-url`、`/api/health`、`/ws` 均无中间件拦截。 +- 静态前端通过 `express.static()` 直接对外提供。 + +后果:任何能访问 WebUI 端口(默认 `3000`)的人都能控制 bot、修改配置、操控播放,并触发对网易云 / QQ / Bilibili 的登录二维码流程。一旦 WebUI 端口暴露公网(无论是直接绑定 `0.0.0.0`、还是经 nginx 反代),即被任意访客接管。 + +## Goal + +为 WebUI 增加用户名 + 密码登录,覆盖所有 HTTP `/api/*` 端点(除显式公共白名单)以及 `/ws` WebSocket,使未登录访客无法调用任何敏感接口或观察 bot 状态。 + +## Out of Scope(明确不做) + +- 登录失败的限流 / 锁定(无 brute-force 防御;可放在反代层;后续 PR 单独做) +- 角色与权限(admin / viewer)—— 全员同权 +- 密码重置流程(不挂邮件;仅提供登录后 `change-password`) +- 双因素认证(2FA) +- "记住我" / 绝对过期 vs 滑动过期的可配置 +- 旧版"无鉴权"兼容开关(`requireAuth=false`)—— 合入后所有部署强制启用鉴权 +- 现有 `config.adminPassword` 字段的迁移 —— 保留为未使用字段,避免破坏旧 `config.json` + +## Non-functional Constraints + +- 不引入需要原生编译的依赖(Windows 用户多,build tools 不稳定)。密码哈希用纯 JS 的 `bcryptjs`。 +- Cookie 行为必须兼容现有 `trustProxy` 反代部署。 +- 升级路径:旧用户首次启动新版本 → 自动进入 `/setup` 创建首位 admin;期间所有 `/api/*` 仍拒绝访问。期间不存在"裸奔窗口"。 +- 后续维护者要能在不阅读 `requireAuth` 内部细节的情况下,把新路由挂到 `/api/*` 下并自动获得鉴权。 + +## Architecture + +### 数据层(`src/data/`) + +扩展 `src/data/database.ts` 的 schema-migration 块,新增两张表: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, -- uuid v4 + username TEXT NOT NULL UNIQUE COLLATE NOCASE, + passwordHash TEXT NOT NULL, -- bcryptjs, 12 rounds + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, -- sha256(rawToken) hex + userId TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + createdAt INTEGER NOT NULL, + expiresAt INTEGER NOT NULL, -- ms epoch + lastSeenAt INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_sessions_userId ON sessions(userId); +CREATE INDEX IF NOT EXISTS idx_sessions_expiresAt ON sessions(expiresAt); +``` + +**为什么 `sessions.id` 存 sha256(token) 而不是 token 本身:** 若 SQLite 文件被泄露(备份、误传、磁盘扫描),原始 token 会让攻击者直接冒充任意已登录用户。存 hash 后只能爆破。代价仅是每次请求一次 sha256。 + +新模块: + +`src/data/users.ts` +- `createUser(username, password): User` — 在事务里 INSERT;遇到 UNIQUE 冲突抛出 `UsernameTakenError` +- `findByUsername(username): User | null` +- `verifyPassword(plain, hash): Promise` — bcryptjs compare +- `countUsers(): number` — 用于 `/needs-setup` +- `changePassword(userId, newPassword): void` + +`src/data/sessions.ts` +- `createSession(userId): { token: string; expiresAt: number }` — 生成 32 字节随机 token(`crypto.randomBytes(32).toString('base64url')`),存 sha256 +- `validateAndTouch(rawToken): { userId, username } | null` — 单次 SQL JOIN:查 session + user;过期 → 返回 null + 删除该行;否则若 `now - lastSeenAt > 1h` 则 UPDATE 滑动续期到 `now + 7d` +- `deleteSession(rawToken): void` — 退出 +- `deleteAllForUser(userId, exceptToken?): void` — change-password 时调用,可保留当前会话 +- `cleanupExpired(): void` — 定时任务 + +### HTTP 层(`src/web/`) + +#### 新增中间件 + +`src/web/middleware/requireAuth.ts` +``` +读取 req.cookies.tsmb_session + → 缺失 → 401 { error: "unauthenticated" } + → 调 sessions.validateAndTouch + → null → 清 cookie + 401 + → 有效 → req.user = { id, username }; next() +``` + +`src/web/middleware/csrf.ts` +``` +若 method ∈ {GET, HEAD, OPTIONS} → next() +否则要求 req.headers.origin || req.headers.referer 的 host 与 req.get('host') 一致 + → 不一致或两者都缺失 → 403 { error: "bad origin" } +``` + +#### 新路由:`src/web/api/session.ts` + +挂在 `/api/session`,全部公共(不挂 requireAuth): + +| Method | Path | 行为 | +|---|---|---| +| GET | `/needs-setup` | `{ needsSetup: users.countUsers() === 0 }` | +| POST | `/setup` | Body `{ username, password }`。在事务内再次检查 `countUsers() === 0`:是则 INSERT user + 立刻 createSession + Set-Cookie + 200 `{ id, username }`;否则 409 `{ error: "already initialized" }` | +| POST | `/login` | Body `{ username, password }`。匹配则 createSession + Set-Cookie + 200;不匹配则等待 250ms 后 401 `{ error: "invalid credentials" }`(常量时间延迟,降低用户名枚举风险) | +| POST | `/logout` | 删 session,清 cookie,204 | +| GET | `/me` | 走 requireAuth;返回 `{ id, username }` | +| POST | `/change-password` | 走 requireAuth;Body `{ oldPassword, newPassword }`;通过则 changePassword + deleteAllForUser(except 当前) + 204 | + +> `/me` 与 `/change-password` 例外地需要 requireAuth —— 在路由内单独挂中间件,避免污染 `/api/session/*` 的公共属性。 + +#### Cookie 规范 + +- 名称:`tsmb_session` +- 值:32 字节 random → base64url +- 属性:`HttpOnly; SameSite=Lax; Path=/; Max-Age=604800`(7 天) +- `Secure` 标志:当 `req.secure === true`(依赖 `trustProxy` + `X-Forwarded-Proto`);本地 HTTP 调试时不加,避免 cookie 被丢弃 + +#### 装配顺序(`src/web/server.ts`) + +```ts +app.use(express.json({ limit: "400kb" })); +app.use(cookieParser()); // 新增 + +// 公共 +app.get("/api/health", …); +app.get("/api/config/public-url", …); +app.use("/api/session", createSessionRouter(...)); + +// 闸门(仅作用于下方注册的 /api/* 路由) +app.use("/api", csrfOriginCheck); +app.use("/api", requireAuth); + +// 受保护 +app.use("/api/bot", createBotRouter(...)); +app.use("/api/music", createMusicRouter(...)); +app.use("/api/player", createPlayerRouter(...)); +app.use("/api/auth", createAuthRouter(...)); // 音乐平台 QR + +// 静态 SPA(公共,前端自行判定登录态后跳转) +app.use(express.static(staticDir)); +app.get(/^(?!\/api|\/ws)/, sendIndex); +``` + +> Express 的 `app.use` 仅对匹配前缀生效。公共路由先注册即可命中;之后的 `app.use("/api", …)` 闸门只在公共路由未匹配时执行,因此 `/api/health`、`/api/config/public-url`、`/api/session/*` 不会被闸门拦截。 + +#### 定时清理 + +`server.start()` 内启动 `setInterval(cleanupExpired, 60 * 60 * 1000)`,`server.stop()` 内 `clearInterval`。 + +### WebSocket 层(`src/web/websocket.ts` + `src/web/server.ts`) + +改造为手动 upgrade: + +```ts +const wss = new WebSocketServer({ noServer: true }); + +server.on("upgrade", (req, socket, head) => { + if (req.url !== "/ws") { socket.destroy(); return; } + const session = validateCookieFromHeaders(req.headers.cookie); + if (!session) { + 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 any).userId = session.userId; + wss.emit("connection", ws, req); + }); +}); +``` + +`validateCookieFromHeaders` 在 `src/web/auth/validateSession.ts` 提供,HTTP 中间件与 WS upgrade 共用同一实现,确保不会出现"HTTP 拒、WS 放行"或反之的偏差。 + +不需要在 upgrade 上单独做 CSRF:浏览器在跨站 WebSocket 请求里仍会带 Origin 头,可在 validate 之外顺手比对 `req.headers.origin` host 与 `req.headers.host` 一致;不一致直接拒绝。 + +### 前端层(`web/`) + +#### 新视图 + +- `web/src/views/Login.vue` — 用户名 + 密码表单 → POST `/api/session/login` → 成功跳 `next` 或 `/` +- `web/src/views/FirstRunSetup.vue` — 同样表单 + 二次确认密码 → POST `/api/session/setup` → 成功后自动登录并跳 `/` + - 名称避免与既有 `Setup.vue`(bot 创建向导)冲突 + +#### Session 状态 + +新增 `web/src/composables/useSession.ts`:暴露 `currentUser: Ref`、`refresh()`、`logout()`、`needsSetup: Ref`。在 `App.vue` mount 时调用 `refresh()`。 + +#### 路由守卫(`web/src/router/index.ts`) + +- 公共路由:`/login`、`/setup` +- 全局 `beforeEach`: + 1. 先 `GET /api/session/needs-setup`(仅在 `needsSetup` 未知时拉一次并缓存) + 2. `needsSetup === true` 且目标不是 `/setup` → `redirect('/setup')` + 3. 否则 `GET /api/session/me`,401 且目标非公共路由 → `redirect('/login?next=')` + +#### API 客户端 + +- 所有 `fetch` 改为 `credentials: 'same-origin'`(若现有有 wrapper 则改一处;否则按文件逐个改 —— 实施时由 plan 列出) +- 包一层 401 拦截器:任意受保护请求返回 401 → 清 `currentUser` → `router.push('/login')` + +#### UI + +- 顶栏新增已登录用户名 + "退出"按钮(POST `/logout` → `router.push('/login')`) +- 修改密码入口暂放在已有的"设置"页签内(若无则新增极简 section) + +### 依赖 + +新增到 `package.json`: + +``` +"bcryptjs": "^2.4.3", +"cookie-parser": "^1.4.6", +"@types/bcryptjs": "^2.4.6", +"@types/cookie-parser": "^1.4.7" +``` + +不引入 `express-session`、`jsonwebtoken`、`passport` 等更大栈。 + +## Data Flow + +### 首次启动 + +``` +Browser → GET / → static SPA +SPA mounted → GET /api/session/needs-setup → { needsSetup: true } +SPA → router.replace('/setup') +User submits form → POST /api/session/setup +Server (TX): countUsers() === 0 → INSERT user → createSession → Set-Cookie → 200 +SPA → currentUser refresh → router.replace('/') +``` + +### 已部署用户升级 + +旧 `config.adminPassword` 字段保留不动;首次启动新版本仍会因 `users` 表为空而进入 setup 流程 —— 旧字段不被采纳,避免歧义。 + +### 后续登录 + +``` +SPA → GET /api/session/me → 401 +SPA → router.replace('/login?next=/queue') +User submits → POST /api/session/login → Set-Cookie + 200 +SPA → currentUser refresh → router.replace('/queue') +``` + +### 受保护请求 + +``` +SPA → fetch('/api/bot', { credentials: 'same-origin' }) +Server requireAuth: validateAndTouch(cookie) + → ok → req.user 注入 → 业务路由处理 + → 不 ok → 401 → SPA 拦截器跳 /login +``` + +### WebSocket + +``` +SPA → new WebSocket(`${wsScheme}://${host}/ws`) // 浏览器自动带 cookie +Server upgrade handler: validateCookieFromHeaders + → ok → handleUpgrade → connection event + → 不 ok → HTTP 401 写回原始 socket → destroy +``` + +## Error Handling + +| 场景 | HTTP 响应 | 备注 | +|---|---|---| +| 未带 cookie | 401 `{ error: "unauthenticated" }` | requireAuth | +| Cookie 解析失败 / token 不存在 | 401 + `Set-Cookie tsmb_session=; Max-Age=0` 清掉 | 自愈 | +| Session 过期 | 同上 + DELETE 该行 | validateAndTouch 内部完成 | +| 用户名/密码不匹配 | 401 `{ error: "invalid credentials" }` + 250ms 延迟 | 不区分"用户不存在"和"密码错"两类 | +| `setup` 时已存在用户 | 409 `{ error: "already initialized" }` | 防止重复初始化 | +| `setup` 用户名重复 | 在 `/setup` 流程中不可能(只允许 0 → 1) | | +| `change-password` 旧密码错 | 401 `{ error: "invalid credentials" }` | | +| CSRF Origin 不匹配 | 403 `{ error: "bad origin" }` | | +| WS 无 cookie / 校验失败 | 写回 HTTP/1.1 401 并 destroy socket | 在握手前拒绝,避免 onopen 假成功 | + +所有错误响应统一 `{ error: string }` 形式,匹配现有 API 风格。 + +## Testing Strategy + +### 单元(vitest) + +`src/data/users.test.ts` +- createUser 成功后 findByUsername 命中(大小写不敏感) +- 重复 username 抛 UsernameTakenError +- verifyPassword 正反例 +- changePassword 之后旧哈希不再验证通过 + +`src/data/sessions.test.ts` +- createSession 返回的 token 不是 DB 内 id(DB 内是 sha256(token)) +- validateAndTouch 过期记录返回 null 且记录被删 +- validateAndTouch 未过 1h 不写 DB;过 1h 后写 DB(用 `Date.now` mock 验证) +- deleteAllForUser(exceptToken) 保留指定会话 + +### 集成(vitest + supertest,真 SQLite in-memory) + +`src/web/api/session.test.ts` +- empty DB → /needs-setup 返回 true;/setup 成功;/needs-setup 再调返回 false;二次 /setup 返回 409 +- /login 成功后受保护路由 (`GET /api/bot`) 200;不带 cookie 401 +- /logout 之后同一 cookie 调受保护路由 401 +- /change-password 后 a) 旧密码 /login 失败 b) 新密码 /login 成功 c) 之前签发的其他 cookie 失效,当前 cookie 仍可用 + +`src/web/middleware/csrf.test.ts` +- 带匹配 Origin 的 POST 通过 +- Origin 与 host 不匹配 → 403 +- 同样规则适用 Referer +- GET 永远通过 + +`src/web/websocket.test.ts`(新增或扩展) +- 无 cookie 的 ws 握手 → 收到 HTTP 401,socket 关闭 +- 带有效 cookie → 握手成功,收到 init 消息 +- Session 删除后已建立的 ws **不会**被主动断(明确记录此妥协 —— 见 Trade-offs) + +### 前端 + +不在本 PR 引入新的 e2e 框架。手动用例(在 PR 描述里列): +- 全新数据库启动 → 自动跳 /setup → 创建账户 → 进入主界面 +- 退出 → 自动跳 /login +- 关闭浏览器 7 天内再开 → 仍登录 +- 登录态下后端重启清空 sessions → 任意 API 调用 → 自动跳 /login + +## Files Changed + +``` +src/data/database.ts (schema migration) +src/data/users.ts (new) +src/data/users.test.ts (new) +src/data/sessions.ts (new) +src/data/sessions.test.ts (new) +src/web/auth/validateSession.ts (new, shared by HTTP + WS) +src/web/middleware/requireAuth.ts (new) +src/web/middleware/csrf.ts (new) +src/web/middleware/csrf.test.ts (new) +src/web/api/session.ts (new) +src/web/api/session.test.ts (new) +src/web/server.ts (cookieParser + 公共白名单 + 闸门 + cleanup interval + WS upgrade 重构调用) +src/web/websocket.ts (移除被动 path 绑定;改为 handleUpgrade 模式) +src/web/websocket.test.ts (新增 / 扩展) +package.json (deps) + +web/src/views/Login.vue (new) +web/src/views/FirstRunSetup.vue (new) +web/src/composables/useSession.ts (new) +web/src/router/index.ts (公共路由 + beforeEach 守卫) +web/src/api/*.ts (credentials: 'same-origin' + 401 拦截) +web/src/App.vue (顶栏 logout + 当前用户名) +``` + +## Trade-offs / 已知妥协 + +1. **会话失效不主动断 WS** —— 后台 deleteSession 后,已有 WS 仍在跑(直到客户端断或服务端进程重启)。原因:WS 长连接没有"每条消息再次鉴权"的廉价手段;为此引入会浪费时间。影响面有限:WS 只推状态、不接收 mutating 命令;所有写操作仍走 HTTP。 +2. **无登录限流** —— 见 Out of Scope。若部署面向公网,建议在反代层加 limit(如 nginx `limit_req`)。 +3. **`config.adminPassword` 留作未使用字段** —— 不迁移、不读取。后续 PR 可移除并加 schema migration。当前保留是为避免破坏旧 `config.json` 解析。 +4. **单一管理员模型** —— 多用户表已存在,但 UI 当前不暴露增删用户。下一个 PR 再加用户管理界面。 +5. **Origin/Referer CSRF 检查** —— 不是 token,但配合 `SameSite=Lax` 已能挡掉常规 CSRF 攻击。代价:会拒绝缺 Origin/Referer 的非浏览器客户端 POST 请求(如裸 curl)—— 这是预期行为。 From d3af918f53a36077fab4e4010eb5d3d5e8f1fd7b Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:18:10 +0800 Subject: [PATCH 02/35] docs(plan): WebUI authentication implementation plan 16 bite-sized tasks with TDD discipline: - 10 backend (schema, users, sessions, middleware, /api/session router, server.ts wiring, WS upgrade gating + integration test) - 5 frontend (useSession composable, Login + FirstRunSetup views, router guard, fetch wrapper, Navbar logout) - 1 manual smoke test gate before PR Notes /first-run as the admin-setup route since /setup is already taken by the bot-creation wizard. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-27-webui-authentication.md | 2215 +++++++++++++++++ 1 file changed, 2215 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-27-webui-authentication.md 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 ` + + From 0dc87469149f37209b247846443699331c0c98a8 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:52:49 +0800 Subject: [PATCH 16/35] feat(web): add /first-run + /login routes with auth guard Co-Authored-By: Claude Sonnet 4.6 --- web/src/router/index.ts | 82 ++++++++++++++++----------------- web/src/views/FirstRunSetup.vue | 75 ++++++++++++++++++++++++++++++ 2 files changed, 116 insertions(+), 41 deletions(-) create mode 100644 web/src/views/FirstRunSetup.vue diff --git a/web/src/router/index.ts b/web/src/router/index.ts index d62afcd..3965ceb 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,23 +1,12 @@ 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: '/', 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', @@ -30,33 +19,44 @@ const router = createRouter({ 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'), - }, - { - // Per-bot URL: /bot/:id — sets active bot then redirects to home - path: '/bot/:id', - name: 'bot', - component: () => import('../views/BotRedirect.vue'), - }, + { 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; diff --git a/web/src/views/FirstRunSetup.vue b/web/src/views/FirstRunSetup.vue new file mode 100644 index 0000000..24664d6 --- /dev/null +++ b/web/src/views/FirstRunSetup.vue @@ -0,0 +1,75 @@ + + + + + From 7509814abc50b5ecdbe4b0a8a896763e207af75e Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:54:24 +0800 Subject: [PATCH 17/35] feat(web): install global fetch wrapper with credentials + 401 handling Co-Authored-By: Claude Sonnet 4.6 --- web/src/api/http.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++ web/src/main.ts | 3 +++ 2 files changed, 51 insertions(+) create mode 100644 web/src/api/http.ts diff --git a/web/src/api/http.ts b/web/src/api/http.ts new file mode 100644 index 0000000..676be26 --- /dev/null +++ b/web/src/api/http.ts @@ -0,0 +1,48 @@ +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; +} diff --git a/web/src/main.ts b/web/src/main.ts index e20f04b..89568a9 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -2,9 +2,12 @@ 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); From e2e888710af6db37bc290afb036fc981ec0a2f1d Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:56:14 +0800 Subject: [PATCH 18/35] fix(web): exclude /api/session/* from 401 auto-refresh to prevent re-entrancy --- web/src/api/http.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/api/http.ts b/web/src/api/http.ts index 676be26..920c02b 100644 --- a/web/src/api/http.ts +++ b/web/src/api/http.ts @@ -15,7 +15,7 @@ export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Prom headers: { ...(init.headers ?? {}) }, }; return fetch(input, merged).then(async (res) => { - if (res.status === 401 && isApiPath(input)) { + if (res.status === 401 && shouldTriggerRefresh(input)) { const session = useSession(); await session.refresh(); const current = router.currentRoute.value; @@ -27,9 +27,9 @@ export function apiFetch(input: RequestInfo | URL, init: RequestInit = {}): Prom }); } -function isApiPath(input: RequestInfo | URL): boolean { +function shouldTriggerRefresh(input: RequestInfo | URL): boolean { const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; - return url.startsWith('/api/'); + return url.startsWith('/api/') && !url.startsWith('/api/session/'); } /** From e148c1556e320acbcf62b0decbca4d0a3a55de61 Mon Sep 17 00:00:00 2001 From: saopig1 <4x7sw862st@gmail.com> Date: Wed, 27 May 2026 13:57:31 +0800 Subject: [PATCH 19/35] feat(web): show current user + logout button in nav Co-Authored-By: Claude Sonnet 4.6 --- web/src/components/Navbar.vue | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/web/src/components/Navbar.vue b/web/src/components/Navbar.vue index 1776d59..789e865 100644 --- a/web/src/components/Navbar.vue +++ b/web/src/components/Navbar.vue @@ -88,6 +88,13 @@ + + @@ -114,10 +121,19 @@