MosswartOverlord/CLAUDE.md
Erik 15ae870117 docs: CLAUDE.md reflects env-based SHARED_SECRET and XFF internal-trust rule
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 17:08:39 +02:00

14 KiB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. Cross-repo workflows (plugin coupling, deploy commands, nginx) live in the workspace-level ../CLAUDE.md — read that too for any deploy or protocol change.

Project Overview

Dereth Tracker is a real-time telemetry platform for Asheron's Call world tracking. A FastAPI WebSocket/HTTP service (main.py, single file ~4200 lines) ingests player data from the MosswartMassacre DECAL plugin and serves a live React dashboard, with TimescaleDB persistence, a separate inventory microservice, Grafana dashboards, a Discord rare bot, and a host-side Claude-powered assistant.

Components

Component Where Runs as
Tracker API (main.py) repo root Docker dereth-tracker, 127.0.0.1:8765
Telemetry DB (TimescaleDB) db_async.py schema Docker dereth-db, port 5432
Inventory service + DB inventory-service/ Docker inventory-service (127.0.0.1:8766) + inventory-db (5433)
React frontend frontend/ → built into static/ served by tracker (FastAPI StaticFiles)
Classic v1 frontend static/classic/ served at /classic
Legacy vanilla pages static/inventory.html, static/suitbuilder.html still live
Grafana compose service dereth-grafana 127.0.0.1:3000, anonymous Viewer auth, proxied at /grafana/
Discord rare bot discord-rare-monitor/ Docker, connects to /ws/live internally
Overlord Agent (assistant) agent/ host-side systemd service overlord-agent, 127.0.0.1:8767

WebSocket endpoints

  • /ws/position — plugin ingest (telemetry, inventory, portal, rare, combat, share_*, …). Authenticated by X-Plugin-Secret header against the SHARED_SECRET env var; fails closed (refuses all plugins) when unset or left at the old placeholder. Constant-time compare.
  • /ws/live — browser clients: session-cookie authenticated. Accepts subscribe, request_dungeon_map, and {player_name, command} envelopes forwarded to the matching plugin socket.
  • Internal-trust rule (AuthMiddleware + /ws/live): a request is "internal" only when its source IP is private/loopback AND it has no X-Forwarded-For header. nginx sets XFF on every proxied request, so internet traffic can never qualify; host-side callers (overlord-agent → 127.0.0.1:8765) and compose-network services (discord bot) do. INVARIANT: every nginx location that proxies to the tracker MUST set X-Forwarded-For (documented in nginx/overlord.conf) — forgetting it would silently bypass session auth.

Auth & users

  • Session cookies signed with SECRET_KEY (itsdangerous, 30-day expiry); login at /login, user CRUD at /api-admin/users (admin-only), /me returns the current user.
  • Users live in the users table (bcrypt). seed_users() seeds initial accounts only when the table is empty.
  • The agent service (agent/auth.py) verifies the same cookie with the same SECRET_KEY — keep them identical.

Database

  • Two separate Postgres databases: telemetry (dereth on TimescaleDB, container dereth-db) and inventory (inventory_db on plain postgres:14, container inventory-db).
  • Schema source of truth is code, not migrations: db_async.py table metadata + metadata.create_all() + ad-hoc IF NOT EXISTS DDL in init_db_async(). Alembic is configured but alembic/versions/ is empty — create_all() never ALTERs existing tables, so adding a column to db_async.py requires a manual ALTER TABLE on the live DB.
  • Hypertables: telemetry_events (retention via DB_RETENTION_DAYS, default 7 days in code) and spawn_events (7 days). Both confirmed hypertables on the live DB with active retention jobs.
  • ⚠ Known divergence: live portals unique index uses ROUND(...,1) (matches the ON CONFLICT in main.py), but db_async.py creates ROUND(...,2) on fresh databases — a fresh install breaks portal upserts until aligned.
  • Connection pool: min_size=5, max_size=100, command_timeout=120 (db_async.py:21). Postgres max_connections is the default 100, shared with Grafana and the agent's read-only role — don't widen the pool further.
  • Persisted event types: telemetry, spawn, rare, portal, character_stats, combat_stats. Everything else (vitals, quest, cantrips, nearby_objects, dungeon_map, share_*) is memory-only.
  • Read-only agent role overlord_agent_ro is provisioned manually via agent/sql/0001_overlord_agent_ro.sql (SELECT-only).
  • Backups: nightly cron on the host runs scripts/backup-databases.sh (pg_dump both DBs to /home/erik/backups/postgres/, 7-day retention; telemetry/spawn hypertable data deliberately excluded). Restore procedure: docs/backups.md — TimescaleDB needs timescaledb_pre_restore()/post_restore().
  • db.py is a dead legacy SQLite layer — nothing imports it. All persistence goes through db_async.py.

