darkwatch/docs/specs/2026-04-10-darkwatch-auth-db-design.md
Aaron Wood 7c7bdf2ee5 chore: consolidate docs into flat structure and commit all plans
- 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>
2026-04-11 23:55:45 -04:00

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-maria container, darkwatch-mariadb-data volume, 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 a migrations table
  • Frontend auth state: React context + useAuth hook

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_stats
  • character_gear
  • character_talents
  • game_items
  • game_talents
  • roll_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 named darkwatch_token
  • Signed with JWT_SECRET env variable
  • Cookie is secure: true in 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.

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:

  1. Creates a migrations table if it doesn't exist
  2. Reads all .sql files in order
  3. Skips files already recorded in the migrations table
  4. 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: DungeonMaster
  • player@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 /login if 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.userId or 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.