A private-use D&D 5e (2024) inventory manager. Local-first browser app with an optional self-hosted backend for multi-member parties, Discord / email login, authoritative sync, and live WebSocket broadcast.
⚠️ Private use only. No PHB / DMG content ships in this repo. Seed JSON lives outside git; seepackages/seeds/data/examples/for the file shape.
- Features
- Status
- Quick start
- Local vs server mode
- Backup & restore
- Self-hosted deployment
- Repo layout
- Docs
| Feature | Details |
|---|---|
| 🎒 Stashes | Inventory, Storage, Party Stash, Recovered Loot — with containers (Bag of Holding etc.) |
| 💰 Currency | Per-stash CP/SP/EP/GP/PP holdings, integer-CP math, convert, transfer, split evenly, bulk edit |
| 🗡️ Catalog | PHB mundane + DMG magic items, homebrew CRUD, fuzzy multi-field search |
| ✨ Magic items | Charges + recharge, identification, attunement (with DM cap-override) |
| 🧙 Multi-member | DM / Player / Banker roles, invite codes, DM cross-character actions |
| 💾 Backup | JSON export / import (round-trip lossless), server-side export |
| 🌐 Modes | Local-only IndexedDB or self-hosted server with Discord / email login |
| ⚡ Live sync | WebSocket broadcast, optimistic writes, persisted outbox, auto-reconnect |
| 📜 History | Party log timeline + per-item history, permission-gated |
| 🎲 DM tools | Loot distribution wizard, hoard generator, shop manager, batch identify |
| 🌓 Theme | Light / dark / system |
MVP + R1–R6 shipped; R7 (polish) in progress. Full history in docs/roadmap.md.
| Milestone / Slice | Scope |
|---|---|
| M0 – M7 ✅ | MVP: skeleton → character → catalog → storage → currency → move/split → homebrew → backup |
| R1 ✅ | Characters & encumbrance (equip, attune, containers, hard-mode enforcement) |
| R2 ✅ | Magic items (DMG seed, charges, identification) |
| R3 ✅ | Backend skeleton (Fastify + Postgres, Discord OAuth + email OTP, sync queue, snapshots) |
| R4 ✅ | Multi-member parties (join/leave/kick, Banker, DM cross-character actions, DM Dashboard) |
| R5 ✅ | Live sync (Socket.IO), reconnect + outbox, sessions UI, history timeline |
| R6 ✅ | DM tools (economy, shops, hoard generator, loot wizard, identification, catalog search) |
| R7 🚧 | Polish — theme + bulk currency edit + fuzzy stash search shipped; log-perf / vault-export deferred |
| RH1 – RH5 ✅ | Hardening: server-authoritative IDs, determinism, GameSession, URL routing, Dexie hydration |
Requirements: Node ≥ 22, pnpm 11.
pnpm install
pnpm --filter @app/web dev # http://localhost:5173 — local modeCommon scripts:
pnpm --filter @app/web build # production build
pnpm --filter @app/web test # Vitest
pnpm --filter @app/web lint # ESLint
pnpm typecheck # tsc --noEmit across workspace
pnpm format # Prettier writeThe web app has two build-time modes selected by VITE_SERVER_URL:
| Mode | VITE_SERVER_URL |
Backend | Auth chrome | Live sync |
|---|---|---|---|---|
| Local | unset / empty | IndexedDB only | hidden | — |
| Server | https://your-host |
Fastify + Postgres | Login / Settings / Logout | Socket.IO |
Vite inlines the value at build time — flipping modes means rebuilding the bundle, not restarting the container.
# Local (default)
pnpm --filter @app/web dev
# Server-mode dev (same-origin required — use Vite proxy or matching localhost)
VITE_SERVER_URL=http://localhost:3000 pnpm --filter @app/web dev
# Server-mode production build
VITE_SERVER_URL=https://dnd.example.com pnpm --filter @app/web buildIn server mode the SPA + API must share an origin (SameSite=Lax cookie). See the self-hosted deployment section for the reverse-proxy setup.
Local mode runs entirely in your browser. In Settings:
- Export JSON — versioned snapshot of your full party (character, stashes, items, currency, homebrew, transaction log).
- Import JSON — restore after a replace-all confirm. Round-trip is bit-for-bit lossless.
Server mode exposes the same envelope shape via GET /sync/export?partyId=<id>.
Designed for a single Linux box behind a reverse proxy — no Kubernetes, no managed cloud.
- Linux server with Docker + Docker Compose v2 (Proxmox LXC works — give it ≥ 2 GB RAM).
- Domain name pointed at the server.
- TLS via Caddy (auto Let's Encrypt), Traefik, or
nginx + certbot. - At least one login provider:
- Discord application (developer portal) — see step 2.
- SMTP relay for email OTP — Postmark / SES / Mailgun / SendGrid / self-hosted Postfix. Local test: Mailpit or the
--profile mailcompose target.
The server boots successfully with any combination of the two — leave the unused env vars blank and the corresponding Login button is hidden.
2. Register a Discord application (optional)
Skip if you're deploying email-OTP-only.
- Discord developer portal → New Application → name it.
- Under OAuth2 → General:
- Copy Client ID →
DISCORD_CLIENT_ID. - Reset Secret → copy →
DISCORD_CLIENT_SECRET. Treat like a password. - Add two Redirect URIs (exact match, trailing slash matters):
https://<your-domain>/auth/callback/discord— primary. SetsDISCORD_REDIRECT_URI.https://<your-domain>/auth/callback/discord/link— used by Settings → Linked accounts.
- Copy Client ID →
The app only requests scope identify (username + avatar). No email, no guilds.
git clone https://github.com/<you>/invManagement.git
cd invManagement/infra/docker
cp .env.example .env
$EDITOR .envEnvironment variables
| Variable | Purpose |
|---|---|
POSTGRES_PASSWORD |
Long random string. Change from the default. |
AUTH_SECRET |
openssl rand -base64 32. Rotating invalidates all sessions. |
WEB_ORIGIN |
Public origin (e.g. https://dnd.example.com). Used for CORS + Auth.js + Socket.IO. Must match VITE_SERVER_URL. |
SESSION_COOKIE_INSECURE |
false in production (HTTPS). true only for local HTTP proxy profile. |
DISCORD_CLIENT_ID / DISCORD_CLIENT_SECRET |
From step 2. Blank → Discord login disabled. |
DISCORD_REDIRECT_URI |
https://<your-domain>/auth/callback/discord (must match Discord portal). |
SMTP_HOST / PORT / USER / PASS / FROM |
Blank → email OTP disabled. |
SERVER_PORT / WEB_PORT / POSTGRES_PORT |
Internal ports. Keep firewalled. |
VITE_SERVER_URL |
Build-time. Public origin, or blank for a local-only bundle. Change requires docker compose up -d --build web. |
cd infra/docker
docker compose up -d --build
docker compose logs -f server # watch seed runner + Fastify bootThe server container runs prisma migrate deploy (idempotent) then seeds PHB + DMG catalog content. Smoke-check:
curl http://127.0.0.1:${SERVER_PORT:-3000}/healthz
# → {"status":"ok","db":"ok","seedVersion":3}
⚠️ VITE_SERVER_URLis baked into the JS bundle. Changing it later requires--build; a baredocker compose up -dwill keep serving the old bundle.
Fastify binds inside the container only. The proxy must route these paths to the server, everything else to the web container:
| Path | Purpose |
|---|---|
/healthz |
liveness probe |
/auth/* |
Auth.js + email OTP |
/sync/* |
authoritative sync (state, actions, export) |
/parties, /parties/* |
join / leave / kick / invite / members |
/socket.io/* |
WebSocket transport (Engine.IO polling + WS upgrade) |
⚠️ Common gotchas:
- Missing
/socket.io/*route → client emitsconnect_error: server errorevery reconnect tick.vite previewrejects reverse-proxied requests by default. SetVITE_ALLOWED_HOSTS=<domain>(or*behind a trusted proxy).- Auth.js v5 needs the canonical
Hostheader. Don't forwardX-Forwarded-Hostblindly.- SPA + API must share an origin so the
SameSite=Laxsession cookie is sent on every fetch.
Caddy (easiest — auto-TLS)
/etc/caddy/Caddyfile:
dnd.example.com {
encode gzip
@server {
path /healthz /healthz/* /auth/* /sync/* /parties /parties/* /socket.io /socket.io/*
}
handle @server {
reverse_proxy 127.0.0.1:3000
}
handle {
reverse_proxy 127.0.0.1:5173
}
}sudo systemctl reload caddy — done. Let's Encrypt certs auto-issued + renewed. reverse_proxy auto-upgrades WebSockets.
nginx + certbot
nginx doesn't auto-rewrite the Upgrade / Connection headers required for WebSocket. Add the map block:
# In the http { } block:
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name dnd.example.com;
ssl_certificate /etc/letsencrypt/live/dnd.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/dnd.example.com/privkey.pem;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
location /healthz { proxy_pass http://127.0.0.1:3000; }
location /auth/ { proxy_pass http://127.0.0.1:3000; }
location /sync/ { proxy_pass http://127.0.0.1:3000; }
location /parties/ { proxy_pass http://127.0.0.1:3000; }
location = /parties { proxy_pass http://127.0.0.1:3000; }
location /socket.io/ { proxy_pass http://127.0.0.1:3000; }
location / { proxy_pass http://127.0.0.1:5173; }
}
server {
listen 80;
server_name dnd.example.com;
return 301 https://$host$request_uri;
}Issue certs: sudo certbot --nginx -d dnd.example.com.
Traefik (Docker labels)
Add labels to server + web in infra/docker/docker-compose.yml (or a compose override):
services:
server:
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.dnd-server.rule=Host(`dnd.example.com`) && (Path(`/healthz`) || PathPrefix(`/auth`) || PathPrefix(`/sync`) || Path(`/parties`) || PathPrefix(`/parties/`) || PathPrefix(`/socket.io`))'
- 'traefik.http.routers.dnd-server.entrypoints=websecure'
- 'traefik.http.routers.dnd-server.tls=true'
- 'traefik.http.routers.dnd-server.tls.certresolver=dnd'
- 'traefik.http.services.dnd-server.loadbalancer.server.port=3000'
web:
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.dnd-web.rule=Host(`dnd.example.com`)'
- 'traefik.http.routers.dnd-web.priority=1'
- 'traefik.http.routers.dnd-web.entrypoints=websecure'
- 'traefik.http.routers.dnd-web.tls=true'
- 'traefik.http.routers.dnd-web.tls.certresolver=dnd'
- 'traefik.http.services.dnd-web.loadbalancer.server.port=5173'Traefik auto-detects Upgrade: websocket — no extra config for /socket.io. Ensure your Traefik network is shared with the compose services.
Local Docker Desktop testing (proxy profile)
For local same-origin testing without a real host proxy:
cd infra/docker
# In .env:
# VITE_SERVER_URL=http://localhost:8080
# WEB_ORIGIN=http://localhost:8080
# SESSION_COOKIE_INSECURE=true # local HTTP — opt out of __Host- / Secure
docker compose --profile proxy up -d --build
# Browse to http://localhost:8080docker compose up without the flag leaves the internal Caddy container out — that's what production behind a real host proxy does.
Verify WebSocket end-to-end:
curl -s "https://dnd.example.com/socket.io/?EIO=4&transport=polling" | head -c 200
# → 0{"sid":"...","upgrades":["websocket"],"pingInterval":25000,...}- Browse to
https://dnd.example.com— SPA loads. curl https://dnd.example.com/healthz—{"status":"ok","db":"ok",...}.https://dnd.example.com/auth/discord/login— Discord consent screen (if configured).- Log in via SPA → DevTools → Network → WS: live
wss://.../socket.io/?...connection carryingappliedevents on every mutation.
Day-2 operations
- Updates:
git pull && docker compose up -d --build. Migrations run on server boot. - Logs:
docker compose logs -f server. OTP values are scrubbed; PII (names) is not. - Backups: nightly
pg_dumpfrom host cron:Server also writes nightlydocker exec -t infra-docker-postgres-1 \ pg_dump -U dnd dnd_inv > /var/backups/dnd-inv/dump-$(date +%F).sql
AppStatesnapshots toSNAPSHOT_DIRifSNAPSHOTS_ENABLED=true. - Rotate
AUTH_SECRET: set new value + restart. All sessions invalidated. - Rotate Discord secret: update portal +
DISCORD_CLIENT_SECRET+ restart. Existing sessions keep working. - Wipe install:
docker compose down -vdrops the Postgres volume. - Player stuck offline: R5.1's outbox buffers writes for solo parties indefinitely; multi-member parties see an offline banner and disabled Save buttons until reconnect.
Security posture
- Postgres firewalled to
127.0.0.1; only the compose network reaches it. - Session cookie:
HttpOnly,SameSite=Lax,Secure,__Host-prefix in production. - WebSocket upgrades reuse the session cookie via
io.use(). Rooms are named server-side fromPartyMembership— clients never name their own. - Discord tokens never persisted (
access_token,refresh_token,id_tokenallNULL). - Sliding 30-day session expiry; deleting a
Sessionrow instantly revokes that device. NODE_ENV=productionrefuses to boot withoutAUTH_SECRET.- See
docs/SECURITY.mdfor the full threat model.
apps/web React SPA (Vite)
apps/server Fastify API + Socket.IO realtime
packages/shared Cross-cutting Zod schemas + types
packages/rules Pure rules engine (currency, capacity, search, …)
packages/seeds PHB / DMG content loader
infra/docker Compose + Caddy proxy profile
docs/ OUTLINE / MVP / TECH_STACK / SECURITY / roadmap
| File | Purpose |
|---|---|
docs/OUTLINE.md |
Full product scope + data model |
docs/MVP.md |
MVP cut (strict subset of OUTLINE) |
docs/TECH_STACK.md |
Technology choices + rationale |
docs/SECURITY.md |
Threat model + mitigations |
docs/roadmap.md |
Slice-by-slice history + upcoming plans |
docs/BUGS.md |
Open + recently-fixed bugs |
apps/server/README.md |
Server env vars + scripts |
infra/docker/docker-compose.yml |
Compose reference |