Ctrl K
ComunidadGuías…

docs/CONFIGURATION.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Configuration

Every setting is read from an environment variable; the `.env` file is gitignored and never
committed. In production the backend refuses to boot with placeholder or weak secrets, with a
datastore URL still carrying an example or default password, with rate limiting switched off, or
with `TRUST_PROXY_HEADERS` left unset. Every problem is reported in one message, naming the
variables and never their values.

Generate the two required secrets before the first run:

```bash
openssl rand -hex 32       # JWT_SECRET
openssl rand -base64 32    # API_KEY_MASTER_KEY (32-byte AES-256 master key)
```

`.env.example` carries the full annotated list; `.env.prod.example` is the production
variant. The tables below group the settings by concern.

## Databases

| Variable | Description | Default |
|---|---|---|
| `POSTGRES_URL` | Async PostgreSQL connection string. Production refuses to boot on this default, on a leftover `CHANGE_ME`, or on a guessable password (one equal to the username, or a compose default like `password`) | `postgresql+asyncpg://maestro:maestro@localhost:5433/maestro` |
| `MONGODB_URL` | MongoDB connection string. Port 27018, not the stock 27017 — a natively-installed MongoDB binds loopback and beats Docker's wildcard bind, so 27017 can silently reach the wrong server. Production refuses to boot on this default or on a leftover `CHANGE_ME` | `mongodb://localhost:27018` |
| `MONGODB_DB_NAME` | MongoDB database name | `maestro` |
| `QDRANT_URL` | Qdrant vector DB address | `http://localhost:6333` |
| `QDRANT_API_KEY` | Qdrant API key (optional for local, and optional in production too — but a leftover `CHANGE_ME` is rejected at boot) | — |

## Security & auth

| Variable | Description | Default |
|---|---|---|
| `JWT_SECRET` | JWT signing secret — random and confidential, min 32 chars in production | — |
| `JWT_ALGORITHM` | JWT signing algorithm | `HS256` |
| `ACCESS_TOKEN_EXPIRE_MINUTES` | Access token lifetime | `30` |
| `REFRESH_TOKEN_EXPIRE_DAYS` | Refresh token lifetime | `7` |
| `REFRESH_COOKIE_SECURE` | `Secure` on the refresh cookie. Unset means "on outside development" — Safari refuses a Secure cookie over `http://localhost`, so a hard `true` would break `npm run dev`. Production refuses to boot with it off. | unset |
| `REFRESH_COOKIE_SAMESITE` | `strict` or `lax`. The only CSRF control on `/auth/refresh` and `/auth/logout`; `none` is rejected at boot. | `strict` |
| `REFRESH_COOKIE_DOMAIN` | Empty = host-only. Naming a domain shares the session cookie with every subdomain; only for an `app.` / `api.` split under one registrable domain. | — |
| `API_KEY_MASTER_KEY` | AES-256-GCM master key for encrypting BYOK keys (32 bytes, base64 or hex) | — |
| `CORS_ORIGINS` | Allowed frontend origins (comma-separated) | `http://localhost:3000` |
| `LLM_SSRF_GUARD_ENABLED` | Validate custom provider endpoints (http(s), credential-free, public addresses only); disable only on a fully self-hosted stack | `true` |

## Rate limiting

| Variable | Description | Default |
|---|---|---|
| `REDIS_URL` | Redis for shared sliding-window buckets; empty falls back to in-process memory (single dev worker). A leftover `CHANGE_ME` or a guessable password is rejected in production — it would otherwise boot and silently fall back to per-process buckets | — |
| `RATE_LIMIT_ENABLED` | Master throttle switch. `false` is rejected at boot in production | `true` |
| `TRUST_PROXY_HEADERS` | `true` only behind a proxy that appends `X-Forwarded-For` (e.g. Caddy). Unset is rejected in production: neither value is safe to guess | unset (means `false`) |

`TRUST_PROXY_HEADERS` cuts both ways: exposed directly to the internet, a client forges the
header and opens a fresh bucket per request; left `false` behind a proxy, every user shares
the proxy's single bucket. That is why there is no default worth shipping: production refuses to
boot until the variable is set explicitly, while development and the test suite treat unset as
`false`. Code reads the resolved `settings.proxy_headers_are_trusted`, never the tri-state field.

## Models & embeddings

