Files
Xichang(Seacen) Zhao 4157f5b26e fix(selfhost): pass MULTICA_WECOM_DEFAULT_LOCALE into the backend container (#9691)
router.go reads MULTICA_WECOM_DEFAULT_LOCALE to pick the language the bot
writes its own copy in for readers it cannot look a language up for (a group
chat, anyone not linked to a Multica account). Neither .env.example nor
docker-compose.selfhost.yml named it, so a self-hosted operator had no way to
find the knob, and setting it on the host did nothing: compose forwards only
what its environment: block lists, and the deployment silently stayed on
zh-Hans.

Add it to both, and a test that parses router.go for every MULTICA_WECOM_*
literal it reads and fails when either file is missing one.
2026-10-30 13:13:02 +08:00

753 lines
42 KiB
Bash

# Database
POSTGRES_DB=multica
POSTGRES_USER=multica
POSTGRES_PASSWORD=multica
POSTGRES_PORT=5432
DATABASE_URL=postgres://multica:multica@localhost:5432/multica?sslmode=disable
# Optional pgxpool tuning. Defaults are 25 / 5 per pod and are usually fine.
# You can also set pool_max_conns / pool_min_conns as query params on
# DATABASE_URL; env vars below take precedence over URL params.
# DATABASE_MAX_CONNS=25
# DATABASE_MIN_CONNS=5
# Search transactions default to a 64 MB PostgreSQL work_mem ceiling per plan
# node. Set 1-64 to lower it for memory-constrained databases, or 0 to keep the
# database/session default. Values above 64 or invalid values fall back to 64.
# DATABASE_SEARCH_WORK_MEM_MB=64
# Optional PostgreSQL read replica. The API keeps all existing reads on primary
# until a business explicitly opts into eventual-consistency routing in code.
# Every new replica connection is required to be read-only and is recycled
# after 5m by default so a promoted node is revalidated promptly. Replica
# failures fall back to primary and open a short passive circuit; no background
# database probe is added. Replica reads may return stale data without a bound.
# DATABASE_REPLICA_URL=postgres://multica:multica@replica:5432/multica?sslmode=require&target_session_attrs=read-only
# Replica pool sizing is independent so enabling it does not clone the primary
# pool's 25 / 5 connection budget per API process.
# DATABASE_REPLICA_MAX_CONNS=10
# DATABASE_REPLICA_MIN_CONNS=0
# Retry transient database failures for up to this duration across the image
# entrypoint's migration and API startup phases. Set to 0 for a single
# fail-fast attempt per phase. Each connection attempt is separately bounded
# so an unreachable host cannot consume the whole budget. A native pgx
# connect_timeout in DATABASE_URL, PGCONNECT_TIMEOUT, or a service file takes
# precedence over MULTICA_DATABASE_CONNECT_TIMEOUT for compatibility.
# MULTICA_DATABASE_STARTUP_TIMEOUT=3m
# MULTICA_DATABASE_CONNECT_TIMEOUT=5s
# Server
# APP_ENV gates production safety checks. Docker self-host pins APP_ENV to
# "production" by default. Local dev can leave it unset.
# See SELF_HOSTING.md for the full login setup.
APP_ENV=
# Optional local/testing shortcut. Empty by default, so there is no fixed
# verification code. Without RESEND_API_KEY, generated codes print to stdout.
# If you need deterministic local automation, set a 6-digit value such as
# 888888 and keep APP_ENV non-production. This is ignored when APP_ENV=production.
MULTICA_DEV_VERIFICATION_CODE=
# Backend port. This is the one to edit: it is both the port the backend process
# listens on for a local/bare run, and the host port the Docker Compose
# self-host stack publishes. In Compose the container always listens on 8080
# internally, so changing this never needs a rebuild.
PORT=8080
# Optional aliases that override PORT for the backend, in this order. Leave them
# commented unless you specifically need the host port to differ from the port
# the process listens on. An uncommented value here wins over PORT everywhere
# (Makefile, scripts/local-env.sh, docker-compose.selfhost.yml), and because
# this file is read by make it also overrides the same variable coming from your
# shell environment.
# BACKEND_PORT=8080
# API_PORT=8080
# SERVER_PORT=8080
FRONTEND_PORT=3000
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_PORT.
# Set explicitly only when serving frontend on a different origin/domain.
FRONTEND_ORIGIN=http://localhost:${FRONTEND_PORT}
# Prometheus metrics are disabled by default. When enabled, bind to loopback
# unless you protect the listener with private networking, allowlists, or
# proxy auth. Do not expose this endpoint through the public app/API ingress.
# HTTP request metrics start accumulating only when this listener is enabled.
# METRICS_ADDR=127.0.0.1:9090
# Optional container-loopback maintenance API. Use /app/maintenance via container exec.
# Never publish this port. See docs/maintenance-jobs.md. Empty disables it.
MAINTENANCE_PORT=
# REQUIRED. Signing key for auth JWTs, daemon JWTs, and avatar/attachment
# URL capability HMACs. Generate a strong random value:
# macOS/Linux: openssl rand -hex 32
# Windows (PowerShell): $b = New-Object byte[] 32; $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create(); $rng.GetBytes($b); $rng.Dispose(); -join ($b | ForEach-Object { $_.ToString('x2') })
# Leave empty ONLY for local non-Docker development (APP_ENV unset) — the
# server then uses a dev-only default and logs a warning. docker-compose
# refuses to start without a value, and production refuses to boot on the
# dev default or any known placeholder.
JWT_SECRET=
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when the daemon reaches the API through a different URL.
# MULTICA_SERVER_URL=ws://localhost:8080/ws
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when the app's public URL differs from local frontend.
MULTICA_APP_URL=${FRONTEND_ORIGIN}
# Public URL the API is reachable at from the open internet (no trailing
# slash). Used to mint absolute webhook URLs for autopilot webhook
# triggers and to show correct daemon setup commands in the web UI. Leave
# unset behind a same-origin reverse proxy or for plain localhost dev —
# the frontend will compose the URL from window.origin + webhook_path in
# that case. Headers are intentionally not used to derive this value, to
# avoid Host / X-Forwarded-Host spoofing when a self-hosted reverse proxy
# is not hardened.
MULTICA_PUBLIC_URL=
# Optional API URL used only in self-host daemon setup commands. Set this
# when the daemon reaches the API through a different URL than the public
# webhook URL above. Falls back to MULTICA_PUBLIC_URL, then MULTICA_APP_URL.
MULTICA_DAEMON_SERVER_URL=
# Optional delay after SIGTERM/SIGINT before the existing graceful shutdown
# begins. Accepts a non-negative Go duration such as 300s or 5m. Empty or 0
# preserves the default behavior and starts shutdown immediately. The platform
# termination grace period must cover this hold plus the later shutdown work.
MULTICA_SHUTDOWN_HOLD_DURATION=
# How long an offline runtime may reconnect before its in-flight tasks fail.
# Accepts a positive Go duration. The default is 3h; values below 150s are
# clamped to the runtime heartbeat freshness window.
MULTICA_RUNTIME_RECONNECT_GRACE=3h
# Comma-separated CIDR list of reverse proxies whose X-Forwarded-For /
# X-Real-IP headers the per-IP webhook rate limiter is allowed to trust.
# Empty (the default) means "trust no headers" — the limiter uses
# r.RemoteAddr only, which is the safe shape when the backend is
# exposed directly. Set this when running behind nginx/Caddy/Cloudflare:
# e.g. "127.0.0.1/32" for a same-host reverse proxy, or the CDN's
# announced ranges for cloud deployments.
MULTICA_TRUSTED_PROXIES=
# Server-side assist layer (MUL-4238). These variables govern the LLM calls the
# API process makes on its own — every one of them goes through server/pkg/llm,
# and that package stays inert until an API key or a base URL is set here.
# Access is internal-only: the generic OpenAI-compatible passthrough endpoints
# were removed in MUL-4309.
#
# This is NOT the deployment's only path from chat content to a model. Running
# an agent is a separate one: the daemon executes an AI coding tool with that
# tool's own credentials, and does not forward these MULTICA_LLM_* settings to
# it. (The task-scoped Multica connection variables the agent needs — server
# URL, task token — are injected by the daemon separately.) Nothing below
# governs that path.
#
# Once configured, the layer has exactly two consumers, and this is what each
# one sends upstream:
# - Chat auto-titling: the first user message of a new chat session, verbatim
# and uncapped. Attachments are never included.
# - Chat follow-up questions (the suggestion buttons under an agent reply):
# the tail of the conversation — up to 6 messages, with the reply being
# answered capped at 3000 characters (2000 head + 1000 tail) and each
# older message at 800.
#
# Leaving BOTH the API key and the base URL empty is a fully supported
# configuration, and the right one when policy forbids this layer sending chat
# content anywhere: it is disabled, makes zero upstream requests, and neither
# feature above sends anything. Nothing breaks — chats keep the title the
# client derives from the first message (30 characters, no model involved) and
# the follow-up question buttons simply never appear. server/pkg/llm asserts
# both the zero-request behaviour and the two-consumer list above, so neither
# can drift from this comment silently.
# - API key for the upstream (OpenAI or any OpenAI-compatible gateway).
MULTICA_LLM_API_KEY=
# - Base URL of the upstream. Leave unset to use OpenAI's default
# (https://api.openai.com/v1). Set this to point at a gateway or a
# self-hosted, OpenAI-compatible model server.
MULTICA_LLM_BASE_URL=
# - Default model used when a request omits `model`. When unset, falls
# back to a small built-in default (gpt-6-luna).
MULTICA_LLM_DEFAULT_MODEL=
# - Transport retry budget for every upstream LLM request. This is the one
# canonical source; the server logs the effective policy at startup
# ("llm retry policy").
# unset use the built-in default of 2 retries
# 0 disable retries — exactly one request per call
# 1..5 at most N retries, so at most N+1 requests per call
# Anything else (negative, non-numeric, above 5) fails the boot rather than
# being corrected silently. The ceiling is a latency budget: backoff starts
# at 0.5s and doubles to an 8s cap, so 6 retries would sleep ~21s while the
# internal callers of this layer time out after 8s and 20s.
# Retried: connection failures, HTTP 408/409/429 and 5xx. Not retried: every
# other 4xx. This budget does not cover a stream that breaks mid-response,
# and it is unrelated to task-level retries.
MULTICA_LLM_MAX_RETRIES=
# - Ask an OpenAI-compatible gateway to turn the model's reasoning
# ("thinking") off by adding chat_template_kwargs: {"enable_thinking":
# false} to every request body this layer sends. Some gateways and
# GLM/Qwen-style model routes honor the field; there it removes the
# thinking pass that would otherwise dominate the latency budget of the
# assist calls above. Standard OpenAI endpoints reject unknown body
# fields, so only enable this when the upstream accepts it. GPT-5.6-family
# models and GPT-6 Luna get reasoning_effort=none on the follow-up questions
# request. Chat auto-titling sends no reasoning field; this switch affects
# it only when the configured upstream accepts chat_template_kwargs.
# Accepted values are true/false and 1/0
# (case-insensitive); anything else fails the boot. The startup log
# ("llm retry policy") reports the effective state.
MULTICA_LLM_DISABLE_THINKING=
MULTICA_DAEMON_CONFIG=
MULTICA_WORKSPACE_ID=
MULTICA_DAEMON_ID=
MULTICA_DAEMON_DEVICE_NAME=
MULTICA_DAEMON_POLL_INTERVAL=30s
# Independent healthy-WebSocket safety poll upper bound; does not replace the ordinary poll interval.
MULTICA_DAEMON_WS_CLAIM_POLL_INTERVAL=3m
MULTICA_DAEMON_HEARTBEAT_INTERVAL=15s
MULTICA_CODEX_PATH=codex
MULTICA_CODEX_MODEL=
MULTICA_CODEX_WORKDIR=
# Feature flags
# Optional path to a YAML file declaring feature flag rules. When unset,
# every flag falls through to the caller's default, which lets the server
# boot before any flag config is authored. When set, the file is read once
# at startup and a parse / IO error fails fast — same loud-failure shape as
# DATABASE_URL or JWT_SECRET misconfig. See server/pkg/featureflag for the
# full schema; the minimum example is:
#
# billing_new_invoice_email:
# default: true
# checkout_algo:
# default: false
# variant: experiment-v2
# percent: { percent: 25, by: user_id }
#
# Individual flags can also be overridden without touching the YAML by
# setting FF_<FLAG_KEY> env vars (FF_BILLING_NEW_INVOICE_EMAIL=false, 25%,
# or any variant string). The env override beats the YAML, which is the
# Ops kill-switch path — flip a flag without redeploying by restarting the
# process with the env var set.
MULTICA_FEATURE_FLAGS_FILE=
# Web/Desktop keep a local search index per workspace and sync it through
# /api/search-index. On by default; turning it off makes clients stop syncing
# and search through the server again:
# FF_LOCAL_SEARCH_INDEX=off
# Workspace subscription Checkout/Portal/entitlement proxying is fail-closed;
# enable it only after multica-cloud billing.subscriptions is configured:
# FF_BILLING_WORKSPACE_SUBSCRIPTIONS=true
#
# Point multica-cloud's three subscription return URLs at this app's
# /billing/return page (checkout_success_url, checkout_cancel_url,
# portal_return_url). Cloud appends the workspace it authorized plus a result
# marker, and that page resolves them to the workspace's Billing settings.
# Configuring a workspace-specific URL instead would strand every other
# workspace on someone else's page.
# Managed multica-cloud connection. Leave the URL empty for self-hosting. When
# connected, Fleet, Billing, entitlement policy, and strict pre-purchased seat
# capacity all use this service. Authentication and network isolation for these
# internal routes are owned by the deployment platform.
MULTICA_CLOUD_URL=
# Self-host image channel
# Default stable release channel. Pin to an exact release like v0.2.4 if you
# want to stay on a specific version. If the selected tag has not been
# published to GHCR yet, use make selfhost-build / the build override instead.
MULTICA_IMAGE_TAG=latest
MULTICA_BACKEND_IMAGE=ghcr.io/multica-ai/multica-backend
MULTICA_WEB_IMAGE=ghcr.io/multica-ai/multica-web
# Email
# Two delivery options - only one needs to be configured:
#
# Option A: Resend (SaaS, recommended for cloud deployments)
# Set RESEND_API_KEY to a key from resend.com and verify your sending domain there.
# For local/dev use, leave RESEND_API_KEY empty - codes print to stdout. To
# accept a fixed local code, also set MULTICA_DEV_VERIFICATION_CODE above
# (ignored when APP_ENV=production).
RESEND_API_KEY=
RESEND_FROM_EMAIL=[email protected]
#
# Option B: SMTP relay (for self-hosted / on-premise deployments)
# Takes priority over Resend when SMTP_HOST is set.
# Supports unauthenticated relay (leave SMTP_USERNAME empty) and authenticated SMTP.
# SMTP_FROM_EMAIL is the envelope/header sender for SMTP. If unset, SMTP
# falls back to RESEND_FROM_EMAIL for backwards compatibility.
# Set SMTP_TLS_INSECURE=true only for private CA or self-signed certificates.
# SMTP_TLS controls the TLS mode:
# - unset / "starttls" (default): plaintext connect, upgrade via STARTTLS.
# - "implicit" (aliases: "smtps", "ssl"): TLS handshake on connect (SMTPS).
# Required by providers that only offer port 465 and do not advertise
# STARTTLS (e.g. Aliyun enterprise mail). Auto-enabled when SMTP_PORT=465
# and SMTP_TLS is unset.
# SMTP_EHLO_NAME is the EHLO/HELO name announced to the relay. Defaults to the
# machine hostname; set a real FQDN when a strict relay (e.g. Google Workspace
# smtp-relay.gmail.com) rejects the default and the connection drops as an EOF.
SMTP_HOST=
SMTP_PORT=25
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_FROM_EMAIL=
SMTP_TLS_INSECURE=false
SMTP_TLS=
SMTP_EHLO_NAME=
# Google OAuth
# The web login page reads GOOGLE_CLIENT_ID from /api/config at runtime, so
# changing it only requires restarting the backend / compose stack. No web
# rebuild is needed.
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# Derived by docker-compose.selfhost.yml / local scripts from FRONTEND_ORIGIN.
# Set explicitly only when your OAuth callback URL differs from local frontend.
GOOGLE_REDIRECT_URI=${FRONTEND_ORIGIN}/auth/callback
# WeCom smart-bot ("智能机器人" / aibot) integration.
# MULTICA_WECOM_* drives the bidirectional chat integration (users message
# the smart bot in their WeCom client, an agent replies over the same
# WebSocket long connection).
#
# MULTICA_WECOM_SECRET_KEY 32-byte base64 master key that encrypts each
# installation's smart-bot secret at rest.
# Empty = the wecom integration is disabled
# and the wecom Web-UI endpoints return 503.
# Generate with: openssl rand -base64 32
#
# MULTICA_WECOM_DEFAULT_LOCALE
# Which language the bot writes its OWN copy in
# — binding prompt, "the agent is offline",
# inbox cards — for readers it cannot look a
# language up for: a group chat, and anyone who
# has not linked a Multica account yet. A linked
# person always overrides this with the language
# on their Multica profile, so this is the
# fallback, not a switch that overrides anybody.
# Accepts zh-Hans (or zh) and en. Empty, or
# anything else, = zh-Hans, which is what every
# existing deployment already gets.
# It does NOT touch the agent's answers: those
# come back in whatever language the model
# replied in, the same as today.
# MULTICA_WECOM_MEDIA_ALLOW_CIDRS
# Comma-separated ranges the media fetcher may
# dial even though they look reserved. Empty by
# default, and that is the right value almost
# everywhere: the guard exists because a callback
# hands us a URL somebody else controls, and a
# redirect to a metadata endpoint is one line of
# response away.
# Set it only behind a fake-IP proxy, where every
# public hostname resolves into the proxy's pool
# (commonly 198.18.0.0/15) and WeCom's own image
# host is therefore refused along with everything
# else. Whatever you list here becomes reachable
# by a URL WeCom supplies. Loopback, the private
# ranges and the IPv6 translation prefixes
# (64:ff9b::/96, 64:ff9b:1::/48, 2002::/16 —
# IPv4 addresses in an IPv6 spelling) are refused
# regardless and cannot be opened this way.
# MULTICA_WECOM_TRACE 1 = log every frame this adapter reads and
# writes, so a real-device session can be checked
# against the server afterwards. Empty / anything
# else = off, which is the right state outside a
# debugging session.
# This logs MESSAGE CONTENT: each traced frame
# carries the first 120 runes of the message body
# (runes, not bytes, so Chinese text is not cut
# mid-character). Binding tokens and other
# token=… query parameters are redacted, and the
# smart-bot secret is never read, but user text
# is not — turn it on deliberately and unset it
# when the session ends. The backend logs a
# warning on every boot while it is on. Read at
# boot, so changing it needs a backend restart;
# who can read the resulting logs and how long
# they are kept is your log stack's business, see
# SELF_HOSTING_ADVANCED.md → WeCom frame tracing.
#
# Per-installation credentials (bot_id, secret) are NOT env vars — they
# live in the channel_installation table. Create an installation from the
# WeCom settings tab in the app (Settings → Integrations → WeCom), which
# seals the smart-bot secret with MULTICA_WECOM_SECRET_KEY before storing it.
#
# SINGLE-REPLICA CONSTRAINT: unlike Slack/Lark (stateless HTTP outbound), the
# WeCom smart bot's only outbound path is an in-process WebSocket long
# connection. Agent replies and inbox pushes are delivered only by the backend
# replica that currently holds a given bot's connection lease. If you run more
# than one backend replica, responses produced on a replica that does not hold
# the lease are dropped and the user sees nothing. Until cross-replica outbound
# routing lands, run the WeCom-enabled backend as a SINGLE replica.
MULTICA_WECOM_SECRET_KEY=
MULTICA_WECOM_DEFAULT_LOCALE=
MULTICA_WECOM_MEDIA_ALLOW_CIDRS=
MULTICA_WECOM_TRACE=
# S3 / CloudFront
# S3_BUCKET — bucket NAME only (e.g. "my-bucket"). Do NOT include the
# ".s3.<region>.amazonaws.com" suffix; the server builds the public URL
# from S3_BUCKET + S3_REGION. S3_REGION must match the bucket's real region.
S3_BUCKET=
S3_REGION=us-west-2
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
# AWS_ENDPOINT_URL — optional S3-compatible endpoint (MinIO, RustFS, R2, etc.).
# For internal Docker/VPC hosts such as http://rustfs:9000, leave
# ATTACHMENT_DOWNLOAD_MODE=auto or set proxy explicitly so browsers/CLI do
# not need direct access to the object store.
AWS_ENDPOINT_URL=
# S3_USE_PATH_STYLE — optional S3 addressing mode. Empty preserves the default:
# true when AWS_ENDPOINT_URL is set for MinIO/LocalStack-style endpoints, false
# for AWS S3. Set to false for providers that require virtual-hosted-style URLs.
S3_USE_PATH_STYLE=
ATTACHMENT_DOWNLOAD_MODE=auto
ATTACHMENT_DOWNLOAD_URL_TTL=30m
CLOUDFRONT_KEY_PAIR_ID=
CLOUDFRONT_PRIVATE_KEY_SECRET=multica/cloudfront-signing-key
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_DOMAIN=
# COOKIE_DOMAIN — optional Domain attribute on session + CloudFront cookies.
# Leave empty for single-host deployments (localhost, LAN IP, or a single
# hostname) — session cookies become host-only, which is what the browser
# wants. Set it when the frontend and backend sit on different subdomains of
# one registered domain (e.g. ".example.com") — there it is REQUIRED, not
# optional: without it the frontend cannot read the multica_csrf cookie
# issued by the API host, so every write fails with 403 "CSRF validation
# failed" while reads keep working.
# When you do set it, use the NARROWEST parent domain that still covers both
# hosts (for agent.example.com + api.agent.example.com that is
# ".agent.example.com", not ".example.com"). This value also scopes the
# multica_auth session cookie, and the browser sends it to every host under
# that domain — HttpOnly stops page scripts from reading it, not sibling
# subdomains from receiving it. Only use this layout when every host under
# the chosen domain is operated by the same trusted party; otherwise serve
# the API from the frontend's own origin and leave this empty.
# Do NOT set it to an IP address: RFC 6265 forbids IP literals in the cookie
# Domain attribute and browsers silently drop such cookies.
COOKIE_DOMAIN=
# AUTH_TOKEN_TTL — auth token lifetime. Accepts Go duration strings (e.g.
# "8760h", "720h30m") or plain integer seconds.
# Default: 2592000 (30 days). Self-hosted deployments on trusted networks can
# set a longer value to reduce re-authentication frequency.
# Note: longer TTL = longer exposure window if a cookie is leaked.
#
# Sessions slide: once a session drops below HALF this value, the next
# authenticated request re-issues it with a full TTL again, so an actively
# used session is not logged out on the anniversary of its login. The bound
# this puts on idleness is approximate and one-sided — a session last used
# while it still had more than half its TTL left is NOT extended, so the
# guaranteed survivable idle gap is TTL/2, not TTL. There is no absolute
# cap: an account in continuous use stays signed in indefinitely.
#
# Minimum: 60 seconds. The renewal cadence is derived as TTL/10, and clients
# floor what the server sends at 5 seconds; below a 60-second TTL that floor
# would start deciding the cadence instead of the server, which is how a
# client ends up checking less often than the session can survive. A value
# below the minimum is clamped up to it with a warning at startup, rather
# than silently logging active users out.
# AUTH_TOKEN_TTL=2592000
# Local file storage (fallback when S3_BUCKET is not set)
LOCAL_UPLOAD_DIR=./data/uploads
# Derived by Makefile / local scripts from the backend port.
# Set explicitly only when uploads are served through a different public URL.
# LOCAL_UPLOAD_BASE_URL=http://localhost:8080
# Security
# Comma-separated list of allowed origins for CORS and WebSocket connections.
# Defaults to localhost dev origins when unset.
# Example: CORS_ALLOWED_ORIGINS=https://multica.ai,https://staging.multica.ai
CORS_ALLOWED_ORIGINS=
# ==================== Rate limiting (optional Redis) ====================
# Per-IP fixed-window rate limiter on the public auth endpoints
# (/auth/send-code, /auth/verify-code, /auth/google). Backed by Redis.
# When REDIS_URL is unset the limiter is a no-op (fail-open) and the
# backend logs "auth rate limiting disabled: REDIS_URL not configured" at
# startup. All Redis-backed features share this one Redis configuration,
# including realtime fan-out, channel leases, rate limits, and token caches.
# REDIS_URL=redis://localhost:6379/0
# Cluster deployments support only database 0 and sharded realtime relay mode.
# The server rejects legacy or dual relay mode when this switch is enabled.
# REDIS_CLUSTER_MODE=false
# Common Redis configurations (choose one):
#
# 1. Standalone Redis
# REDIS_URL=redis://:[email protected]:6379/0
# REDIS_CLUSTER_MODE=false
#
# 2. AWS ElastiCache Serverless (single configuration endpoint + required TLS)
# The rediss:// scheme is required to enable TLS.
# REDIS_URL=rediss://:[email protected]:6379/0
# REDIS_CLUSTER_MODE=true
#
# 3. Native multi-node Redis Cluster
# Separate node addresses between @ and / with commas.
# REDIS_URL=redis://:[email protected]:6379,10.0.0.2:6379,10.0.0.3:6379/0
# REDIS_CLUSTER_MODE=true
# Realtime relay requires Redis/Valkey with XTRIM MINID support (Redis 6.2+).
# Relay retention defaults: 8 shards, 2000 entries per shard, 5m replay, and
# exact time trimming after 10m. MAXLEN is the immediate operational pressure
# valve; size it from observed entry bytes, event rate, and the Redis memory
# budget. MAXLEN, trim, and TTL controls apply consistently to sharded,
# legacy, and dual mode; replay grace applies to sharded readers.
# REALTIME_RELAY_STREAM_MAXLEN=2000
# REALTIME_RELAY_REPLAY_GRACE=5m
# REALTIME_RELAY_TRIM_HORIZON=10m
# Stream TTL is a staged, opt-in fallback for volatile-* policies; it evicts a
# whole stream and does not protect a noeviction instance before expiry.
# Safe rollout: first deploy this version everywhere with TTL disabled (the
# default). Then set the flag to true and roll every replica a second time.
# Safe rollback: set the flag to false and update every replica, then wait for
# a complete legacy SCAN cycle (large keyspaces may need several maintenance
# intervals) and verify relay PTTL values are -1 before rolling back the binary.
# Do not roll directly back while TTL remains enabled.
# REALTIME_RELAY_STREAM_TTL_ENABLED=false
# REALTIME_RELAY_STREAM_TTL=15m
# REALTIME_RELAY_TTL_REFRESH_INTERVAL=30s
# REALTIME_RELAY_MAINTENANCE_INTERVAL=1m
# Set to "true" to skip the CLIENT SETNAME handshake on every Redis
# connection. Required for managed Redis providers that block the CLIENT
# command (e.g. GCP Memorystore, AWS ElastiCache with restricted ACLs).
# Default is false (client naming enabled for connection observability).
# REDIS_DISABLE_CLIENT_NAME=true
# ==================== Channel WebSocket leases ====================
# Multi-replica production should use Redis. The channel supervisor gets its
# own client/pool from REDIS_URL and fails closed (does not start connections)
# if Redis, Ping, or the atomic lease scripts are unavailable at startup.
# PostgreSQL remains available for self-hosted single-node use and rollback.
# Switch every API replica together; there is intentionally no dual-lock mode.
# CHANNEL_WS_LEASE_BACKEND=redis
# REQUIRED operational policy for the global Redis configured by REDIS_URL:
# `maxmemory-policy noeviction`. Enable HA plus persistence appropriate to the provider, and
# alert on memory/capacity, evictions (must stay zero), availability, and
# command latency. Silent lease-key eviction can interrupt channel connections.
# Required in Redis mode. Use a stable deployment identifier so environments
# sharing one Redis database never contend on each other's installation IDs.
# Allowed characters: letters, digits, dot, underscore, hyphen.
# CHANNEL_WS_LEASE_NAMESPACE=production
# Lease timing must satisfy: 0 < poll <= renew < TTL.
# CHANNEL_WS_LEASE_TTL=180s
# CHANNEL_WS_LEASE_RENEW_INTERVAL=60s
# CHANNEL_WS_LEASE_POLL_INTERVAL=30s
# Redis errors retry quickly, but the owner disconnects before its last
# confirmed TTL (minus the safety margin) to prevent split-brain.
# CHANNEL_WS_LEASE_ERROR_RETRY_INTERVAL=5s
# CHANNEL_WS_LEASE_EXPIRY_SAFETY_MARGIN=5s
# The explicit PostgreSQL rollback backend restores correctness, not the Redis
# load reduction: foreign owners still cause one SQL acquire attempt per poll.
# It also uses the timing values above; set TTL=90s, renew=30s, poll=30s if the
# previous PostgreSQL failover profile is required during rollback.
# Max requests per IP per minute. Defaults are 5 for send-code/google
# and 20 for verify-code.
# RATE_LIMIT_AUTH=5
# RATE_LIMIT_AUTH_VERIFY=20
# Sliding-window budgets for workspace invitation creation. The actor gate is
# per inviter, the workspace gate is shared by all admins in a workspace, and
# the recipient gate is shared across workspaces using a SHA-256 email digest.
# Without Redis these gates use process-local memory; with Redis their budgets
# are shared across replicas. Set an individual value to 0 to disable that gate.
# RATE_LIMIT_INVITATION_ACTOR_10M=10
# RATE_LIMIT_INVITATION_WORKSPACE_24H=50
# RATE_LIMIT_INVITATION_RECIPIENT_24H=6
# Comma-separated CIDRs whose X-Forwarded-For the auth limiter is
# allowed to trust. Empty (default) = never trust XFF, only RemoteAddr.
# REQUIRED behind a reverse proxy — otherwise every real user shares
# the proxy IP and the whole deployment lands in one bucket, turning
# /auth/send-code into 5 req/min site-wide. Use e.g. "127.0.0.1/32,::1/128"
# for same-host Caddy/Nginx, or the CDN's published ranges for ALB/CF.
# This is a separate list from MULTICA_TRUSTED_PROXIES above (which
# governs the autopilot webhook limiter).
# RATE_LIMIT_TRUSTED_PROXIES=
# Realtime metrics endpoint (/health/realtime) access control. See MUL-1342.
# When unset, the endpoint only serves direct loopback (127.0.0.1 / ::1)
# callers with no forwarding headers and returns 404 to everything else —
# safe for local dev. Any deployment behind a reverse proxy (Caddy / Nginx
# terminating TLS in front of localhost:8080) MUST set this token, since
# proxied requests look like loopback at the Go layer; with no token, those
# requests are refused with 404. Pass the token as
# `Authorization: Bearer <token>`.
# REALTIME_METRICS_TOKEN=
# GitHub App integration (Settings → GitHub "Connect GitHub")
# Both must be set for the Connect button to enable and for webhooks to be
# accepted; leave empty to disable the integration. See docs/github-integration.
# GITHUB_APP_SLUG is the tail of https://github.com/apps/<slug>.
GITHUB_APP_SLUG=
GITHUB_WEBHOOK_SECRET=
# Optional for webhook-only deployments, but required for PR-card CI /
# mergeability and Settings → Repositories → Choose from GitHub. These
# credentials let the server authenticate as the App, mint short-lived
# installation tokens, and enrich the installation row with the real account
# login immediately.
# GITHUB_APP_ID is the numeric "App ID" shown on the App's settings page.
# GITHUB_APP_PRIVATE_KEY is the full PEM block (including BEGIN/END lines)
# generated under "Private keys" on that same page; preserve newlines.
# In this file that means wrapping the value in double quotes and keeping
# the real line breaks - an unquoted multi-line value stops at line one:
# GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----
# ...
# -----END RSA PRIVATE KEY-----"
GITHUB_APP_ID=
GITHUB_APP_PRIVATE_KEY=
# Self-hosted Git provider integration (Settings → Integrations): Forgejo, Gitea,
# and GitLab. This is a SELF-HOSTED-MULTICA-ONLY feature (not offered on the
# managed cloud). Two settings gate it, and BOTH are required — connect,
# webhook, and rotate all check both:
# 1. MULTICA_VCS_INTEGRATION_ENABLED=true turns the feature on for this
# deployment. Left unset/false the section is hidden entirely (this is how
# the managed cloud keeps it off). The self-host docker-compose sets it.
# 2. MULTICA_VCS_SECRET_KEY — a base64-encoded 32-byte key that encrypts each
# workspace's access token and webhook secret at rest. Generate one with:
# openssl rand -base64 32
# Unlike GitHub there is no App: each workspace connects its own instance URL
# and access token in the UI, and registers the returned webhook URL/secret on
# its repo or org webhook settings.
MULTICA_VCS_INTEGRATION_ENABLED=
MULTICA_VCS_SECRET_KEY=
# Plugins (Settings → Plugins). A plugin's frontend is an artifact its author
# uploads; Multica stores it and serves it into a sandboxed iframe, and an
# installation is bound to one immutable version. Hook endpoints and MCP servers
# stay on the author's own infrastructure. Settings:
# 1. MULTICA_PLUGIN_SECRET_KEY — a base64-encoded 32-byte key that encrypts
# `secret` config values at rest, seals short-lived surface launch URLs,
# and derives per-installation hook signing secrets. Without it, those
# secret/surface/hook features fail closed; `mpi_` Public API token issuance
# remains available and does not require hook signing. Generate one with:
# openssl rand -base64 32
# 2. MULTICA_PLUGIN_SURFACE_ORIGIN — a dedicated browser origin routed to this
# backend (for example https://plugins.example.com). It must differ from
# the app/API origins and carry no app session cookies. The proxy must
# preserve Host. The backend serves
# only /plugin-surfaces/* there; a Host check refuses the same URL on the
# ordinary API origin.
# 3. MULTICA_PLUGIN_DIR — an absolute path to a directory of plugin folders,
# each containing a multica.plugin.json. It is the development publish
# channel: an author iterating on a surface publishes straight from disk
# instead of zipping and uploading after every edit. It still produces an
# ordinary immutable version, so there is no second way for plugin code to
# reach a browser. Leave empty to accept uploads only.
# 4. MULTICA_PLUGIN_API_URL — the complete, versioned Public API base URL
# sent to hook servers (for example https://plugin-api.example.com/v1).
# Route this hostname's /v1/* traffic to the backend. Leave empty to use
# MULTICA_PUBLIC_URL + /v1, which is convenient for local and self-hosted
# deployments that serve the Public API on the ordinary API origin.
MULTICA_PLUGIN_SECRET_KEY=
MULTICA_PLUGIN_SURFACE_ORIGIN=
MULTICA_PLUGIN_API_URL=
MULTICA_PLUGIN_DIR=
# Lark / Feishu bot integration (Settings → Integrations "Bind to Lark")
# Off until MULTICA_LARK_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each Bot's app secret at rest. Leave empty to disable.
# Generate one with: openssl rand -base64 32
MULTICA_LARK_SECRET_KEY=
# Mainland 飞书 and international Lark are auto-detected per installation
# (at QR scan) and served side by side — LEAVE THESE EMPTY for normal use.
# They are optional deployment-wide overrides that force EVERY installation
# onto one host (a proxy, a mock for tests, or a single-cloud staging
# setup); HTTP drives outbound Open Platform API calls, CALLBACK the inbound
# long-conn bootstrap. NOTE: if you previously ran international Lark by
# setting these to https://open.larksuite.com, the server relabels your
# existing installs to region=lark on first boot after upgrade, so you can
# clear these afterwards. See docs/lark-bot-integration.
MULTICA_LARK_HTTP_BASE_URL=
MULTICA_LARK_CALLBACK_BASE_URL=
# Optional fixed HTTP CONNECT proxy URL for Lark/Feishu WebSocket long-conn
# handshakes. Leave empty to use standard HTTP_PROXY / HTTPS_PROXY / NO_PROXY
# environment handling.
MULTICA_LARK_WS_PROXY_URL=
# DingTalk bot integration (Settings → Integrations "Bind to DingTalk")
# Off until MULTICA_DINGTALK_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each Bot's AppSecret at rest. Leave empty to disable.
# Generate one with: openssl rand -base64 32
MULTICA_DINGTALK_SECRET_KEY=
# Slack bot integration (Settings → Integrations → Slack)
# Off until MULTICA_SLACK_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each installation's bot and app tokens at rest. Leave empty
# to disable. Generate one with: openssl rand -base64 32
MULTICA_SLACK_SECRET_KEY=
# Telegram bot integration (Settings → Integrations → Telegram)
# Off until MULTICA_TELEGRAM_SECRET_KEY is set — a base64-encoded 32-byte key
# that encrypts each Bot's token at rest. Leave empty to disable. Keep it
# stable: losing or rotating it makes stored Bot tokens unreadable.
# Generate one with: openssl rand -base64 32
MULTICA_TELEGRAM_SECRET_KEY=
# Frontend
# Leave empty — auto-derived from page origin in browser, set by Makefile for local dev.
# NEXT_PUBLIC_API_URL also feeds the Next.js SSR proxy when explicitly set.
#
# When you do set it, give the backend's ORIGIN only — scheme + host (+ port),
# with NO path: https://api.example.com, not https://api.example.com/api.
# The client appends its own /api, /uploads and /ws prefixes, so a path here is
# doubled: /api/api/... for every request and 404s for every avatar/attachment.
# A trailing /api is stripped defensively, but anything else is kept as-is.
NEXT_PUBLIC_API_URL=
NEXT_PUBLIC_WS_URL=
# Google Analytics (optional) — GA4 Measurement ID, e.g. G-XXXXXXXXXX.
# Leave empty to keep gtag.js off (the default for local dev and self-hosting).
# Read by the Next.js server at runtime, so no web rebuild is needed to toggle it.
GOOGLE_ANALYTICS_ID=
# Remote API (optional) — set to proxy local frontend to a remote backend
# Leave empty to use local backend (localhost:8080)
# REMOTE_API_URL=https://multica-api.copilothub.ai
# ==================== Self-hosting: Control Signups (fixes #930) ====================
# Set to "false" to restrict new accounts to allowlisted or invited users.
# A pending, unexpired workspace invitation also permits emails outside either
# allowlist when ALLOW_SIGNUP=true. Revocation does not remove existing accounts.
ALLOW_SIGNUP=true
# The web UI reads ALLOW_SIGNUP from /api/config at runtime, so toggling this
# only requires restarting the backend / compose stack — not rebuilding web.
# It is not hot-reloaded.
# Optional: Allow emails from these domains without an invitation (comma-separated)
ALLOWED_EMAIL_DOMAINS=
# Optional: Allow these exact email addresses without an invitation (comma-separated)
ALLOWED_EMAILS=
# Set to "true" to disable workspace creation for every caller on this
# instance (#3433). Operators usually leave this unset, bootstrap the
# shared workspace, then flip this to "true" and restart so subsequent
# users join only via invitations and the entire deployment is visible to
# the platform admin. The web UI reads this from /api/config at runtime,
# so toggling requires a backend restart but not a frontend rebuild.
DISABLE_WORKSPACE_CREATION=
# Optional: only these exact email addresses may create workspaces
# (comma-separated, case-insensitive) (#9400). Leave empty to let every
# signed-in user create workspaces. DISABLE_WORKSPACE_CREATION=true still
# blocks everyone, including listed users. Other users can sign in and join
# workspaces they are invited to, but POST /api/workspaces returns 403 for
# them. The UI does not hide the "Create workspace" entry for them; the
# server error is shown instead. Listing a user grants no access to other
# workspaces. Requires a backend restart.
WORKSPACE_CREATION_ALLOWED_EMAILS=
# ==================== Anonymous Self-host Telemetry ====================
# The API server sends one first-party, deployment-level anonymous snapshot per
# UTC day to https://telemetry.multica.ai. It contains the release version,
# bucketed workspace/member/agent/active-daemon counts, and aggregate run counts
# for the previous 24 hours. It never includes names, email addresses, content,
# business IDs, host/device data, prompts, paths, credentials, or error text.
# Set to 1 or true (case-insensitive) and recreate/restart the backend to disable
# both collection and delivery. This is separate from ANALYTICS_DISABLED below.
DO_NOT_TRACK=
# ==================== Analytics (PostHog) ====================
# Product analytics events feed the acquisition → activation → expansion funnel.
# Leave POSTHOG_API_KEY empty for local dev / self-hosted instances; the server
# will run a no-op analytics client and ship nothing.
POSTHOG_API_KEY=
POSTHOG_HOST=https://us.i.posthog.com
# Optional override for the `environment` PostHog event property.
# Defaults from APP_ENV and normalizes to production / staging / dev.
ANALYTICS_ENVIRONMENT=
# Force the no-op client even when POSTHOG_API_KEY is set (CI / opt-out).
ANALYTICS_DISABLED=
# Server-managed Qoder Cloud Agents connections require
# MULTICA_VCS_SECRET_KEY: it encrypts the saved Qoder and GitHub tokens
# and the run journal in the database. Keep it stable across restarts.