darkwatch/docs/superpowers/plans/2026-04-10-darkwatch-auth-db.md
Aaron Wood e608977b0a docs: add Darkwatch auth + MariaDB implementation plan
18-task plan covering Docker/MariaDB setup, mysql2 migration, JWT auth,
campaign membership, invite links, socket auth, and frontend DM/player views.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:41:51 -04:00

86 KiB

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

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
cd /Users/aaron.wood/workspace/shadowdark
docker compose up -d darkwatch-maria

Wait ~10 seconds for MariaDB to initialise.

  • Step 6: Verify connection
docker exec darkwatch-maria mariadb -udarkwatch -pdarkwatch_dev darkwatch -e "SELECT 'connected' AS status"

Expected output:

+----------+
| status   |
+----------+
| connected|
+----------+
  • Step 7: Commit
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

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
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:

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
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:

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
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<void> {
  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<import("mysql2").RowDataPacket[]>(
      "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
cd /Users/aaron.wood/workspace/shadowdark
git add server/migrations/ server/src/migrate.ts
git commit -m "feat: add MariaDB schema migration and runner"

Files:

  • Modify: server/src/index.ts

  • Step 1: Rewrite server/src/index.ts

Replace the entire file:

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:

import { Router } from "express";
const router = Router();
export default router;
  • Step 3: Start the server and verify migrations run
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:

docker ps | grep darkwatch-maria
  • Step 4: Verify tables were created
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
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<RowDataPacket[]>(sql, params) for SELECT

  • const [result] = await db.execute<ResultSetHeader>(sql, params) for INSERT/UPDATE/DELETE

  • All route handlers become async (req, res) => { ... }

  • Step 1: Rewrite server/src/routes/campaigns.ts

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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "INSERT INTO campaigns (name) VALUES (?)",
    [name.trim()]
  );
  const [rows] = await db.execute<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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
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<RowDataPacket[]>(
    "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
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<RowDataPacket[]>(
    "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
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<RowDataPacket[]>(
    "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
# 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
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
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<string, unknown> {
  if (typeof val === "string") {
    try { return JSON.parse(val); } catch { return {}; }
  }
  return (val as Record<string, unknown>) ?? {};
}

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<RowDataPacket[]>(
        "SELECT stat_name, value FROM character_stats WHERE character_id = ?",
        [char.id]
      );
      const [gear] = await db.execute<RowDataPacket[]>(
        "SELECT * FROM character_gear WHERE character_id = ?",
        [char.id]
      );
      const [talents] = await db.execute<RowDataPacket[]>(
        "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<CampaignParams>("/", async (req, res) => {
  const { campaignId } = req.params;
  const [characters] = await db.execute<RowDataPacket[]>(
    "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<CampaignParams>("/", 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<ResultSetHeader>(
    `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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    `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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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<ResultSetHeader>(
    `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<RowDataPacket[]>(
    "SELECT * FROM character_talents WHERE id = ?",
    [result.insertId]
  );
  const talent = { ...talentRows[0], effect: parseJson(talentRows[0].effect) };

  const [charRows] = await db.execute<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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
# server should start without errors in the tsx watch terminal
  • Step 3: Commit
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

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<void> {
  const [userRows] = await db.execute<RowDataPacket[]>(
    "SELECT COUNT(*) as c FROM users"
  );
  if ((userRows[0] as { c: number }).c > 0) return;

  // Seed game_items
  const [itemCount] = await db.execute<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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<ResultSetHeader>(
    "INSERT INTO users (email, username, password_hash) VALUES (?, ?, ?)",
    ["player@darkwatch.test", "Adventurer", passwordHash]
  );
  const playerId = playerResult.insertId;

  // Create campaign
  const [campaignResult] = await db.execute<ResultSetHeader>(
    "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<number> {
    const [r] = await db.execute<ResultSetHeader>(
      `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
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
curl http://localhost:3000/api/campaigns/1/characters | head -c 200

Expected: JSON array with Limpie and Brynn.

  • Step 5: Commit
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

// 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
// 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<void> => {
    const campaignId = req.params.campaignId ?? req.params.id;
    const userId = req.user!.userId;
    const [rows] = await db.execute<RowDataPacket[]>(
      "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
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

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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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<RowDataPacket[]>(
    "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
# 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
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
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<RowDataPacket[]>(
    `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<ResultSetHeader>(
      "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<RowDataPacket[]>(
      "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<RowDataPacket[]>(
    "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<ResultSetHeader>(
    "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<RowDataPacket[]>(
    "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<RowDataPacket[]>(
    `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<RowDataPacket[]>(
    "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
# 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/<token>"}
  • Step 3: Commit
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:

import { requireAuth, requireCampaignRole } from "../auth/middleware.js";

Replace the POST /:campaignId/characters handler — change the INSERT to include user_id:

// POST /api/campaigns/:campaignId/characters
router.post<CampaignParams>("/", 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<ResultSetHeader>(
    `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<RowDataPacket[]>(
    "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:

async function canModifyCharacter(characterId: string, userId: number): Promise<boolean> {
  // Check if user is DM of the campaign this character belongs to
  const [dmCheck] = await db.execute<RowDataPacket[]>(
    `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<RowDataPacket[]>(
    "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:

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<RowDataPacket[]>(
    "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:

router.delete("/:id", requireAuth, async (req, res) => {
  const [rows] = await db.execute<RowDataPacket[]>(
    "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
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"

Files:

  • Modify: server/src/socket.ts

  • Step 1: Update server/src/socket.ts

Replace the entire file:

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<RowDataPacket[]>(
          "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<RowDataPacket[]>(
            "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<import("mysql2").ResultSetHeader>(
          `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<RowDataPacket[]>(
          "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<RowDataPacket[]>(
        "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
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:

import type {
  Campaign,
  Character,
  Gear,
  Talent,
  GameItem,
  GameTalent,
  RollResult,
} from "./types";

const BASE = "/api";

async function request<T>(path: string, options?: RequestInit): Promise<T> {
  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<AuthUser>("/auth/me");
export const login = (email: string, password: string) =>
  request<AuthUser>("/auth/login", {
    method: "POST",
    body: JSON.stringify({ email, password }),
  });
export const register = (email: string, username: string, password: string) =>
  request<AuthUser>("/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<Campaign[]>("/campaigns");
export const createCampaign = (name: string) =>
  request<Campaign>("/campaigns", {
    method: "POST",
    body: JSON.stringify({ name }),
  });
export const deleteCampaign = (id: number) =>
  request<void>(`/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<Character[]>(`/campaigns/${campaignId}/characters`);
export const createCharacter = (
  campaignId: number,
  data: { name: string; class?: string; ancestry?: string; hp_max?: number },
) =>
  request<Character>(`/campaigns/${campaignId}/characters`, {
    method: "POST",
    body: JSON.stringify(data),
  });
export const updateCharacter = (id: number, data: Partial<Character>) =>
  request<Character>(`/characters/${id}`, {
    method: "PATCH",
    body: JSON.stringify(data),
  });
export const deleteCharacter = (id: number) =>
  request<void>(`/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<string, unknown>;
    effects?: Record<string, unknown>;
    game_item_id?: number | null;
  },
) =>
  request<Gear>(`/characters/${characterId}/gear`, {
    method: "POST",
    body: JSON.stringify(data),
  });
export const removeGear = (characterId: number, gearId: number) =>
  request<void>(`/characters/${characterId}/gear/${gearId}`, { method: "DELETE" });

// Talents
export const addTalent = (
  characterId: number,
  data: {
    name: string;
    description?: string;
    effect?: Record<string, unknown>;
    game_talent_id?: number | null;
  },
) =>
  request<Talent>(`/characters/${characterId}/talents`, {
    method: "POST",
    body: JSON.stringify(data),
  });
export const removeTalent = (characterId: number, talentId: number) =>
  request<void>(`/characters/${characterId}/talents/${talentId}`, { method: "DELETE" });

// Game Items
export const getGameItems = () => request<GameItem[]>("/game-items");

// Game Talents
export const getGameTalents = () => request<GameTalent[]>("/game-talents");

// Rolls
export const getRolls = (campaignId: number) =>
  request<RollResult[]>(`/campaigns/${campaignId}/rolls`);
  • Step 2: Update client/src/socket.ts
import { io } from "socket.io-client";

const socket = io("/", {
  autoConnect: true,
  reconnection: true,
  withCredentials: true,
});

export default socket;
  • Step 3: Commit
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

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<void>;
}

const AuthContext = createContext<AuthContextValue>({
  user: null,
  loading: true,
  setUser: () => {},
  logout: async () => {},
});

export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<AuthUser | null>(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 (
    <AuthContext.Provider value={{ user, loading, setUser, logout }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth() {
  return useContext(AuthContext);
}
  • Step 2: Create client/src/components/RequireAuth.tsx
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 <Navigate to="/login" replace />;
  return <>{children}</>;
}
  • Step 3: Update client/src/App.tsx
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 (
    <BrowserRouter>
      <AuthProvider>
        <div className={styles.app}>
          <header className={styles.header}>
            <h1>Darkwatch</h1>
            <ThemeToggle />
          </header>
          <Routes>
            <Route path="/login" element={<LoginPage />} />
            <Route path="/register" element={<RegisterPage />} />
            <Route
              path="/join/:token"
              element={
                <RequireAuth>
                  <JoinPage />
                </RequireAuth>
              }
            />
            <Route
              path="/"
              element={
                <RequireAuth>
                  <CampaignList />
                </RequireAuth>
              }
            />
            <Route
              path="/campaign/:id"
              element={
                <RequireAuth>
                  <CampaignView />
                </RequireAuth>
              }
            />
          </Routes>
        </div>
      </AuthProvider>
    </BrowserRouter>
  );
}
  • Step 4: Commit
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

.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
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 (
    <div className={styles.page}>
      <div className={styles.card}>
        <div className={styles.title}>Sign In</div>
        <form onSubmit={handleSubmit}>
          <div className={styles.field}>
            <label className={styles.label}>Email</label>
            <input
              className={styles.input}
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              autoFocus
              required
            />
          </div>
          <div className={styles.field}>
            <label className={styles.label}>Password</label>
            <input
              className={styles.input}
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>
          {error && <div className={styles.error}>{error}</div>}
          <button className={styles.btn} type="submit" disabled={loading}>
            {loading ? "Signing in…" : "Sign In"}
          </button>
        </form>
        <div className={styles.link}>
          No account? <Link to="/register">Create one</Link>
        </div>
      </div>
    </div>
  );
}
  • 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
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 (
    <div className={styles.page}>
      <div className={styles.card}>
        <div className={styles.title}>Create Account</div>
        <form onSubmit={handleSubmit}>
          <div className={styles.field}>
            <label className={styles.label}>Email</label>
            <input
              className={styles.input}
              type="email"
              value={email}
              onChange={(e) => setEmail(e.target.value)}
              autoFocus
              required
            />
          </div>
          <div className={styles.field}>
            <label className={styles.label}>Username</label>
            <input
              className={styles.input}
              type="text"
              value={username}
              onChange={(e) => setUsername(e.target.value)}
              required
            />
          </div>
          <div className={styles.field}>
            <label className={styles.label}>Password</label>
            <input
              className={styles.input}
              type="password"
              value={password}
              onChange={(e) => setPassword(e.target.value)}
              required
            />
          </div>
          {error && <div className={styles.error}>{error}</div>}
          <button className={styles.btn} type="submit" disabled={loading}>
            {loading ? "Creating account…" : "Create Account"}
          </button>
        </form>
        <div className={styles.link}>
          Already have an account? <Link to="/login">Sign in</Link>
        </div>
      </div>
    </div>
  );
}
  • 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
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
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 (
    <div className={styles.page}>
      <div className={styles.card}>
        {status === "joining" && <div className={styles.title}>Joining campaign</div>}
        {status === "error" && (
          <>
            <div className={styles.title}>Invalid Invite</div>
            <div className={styles.error}>{error}</div>
          </>
        )}
      </div>
    </div>
  );
}
  • 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:

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
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:

import { useAuth } from "../context/AuthContext";
import { getMyCampaignRole, generateInvite } from "../api";

Inside the CampaignView component function, add new state after existing state declarations:

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:

getMyCampaignRole(campaignId).then((r) => setRole(r.role)).catch(() => {});
  • Step 2: Add invite handler

Add this function inside CampaignView, near the other handlers:

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 <div className={styles.headerBtns}> section. Update it so atmosphere panel and invite button are DM-only:

<div className={styles.headerBtns}>
  {role === "dm" && (
    <AtmospherePanel
      atmosphere={atmosphere}
      onAtmosphereChange={handleAtmosphereChange}
    />
  )}
  {role === "dm" && (
    <button className={styles.addBtn} onClick={handleInvite}>
      Invite Player
    </button>
  )}
  <button
    className={styles.addBtn}
    onClick={() => setShowCreate(true)}
  >
    + Add Character
  </button>
</div>
  • Step 4: Update CharacterCard to pass ownership info

Find the characters.map section. Pass ownership flag:

{characters.map((char) => (
  <CharacterCard
    key={char.id}
    character={char}
    onHpChange={handleHpChange}
    onUpdate={handleUpdate}
    onClick={setSelectedId}
    canEdit={role === "dm" || char.user_id === user?.userId}
  />
))}
  • 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:

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:

{selectedCharacter && (
  <CharacterDetail
    character={selectedCharacter}
    campaignId={campaignId}
    critKeys={critKeys}
    canEdit={role === "dm" || selectedCharacter.user_id === user?.userId}
    onUpdate={handleUpdate}
    onStatChange={handleStatChange}
    onAddGearFromItem={handleAddGearFromItem}
    onAddGearCustom={handleAddGearCustom}
    onRemoveGear={handleRemoveGear}
    onAddTalent={handleAddTalent}
    onRemoveTalent={handleRemoveTalent}
    onDelete={handleDelete}
    onClose={() => 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
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 <title> 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
cd /Users/aaron.wood/workspace/shadowdark
git add client/index.html
git commit -m "chore: rename app to Darkwatch"