# Template for the shared `.env` file. # # cp .env.example .env # # Secrets (API keys, DB passwords) go in `.env`, which is gitignored. # Do NOT put secrets in this file: it is tracked in git. # # Per-network values (CARDANO_NETWORK, OGMIOS_WS_URL, API_PORT) live in # .env.mainnet / .env.preprod / .env.preview / .env. (also gitignored) # and are selected at launch via TMS_ENV=. Defaults to preprod when # unset, so set TMS_ENV=mainnet explicitly for a mainnet deployment. # ── Selected Network ─────────────────────────────────────────────── # Determines which network-specific env file is selected when using the app profile. # Defaults to preprod when unset. # CHANGE THIS for a mainnet deployment: left at preprod, a mainnet host that also # has a .env.preprod silently layers it and monitors preprod instead (the same # mix-up re-targets scripts/reset.sh). Set TMS_ENV=mainnet and create # .env.mainnet from .env.mainnet.example. # # Setting it HERE covers docker compose only, which reads this file to resolve # the `env_file: .env.${TMS_ENV}` path. Anything run on the host (run.py, # app.cli, scripts/reset.sh) picks the per-network file from the PROCESS # environment before this file is read, so a TMS_ENV that lives only here is # invisible to it and those tools silently fall back to preprod. Export it in # the deploy shell profile as well: `export TMS_ENV=mainnet`. TMS_ENV=preprod # ── Ogmios tuning (shared) ──────────────────────────────────────────────────── OGMIOS_RECONNECT_MAX_DELAY=60 # max backoff delay in seconds OGMIOS_HEARTBEAT_INTERVAL=30 # ping interval in seconds OGMIOS_HEARTBEAT_TIMEOUT=90 # pong timeout in seconds OGMIOS_CIRCUIT_BREAKER_THRESHOLD=5 # consecutive failures before cooldown OGMIOS_CIRCUIT_BREAKER_COOLDOWN=120 # cooldown duration in seconds # ── API Server ──────────────────────────────────────────────────────────────── API_HOST=0.0.0.0 # API_PORT has two distinct jobs, so it lives in two places: # - Under docker compose it selects the PUBLISHED HOST port, via ${API_PORT} # interpolation, which Compose resolves from the shell and the top-level # `.env` only (this template once copied to `.env`; never .env.). # Set it here to move the published port. The container always listens on # 8000, pinned in docker-compose.yml to match EXPOSE and the healthcheck. # - For a host run, run.py binds uvicorn to it, and it is read from the # per-network file (see .env.mainnet, .env.preprod, .env.preview). #API_PORT=8000 # ── API Key Authentication ──────────────────────────────────────────────────── # Comma-separated list of valid keys. Set this in production. # If left empty, the app refuses to start unless TMS_ALLOW_DEV_MODE=1 # is also set (see below). This guard prevents an accidental production # deploy from silently running an open API. API_KEYS= # API_KEY_HEADER=X-API-Key # default: X-API-Key # TMS_ALLOW_DEV_MODE=1 # required to run with empty API_KEYS # ── Frontend Build (Vite) ───────────────────────────────────────────────────── # Baked into the SPA bundle by the frontend-build stage in backend/Dockerfile. # Browser auth is the magic-link session cookie set by /api/v1/auth/verify; # no API key is shipped to the client. API_KEYS above is for server-to-server # callers (CLI, integrations) only. VITE_NETWORK=preprod # ── Rate Limiting ───────────────────────────────────────────────────────────── RATE_LIMIT_ENABLED=true RATE_LIMIT_REQUESTS=240 # max requests per window per key RATE_LIMIT_WINDOW_SECONDS=60 # ── PostgreSQL ──────────────────────────────────────────────────────────────── POSTGRES_HOST=localhost POSTGRES_PORT=5433 # host port published by docker-compose (container listens on 5432) POSTGRES_USER=tms_user POSTGRES_PASSWORD=tms_password POSTGRES_DB=tms_db # ── ClickHouse ──────────────────────────────────────────────────────────────── CLICKHOUSE_HOST=localhost CLICKHOUSE_PORT=9000 CLICKHOUSE_HTTP_PORT=8123 CLICKHOUSE_USER=default CLICKHOUSE_PASSWORD= CLICKHOUSE_DB=tms_analytics # ── Raw Transaction Store ───────────────────────────────────────────────────── # Local filesystem path for gzip-compressed raw Ogmios JSON blobs. # Path structure: {RAW_STORE_PATH}/{confirmed|mempool}/{network}/{YYYYMMDD}/{tx_hash[:2]}/{tx_hash}.json.gz # Upgrade path: MinIO (production Docker Compose) → S3/R2/B2 (Mainnet). RAW_STORE_PATH=./data/raw RAW_STORE_ENABLED=true # ── Analysis Engine ─────────────────────────────────────────────────────────── ANALYSIS_ENGINE_ENABLED=true ANALYSIS_ENGINE_INTERVAL_SECONDS=30 ANALYSIS_ENGINE_BATCH_SIZE=100 # ── Magic-link auth ─────────────────────────────────────────────────────────── # Sessions are opaque IDs (NOT JWTs) backed by Postgres rows. The cookie # is HTTP-only + SameSite=Lax + Secure (auto-detected behind a TLS proxy). SESSION_COOKIE_NAME=tms_session SESSION_TTL_DAYS=7 MAGIC_LINK_TTL_MINUTES=15 # Used to build the verification URL inside outgoing magic-link emails. # Point this at the public URL of the SPA in prod (e.g. https://tms.example.com). # Deliberately left out of the compose `environment:` block (along with KUPO_URL) # so a per-network .env. can override it, since the dashboard URL # differs per network. Anything that block names must be set here in .env. # Getting it wrong fails silently: links are emitted, they just do not resolve. APP_BASE_URL=http://localhost:8000 # ── SMTP (magic-link delivery) ──────────────────────────────────────────────── # Dev: docker compose ships a Mailpit catch-all, opt-in via `--profile mail` # (SMTP at mailpit:1025, web UI on :8025): every email sent is captured and # viewable, never forwarded. Without that profile no mail catcher runs, so # sends fail silently (tolerated); enable it or point SMTP_* at a real server. # Prod: replace these with the customer's SMTP provider. Avoid special-use # domains (.local, .test) in user-facing addresses: EmailStr rejects them, # so users can't be created with one (noreply@example.com is fine as a # sender; nothing validates the From address). SMTP_ENABLED=true SMTP_HOST=mailpit SMTP_PORT=1025 SMTP_USER= SMTP_PASSWORD= SMTP_USE_TLS=false # implicit TLS (port 465 style) SMTP_USE_STARTTLS=false # opportunistic STARTTLS upgrade SMTP_FROM_EMAIL=noreply@example.com SMTP_FROM_NAME=TMS # ── Alerting ────────────────────────────────────────────────────────────────── # These are the master switches and transport knobs. WHAT gets alerted, to WHOM, # and over which channel is NOT set here: it lives in a config document in # Postgres, edited from the dashboard under Settings > Notifications or through # PUT /api/v1/notifications/config. See docs/ALERTING.md. # # The two layers interact in a way that is easy to get wrong: a channel switched # off here can never fire no matter what the config document routes to it, and # the dashboard cannot see these variables. Changing anything in this block needs # an application restart; changing the config document does not. EMAIL_NOTIFY_ENABLED=true WEBHOOK_NOTIFY_ENABLED=true # HMAC-SHA256 key for signing webhook bodies, sent as X-TMS-Signature. LEAVE THIS # UNSET AND WEBHOOK REQUESTS ARE UNSIGNED: the receiver has no way to verify the # body came from this system. Set it for any deployment whose webhook leaves the # host. The verification recipe is in RUNBOOK.md. WEBHOOK_SIGNING_SECRET= # Allow webhook URLs that resolve to loopback / private / link-local addresses. # Off by default because an operator-supplied URL would otherwise let the server # SSRF into its own network or a cloud metadata endpoint. Turn on only for a # deliberate in-VPC receiver such as a SIEM. WEBHOOK_ALLOW_INTERNAL=false # Transport tuning. Note the interaction: the dispatcher wraps an entire channel # send, retries included, in NOTIFY_SEND_TIMEOUT_SECONDS. With the defaults below # the webhook worst case (8 + 1 + 8 + 2 + 8 = 27s) cannot complete inside the 10s # ceiling, so against a hanging receiver roughly one attempt elapses. Raise the # ceiling or lower the per-attempt timeout if you need all three attempts. #NOTIFY_SEND_TIMEOUT_SECONDS=10 #NOTIFY_MAX_CONCURRENT_DELIVERIES=8 #WEBHOOK_TIMEOUT_SECONDS=8.0 #WEBHOOK_MAX_RETRIES=2 #WEBHOOK_RETRY_BACKOFF_SECONDS=1.0 # Periodic report and the clustering poller. #NOTIFY_REPORT_CHECK_INTERVAL_SECONDS=60 #NOTIFY_REPORT_TOP_ALERTS=10 #NOTIFY_CONTRACT_ANOMALY_POLL_SECONDS=60 #NOTIFY_CONTRACT_ANOMALY_MAX_ALERTS_PER_TICK=50 #NOTIFY_DEDUP_RETENTION_DAYS=30 # Alert grouping. For attack classes where repeat findings at one script are a # single situation (`large_datum` only today), deliver one alert per script per # window instead of one per transaction. Sized against the incident that # motivated it: one mainnet script produced 106 Critical alerts in 67 minutes. # Recall is unaffected: escalation to a higher band always breaks through # immediately, suppression expires with the window, and every finding is still # recorded and visible in the dashboard regardless. 0 disables grouping. # See docs/ALERTING.md#per-group-one-alert-per-script-per-window. #NOTIFY_GROUP_WINDOW_MINUTES=60 # Failed-delivery retry (the scorer path's dead letter). A scorer alert whose # every channel failed is recorded and re-attempted with exponential backoff # (base doubling per attempt, capped near an hour) until it delivers or the # attempt budget is spent; the budget's default spans roughly two hours of # receiver outage, after which the row is kept, marked abandoned, and logged # at ERROR. See docs/ALERTING.md#the-scorer-path-retries-through-a-dead-letter. #NOTIFY_RETRY_CHECK_INTERVAL_SECONDS=60 #NOTIFY_RETRY_BACKOFF_SECONDS=60.0 #NOTIFY_RETRY_MAX_ATTEMPTS=8 #NOTIFY_RETRY_MAX_PER_TICK=20 # ── Logging ─────────────────────────────────────────────────────────────────── LOG_LEVEL=INFO # ── Reverse proxy / client IP ───────────────────────────────────────────────── # Honour forwarded headers for client IPs (rate limiting, audit logs). Only # enable behind a reverse proxy or tunnel; the parser takes the right-most # untrusted hop and ignores headers from peers outside TRUSTED_PROXY_CIDRS. # Whatever proxy fronts the app MUST strip or overwrite the client-supplied # X-Forwarded-For and CF-Connecting-IP headers, otherwise clients can forge # their reported IP. TRUSTED_PROXY_ENABLED=true TRUSTED_PROXY_HOPS=1 # Optional single-value client-IP header set authoritatively by the edge. # Set to CF-Connecting-IP ONLY when Cloudflare fronts the app: behind any # other proxy that does not strip the header, a client-supplied value is # trusted absolutely (rate-limit bucket rotation, victim-IP bucket # pollution, forged audit IPs). Empty (the default) uses the append-safe # right-most X-Forwarded-For hop instead. TRUSTED_PROXY_CLIENT_IP_HEADER= # Direct peers allowed to set forwarded headers. The default (loopback plus # the RFC1918 ranges Docker bridge networks use) is broad so the compose # bridge gateway works out of the box; narrow it to the actual proxy # address in production, e.g. TRUSTED_PROXY_CIDRS=172.18.0.1/32. #TRUSTED_PROXY_CIDRS=127.0.0.1/32,::1/128,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16 # ── CORS ────────────────────────────────────────────────────────────────────── # Comma-separated dashboard origin(s), e.g. https://tms.example.com. # '*' is dev-only: the app refuses to start with '*' (or empty) when API # keys are configured, unless TMS_ALLOW_DEV_MODE=1. CORS_ALLOW_ORIGINS= # ── API docs ────────────────────────────────────────────────────────────────── # /docs, /redoc, /openapi.json are always on in dev mode; set true to expose # them on a keyed deployment. TMS_API_DOCS_ENABLED=false # ── Container memory limits (docker compose) ──────────────────────────────── # Single-box deployments: bound each service so one cannot OOM-starve the rest. #CLICKHOUSE_MEM_LIMIT=4g #POSTGRES_MEM_LIMIT=1g #APP_MEM_LIMIT=2g # ── Bundled Kupo index (docker compose --profile ingestion) ────────────────── # These two configure the Kupo CONTAINER, not the app, and they must live here # in the shared .env (or the shell): docker compose resolves ${...} in the # compose file from the shell and the top-level .env only, so a per-network # .env. is injected into containers but never seen by interpolation. # The app-side Kupo setting is KUPO_URL, which does belong in .env.. # # KUPO_SINCE bounds the one-time index cost: 'origin' indexes the full chain # (needed for an OLD contract's complete history) or a 'slot.header_hash' point # to index only from there forward. Compose defaults to origin, which on mainnet # means hundreds of GB and days of sync: set an explicit recent point there # unless you genuinely need complete history. The start point is fixed once the # DB is built; going deeper later means wiping the volume and re-indexing. #KUPO_SINCE=origin # '*' = index every address, so any onboarded contract is answerable on demand. # A single narrower address or credential pattern also works; multiple distinct # patterns cannot be expressed through this one env var. # Write it literally as '*' in this file. Do NOT `export KUPO_MATCH=*` in a # shell: the shell glob-expands it to the filenames in your working directory. # Quote it if you must set it from a shell (export KUPO_MATCH='*'). #KUPO_MATCH=* # ── Audit log retention ────────────────────────────────────────────────────── # 0 = keep forever. Audit rows are the alert-suppression accountability # record: prefer archiving the table over short retention. AUDIT_LOG_RETENTION_DAYS=0 # ── Clustering sidecar (optional, --profile clustering) ────────────────────── # Zero-config locally. To EXPOSE the sidecar on a network, lock it down: # 1. CLUSTERING_API_KEY : shared key; the host proxy forwards it as # X-API-Key and the sidecar requires it. # 2. CLUSTERING_REQUIRE_AUTH=1 : sidecar refuses to start unless API_KEY and # MODEL_SIGNING_KEYS are both set. # 3. MODEL_SIGNING_KEYS : HMAC key(s) for stored model blobs (a tampered # unsigned blob is pickle = code execution). # Leave all empty for a local/demo run (sidecar binds to loopback in compose). #CLUSTERING_API_KEY= #CLUSTERING_REQUIRE_AUTH=1 #MODEL_SIGNING_KEYS= #CLUSTERING_CORS_ORIGINS= # # Chain source for the sidecar. Default host_ch: read each watched contract's txs # from the host's tms_analytics (auto-fed as the node ingests). Set to blockfrost # to instead pull an arbitrary address's history on demand over HTTP (no local # index / extra disk; the host_ch auto-feed is off in this mode and downloaded txs # land in the sidecar's own tms_clustering tables). Blockfrost mode REQUIRES # BLOCKFROST_PROJECT_ID (a mainnet/preprod/preview project id from blockfrost.io); # the network is taken from CARDANO_NETWORK. RPS/burst/page-size are optional # tuning (free-tier defaults: 10 rps, burst 500, 100 rows/page). #CLUSTERING_CHAIN_SOURCE=blockfrost #BLOCKFROST_PROJECT_ID= #BLOCKFROST_MAX_RPS=10 #BLOCKFROST_BURST=500 #BLOCKFROST_PAGE_SIZE=100 # # Optional pre-deployment HISTORY backfill for the host_ch sidecar. The host # syncs tip-forward, so a watched contract's older activity is not in the host # tables; with a history source set, every onboarded contract automatically # backfills up to CLUSTERING_HISTORY_MAX_TXS (per-contract override via the # onboarding form, clamped to the CEILING) strictly below a rollback-safe # boundary. Values: # blockfrost: downloads into the sidecar's own tables (reads become a # host+local union). Requires BLOCKFROST_PROJECT_ID. # kupo: triggers the host's own POST /api/v1/backfill (full-fidelity # rows in the HOST tables). Requires CLUSTERING_HOST_API_KEY (a # host API key) and the host's KUPO_URL. NOTE: kupo-backfilled # history is also scored by the host DETECTION engine and can # fire immediate alerts for old transactions on onboarding; # accepted, documented behavior of this opt-in flavor. # These are root-.env interpolations (like CLUSTERING_CHAIN_SOURCE): a # per-network .env. file does NOT reach them. #CLUSTERING_HISTORY_SOURCE= #CLUSTERING_HISTORY_MAX_TXS=500 #CLUSTERING_HISTORY_MAX_TXS_CEILING=5000 # Skip the backfill when the host already has >= N rows for the target (its # recent sample is a sufficient fit baseline). 0 = off (always top up). #CLUSTERING_HISTORY_MIN_HOST_TXS=0 # Per-contract "latest N to cluster on": the engine fits/scores/counts a contract # over its most recent N transactions (host tip-forward data first, topped up # from the history source when the host holds fewer). CLUSTERING_DEFAULT_TARGET_TXS # is the N a contract gets when the operator names none on onboarding; # CLUSTERING_MIN_TARGET_TXS is the recall floor a named N is clamped up to (below # it the outlier baseline is too thin); both are capped by CLUSTERING_WINDOW_TXS # (the window/fit-memory ceiling, default 50000). #CLUSTERING_DEFAULT_TARGET_TXS=5000 #CLUSTERING_MIN_TARGET_TXS=200 #CLUSTERING_HOST_API_URL=http://app:8000 #CLUSTERING_HOST_API_KEY= # Per-request ceiling on the sidecar->host trigger/status round trip (kupo # flavor; the backfill itself runs host-side in the background). #CLUSTERING_HOST_API_TIMEOUT_SECONDS=30