← Build dossier
ThreatWatch v0.3.3 · b2597c7

Architecture & site map

A multi-tenant SOC platform for small teams. Signed event batches are ingested, a Temporal worker runs detection rules and correlation, findings become triaged alerts, and alerts notify by email or signed webhook through outboxes. This page documents the runtime system, the request pipeline, and every screen in the web app.

8 containers 29 routes 12 key workflows 7 scheduled jobs 4 roles 27 tables 43 invariants

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.

public HTTPS trafficinternal callsrealtime notifications

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.

ServiceImageRoleHealth
caddycaddy:2.10-alpineTLS termination, :80→:443 redirect, reverse proxy, WebSocket passthrough, security headerscaddy validate + TCP
webweb/DockerfileNext.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
apiserver/Dockerfile → apiExpress 5 REST /api/v1, /api/openapi.json, ws hub at /ws, Temporal client, LISTEN tw_eventsGET /api/health (db, temporal, listener)
workerserver/Dockerfile → workerTemporal worker on task queue threatwatch; registers the 7 schedules at boot/healthz on internal :4001
temporaltemporalio/temporalWorkflow engine (CLI dev server, SQLite file)operator cluster health
postgrespostgres:17-alpineSystem of record; LISTEN/NOTIFY fan-outpg_isready
mailpitaxllent/mailpitLocal SMTP sink for developmentHTTP
migrateserver image, one-shotprisma migrate deploy + seed; api and worker wait for it to completeexit 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.

#WorkflowPages

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.

#ScheduleCadenceBusiness effect
T1detection-sweep1 minPending batches older than 30 s are claimed and evaluated
T2email-drain1 minDue emails → sent, or backoff → DLQ after 3 failures
T3delivery-drain1 minSigned webhooks → delivered on 2xx, or backoff 1m/5m/30m/2h/6h → DLQ
T4escalation-check1 minUnacknowledged alerts past the policy threshold escalate exactly once
T5ingestion-health5 minIntegrations marked silent, degraded (>20% errors) or healthy
T6retention-sweepdaily 03:00Events past org retention deleted (alert evidence kept as snapshots); receipts after 7 days
T7report-generatehourly :05Due 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.

Permissionowneradminanalystviewer

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.