# 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.