01System topology
Everything runs as one Docker Compose project (threatwatch-mdlc) on a single host. Caddy is the only public entry point; Postgres and the API are not published.
02Services
Each container, what it does, and how Compose decides it is healthy. All images bake the version and commit, and every tier reports them on its health endpoint.
| Service | Image | Role | Health |
|---|---|---|---|
| caddy | caddy:2.10-alpine | TLS termination, :80→:443 redirect, reverse proxy, WebSocket passthrough, security headers | caddy validate + TCP |
| web | web/Dockerfile | Next.js 16 standalone. proxy.ts sets a per-request CSP nonce and the auth redirect; the (app) layout resolves the session server-side (ADR-031) | GET /healthz |
| api | server/Dockerfile → api | Express 5 REST /api/v1, /api/openapi.json, ws hub at /ws, Temporal client, LISTEN tw_events | GET /api/health (db, temporal, listener) |
| worker | server/Dockerfile → worker | Temporal worker on task queue threatwatch; registers the 7 schedules at boot | /healthz on internal :4001 |
| temporal | temporalio/temporal | Workflow engine (CLI dev server, SQLite file) | operator cluster health |
| postgres | postgres:17-alpine | System of record; LISTEN/NOTIFY fan-out | pg_isready |
| mailpit | axllent/mailpit | Local SMTP sink for development | HTTP |
| migrate | server image, one-shot | prisma migrate deploy + seed; api and worker wait for it to complete | exit 0 |
server/
domain/ is pure logic (normalize, enrich, rules DSL, correlate, risk, state machines). services/ is the only layer that touches Prisma, and every function takes a TenantScope. api/ and worker/ are thin adapters over services.
web/
App Router with (auth) and (app) route groups. Design tokens in styles/tokens.css, primitives in components/ui.tsx, and a same-origin API client in lib/api.ts that maps problem+json errors[] onto form fields.
Stack
Node 22 · TypeScript 5.9 · Express 5 · Prisma 7 · zod · pino · Temporal TS SDK · Next.js 16 · React 19 · Tailwind v4 · Vitest · Playwright + axe.
03Core pipeline: event → alert → notification
The path a signed batch takes from an integration to an analyst's screen. Every side effect is written to an outbox in the same transaction as the state that caused it, then drained by a worker job.
Verify
POST /api/v1/ingest/:id. Body ≤ 256 KB; Standard Webhooks HMAC-SHA256 within ±300 s, constant-time. Any failure is the same 401.
Ingest
One transaction: receipt dedupe (replay → 200 {duplicate}), normalize, enrich (geo, intel, asset), insert events, create a pending batch, pg_notify. Returns 202 with per-event errors.
Detect
Workflow detect-<batchId>: claim batch with a conditional write, evaluate enabled rules (match clauses + windowed thresholds). A sweep picks up batches if Temporal was down.
Correlate
Group by shared entity (source IP, asset, indicator, user) within 30 min of event time. Create alert with evidence, risk score and MITRE technique.
Notify
Policies enqueue pending_emails / pending_deliveries in the alert's transaction. Drains send with capped backoff and a DLQ; escalation re-notifies.
Live
One LISTEN client fans notifications to that org's WebSockets. Payloads carry ids only; the client refetches through scoped REST.
Investigate
Alert detail joins evidence, rule version, source profile, asset, correlation siblings and timeline. Transitions are validated by the alert state machine.
Alert lifecycle
new → acknowledged → investigating → resolved or false_positive. Resolved and false-positive alerts can be reopened to investigating. An invalid transition returns 409 and the UI shows a conflict state.
04Site map
The sidebar groups match web/src/lib/nav.ts. Detail routes are reached from their list pages, search and pivots. Entries marked with a permission are hidden from roles that lack it; the API enforces every permission regardless.
05Pages
Every screen with a wireframe of its layout, who can use it, the endpoints it binds and the UI states it implements. Highlighted cards are the key pages of the core analyst loop.
06Key workflows
The twelve workflows from SPEC §5.2, each covered by a Playwright spec (W1–W12) that runs on desktop and mobile profiles.
| # | Workflow | Pages |
|---|
07Scheduled jobs
Temporal Schedules evaluated in UTC with overlap policy SKIP and a 10-minute catch-up window. Each job takes an injected now, claims work with a conditional write, and records a job_runs row. Owners can trigger any job from Settings → Jobs.
| # | Schedule | Cadence | Business effect |
|---|---|---|---|
| T1 | detection-sweep | 1 min | Pending batches older than 30 s are claimed and evaluated |
| T2 | email-drain | 1 min | Due emails → sent, or backoff → DLQ after 3 failures |
| T3 | delivery-drain | 1 min | Signed webhooks → delivered on 2xx, or backoff 1m/5m/30m/2h/6h → DLQ |
| T4 | escalation-check | 1 min | Unacknowledged alerts past the policy threshold escalate exactly once |
| T5 | ingestion-health | 5 min | Integrations marked silent, degraded (>20% errors) or healthy |
| T6 | retention-sweep | daily 03:00 | Events past org retention deleted (alert evidence kept as snapshots); receipts after 7 days |
| T7 | report-generate | hourly :05 | Due report schedules produce a report snapshot and queue recipient emails |
08Roles & permissions
One permission matrix in services/auth/rbac.ts. A resource in another org is always 404, never 403. The last active owner cannot be demoted or disabled.
| Permission | owner | admin | analyst | viewer |
|---|
09Data model
PostgreSQL 17 through Prisma 7. UUID v7 ids, TIMESTAMPTZ in UTC, enums as TEXT + CHECK, no floating-point columns. Every tenant table carries org_id with an index led by it.
10Security architecture
Controls that shape how pages and endpoints behave.
Sessions & CSRF
__Host-tw_session cookie (Secure, HttpOnly, SameSite), token stored as a SHA-256 hash. Every non-GET request needs a same-origin Origin; cross-origin mutations get 403 CSRF_ORIGIN.
Ingest authenticity
Per-integration secrets encrypted with AES-256-GCM. HMAC verified before parsing; replays are idempotent by message id; rate-limited per integration and per IP.
Outbound safety
Webhook URLs must be HTTPS and pass assertSafeUrl; delivery pins the resolved address to block DNS rebinding. Payloads are signed at insert time.
Error contract
RFC 9457 problem+json with code, detail, errors[] {field, code, message} and requestId. The web client attaches field errors to the offending control.
Headers & transport
Nonce-based CSP from proxy.ts, frame and content-type protections from Caddy. Local HSTS is max-age=0; production sets a real policy at its trusted origin.
Rate limits
Global 300/min/IP · login 10/min/IP + 5/min/email · register 5/h/IP · ingest 120/min/integration + 600/min/IP · search 60/min/user.