| Variable | Description | Default |
|---|---|---|
| `FREE_MODEL_ENDPOINT` | Ollama OpenAI-compatible endpoint | `http://localhost:11434/v1` |
| `FREE_MODEL_NAME` | Free-tier / local model | `qwen3.5:9b` |
| `OLLAMA_CHAT_ENABLED` | Serve the local Ollama chat model; `false` on hosted deployments where the Ollama service only runs embeddings | `true` |
| `OLLAMA_NATIVE_TOOLS` | Use Ollama's native function calling instead of the directive fallback | `false` |
| `EMBEDDING_ENDPOINT` | Embedding endpoint; reuses `FREE_MODEL_ENDPOINT` when blank | — |
| `EMBEDDING_MODEL_NAME` | RAG embedding model | `nomic-embed-text` |
| `EMBEDDING_DIM` | Embedding vector dimension | `768` |
| `GEMINI_MODEL_NAME` | Gemini model id; the `-latest` alias survives model retirements — pin a stable id for deterministic behavior | `gemini-flash-latest` |
| `LLM_REQUEST_TIMEOUT_SECONDS` | Per-LLM-call read timeout | `180` |
| `LLM_CONNECT_TIMEOUT_SECONDS` | Per-LLM-call connect timeout | `10` |

## Agent tools

| Variable | Description | Default |
|---|---|---|
| `WEB_SEARCH_ENABLED` | DuckDuckGo web-search tool | `true` |
| `WEB_SEARCH_MAX_RESULTS` | Results per query | `5` |
| `WEB_SEARCH_TIMEOUT_SECONDS` | Per-query timeout | `10` |
| `WEB_SEARCH_MAX_USES_PER_SUBTASK` | Searches per subtask | `3` |
| `DATA_FETCH_ENABLED` | Data-fetch tool (Scrapling: TLS-impersonating GET → page text, or CSS-selected JSON) | `true` |
| `DATA_FETCH_TIMEOUT_SECONDS` | Per-fetch timeout | `15` |
| `DATA_FETCH_MAX_USES_PER_SUBTASK` | Fetches per subtask | `3` |
| `DATA_FETCH_ENGINE` | `scrapling` or `httpx` (the pre-Scrapling path: no selectors, no impersonation, but a streaming size cap) | `scrapling` |
| `DATA_FETCH_RENDER_ENABLED` | Browser tier for JS-rendered pages; needs `scrapling install` in the image plus ~1GB RAM. Self-host only — the tool works fully without it | `false` |
| `DATA_FETCH_RENDER_TIMEOUT_SECONDS` | Per-render timeout | `45` |
| `DATA_FETCH_RENDER_MAX_CONCURRENCY` | Concurrent browser renders | `1` |
| `REPO_INTEL_ENABLED` | GitHub repository intelligence for the Open Source squad. Works with no key at all (anonymous reads, 60/hour); a user's stored token raises it to 5000 | `true` |
| `SOCIAL_SEARCH_ENABLED` | X post search for the Social Listening squad. Needs the user's X key; withheld without one | `true` |
| `COMMUNITY_READ_ENABLED` | Discord / Slack / Telegram channel reading for the Community squad. Needs the user's key for that platform | `true` |
| `PLACES_INTEL_ENABLED` | Google Places lookup for the Local Market squad. Needs the user's Maps key | `true` |
| `MCP_ENABLED` | Remote MCP servers as a BYOK tool source (Streamable HTTP only; no local stdio, no OAuth). Off by default: the host is user-supplied *and* the tool descriptions that shape a subagent's prompt are written by a third party | `false` |
| `MCP_TIMEOUT_SECONDS` | Deadline for one whole tool call (three requests), not per request | `30` |
| `MCP_DISCOVERY_TIMEOUT_SECONDS` | Bounds the handshake plus a paginated `tools/list` | `20` |
| `MCP_MAX_USES_PER_SUBTASK` | Calls per remote tool per subtask | `3` |
| `PLUGINS_ENABLED` | Plugins: bundles of skills + MCP servers + agents, installed in one action. Off by default because its members are the two features that themselves ship off | `false` |
| `PLUGIN_EXTERNAL_IMPORT_ENABLED` | Additionally allows importing a manifest from a user-named URL — content that passed no publish scan and can change after install. A separate decision from the switch above | `false` |
| `PLUGIN_IMPORT_TIMEOUT_SECONDS` | Per-fetch timeout for a manifest import | `15` |
| `CODE_EXECUTION_ENABLED` | Docker code sandbox. Off by default and self-host only: it needs access to the Docker daemon, so enabling it on a hosted deployment puts the host in the tool's blast radius | `false` |
| `CODE_EXECUTION_IMAGE` | Sandbox container image | `python:3.12-slim` |
| `CODE_EXECUTION_TIMEOUT_SECONDS` | Per-run timeout | `30` |
| `CODE_EXECUTION_MEMORY_LIMIT` / `CODE_EXECUTION_CPUS` | Sandbox resource limits | `512m` / `1` |
| `CODE_EXECUTION_MAX_USES_PER_SUBTASK` | Runs per subtask | `3` |

