darkwatch/docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md
Aaron Wood 1830d80ad4 docs: add Darkwatch auth + MariaDB + DM/player views design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-10 23:28:10 -04:00

278 lines
10 KiB
Markdown

# 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`
```sql
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`
```sql
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`
```sql
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`
```sql
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:
```sql
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.
### 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:
```ts
const socket = io({ withCredentials: true });
```
Server middleware parses the cookie from the handshake headers:
```ts
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.
```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:
```
**`.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.