docs: add Darkwatch auth + MariaDB + DM/player views design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e2ce57527f
commit
1830d80ad4
1 changed files with 278 additions and 0 deletions
278
docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md
Normal file
278
docs/superpowers/specs/2026-04-10-darkwatch-auth-db-design.md
Normal file
|
|
@ -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.
|
||||||
Loading…
Add table
Reference in a new issue