9a8671c3af
Механика конкурентной записи (withPageLock → acquireCollabSession → mutate) держалась на цепочке конвенций в комментариях. Новый write-метод без знания правил компилировался и уходил в прод (класс #260/#152/#159). Закрепляем ключевые инварианты кодом. - page-lock.ts: экспортированы UUID_RE/isUuid (тот же regex, что resolvePageId, UUID v1–8/v7). withPageLock FAIL-FAST кидает при не-UUID ключе ДО любой работы (комментарий-инвариант #260/#449) — забытый resolve/slugId больше не даёт тихую потерю сериализации под другим ключом. client.ts импортирует isUuid оттуда (убран локальный дубль — resolver и assert не разъедутся). - mutatePage/replacePage seams стали async и сами вызывают resolvePageId — ключ лока/кэша канонический даже если вызывающий забыл (для уже-UUID это cached no-op; все 7 текущих вызывающих и так резолвят). replaceImage (один внешний лок + mutateLiveContentUnlocked) не тронут, deadlock невозможен. - collab-session.ts: машинно-проверяемые маркеры MUTATE-CRITICAL-WINDOW BEGIN/END вокруг синхронного блока fromYdoc→applyDocToFragment (INVARIANT 1). Тест no-await-critical-window читает исходник и краснеет на await/yield в окне (проверено нейтером). Случайный await больше не тихо клоббит живые правки. - Документация осознанной позиции: single-instance/sticky-sessions — требование деплоя (Dockerfile + README EN/RU + .env.example), т.к. мьютекс и stash — per-process. Окно устаревших прав (кэш-сессия пишет под токеном момента connect до MCP_COLLAB_SESSION_MAX_AGE_MS=10мин) — задокументированный trade-off в .env.example; push-инвалидации нет (осознанно). Тесты: page-lock fail-fast (slugId/пусто/non-string → throw; канонический UUID принят), no-away-guard, обновлённые фикстуры на валидные UUID. #449-специфичные 37/37 зелёные; mcp tsc чисто. closes #449. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
348 lines
18 KiB
Bash
348 lines
18 KiB
Bash
# your domain, e.g https://example.com
|
|
APP_URL=http://localhost:3000
|
|
PORT=3000
|
|
|
|
# --- Security / reverse proxy ---
|
|
# The app derives the client IP (req.ip) from the `X-Forwarded-For` header via
|
|
# Fastify `trustProxy`. That header is client-forgeable, so XFF is trusted only
|
|
# from proxies on the configured trusted networks. Deploy this app behind a
|
|
# trusted reverse proxy that SETS/OVERWRITES (not appends) `X-Forwarded-For`
|
|
# with the real client IP. If XFF is trusted from an untrusted source, any
|
|
# per-IP throttling — including the /mcp Basic brute-force limiter — can be
|
|
# bypassed by an attacker who simply spoofs `X-Forwarded-For` to rotate IPs.
|
|
# (The /mcp limiter keeps a global per-email key as an IP-independent backstop,
|
|
# but the per-IP and per-IP+email keys rely on a trustworthy X-Forwarded-For.)
|
|
#
|
|
# TRUST_PROXY controls which proxies are trusted to set X-Forwarded-For.
|
|
# Default (unset/empty): `loopback, linklocal, uniquelocal` — XFF is trusted
|
|
# ONLY from private/loopback proxies, so a public-IP client cannot spoof req.ip.
|
|
# This is the safe default for the common case where the reverse proxy runs on
|
|
# loopback or a private network; req.ip still resolves to the real client.
|
|
# WARNING: this changed the previous default of trust-all. If your reverse proxy
|
|
# sits on a PUBLIC IP, the default will NOT trust its XFF and req.ip will be the
|
|
# proxy's IP — set TRUST_PROXY accordingly. Accepted values:
|
|
# - true restore trust-all (ONLY safe if a trusted proxy ALWAYS overwrites
|
|
# X-Forwarded-For; otherwise clients can spoof their IP)
|
|
# - false never trust X-Forwarded-For (req.ip is the socket peer)
|
|
# - <int> number of trusted proxy hops in front of the app
|
|
# - <list> comma-separated CIDR/IP list of trusted proxies, e.g.
|
|
# `127.0.0.1, 10.0.0.0/8`
|
|
# TRUST_PROXY=
|
|
|
|
# APP_SECRET has a DUAL role: it signs JWTs AND derives the AES-256-GCM key that
|
|
# encrypts stored AI-provider credentials (API keys) at rest. CONSEQUENCE: if you
|
|
# change APP_SECRET after setup, every stored AI API key becomes undecryptable —
|
|
# you must re-enter them in AI settings — and all existing sessions/JWTs are
|
|
# invalidated. Choose it ONCE, keep it stable, and back it up alongside your DB.
|
|
# minimum of 32 characters. Generate one with: openssl rand -hex 32
|
|
APP_SECRET=REPLACE_WITH_LONG_SECRET
|
|
|
|
JWT_TOKEN_EXPIRES_IN=30d
|
|
|
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
|
|
REDIS_URL=redis://127.0.0.1:6379
|
|
|
|
# options: local | s3 | azure
|
|
STORAGE_DRIVER=local
|
|
|
|
# S3 driver config
|
|
AWS_S3_ACCESS_KEY_ID=
|
|
AWS_S3_SECRET_ACCESS_KEY=
|
|
AWS_S3_REGION=
|
|
AWS_S3_BUCKET=
|
|
AWS_S3_ENDPOINT=
|
|
AWS_S3_FORCE_PATH_STYLE=
|
|
|
|
# Azure Blob Storage driver config
|
|
AZURE_STORAGE_ACCOUNT_NAME=
|
|
AZURE_STORAGE_ACCOUNT_KEY=
|
|
AZURE_STORAGE_CONTAINER=
|
|
|
|
# default: 50mb
|
|
FILE_UPLOAD_SIZE_LIMIT=
|
|
|
|
# options: smtp | postmark
|
|
MAIL_DRIVER=smtp
|
|
MAIL_FROM_ADDRESS=hello@example.com
|
|
MAIL_FROM_NAME=Docmost
|
|
|
|
# SMTP driver config
|
|
SMTP_HOST=127.0.0.1
|
|
SMTP_PORT=587
|
|
SMTP_USERNAME=
|
|
SMTP_PASSWORD=
|
|
SMTP_SECURE=false
|
|
SMTP_IGNORETLS=false
|
|
|
|
# Postmark driver config
|
|
POSTMARK_TOKEN=
|
|
|
|
# for custom drawio server
|
|
DRAWIO_URL=
|
|
|
|
# Gotenberg URL for server-side PDF export
|
|
GOTENBERG_URL=
|
|
|
|
DISABLE_TELEMETRY=false
|
|
|
|
# Allow other sites to embed Docmost in an iframe.
|
|
IFRAME_EMBED_ALLOWED=false
|
|
|
|
# Only used when IFRAME_EMBED_ALLOWED=true. When empty, any origin is allowed.
|
|
# Example: https://intranet.example.com,https://portal.example.com
|
|
IFRAME_ALLOWED_ORIGINS=
|
|
|
|
# Enable debug logging in production (default: false)
|
|
DEBUG_MODE=false
|
|
|
|
# Log database queries
|
|
DEBUG_DB=false
|
|
|
|
# Log http requests
|
|
LOG_HTTP=false
|
|
|
|
# MCP server (community): the embedded /mcp endpoint authenticates PER USER.
|
|
# An MCP client authenticates with one of:
|
|
# - HTTP Basic: `Authorization: Basic base64(email:password)` — the user's own
|
|
# Docmost login/password. The server validates the credentials and the MCP
|
|
# session then acts under that user's permissions (edits attributed to them).
|
|
# - Bearer access JWT: `Authorization: Bearer <access-jwt>` (the user's
|
|
# `authToken` cookie value). Validated as an ACCESS token.
|
|
#
|
|
# OPTIONAL service-account fallback. When a request carries NEITHER Basic NOR
|
|
# Bearer credentials and these are set, the MCP session falls back to this
|
|
# shared service account (back-compat; useful for CI/scripts). Leave BLANK to
|
|
# require per-user credentials.
|
|
MCP_DOCMOST_EMAIL=
|
|
MCP_DOCMOST_PASSWORD=
|
|
# MCP_DOCMOST_API_URL=http://127.0.0.1:3000/api
|
|
# Optional shared guard for the /mcp endpoint. When set, every /mcp request must
|
|
# carry a matching `X-MCP-Token` header (separate from `Authorization`, which now
|
|
# carries the per-user credentials). When unset, /mcp relies on the per-user
|
|
# credentials above plus the workspace MCP toggle and network isolation (do not
|
|
# expose the port publicly).
|
|
# MCP_TOKEN=
|
|
# MCP_SESSION_IDLE_MS=1800000
|
|
#
|
|
# --- MCP collaboration write path: concurrency + rights-staleness (#449) ------
|
|
# MCP content writes (update_page, insert/replace nodes, comments-in-body, etc.)
|
|
# go over the collaboration websocket and are serialized PER PAGE by an
|
|
# in-process mutex (a module-level Map, one promise-chain per page UUID). This
|
|
# guarantees no two MCP writes on the SAME page overlap and clobber each other.
|
|
#
|
|
# DEPLOY REQUIREMENT — SINGLE INSTANCE or STICKY SESSIONS. The mutex is
|
|
# process-local. Behind a multi-replica load balancer WITHOUT sticky sessions,
|
|
# two replicas can each "hold" the lock for the same page at the same time and
|
|
# serialization is silently lost (concurrent full-document writes race on the
|
|
# live Yjs fragment). Run the MCP/app as a SINGLE instance, OR pin a page's
|
|
# traffic to one replica (sticky sessions / consistent hashing on page id). The
|
|
# same constraint applies to the RAM-only stash_page blob store above. There is
|
|
# deliberately no cross-process (e.g. Postgres advisory) lock yet — this is a
|
|
# CONSCIOUS documented constraint, not an oversight (#449).
|
|
#
|
|
# To reduce connect-storms the write path caches ONE live collab session per
|
|
# (wsUrl, page, token). Tunables (all optional; defaults are safe):
|
|
# MCP_COLLAB_SESSION_IDLE_MS=60000 # idle TTL, reset per op; 0 disables cache
|
|
# MCP_COLLAB_SESSION_MAX_ENTRIES=32 # LRU cap on cached sessions
|
|
# MCP_COLLAB_TOKEN_TTL_MS=300000 # per-client collab-token cache (5 min)
|
|
#
|
|
# RIGHTS-STALENESS TRADE-OFF. A cached collab session writes under the token
|
|
# captured at CONNECT time, and the collab-token cache reuses a token for its TTL.
|
|
# So if a user's access to a page is REVOKED, MCP writes on an already-open
|
|
# session may keep succeeding until the session ages out. MCP_COLLAB_SESSION_MAX_AGE_MS
|
|
# is the HARD lifetime (checked at each acquire) that BOUNDS this window: after it,
|
|
# the session is torn down and the next write re-auths with a fresh token, picking
|
|
# up the revocation. Default 10 min. LOWER it to shorten the revocation lag at the
|
|
# cost of more reconnects; RAISE it to reduce reconnects at the cost of a longer
|
|
# stale-rights window. There is intentionally no push-based cache invalidation on
|
|
# a rights change — this bounded window is the accepted trade-off (#449).
|
|
# MCP_COLLAB_SESSION_MAX_AGE_MS=600000
|
|
#
|
|
# BLOB SANDBOX (stash_page). An in-RAM, process-local store that hands large page
|
|
# content + images to an external consumer WITHOUT bloating the model context or
|
|
# requiring Docmost auth. The stash_page tool serializes a page, mirrors its
|
|
# internal images into the store, and returns ONLY a short anonymous URL; the
|
|
# consumer fetches blobs via `GET /api/sb/<uuid>` (no token — the capability is
|
|
# the unguessable UUID + short TTL + TLS). Blobs are RAM-only and cleared on
|
|
# restart. ETag = the blob's sha256 (integrity check).
|
|
# SANDBOX_PUBLIC_URL is the base used to build those URLs; it MUST be reachable
|
|
# by the consumer (do NOT use a loopback address if the consumer is remote).
|
|
# Defaults to APP_URL when unset.
|
|
# NOTE: the store is process-local — blobs live only on the instance that
|
|
# created them. Behind a multi-replica load balancer WITHOUT sticky sessions a
|
|
# consumer may hit a different instance and get a 404 (indistinguishable from an
|
|
# expired blob). Single-host deployments are unaffected.
|
|
# SANDBOX_PUBLIC_URL=https://docs.example.com
|
|
# SANDBOX_TTL_MS=3600000
|
|
# SANDBOX_MAX_BYTES=8388608
|
|
# SANDBOX_MAX_IMAGE_BYTES=20971520
|
|
# SANDBOX_MAX_TOTAL_BYTES=134217728
|
|
#
|
|
# AI-AGENT ATTRIBUTION (comments/pages written via MCP are badged as "AI"):
|
|
# attribution is driven by a per-user `is_agent` flag on the users row. There is
|
|
# NO admin UI/API for it — set it out-of-band with SQL. Use a DEDICATED service
|
|
# account for the MCP fallback above and flag ONLY that account, e.g.:
|
|
# UPDATE users SET is_agent = true WHERE email = 'mcp-bot@your-domain';
|
|
# NEVER set is_agent on a human or shared account — every action by that account
|
|
# (including normal human edits) would then be mis-attributed as AI.
|
|
|
|
# Agent-roles catalog source: an http(s):// base URL to the catalog's raw files
|
|
# (the server appends /index.yaml and /bundles/<id>/<lang>.yaml). This value is
|
|
# baked into the Docker image at build time per branch (see the Dockerfile ARG
|
|
# AI_AGENT_ROLES_CATALOG_URL and the CI build-args). Set it here only to point a
|
|
# local/non-Docker run at a catalog; if unset, the "import role from catalog"
|
|
# admin feature is unavailable. Local-filesystem sources are no longer supported.
|
|
# AI_AGENT_ROLES_CATALOG_URL=
|
|
|
|
# Per-embedding-call timeout in milliseconds for the RAG indexer.
|
|
# A slow/hung embeddings endpoint fails after this and the batch continues.
|
|
# AI_EMBEDDING_TIMEOUT_MS=120000
|
|
|
|
# Silence timeout (ms) for streaming chat/agent AI calls AND external-MCP traffic.
|
|
# Bounds time-to-first-byte and the gap BETWEEN chunks (NOT the total turn length),
|
|
# so an arbitrarily long turn that keeps streaming is never cut. Finite so a hung
|
|
# provider is eventually broken instead of leaking forever. Default 900000 (15 min).
|
|
# AI_STREAM_TIMEOUT_MS=900000
|
|
|
|
# Keep-alive recycle window (ms) for streaming chat/agent AI + external-MCP calls.
|
|
# A pooled connection idle longer than this is closed instead of reused, so a
|
|
# NAT / egress firewall / reverse proxy that silently drops idle connections
|
|
# cannot poison a reused socket into a PRE-RESPONSE `read ECONNRESET`. Kept under
|
|
# common ~5s upstream/middlebox idle cutoffs so undici recycles the socket before
|
|
# the network kills it (fewer resets), while still reusing within a burst of
|
|
# back-to-back calls. Lower it further if your egress drops idle connections even
|
|
# faster. Default 4000 (4 s).
|
|
# AI_STREAM_KEEPALIVE_MS=4000
|
|
|
|
# Number of PRE-RESPONSE connection retries for streaming chat/agent AI calls: a
|
|
# reset/timeout BEFORE any response byte (e.g. `read ECONNRESET` on a stale pooled
|
|
# socket) is retried on a fresh connection with jittered exponential backoff.
|
|
# Total attempts = value + 1, so the default 4 gives 5 attempts — headroom to
|
|
# absorb a short BURST of upstream resets without exhausting the budget. Safe to
|
|
# retry: a started stream is never replayed, only a connect that never responded.
|
|
# 0 disables the retry. Default 4.
|
|
# AI_STREAM_PRE_RESPONSE_RETRIES=4
|
|
|
|
# Silence timeout (ms) for EXTERNAL-MCP transport ONLY (not the chat provider).
|
|
# Tighter than AI_STREAM_TIMEOUT_MS so a byte-silent/hung MCP server is broken in
|
|
# ~1 min instead of 15. Note it also cuts a legitimately long but byte-silent
|
|
# single tool call (a slow crawl that emits nothing until done) and an SSE
|
|
# transport idling >1 min BETWEEN tool calls. Default 60000 (1 min).
|
|
# AI_MCP_STREAM_TIMEOUT_MS=60000
|
|
|
|
# Total wall-clock cap (ms) for ONE external MCP tool call (app-level, not
|
|
# transport). Aborts a tool that keeps the socket warm (SSE heartbeats / trickle)
|
|
# but never returns a result — which the silence timeout above never breaks.
|
|
# Default 120000 (2 min).
|
|
# AI_MCP_CALL_TIMEOUT_MS=120000
|
|
|
|
# Max JSON/urlencoded request body size (bytes). Fastify's 1 MiB default is too
|
|
# small for a long AI-chat research turn: the client resends the FULL message
|
|
# history (every tool call + search result) on each turn, so a deep conversation's
|
|
# POST to /api/ai-chat/stream can be several MB and would otherwise be rejected
|
|
# with FST_ERR_CTP_BODY_TOO_LARGE (413). Does NOT affect multipart file uploads
|
|
# (see FILE_UPLOAD_SIZE_LIMIT). Default 26214400 (25 MiB).
|
|
# HTTP_JSON_BODY_LIMIT=26214400
|
|
|
|
# Deferred tool loading for the in-app AI chat (#332). Default ON: the agent sees
|
|
# a compact <tool_catalog> and only CORE tools + a loadTools meta-tool are active
|
|
# each step; deferred tools (the fat/rare ones + all external MCP tools) load on
|
|
# demand. Set AI_CHAT_DEFERRED_TOOLS=false to restore the old "all tools always
|
|
# active" behavior.
|
|
# AI_CHAT_DEFERRED_TOOLS=true
|
|
|
|
# Final-step lockdown for the in-app agent loop (#444). Default OFF. When ON
|
|
# (legacy), the LAST allowed step forces a text-only answer: the model's tools are
|
|
# stripped (toolChoice=none) and a synthesis instruction is appended. That
|
|
# tool-stripping caused a token-degeneration incident — robbed of its tools on the
|
|
# final step mid-work, the model emitted a ~255KB block repeating a single token —
|
|
# so the default is now OFF: the last step keeps its tools and gets only a SOFT
|
|
# nudge to finish with a text summary, and a token-degeneration detector is the
|
|
# universal anti-babble guard. Enable this ONLY for a model that reliably ends its
|
|
# turns with a clear text answer.
|
|
# AI_CHAT_FINAL_STEP_LOCKDOWN=false
|
|
|
|
# --- Autonomous / detached agent runs (settings.ai.autonomousRuns) ---
|
|
# Opt-in per workspace (AI settings; off by default). When on, a chat turn becomes
|
|
# a server-side RUN that survives a browser disconnect — only an explicit Stop ends
|
|
# it, and a client reconnects/live-follows the run.
|
|
#
|
|
# DEPLOY CONSTRAINT — SINGLE-INSTANCE ONLY in phase 1: Stop and the in-process
|
|
# AbortController that backs it are process-local, so a Stop only aborts a run
|
|
# executing on the SAME replica that owns it (cross-instance pub/sub stop is phase
|
|
# 2 and not yet reliable). Do NOT enable autonomousRuns on a horizontally-scaled
|
|
# deployment (multiple replicas behind a load balancer, or Docmost cloud
|
|
# CLOUD=true) — run a single instance instead. The server logs a startup WARNING
|
|
# when it detects a multi-instance deployment (CLOUD=true) so the constraint is
|
|
# visible, and a startup sweep settles any run left dangling by a restart.
|
|
#
|
|
# Resumable run streams (#184 phase 1.5, #381). With the flag ON, an active
|
|
# durable run tees its SSE frames into an in-memory registry, and a
|
|
# reloaded/second tab attaches via GET /ai-chat/runs/:chatId/stream to follow the
|
|
# run LIVE (replay of the buffered frames + the live tail). With the flag OFF
|
|
# (default) the registry is never populated and attach always answers 204, so a
|
|
# reopened tab of an active run silently falls back to degraded 2.5s history
|
|
# polling — every wire path stays byte-for-byte identical to a build without the
|
|
# feature. Staged-rollout switch: only meaningful when autonomousRuns (above) is
|
|
# enabled for a workspace, and the same single-instance constraint applies (the
|
|
# registry is process-local).
|
|
# AI_CHAT_RESUMABLE_STREAM=false
|
|
|
|
# --- Anonymous public-share AI assistant ---
|
|
# Opt-in per workspace (AI settings -> "public share assistant"; off by default).
|
|
# When enabled, anonymous visitors of a published share can ask an AI about that
|
|
# share at POST /api/shares/ai/stream. The assistant is read-only and hard-scoped
|
|
# to the single share tree, but every call spends real tokens on the workspace
|
|
# owner's configured AI provider.
|
|
#
|
|
# DEPLOYMENT REQUIREMENT: the per-IP rate limit on this endpoint is only
|
|
# effective behind a trusted reverse proxy that OVERWRITES (not appends)
|
|
# X-Forwarded-For with the real client IP. The app runs with trustProxy, so
|
|
# without such a proxy an attacker can rotate X-Forwarded-For to evade the
|
|
# per-IP limit. Put this endpoint (and the app) behind a proxy you control that
|
|
# sets X-Forwarded-For to the real client IP.
|
|
#
|
|
# Backstop: a cluster-wide, sliding-window cap per workspace (IP-independent,
|
|
# keyed by the server-resolved workspace id) bounds the owner's bill even if the
|
|
# per-IP limit is fully evaded. It is a COST backstop, not an access control, and
|
|
# FAILS CLOSED if Redis is unavailable (an optional assistant briefly going
|
|
# offline is safer than an unbounded bill). Override the hourly cap below
|
|
# (default: 100 calls per workspace per rolling hour).
|
|
# SHARE_AI_WORKSPACE_MAX_PER_HOUR=100
|
|
#
|
|
# Per-request output-token ceiling for the anonymous assistant (default: 512).
|
|
# Worst-case output per accepted call = agent steps (5) × this value.
|
|
# SHARE_AI_MAX_OUTPUT_TOKENS=512
|
|
#
|
|
# Second cost backstop: a cluster-wide per-workspace rolling-DAY token budget
|
|
# (input re-sent per step + output, summed across every accepted turn). The
|
|
# hourly request cap above bounds how MANY calls run, not how expensive each is,
|
|
# so this caps the owner's actual provider bill directly. Like the request cap it
|
|
# FAILS CLOSED if Redis is unavailable (default: 1,000,000 tokens per workspace
|
|
# per rolling day).
|
|
# SHARE_AI_WORKSPACE_TOKEN_BUDGET_PER_DAY=1000000
|
|
|
|
# --- Observability / perf metrics (#355) ---
|
|
#
|
|
# Two INDEPENDENT toggles, both OFF by default:
|
|
#
|
|
# 1) METRICS_PORT — the server-side Prometheus scrape endpoint.
|
|
# UNSET (default) => the whole prom subsystem is OFF: no registry, no
|
|
# collectors, and NOTHING is exposed on the main app port. There is NO
|
|
# default port — leaving it blank disables it. When set to a port (e.g.
|
|
# 9464), a SEPARATE bare node:http listener serves GET /metrics on that port
|
|
# only (never on the main :3000 app listener), for a scraper such as
|
|
# VictoriaMetrics/Prometheus reaching it as <host>:<port>/metrics.
|
|
# METRICS_PORT=9464
|
|
#
|
|
# 2) CLIENT_TELEMETRY_ENABLED — the public client perf-telemetry sink.
|
|
# OFF by default. When true, the unauthenticated POST /api/telemetry/vitals
|
|
# endpoint is registered and browsers collect + send web-vitals / editor
|
|
# metrics into the `client_metrics` table (read directly by Grafana, separate
|
|
# from METRICS_PORT). Leave OFF unless you actually consume this data: the
|
|
# endpoint is public and the table has NO app-side retention, so enabling it
|
|
# requires an EXTERNAL pruner to bound `client_metrics` growth (the deployed
|
|
# infra prunes rows >90d via a maintenance container). When off, the endpoint
|
|
# does not exist and the client installs no observers.
|
|
# CLIENT_TELEMETRY_ENABLED=false
|