A missing *service* key is deliberately not fatal: the tool is withheld from the squad, the
members fall back to `web_search`, and the answer's mandatory data-coverage section states
what could not be reached. A missing *brain* key stops the task and tells the user.

## Agent limits & execution

| Variable | Description | Default |
|---|---|---|
| `MAX_ITERATIONS` | Max steps per Subagent | `10` |
| `MAX_REVIEW_ITERATIONS` | Reviewer ↔ Subagent loop limit | `3` |
| `TASK_TIMEOUT_SECONDS` | Total timeout per task (whole pipeline) | `1800` |
| `SUBAGENT_MAX_PARALLEL` | Concurrent Subagents per task | `3` |
| `SUBAGENT_MAX_TOOL_CALLS` | Total tool calls (all kinds) per subtask | `6` |
| `REVIEWER_FAIL_MODE` | What a reviewer crash means for the subtask: `warn`, `approve`, or `reject` | `warn` |
| `TASK_RETENTION_DAYS` | Mongo TTL on task sessions + agent logs; dashboard metrics cover this window | `30` |

## Payments

| Variable | Description | Default |
|---|---|---|
| `PAYMENT_PROVIDER` | Payment gateway; only `mock` is implemented (Luhn/BIN validation, moves no real money) | `mock` |
| `BILLING_ENABLED` | Whether paid plans are reachable. `false` parks them: subscribe/cancel answer 403 for everyone but admins, and every account runs on the unlimited `free` plan. Flip together with `BILLING_LIVE` in `frontend/src/lib/legal/config.ts` | `false` |
| `GRANT_ADMIN_EMAILS` | Comma-separated accounts `python -m app.scripts.grant_admin` promotes to admin | *(empty)* |

Plan prices and quotas are product constants in `backend/app/core/constants.py`, not
environment variables.

## Transactional email

| Variable | Description | Default |
|---|---|---|
| `EMAIL_PROVIDER` | `console` logs messages (dev / self-host — verification links are read from the log); `resend` sends via the Resend API | `console` |
| `RESEND_API_KEY` | Required when `EMAIL_PROVIDER=resend`; checked at boot in production | — |
| `EMAIL_FROM` | From header for outgoing mail | `Maestro <noreply@maestro.example.com>` |
| `SITE_URL` | Base URL the backend uses to build verification / reset links | `http://localhost:3000` |
| `EMAIL_VERIFICATION_REQUIRED` | Soft-gates task start, API-key creation and custom-API-tool writes until the email is verified. Ships off: the default `console` sender delivers nothing to an inbox, so an enforced gate would lock a fresh install rather than protect it. Enable it only alongside a real sender, and flip `EMAIL_VERIFICATION_LIVE` in `frontend/src/lib/legal/config.ts` with it | `false` |

## Signup abuse protection

Two protections guard the endpoints that send mail. Neither needs configuring.

The **per-recipient mail budget** counts sends per *recipient* address and is
always on — `rate_limit` keys by caller, and every one of these endpoints lets
the caller choose who receives the mail, so a caller-keyed limit measures the
wrong thing. The **anti-automation check** (honeypot plus a server-signed form
challenge) guards the two unauthenticated endpoints, `/auth/register` and
`/auth/forgot-password`; a CAPTCHA provider is an optional third layer on top.

Rejections are silent by design: the endpoint answers with its normal success
body, because those endpoints already guarantee a response that reveals nothing
about whether an address exists. Watch `maestro_abuse_rejected_total{reason}` on
`/metrics` — a silent rejection is invisible to the user, so that counter is the
only place a false positive shows up.

| Variable | Description | Default |
|---|---|---|
| `CAPTCHA_PROVIDER` | `none` or `turnstile`. `none` is not "unprotected": the honeypot and form-challenge layers still run and no third party is contacted. There is deliberately no production guard forcing a provider — requiring one would refuse to boot every self-hosted deployment, the same trap `EMAIL_VERIFICATION_REQUIRED` avoids by shipping off | `none` |
| `CAPTCHA_SITE_KEY` | Cloudflare Turnstile site key. Served to the browser at runtime by `GET /api/v1/auth/challenge`, never baked into the frontend bundle, so turning a provider on needs no rebuild | — |
| `CAPTCHA_SECRET_KEY` | Turnstile secret, used server-side only. Production refuses to boot if either key is empty while `CAPTCHA_PROVIDER=turnstile`: a provider that cannot verify fails closed on every call and stops registration silently | — |

Turnstile verification **fails closed** — if Cloudflare is unreachable, the
submission is refused. That costs nothing in practice: when siteverify is down
the widget did not load for real users either, so they hold no token regardless,
and failing open would relax the gate only for clients that never needed it.

## App & observability

| Variable | Description | Default |
|---|---|---|
| `ENVIRONMENT` | `production` enforces strong secrets and closes Swagger | `development` |
| `LOG_LEVEL` | Application log level | `INFO` |
| `LOG_FORMAT` | `text` for local dev, `json` for structured logs in production | `text` |
| `SENTRY_DSN` | Sentry error tracking; empty disables Sentry entirely | — |
| `SENTRY_TRACES_SAMPLE_RATE` | Tracing/APM sample rate (`0.0` = off) | `0.0` |
| `SENTRY_ENVIRONMENT` | Sentry environment tag; falls back to `ENVIRONMENT` | — |
| `TRACING_ENABLED` | Per-task span tracing (Mongo `trace_spans`); disabled = zero overhead | `false` |
| `TRACE_RETENTION_DAYS` | TTL on stored trace spans | `30` |
| `HEALTH_DETAIL_TOKEN` | Unlocks the per-dependency `checks` map on `/health/ready` for callers sending it as `X-Health-Token`; empty withholds it from everyone. Grants nothing else | — |

## Operator alerting & metrics

Self-contained: no monitoring service to run. Both alert channels empty means
alerting is a silent no-op with zero egress — configuring a channel *is* the
enable. See [`DEPLOYMENT.md`](./DEPLOYMENT.md) "Monitoring" for what fires and why.

| Variable | Description | Default |
|---|---|---|
| `ALERT_WEBHOOK_URL` | Slack/Discord-compatible incoming webhook (one payload serves both). This URL is a credential — never logged or echoed in an error | — |
| `ALERT_EMAIL_TO` | Operator address for the same alerts, delivered via `EMAIL_PROVIDER` | — |
| `ALERT_WATCHDOG_INTERVAL_SECONDS` | Seconds between readiness/error-rate evaluations; `0` stops the loop, which also freezes the `/metrics` dependency gauges | `60` |
| `ALERT_READINESS_FAILURES` | Consecutive failing ticks before "degraded" is declared. Recovery takes one good tick | `2` |
| `ALERT_COOLDOWN_SECONDS` | Minimum gap between two alerts sharing a dedupe key; claimed in Redis so N workers page once | `900` |
| `ALERT_ERROR_RATE_THRESHOLD` | 5xx share that triggers an alert. A *ratio*, so it means the same at any `WEB_CONCURRENCY` | `0.05` |
| `ALERT_ERROR_RATE_WINDOW_SECONDS` | Rolling window the ratio is measured over | `300` |
| `ALERT_ERROR_RATE_MIN_REQUESTS` | Volume floor below which the ratio is ignored | `20` |
| `METRICS_TOKEN` | Unlocks `GET /metrics` (Prometheus text format) for callers sending it as `X-Metrics-Token`; empty makes the route answer 404. Deliberately separate from `HEALTH_DETAIL_TOKEN` | — |
| `UPTIME_INTERVAL_SECONDS` | Poll interval for the optional `uptime` compose profile | `60` |
| `UPTIME_FAILURES_BEFORE_ALERT` | Consecutive failures before the sidecar alerts | `2` |

---

Deployment-specific settings — domain, TLS, backups, the purge cron — live in
[`DEPLOYMENT.md`](./DEPLOYMENT.md). Architectural rationale for the non-obvious settings is
in [`CLAUDE.md`](https://github.com/Yigtwxx/Maestro/blob/main/CLAUDE.md) §11.