- 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>
10 KiB
Darkwatch — Auth, MariaDB Migration & DM/Player Views Design
Goal
Replace SQLite with MariaDB, add email/password authentication, and introduce per-campaign DM/player roles with appropriate view separation.
Architecture
Users register and log in with email and password. JWTs are issued as httpOnly cookies. Any user can create a campaign (becoming its DM) or join one via an invite link (becoming a player). Role is per-campaign — the same user can be a DM in one campaign and a player in another. MariaDB runs in a dedicated Docker container isolated from all other system containers.
Tech Stack
- DB: MariaDB 11 via Docker (
darkwatch-mariacontainer,darkwatch-mariadb-datavolume, host port 3307) - DB client:
mysql2(async/await, plain SQL — no ORM) - Auth:
bcrypt(password hashing),jsonwebtoken(JWT signing/verification) - Migrations: Numbered SQL files in
server/migrations/, applied on startup via amigrationstable - Frontend auth state: React context +
useAuthhook
Data Model
users
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
username VARCHAR(100) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
avatar_url VARCHAR(500) DEFAULT NULL,
created_at DATETIME DEFAULT NOW()
campaigns
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
created_at DATETIME DEFAULT NOW()
created_by text column removed — ownership is tracked via campaign_members.
campaign_members
campaign_id INT UNSIGNED NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
user_id INT UNSIGNED NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role ENUM('dm', 'player') NOT NULL,
joined_at DATETIME DEFAULT NOW(),
PRIMARY KEY (campaign_id, user_id)
campaign_invites
id INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
campaign_id INT UNSIGNED NOT NULL REFERENCES campaigns(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
created_by INT UNSIGNED NOT NULL REFERENCES users(id),
expires_at DATETIME DEFAULT NULL,
created_at DATETIME DEFAULT NOW()
Invites do not expire by default (expires_at NULL = never). Expiry can be added later.
characters
All existing columns retained. One new column added:
user_id INT UNSIGNED DEFAULT NULL REFERENCES users(id) ON DELETE SET NULL
A user may own multiple characters in the same campaign. user_id NULL is allowed for legacy/DM-created characters with no designated owner.
Unchanged tables (schema identical, moved to MariaDB)
character_statscharacter_gearcharacter_talentsgame_itemsgame_talentsroll_log
Auth System
Endpoints
| Method | Path | Description |
|---|---|---|
| POST | /api/auth/register |
Create account (email, username, password) |
| POST | /api/auth/login |
Login (email, password) → sets JWT cookie |
| POST | /api/auth/logout |
Clears JWT cookie |
| GET | /api/auth/me |
Returns current user from JWT (or 401) |
JWT
- Payload:
{ userId, email, username } - Expiry: 7 days
- Stored as
httpOnly,sameSite: 'lax'cookie nameddarkwatch_token - Signed with
JWT_SECRETenv variable - Cookie is
secure: truein production only
Middleware
requireAuth — reads darkwatch_token cookie, verifies JWT, attaches req.user = { userId, email, username }. Returns 401 if missing or invalid.
requireCampaignRole(role) — checks campaign_members for the given user + campaign. Returns 403 if not a member or wrong role. Used as: requireCampaignRole('dm').
Password rules
- Minimum 8 characters
- Stored as bcrypt hash (cost factor 12)
- Email validated for format, checked for uniqueness
Google OAuth
Deferred to a future iteration. The user table has avatar_url ready for it; adding a google_id column and passport-google-oauth20 strategy will not require schema changes beyond that.
Campaign Management
Creating a campaign
POST /api/campaigns (requireAuth) creates the campaign and inserts the creator into campaign_members as dm in a single transaction.
Invite links
POST /api/campaigns/:id/invite (requireAuth + requireCampaignRole('dm')) generates a random 32-byte hex token stored in campaign_invites, returns the full invite URL.
GET /api/auth/me is called on the join page to ensure the user is logged in before redeeming.
POST /api/campaigns/join/:token (requireAuth) validates the token, checks it hasn't expired, and inserts the user into campaign_members as player. If already a member, returns 200 with no change.
Campaign listing
GET /api/campaigns returns only campaigns where the current user is a member (DM or player), including the user's role for each. Previously returned all campaigns — this changes with auth.
GET /api/campaigns/:id/my-role returns { role: 'dm' | 'player' } for the current user in the given campaign, or 403 if not a member. Used by CampaignView on mount to determine which controls to render.
Authorization Rules
| Action | DM | Player |
|---|---|---|
| Create character in campaign | ✓ (any) | ✓ (owns it) |
| Edit character | ✓ (any) | Own only |
| Delete character | ✓ (any) | Own only |
| Control atmosphere (API + socket) | ✓ | ✗ |
| Generate invite link | ✓ | ✗ |
| View all party characters | ✓ | ✓ |
| Roll for a character | ✓ (any) | Own only |
| View roll log | ✓ | ✓ |
Socket.io Auth
Since the JWT lives in a httpOnly cookie, the client cannot read it directly. Instead, the client connects with withCredentials: true and the browser sends the cookie automatically in the socket handshake headers:
const socket = io({ withCredentials: true });
Server middleware parses the cookie from the handshake headers:
import { parse as parseCookie } from 'cookie';
io.use((socket, next) => {
const cookies = parseCookie(socket.handshake.headers.cookie ?? '');
const token = cookies['darkwatch_token'];
try {
socket.data.user = jwt.verify(token, JWT_SECRET);
next();
} catch {
next(new Error('Unauthorized'));
}
});
atmosphere:update events are validated: server checks campaign_members to confirm the emitting socket's user is a DM for that campaign before broadcasting.
Roll requests (roll:request) validated: user must be a member of the campaign. Rolling for a specific character validates character.user_id === socket.data.user.userId unless the user is a DM.
Docker Setup
docker-compose.yml in repo root. Scoped to the darkwatch project.
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:
.env (gitignored, .env.example committed):
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
Safety: Container name darkwatch-maria and volume darkwatch-mariadb-data are distinct from all existing system containers. The existing mysql container is never referenced or affected.
Migrations
server/migrations/ contains numbered SQL files:
001_initial_schema.sql — all tables from scratch
002_seed_game_data.sql — game_items and game_talents seed data
On server startup, a migration runner:
- Creates a
migrationstable if it doesn't exist - Reads all
.sqlfiles in order - Skips files already recorded in the
migrationstable - Runs and records new ones
No external migration tool — plain SQL + a small runner function in TypeScript.
Dev Seed Data
server/src/seed-dev-data.ts creates (idempotent, skipped if users already exist):
dm@darkwatch.test/ password:password/ username:DungeonMasterplayer@darkwatch.test/ password:password/ username:Adventurer- Campaign: "The Lost Dungeon" with DM as owner
- 2 characters owned by the player account (same as existing seed characters)
- Invite token for the campaign (for testing the join flow)
Frontend Changes
New pages
/login— email + password form, link to/register/register— email, username, password form, redirects to/on success/join/:token— shows campaign name, "Join Campaign" button; redirects to/loginif not authenticated
Route protection
RequireAuth wrapper component: reads auth context, redirects to /login if no user. Wraps /, /campaign/:id, and /join/:token (post-login).
Auth context
AuthContext + useAuth hook. On app load, calls GET /api/auth/me. Stores { userId, email, username } or null. All components that need user/role info consume this context.
CampaignView changes
- On mount: fetches current user's role in this campaign from
GET /api/campaigns/:id/my-role - DM only: AtmospherePanel controls rendered; Invite Link button in header (copies URL to clipboard)
- Player: Atmosphere effects visible, controls hidden
- Character cards: Edit/delete controls shown only if
character.user_id === currentUser.userIdor user is DM - Add Character button: Visible to all members (players create their own)
Socket client
Socket connects with withCredentials: true. The browser sends the httpOnly JWT cookie automatically in the handshake headers — no client-side token handling needed.
Project rename
App title, page titles, and README updated to "Darkwatch". No CSS class or file renames needed.
Migration Strategy
SQLite is abandoned entirely. No data migration — the existing database is dev/test data only. The better-sqlite3 package is removed. The seed-dev-data.ts script is rewritten for MariaDB and provides equivalent test data.
Existing server/src/db.ts is replaced by a mysql2 connection pool module. All route files updated to use async/await queries instead of synchronous better-sqlite3 calls.