# Appdor — the whole self-hosted stack in ONE container.
#
#   cp .env.example .env
#   node scripts/generate-keys.mjs      # writes ANON_KEY / SERVICE_ROLE_KEY / JWT_SECRET
#   docker compose up --build
#
#   app      http://localhost:8080
#   (the API is served on the SAME port, proxied: /rest, /auth, /storage, /realtime)
#
# There is no compose network to reason about and no service ordering to get
# wrong, so `docker run` works just as well:
#
#   docker run -p 8080:80 --env-file .env -v appdor-data:/data appdor
#
# ## What happened to the other thirteen services
#
# Nothing was removed. web, kong, db, rest, rest-2, auth, storage, admission,
# ai-proxy, mcp-server, scim-server, portal-server, workflow-worker and
# python-runner are all still running — as processes under supervisord, on the
# same ports, with the same environment. See Dockerfile for what that cost and
# what it bought, and `docker/supervisord.conf` for the process list.
#
#   docker compose exec appdor supervisorctl status      # was: docker compose ps
#   docker compose logs appdor | grep '^\[auth\]'        # was: docker compose logs auth
#
# Python steps use a mandatory Landlock/seccomp boundary inside this container.
# Host requirements and verification: services/python-runner/README.md.
#
# ## Studio and postgres-meta are still separate, and still dev-only
#
# They were ALREADY `profiles: ["dev"]` and excluded from a default `up` — the
# old file's own comments say why: 317 MB and 263 MiB resident for a database
# GUI "no product code path uses", holding SERVICE_ROLE_KEY with no
# authentication of its own. Merging a 317 MB Next.js admin console into the
# product image to satisfy "one container" would have doubled it for something
# a deployment never serves a request from. They stay as they were: opt-in, and
# not part of the product image.
#
# No keys live in this file. ANON_KEY / SERVICE_ROLE_KEY / JWT_SECRET come from
# .env, which ships them EMPTY on purpose — generate a deployment's own with
# `node scripts/generate-keys.mjs`.

