# 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" ```