diff --git a/docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md b/docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md new file mode 100644 index 0000000..eeba591 --- /dev/null +++ b/docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md @@ -0,0 +1,2957 @@ +# Darkwatch Auth + MariaDB Migration 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:** Replace SQLite with MariaDB in Docker, add email/password auth with JWT httpOnly cookies, and enforce per-campaign DM/player role separation across the API, sockets, and frontend. + +**Architecture:** MariaDB 11 runs in `darkwatch-maria` Docker container (port 3307, volume `darkwatch-mariadb-data`). Server uses `mysql2` async/await for all queries. JWTs are signed with `jsonwebtoken`, stored in `httpOnly` cookies, and verified by Express middleware and Socket.io handshake middleware. React auth context holds current user; `RequireAuth` component guards routes. + +**Tech Stack:** MariaDB 11 (Docker/OrbStack), mysql2, jsonwebtoken, bcrypt, cookie-parser, dotenv (server); React context + react-router-dom (client). Plain SQL — no ORM. + +**⚠ Docker safety:** Never touch the `mysql` container or any volume not prefixed `darkwatch-`. All Darkwatch Docker resources use `darkwatch-*` naming. + +--- + +## File Map + +**New — server:** +- `docker-compose.yml` — MariaDB service definition +- `.env.example` — committed template +- `server/.env` — local dev secrets (gitignored) +- `server/migrations/001_initial_schema.sql` — full MariaDB schema +- `server/src/migrate.ts` — migration runner +- `server/src/auth/jwt.ts` — sign/verify JWT +- `server/src/auth/middleware.ts` — requireAuth, requireCampaignRole + +**New — server routes:** +- `server/src/routes/auth.ts` — register, login, logout, me + +**Modified — server:** +- `server/package.json` — swap better-sqlite3 for mysql2 + auth deps +- `server/src/db.ts` — complete rewrite: mysql2 pool +- `server/src/index.ts` — dotenv, cookie-parser, CORS with credentials, auth routes, run migrations +- `server/src/socket.ts` — cookie-based JWT auth +- `server/src/seed-dev-data.ts` — rewrite for MariaDB with test users +- `server/src/routes/campaigns.ts` — async mysql2 + membership + invite + join + my-role +- `server/src/routes/characters.ts` — async mysql2 + user_id + ownership checks +- `server/src/routes/rolls.ts` — async mysql2 +- `server/src/routes/game-items.ts` — async mysql2 +- `server/src/routes/game-talents.ts` — async mysql2 + +**New — client:** +- `client/src/context/AuthContext.tsx` — current user state + login/logout helpers +- `client/src/components/RequireAuth.tsx` — redirect to /login if not authed +- `client/src/pages/LoginPage.tsx` +- `client/src/pages/LoginPage.module.css` +- `client/src/pages/RegisterPage.tsx` +- `client/src/pages/RegisterPage.module.css` +- `client/src/pages/JoinPage.tsx` +- `client/src/pages/JoinPage.module.css` + +**Modified — client:** +- `client/src/api.ts` — add `credentials: 'include'` to all requests; add auth + invite API calls +- `client/src/socket.ts` — add `withCredentials: true` +- `client/src/App.tsx` — AuthProvider, RequireAuth, new routes +- `client/src/pages/CampaignView.tsx` — fetch role on mount, conditional DM/player UI + +--- + +## Task 1: Docker — MariaDB container + +**Files:** +- Create: `docker-compose.yml` (repo root — `/Users/aaron.wood/workspace/shadowdark/docker-compose.yml`) +- Create: `.env.example` (repo root) +- Create: `server/.env` +- Modify: `.gitignore` (repo root — add `server/.env`) + +- [ ] **Step 1: Write docker-compose.yml** + +```yaml +name: darkwatch +services: + darkwatch-maria: + image: mariadb:11 + container_name: darkwatch-maria + restart: unless-stopped + volumes: + - darkwatch-mariadb-data:/var/lib/mysql + ports: + - "3307:3306" + environment: + MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD} + MARIADB_DATABASE: ${DB_NAME} + MARIADB_USER: ${DB_USER} + MARIADB_PASSWORD: ${DB_PASSWORD} + +volumes: + darkwatch-mariadb-data: +``` + +- [ ] **Step 2: Write .env.example** + +``` +DB_HOST=127.0.0.1 +DB_PORT=3307 +DB_NAME=darkwatch +DB_USER=darkwatch +DB_PASSWORD=darkwatch_dev +DB_ROOT_PASSWORD=rootpassword_dev +JWT_SECRET=change_me_in_production +CLIENT_URL=http://localhost:5173 +``` + +- [ ] **Step 3: Write server/.env** (same values as .env.example, used by the server process) + +``` +DB_HOST=127.0.0.1 +DB_PORT=3307 +DB_NAME=darkwatch +DB_USER=darkwatch +DB_PASSWORD=darkwatch_dev +DB_ROOT_PASSWORD=rootpassword_dev +JWT_SECRET=dev_jwt_secret_32chars_minimum_ok +CLIENT_URL=http://localhost:5173 +``` + +- [ ] **Step 4: Add .gitignore entries** + +Open `/Users/aaron.wood/workspace/shadowdark/.gitignore`. If it doesn't exist, create it. Add: +``` +server/.env +*.db +``` + +- [ ] **Step 5: Start the container** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +docker compose up -d darkwatch-maria +``` + +Wait ~10 seconds for MariaDB to initialise. + +- [ ] **Step 6: Verify connection** + +```bash +docker exec darkwatch-maria mariadb -udarkwatch -pdarkwatch_dev darkwatch -e "SELECT 'connected' AS status" +``` + +Expected output: +``` ++----------+ +| status | ++----------+ +| connected| ++----------+ +``` + +- [ ] **Step 7: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add docker-compose.yml .env.example .gitignore +git commit -m "feat: add MariaDB Docker container for Darkwatch" +``` + +--- + +## Task 2: Server — install packages and rewrite db.ts + +**Files:** +- Modify: `server/package.json` +- Modify: `server/src/db.ts` + +- [ ] **Step 1: Install new packages and remove better-sqlite3** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server +npm remove better-sqlite3 @types/better-sqlite3 +npm install mysql2 jsonwebtoken bcrypt cookie-parser dotenv +npm install --save-dev @types/jsonwebtoken @types/bcrypt @types/cookie-parser +``` + +- [ ] **Step 2: Verify package.json dependencies look correct** + +```bash +cat package.json +``` + +Expected — dependencies should include `mysql2`, `jsonwebtoken`, `bcrypt`, `cookie-parser`, `dotenv`. `better-sqlite3` must not appear. + +- [ ] **Step 3: Rewrite server/src/db.ts** + +Replace the entire file with: + +```typescript +import mysql from "mysql2/promise"; +import dotenv from "dotenv"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +dotenv.config({ path: path.join(__dirname, "..", "..", ".env") }); + +const pool = mysql.createPool({ + host: process.env.DB_HOST ?? "127.0.0.1", + port: Number(process.env.DB_PORT ?? 3307), + user: process.env.DB_USER ?? "darkwatch", + password: process.env.DB_PASSWORD ?? "darkwatch_dev", + database: process.env.DB_NAME ?? "darkwatch", + waitForConnections: true, + connectionLimit: 10, + timezone: "+00:00", +}); + +export default pool; +``` + +- [ ] **Step 4: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/package.json server/package-lock.json server/src/db.ts +git commit -m "feat: replace better-sqlite3 with mysql2 connection pool" +``` + +--- + +## Task 3: Server — write migration SQL and migration runner + +**Files:** +- Create: `server/migrations/001_initial_schema.sql` +- Create: `server/src/migrate.ts` + +- [ ] **Step 1: Create migrations directory and write 001_initial_schema.sql** + +Create directory: `server/migrations/` + +Write `server/migrations/001_initial_schema.sql`: + +```sql +CREATE TABLE IF NOT EXISTS users ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + email VARCHAR(255) NOT NULL, + username VARCHAR(100) NOT NULL, + password_hash VARCHAR(255) NOT NULL, + avatar_url VARCHAR(500) DEFAULT NULL, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY uq_users_email (email) +); + +CREATE TABLE IF NOT EXISTS campaigns ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + created_at DATETIME DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS campaign_members ( + campaign_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED NOT NULL, + role ENUM('dm', 'player') NOT NULL, + joined_at DATETIME DEFAULT NOW(), + PRIMARY KEY (campaign_id, user_id), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS campaign_invites ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + token VARCHAR(64) NOT NULL, + created_by INT UNSIGNED NOT NULL, + expires_at DATETIME DEFAULT NULL, + created_at DATETIME DEFAULT NOW(), + UNIQUE KEY uq_invites_token (token), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES users(id) +); + +CREATE TABLE IF NOT EXISTS characters ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + user_id INT UNSIGNED DEFAULT NULL, + name VARCHAR(255) NOT NULL, + class VARCHAR(100) NOT NULL DEFAULT 'Fighter', + ancestry VARCHAR(100) NOT NULL DEFAULT 'Human', + level INT NOT NULL DEFAULT 1, + xp INT NOT NULL DEFAULT 0, + hp_current INT NOT NULL DEFAULT 0, + hp_max INT NOT NULL DEFAULT 0, + ac INT NOT NULL DEFAULT 10, + alignment VARCHAR(50) NOT NULL DEFAULT 'Neutral', + title VARCHAR(255) DEFAULT '', + notes TEXT DEFAULT '', + background VARCHAR(255) DEFAULT '', + deity VARCHAR(255) DEFAULT '', + languages VARCHAR(500) DEFAULT '', + gp INT NOT NULL DEFAULT 0, + sp INT NOT NULL DEFAULT 0, + cp INT NOT NULL DEFAULT 0, + gear_slots_max INT NOT NULL DEFAULT 10, + overrides TEXT DEFAULT '{}', + color VARCHAR(100) DEFAULT '', + luck_token TINYINT NOT NULL DEFAULT 1, + torch_lit_at DATETIME DEFAULT NULL, + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL +); + +CREATE TABLE IF NOT EXISTS character_stats ( + character_id INT UNSIGNED NOT NULL, + stat_name VARCHAR(10) NOT NULL, + value INT NOT NULL DEFAULT 10, + PRIMARY KEY (character_id, stat_name), + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS character_gear ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL DEFAULT 'gear', + slot_count INT NOT NULL DEFAULT 1, + properties TEXT DEFAULT '{}', + effects TEXT DEFAULT '{}', + game_item_id INT UNSIGNED DEFAULT NULL, + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS character_talents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + character_id INT UNSIGNED NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + effect TEXT DEFAULT '{}', + game_talent_id INT UNSIGNED DEFAULT NULL, + FOREIGN KEY (character_id) REFERENCES characters(id) ON DELETE CASCADE +); + +CREATE TABLE IF NOT EXISTS game_items ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + type VARCHAR(50) NOT NULL, + slot_count INT NOT NULL DEFAULT 1, + effects TEXT DEFAULT '{}', + properties TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS game_talents ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + source VARCHAR(255) NOT NULL, + description TEXT DEFAULT '', + effect TEXT DEFAULT '{}' +); + +CREATE TABLE IF NOT EXISTS roll_log ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + campaign_id INT UNSIGNED NOT NULL, + character_id INT UNSIGNED DEFAULT NULL, + character_name VARCHAR(255) NOT NULL DEFAULT 'Roll', + type VARCHAR(50) NOT NULL DEFAULT 'custom', + label VARCHAR(255) NOT NULL, + dice_expression VARCHAR(255) NOT NULL, + rolls TEXT NOT NULL DEFAULT '[]', + modifier INT NOT NULL DEFAULT 0, + total INT NOT NULL DEFAULT 0, + advantage TINYINT NOT NULL DEFAULT 0, + disadvantage TINYINT NOT NULL DEFAULT 0, + nat20 TINYINT NOT NULL DEFAULT 0, + character_color VARCHAR(100) DEFAULT '', + created_at DATETIME DEFAULT NOW(), + FOREIGN KEY (campaign_id) REFERENCES campaigns(id) ON DELETE CASCADE +); +``` + +- [ ] **Step 2: Write server/src/migrate.ts** + +```typescript +import type { Pool } from "mysql2/promise"; +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const MIGRATIONS_DIR = path.join(__dirname, "..", "..", "migrations"); + +export async function runMigrations(pool: Pool): Promise { + await pool.execute(` + CREATE TABLE IF NOT EXISTS _migrations ( + id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + filename VARCHAR(255) NOT NULL UNIQUE, + run_at DATETIME DEFAULT NOW() + ) + `); + + const files = fs + .readdirSync(MIGRATIONS_DIR) + .filter((f) => f.endsWith(".sql")) + .sort(); + + for (const file of files) { + const [rows] = await pool.execute( + "SELECT id FROM _migrations WHERE filename = ?", + [file] + ); + if (rows.length > 0) continue; + + const sql = fs.readFileSync(path.join(MIGRATIONS_DIR, file), "utf-8"); + const statements = sql + .split(";") + .map((s) => s.trim()) + .filter((s) => s.length > 0 && !s.startsWith("--")); + + for (const stmt of statements) { + await pool.execute(stmt); + } + + await pool.execute("INSERT INTO _migrations (filename) VALUES (?)", [file]); + console.log(`Migration applied: ${file}`); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/migrations/ server/src/migrate.ts +git commit -m "feat: add MariaDB schema migration and runner" +``` + +--- + +## Task 4: Server — update index.ts (dotenv, CORS, cookie-parser, migrations) + +**Files:** +- Modify: `server/src/index.ts` + +- [ ] **Step 1: Rewrite server/src/index.ts** + +Replace the entire file: + +```typescript +import "dotenv/config"; +import express from "express"; +import cors from "cors"; +import cookieParser from "cookie-parser"; +import { createServer } from "http"; +import { Server } from "socket.io"; +import { setupSocket } from "./socket.js"; +import { runMigrations } from "./migrate.js"; +import { seedDevData } from "./seed-dev-data.js"; +import campaignRoutes from "./routes/campaigns.js"; +import characterRoutes from "./routes/characters.js"; +import gameItemRoutes from "./routes/game-items.js"; +import gameTalentRoutes from "./routes/game-talents.js"; +import rollRoutes from "./routes/rolls.js"; +import authRoutes from "./routes/auth.js"; +import db from "./db.js"; + +const CLIENT_URL = process.env.CLIENT_URL ?? "http://localhost:5173"; + +const app = express(); +const httpServer = createServer(app); +const io = new Server(httpServer, { + cors: { origin: CLIENT_URL, credentials: true }, +}); + +app.use(cors({ origin: CLIENT_URL, credentials: true })); +app.use(express.json()); +app.use(cookieParser()); + +app.set("io", io); + +setupSocket(io); + +app.use("/api/auth", authRoutes); +app.use("/api/campaigns", campaignRoutes); +app.use("/api/campaigns/:campaignId/characters", characterRoutes); +app.use("/api/characters", characterRoutes); +app.use("/api/game-items", gameItemRoutes); +app.use("/api/game-talents", gameTalentRoutes); +app.use("/api/campaigns/:campaignId/rolls", rollRoutes); + +const PORT = process.env.PORT ?? 3000; + +async function start() { + await runMigrations(db); + await seedDevData(); + httpServer.listen(PORT, () => { + console.log(`Darkwatch server running on http://localhost:${PORT}`); + }); +} + +start().catch((err) => { + console.error("Failed to start server:", err); + process.exit(1); +}); + +export { io }; +``` + +Note: `authRoutes` doesn't exist yet — the server won't compile until Task 9 creates it. With `tsx watch`, it will start but crash on the import. Create a placeholder for now: + +- [ ] **Step 2: Create placeholder auth route** + +Create `server/src/routes/auth.ts`: + +```typescript +import { Router } from "express"; +const router = Router(); +export default router; +``` + +- [ ] **Step 3: Start the server and verify migrations run** + +```bash +cd /Users/aaron.wood/workspace/shadowdark/server +npm run dev +``` + +Look for these lines in output: +``` +Migration applied: 001_initial_schema.sql +Darkwatch server running on http://localhost:3000 +``` + +If you see a MariaDB connection error, ensure the Docker container is running: +```bash +docker ps | grep darkwatch-maria +``` + +- [ ] **Step 4: Verify tables were created** + +```bash +docker exec darkwatch-maria mariadb -udarkwatch -pdarkwatch_dev darkwatch -e "SHOW TABLES" +``` + +Expected output should list: `_migrations`, `campaign_invites`, `campaign_members`, `campaigns`, `character_gear`, `character_stats`, `character_talents`, `characters`, `game_items`, `game_talents`, `roll_log`, `users` + +- [ ] **Step 5: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/index.ts server/src/routes/auth.ts +git commit -m "feat: wire up migrations, CORS with credentials, cookie-parser" +``` + +--- + +## Task 5: Server — convert campaigns.ts, game-items.ts, game-talents.ts, rolls.ts to async mysql2 + +**Files:** +- Modify: `server/src/routes/campaigns.ts` +- Modify: `server/src/routes/game-items.ts` +- Modify: `server/src/routes/game-talents.ts` +- Modify: `server/src/routes/rolls.ts` + +The pattern for all mysql2 queries: +- `const [rows] = await db.execute(sql, params)` for SELECT +- `const [result] = await db.execute(sql, params)` for INSERT/UPDATE/DELETE +- All route handlers become `async (req, res) => { ... }` + +- [ ] **Step 1: Rewrite server/src/routes/campaigns.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import db from "../db.js"; + +const router = Router(); + +// GET /api/campaigns +router.get("/", async (_req, res) => { + const [rows] = await db.execute( + "SELECT * FROM campaigns ORDER BY created_at DESC" + ); + res.json(rows); +}); + +// POST /api/campaigns +router.post("/", async (req, res) => { + const { name } = req.body; + if (!name?.trim()) { + res.status(400).json({ error: "Campaign name is required" }); + return; + } + const [result] = await db.execute( + "INSERT INTO campaigns (name) VALUES (?)", + [name.trim()] + ); + const [rows] = await db.execute( + "SELECT * FROM campaigns WHERE id = ?", + [result.insertId] + ); + res.status(201).json(rows[0]); +}); + +// GET /api/campaigns/:id +router.get("/:id", async (req, res) => { + const [rows] = await db.execute( + "SELECT * FROM campaigns WHERE id = ?", + [req.params.id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Campaign not found" }); + return; + } + res.json(rows[0]); +}); + +// DELETE /api/campaigns/:id +router.delete("/:id", async (req, res) => { + const [result] = await db.execute( + "DELETE FROM campaigns WHERE id = ?", + [req.params.id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Campaign not found" }); + return; + } + res.status(204).end(); +}); + +export default router; +``` + +- [ ] **Step 2: Rewrite server/src/routes/game-items.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import db from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const [rows] = await db.execute( + "SELECT * FROM game_items ORDER BY type, name" + ); + const parsed = rows.map((item) => ({ + ...item, + effects: JSON.parse(item.effects as string), + properties: JSON.parse(item.properties as string), + })); + res.json(parsed); +}); + +export default router; +``` + +- [ ] **Step 3: Rewrite server/src/routes/game-talents.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import db from "../db.js"; + +const router = Router(); + +router.get("/", async (_req, res) => { + const [rows] = await db.execute( + "SELECT * FROM game_talents ORDER BY source, name" + ); + const parsed = rows.map((t) => ({ + ...t, + effect: JSON.parse(t.effect as string), + })); + res.json(parsed); +}); + +export default router; +``` + +- [ ] **Step 4: Rewrite server/src/routes/rolls.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket } from "mysql2"; +import db from "../db.js"; + +const router = Router({ mergeParams: true }); + +router.get("/", async (req, res) => { + const { campaignId } = req.params; + const [rows] = await db.execute( + "SELECT * FROM roll_log WHERE campaign_id = ? ORDER BY created_at DESC LIMIT 50", + [campaignId] + ); + const parsed = rows.map((r) => ({ + ...r, + rolls: JSON.parse(r.rolls as string), + advantage: r.advantage === 1, + disadvantage: r.disadvantage === 1, + nat20: r.nat20 === 1, + })); + res.json(parsed); +}); + +export default router; +``` + +- [ ] **Step 5: Restart server and verify these routes work** + +```bash +# In one terminal, ensure server is running (npm run dev in server/) +curl http://localhost:3000/api/campaigns +# Expected: [] (empty array — no data seeded yet) + +curl http://localhost:3000/api/game-items +# Expected: [] (seed hasn't run yet — seed-dev-data rewrite comes in Task 8) +``` + +- [ ] **Step 6: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/routes/campaigns.ts server/src/routes/game-items.ts \ + server/src/routes/game-talents.ts server/src/routes/rolls.ts +git commit -m "feat: convert campaigns, game-items, game-talents, rolls routes to async mysql2" +``` + +--- + +## Task 6: Server — convert characters.ts to async mysql2 + +**Files:** +- Modify: `server/src/routes/characters.ts` + +This is the largest route file. All synchronous `db.prepare(...).run/get/all` calls become async mysql2. + +- [ ] **Step 1: Rewrite server/src/routes/characters.ts** + +```typescript +import { Router } from "express"; +import type { ParamsDictionary } from "express-serve-static-core"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import type { Server } from "socket.io"; +import db from "../db.js"; +import { broadcastToCampaign } from "../socket.js"; + +type CampaignParams = ParamsDictionary & { campaignId: string }; + +const router = Router({ mergeParams: true }); + +const DEFAULT_STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"]; + +function generateCharacterColor(): string { + const hue = Math.floor(Math.random() * 360); + return `hsl(${hue}, 60%, 65%)`; +} + +function parseJson(val: unknown): Record { + if (typeof val === "string") { + try { return JSON.parse(val); } catch { return {}; } + } + return (val as Record) ?? {}; +} + +function parseGear(rows: RowDataPacket[]) { + return rows.map((r) => ({ + ...r, + properties: parseJson(r.properties), + effects: parseJson(r.effects), + })); +} + +function parseTalents(rows: RowDataPacket[]) { + return rows.map((r) => ({ ...r, effect: parseJson(r.effect) })); +} + +async function enrichCharacters(characters: RowDataPacket[]) { + return Promise.all( + characters.map(async (char) => { + const [stats] = await db.execute( + "SELECT stat_name, value FROM character_stats WHERE character_id = ?", + [char.id] + ); + const [gear] = await db.execute( + "SELECT * FROM character_gear WHERE character_id = ?", + [char.id] + ); + const [talents] = await db.execute( + "SELECT * FROM character_talents WHERE character_id = ?", + [char.id] + ); + return { + ...char, + overrides: parseJson(char.overrides), + stats, + gear: parseGear(gear), + talents: parseTalents(talents), + }; + }) + ); +} + +// GET /api/campaigns/:campaignId/characters +router.get("/", async (req, res) => { + const { campaignId } = req.params; + const [characters] = await db.execute( + "SELECT * FROM characters WHERE campaign_id = ? ORDER BY name", + [campaignId] + ); + const enriched = await enrichCharacters(characters); + res.json(enriched); +}); + +// POST /api/campaigns/:campaignId/characters +router.post("/", async (req, res) => { + const { campaignId } = req.params; + const { name, class: charClass, ancestry, hp_max } = req.body; + + if (!name?.trim()) { + res.status(400).json({ error: "Character name is required" }); + return; + } + + const [result] = await db.execute( + `INSERT INTO characters + (campaign_id, name, class, ancestry, hp_current, hp_max, color) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + campaignId, + name.trim(), + charClass ?? "Fighter", + ancestry ?? "Human", + hp_max ?? 0, + hp_max ?? 0, + generateCharacterColor(), + ] + ); + const characterId = result.insertId; + + await Promise.all( + DEFAULT_STATS.map((stat) => + db.execute( + "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)", + [characterId, stat] + ) + ) + ); + + const [charRows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [characterId] + ); + const enriched = { + ...charRows[0], + overrides: {}, + stats: DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 })), + gear: [], + talents: [], + }; + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(campaignId), "character:created", enriched); + res.status(201).json(enriched); +}); + +// PATCH /api/characters/:id +router.patch("/:id", async (req, res) => { + const { id } = req.params; + const allowedFields = [ + "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", + "ac", "alignment", "title", "notes", "background", "deity", "languages", + "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token", + "torch_lit_at", + ]; + + const updates: string[] = []; + const values: unknown[] = []; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates.push(`${field} = ?`); + const val = req.body[field]; + values.push(typeof val === "object" && val !== null ? JSON.stringify(val) : val); + } + } + + if (updates.length === 0) { + res.status(400).json({ error: "No valid fields to update" }); + return; + } + + values.push(id); + await db.execute(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`, values); + + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", { + id: Number(id), + ...req.body, + }); + res.json(rows[0]); +}); + +// DELETE /api/characters/:id +router.delete("/:id", async (req, res) => { + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [req.params.id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]); + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:deleted", { + id: Number(req.params.id), + }); + res.status(204).end(); +}); + +// PATCH /api/characters/:id/stats/:statName +router.patch("/:id/stats/:statName", async (req, res) => { + const { id, statName } = req.params; + const { value } = req.body; + const upper = statName.toUpperCase(); + + if (!DEFAULT_STATS.includes(upper)) { + res.status(400).json({ error: "Invalid stat name" }); + return; + } + + await db.execute( + "UPDATE character_stats SET value = ? WHERE character_id = ? AND stat_name = ?", + [value, id, upper] + ); + + const [rows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "stat:updated", { + characterId: Number(id), + statName: upper, + value, + }); + res.json({ characterId: Number(id), statName: upper, value }); +}); + +// POST /api/characters/:id/gear +router.post("/:id/gear", async (req, res) => { + const { id } = req.params; + const { name, type, slot_count, properties, effects, game_item_id } = req.body; + + if (!name?.trim()) { + res.status(400).json({ error: "Gear name is required" }); + return; + } + + const [result] = await db.execute( + `INSERT INTO character_gear + (character_id, name, type, slot_count, properties, effects, game_item_id) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + [ + id, name.trim(), type ?? "gear", slot_count ?? 1, + JSON.stringify(properties ?? {}), + JSON.stringify(effects ?? {}), + game_item_id ?? null, + ] + ); + + const [gearRows] = await db.execute( + "SELECT * FROM character_gear WHERE id = ?", + [result.insertId] + ); + const gear = { + ...gearRows[0], + properties: parseJson(gearRows[0].properties), + effects: parseJson(gearRows[0].effects), + }; + + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:added", { + characterId: Number(id), + gear, + }); + res.status(201).json(gear); +}); + +// DELETE /api/characters/:id/gear/:gearId +router.delete("/:id/gear/:gearId", async (req, res) => { + const { id, gearId } = req.params; + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (charRows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const [result] = await db.execute( + "DELETE FROM character_gear WHERE id = ? AND character_id = ?", + [gearId, id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Gear not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "gear:removed", { + characterId: Number(id), + gearId: Number(gearId), + }); + res.status(204).end(); +}); + +// POST /api/characters/:id/talents +router.post("/:id/talents", async (req, res) => { + const { id } = req.params; + const { name, description, effect, game_talent_id } = req.body; + + if (!name?.trim()) { + res.status(400).json({ error: "Talent name is required" }); + return; + } + + const [result] = await db.execute( + `INSERT INTO character_talents + (character_id, name, description, effect, game_talent_id) + VALUES (?, ?, ?, ?, ?)`, + [id, name.trim(), description ?? "", JSON.stringify(effect ?? {}), game_talent_id ?? null] + ); + + const [talentRows] = await db.execute( + "SELECT * FROM character_talents WHERE id = ?", + [result.insertId] + ); + const talent = { ...talentRows[0], effect: parseJson(talentRows[0].effect) }; + + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:added", { + characterId: Number(id), + talent, + }); + res.status(201).json(talent); +}); + +// DELETE /api/characters/:id/talents/:talentId +router.delete("/:id/talents/:talentId", async (req, res) => { + const { id, talentId } = req.params; + const [charRows] = await db.execute( + "SELECT campaign_id FROM characters WHERE id = ?", + [id] + ); + if (charRows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const [result] = await db.execute( + "DELETE FROM character_talents WHERE id = ? AND character_id = ?", + [talentId, id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Talent not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(charRows[0].campaign_id), "talent:removed", { + characterId: Number(id), + talentId: Number(talentId), + }); + res.status(204).end(); +}); + +export default router; +``` + +- [ ] **Step 2: Verify server still starts** + +```bash +# server should start without errors in the tsx watch terminal +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/routes/characters.ts +git commit -m "feat: convert characters routes to async mysql2" +``` + +--- + +## Task 7: Server — rewrite seed-dev-data.ts for MariaDB with test users + +**Files:** +- Modify: `server/src/seed-dev-data.ts` + +- [ ] **Step 1: Rewrite server/src/seed-dev-data.ts** + +```typescript +import bcrypt from "bcrypt"; +import type { ResultSetHeader, RowDataPacket } from "mysql2"; +import db from "./db.js"; +import { SEED_ITEMS } from "./seed-items.js"; +import { SEED_TALENTS } from "./seed-talents.js"; + +export async function seedDevData(): Promise { + const [userRows] = await db.execute( + "SELECT COUNT(*) as c FROM users" + ); + if ((userRows[0] as { c: number }).c > 0) return; + + // Seed game_items + const [itemCount] = await db.execute( + "SELECT COUNT(*) as c FROM game_items" + ); + if ((itemCount[0] as { c: number }).c === 0) { + for (const item of SEED_ITEMS) { + await db.execute( + "INSERT INTO game_items (name, type, slot_count, effects, properties) VALUES (?, ?, ?, ?, ?)", + [item.name, item.type, item.slot_count, JSON.stringify(item.effects), JSON.stringify(item.properties)] + ); + } + } + + // Seed game_talents + const [talentCount] = await db.execute( + "SELECT COUNT(*) as c FROM game_talents" + ); + if ((talentCount[0] as { c: number }).c === 0) { + for (const t of SEED_TALENTS) { + await db.execute( + "INSERT INTO game_talents (name, source, description, effect) VALUES (?, ?, ?, ?)", + [t.name, t.source, t.description, JSON.stringify(t.effect)] + ); + } + } + + const passwordHash = await bcrypt.hash("password", 12); + + // Create DM user + const [dmResult] = await db.execute( + "INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)", + ["dm@darkwatch.test", "DungeonMaster", passwordHash] + ); + const dmId = dmResult.insertId; + + // Create Player user + const [playerResult] = await db.execute( + "INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)", + ["player@darkwatch.test", "Adventurer", passwordHash] + ); + const playerId = playerResult.insertId; + + // Create campaign + const [campaignResult] = await db.execute( + "INSERT INTO campaigns (name) VALUES (?)", + ["Tomb of the Serpent King"] + ); + const campaignId = campaignResult.insertId; + + // Add DM as dm, player as player + await db.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'dm')", + [campaignId, dmId] + ); + await db.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'player')", + [campaignId, playerId] + ); + + // Create invite token for testing + await db.execute( + "INSERT INTO campaign_invites (campaign_id, token, created_by) VALUES (?, ?, ?)", + [campaignId, "dev-invite-token-abc123", dmId] + ); + + async function createCharacter( + campaignId: number, + userId: number, + data: { + name: string; class: string; ancestry: string; level: number; xp: number; + hp_current: number; hp_max: number; ac: number; alignment: string; + title: string; background: string; deity: string; languages: string; + gp: number; sp: number; cp: number; color: string; + } + ): Promise { + const [r] = await db.execute( + `INSERT INTO characters + (campaign_id, user_id, name, class, ancestry, level, xp, hp_current, hp_max, + ac, alignment, title, background, deity, languages, gp, sp, cp, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [campaignId, userId, data.name, data.class, data.ancestry, data.level, data.xp, + data.hp_current, data.hp_max, data.ac, data.alignment, data.title, + data.background, data.deity, data.languages, data.gp, data.sp, data.cp, data.color] + ); + return r.insertId; + } + + async function addStats(charId: number, stats: [string, number][]) { + for (const [name, value] of stats) { + await db.execute( + "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, ?)", + [charId, name, value] + ); + } + } + + async function addGear(charId: number, items: { name: string; type: string; slot_count: number; properties: string; effects: string }[]) { + for (const item of items) { + await db.execute( + "INSERT INTO character_gear (character_id, name, type, slot_count, properties, effects) VALUES (?, ?, ?, ?, ?, ?)", + [charId, item.name, item.type, item.slot_count, item.properties, item.effects] + ); + } + } + + async function addTalents(charId: number, talents: { name: string; description: string; effect: string }[]) { + for (const t of talents) { + await db.execute( + "INSERT INTO character_talents (character_id, name, description, effect) VALUES (?, ?, ?, ?)", + [charId, t.name, t.description, t.effect] + ); + } + } + + // --- Limpie (player's character 1) --- + const limpieId = await createCharacter(campaignId, playerId, { + name: "Limpie", class: "Thief", ancestry: "Goblin", level: 1, xp: 6, + hp_current: 1, hp_max: 1, ac: 10, alignment: "Lawful", title: "Footpad", + background: "Chirurgeo", deity: "Madeera the Covenant", + languages: "Common, Goblin", gp: 8, sp: 0, cp: 0, color: "hsl(45, 60%, 65%)", + }); + await addStats(limpieId, [["STR",11],["DEX",11],["CON",8],["INT",10],["WIS",14],["CHA",10]]); + await addGear(limpieId, [ + { name: "Shortsword", type: "weapon", slot_count: 1, properties: "{}", effects: '{"damage":"1d6","melee":true,"stat":"STR"}' }, + { name: "Shortbow", type: "weapon", slot_count: 1, properties: "{}", effects: '{"damage":"1d4","ranged":true,"stat":"DEX","two_handed":true}' }, + { name: "Leather Armor", type: "armor", slot_count: 1, properties: "{}", effects: '{"ac_base":11,"ac_dex":true}' }, + { name: "Shield", type: "armor", slot_count: 1, properties: "{}", effects: '{"ac_bonus":2}' }, + { name: "Arrows (20)", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + { name: "Torch", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + { name: "Rations", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + { name: "Rope (60ft)", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + { name: "Thieves' Tools", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + ]); + await addTalents(limpieId, [ + { name: "Backstab", description: "Extra 1 + half level (round down) weapon dice of damage with surprise attacks", effect: '{"damage_bonus_surprise":true}' }, + { name: "Thievery", description: "Trained in climbing, sneaking, hiding, disguise, finding & disabling traps, delicate tasks", effect: "{}" }, + { name: "Keen Senses", description: "Can't be surprised", effect: '{"immune_surprise":true}' }, + ]); + + // --- Brynn (player's character 2) --- + const brynnId = await createCharacter(campaignId, playerId, { + name: "Brynn", class: "Fighter", ancestry: "Human", level: 2, xp: 15, + hp_current: 8, hp_max: 10, ac: 10, alignment: "Neutral", title: "the Bold", + background: "Soldier", deity: "Gede", languages: "Common", + gp: 25, sp: 5, cp: 10, color: "hsl(200, 60%, 65%)", + }); + await addStats(brynnId, [["STR",16],["DEX",12],["CON",14],["INT",8],["WIS",10],["CHA",11]]); + await addGear(brynnId, [ + { name: "Longsword", type: "weapon", slot_count: 1, properties: "{}", effects: '{"damage":"1d8","melee":true,"stat":"STR"}' }, + { name: "Chainmail", type: "armor", slot_count: 1, properties: "{}", effects: '{"ac_base":13,"ac_dex":true}' }, + { name: "Shield", type: "armor", slot_count: 1, properties: "{}", effects: '{"ac_bonus":2}' }, + { name: "Javelin", type: "weapon", slot_count: 1, properties: "{}", effects: '{"damage":"1d4","melee":true,"stat":"STR","thrown":true,"range":"far"}' }, + { name: "Torch", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + { name: "Rations", type: "gear", slot_count: 1, properties: "{}", effects: "{}" }, + ]); + await addTalents(brynnId, [ + { name: "Weapon Mastery", description: "+1 to attack and damage with longswords", effect: '{"attack_bonus":1,"damage_bonus":1}' }, + { name: "Grit", description: "+2 HP and +1 HP each level", effect: '{"hp_bonus":2,"hp_per_level":1}' }, + ]); + + console.log("Dev data seeded: 2 users, 1 campaign, 2 characters (Limpie & Brynn)"); +} +``` + +- [ ] **Step 2: Restart server and verify seed runs** + +Stop and restart the server (`Ctrl+C` then `npm run dev`). Look for: +``` +Dev data seeded: 2 users, 1 campaign, 2 characters (Limpie & Brynn) +``` + +- [ ] **Step 3: Verify data in DB** + +```bash +docker exec darkwatch-maria mariadb -udarkwatch -pdarkwatch_dev darkwatch \ + -e "SELECT id, email, username FROM users; SELECT id, name FROM campaigns; SELECT id, name FROM characters;" +``` + +Expected: 2 users, 1 campaign, 2 characters. + +- [ ] **Step 4: Verify API returns characters** + +```bash +curl http://localhost:3000/api/campaigns/1/characters | head -c 200 +``` + +Expected: JSON array with Limpie and Brynn. + +- [ ] **Step 5: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/seed-dev-data.ts +git commit -m "feat: rewrite seed-dev-data for MariaDB with test users and campaign members" +``` + +--- + +## Task 8: Server — JWT utility and auth middleware + +**Files:** +- Create: `server/src/auth/jwt.ts` +- Create: `server/src/auth/middleware.ts` + +- [ ] **Step 1: Create server/src/auth/ directory and write jwt.ts** + +```typescript +// server/src/auth/jwt.ts +import jwt from "jsonwebtoken"; + +const SECRET = process.env.JWT_SECRET ?? "dev_jwt_secret_change_me"; +export const COOKIE_NAME = "darkwatch_token"; +const EXPIRY = "7d"; + +export interface JWTPayload { + userId: number; + email: string; + username: string; +} + +export function signToken(payload: JWTPayload): string { + return jwt.sign(payload, SECRET, { expiresIn: EXPIRY }); +} + +export function verifyToken(token: string): JWTPayload { + return jwt.verify(token, SECRET) as JWTPayload; +} +``` + +- [ ] **Step 2: Write server/src/auth/middleware.ts** + +```typescript +// server/src/auth/middleware.ts +import type { Request, Response, NextFunction } from "express"; +import type { RowDataPacket } from "mysql2"; +import db from "../db.js"; +import { verifyToken, COOKIE_NAME } from "./jwt.js"; +import type { JWTPayload } from "./jwt.js"; + +declare global { + namespace Express { + interface Request { + user?: JWTPayload; + } + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + const token = req.cookies?.[COOKIE_NAME]; + if (!token) { + res.status(401).json({ error: "Unauthorized" }); + return; + } + try { + req.user = verifyToken(token); + next(); + } catch { + res.status(401).json({ error: "Unauthorized" }); + } +} + +export function requireCampaignRole(role: "dm" | "player") { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const campaignId = req.params.campaignId ?? req.params.id; + const userId = req.user!.userId; + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [campaignId, userId] + ); + if (rows.length === 0) { + res.status(403).json({ error: "Not a campaign member" }); + return; + } + if (role === "dm" && rows[0].role !== "dm") { + res.status(403).json({ error: "DM access required" }); + return; + } + next(); + }; +} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/auth/ +git commit -m "feat: add JWT utility and requireAuth/requireCampaignRole middleware" +``` + +--- + +## Task 9: Server — auth routes (register, login, logout, me) + +**Files:** +- Modify: `server/src/routes/auth.ts` (replace placeholder) + +- [ ] **Step 1: Write server/src/routes/auth.ts** + +```typescript +import { Router } from "express"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import bcrypt from "bcrypt"; +import db from "../db.js"; +import { signToken, COOKIE_NAME } from "../auth/jwt.js"; +import { requireAuth } from "../auth/middleware.js"; + +const router = Router(); +const BCRYPT_ROUNDS = 12; +const COOKIE_OPTS = { + httpOnly: true, + sameSite: "lax" as const, + secure: process.env.NODE_ENV === "production", + maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days in ms +}; + +// POST /api/auth/register +router.post("/register", async (req, res) => { + const { email, username, password } = req.body; + + if (!email?.trim() || !username?.trim() || !password) { + res.status(400).json({ error: "email, username, and password are required" }); + return; + } + if (password.length < 8) { + res.status(400).json({ error: "Password must be at least 8 characters" }); + return; + } + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + res.status(400).json({ error: "Invalid email format" }); + return; + } + + const [existing] = await db.execute( + "SELECT id FROM users WHERE email = ?", + [email.trim().toLowerCase()] + ); + if (existing.length > 0) { + res.status(409).json({ error: "Email already registered" }); + return; + } + + const passwordHash = await bcrypt.hash(password, BCRYPT_ROUNDS); + const [result] = await db.execute( + "INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)", + [email.trim().toLowerCase(), username.trim(), passwordHash] + ); + + const token = signToken({ userId: result.insertId, email: email.trim().toLowerCase(), username: username.trim() }); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.status(201).json({ userId: result.insertId, email: email.trim().toLowerCase(), username: username.trim() }); +}); + +// POST /api/auth/login +router.post("/login", async (req, res) => { + const { email, password } = req.body; + + if (!email?.trim() || !password) { + res.status(400).json({ error: "email and password are required" }); + return; + } + + const [rows] = await db.execute( + "SELECT id, email, username, password_hash FROM users WHERE email = ?", + [email.trim().toLowerCase()] + ); + if (rows.length === 0) { + res.status(401).json({ error: "Invalid email or password" }); + return; + } + + const user = rows[0]; + const valid = await bcrypt.compare(password, user.password_hash as string); + if (!valid) { + res.status(401).json({ error: "Invalid email or password" }); + return; + } + + const token = signToken({ userId: user.id as number, email: user.email as string, username: user.username as string }); + res.cookie(COOKIE_NAME, token, COOKIE_OPTS); + res.json({ userId: user.id, email: user.email, username: user.username }); +}); + +// POST /api/auth/logout +router.post("/logout", (_req, res) => { + res.clearCookie(COOKIE_NAME); + res.json({ ok: true }); +}); + +// GET /api/auth/me +router.get("/me", requireAuth, (req, res) => { + res.json(req.user); +}); + +export default router; +``` + +- [ ] **Step 2: Test register and login** + +```bash +# Register +curl -s -c /tmp/darkwatch-cookies.txt -X POST http://localhost:3000/api/auth/register \ + -H "Content-Type: application/json" \ + -d '{"email":"test@example.com","username":"TestUser","password":"password123"}' | cat + +# Expected: {"userId":3,"email":"test@example.com","username":"TestUser"} + +# Me (using saved cookie) +curl -s -b /tmp/darkwatch-cookies.txt http://localhost:3000/api/auth/me | cat +# Expected: {"userId":3,"email":"test@example.com","username":"TestUser","iat":...,"exp":...} + +# Login with dev account +curl -s -c /tmp/darkwatch-dm.txt -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"dm@darkwatch.test","password":"password"}' | cat +# Expected: {"userId":1,"email":"dm@darkwatch.test","username":"DungeonMaster"} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/routes/auth.ts +git commit -m "feat: add register, login, logout, and me auth endpoints" +``` + +--- + +## Task 10: Server — campaign membership routes + +**Files:** +- Modify: `server/src/routes/campaigns.ts` + +Add: membership-aware GET, campaign creation adds creator as DM, invite generation, join via token, my-role endpoint. All new routes require auth. + +- [ ] **Step 1: Rewrite server/src/routes/campaigns.ts with membership** + +```typescript +import { Router } from "express"; +import type { RowDataPacket, ResultSetHeader } from "mysql2"; +import crypto from "crypto"; +import db from "../db.js"; +import { requireAuth, requireCampaignRole } from "../auth/middleware.js"; + +const router = Router(); + +// GET /api/campaigns — only campaigns current user is a member of +router.get("/", requireAuth, async (req, res) => { + const userId = req.user!.userId; + const [rows] = await db.execute( + `SELECT c.*, cm.role + FROM campaigns c + JOIN campaign_members cm ON cm.campaign_id = c.id + WHERE cm.user_id = ? + ORDER BY c.created_at DESC`, + [userId] + ); + res.json(rows); +}); + +// POST /api/campaigns — create campaign, creator becomes DM +router.post("/", requireAuth, async (req, res) => { + const { name } = req.body; + if (!name?.trim()) { + res.status(400).json({ error: "Campaign name is required" }); + return; + } + const conn = await db.getConnection(); + try { + await conn.beginTransaction(); + const [result] = await conn.execute( + "INSERT INTO campaigns (name) VALUES (?)", + [name.trim()] + ); + const campaignId = result.insertId; + await conn.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'dm')", + [campaignId, req.user!.userId] + ); + await conn.commit(); + const [rows] = await db.execute( + "SELECT c.*, 'dm' as role FROM campaigns c WHERE c.id = ?", + [campaignId] + ); + res.status(201).json(rows[0]); + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } +}); + +// GET /api/campaigns/:id +router.get("/:id", requireAuth, async (req, res) => { + const [rows] = await db.execute( + "SELECT * FROM campaigns WHERE id = ?", + [req.params.id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Campaign not found" }); + return; + } + res.json(rows[0]); +}); + +// DELETE /api/campaigns/:id — DM only +router.delete("/:id", requireAuth, requireCampaignRole("dm"), async (req, res) => { + const [result] = await db.execute( + "DELETE FROM campaigns WHERE id = ?", + [req.params.id] + ); + if (result.affectedRows === 0) { + res.status(404).json({ error: "Campaign not found" }); + return; + } + res.status(204).end(); +}); + +// GET /api/campaigns/:id/my-role — returns current user's role in this campaign +router.get("/:id/my-role", requireAuth, async (req, res) => { + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [req.params.id, req.user!.userId] + ); + if (rows.length === 0) { + res.status(403).json({ error: "Not a campaign member" }); + return; + } + res.json({ role: rows[0].role }); +}); + +// POST /api/campaigns/:id/invite — generate invite link (DM only) +router.post("/:id/invite", requireAuth, requireCampaignRole("dm"), async (req, res) => { + const token = crypto.randomBytes(32).toString("hex"); + await db.execute( + "INSERT INTO campaign_invites (campaign_id, token, created_by) VALUES (?, ?, ?)", + [req.params.id, token, req.user!.userId] + ); + const clientUrl = process.env.CLIENT_URL ?? "http://localhost:5173"; + res.json({ url: `${clientUrl}/join/${token}` }); +}); + +// POST /api/campaigns/join/:token — join campaign as player +router.post("/join/:token", requireAuth, async (req, res) => { + const [inviteRows] = await db.execute( + `SELECT * FROM campaign_invites + WHERE token = ? AND (expires_at IS NULL OR expires_at > NOW())`, + [req.params.token] + ); + if (inviteRows.length === 0) { + res.status(404).json({ error: "Invite not found or expired" }); + return; + } + + const campaignId = inviteRows[0].campaign_id as number; + const userId = req.user!.userId; + + // Already a member? Return 200 silently + const [existing] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [campaignId, userId] + ); + if (existing.length > 0) { + res.json({ campaignId, role: existing[0].role }); + return; + } + + await db.execute( + "INSERT INTO campaign_members (campaign_id, user_id, role) VALUES (?, ?, 'player')", + [campaignId, userId] + ); + res.json({ campaignId, role: "player" }); +}); + +export default router; +``` + +- [ ] **Step 2: Test campaign creation and invite** + +```bash +# Login as DM +curl -s -c /tmp/dm.txt -X POST http://localhost:3000/api/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"dm@darkwatch.test","password":"password"}' | cat + +# List campaigns (should see Tomb of the Serpent King) +curl -s -b /tmp/dm.txt http://localhost:3000/api/campaigns | cat + +# Get DM role +curl -s -b /tmp/dm.txt http://localhost:3000/api/campaigns/1/my-role | cat +# Expected: {"role":"dm"} + +# Generate invite +curl -s -b /tmp/dm.txt -X POST http://localhost:3000/api/campaigns/1/invite \ + -H "Content-Type: application/json" | cat +# Expected: {"url":"http://localhost:5173/join/"} +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/routes/campaigns.ts +git commit -m "feat: add campaign membership, invite generation, and join-by-token routes" +``` + +--- + +## Task 11: Server — character ownership enforcement + +**Files:** +- Modify: `server/src/routes/characters.ts` + +Add `user_id` when creating characters. Add ownership checks to PATCH and DELETE: players can only modify their own characters; DMs can modify any. + +- [ ] **Step 1: Add requireAuth import and user_id to character creation** + +At the top of `server/src/routes/characters.ts`, add the import: + +```typescript +import { requireAuth, requireCampaignRole } from "../auth/middleware.js"; +``` + +Replace the POST `/:campaignId/characters` handler — change the INSERT to include `user_id`: + +```typescript +// POST /api/campaigns/:campaignId/characters +router.post("/", requireAuth, async (req, res) => { + const { campaignId } = req.params; + const { name, class: charClass, ancestry, hp_max } = req.body; + + if (!name?.trim()) { + res.status(400).json({ error: "Character name is required" }); + return; + } + + const userId = req.user?.userId ?? null; + + const [result] = await db.execute( + `INSERT INTO characters + (campaign_id, user_id, name, class, ancestry, hp_current, hp_max, color) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + [ + campaignId, + userId, + name.trim(), + charClass ?? "Fighter", + ancestry ?? "Human", + hp_max ?? 0, + hp_max ?? 0, + generateCharacterColor(), + ] + ); + const characterId = result.insertId; + + await Promise.all( + DEFAULT_STATS.map((stat) => + db.execute( + "INSERT INTO character_stats (character_id, stat_name, value) VALUES (?, ?, 10)", + [characterId, stat] + ) + ) + ); + + const [charRows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [characterId] + ); + const enriched = { + ...charRows[0], + overrides: {}, + stats: DEFAULT_STATS.map((s) => ({ stat_name: s, value: 10 })), + gear: [], + talents: [], + }; + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(campaignId), "character:created", enriched); + res.status(201).json(enriched); +}); +``` + +- [ ] **Step 2: Add ownership helper function and protect PATCH and DELETE** + +Add this helper function after `parseTalents`: + +```typescript +async function canModifyCharacter(characterId: string, userId: number): Promise { + // Check if user is DM of the campaign this character belongs to + const [dmCheck] = await db.execute( + `SELECT cm.role FROM campaign_members cm + JOIN characters c ON c.campaign_id = cm.campaign_id + WHERE c.id = ? AND cm.user_id = ? AND cm.role = 'dm'`, + [characterId, userId] + ); + if (dmCheck.length > 0) return true; + + // Or if user owns the character + const [ownerCheck] = await db.execute( + "SELECT id FROM characters WHERE id = ? AND user_id = ?", + [characterId, userId] + ); + return ownerCheck.length > 0; +} +``` + +Update the PATCH `/:id` handler — replace the existing handler entirely: + +```typescript +router.patch("/:id", requireAuth, async (req, res) => { + const { id } = req.params; + + const allowed = await canModifyCharacter(id, req.user!.userId); + if (!allowed) { + res.status(403).json({ error: "Not authorized to modify this character" }); + return; + } + + const allowedFields = [ + "name", "class", "ancestry", "level", "xp", "hp_current", "hp_max", + "ac", "alignment", "title", "notes", "background", "deity", "languages", + "gp", "sp", "cp", "gear_slots_max", "overrides", "color", "luck_token", + "torch_lit_at", + ]; + + const updates: string[] = []; + const values: unknown[] = []; + + for (const field of allowedFields) { + if (req.body[field] !== undefined) { + updates.push(`${field} = ?`); + const val = req.body[field]; + values.push(typeof val === "object" && val !== null ? JSON.stringify(val) : val); + } + } + + if (updates.length === 0) { + res.status(400).json({ error: "No valid fields to update" }); + return; + } + + values.push(id); + await db.execute(`UPDATE characters SET ${updates.join(", ")} WHERE id = ?`, values); + + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:updated", { + id: Number(id), + ...req.body, + }); + res.json(rows[0]); +}); +``` + +Update the DELETE `/:id` handler — replace the existing handler entirely: + +```typescript +router.delete("/:id", requireAuth, async (req, res) => { + const [rows] = await db.execute( + "SELECT * FROM characters WHERE id = ?", + [req.params.id] + ); + if (rows.length === 0) { + res.status(404).json({ error: "Character not found" }); + return; + } + + const allowed = await canModifyCharacter(req.params.id, req.user!.userId); + if (!allowed) { + res.status(403).json({ error: "Not authorized to delete this character" }); + return; + } + + await db.execute("DELETE FROM characters WHERE id = ?", [req.params.id]); + + const io: Server = req.app.get("io"); + broadcastToCampaign(io, Number(rows[0].campaign_id), "character:deleted", { + id: Number(req.params.id), + }); + res.status(204).end(); +}); +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/routes/characters.ts +git commit -m "feat: enforce character ownership — players own their characters, DMs can modify any" +``` + +--- + +## Task 12: Server — Socket.io cookie-based JWT auth + +**Files:** +- Modify: `server/src/socket.ts` + +- [ ] **Step 1: Update server/src/socket.ts** + +Replace the entire file: + +```typescript +import { Server } from "socket.io"; +import { parse as parseCookie } from "cookie"; +import type { RowDataPacket } from "mysql2"; +import db from "./db.js"; +import { rollDice } from "./dice.js"; +import { verifyToken } from "./auth/jwt.js"; + +interface EffectState { + active: boolean; + intensity: number; +} + +interface AtmosphereUpdateData { + campaignId: number; + fog: EffectState; + fire: EffectState; + rain: EffectState; + embers: EffectState; +} + +export function setupSocket(io: Server) { + // Verify JWT from cookie on every connection + io.use((socket, next) => { + try { + const cookies = parseCookie(socket.handshake.headers.cookie ?? ""); + const token = cookies["darkwatch_token"]; + if (!token) throw new Error("No token"); + socket.data.user = verifyToken(token); + next(); + } catch { + next(new Error("Unauthorized")); + } + }); + + io.on("connection", (socket) => { + socket.on("join-campaign", (campaignId: string) => { + socket.join(`campaign:${campaignId}`); + }); + + socket.on("leave-campaign", (campaignId: string) => { + socket.leave(`campaign:${campaignId}`); + }); + + socket.on( + "roll:request", + async (data: { + campaignId: number; + characterId?: number; + characterName?: string; + characterColor?: string; + type: string; + dice: string; + label: string; + modifier?: number; + advantage?: boolean; + disadvantage?: boolean; + }) => { + // Verify user is a member of this campaign + const userId = socket.data.user?.userId; + const [memberRows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [data.campaignId, userId] + ); + if (memberRows.length === 0) { + socket.emit("roll:error", { error: "Not a campaign member" }); + return; + } + const isDM = memberRows[0].role === "dm"; + + // If rolling for a specific character, verify ownership (DMs can roll any) + if (data.characterId && !isDM) { + const [charRows] = await db.execute( + "SELECT user_id FROM characters WHERE id = ?", + [data.characterId] + ); + if (charRows.length === 0 || charRows[0].user_id !== userId) { + socket.emit("roll:error", { error: "Cannot roll for another player's character" }); + return; + } + } + + const result = rollDice(data.dice, { + advantage: data.advantage, + disadvantage: data.disadvantage, + }); + + if (result.error) { + socket.emit("roll:error", { error: result.error }); + return; + } + + const isD20Roll = data.dice.match(/d20/i); + let nat20 = false; + if (isD20Roll && result.rolls.length > 0) { + if (data.advantage) { + nat20 = Math.max(...result.rolls) === 20; + } else if (data.disadvantage) { + nat20 = Math.min(...result.rolls) === 20; + } else { + nat20 = result.rolls[0] === 20; + } + } + + const [insertResult] = await db.execute( + `INSERT INTO roll_log + (campaign_id, character_id, character_name, character_color, type, label, + dice_expression, rolls, modifier, total, advantage, disadvantage, nat20) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + data.campaignId, + data.characterId ?? null, + data.characterName ?? "Roll", + data.characterColor ?? "", + data.type ?? "custom", + data.label, + data.dice, + JSON.stringify(result.rolls), + result.modifier, + result.total, + data.advantage ? 1 : 0, + data.disadvantage ? 1 : 0, + nat20 ? 1 : 0, + ] + ); + + const [savedRows] = await db.execute( + "SELECT * FROM roll_log WHERE id = ?", + [insertResult.insertId] + ); + + const broadcast = { + ...savedRows[0], + rolls: result.rolls, + advantage: data.advantage ?? false, + disadvantage: data.disadvantage ?? false, + nat20, + }; + + io.to(`campaign:${data.campaignId}`).emit("roll:result", broadcast); + } + ); + + socket.on("atmosphere:update", async (data: AtmosphereUpdateData) => { + // Only DMs can broadcast atmosphere changes + const userId = socket.data.user?.userId; + const [rows] = await db.execute( + "SELECT role FROM campaign_members WHERE campaign_id = ? AND user_id = ?", + [data.campaignId, userId] + ); + if (rows.length === 0 || rows[0].role !== "dm") return; + + const { campaignId, ...atmosphere } = data; + io.to(`campaign:${campaignId}`).emit("atmosphere:update", atmosphere); + }); + + socket.on("disconnect", () => { + // Rooms are cleaned up automatically by Socket.IO + }); + }); +} + +export function broadcastToCampaign( + io: Server, + campaignId: number, + event: string, + data: unknown, +) { + io.to(`campaign:${campaignId}`).emit(event, data); +} +``` + +- [ ] **Step 2: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add server/src/socket.ts +git commit -m "feat: add JWT cookie auth to Socket.io connections and enforce DM-only atmosphere" +``` + +--- + +## Task 13: Client — add credentials and auth API calls + +**Files:** +- Modify: `client/src/api.ts` +- Modify: `client/src/socket.ts` + +- [ ] **Step 1: Update client/src/api.ts** + +Replace the entire file: + +```typescript +import type { + Campaign, + Character, + Gear, + Talent, + GameItem, + GameTalent, + RollResult, +} from "./types"; + +const BASE = "/api"; + +async function request(path: string, options?: RequestInit): Promise { + const res = await fetch(`${BASE}${path}`, { + headers: { "Content-Type": "application/json" }, + credentials: "include", + ...options, + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + if (res.status === 204) return undefined as T; + return res.json(); +} + +// Auth +export interface AuthUser { + userId: number; + email: string; + username: string; +} + +export const getMe = () => request("/auth/me"); +export const login = (email: string, password: string) => + request("/auth/login", { + method: "POST", + body: JSON.stringify({ email, password }), + }); +export const register = (email: string, username: string, password: string) => + request("/auth/register", { + method: "POST", + body: JSON.stringify({ email, username, password }), + }); +export const logout = () => + request<{ ok: boolean }>("/auth/logout", { method: "POST" }); + +// Campaigns +export const getCampaigns = () => request("/campaigns"); +export const createCampaign = (name: string) => + request("/campaigns", { + method: "POST", + body: JSON.stringify({ name }), + }); +export const deleteCampaign = (id: number) => + request(`/campaigns/${id}`, { method: "DELETE" }); +export const getMyCampaignRole = (campaignId: number) => + request<{ role: "dm" | "player" }>(`/campaigns/${campaignId}/my-role`); +export const generateInvite = (campaignId: number) => + request<{ url: string }>(`/campaigns/${campaignId}/invite`, { method: "POST" }); +export const joinCampaign = (token: string) => + request<{ campaignId: number; role: string }>(`/campaigns/join/${token}`, { + method: "POST", + }); + +// Characters +export const getCharacters = (campaignId: number) => + request(`/campaigns/${campaignId}/characters`); +export const createCharacter = ( + campaignId: number, + data: { name: string; class?: string; ancestry?: string; hp_max?: number }, +) => + request(`/campaigns/${campaignId}/characters`, { + method: "POST", + body: JSON.stringify(data), + }); +export const updateCharacter = (id: number, data: Partial) => + request(`/characters/${id}`, { + method: "PATCH", + body: JSON.stringify(data), + }); +export const deleteCharacter = (id: number) => + request(`/characters/${id}`, { method: "DELETE" }); + +// Stats +export const updateStat = (characterId: number, statName: string, value: number) => + request<{ characterId: number; statName: string; value: number }>( + `/characters/${characterId}/stats/${statName}`, + { method: "PATCH", body: JSON.stringify({ value }) }, + ); + +// Gear +export const addGear = ( + characterId: number, + data: { + name: string; + type?: string; + slot_count?: number; + properties?: Record; + effects?: Record; + game_item_id?: number | null; + }, +) => + request(`/characters/${characterId}/gear`, { + method: "POST", + body: JSON.stringify(data), + }); +export const removeGear = (characterId: number, gearId: number) => + request(`/characters/${characterId}/gear/${gearId}`, { method: "DELETE" }); + +// Talents +export const addTalent = ( + characterId: number, + data: { + name: string; + description?: string; + effect?: Record; + game_talent_id?: number | null; + }, +) => + request(`/characters/${characterId}/talents`, { + method: "POST", + body: JSON.stringify(data), + }); +export const removeTalent = (characterId: number, talentId: number) => + request(`/characters/${characterId}/talents/${talentId}`, { method: "DELETE" }); + +// Game Items +export const getGameItems = () => request("/game-items"); + +// Game Talents +export const getGameTalents = () => request("/game-talents"); + +// Rolls +export const getRolls = (campaignId: number) => + request(`/campaigns/${campaignId}/rolls`); +``` + +- [ ] **Step 2: Update client/src/socket.ts** + +```typescript +import { io } from "socket.io-client"; + +const socket = io("/", { + autoConnect: true, + reconnection: true, + withCredentials: true, +}); + +export default socket; +``` + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/src/api.ts client/src/socket.ts +git commit -m "feat: add credentials:include to all API calls and withCredentials to socket" +``` + +--- + +## Task 14: Client — auth context, RequireAuth, and updated App routing + +**Files:** +- Create: `client/src/context/AuthContext.tsx` +- Create: `client/src/components/RequireAuth.tsx` +- Modify: `client/src/App.tsx` + +- [ ] **Step 1: Create client/src/context/AuthContext.tsx** + +```typescript +import { createContext, useContext, useEffect, useState } from "react"; +import type { ReactNode } from "react"; +import { getMe, logout as apiLogout } from "../api"; +import type { AuthUser } from "../api"; + +interface AuthContextValue { + user: AuthUser | null; + loading: boolean; + setUser: (user: AuthUser | null) => void; + logout: () => Promise; +} + +const AuthContext = createContext({ + user: null, + loading: true, + setUser: () => {}, + logout: async () => {}, +}); + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + getMe() + .then(setUser) + .catch(() => setUser(null)) + .finally(() => setLoading(false)); + }, []); + + async function logout() { + await apiLogout().catch(() => {}); + setUser(null); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + return useContext(AuthContext); +} +``` + +- [ ] **Step 2: Create client/src/components/RequireAuth.tsx** + +```typescript +import { Navigate } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useAuth } from "../context/AuthContext"; + +export default function RequireAuth({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + if (loading) return null; + if (!user) return ; + return <>{children}; +} +``` + +- [ ] **Step 3: Update client/src/App.tsx** + +```typescript +import { BrowserRouter, Routes, Route } from "react-router-dom"; +import { AuthProvider } from "./context/AuthContext"; +import RequireAuth from "./components/RequireAuth"; +import CampaignList from "./pages/CampaignList"; +import CampaignView from "./pages/CampaignView"; +import LoginPage from "./pages/LoginPage"; +import RegisterPage from "./pages/RegisterPage"; +import JoinPage from "./pages/JoinPage"; +import ThemeToggle from "./components/ThemeToggle"; +import styles from "./App.module.css"; + +export default function App() { + return ( + + +
+
+