services:
  appdor:
    build:
      context: .
    image: ghcr.io/nebosa-company/appdor
    container_name: appdor
    ports:
      # The app. Host 8080 -> container 80.
      - "${WEB_PORT:-8080}:80"
      # The gateway is NOT published any more — this deployment exposes ONE
      # port. `:80` proxies /rest, /auth, /storage and /realtime to it on
      # 127.0.0.1:8000 inside the container (see docker/nginx.conf.template),
      # so the browser reaches the API on the same origin it loaded the app
      # from and `SUPABASE_URL` is the app's own address.
      #
      # Two things came free with that. Same-origin means no CORS preflight and
      # no `Access-Control-Allow-Headers` list to keep in step with supabase-js
      # — the failure that once cost a debugging round when a running gateway
      # served a staler header set than the file on disk. And the IPv6 trap
      # recorded here before is gone with the publish: there is no `[::1]:8000`
      # for a WSL relay to take.
      #
      # To expose it again — a separate front end, a direct API consumer —
      # re-add:  - "127.0.0.1:${KONG_HTTP_PORT:-8000}:8000"
      # 5432 is deliberately absent, and so is every internal service port
      # (9999, 3000, 3001, 5000, 8090, 8787, 8788, 8790, 8791, 8792, 8793,
      # 8794, 8795, 8796, 8797).
      # They bind 127.0.0.1 INSIDE the container, so they are not merely
      # unpublished — they are unreachable from another container too.
    environment:
      # --- required: no defaults, on purpose ---------------------------------
      # The entrypoint refuses to start when any of these is empty rather than
      # booting a stack that cannot work. An empty POSTGRES_PASSWORD leaves
      # `authenticator` passwordless; an empty JWT_SECRET makes every signature
      # verifiable by anyone; an empty ANON_KEY would make the gateway's apikey
      # map match a request that sent NO key at all.
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      JWT_SECRET: ${JWT_SECRET}
      ANON_KEY: ${ANON_KEY}
      SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY}
      POSTGRES_DB: ${POSTGRES_DB:-postgres}

      # --- addresses ---------------------------------------------------------
      # BROWSER-facing URL of the gateway. Must be reachable from the user's
      # browser, i.e. the host-published port. Internal callers do NOT use this
      # — supervisord.conf gives the worker and the Node services
      # http://127.0.0.1:8000 explicitly, which is what `http://kong:8000` was.
      SUPABASE_URL: ${SUPABASE_URL:-http://localhost:8080}
      SITE_URL: ${SITE_URL:-http://localhost:8080}
      # Optional IP allowlist for a locked-down self-hosted deployment, e.g.
      # ALLOWED_IPS="203.0.113.7, 198.51.100.0/24". Empty = open to everyone.
      ALLOWED_IPS: ${ALLOWED_IPS:-}
      TRUSTED_PROXIES: ${TRUSTED_PROXIES:-}
      GOTRUE_SAML_ENABLED: ${GOTRUE_SAML_ENABLED:-}
      GOTRUE_SAML_PRIVATE_KEY: ${GOTRUE_SAML_PRIVATE_KEY:-}
      # AUDIT P1 (2026-08-19): the four GoTrue auth-policy settings were
      # hardcoded in supervisord.conf and not overridable. They are exposed
      # here with safe defaults (see docker/entrypoint.sh); set in .env to
      # change policy, e.g. GOTRUE_DISABLE_SIGNUP=false to open signups.
      GOTRUE_URI_ALLOW_LIST: ${GOTRUE_URI_ALLOW_LIST:-}
      GOTRUE_DISABLE_SIGNUP: ${GOTRUE_DISABLE_SIGNUP:-}
      GOTRUE_MAILER_AUTOCONFIRM: ${GOTRUE_MAILER_AUTOCONFIRM:-}
      # ASVS V3.3.4: refresh tokens rotate (single-use) with a 30s reuse window
      # so concurrent tabs do not log each other out. Defaults in
      # docker/entrypoint.sh; override here to opt out.
      GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: ${GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED:-}
      GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: ${GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL:-}
      GOTRUE_SMS_AUTOCONFIRM: ${GOTRUE_SMS_AUTOCONFIRM:-}
      # GoTrue SMTP — password reset and email confirmation. These were
      # defaulted in docker/entrypoint.sh and consumed by supervisord.conf, but
      # compose never forwarded them: setting the six in .env changed nothing,
      # the container always saw the empty defaults, and reset mail was a dead
      # letter however carefully the relay was configured. GOTRUE_SMTP_HOST is
      # the master switch — empty means GoTrue serves no mailer at all.
      # Set the six together, and GOTRUE_MAILER_AUTOCONFIRM=false so new
      # addresses are actually verified. AUTH_EMAIL_ENABLED no longer has to be
      # set alongside them — docker/entrypoint.sh derives it from this host, so
      # the reset screen stops disclaiming the moment a mailer exists.
      GOTRUE_SMTP_HOST: ${GOTRUE_SMTP_HOST:-}
      GOTRUE_SMTP_PORT: ${GOTRUE_SMTP_PORT:-}
      GOTRUE_SMTP_USER: ${GOTRUE_SMTP_USER:-}
      # Defaults to EMAIL_API_KEY, which is the SAME Resend key. Resend's SMTP
      # relay authenticates with the API key as the password (user `resend`),
      # so a deployment on Resend would otherwise carry one credential under
      # two names and drift the moment either is rotated. The comment above
      # records that these two transports "fail independently and an operator
      # can easily configure one and not the other" -- this closes half of that
      # gap for the common case, and an explicit GOTRUE_SMTP_PASS still wins.
      # Only meaningful when GOTRUE_SMTP_HOST is smtp.resend.com; pointing a
      # different relay at a Resend key fails AUTH loudly, which is the right
      # failure.
      GOTRUE_SMTP_PASS: ${GOTRUE_SMTP_PASS:-${EMAIL_API_KEY:-}}
      GOTRUE_SMTP_ADMIN_EMAIL: ${GOTRUE_SMTP_ADMIN_EMAIL:-}
      GOTRUE_SMTP_SENDER_NAME: ${GOTRUE_SMTP_SENDER_NAME:-}
      # Google OAuth (Sign in with Google). Empty = the provider is off, and
      # the login form's Google button stays hidden — the client id and secret
      # are GoTrue's own env, read at auth startup. The Google Cloud Console
      # client must carry, as its authorized redirect URI, the GoTrue callback
      # `<SUPABASE_URL>/auth/v1/callback` (this deployment:
      # http://localhost:8080/auth/v1/callback).
      GOTRUE_EXTERNAL_GOOGLE_ENABLED: ${GOTRUE_EXTERNAL_GOOGLE_ENABLED:-}
      GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID: ${GOTRUE_EXTERNAL_GOOGLE_CLIENT_ID:-}
      GOTRUE_EXTERNAL_GOOGLE_CLIENT_SECRET: ${GOTRUE_EXTERNAL_GOOGLE_CLIENT_SECRET:-}
      GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI: ${GOTRUE_EXTERNAL_GOOGLE_REDIRECT_URI:-}
      API_EXTERNAL_URL: ${API_EXTERNAL_URL:-}
      # Which addresses the postmaster binds. 127.0.0.1 = unreachable from
      # outside this container by construction. The dev profile overrides it.
      PG_LISTEN: ${PG_LISTEN:-127.0.0.1}
      # Where the database is. Anything but 127.0.0.1 means a managed
      # PostgreSQL, and the container runs no postmaster of its own.
      POSTGRES_HOST: ${POSTGRES_HOST:-127.0.0.1}
      POSTGRES_PORT: ${POSTGRES_PORT:-5432}
      POSTGRES_SSLMODE: ${POSTGRES_SSLMODE:-}

      # --- the app -----------------------------------------------------------
      # Crash telemetry (LC1.24). PUBLIC — this ships in the page and names the
      # deployment; it is not authentication. Empty = the SDK stays inert.
      CRASHALYTICS_KEY: ${CRASHALYTICS_KEY:-}
      # Google Analytics measurement ID. PUBLIC, and empty by default on purpose
      # — this compose file is what self-hosters run, so a literal ID here would
      # point their traffic at somebody else's property. Set it in .env if you
      # want your own deployment measured.
      GA_MEASUREMENT_ID: ${GA_MEASUREMENT_ID:-}
      # Deliberately empty rather than a literal default: a default here would
      # be *set*, so the entrypoint's fallback to the version baked into the
      # image at build time could never fire. Two defaults fighting is how every
      # crash report ended up tagged 0.0.0.
      APP_VERSION: ${APP_VERSION:-}
      APP_ENV: ${APP_ENV:-production}
      # DERIVED from GOTRUE_SMTP_HOST by docker/entrypoint.sh; leave it empty
      # and it follows the mailer. Set it explicitly only to disagree with that
      # — 0 to keep the reset screen disclaiming on a stack that does have SMTP,
      # 1 to suppress the disclaimer on one that does not (which is a promise
      # nobody keeps, and the entrypoint warns at boot when it sees it).
      AUTH_EMAIL_ENABLED: ${AUTH_EMAIL_ENABLED:-}
      # The auth-v1 rate limit (LC58.13's API-rate clause). The whole nginx rate
      # token, not a bare number: `10r/s` has to be expressible.
      AUTH_RATE_LIMIT: ${AUTH_RATE_LIMIT:-30r/m}
      # The admin console's deployment probes (see .env.example). Forwarded
      # EMPTY when unset rather than defaulted here: the service carries the
      # image's own paths, and a default in two places is two answers to where
      # the backup script lives.
      BACKUP_CHECK_SCRIPT: ${BACKUP_CHECK_SCRIPT:-}
      NGINX_EFFECTIVE_CONF: ${NGINX_EFFECTIVE_CONF:-}
      # Self-hosted licensing. The key that VERIFIES a licence document —
      # PUBLIC, since 2026-08-24, when the signature became Ed25519 instead of a
      # keyed MAC whose holder could mint their own enterprise licence. Empty
      # falls back to the vendor key baked into the image; empty on BOTH means
      # this deployment cannot check licences, which is a fully supported way to
      # run — the endpoint answers `no-license-key-configured` and every screen
      # reads the subscription or the free tier instead.
      #
      # The env line is here because of a gap measured on 2026-08-15: the server
      # read the variable and this file never forwarded it, so an operator who
      # set it in `.env` got nothing with no error anywhere.
      LICENSE_PUBLIC_KEY: ${LICENSE_PUBLIC_KEY:-}
      # Where the licence document is read from and written to. The plan screen
      # writes it when a realm owner pastes a licence in; mounting the file
      # yourself works exactly as well. Re-read on every check — no restart.
      LICENSE_FILE: ${LICENSE_FILE:-/appdor/license.json}
      # Optional. Rendered as the "How to buy" link on the plan screen of a
      # deployment that takes no card payments. No default: an invented address
      # is worse than none.
      LICENSE_PURCHASE_URL: ${LICENSE_PURCHASE_URL:-}

      # Whether commercial feature gating applies to this deployment (Pro-only
      # realtime, Business-only field permissions, and the rest of the matrix in
      # supabase/init/99-fb-plan-feature-gates.sql).
      #
      # Empty is the right default and means gated. Self-hosting is free and
      # this stack runs the FREE tier indefinitely with no licence at all; the
      # paid tiers are paid for, and the licence above is how one arrives.
      # Set it to `off` to keep an instance open — an internal deployment, a
      # demo — or to `on` to state the default explicitly.
      #
      # This is the PROXY's half. The database half is
      # `public.deployment_settings.feature_gating`, which is what actually
      # refuses a write, and the two are set separately on purpose: this one
      # decides what the browser offers, that one decides what Postgres allows.
      APPDOR_FEATURE_GATING: ${APPDOR_FEATURE_GATING:-}

      # --- admission control (LC58.5) ----------------------------------------
      # These are the CODE defaults, restated so there is one source of truth.
      # They are the configuration that measured clean: bound held at 0.48x and
      # the victim recovered fully in 7.7s. A wider queue (200) was tried and is
      # WORSE — requests wait instead of shedding, the storm took 4.9 minutes,
      # and the victim was still at 2.41x baseline when the run ended.
      ADMISSION_MAX_CONCURRENT: ${ADMISSION_MAX_CONCURRENT:-12}
      ADMISSION_PER_TENANT_CONCURRENT: ${ADMISSION_PER_TENANT_CONCURRENT:-4}
      ADMISSION_MAX_WAIT_MS: ${ADMISSION_MAX_WAIT_MS:-5000}
      # Deliberately loose. Concurrency is the scarce resource — the pool — and
      # the in-flight cap is what holds the fairness bound. A tight rate limit
      # ALSO holds it, by refusing 85% of a legitimate tenant's traffic.
      ADMISSION_PER_TENANT_QUEUE: ${ADMISSION_PER_TENANT_QUEUE:-50}
      ADMISSION_RATE: ${ADMISSION_RATE:-5000}
      ADMISSION_BURST: ${ADMISSION_BURST:-10000}

      # --- workflow runtime --------------------------------------------------
      # Telemetry retention (LC1.23). Shorten for a privacy-sensitive
      # deployment; the purge functions take the window as an argument.
      PURGE_INTERVAL_MS: ${PURGE_INTERVAL_MS:-21600000}
      CRASH_RETAIN_DAYS: ${CRASH_RETAIN_DAYS:-90}
      ANALYTICS_RETAIN_DAYS: ${ANALYTICS_RETAIN_DAYS:-365}
      # Linked-account detection. Empty LINKAGE_PEPPER = the ingest stores
      # nothing and the scoring pass does not run, which is the default: this
      # collects personal data and an operator has to choose it deliberately.
      LINKAGE_PEPPER: ${LINKAGE_PEPPER:-}
      LINKAGE_TRUSTED_HOPS: ${LINKAGE_TRUSTED_HOPS:-0}
      LINKAGE_ASN_HEADER: ${LINKAGE_ASN_HEADER:-}
      SIGNUP_SIGNAL_RETAIN_DAYS: ${SIGNUP_SIGNAL_RETAIN_DAYS:-180}
      LINKAGE_CASE_RETAIN_DAYS: ${LINKAGE_CASE_RETAIN_DAYS:-365}
      LINKAGE_INTERVAL_MS: ${LINKAGE_INTERVAL_MS:-21600000}
      LINKAGE_FIRST_RUN_MS: ${LINKAGE_FIRST_RUN_MS:-60000}
      LINKAGE_MAX_ACCOUNTS: ${LINKAGE_MAX_ACCOUNTS:-20000}
      MAX_WORKFLOW_DEPTH: ${MAX_WORKFLOW_DEPTH:-5}
      # Shared secret between workflow-worker and python-runner. One value now,
      # where compose used to set PYTHON_RUNNER_SECRET on one service and
      # RUNNER_SECRET on the other and trust them to match.
      # AUDIT B4 (2026-08-19): the published dev default is gone. When unset,
      # the entrypoint generates a random value at boot (see entrypoint.sh); an
      # operator who wants a stable value sets it explicitly. A known constant
      # as the bearer secret for a remote-code-execution endpoint is not a
      # secret.
      PYTHON_RUNNER_SECRET: ${PYTHON_RUNNER_SECRET:-}
      PYTHON_STEP_TIMEOUT_MS: ${PYTHON_STEP_TIMEOUT_MS:-30000}
      PYTHON_STEP_MAX_TIMEOUT_MS: ${PYTHON_STEP_MAX_TIMEOUT_MS:-300000}
      # --- platform NFRs (docs/initiation/nfrs-platform.md) ------------------
      #
      # These were documented in .env.example and NOT listed here, which on this
      # compose file means they never reached the container: the environment is
      # ENUMERATED, so an unnamed variable is silently dropped. An operator
      # would have set a maintenance window, restarted, and watched writes
      # continue — a knob that reads as configured and does nothing, which is
      # the failure this repository keeps writing gates about.
      #
      # Planned maintenance (1.4). Enforced in the admission controller, so a
      # script writing straight to the REST API is held back like the browser.
      MAINTENANCE_FROM: ${MAINTENANCE_FROM:-}
      MAINTENANCE_UNTIL: ${MAINTENANCE_UNTIL:-}
      MAINTENANCE_MESSAGE: ${MAINTENANCE_MESSAGE:-}
      # The aggregate status probe (1.1).
      STATUS_CACHE_MS: ${STATUS_CACHE_MS:-5000}
      STATUS_PROBE_TIMEOUT_MS: ${STATUS_PROBE_TIMEOUT_MS:-2000}
      # SLO alerting (1.7). ALERT_REALM_ID names whose stored `alert-webhook`
      # connections receive deployment-wide alerts; the URL itself lives in the
      # connection vault, not here.
      ALERT_REALM_ID: ${ALERT_REALM_ID:-}
      ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-}
      ALERT_INTERVAL_MS: ${ALERT_INTERVAL_MS:-300000}
      SLO_SOURCE_URL: ${SLO_SOURCE_URL:-http://127.0.0.1:8787/slo}
      # How long a request waits for a cold PostgREST before being told to come
      # back, instead of being answered 502 (1.7's boot-noise fix).
      ADMISSION_WARMUP_MS: ${ADMISSION_WARMUP_MS:-15000}
      # Retention (7.2). Both UNSET on purpose — they delete data a customer may
      # still want, so nothing is removed until an operator chooses a window.
      RECORD_HISTORY_RETAIN_DAYS: ${RECORD_HISTORY_RETAIN_DAYS:-}
      ATTACHMENT_ORPHAN_RETAIN_DAYS: ${ATTACHMENT_ORPHAN_RETAIN_DAYS:-}
      ATTACHMENT_ORPHAN_PURGE: ${ATTACHMENT_ORPHAN_PURGE:-}
      # Unsubmitted form drafts (LC21.21). SET by default, unlike the two above,
      # and the difference is deliberate: those two delete data a customer asked
      # to keep, while a draft is answers somebody typed into a public form and
      # never sent. LC21.N5 asks for a hard purge on that, so the window ships
      # closed rather than off. The default lives in src/audit/retention-policy.js.
      FORM_DRAFT_RETAIN_DAYS: ${FORM_DRAFT_RETAIN_DAYS:-}
      FIELD_CONVERSION_RECEIPT_RETAIN_DAYS: ${FIELD_CONVERSION_RECEIPT_RETAIN_DAYS:-}

      # Email delivery (MASTER §4 #8). Empty EMAIL_PROVIDER = no email is sent,
      # which is a supported deployment — with one consequence worth knowing on
      # a VENDOR instance: a licence minted by the Stripe webhook then has
      # nowhere to go, and is only recoverable from the log.
      #
      # resend | postmark | sendgrid | mailgun | mailpit. The last is the local
      # catcher in the mail profile below — no key, no delivery, development
      # only. `node scripts/send-test-email.mjs you@example.com` proves whichever
      # you picked actually sends.
      EMAIL_PROVIDER: ${EMAIL_PROVIDER:-}
      EMAIL_API_KEY: ${EMAIL_API_KEY:-}
      EMAIL_FROM: ${EMAIL_FROM:-}
      EMAIL_DOMAIN: ${EMAIL_DOMAIN:-}
      # The lockup at the top of every message. Unset, it is derived from
      # APP_URL/SITE_URL — which on this stack is localhost, an origin no
      # recipient can fetch, so the mark is dropped rather than sent broken.
      # Point it at the public copy to see the real lockup in a test send.
      EMAIL_BRAND_MARK_URL: ${EMAIL_BRAND_MARK_URL:-}
      # Where the catcher listens. Only read when EMAIL_PROVIDER=mailpit, and
      # the default is the compose service name, so nothing needs setting to use
      # the container beside this one.
      EMAIL_MAILPIT_URL: ${EMAIL_MAILPIT_URL:-http://mailpit:8025}

      # --- secrets held server-side ------------------------------------------
      # Passphrase for at-rest BYOK key encryption (AES-256-GCM). AUDIT B3
      # (2026-08-19): the dev fallback is gone — it encrypted every tenant's
      # stored provider keys under a passphrase published in this repository.
      # BYOK_ENC_KEY is now REQUIRED at boot (entrypoint.sh's required list);
      # an unset value refuses to start rather than silently sealing customer
      # secrets under a known constant.
      BYOK_ENC_KEY: ${BYOK_ENC_KEY}
      # The portal session signing secret. Verifying an HMAC capability in the
      # browser would mean shipping this to every visitor, which is the whole
      # reason portal-server is a process.
      PORTAL_SESSION_SECRET: ${PORTAL_SESSION_SECRET}
      # Opaque portal list cursors expire after this many milliseconds. The
      # service still enforces its one-day maximum.
      PORTAL_CURSOR_TTL_MS: ${PORTAL_CURSOR_TTL_MS:-28800000}

      # Where a magic-link email points. Configuration, never the request's
      # Host header — see .env.example for why that shortcut mails a credential
      # to whoever asked. Empty means no sign-in link is sent, and the reason is
      # written to portal_access_log rather than only logged.
      PORTAL_PUBLIC_URL: ${PORTAL_PUBLIC_URL:-}

      # --- portal custom-domain certificate alerts (LC25.9) ------------------
      # Who hears that a portal custom domain's certificate is about to expire.
      # cert-watch measures the live certificate over TLS and queues a mail at
      # 30/14/7/3/1/0 days remaining; the realm's owner/admin profiles are told
      # as well, but THIS is the address that reaches the person holding the
      # private key on a self-hosted box. Empty and a realm with no admin email
      # means the alert reaches nobody, which turns the container's healthcheck
      # red rather than passing quietly.
      CERT_ALERT_EMAIL: ${CERT_ALERT_EMAIL:-}
      CERT_WATCH_INTERVAL_SECONDS: ${CERT_WATCH_INTERVAL_SECONDS:-21600}
      CERT_WATCH_TIMEOUT_MS: ${CERT_WATCH_TIMEOUT_MS:-10000}

      # --- AI providers (server-side only; empty = that provider off) --------
      GEMINI_API_KEY: ${GEMINI_API_KEY:-}
      GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash-lite}
      OPENAI_API_KEY: ${OPENAI_API_KEY:-}
      ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
      MISTRAL_API_KEY: ${MISTRAL_API_KEY:-}
      OPENROUTER_API_KEY: ${OPENROUTER_API_KEY:-}
      DEEPSEEK_API_KEY: ${DEEPSEEK_API_KEY:-}
      GROQ_API_KEY: ${GROQ_API_KEY:-}
      TOGETHER_API_KEY: ${TOGETHER_API_KEY:-}
      XAI_API_KEY: ${XAI_API_KEY:-}
      OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT:-}
      # Local OpenAI-compatible servers (LM Studio / vLLM / llama.cpp / …).
      LMSTUDIO_ENDPOINT: ${LMSTUDIO_ENDPOINT:-}
      OPENAI_COMPAT_ENDPOINT: ${OPENAI_COMPAT_ENDPOINT:-}
      OPENAI_COMPAT_API_KEY: ${OPENAI_COMPAT_API_KEY:-}
      # Azure OpenAI managed path: both are required (deployment URL + key).
      AZURE_OPENAI_API_KEY: ${AZURE_OPENAI_API_KEY:-}
      AZURE_OPENAI_ENDPOINT: ${AZURE_OPENAI_ENDPOINT:-}

      # --- web push (empty = the push channel is off and says so) -----------
      # LC57.9. The public half is written into /env.js by docker-entrypoint.sh
      # because `pushManager.subscribe()` needs it in the browser; the private
      # half signs the VAPID token in the workflow worker and goes no further.
      # Unset means no subscribe call is ever made, so no permission prompt is
      # raised and the preferences panel says why — never a granted permission
      # with nothing behind it.
      VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
      VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
      VAPID_SUBJECT: ${VAPID_SUBJECT:-}

      # --- billing (empty = billing off; the app runs free-tier) -------------
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
      STRIPE_PRICE_PRO: ${STRIPE_PRICE_PRO:-}
      STRIPE_PRICE_BUSINESS: ${STRIPE_PRICE_BUSINESS:-}
      # The annual price ids. `server.mjs` has read both since the interval
      # toggle shipped and this file forwarded neither, so a deployment that had
      # configured annual billing in .env still answered `interval-not-configured`
      # to anyone who chose it — the same defect this file records against
      # LICENSE_SECRET, one variable along. Measured on 2026-08-24 against a
      # running container: zero environment entries containing "ANNUAL".
      STRIPE_PRICE_PRO_ANNUAL: ${STRIPE_PRICE_PRO_ANNUAL:-}
      STRIPE_PRICE_BUSINESS_ANNUAL: ${STRIPE_PRICE_BUSINESS_ANNUAL:-}

      # Where this deployment is reachable. Stripe redirects here after checkout,
      # the Google OAuth callback is built from it, and the licence email prints
      # it. The code's own fallback is localhost:8080, which is correct for a
      # laptop and wrong for every real install — and being unforwarded meant no
      # real install could correct it.
      APP_URL: ${APP_URL:-http://localhost:8080}

      # --- upload guard (ASVS 12.2.1) ---------------------------------------
      # Server-side file-type validation for attachment uploads. nginx routes
      # POST /storage/v1/object/ through the guard; these three decide what it
      # does. Defaults (off / the module's default allowlist / reject) are set
      # in docker/entrypoint.sh — leaving them unset here means the guard runs
      # as a transparent passthrough and the deployment's behaviour is
      # unchanged. Set UPLOAD_GUARD_MODE=enforce to reject uploads whose magic
      # bytes are not on the allowlist (see docs/security-asvs-review.md).
      UPLOAD_GUARD_MODE: ${UPLOAD_GUARD_MODE:-}
      UPLOAD_GUARD_ALLOWLIST: ${UPLOAD_GUARD_ALLOWLIST:-}
      UPLOAD_GUARD_UNKNOWN: ${UPLOAD_GUARD_UNKNOWN:-}

      # --- off-volume backups (P0, audit B1) --------------------------------
      # /data is ONE filesystem holding the database AND its dumps, so a volume
      # loss takes both and `backup.sh --check` FAILS with
      # `reason=not-shipped-offsite` until a dump has actually left it. Set
      # BACKUP_S3_BUCKET and a key pair and every verified dump is copied out.
      #
      # The credentials are forwarded here but NOT exported into the container's
      # environment: docker/entrypoint.sh stages them in a mode-400 file that
      # only backupd can read, because python-runner and plugin-runner exist to
      # execute code a builder wrote and would otherwise inherit them.
      #
      # AWS_ENDPOINT_URL points this at any S3-compatible store — MinIO, R2,
      # B2 — and is what the mechanism is tested against, since a real bucket
      # cannot be part of a test suite.
      BACKUP_S3_BUCKET: ${BACKUP_S3_BUCKET:-}
      BACKUP_S3_PREFIX: ${BACKUP_S3_PREFIX:-}
      BACKUP_S3_SSE: ${BACKUP_S3_SSE:-}
      BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-}
      BACKUP_WAL_SHIP_INTERVAL_SECONDS: ${BACKUP_WAL_SHIP_INTERVAL_SECONDS:-}
      BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-}
      BACKUP_REQUIRE_OFFSITE: ${BACKUP_REQUIRE_OFFSITE:-}
      AWS_ACCESS_KEY_ID: ${AWS_ACCESS_KEY_ID:-}
      AWS_SECRET_ACCESS_KEY: ${AWS_SECRET_ACCESS_KEY:-}
      AWS_SESSION_TOKEN: ${AWS_SESSION_TOKEN:-}
      AWS_REGION: ${AWS_REGION:-}
      AWS_ENDPOINT_URL: ${AWS_ENDPOINT_URL:-}

      # --- SQL synced tables (LC12.3) ---------------------------------------
      # Which database hosts a PostgreSQL synced table may reach — `host` or
      # `host:port`, comma-separated. Read this DIFFERENTLY from ALLOWED_IPS
      # above: empty is the default and empty means NO SQL SOURCE IS REACHABLE,
      # not "unrestricted". Under the other convention an unset value would let
      # any builder point a synced table at this container's own Postgres and
      # read every tenant's rows. There is no wildcard, and a wildcard value
      # stops the container at boot rather than being narrowed silently. See
      # the amendment on docs/decisions/009-no-raw-database-driver.md.
      SQL_SOURCE_HOSTS: ${SQL_SOURCE_HOSTS:-}

      # The gateway the proxy reaches Postgres through, and three timing knobs.
      # Same defaults the code carries, restated here so they are visible and
      # overridable rather than only discoverable by reading server.mjs.
      SUPABASE_INTERNAL_URL: ${SUPABASE_INTERNAL_URL:-http://kong:8000}
      SESSION_CHECK_TTL_MS: ${SESSION_CHECK_TTL_MS:-5000}
      API_BUDGET_TTL_MS: ${API_BUDGET_TTL_MS:-30000}
      INBOUND_TOLERANCE_SEC: ${INBOUND_TOLERANCE_SEC:-300}

    volumes:
      # ONE volume where there were two (db-data, storage-data). /data/pgdata is
      # the database, /data/storage is the attachments backend.
      - appdor-data:/data

    # Arbitrary workflow Python runs in this container (see the Dockerfile
    # header). It runs as an unprivileged user, and this stops that user from
    # gaining anything through a setuid binary. `read_only: true` is NOT
    # possible here and its absence is the honest cost of one container:
    # Postgres writes to /data and storage-api writes to /data/storage.
    security_opt:
      - no-new-privileges:true
    # AUDIT P1 (2026-08-19): the container runs root-managed services and
    # executes tenant-authored Python. Drop the capabilities nothing here uses
    # (a container that cannot create device nodes, craft raw packets, or set
    # file capabilities has a smaller surface if any service is compromised),
    # bound its memory and pids so a runaway cannot starve the host, and make
    # /tmp a small noexec tmpfs — Python needs a HOME but nothing legitimately
    # needs executable scratch.
    #
    # cap_drop ALL is NOT possible here, and this comment is why the list is
    # short: supervisord's user=nobody/postgres/runner directives and the
    # entrypoint's su-exec calls setuid()/setgid() (CAP_SETUID/SETGID), nginx
    # binds :80 (CAP_NET_BIND_SERVICE), postgres initdb needs CHOWN/DAC_OVERRIDE/
    # FOWNER/FSETID, and the audit harness pings services (KILL). The safe
    # reduction is the three capabilities above; the previous multi-container
    # topology dropped the same three.
    cap_drop:
      - MKNOD
      - NET_RAW
      - SETFCAP
    mem_limit: 4g
    pids_limit: 2048
    tmpfs:
      # python-runner's HOME. Keeps interpreter scratch off the writable layer,
      # size-capped, and noexec so a compromised process cannot drop a binary
      # there and run it. mode=1777: workflow-worker mkdtemp()s step sources
      # into TMPDIR as nobody, and python-runner's HOME is /tmp as uid 10001 —
      # the default 2755 (inherited from the image) lets neither of them write.
      - /tmp:size=64m,noexec,nosuid,mode=1777
    # AUDIT P1: unbounded default json-file logs eventually fill the host disk.
    # Cap the rotation so a noisy service degrades to dropping old logs rather
    # than taking the machine down with it.
    logging:
      driver: json-file
      options:
        max-size: "20m"
        max-file: "5"

    # ONE verdict, ELEVEN probes. `docker/healthcheck.mjs` asks each internal
    # service the same real question its own compose healthcheck asked — GoTrue
    # by the refresh path, storage against a bucket list, web against its
    # injected config — and fails if any one is down. It also checks the two
    # PostgREST replicas, which were previously unmonitorable: their image
    # contained exactly one executable and nothing in it could make an HTTP
    # request to itself.
    #
    # start_period is 90s because a FIRST boot runs initdb, 86 schema files,
    # GoTrue's 49 migrations and storage-api's, in sequence, before anything can
    # answer. Subsequent boots are far quicker; the window costs nothing then.
    healthcheck:
      test: ["CMD", "/appdor/healthcheck.sh"]
      interval: 30s
      timeout: 20s
      retries: 3
      start_period: 90s
    restart: unless-stopped

  # ---------------------------------------------------------------------------
  # DEV PROFILE — not started by a plain `docker compose up`.
  #
  #   docker compose --profile dev up
  #
  # Requires PG_LISTEN='*' so these two containers can reach the database over
  # the compose network. That is the reachability the old `db` service always
  # had, and it is still published to nothing.
  # ---------------------------------------------------------------------------
  meta:
    image: supabase/postgres-meta:v0.83.2
    container_name: appdor-meta
    profiles: ["dev"]
    depends_on:
      appdor:
        condition: service_healthy
    environment:
      PG_META_PORT: 8080
      PG_META_DB_HOST: appdor
      PG_META_DB_PORT: 5432
      PG_META_DB_NAME: ${POSTGRES_DB:-postgres}
      PG_META_DB_USER: supabase_admin
      PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD}
    restart: unless-stopped

  studio:
    image: supabase/studio:latest
    container_name: appdor-studio
    profiles: ["dev"]
    depends_on:
      - meta
    ports:
      # BOUND TO LOOPBACK DELIBERATELY. Docker's default publishing is 0.0.0.0,
      # and Studio has NO authentication of its own: it is not behind the
      # gateway, it holds SERVICE_ROLE_KEY, and its pg-meta proxy will run
      # arbitrary SQL as supabase_admin for anyone who can open the port.
      #
      # Verified before this line existed: an unauthenticated POST to
      # /api/platform/pg-meta/default/query from another machine returned
      # `{"current_user":"supabase_admin", ...}` and a count of auth.users.
      # That bypasses every RLS policy, every SECURITY DEFINER check and the
      # whole realm-owner model at once.
      #
      # The app (8080) and the gateway (8000) stay on 0.0.0.0 on purpose — they
      # ARE the product's public surface and enforce API keys and RLS. Studio is
      # an unauthenticated admin console and is not.
      - "127.0.0.1:${STUDIO_PORT:-3001}:3000"
    environment:
      # Next.js binds to its container HOSTNAME, so Studio listened only on
      # 172.18.x.x:3000 — never on loopback. The image's own healthcheck fetches
      # http://localhost:3000/api/platform/profile, so it could not pass: 13,592
      # consecutive failures while the service answered 200 the whole time.
      HOSTNAME: 0.0.0.0
      STUDIO_PG_META_URL: http://meta:8080
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      SUPABASE_URL: http://appdor:8000
      SUPABASE_PUBLIC_URL: ${SUPABASE_URL:-http://localhost:8000}
      SUPABASE_ANON_KEY: ${ANON_KEY}
      SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
    restart: unless-stopped

  # MAIL PROFILE — a local mail catcher, for developers with no SMTP account.
  #
  #   docker compose --profile mail up -d
  #
  # GoTrue's password-reset and confirmation mail needs a relay, and every
  # hosted relay wants a signed-up account and a verified sending domain before
  # it will carry the first message. That is a real obstacle to testing the
  # reset flow at all, and the workaround people reach for — leaving
  # GOTRUE_MAILER_AUTOCONFIRM=true so no mail is ever needed — means the flow
  # ships untested.
  #
  # Mailpit accepts every message on :1025 without credentials and shows it in
  # a web UI instead of delivering it. Point the six GOTRUE_SMTP_* at it:
  #
  #   GOTRUE_SMTP_HOST=mailpit
  #   GOTRUE_SMTP_PORT=1025
  #   GOTRUE_SMTP_USER=
  #   GOTRUE_SMTP_PASS=
  #   GOTRUE_SMTP_ADMIN_EMAIL=no-reply@appdor.local
  #   GOTRUE_SMTP_SENDER_NAME=Appdor
  #   GOTRUE_MAILER_AUTOCONFIRM=false
  #
  # `.env.example` carries these same lines under "DEV MAIL IN ONE STEP", as a
  # block to uncomment rather than a list to retype, and with the outbound
  # (EMAIL_PROVIDER) half beside them — the two transports are configured
  # separately and setting only one is the usual way this goes wrong.
  # AUTH_EMAIL_ENABLED is not in the list any more: it is derived from
  # GOTRUE_SMTP_HOST at boot.
  #
  # DEVELOPMENT ONLY. Nothing leaves the host, so a deployment that points real
  # users at this silently swallows every reset mail they ask for.
  mailpit:
    image: axllent/mailpit:v1.21
    container_name: appdor-mailpit
    profiles: ["dev", "mail"]
    environment:
      # Keep the catcher's own footprint bounded; it is a debugging surface,
      # not a mailbox.
      MP_MAX_MESSAGES: 500
      MP_SMTP_AUTH_ACCEPT_ANY: 1
      MP_SMTP_AUTH_ALLOW_INSECURE: 1
    ports:
      # LOOPBACK, like Studio above and for the same reason: the UI reads every
      # message the stack sends, including password-reset links, and has no
      # authentication in front of it.
      - "127.0.0.1:${MAILPIT_UI_PORT:-8025}:8025"
      # :1025 is NOT published. The appdor container reaches it over the compose
      # network by name; nothing on the host needs to.
    restart: unless-stopped

volumes:
  appdor-data:
