# Server # # --- Open source / your deployment --- # Example URLs in comments sometimes use **chat-echo.com** as the maintainers' **reference deployment**. # For your own instance, set `VITE_*`, `CORS_ORIGIN`, `ECHO_APP_PUBLIC_URL`, and related variables to **your** hosts. # # --- Vite / `frontend` (important for `bun run prod` and `vite preview`) --- # Do not set `NODE_ENV=production` or `NODE_ENV=PRODUCTION` inside any file Vite loads from the # frontend package (`clients/web/.env`, `clients/web/.env.*`, `clients/web/.env.local`). Vite will warn # and ignores it for mode selection. Production for the API is set by root `prod:serve` via # `cross-env NODE_ENV=production` on `bun run start`. If you symlink `clients/web/.env` → this root # file, keep `NODE_ENV` out of the symlinked file and use `server/backend/.env` for backend-only flags, # or rely on the prod script as above. # # --- Instance policy (optional JSON for self-host tuning) --- # Copy echo.instance.example.json → echo.instance.json. Env vars override file values. # Docs: docs/operations/instance-policy.md · validate: bun run instance-policy:validate # ECHO_INSTANCE_POLICY_PATH=echo.instance.json # ECHO_INSTANCE_POLICY_WATCH=1 # PORT=3000 HOST=0.0.0.0 LOG_LEVEL=info # TLS terminates at Caddy and traffic to Node is HTTP: set true so `ENFORCE_HTTPS` (on by # default in production) trusts `X-Forwarded-Proto` / forwarded scheme. Required for e.g. # chat-echo.com → proxy → Echo. Never set on a host where clients can reach the API directly # without a proxy that strips forged `X-Forwarded-*` headers. # ECHO_TRUST_PROXY=true # Trusted reverse-proxy hops (Cloudflare → Caddy → Node = 2). Default 1. # ECHO_TRUST_PROXY_HOPS=1 # When your edge proxy emits CSP + HSTS on every response (including `/api/`) and you must avoid # duplicate headers, set true. Unusual for Caddy split-`handle` configs — see # docs/operations/http-observatory-headers.md. # ECHO_EDGE_SECURITY_HEADERS=true # LAN + voice from another PC on the same Wi‑Fi: `bun run dev:lan` (writes `.env.lan` + `livekit.runtime.yaml`; no hand-edited IPs). # Pin the address if auto-detect picks the wrong NIC: ECHO_DEV_LAN_IP=192.168.1.50 # Windows inbound rules (once, elevated): bun run lan:firewall # Undo LAN overrides: `bun run lan:clear` then `bun run db:up` (before `bun run prod`, clear `.env.lan`). # Comma-separated if the SPA is served from more than one origin (Vite + preview, etc.). CORS_ORIGIN=http://localhost:8080,http://localhost:4173 # Backend storage (single source of truth) # - memory: dev-only mockdb (no Postgres) # - postgres: persisted auth + Echo data (requires DATABASE_URL) # ECHO_BACKEND_STORAGE=memory # ECHO_BACKEND_STORAGE=postgres # Database (required when ECHO_BACKEND_STORAGE=postgres) # DATABASE_URL=postgresql://user:password@localhost:5432/echo # Max clients in the shared pg.Pool (default 10, clamp 1–100). Raise under concurrent history/search; # keep (API processes × ECHO_PG_POOL_MAX) under Postgres max_connections. # ECHO_PG_POOL_MAX=10 # Legacy (deprecated): USE_MOCK_DB is no longer a selector. Prefer ECHO_BACKEND_STORAGE. # USE_MOCK_DB=true # Docker Postgres (repo root: `bun run db:up`). Compose substitutes these from `.env`: # POSTGRES_USER=echo # POSTGRES_PASSWORD=echo_dev # POSTGRES_DB=echo # POSTGRES_PORT=5432 # DATABASE_URL=postgresql://echo:echo_dev@localhost:5432/echo # Compose project for fixed-name containers (`echo-postgres`, …). Unset = auto-detect from # a running `echo-postgres` container, else `echo`. Set explicitly when multiple clones share one host. # COMPOSE_PROJECT_NAME=echo # NATS JetStream (optional - for Socket.IO adapter, presence, messaging) # NATS_URL=nats://localhost:4222 # PWA: Chrome “Install app” needs HTTPS in production (localhost is exempt). Serve the built SPA with TLS; # ensure `manifest.webmanifest` is not blocked and gets a JSON-compatible Content-Type from your CDN. # Frontend — `VITE_*` is merged from monorepo root `.env` and `clients/web/.env` (see clients/web/vite.config.ts). # You can keep a single root `.env` for backend + frontend. # # --- Local dev (optional — in .env at monorepo root or clients/web/) --- # Leave unset in local dev: REST uses the Vite origin (e.g. http://localhost:8080) with /api proxied # to the backend. Socket.IO connects directly to http://localhost:3000 (see clients/web/src/config.ts). # VITE_API_URL=http://localhost:3000 # VITE_SOCKET_IO_URL=http://localhost:3000 # # --- Production / native release (bake HTTPS API + Socket.IO; pair both vars) --- # VITE_API_URL=https://chat-echo.com # VITE_SOCKET_IO_URL=https://chat-echo.com # When UI is on Vite (:8080), Socket.IO defaults to http://:3000; override port if Echo is not on 3000: # VITE_DEV_ECHO_PORT=3000 # Mock-only UI without a backend: skip Socket.IO (fewer console errors, better back/forward cache). # VITE_DISABLE_SOCKET=true # Native/desktop only: show Echo’s screen-share quality modal before capture. Browsers use the OS picker only. # VITE_SCREEN_SHARE_CONFIG_MODAL=true # Full backend + real DB: set ECHO_BACKEND_STORAGE=postgres and DATABASE_URL (see backend config). # # Verbose VC trace in the browser console — grep `[Echo:VC:TRACE]`. Or: # localStorage.setItem('echo_vc_verbose','1') # VITE_ECHO_VC_VERBOSE=true # # Session diagnostics (dev): backend artifacts under `.diagnostics/sessions/`; see docs/operations/session-diagnostics.md # ECHO_SESSION_DIAGNOSTICS=true # # Optional: seed a dev user + server over HTTP (backend must be running): # bun run seed:dev # # Official Echo community server — new accounts are auto-joined on signup (Postgres Echo store). # Set ECHO_OFFICIAL_SERVER_ID to the server snowflake id, or leave unset and use vanity (default `echo`). # ECHO_OFFICIAL_SERVER_ID= # ECHO_OFFICIAL_SERVER_VANITY=echo # Auth secrets. JWT_SECRET signs access tokens, MFA-pending tokens, and OAuth state HMACs. # Required in production — the server refuses blank/short/default values. # Generate with: bun -e "console.log(require('crypto').randomBytes(48).toString('base64url'))" # JWT_SECRET= # JWT_EXPIRES_IN=15m # BCRYPT_SALT_ROUNDS=12 # REFRESH_TOKEN_TTL_DAYS=30 # Redis session store. In NODE_ENV=production the server requires REDIS_URL by default # (set ECHO_REQUIRE_REDIS_IN_PRODUCTION=false only for deliberate single-process deployments). # `bun run prod:serve` / `prod:smoke` set these to false for the backend process only (single-host smoke): # ECHO_REQUIRE_REDIS_IN_PRODUCTION, ECHO_REQUIRE_METRICS_SCRAPE_TOKEN_IN_PRODUCTION, # ECHO_REQUIRE_MEDIA_URL_HARDENING_IN_PRODUCTION, ECHO_REQUIRE_GUEST_BINDING_SECRET_IN_PRODUCTION. # `prod:smoke` also sets ECHO_ALLOW_EMBEDDED_VIDEO_HLS=true so ffmpeg can stay in-process. # Real deployments should set REDIS_URL, ECHO_METRICS_SCRAPE_TOKEN, ECHO_GUEST_BINDING_SECRET, # ECHO_VIDEO_HLS_WORKER=standalone (+ worker process), and media URL hardening instead of those overrides. # REDIS_URL=redis://localhost:6379 # When REDIS_URL is set, global + Echo/auth HTTP rate-limit scopes share Redis (cluster-wide). # Route-local limiters may still be in-memory until migrated. # ECHO_REQUIRE_REDIS_IN_PRODUCTION= # unset in prod = default true (Redis required) # In-RAM server permission aggregate fold (warm folds hit Postgres zero times). ON by default; # set false to fall back to the direct-DB fold. # ECHO_PERM_AGGREGATE_CACHE=false # --- Required in NODE_ENV=production (startup fails if missing / invalid) --- # Guest binding cookie HMAC (independent of JWT_SECRET). Unset in dev derives from JWT_SECRET. # ECHO_GUEST_BINDING_SECRET=<32+ random chars, not JWT_SECRET> # ECHO_REQUIRE_GUEST_BINDING_SECRET_IN_PRODUCTION= # unset in prod = default true (secret required) # CORS_ORIGIN=https://your.app.origin # explicit; never * or empty # ECHO_METRICS_SCRAPE_TOKEN=<32+ random chars> # GET /api/v1/metrics requires Authorization: Bearer # ECHO_REQUIRE_METRICS_SCRAPE_TOKEN_IN_PRODUCTION= # unset in prod = default true # Optional: in-memory auth/upload network diagnostics for operators / automation # (e.g. Cursor agent curl against the public API). Off by default. # In NODE_ENV=production, enabling requires a strong token (same length rules as metrics scrape). # ECHO_AGENT_NETWORK_DIAG_ENABLED=true # ECHO_AGENT_NETWORK_DIAG_TOKEN=<32+ random chars> # GET /api/v1/agent/network-diagnostics # ECHO_MEDIA_URL_REQUIRE_HTTPS=true # and/or set ECHO_MEDIA_URL_ALLOWED_HOSTS (comma-separated hosts) # ECHO_REQUIRE_MEDIA_URL_HARDENING_IN_PRODUCTION= # unset in prod = default true # ECHO_MEDIA_URL_ALLOWED_HOSTS=cdn.example.com,r2.example.com # WebAuthn passkeys (login, registration, account management) are always enabled. # Requires ECHO_BACKEND_STORAGE=postgres (credentials stored in auth_webauthn_credentials). # RP ID and origin are derived from ECHO_APP_PUBLIC_URL automatically. # The API returns 503 gracefully if Postgres is not configured. # Guest onboarding (auto-mint on first visit, POST /api/v1/auth/guest). Off by default. # Guest mint / auto-guest (off by default). Enable only if you want Slack-style guests: # ECHO_GUEST_ACCOUNTS_ENABLED=1 # Frontend build (must match backend): VITE_ECHO_GUEST_ACCOUNTS_ENABLED=1 # ECHO_INSTANCE_BANS_ENABLED=1 # Legacy Bearer auth (Authorization: Bearer ). Enabled by default for dev. # Disable in production unless API clients depend on it — Bearer tokens cannot be # revoked before expiry, unlike cookie sessions. # AUTH_LEGACY_BEARER=false # Session-bound bearer tokens for iOS native shell (cross-origin API; Keychain refresh token). # Session-bound bearer tokens for native Apple clients (revocable server-side). # AUTH_NATIVE_BEARER=true # Sockets: require JWT on every connection (always on when NODE_ENV=production). # AUTH_REQUIRE_SOCKET_TOKEN=true # Presence TTL sweep (offline if no heartbeat/set within N minutes). Interval ms; 0 = disable sweep. # ECHO_PRESENCE_STALE_MINUTES=5 # ECHO_PRESENCE_SWEEP_MS=60000 # Socket message rate limits (per user per channel + burst window) # ECHO_SOCKET_MSG_PER_MINUTE=60 # ECHO_SOCKET_BURST_MAX=20 # ECHO_SOCKET_BURST_WINDOW_MS=2000 # ECHO_SOCKET_MAX_EVENTS_PER_SEC=80 # ECHO_MESSAGE_IDEMPOTENCY_MINUTES=10 # Echo public snowflake ids (ADR 002). Each backend writer needs a unique worker id 0–31. # SNOWFLAKE_WORKER_ID=0 # SNOWFLAKE_DATACENTER_ID=1 # After cutover is stable: drop legacy (channel_id, created_at) message indexes (see docs/operations/runbooks/snowflake-cutover.md). # ECHO_DROP_LEGACY_MESSAGE_TIMELINE_INDEXES=false # Optional partial index (channel_id, author_id, id) if you dropped the author+created_at index. # ECHO_CREATE_MESSAGE_AUTHOR_ID_INDEX=false # S3-compatible presigned uploads (Echo). Optional ECHO_S3_ENDPOINT for Cloudflare R2. # ECHO_S3_BUCKET= # ECHO_S3_REGION=auto # ECHO_S3_ACCESS_KEY= # ECHO_S3_SECRET_KEY= # ECHO_S3_ENDPOINT= # Public read URL for browsers (no trailing slash). R2: Dashboard → bucket → Settings → Public Development URL # (e.g. https://pub-xxxxxxxx.r2.dev). Presign PUT still uses ECHO_S3_ENDPOINT; stored/displayed URLs use this. # ECHO_S3_PUBLIC_BASE_URL=https://pub-xxxxxxxx.r2.dev # When true, presign `publicUrl` uses same-origin GET /api/v1/echo/uploads/s3/… (session cookie) instead of # ECHO_S3_PUBLIC_BASE_URL so GIF decode / canvas can read bytes without R2 GET CORS. The SPA also rewrites # existing *.r2.dev/echo/… URLs to that path. Default false (direct R2 reads; then R2 CORS must allow GET for your SPA origin). # ECHO_S3_PUBLIC_READ_THROUGH_API=true # Media CDN sidecar (signed GET delivery). When enabled, presign `publicUrl` uses canonical # `ECHO_MEDIA_CDN_BASE_URL/v1/o/{storageKey}` (unsigned in messages; clients call POST /media/sign). # ECHO_MEDIA_CDN_ENABLED=true # ECHO_MEDIA_CDN_BASE_URL=https://media.echo.example # ECHO_MEDIA_CDN_SIGNING_SECRET= # >= 32 chars; required in production when CDN enabled # ECHO_MEDIA_CDN_INTERNAL_URL=http://127.0.0.1:3010 # ECHO_MEDIA_CDN_PORT=3010 # ECHO_MEDIA_CDN_HOST=127.0.0.1 # ECHO_MEDIA_CDN_READ_TTL_MS=3600000 # ECHO_MEDIA_CDN_PUBLIC_READ_TTL_MS=604800000 # Frontend build (optional): VITE_MEDIA_CDN_BASE_URL=https://media.echo.example # If S3 is unset, uploads fall back to disk under ECHO_LOCAL_UPLOAD_DIR (default: data/echo-local-uploads). Set ECHO_LOCAL_UPLOADS=false to disable and keep presign 503 until S3 is configured. # Watch Together VC uploads always use local disk when ECHO_LOCAL_UPLOAD_DIR is set, even if S3 is configured. # ECHO_LOCAL_UPLOAD_DIR=/absolute/path/to/echo-local-uploads # Required in production when local uploads are enabled (independent of JWT_SECRET; >= 32 chars): # LOCAL_UPLOAD_TOKEN_SECRET= # ECHO_LOCAL_UPLOADS= # Max raw body size for disk-backed PUT /api/v1/echo/uploads/local/put (default 256 MiB). # Tier caps above this still get presign metadata, but local mode cannot accept huge single PUT bodies without streaming. # ECHO_LOCAL_UPLOAD_MAX_BYTES=268435456 # Optional CSAM / integrity image scan on upload register (default off). # ECHO_CSAM_IMAGE_SCAN_MODE=off # When mode=on, scanner errors fail closed by default (block upload). Opt out: # ECHO_CSAM_FAIL_CLOSED=false # ECHO_CSAM_SHA256_BLOCKLIST_PATH= # ECHO_CSAM_EXTERNAL_SCAN_CMD= # ECHO_CSAM_EXTERNAL_SCAN_TIMEOUT_MS=120000 # Custom emoji CDN (cross-guild rendering in messages) # ECHO_EMOJI_PUBLIC_BASE_URL=https://api.echo.example # optional; defaults to ECHO_API_PUBLIC_URL # ECHO_EMOJI_CDN_BASE_URL=https://pub-xxxxxxxx.r2.dev # optional; defaults to ECHO_S3_PUBLIC_BASE_URL # ECHO_EMOJI_PUBLISH_TO_CDN=true # copy uploads to echo/public-emojis/{id}.{ext} # Chat video HLS packaging (background ffmpeg after upload). Upload registration wakes the worker immediately; interval is the fallback poll. # embedded = drain inside API (default local/dev). standalone = API enqueues only; run bun run worker:video-hls (ffmpeg on worker host). # Production requires standalone unless ECHO_ALLOW_EMBEDDED_VIDEO_HLS=true (single-process smoke only). # ECHO_VIDEO_HLS_WORKER=embedded # ECHO_ALLOW_EMBEDDED_VIDEO_HLS=false # Omit TipTap contentJson from socket message / dm:activity fan-out (default true). Ack/REST/push stay full. # ECHO_SOCKET_MESSAGE_SLIM=true # ECHO_VIDEO_OPTIMIZE_MS=15000 # ECHO_VIDEO_HLS_MAX_DURATION_S=600 # ECHO_VIDEO_HLS_MAX_INPUT_BYTES=125829120 # ECHO_VIDEO_HLS_MAX_PIXELS=8294400 # ECHO_FFMPEG_THREADS=2 # ECHO_VIDEO_HLS_TIMEOUT_MS=900000 # ECHO_VIDEO_HLS_MAX_ATTEMPTS=3 # FFMPEG_PATH=ffmpeg # FFPROBE_PATH=ffprobe # # Subscription tier (Postgres): column auth_users.echo_plan = free | plus | black (default free). No billing webhooks yet. # See docs/plans/echo-plan-limits-status.md # # --- Presigned PUT + CORS (S3 / Cloudflare R2) --- # The SPA uploads with XMLHttpRequest PUT to the presigned URL (cross-origin). The bucket must # answer OPTIONS with Access-Control-Allow-Origin for your site or the browser blocks the upload # ("No 'Access-Control-Allow-Origin' header"). Echo cannot fix this from Node — configure the bucket. # R2: Dashboard → R2 → your bucket → Settings → CORS policy. Example JSON (edit origins): # [ # { # "AllowedOrigins": ["https://chat-echo.com", "http://localhost:8080"], # "AllowedMethods": ["GET", "PUT", "HEAD"], # "AllowedHeaders": ["*"], # "ExposeHeaders": ["ETag", "x-amz-request-id"], # "MaxAgeSeconds": 3600 # } # ] # Public read URLs (ECHO_S3_PUBLIC_BASE_URL) are separate from the S3 API host; CORS applies to the # API endpoint used for PUT. Allow Content-Type (Echo sends it on the PUT). # For **GET** on the public R2 host: if you load GIFs with `fetch`/canvas (Echo’s limited GIF playback), # the browser also needs Access-Control-Allow-Origin on GET for your SPA origin — or enable # ECHO_S3_PUBLIC_READ_THROUGH_API above and skip R2 GET CORS. # Email verification (Postgres auth). Without ECHO_SMTP_HOST, verification links are logged only (see backend logs). # Link in email points at ECHO_API_PUBLIC_URL + /api/v1/auth/verify-email?token=... # After success, browser redirects to ECHO_APP_PUBLIC_URL + /login?emailVerified=1 # # Local dev with Mailpit (included in docker-compose as service `mailpit`): # bun run db:up # Open http://localhost:8025 to read outbound mail. SMTP: 127.0.0.1:1025, no TLS, no auth. # ECHO_API_PUBLIC_URL=http://localhost:3000 # Optional extra pepper for SHA-256 of channel incoming webhook tokens (defaults to JWT_SECRET when unset). # ECHO_CHANNEL_WEBHOOK_TOKEN_PEPPER= # ECHO_APP_PUBLIC_URL=http://localhost:8080 # ECHO_SMTP_HOST=127.0.0.1 # ECHO_SMTP_PORT=1025 # ECHO_SMTP_SECURE=false # ECHO_SMTP_USER= # ECHO_SMTP_PASSWORD= # ECHO_EMAIL_FROM=Echo # Optional absolute URL for the logo in HTML emails (defaults to {ECHO_APP_PUBLIC_URL}/icons/pwa-192.png). # ECHO_EMAIL_LOGO_URL=https://your-cdn.example.com/echo-email-logo.png # Inbox "sender avatar" in Gmail etc. is not set by HTML; use BIMI (verified mark) or your provider's brand tools. # # Production / real SMTP (e.g. port 587 STARTTLS: ECHO_SMTP_SECURE=false; port 465 SSL: ECHO_SMTP_SECURE=true): # ECHO_SMTP_HOST=smtp.example.com # ECHO_SMTP_PORT=587 # ECHO_SMTP_USER=... # ECHO_SMTP_PASSWORD=... # ECHO_EMAIL_FROM=Echo # From address for in-app bug report emails to ECHO_SUPPORT_EMAIL (verify in Resend). # ECHO_BUG_REPORT_EMAIL_FROM=Echo Bugs # Inbox for automated client boot-stall / load-hang alerts from the web app watcher. # ECHO_BUGS_EMAIL=bugs@chat-echo.com # Weekly digest of all recorded Prometheus metrics, emailed to ECHO_METRICS_DIGEST_EMAIL # (defaults to ECHO_BUGS_EMAIL). Set the interval to 0 to disable; default 7 days. # Requires ECHO_SMTP_HOST and postgres storage; otherwise the send is logged/skipped. # ECHO_METRICS_DIGEST_INTERVAL_MS=604800000 # ECHO_METRICS_DIGEST_EMAIL=bugs@chat-echo.com # ECHO_EMAIL_VERIFICATION_TOKEN_HOURS=48 # ECHO_EMAIL_VERIFICATION_RESEND_COOLDOWN_SECONDS=60 # Marketing site origin (app-echo.net); must be listed in CORS_ORIGIN for support form POSTs. # ECHO_MARKETING_PUBLIC_URL=https://app-echo.net # # --- Resend (https://resend.com) — SMTP matches Echo’s nodemailer transport in server/backend/src/services/email/sendMail.ts --- # Create an API key in the Resend dashboard; set ECHO_SMTP_PASSWORD to that key only on the server (never commit it). # Verify your sending domain in Resend; ECHO_EMAIL_FROM must use an address on that domain (or Resend’s onboarding domain). # Option A — port 465 (implicit TLS), Resend’s common recommendation: # ECHO_SMTP_HOST=smtp.resend.com # ECHO_SMTP_PORT=465 # ECHO_SMTP_SECURE=true # ECHO_SMTP_USER=resend # ECHO_SMTP_PASSWORD= # Option B — port 587 (STARTTLS): # ECHO_SMTP_HOST=smtp.resend.com # ECHO_SMTP_PORT=587 # ECHO_SMTP_SECURE=false # ECHO_SMTP_USER=resend # ECHO_SMTP_PASSWORD= # ECHO_EMAIL_FROM=Echo # SMS verification (Telnyx, Postgres). Without ECHO_TELNYX_API_KEY, OTPs are logged only (see backend logs). # Production: set ECHO_SMS_OTP_PEPPER to a long random secret whenever ECHO_TELNYX_API_KEY is set. # ECHO_TELNYX_API_KEY= # ECHO_TELNYX_FROM_NUMBER=+15551234567 # ECHO_SMS_OTP_PEPPER= # ECHO_SMS_DEFAULT_REGION=US # ECHO_SMS_OTP_TTL_MINUTES=10 # ECHO_SMS_OTP_LENGTH=6 # ECHO_SMS_OTP_RESEND_COOLDOWN_SECONDS=60 # ECHO_SMS_OTP_MAX_ATTEMPTS=5 # ECHO_SMS_SEND_PER_IP_PER_HOUR=20 # ECHO_SMS_SEND_PER_USER_PER_DAY=15 # ECHO_SMS_SEND_PER_PHONE_PER_DAY=8 # TOTP / authenticator-app 2FA (Postgres auth). Secrets are encrypted at rest; required in production when DATABASE_URL is set. # 32-byte key: 64 hex characters, or standard base64 encoding exactly 32 raw bytes. # ECHO_2FA_ENCRYPTION_KEY= # Optional separate pepper for recovery-code hashes (defaults to ECHO_SMS_OTP_PEPPER when set, else a dev default). # ECHO_2FA_RECOVERY_PEPPER= # Issuer label in authenticator apps (otpauth URI). # ECHO_TOTP_ISSUER=Echo # Short-lived JWT after correct password when TOTP is enabled (e.g. 5m). # ECHO_MFA_PENDING_JWT_EXPIRES_IN=5m # Number of one-time backup codes issued on enroll (5–20). # ECHO_2FA_RECOVERY_CODE_COUNT=10 # Rate limit POST /api/v1/auth/login/mfa per IP per 15 minutes. # ECHO_MFA_LOGIN_MAX_PER_IP_PER_15MIN=60 # Account-based login protection (Redis-backed when REDIS_URL is set; in-memory fallback). # ECHO_LOGIN_MAX_PASSWORD_FAILURES=10 # ECHO_LOGIN_MAX_MFA_FAILURES=10 # ECHO_LOGIN_LOCKOUT_DURATION_MS=900000 # ECHO_LOGIN_BACKOFF_BASE_MS=1000 # ECHO_LOGIN_BACKOFF_AFTER_FAILURES=5 # LiveKit VC (Layer 1). Started with `bun run db:up` alongside Postgres. # For other laptops + voice, prefer `bun run dev:lan` — it sets LIVEKIT_PUBLIC_URL / ICE / TURN via generated `.env.lan` (see scripts/sync-lan-dev.mjs). # LIVEKIT_API_KEY and LIVEKIT_API_SECRET must match the keys in server/ops/infra/livekit/livekit.yaml (Compose defaults: devkey / dev-only-livekit-secret-change-me-0000000000). # LIVEKIT_PUBLIC_URL is the browser-reachable WebSocket URL (NOT the Docker-internal hostname). Overridden for LAN by `lan:sync`. # Webhooks: LiveKit must POST to a public HTTPS URL reachable from the SFU (not localhost in cloud). # LIVEKIT_API_KEY=devkey # LIVEKIT_API_SECRET=dev-only-livekit-secret-change-me-0000000000 # LIVEKIT_PUBLIC_URL=ws://127.0.0.1:7880 # Join JWT lifetime in seconds (60–3600). Default 300. Guild auto-reconnect re-mints via livekit-session; SDK Reconnecting uses the same token until expiry. # LIVEKIT_JOIN_TOKEN_TTL_SEC=120 # # --- Production (LiveKit) --- # See docs/operations/livekit-production.md. Example SFU config: server/ops/infra/livekit/livekit.production.example.yaml # LIVEKIT_PUBLIC_URL must be wss:// in NODE_ENV=production when LiveKit env vars are set (ws:// is rejected at startup). # LIVEKIT_API_KEY= # LIVEKIT_API_SECRET= # LIVEKIT_PUBLIC_URL=wss://livekit.example.com # Webhook URL on Echo (configure this exact URL in LiveKit server webhook.urls): https://chat-echo.com/api/v1/hooks/livekit # # Dev TURN (docker-compose `coturn` + rtc.turn_servers in livekit.yaml): static user echo / echo_turn_dev_secret on UDP 3478. # Open host UDP 57000–57100 for media (see docs/infra/livekit-turn.md; `bun run lan:firewall` adds the rule). # Optional: forward LiveKit active_speakers_changed to Socket.IO as voice_active_speakers (default off; client uses ActiveSpeakersChanged). # LIVEKIT_EMIT_ACTIVE_SPEAKERS_WEBHOOK=false # # TURN username/password are NOT read from .env by LiveKit YAML — copy values into server/ops/infra/livekit/livekit.yaml and docker-compose coturn --user= when rotating. # Compose coturn reads ECHO_TURN_CREDENTIAL (user:password; default echo:echo_turn_dev_secret). Set it for any non-local host # and keep it equal to livekit yaml rtc.turn_servers. ECHO_RTC_BIND restricts the host bind address of LiveKit/coturn ports. # ECHO_TURN_CREDENTIAL=echo: # ECHO_RTC_BIND=0.0.0.0 # LIVEKIT_TURN_USERNAME=echo # LIVEKIT_TURN_CREDENTIAL=echo_turn_dev_secret # # Voice E2EE: DM / group-DM call E2EE is OFF by default (transport DTLS-SRTP only) until # Element Call–style media-key join e2e is validated — see docs/operations/voice-e2ee-livekit.md. # Guild server/voice/stage channels still opt in per channel (`voice_e2ee_enabled`). # ECHO_DM_VOICE_E2EE_ENABLED=true # opt in (requires matching VITE_DM_VOICE_E2EE=1 on the web client) # Client build flags (clients/web/.env): VITE_DM_VOICE_E2EE=0 (default) # # Verbose VC / LiveKit trace in API logs — grep `[Echo:VC:TRACE]`. Defaults on when NODE_ENV !== production # unless you set ECHO_VC_VERBOSE_LOG=false; in production default off unless ECHO_VC_VERBOSE_LOG=true. # ECHO_VC_VERBOSE_LOG=true # Discord export bot (optional). Same root `.env` is loaded via `bun run bot:export -- `. # See bot/README.md (privileged intents, invite bot to the server). # DISCORD_BOT_TOKEN= # DISCORD_GUILD_ID= # EXPORT_BASE_DIR=bot/exports # Echo import reads bundles from ECHO_DISCORD_EXPORTS_ROOT on the API (default: backend resolves to bot/exports). `bun run dev` sets the bot’s EXPORT_BASE_DIR to that path when unset so exports land where import expects them. # ECHO_DISCORD_EXPORTS_ROOT= # Discord CDN → Echo rehost for imported/synced messages (Discord URLs stored first for instant display; background job mirrors to local/S3 and pushes message:media_mirror). Requires ECHO_LOCAL_UPLOAD_DIR or S3 upload config. 0 disables. # ECHO_DISCORD_IMPORT_MEDIA_MIRROR_MS=2000 # ECHO_DISCORD_IMPORT_MEDIA_MIRROR_BATCH_SIZE=2 # Maximum bytes buffered per imported Discord attachment during mirroring (default 512 MiB). # ECHO_DISCORD_IMPORT_MEDIA_MIRROR_MAX_BYTES=536870912 # ECHO_DISCORD_IMPORT_MEDIA_MIRROR_MAX_QUEUE_DEPTH=10000 # ECHO_DISCORD_IMPORT_MEDIA_MIRROR_KICK_DEBOUNCE_MS=50 # ECHO_WORKSPACE_EVENT_OUTBOX_MS=1000 # Max new Discord import metadata pipelines per user per UTC day in production (first-time metadata import per server). Default 3 when unset in production; 0 = unlimited (or no limit in non-production unless set). # ECHO_DISCORD_IMPORT_MAX_PER_USER_PER_DAY= # After each successful export, the bot POSTs here so the web UI can resume import. The bot also GETs …/export-pending # (same URL prefix) to export guilds you already added before the bot was online. Use the same secret on API + bot. # `bun run dev` waits for the API on 127.0.0.1:$PORT before starting the bot (override host with ECHO_DEV_WAIT_API_HOST). # `bun run dev` defaults URL (127.0.0.1:$PORT) + a shared dev secret when these are unset; production must set real values. # ECHO_DISCORD_BOT_WEBHOOK_URL=http://localhost:3000/api/v1/hooks/discord-bot/export-ready # ECHO_DISCORD_BOT_WEBHOOK_SECRET= # Production POST hooks require HMAC (x-echo-signature-ts + x-echo-signature); the export bot signs automatically in NODE_ENV=production. # The export bot also GETs/POSTs Discord bridge hooks using this secret: /api/v1/hooks/discord-bridge/allowlist and …/inbound (Discord → Echo live sync). # Optional: milliseconds between export-pending polls (default 45000). Set 0 to poll only once at startup. # ECHO_DISCORD_BOT_POLL_MS=45000 # Uptime embeds (e!cho uptime / ECHO_UPTIME_AUTO_CHANNEL_IDS). On healthy→down, optionally spawn recovery: # ECHO_UPTIME_URL=https://chat-echo.com/api/v1/health # ECHO_UPTIME_POLL_MS=300000 # ECHO_RECOVERY_WATCHDOG_ENABLED=1 # ECHO_WATCHDOG_NOTIFY_EMAIL=support@chat-echo.com # Discord user OAuth (Settings → Discord). All three are required on the Echo API process. DISCORD_BOT_TOKEN does not enable linking, # but when set the API uses it to detect if the export bot is already in a server so “Import from Discord” can skip opening the install tab. # Use KEY=value lines only (no leading spaces on keys). Do not paste uncommented prose between variables — it breaks .env parsing. # Redirect URL must match Discord Developer Portal → OAuth2 → Redirects exactly (scheme included: http ≠ https). # Local dev (Vite proxy default): use http://localhost:8080/api/v1/auth/discord/callback so OAuth cookies are set on the SPA origin used by /api calls. # Local dev with direct API origin (e.g. VITE_API_URL=http://localhost:3000): use http://localhost:3000/api/v1/auth/discord/callback. # HTTPS note: if localhost is plain HTTP, using https://localhost/... in Discord + .env causes browser ERR_SSL_PROTOCOL_ERROR after callback. # Production (or TLS in front of the API): use https:///api/v1/auth/discord/callback in both places. # DISCORD_OAUTH_CLIENT_ID= # DISCORD_OAUTH_CLIENT_SECRET= # server-only; never expose to the frontend; rotate if leaked # DISCORD_OAUTH_REDIRECT_URI=http://localhost:8080/api/v1/auth/discord/callback # Space-separated OAuth scopes. Default (if unset) matches Echo’s built-in list (see backend `DEFAULT_DISCORD_OAUTH_SCOPES`): identify + identify.premium, email, guilds, connections, openid, relationships.read, sdk.social_layer, sdk.social_layer_presence. # Discord lists many more scopes in Developer Portal → OAuth2 → URL Generator (bot, rpc.*, webhook.incoming, etc.); only add what your product needs — bot/RPC scopes are not the same flow as user linking. # Some scopes need Discord approval; the linking flow degrades if an endpoint is unavailable. # DISCORD_OAUTH_SCOPES=connections email guilds identify identify.premium openid relationships.read sdk.social_layer sdk.social_layer_presence # Required in production when linking is enabled (64 hex or base64 32 bytes). Separate from ECHO_2FA_ENCRYPTION_KEY. # ECHO_DISCORD_TOKEN_ENCRYPTION_KEY= # Shared with the export bot so it can notify Echo when a guild export finishes (see ECHO_DISCORD_BOT_WEBHOOK_* near DISCORD_BOT_TOKEN). # ECHO_DISCORD_BOT_WEBHOOK_SECRET= # After OAuth, browser redirects to ECHO_APP_PUBLIC_URL?discord_linked=1 (see docs/integrations/discord-user-oauth.md). # Honcho conversational memory — future Echo AI / personalization (https://honcho.dev). # Off by default. Requires both HONCHO_ENABLED=true and a key from https://app.honcho.dev/api-keys # HONCHO_ENABLED=false # HONCHO_API_KEY= # HONCHO_WORKSPACE_ID=ECHO # HONCHO_ENVIRONMENT=production # HONCHO_BASE_URL=https://api.honcho.dev # HONCHO_RATE_LIMIT_PER_MINUTE=20 # Link unfurl in-process cache (success vs failure TTL; see server/backend/src/services/linkUnfurl/unfurlCache.ts) # ECHO_UNFURL_CACHE_SUCCESS_TTL_MS=300000 # ECHO_UNFURL_CACHE_FAILURE_TTL_MS=30000 # Composer GIF popout — Giphy primary, Klipy fallback (https://docs.klipy.com/) # GIPHY_API_KEY= # KLIPY_API_KEY= # Serper.dev — composer GIF popout → Images (Google Images via https://serper.dev) # SERPER_API_KEY= # SERPER_DEFAULT_QUERY=photography # SERPER_IMAGE_NUM=20 # SERPER_IMAGE_MAX_PAGE=4 # L1 hot cache + Postgres L2 (90-day freshness, async merge refresh). # SERPER_CACHE_TTL_MS=600000 # SERPER_CACHE_MAX_ENTRIES=200 # SERPER_CACHE_REFRESH_DAYS=90 # SERPER_CACHE_MAX_RESULTS=60 # SERPER_RATE_LIMIT_PER_MINUTE=10 # Per-IP cold upstream calls per UTC day (0 = unlimited). # SERPER_UPSTREAM_MAX_PER_DAY_PER_IP=180 # Global Serper upstream caps (0 = unlimited): all instances, cold + stale refresh. # SERPER_GLOBAL_MAX_PER_DAY=500 # SERPER_GLOBAL_MAX_PER_MONTH=10000 # Stop async stale refresh after N consecutive failures (0 = backoff tiers only). # SERPER_REFRESH_FAILURE_MAX_COUNT=0 # Per-user daily search credits: free 10, Echo+ 250, Echo Black 500 (see contracts/echoPlanLimits.ts). # Google user OAuth (see docs/infra/integrations/google-user-oauth.md). GOOGLE_OAUTH_CLIENT_SECRET is server-only. # GOOGLE_OAUTH_CLIENT_ID= # GOOGLE_OAUTH_CLIENT_SECRET= # GOOGLE_OAUTH_REDIRECT_URI=http://localhost:8080/api/v1/auth/google/callback # # YouTube channel link + stage RTMP live (see docs/infra/integrations/youtube-live-streaming.md) # YOUTUBE_OAUTH_REDIRECT_URI=http://localhost:8080/api/v1/auth/youtube/callback # LIVEKIT_EGRESS_ENABLED=true # stage → YouTube RTMP; defaults to on when LIVEKIT_* is set (set false to disable) # LIVEKIT_STAGE_EGRESS_TEMPLATE_URL=https://your-echo-host/egress/stage-program/ # optional; default is ECHO_APP_PUBLIC_URL + /egress/stage-program/ # # --- Compile-time integration kill switches (contracts/integrationKillSwitches.ts) --- # Google SSO, YouTube surfaces, and channel webhooks ship disabled in source by default. # Flip GOOGLE_INTEGRATION_ENABLED, YOUTUBE_INTEGRATION_ENABLED, YOUTUBE_CHAT_EMBEDS_ENABLED, # or CHANNEL_WEBHOOKS_ENABLED to true/false in that file and rebuild. # # --- Game server (authoritative VC mini-games; bun run dev starts on :3060) --- # GAME_SERVER_PUBLIC_URL=http://127.0.0.1:3060 # GAME_SERVER_JWT_SECRET= # production: strong random secret (dev falls back to JWT_SECRET) # GAME_SERVER_CORS_ORIGIN=https://your-echo-host # production: comma-separated allowlist (required) # GAME_SERVER_FORWARD_SECRET= # HMAC for backend→game-server forwarded hooks # GAME_SERVER_TTT_ENGINE=typescript # opt-in: holyc # GAME_SERVER_TTT_HOLYC_COMMAND= # absolute supervised HolyC engine executable # GAME_SERVER_TTT_HOLYC_ARGS=[] # JSON argv array; never a shell command string # GAME_SERVER_TTT_HOLYC_TIMEOUT_MS=1000 # # --- Web Push (background browser notifications) --- # Generate a keypair: bun -e "console.log(require('web-push').generateVAPIDKeys())" # All three must be set to enable push; clients fetch the public key from the API and # auto re-subscribe when it changes (so rotation is safe). # VAPID_PUBLIC_KEY= # VAPID_PRIVATE_KEY= # VAPID_SUBJECT=mailto:push@your-echo-host # # --- Apple Push Notification service (native iOS + macOS) --- # One APNs token-auth key can serve both Echo bundle IDs when enabled in Apple Developer. # Preserve PEM line breaks or encode them as literal \n characters. # APNS_KEY_ID= # APNS_TEAM_ID= # APNS_KEY_P8=-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY----- # APNS_BUNDLE_ID=com.echo.ios # APNS_ENVIRONMENT=sandbox # fallback only; native clients submit their signed environment # # --- Postgres Backup (Option B - self-hosted) --- # These are SERVER-ONLY variables. Do NOT commit them to the repo. # Store in /etc/echo/backup-upload.env on your VPS (see server/ops/infra/systemd/echo-backup.env.example). # # Separate backup bucket (not the same as ECHO_S3_* media bucket). # Write-only credentials for daily automated uploads (no read/list/delete). # ECHO_BACKUP_S3_BUCKET=echo-backups # ECHO_BACKUP_S3_ENDPOINT=https://your-account.r2.cloudflarestorage.com # ECHO_BACKUP_S3_ACCESS_KEY=your_write_only_key # ECHO_BACKUP_S3_SECRET_KEY=your_write_only_secret # ECHO_BACKUP_S3_PREFIX=postgres # optional, default: postgres # ECHO_BACKUP_TMP_DIR=/var/lib/echo/backups/tmp # optional, default: system temp # Exyno chat.exyno.de password-unlock alerts (server→server via shared token) # ECHO_EXYNO_CHAT_UNLOCK_EMAIL=reachbypass@gmail.com # ECHO_EXYNO_CHAT_UNLOCK_TOKEN=change-me-shared-with-exynos