Replaces the table tucked into Settings with a real instance dashboard,
gated server-side to ADMIN_USERS (frontend nav entry appears for admins only).
Backend (/api/admin/*, admin-token required):
- GET overview: uptime, RSS/heap, data-dir bytes, account count vs MAX_USERS,
default quota, campaign/character totals, live-room load
- GET users: created date, usage vs effective quota, session count, admin flag
- POST users/:name/revoke — log a user out everywhere
- DELETE users/:name — delete account + backup + owned campaigns + published
characters (admin accounts are protected from deletion)
- GET/DELETE campaigns — oversight + removal incl. published characters
- GET rooms — players/seats/images/idle per room; join codes never exposed
Frontend: new /admin page (health cards, account management with quota editor +
two-click destructive confirms, campaign table, live-room table), ShieldCheck nav
entry via useIsAdmin(), Settings now links to the panel instead of embedding it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Audit-driven; goal: one user can't exhaust the box or starve the shared Traefik stack.
Resource containment:
- compose: mem_limit 512m, memswap_limit, pids_limit 200, cpus 1.0 (blast radius = container)
- Enforce per-user cloud quota (default 30MB, admin override) on /api/save + /api/characters → 413
- Cap published-character size (2MB); cap rooms (200), images/room (40), image bytes/room (40MB)
- Cap WS image dataUrl length in the wire schema; per-IP WS connection cap (20)
- MAX_USERS registration ceiling → 503 when full
Availability + correctness:
- Async crypto.scrypt (was scryptSync) so password hashing can't freeze the event loop;
constant-time on unknown users to avoid username probing
- trustProxy so req.ip is the real client behind Traefik — rate limits are per-IP, not global
- Tighter auth-endpoint bucket (10/min) on /register + /login; prune stale rate buckets
- O(1) token->user index; log persist() failures instead of swallowing them
- Trim /healthz info; surface quota in /api/usage + the admin dashboard storage bar
- Client surfaces 413 (quota) / 429 (rate) clearly
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Layout:
- CombatantRow: action controls (damage/AC/move/remove) grouped into one
right-aligned wrapper so they wrap together instead of scattering on narrow
widths.
- top bar: the campaign switcher now shrinks/truncates instead of overflowing
on tablet widths.
- character sheet header: stat coins go full-width grid on mobile; the name
truncates and scales down on small screens.
UX consistency:
- player Cast and resource Spend/Regain no longer fail silently — Cast shows the
reason, resource −/+ disable at their bounds.
- the AI encounter builder now catches load failures and reports them; level-up
advisor shows the human error message, not the error-kind enum.
- section headers standardized on the .smallcaps design token across 16 files
(replacing an ad-hoc uppercase class).
- player HP bar uses the shared Meter primitive.
pf2e depth: condition-effects table gains drained, dazzled, enfeebled, fatigued.
FeatCard: dropped a dead text-xl wrapper left from the emoji purge.
Left as documented (functional, off-theme, refactor-risky): the native
confirm/prompt in Settings cloud-conflict and the session-password flow.
Gate: 293 unit + 35 e2e + 2 realtime; tsc + build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P5 backend (the deferred items):
- conflict-aware cloud sync via optimistic concurrency: the server versions each
backup blob (file mtime) and rejects a push whose base version is stale (409),
so a second device can't silently overwrite a newer cloud copy. The client
tracks its last-synced version; the SyncStatusIndicator surfaces a "Sync
conflict" with one-click resolution (Use cloud / Keep this device), and the
Settings push offers the same choice.
- revocable invites: owner can rotate a campaign's invite code (kills the old
one) and remove a member (drops them + deletes their published characters).
(Skipped editor/player roles — no enforcement target in the current model.)
- image storage: deliberately deferred (live images already stream de-duped on a
separate channel; a backup blob-store is infra with no correctness gain).
P7 — subclass/feat/multiclass depth:
- feats are now first-class tracked records (name + effect text) in a Feats &
Features sheet section, not buried in notes. Dexie v15 migration.
- 5e multiclass spell-slot calculator: a pure multiclassSlots() (full ×1, half
÷2, third ÷3 → full-caster table) + a sheet control to fill the slot table for
multiclass casters. (Honest scope: tracking + slots, not per-subclass feature
automation.)
P8 — test hardening: v2-surfaces e2e (feats, multiclass calc, signals bell,
World nav) + server tests (blob versioning, invite rotation, member removal) +
multiclass unit tests.
Gate green: 283 unit + 35 e2e + 2 realtime. App + server build clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Server: AccountStore gains admin gating (ADMIN_USERS env), per-user quotaBytes
override, listUsers + blobBytes. /api/usage now reports total bytes (backup blob +
cloud characters) + an admin flag. Admin routes GET /api/admin/users and POST
/api/admin/quota (gated). Quotas are tracked, not yet enforced. +1 test.
- Client: Settings cloud panel shows "Your cloud storage", and for an admin a users
table with per-user usage + an editable quota (GB). compose sets ADMIN_USERS=nilsb.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- src/lib/cloud/campaigns.ts wraps the campaign/character API.
- Settings "Shared campaigns (cloud)" panel (signed-in only): publish a local
campaign → get an invite code; join a campaign by code; publish a local character
to a campaign (stays yours, re-publish to sync); owners "View party" to read all
published characters (read-all, per the View+suggest model).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>