From 1830d80ad4592241f3aa8852955b5dd9a7a65b76 Mon Sep 17 00:00:00 2001 From: Aaron Wood Date: Fri, 10 Apr 2026 23:28:10 -0400 Subject: [PATCH] docs: add Darkwatch auth + MariaDB + DM/player views design spec Co-Authored-By: Claude Sonnet 4.6 --- .../2026-04-10-darkwatch-auth-db-design.md | 278 ++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md diff --git a/docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md b/docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md new file mode 100644 index 0000000..c0787b0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md @@ -0,0 +1,278 @@ +# 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.