Skip to content

Auth

Canonical references for the auth surface — the in-repo files are the source of truth, this page summarises the constraints those files encode.

  • apps/api/src/auth.ts — Better Auth factory: email + password only, argon2id, no SMTP, no 2FA, login throttle 60s / 5, session cookie 24h with sliding refresh < 1h idle.
  • apps/api/src/routes/auth.ts — onboarding gate (single master account), sign-up / session / change-password handlers.
  • apps/api/src/routes/account-settings.tsGET/PATCH /account/settings for the master user's account-global display preferences. Today this carries timezone (a validated IANA zone, default UTC) the web UI applies to every rendered timestamp; the value persists on users.timezone.
  • packages/db/migrations/0007_better_auth.sql — initial Better Auth tables.
  • packages/db/migrations/0087_better_auth_account_issuer.sql — Better Auth 1.7 account identity migration.
  • packages/db/src/schema/better-auth.ts — drizzle schema for the same.
  • apps/api/src/middleware/cors.ts / apps/api/src/routes/ws.ts — the WEB_ORIGIN allowlist (a comma-separated list of exact scheme://host:port origins) gates CORS, Better Auth trustedOrigins (CSRF), and the WebSocket upgrade. Every origin is matched exactly; a * wildcard is rejected at env-parse time because credentialed CORS forbids it. Add a LAN origin to reach the dev server from another device.

Threat model

The system ships without encryption-at-rest. Binance API keys, notifier secrets, and AI-assist provider credentials (ai_provider_config — the Anthropic Console key / OAuth token and any OpenAI-compatible endpoint key, a spend-capable credential) sit plaintext in Postgres; the mitigation is operator-side and twofold: (1) create the Binance API key without the Enable Withdrawals permission — the bot only reads market data and places spot orders, so a leaked key cannot move funds off the exchange — and (2) IP-allowlist the key at the Binance console so a stolen DB dump cannot place orders without also matching the operator's egress IP. The create wizard and the API-key replace form surface both via the shared ApiKeyGuidance component. The REST client also attaches X-MBX-APIKEY only to the calls it signs, so unsigned market-data reads carry no credential past any proxy or log between the worker and Binance. Step-up / TOTP was deliberately dropped because the system runs single-account on a self-hosted VM — adding a second factor without an email channel for recovery would lock the operator out on phone loss with no fallback.

action_logs rows (msg + ctx) are client-readable at every level, so worker writers MUST keep secrets — API keys, signed URLs, raw credentials — out of action_logs msg/ctx. Three surfaces read them: GET /profiles/:id/action-logs (the activity feed, warn+error only), GET /profiles/:id/logs and its /logs/export (any level, no default level narrowing), and GET /profiles/:id/tick-trace, which returns the raw Redis audit entries verbatim. Deep capture widens this further: while armed, the whole audit payload of every tick is copied into ctx unredacted. Nothing filters these — the rule is that nothing credential-equivalent enters an audit payload or an action_logs row in the first place, because a debug view whose job is showing what the tick saw cannot also be the place a secret is scrubbed.

The process log stream is scrubbed; action_logs is not. Because the credentials above sit plaintext in Postgres, the statement that writes one binds it as a parameter — and drizzle raises a failed statement as an error carrying those bind values three times over: the params array, the message its template inlines them into, and the stack that opens with that message. A logger's default error serializer copies all three, so one failed api_keys insert would write a live Binance secret into the process log, where it outlives the request, the process, and any key rotation. Both loggers (apps/api/src/middleware/logger.ts, apps/worker/src/boot/builders/primitives.ts) therefore run pino's serializer and then scrubDrizzleParams from @app/core/logger, which replaces the bind list wherever it appears — at any nesting depth, through aggregateErrors, and in the chained stack a wrapped cause contributes.

Be precise about what that buys, because it is easy to over-read. It is a backstop for one known error shape, the driver's, not a general redactor: it recognises a params value by its sibling query, and it knows where drizzle puts the bind list in a message. The same known-shape backstop also covers exhausted worker jobs: apps/worker/src/queues/queue-set.ts scrubs the DLQ errorMessage and optional stack before the DLQ Queue.add persists the entry, while apps/worker/src/queues/dlq-watcher.ts and apps/worker/src/boot/builders/notifiers.ts publish and notify from that already-scrubbed errorMessage; this does not make the DLQ path a general payload redactor. It does nothing for a credential a caller passes to a log line itself, and it is deliberately not extended to cover that — a scrubber broad enough to catch arbitrary shapes would be a filter nobody could reason about. The action_logs rule above therefore still governs everything else, unchanged and unweakened: nothing credential-equivalent may enter an audit payload or an action_logs row in the first place. Before adding a route that reads or writes a credential, assume the log stream protects you from the driver's error and from nothing else.

Client-IP trust boundary. The login rate-limiter (apps/api/src/middleware/login-rate-limit.ts) and the audit trail (apps/api/src/middleware/audit.ts) derive the client IP from the rightmost X-Forwarded-For hop via the shared clientIp helper, falling back to X-Real-IP then the literal 'unknown'. This trusts exactly one reverse proxy that appends the real client address to the right of the chain (the bundled nginx $proxy_add_x_forwarded_for config does this). The API therefore MUST run behind exactly one such proxy: expose it directly and the leftmost-to-rightmost chain is fully client-controlled, re-opening per-IP throttle bypass and audit-IP spoofing; front it with two or more proxies and the rightmost hop is an internal proxy, collapsing all clients into one bucket. A request with no forwarded headers records 'unknown' (not SQL NULL) in audit_logs.ip.

Live demo (public, no-login, testnet deployment)

LIVE_DEMO (env, read by both the api and the worker; parsed strictly — only 1/true enable it, default off) turns a separate deployment into a public sandbox. It is never the operator's real instance, which always requires login.

  • No login. sessionResolver (apps/api/src/middleware/auth.ts) injects the boot-resolved sole operator id (repo.users.findSingleId, resolved once in di.ts boot) for every request with no Better Auth session. A real session still wins. Zero-user cold start injects nothing, so /onboarding still works. The login screen never appears (it is only shown reactively by the 401 interceptor).
  • Boot refuses a live key. assertLiveDemoInvariant (apps/api/src/di.ts; mirrored in the worker boot) throws when the flag is on and any account is binance_mode='live'. The box can only ever hold testnet keys, so "no sensitive info exposed" holds by construction.
  • Locked routes. requireNotDemo (apps/api/src/middleware/require-not-demo.ts, reads di.env.LIVE_DEMO at request time) returns 403 for API-key management; backup, backup-config, and restore; AI-provider management and testing; notifier configuration and testing; ops-notify; account creation, rename, and delete (rename defaces the box's only account and delete cascades its profiles away until the nightly restore); password and session changes; retention changes; starting a diagnosis run; fee reconciliation; trade-archive backfill; and both backtest-advisor writes (starting a variant, and persisting a manually pasted reply). The advisor is the one locked surface that spends money outside Binance — a variant bills the operator's stored AI-provider credential — so it is disabled outright rather than capped; its reads (GET .../advisor, GET .../advisor/manual/prompt) stay open, so suggestions already saved to a run remain legible in the demo. Fee reconciliation and trade-archive backfill are weighted Binance myTrades pulls whose job ids carry a timestamp, so repeat clicks never dedup and an anonymous visitor could hold the operator's request budget down from a button — the archive one worst, since "Recover all" fires one per symbol at once. Interactive trading remains available against Binance testnet. apps/api/__tests__/routes/live-demo-guard-topology.test.ts checks the exact mounted guard set and independently detects an unguarded sensitive route. apps/api/__tests__/routes/live-demo-guard.test.ts preserves request-level 403 samples.
  • Notifier suppression (worker). Under the flag both notifier fan-outs (createNotifyEvent, createAccountNotifyEvent) are total no-ops — no dispatch, DB gate unread — so a seed snapshot's real webhooks can never leak.
  • Web. The onboarding-status response carries demoMode; the SPA renders a persistent "Live demo" banner and hides every entry point into a locked route. Each navigation destination declares its own answer through a REQUIRED demoHidden field (apps/web/src/shared/lib/demo-visibility.ts), and every nav surface filters through visibleInDemo rather than restating paths — so the compiler rejects a new REGISTRY entry that has not been considered. A link hand-placed in a component sits outside that guarantee and is hidden by an explicit check at its own site. Currently hidden: Settings, backup/restore, account settings, New account, Manage account, the per-profile Notifications section, both "configure API key" call-to-actions, the start-investigation control, Reconcile fees, and every advisor control on the backtest panel (the five variant buttons, "Run it myself", the paste-a-reply panel, and Regenerate) — the panel itself stays, showing saved suggestions plus a note that asking the AI is off because it spends the operator's own AI credit. Because the hidden variant grid was also the only way to switch which saved slot is displayed, the demo renders a read-only switcher in its place whenever more than one slot holds a saved row (showVariant is local state and issues no request), so a newest-first error row cannot bury the good suggestions behind it. A single saved row needs no switcher — it is already the row on screen. Trade-archive backfill is reached from History, which stays visible, so that one is guarded server-side only. The API guard is the enforcement; the nav hide is defence-in-depth, so a hand-typed URL still 403s.

The ops half (testnet golden-snapshot seeding, the separate deployment, the nightly reset that re-restores the snapshot directly rather than via the locked /restore route, and public-endpoint rate limiting) is operator-side. Compose override: deploy/compose/docker-compose.demo.yml.