- Docker and Docker Compose v2
- Python 3.12+ and Node 20+ for local tooling (Ruff, Pytest, Vite)
cd backend
python3.12 -m venv .venv
source .venv/bin/activate
pip install -e thmp_common -e thmp_cdk -e user_service -e audit_service -e hypothesis_service -e attack_service -e ingestion_service pytest ruff
pip install -e ../connectors/example_webhook
cd hypothesis_service && pytest -qcp .env.example .env
# edit secrets in .env if not using defaults for local dev
docker compose up --build
# Optional (only if you want ATT&CK auto-suggest sidecar):
# docker compose --profile ml up -d attack-suggest-serviceThe frontend service keeps node_modules in a Docker volume (thmp_frontend_node_modules) so npm ci inside the container does not fight your host’s frontend/node_modules (which can cause ENOTEMPTY / rmdir errors). For editor IntelliSense on the host, run npm ci once in ./frontend locally if you want.
- Traefik listens on http://localhost (port 80) for API prefixes (
/api/v1/...). Hittinghttp://localhost/for SPA routes will 404 by design. - Frontend dev server: http://localhost:5173. With the default setup,
VITE_API_BASE_URLis empty: the browser calls/api/...on port 5173 and Vite proxies to Traefik (DEV_PROXY_TARGET,http://traefik:80inside Compose). That avoids “Failed to fetch” when port 80 is blocked or flaky from the browser. - Product name in the UI — Set optional
VITE_COMPANY_NAMEin.env(build-time for Vite). When unset, the shell and browser tab show THMP; when set to e.g.Acme, they show Acme THMP. Rebuild or restart the dev server after changing it. - If you open the UI via another hostname (for example
http://192.168.x.x:5173), setTHMP_CORS_ORIGINSto include that exact origin, or use the same host you configured for the UI. - Frontend stack (Vite, shadcn/ui, light/dark theming): frontend-ui.md. Routes and API mapping: frontend-routes.md.
| Path prefix | Service |
|---|---|
/api/v1/auth, /api/v1/users, /api/v1/workspaces, /api/v1/integrations |
User |
/api/v1/hypotheses, /api/v1/hunts, /api/v1/evidence, /api/v1/workspace, /api/v1/kanban, /api/v1/notifications, /api/v1/search |
Hypothesis |
/api/v1/audit |
Audit |
/api/v1/attack |
ATT&CK catalogue (techniques, tactics, sync, Navigator layer, auto-suggest) |
Evidence file uploads — Evidence files are stored in MinIO (S3-compatible object store) in development. Compose starts a MinIO container on port 9000 (S3 API) and 9001 (web console). Credentials are set by S3_ACCESS_KEY / S3_SECRET_KEY (defaults: thmp-minio / thmp-minio-secret). Bucket thmp-evidence is created automatically at startup. Multipart upload: POST /api/v1/evidence/upload. Download: GET /api/v1/evidence/{id}/file. For production, set S3_ENDPOINT_URL to a blank value and use standard AWS environment variables instead.
Full-text search — The Compose stack starts an OpenSearch single-node instance on port 9200. Hypothesis, Evidence, and Finding records are indexed after creation/update via background tasks. Search: GET /api/v1/search?q=...&types=hypothesis,evidence,finding. The feature degrades gracefully if OPENSEARCH_URL is unset — list queries still work.
OIDC single sign-on — OIDC identity providers can be configured by workspace admins at POST /api/v1/auth/oidc/providers. The login flow: GET /api/v1/auth/oidc/login?idp={slug} → browser redirected to IdP → IdP redirects to GET /api/v1/auth/oidc/callback → THMP issues access + refresh tokens. On first login, users are JIT-provisioned with a personal workspace. Set OIDC_REDIRECT_URI to the callback URL matching your IdP's allowed redirect list.
Send header X-Workspace-Id on Hypothesis and ATT&CK read routes after login (use the workspace id returned from registration or /api/v1/workspaces).
ATT&CK data — The catalogue is empty until a workspace admin runs a sync (downloads the MITRE Enterprise STIX bundle). Example:
curl -sS -X POST http://127.0.0.1/api/v1/attack/sync \
-H "Authorization: Bearer $TOKEN" \
-H "X-Workspace-Id: $WORKSPACE_ID"Default bundle URL: see ATTACK_STIX_URL in .env.example. Override for air-gapped or pinned mirrors.
JWT validation is performed in each service (Traefik routes only). See gateway-jwt.md.
- Integration config — Workspace admin or manager can manage rows under
GET/POST /api/v1/integrations(requiresX-Workspace-Id). Non-secret JSON lives inconfig; useingest_actor_user_id(UUID string) so ingested hypotheses have a validcreated_by. API responses masksecret_refas***when set (operators still PATCH new values).POST /api/v1/integrations/{id}/testproxies to the ingestion service (requiresINGESTION_SERVICE_URLon user-service). Secrets are encrypted at rest whenTHMP_JWT_SECRETis set (see user-service vault). - Ingestion service — Not routed through Traefik by default. With Compose, call
http://127.0.0.1:8005/api/v1/ingest/batchwith headerX-Internal-Token: $THMP_INTERNAL_API_SECRETand JSON bodyworkspace_id,connector_id,raw_payload(see connectors/example_webhook/README.md). - Dedupe and internal auth — See ADR 0008: Ingestion auth and dedupe.
- Triage queries —
triage_queue=truereturns draft hypotheses with any non-manualsource_type(e.g.intel_feed,integration).integration_queue=truereturns only draft hypotheses withsource_type=integration(connector ingest). Optionalingest_triage=auto|reviewsplits rows byconfidence_scorevsTHMP_INGEST_AUTO_CONFIDENCE_MIN(default0.7). The Ingestion UI usesintegration_queue=true. - Integrations UI + Vite — With Compose, the frontend dev server proxies
/api/v1/integrationsdirectly to user-service (VITE_USER_SERVICE_PROXYin docker-compose.yml) so the Integrations page works even if Traefik omits that path. Restartfrontendafter changing proxy env vars.
Traefik only routes /api/v1/..., so Swagger is not on port 80. For local dev, each API container publishes a host port:
| Service | Swagger UI | OpenAPI JSON |
|---|---|---|
| User | http://localhost:8001/docs | http://localhost:8001/openapi.json |
| Hypothesis | http://localhost:8002/docs | http://localhost:8002/openapi.json |
| Audit | http://localhost:8003/docs | http://localhost:8003/openapi.json |
| ATT&CK | http://localhost:8004/docs | http://localhost:8004/openapi.json |
| Ingestion | http://localhost:8005/docs | http://localhost:8005/openapi.json |
Service images run alembic upgrade head on container start (see each Dockerfile CMD). You normally do not need manual upgrades after docker compose up.
Run migrations by hand only when debugging (e.g. after editing migration files without recreating the container):
docker compose exec user-service alembic upgrade head
docker compose exec hypothesis-service alembic upgrade head
docker compose exec audit-service alembic upgrade head
docker compose exec attack-service alembic upgrade headOr from the host (with DATABASE_URL / ALEMBIC_SYNC_URL pointing at Postgres and deps installed):
cd backend/user_service && alembic upgrade head-
405 Method Not Allowedon/api/v1/auth/registerin the browser — Registration is POST-only. A browser address bar issues GET, so FastAPI returns 405; that usually means the request reached user-service (often via Traefik on port 80). Usecurl -X POST, the UI, orscripts/smoke_api.py. -
404 page not found(plain text) onhttp://127.0.0.1/api/v1/...— Traefik’s response when no router matches entrypointweb(host port 80). Not the same as 405 above. After editing Traefik labels indocker-compose.yml, recreate:docker compose down && docker compose up -d(add--buildif images changed). -
Confirm port 80 is Traefik — Another process (or an old container) on host port 80 causes wrong or empty routes. On macOS, check what listens:
lsof -nP -iTCP:80 -sTCP:LISTEN. -
Compare POST through Traefik vs direct user-service (same JSON body):
curl -sS -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"probe@example.com","password":"probepass12","display_name":"Probe"}' curl -sS -o /dev/null -w "%{http_code}\n" -X POST http://127.0.0.1:8001/api/v1/auth/register \ -H "Content-Type: application/json" \ -d '{"email":"probe2@example.com","password":"probepass12","display_name":"Probe"}'
Expect 201 (or 409 if the email exists) with JSON from the app. If :8001 works but :80 returns plain 404 page not found, fix Traefik routing (see dashboard below), not the app.
-
Traefik dashboard (local dev only) — With
--api.insecure=truein Compose, open http://127.0.0.1:8080/dashboard/ and under HTTP → Routers confirm routers such asthmp-user-auth(rulePathPrefix(/api/v1/auth)) and servicethmp-user-svc. If the dashboard does not load, checkdocker compose logs traefik.
GET /api/v1/attack/status— Returnscatalog_ready, counts,last_sync_at, andbundle_attack_version(after sync). Use it to confirm the DB has MITRE data without opening Postgres.- Sync timeout / MITRE URL blocked —
POST /api/v1/attack/syncdownloads the full Enterprise JSON (large). Air-gapped or filtered networks may blockATTACK_STIX_URL. Host a bundle internally and pointATTACK_STIX_URLat it, or run sync from a machine with egress. Checkdocker compose logs attack-service. 422/ unknownattack_technique_ids— Hypothesis validation calls attack-service; IDs must exist in the local catalogue. After restoring an old DB dump or skipping sync, run an admin sync or remove stale technique UUIDs from hypotheses.- Navigator layer has empty
techniques— Normal if no hypotheses link ATT&CK techniques. Theversions.attackfield comes from the last successful sync (x_mitre_versionin the STIX collection); if missing, the export uses"unknown". Override the Navigator app version string withTHMP_NAVIGATOR_APP_VERSIONif needed. - Technique search slow — Postgres
pg_trgmindex onattack.techniques.nameis created by migrationa002; ensure migrations have run (docker compose exec attack-service alembic current).
- Service/API: reporting routes are exposed via Traefik at
/api/v1/reports. - Compose services:
reporting-service(API),reporting-worker(Celery worker),reporting-beat(scheduler tick). - Storage: report artifacts are stored in MinIO bucket
S3_BUCKET_REPORTS(defaultthmp-reports). - Create a report job:
curl -sS -X POST http://127.0.0.1/api/v1/reports/jobs \ -H "Authorization: Bearer $ACCESS" \ -H "X-Workspace-Id: $WS" \ -H "Content-Type: application/json" \ -d '{"report_type":"coverage","params":{"period_days":90}}'
- Download artifacts: after status becomes
succeeded, use:GET /api/v1/reports/jobs/{id}/download?format=pdfGET /api/v1/reports/jobs/{id}/download?format=stix
- Schedules:
POST /api/v1/reports/schedulescreates interval schedules;POST /api/v1/reports/schedules/{id}/runtriggers an immediate run. - Troubleshooting: if jobs remain
queued, checkdocker compose logs reporting-workeranddocker compose logs reporting-beat.
With the stack up and Traefik on port 80:
SMOKE_BASE_URL=http://127.0.0.1 python3 scripts/smoke_api.pyGitHub Actions runs Ruff, backend unit tests, frontend build, and a Compose-based smoke test on pull requests. See contributing.md.