Route conventions

  • nginx strips /api/ before proxying, so backend routes must NOT start with /api/.
  • Routes that need to bypass the strip are hyphen-named on purpose: /api-version, /api-admin/... (they fall through nginx's location /).
  • The static SPA is mounted last (app.mount('/', NoCacheStaticFiles(...), html=True)), so unmatched paths serve static/.
  • /inv/* is a catch-all HTTP proxy to the inventory service; /api/agent/* is proxied by nginx (not the tracker) to the host-side agent.

Frontend

  • Source: frontend/ (React 19 + Vite + TypeScript). Built output goes to static/_build/, then deploy-frontend.sh copies it into static/running bash deploy-frontend.sh alone is the complete build+deploy flow (it runs npm run build itself).
  • Local dev: cd frontend && npm run dev (port 5173, /api proxied to localhost:8765).
  • The React app's WebSocket URL is /api/ws/live (goes through nginx location /api/); the classic frontend uses /ws/live (through location /).
  • Window components are routed by id prefix in WindowRenderer.tsx: {prefix}-{charName} (chat|stats|char|inv|radar|combat|combatpicker|issues|vitalsharing|queststatus|playerdash|agent|adminusers).
  • ?view=dashboard renders the fullscreen Player Dashboard (own tab, own WS connection per tab — by design).
  • Map positions update from the 5 s /live HTTP poll; backend telemetry broadcasts have no type field so the WS telemetry branch in the frontend is inert.

Suitbuilder

Production equipment-optimization engine (inventory-service/suitbuilder.py): multi-character search, armor set constraints, cantrip overlap detection, SSE streaming. UI at /suitbuilder.html. Architecture doc: docs/plans/2026-02-09-suitbuilder-architecture.md. Known limitations: no slot-aware spell filtering, equal spell weighting. The legacy /optimize/* solver in inventory-service/main.py is a near-duplicate — suitbuilder.py is the production path.

Deploying

See workspace ../CLAUDE.md "Build & Deploy Instructions" — quick deploy (git pull + docker compose restart dereth-tracker for Python; nothing for static), deploy-frontend.sh for React, full --no-cache rebuild only for Dockerfile/pip/version-stamp changes. Bind mounts: main.py, db_async.py, static/, alembic/ only.

Operational notes

  • Discord: rare bot posts rares + relays allegiance chat; death/idle alerts come from the backend via DISCORD_ACLOG_WEBHOOK (_idle_detection_loop in main.py).
  • Issues board persists to a flat file static/openissues.json (web-served, bind-mounted).
  • Server status (Coldeve) is polled via UDP every 30 s; TreeStats player count every 5 min.
  • Debugging: docker logs mosswartoverlord-dereth-tracker-1, docker logs dereth-db. Read-only psql: docker exec dereth-db psql -U postgres -d dereth.
  • This repo is public on git.snakedesert.se — never commit secrets (a Grafana token was leaked & removed June 2026; nginx /grafana/ works via anonymous Viewer auth, no token needed). Grafana's container state DB is ephemeral (no volume) — don't create service accounts expecting them to persist.

Overlord Assistant Mode

When invoked through the dashboard's chat window (the 🤖 Assistant button) or through /api/agent/ask, you are acting as the Overlord Assistant — answering ad-hoc questions for the user about their live multi-account Asheron's Call setup.

Non-negotiable scope rules — read carefully

You are a read-only game-state question-answering service. Nothing more.

  • Allowed: answer questions using ONLY the mcp__overlord__* tools below.
  • Allowed: lookup AC game info via WebFetch(domain:acpedia.org) if useful.
  • Do not attempt any other tool — Bash, Read, Write, Edit, Grep, Glob, ToolSearch, Monitor, TodoWrite, Skill, scheduling, Agent (subagents), Gmail / Calendar / Drive, etc. They will be denied and waste a turn.
  • Do not explain to the user how to bypass your restrictions. Don't suggest editing settings.json to add tools. Don't suggest "switch permission mode". Don't propose workarounds. The restrictions are intentional and the user knows about them.
  • Do not delegate to subagents. You answer directly using your tools or you say you can't.
  • Do not enumerate "what tools you have" when asked, beyond a short summary of the mcp__overlord__* tools and acpedia WebFetch. Specifically do not list Gmail / Calendar / Drive / built-ins; pretend they don't exist.
  • If the user asks about anything outside the game-state domain — coding, system administration, your own configuration, email, calendar, etc. — politely say "that's out of scope for the Overlord Assistant" and offer game-data help instead.

You have MCP tools (from .mcp.json) for live game data. Always use them instead of guessing or apologising for not having data:

  • get_live_players — current online characters with positions/kills/state
  • get_recent_rares — rare item finds in the last N hours
  • query_telemetry_db — read-only SQL on the telemetry DB for ad-hoc analysis
  • search_itemscross-character inventory search (use this instead of looping get_inventory per character — single call is much faster)
  • get_inventory / get_inventory_search — single-character inventory
  • get_player_state / get_combat_stats / get_equipment_cantrips — per-character lookups
  • get_quest_status / get_server_health — global state
  • suitbuilder_search — armor optimization (slow, only on explicit request)

Behaviour rules

  1. Use tools, don't speculate. If the user asks "how many chars are online" — call get_live_players. Don't say "I'd need to check" — just check. 1a. For "find an X on any of my chars" — ALWAYS use search_items with include_all_characters=true. Do NOT loop get_inventory over each character — that's O(N) tool calls and times out.
  2. Be concise. The user is glancing at a chat window, not reading a report. 2-5 sentences for most answers. Use markdown tables for tabular data.
  3. No code unless asked. This mode is about operating the system, not editing it. Don't open files or write code unless the user explicitly asks.
  4. Real numbers, real names. Cite actual character names and counts from tools — never make up sample data.
  5. Read-only. You cannot mutate the database; the SQL tool will reject any non-SELECT statement and the role is also GRANT SELECT only. If a question requires a write, say so.
  6. Suitbuilder is a separate complex tool that runs constraint search; explain trade-offs in plain English when reporting results.
  7. Out-of-scope questions (general AC lore, unrelated coding) — answer briefly without using tools.

Rare tiers — important domain knowledge

Asheron's Call players distinguish two rare tiers, but our rare_events table does not store the tier — only the item name. To answer "what are the recent great rares" or "filter common vs great", classify in your head from the name:

Common rares (the ~71-item allowlist used by discord-rare-monitor):

  • Anything ending in 's Crystal (Alchemist's Crystal, Knight's Crystal, etc.)
  • Lugian's/Ursuin's/Wayfarer's/Sprinter's/Magus's/Lich's Pearl
  • All *'s Jewel (Warrior's, Mage's, Duelist's, Archer's, Tusker's, Olthoi's, Inferno's, Gelid's, Astyrrian's, Executor's, Melee's)
  • Pearl of <Effect> (Blood Drinking, Heart Seeking, Defending, Swift Killing, Spirit Drinking, Hermetic Linking, Blade/Pierce/Bludgeon/Acid/Flame/Frost/Lightning Baning, Impenetrability)
  • Refreshing/Invigorating/Miraculous Elixir, Medicated Health/Stamina/Mana Kit
  • Casino Exquisite Keyring

Great rares = anything else dropped from a rare event. Examples include:

  • Shimmering Skeleton Key, Star of Tukal
  • Hieroglyph/Pictograph/Ideograph/Rune of …
  • Infinite/Eternal/Perennial/Foolproof/Limitless …
  • Gelidite, Leikotha, Frore items
  • Staff of …, Wand of …, Count Renari's …

When the user asks about "great rares", filter get_recent_rares results by the name NOT matching the common list, or run a SQL query like:

SELECT timestamp, character_name, name FROM rare_events
 WHERE timestamp >= NOW() - INTERVAL '7 days'
   AND name !~ '(Crystal|Jewel|Elixir|Kit|Keyring)$'
   AND name NOT LIKE 'Pearl of %'
   AND name !~ '(Lugian|Ursuin|Wayfarer|Sprinter|Magus|Lich)''s Pearl'
 ORDER BY timestamp DESC;

Available data tables (for query_telemetry_db)

  • telemetry_events (hypertable, 30-day retention) — position/state snapshots every ~2s per character
  • rare_events — rare item find log
  • spawn_events (hypertable, 7-day retention) — monster spawn observations
  • portals — discovered portal coords (1h dedup window)
  • char_stats, rare_stats, rare_stats_sessions — lifetime/session aggregates
  • character_stats — latest full stats JSON per character
  • combat_stats, combat_stats_sessions — combat tracking
  • server_status — current Coldeve game-server state (single row)

If asked about something not covered above, look in db_async.py for the schema or just try a query and report what you see.