Darkwatch

+ +
+ + } /> + } /> + + + + } + /> + + + + } + /> + + + + } + /> + +
+
+
+ ); +} +``` + +- [ ] **Step 4: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/src/context/AuthContext.tsx client/src/components/RequireAuth.tsx client/src/App.tsx +git commit -m "feat: add AuthContext, RequireAuth guard, and Darkwatch app routing" +``` + +--- + +## Task 15: Client — Login and Register pages + +**Files:** +- Create: `client/src/pages/LoginPage.tsx` +- Create: `client/src/pages/LoginPage.module.css` +- Create: `client/src/pages/RegisterPage.tsx` +- Create: `client/src/pages/RegisterPage.module.css` + +- [ ] **Step 1: Create client/src/pages/LoginPage.module.css** + +```css +.page { + display: flex; + justify-content: center; + align-items: center; + min-height: 60vh; +} + +.card { + background: var(--card-bg); + border: 1px solid var(--border); + border-radius: 8px; + padding: 2rem; + width: 100%; + max-width: 380px; +} + +.title { + font-size: 1.4rem; + font-weight: 700; + margin: 0 0 1.5rem; + color: var(--accent); +} + +.field { + display: flex; + flex-direction: column; + gap: 0.4rem; + margin-bottom: 1rem; +} + +.label { + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.input { + background: var(--input-bg, #1a1a1a); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 0.95rem; + padding: 0.5rem 0.75rem; +} + +.input:focus { + outline: none; + border-color: var(--accent); +} + +.error { + color: #e55; + font-size: 0.85rem; + margin-bottom: 0.75rem; +} + +.btn { + width: 100%; + padding: 0.6rem; + background: var(--accent); + color: #fff; + border: none; + border-radius: 4px; + font-size: 1rem; + font-weight: 600; + cursor: pointer; + margin-top: 0.5rem; +} + +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.link { + display: block; + text-align: center; + margin-top: 1rem; + font-size: 0.85rem; + color: var(--text-muted, #aaa); +} + +.link a { + color: var(--accent); +} +``` + +- [ ] **Step 2: Create client/src/pages/LoginPage.tsx** + +```typescript +import { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { login } from "../api"; +import { useAuth } from "../context/AuthContext"; +import styles from "./LoginPage.module.css"; + +export default function LoginPage() { + const { setUser } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + setLoading(true); + try { + const user = await login(email, password); + setUser(user); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Login failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
Sign In
+
+
+ + setEmail(e.target.value)} + autoFocus + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+ No account? Create one +
+
+
+ ); +} +``` + +- [ ] **Step 3: Create client/src/pages/RegisterPage.module.css** + +Copy `LoginPage.module.css` — identical styles, just rename the import. + +Create `client/src/pages/RegisterPage.module.css` with the same content as `LoginPage.module.css`. + +- [ ] **Step 4: Create client/src/pages/RegisterPage.tsx** + +```typescript +import { useState } from "react"; +import { useNavigate, Link } from "react-router-dom"; +import { register } from "../api"; +import { useAuth } from "../context/AuthContext"; +import styles from "./RegisterPage.module.css"; + +export default function RegisterPage() { + const { setUser } = useAuth(); + const navigate = useNavigate(); + const [email, setEmail] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(""); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(""); + if (password.length < 8) { + setError("Password must be at least 8 characters"); + return; + } + setLoading(true); + try { + const user = await register(email, username, password); + setUser(user); + navigate("/"); + } catch (err) { + setError(err instanceof Error ? err.message : "Registration failed"); + } finally { + setLoading(false); + } + } + + return ( +
+
+
Create Account
+
+
+ + setEmail(e.target.value)} + autoFocus + required + /> +
+
+ + setUsername(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ {error &&
{error}
} + +
+
+ Already have an account? Sign in +
+
+
+ ); +} +``` + +- [ ] **Step 5: Test in browser** + +Navigate to `http://localhost:5173`. You should be redirected to `/login`. Sign in with `dm@darkwatch.test` / `password`. You should land on the campaign list and see "Tomb of the Serpent King". + +- [ ] **Step 6: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/src/pages/LoginPage.tsx client/src/pages/LoginPage.module.css \ + client/src/pages/RegisterPage.tsx client/src/pages/RegisterPage.module.css +git commit -m "feat: add Login and Register pages" +``` + +--- + +## Task 16: Client — Join campaign page + +**Files:** +- Create: `client/src/pages/JoinPage.tsx` +- Create: `client/src/pages/JoinPage.module.css` + +- [ ] **Step 1: Create client/src/pages/JoinPage.module.css** + +Same styles as LoginPage.module.css — create with identical content. + +- [ ] **Step 2: Create client/src/pages/JoinPage.tsx** + +```typescript +import { useEffect, useState } from "react"; +import { useParams, useNavigate } from "react-router-dom"; +import { joinCampaign } from "../api"; +import styles from "./JoinPage.module.css"; + +export default function JoinPage() { + const { token } = useParams<{ token: string }>(); + const navigate = useNavigate(); + const [status, setStatus] = useState<"joining" | "error">("joining"); + const [error, setError] = useState(""); + + useEffect(() => { + if (!token) return; + joinCampaign(token) + .then(({ campaignId }) => { + navigate(`/campaign/${campaignId}`); + }) + .catch((err) => { + setStatus("error"); + setError(err instanceof Error ? err.message : "Invalid invite link"); + }); + }, [token, navigate]); + + return ( +
+
+ {status === "joining" &&
Joining campaign…
} + {status === "error" && ( + <> +
Invalid Invite
+
{error}
+ + )} +
+
+ ); +} +``` + +- [ ] **Step 3: Test the invite flow** + +Generate an invite from the browser (after DM/player UI is added in the next task), or use curl: + +```bash +curl -s -b /tmp/dm.txt -X POST http://localhost:3000/api/campaigns/1/invite \ + -H "Content-Type: application/json" | cat +``` + +Visit the returned URL while logged in as a different user. You should be redirected to the campaign view. + +- [ ] **Step 4: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/src/pages/JoinPage.tsx client/src/pages/JoinPage.module.css +git commit -m "feat: add Join campaign page for invite link redemption" +``` + +--- + +## Task 17: Client — DM/player role separation in CampaignView + +**Files:** +- Modify: `client/src/pages/CampaignView.tsx` + +Add: fetch current user role on mount, pass role to relevant components, hide atmosphere controls and invite button for players, hide edit controls on characters the current user doesn't own. + +- [ ] **Step 1: Add role fetching and user context to CampaignView.tsx** + +At the top of the file, add imports: + +```typescript +import { useAuth } from "../context/AuthContext"; +import { getMyCampaignRole, generateInvite } from "../api"; +``` + +Inside the `CampaignView` component function, add new state after existing state declarations: + +```typescript +const { user } = useAuth(); +const [role, setRole] = useState<"dm" | "player" | null>(null); +``` + +In the existing `useEffect` that fetches characters (the one with `[campaignId]` dependency), add the role fetch: + +```typescript +getMyCampaignRole(campaignId).then((r) => setRole(r.role)).catch(() => {}); +``` + +- [ ] **Step 2: Add invite handler** + +Add this function inside `CampaignView`, near the other handlers: + +```typescript +async function handleInvite() { + try { + const { url } = await generateInvite(campaignId); + await navigator.clipboard.writeText(url); + alert("Invite link copied to clipboard!"); + } catch { + alert("Failed to generate invite link"); + } +} +``` + +- [ ] **Step 3: Update the JSX — header buttons** + +Find the `
` section. Update it so atmosphere panel and invite button are DM-only: + +```tsx +
+ {role === "dm" && ( + + )} + {role === "dm" && ( + + )} + +
+``` + +- [ ] **Step 4: Update CharacterCard to pass ownership info** + +Find the `characters.map` section. Pass ownership flag: + +```tsx +{characters.map((char) => ( + +))} +``` + +- [ ] **Step 5: Update CharacterCard to accept and use canEdit prop** + +Open `client/src/components/CharacterCard.tsx`. Read it first to understand its current props and structure, then add the `canEdit` prop and conditionally render edit controls. + +Find the component's Props interface and add: +```typescript +canEdit?: boolean; +``` + +Use `canEdit` to conditionally show any edit buttons or click-to-edit functionality within the card. If the card has no edit controls directly (editing is only in CharacterDetail), this prop can be passed through for now and used in a future enhancement. + +- [ ] **Step 6: Update CharacterDetail to pass ownership info** + +Find where `CharacterDetail` is rendered in `CampaignView`. Pass canEdit: + +```tsx +{selectedCharacter && ( + setSelectedId(null)} + /> +)} +``` + +- [ ] **Step 7: Update CharacterDetail to accept and use canEdit** + +Open `client/src/components/CharacterDetail.tsx`. Read the full file first, then: + +Add `canEdit?: boolean` to its Props interface. + +Wrap any edit/delete controls with `{canEdit && ...}`. This includes: delete button, gear removal buttons, talent removal buttons, stat editing, and any inline edit fields. + +- [ ] **Step 8: Add user_id to the Character type** + +Open `client/src/types.ts`. Read it, then add `user_id?: number | null` to the `Character` interface. + +- [ ] **Step 9: Test DM vs player views** + +1. Log in as `dm@darkwatch.test` / `password` — verify atmosphere panel and "Invite Player" button are visible. +2. Open a new incognito window, register a new account, use the dev invite token (`http://localhost:5173/join/dev-invite-token-abc123`) to join as player. +3. Log in as the new player — verify atmosphere panel is hidden, Invite Player button is hidden. +4. As the DM, verify you can see and edit all characters. +5. As a player, create a character — verify you can edit your own character but the DM's characters show no edit controls. + +- [ ] **Step 10: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/src/pages/CampaignView.tsx client/src/components/CharacterCard.tsx \ + client/src/components/CharacterDetail.tsx client/src/types.ts +git commit -m "feat: DM/player role separation — atmosphere DM-only, edit controls owner/DM-only, invite UI" +``` + +--- + +## Task 18: Rename to Darkwatch + +**Files:** +- Modify: `client/src/App.tsx` (already done in Task 14 — title already says "Darkwatch") +- Modify: `client/index.html` — update `` tag + +- [ ] **Step 1: Update client/index.html** + +Read `client/index.html`, then find the `<title>` tag and change it to `Darkwatch`. + +- [ ] **Step 2: Verify in browser** + +Browser tab should show "Darkwatch". + +- [ ] **Step 3: Commit** + +```bash +cd /Users/aaron.wood/workspace/shadowdark +git add client/index.html +git commit -m "chore: rename app to Darkwatch" +```