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