- Move docs/superpowers/{plans,specs}/ → docs/{plans,specs}/
- Add 4 previously untracked implementation plans to git
- Update CLAUDE.md with docs path overrides for superpowers skills
- Update HANDBOOK.md repo structure and workflow paths
- Add per-enemy dice rolls to ROADMAP planned section
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2957 lines
86 KiB
Markdown
2957 lines
86 KiB
Markdown
# 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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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/<token>"}
|
|
```
|
|
|
|
- [ ] **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<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`:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```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<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:
|
|
|
|
```typescript
|
|
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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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<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**
|
|
|
|
```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 <Navigate to="/login" replace />;
|
|
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 (
|
|
<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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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 (
|
|
<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**
|
|
|
|
```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 (
|
|
<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:
|
|
|
|
```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 `<div className={styles.headerBtns}>` section. Update it so atmosphere panel and invite button are DM-only:
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx
|
|
{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:
|
|
```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 && (
|
|
<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**
|
|
|
|
```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 `<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**
|
|
|
|
```bash
|
|
cd /Users/aaron.wood/workspace/shadowdark
|
|
git add client/index.html
|
|
git commit -m "chore: rename app to Darkwatch"
|
|
```
|