# =============================================================================
# GRAVITY PLATFORM DOCKER COMPOSE
# =============================================================================
#
# LAYER 1: CORE PLATFORM (Pre-compiled via GitHub Actions → DOCR)
#   - unoverse (the platform runtime — engine in-process + node plane + native MCP),
#     canvas, umap, memory. Code protected, pulled as images.
#
# LAYER 2: PLATFORM-DEV MOUNTS (monorepo only — sync-starter strips them)
#   - apps/unoverse/design    — design (components, templates, styles)
#   - apps/unoverse/prompts  — behavior (skills + blocks)
#   In a STARTER/customer universe these folders do not exist: authoring is
#   Studio's (publish over the API) and content is DB-driven (marketplace).
#   Everything else under apps/unoverse (engine, server, web, tools) is image-only.
#
# OBSERVABILITY (runs by default):
#   - Dozzle — single-container live log viewer (http://localhost:8080)
#
# =============================================================================

# Bounds the disk Docker's json-file driver uses per container: rotate at 10 MB,
# keep 3 files → 30 MB ceiling each. Applied to every service via *default-logging.
# This is what caps log growth on the host (Dozzle just views it; it stores nothing).
x-logging: &default-logging
  driver: json-file
  options:
    max-size: "10m"
    max-file: "3"

services:
  # ===========================================================================
  # LAYER 1: CORE PLATFORM (DigitalOcean Container Registry)
  # ===========================================================================
  # Images pulled from: registry.digitalocean.com/gravity-repo/

  # ═══════════════════════════════════════════════════════════════════════════
  # RETIRED at the One Engine completion (ONE_ENGINE.md step 5): the gateway has
  # zero jobs — the unoverse container serves /api, /ws/gravity, /components,
  # and the Content Engine. ROLLBACK: uncomment this block and repoint the
  # ingress api.{domain} target back to :4100. Image still published by CI.
  # ═══════════════════════════════════════════════════════════════════════════
  # server:
  #   image: registry.digitalocean.com/gravity-repo/server:latest
  #   ports:
  #     - "4100:4100"
  #   volumes:
  #     - ./packages/design-system:/app/packages/design-system
  #   environment:
  #     - NODE_ENV=production
  #     - PORT=4100
  #     - DESIGN_SYSTEM_PATH=/app/packages/design-system
  #     - REDIS_HOST=${REDIS_HOST:-}
  #     - REDIS_PORT=${REDIS_PORT:-}
  #     - REDIS_PASSWORD=${REDIS_PASSWORD:-}
  #     - REDIS_TLS=${REDIS_TLS:-}
  #     - REDIS_NAMESPACE=${REDIS_NAMESPACE:-}
  #     - AUTH_ISSUER=${AUTH_ISSUER:-}
  #     - AUTH_AUDIENCE=${AUTH_AUDIENCE:-}
  #     - WORKFLOW_SERVICE_URL=http://unoverse:4101
  #     - UNOVERSE_SERVICE_URL=http://unoverse:4106
  #   labels:
  #     - "com.docker.compose.service=server"
  #   restart: unless-stopped

  # ═══════════════════════════════════════════════════════════════════════════
  # RETIRED at the One Engine cutover (ONE_ENGINE.md step 5): the workflow engine
  # now runs IN-PROCESS in the unoverse container, which serves the identical
  # :4101 surface. ROLLBACK: uncomment this block, remove DATABASE_URL from the
  # unoverse service (its capability gate — prevents double-boot), and point
  # WORKFLOW_SERVICE_URL back to http://workflow:4101 everywhere. The
  # gravity-workflow image is still published by CI for exactly this purpose.
  # ═══════════════════════════════════════════════════════════════════════════
  # workflow:
  #   image: registry.digitalocean.com/gravity-repo/workflow:latest
  #   ports:
  #     - "4101:4101"
  #   environment:
  #     - NODE_ENV=production
  #     - PORT=4101
  #     - REDIS_HOST=${REDIS_HOST:-}
  #     - REDIS_PORT=${REDIS_PORT:-}
  #     - REDIS_PASSWORD=${REDIS_PASSWORD:-}
  #     - REDIS_TLS=${REDIS_TLS:-}
  #     - REDIS_NAMESPACE=${REDIS_NAMESPACE:-}
  #     - DATABASE_URL=${DATABASE_URL:-}
  #     - SERVER_WS_URL=ws://server:4100
  #     - UMAP_SERVICE_URL=http://umap:5001
  #     - UNOVERSE_SERVICE_URL=http://unoverse:4106
  #   labels:
  #     - "com.docker.compose.service=workflow"
  #   depends_on:
  #     unoverse:
  #       condition: service_healthy
  #   restart: unless-stopped

  # Canvas UI - React frontend (lightweight - static files via nginx)
  # Runtime config injected via docker-entrypoint.sh at container start
  canvas:
    image: registry.digitalocean.com/gravity-repo/canvas:latest
    ports:
      - "3001:80"
    environment:
      - VITE_API_URL=${API_URL:-${DOMAIN:+https://api.${DOMAIN:-}}}
      - VITE_SERVER_WS_URL=${VITE_SERVER_WS_URL:-${DOMAIN:+wss://api.${DOMAIN:-}}}
      - VITE_AUTH_ISSUER=${AUTH_ISSUER:-}
      - VITE_AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-}
      - VITE_AUTH_AUDIENCE=${AUTH_AUDIENCE:-}
      # Unoverse public URL (JWT-gated :4105) — the browser calls it directly for
      # component/template defs (MCP), prompt-blocks, and plugin management. This is the
      # SAME :4105 backend as api.<domain>, so default to the api host (one public entry,
      # one cert, no extra DNS). UNOVERSE_URL can still override for a dedicated MCP host.
      - VITE_UNOVERSE_SERVICE_URL=${UNOVERSE_URL:-${DOMAIN:+https://api.${DOMAIN:-}}}
      # THE BROWSER'S MEMORY UPSTREAM, AND IT IS THE PUBLIC JWT LANE (:4104). Its own
      # variable, because `MEMORY_SERVICE_URL` means the opposite thing to a server-to-
      # server caller: the engine uses that name for the TOKENLESS platform lane (:4114),
      # and the canvas entrypoint was reading the same name for its nginx upstream. The
      # dashboard therefore proxied the browser at :4114, whose /api/* routes do not
      # exist — every memory page call 404'd on every deployed universe.
      #
      # The 404 was the lucky part. The platform lane asserts identity instead of proving
      # it, so a browser reaching it would read and write ANY user's memory with no token.
      # A browser must only ever reach :4104, which authenticates every request.
      - MEMORY_PUBLIC_URL=http://memory:4104
    depends_on:
      - unoverse
    logging: *default-logging
    restart: unless-stopped

  # Studio is NOT a platform image (removed 2026-07-28): it ships from npm and
  # connects to a universe over the API with a publish key.

  # UMAP Service - Python ML service.
  #
  # THE ENGINE REACHES IT AS http://umap:5001 OVER THE DOCKER NETWORK, which needs no
  # published port at all. It used to read "5001:5001", which binds 0.0.0.0 and put an ML
  # service on the internet, and until 2026-08-30 that service also failed OPEN:
  # its `require_auth` returned the handler unguarded whenever OIDC was unconfigured, and no
  # AUTH_ISSUER was ever passed here. So `/train` answered anyone who could reach the port.
  #
  # It was the only service in this file that was both externally bound and ungated: 4101 and
  # 4106 are loopback-pinned with comments saying never to widen them, and 4105 is published
  # but JWT-gated. This one had neither half.
  #
  # NOT PUBLISHED AT ALL. Owner ruling 2026-08-30: this is internal to Docker, full stop.
  # There is no `ports:` block below and that is the whole point — a service reachable only
  # over the compose network cannot be bound to the wrong interface by a later edit, because
  # there is no line to get wrong. 4101 and 4106 publish to loopback because a developer's
  # tooling on the host genuinely needs them; nothing on the host needs this.
  #
  # Auth stays configured anyway. The network boundary and the gate are not alternatives,
  # and this service is the proof: it had a `ports:` line and a fail-open decorator, so
  # losing either one lost everything.
  umap:
    image: registry.digitalocean.com/gravity-repo/umap:latest
    environment:
      # app.py refuses to boot if auth is on and these are missing (assert_auth_startup),
      # which is the point: an unconfigured deployment fails loudly instead of open.
      - AUTH_ENABLED=${AUTH_ENABLED:-true}
      - AUTH_ISSUER=${AUTH_ISSUER}
      - AUTH_AUDIENCE=${AUTH_AUDIENCE}
    volumes:
      - umap_models:/app/models
    logging: *default-logging
    restart: unless-stopped

  # Document service - Python, reads a file into its structure (text, tables as rows,
  # figures, lists, dates, provenance). docs/unoverse/data/documents/README.md.
  #
  # REACHED AS http://documents:5002 OVER THE DOCKER NETWORK BY THE MARKETPLACE NODES that
  # name it (ParseDocument, RenderDocument, InventoryFolder), through the host on their
  # credential; the engine never learns a URL for it. NOT PUBLISHED, no `ports:` block, for
  # the same reason umap has none (above). Same auth rules: refuses to boot enabled without
  # OIDC, or disabled in production.
  #
  # Parsing is CPU-bound and a worker holds the layout models: DOCUMENTS_WORKERS is the one
  # knob, two by default. The models are baked into the image (no egress, instant boot); the
  # parse cache is a volume so an unchanged file in a re-read pack is a lookup.
  documents:
    image: registry.digitalocean.com/gravity-repo/documents:latest
    environment:
      - AUTH_ENABLED=${AUTH_ENABLED:-true}
      - AUTH_ISSUER=${AUTH_ISSUER}
      - AUTH_AUDIENCE=${AUTH_AUDIENCE}
      - DOCUMENTS_WORKERS=${DOCUMENTS_WORKERS:-2}
    volumes:
      - documents_cache:/app/cache
    logging: *default-logging
    restart: unless-stopped

  # Unoverse - node plane (author·distribute·execute): the relocated node runtime +
  # workbench + MCP. TWO listeners:
  #   :4105 = PUBLIC (MCP + workbench), JWT-gated — published below.
  #   :4106 = INTERNAL node runtime (catalog + execute) + builder MCP, UNGATED —
  #           published LOOPBACK-ONLY (127.0.0.1). The Docker network and the host
  #           machine itself are the trust boundary: nothing off the machine can
  #           reach it. This is how a dev's local Claude Code drives the workflow
  #           builder (/mcp-builder); never widen it to "4106:4106".
  unoverse:
    image: registry.digitalocean.com/gravity-repo/unoverse:latest
    # For local development, build from source instead:
    # build:
    #   context: .
    #   dockerfile: apps/unoverse/Dockerfile
    ports:
      - "4105:4105" # PUBLIC port
      - "127.0.0.1:4106:4106" # builder MCP + runtime, LOOPBACK ONLY — local Claude Code connects here; never "4106:4106"
      # LOOPBACK ONLY. The engine surface is unauthenticated; a plain "4101:4101" is
      # internet-reachable on a VM because Docker's iptables rules BYPASS ufw — the
      # harden.yml firewall does not protect published ports. Host tooling (./unoverse key,
      # health checks) still reaches it via 127.0.0.1. Never widen to "4101:4101".
      - "127.0.0.1:4101:4101" # workflow surface (engine in-process)
    environment:
      # PRODUCTION UNLESS THE DEVELOPER SAYS OTHERWISE. This was fixed at "production", and
      # auth-off refuses to boot in production — correctly — so a LOCAL universe running these
      # images could never turn auth off at all: AUTH_ENABLED=false and NODE_ENV=production
      # are a permanent contradiction, and the platform was right to refuse both ways round.
      #
      # The guard is not weakened: unset still means production, and terraform's rendered env
      # never sets this, so a deployed universe cannot drift. Turning it off is a deliberate
      # line in a developer's own .env.
      - NODE_ENV=${NODE_ENV:-production}
      # Studio is a separate app, launched by the CLI from source. It is not in this image
      # and has no switch here: the Docker runtime serves MCP + API only.
      - UNOVERSE_PORT=4105
      # PUBLIC origin the app's OWN /mcp + /stream live at — baked into each MCP app's
      # widget CSP (openai/widgetCSP.connect_domains). MUST be the api.<domain> host, or
      # the widget (running in ChatGPT's iframe) is CSP-blocked from calling back and renders
      # blank. Derived from DOMAIN like every other HTTPS URL (set DOMAIN only); empty in dev,
      # where the code falls back to localhost:4105.
      - UNOVERSE_PUBLIC_URL=${DOMAIN:+https://api.${DOMAIN:-}}
      - UNOVERSE_RUNTIME_PORT=4106
      # The internal listeners (:4106 runtime, :4101 engine) bind LOOPBACK by default in
      # code. Inside the container they must bind all interfaces (docker-proxy and sibling
      # containers connect to the container IP); the loopback-only port mappings above and
      # the Docker network are the boundary here.
      - UNOVERSE_INTERNAL_BIND=0.0.0.0
      # THE FLAG TRAVELS WITH THE ISSUER. .env could say AUTH_ENABLED=false and no
      # container ever saw it: compose forwarded the issuer and audience and not the flag,
      # so the server defaulted to enabled — correctly — and refused to boot on a local
      # universe with no OIDC. The developer's own setting was invisible to the thing it
      # configures.
      - AUTH_ENABLED=${AUTH_ENABLED:-}
      - AUTH_ISSUER=${AUTH_ISSUER:-}
      # AND SO DOES THE CLIENT ID. It is documented everywhere as a browser value
      # (VITE_AUTH_CLIENT_ID) because for a long time only the browser needed it. The
      # server needs it now: `/.well-known/unoverse-universe` is how Studio learns where
      # to send someone to sign in, and it answers from AUTH_CLIENT_ID. Unforwarded, that
      # endpoint returned `clientId: null` and Studio correctly refused to connect,
      # against a universe whose .env had the value all along. Public by definition — an
      # OAuth client id is not a secret.
      - AUTH_AUDIENCE=${AUTH_AUDIENCE:-}
      - AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-}
      # WHERE THE MARKETPLACE IS. The catalogue this universe can install from, served as
      # static files (catalogue.json plus each item's own definition). No default: a baked
      # URL ties the image to one deployment and hides a missing setting. Empty means no
      # remote catalogue, and the universe offers only what is on its own disk, which is
      # what the monorepo and an air-gapped install both want.
      - UNOVERSE_MARKETPLACE_URL=${UNOVERSE_MARKETPLACE_URL:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
      # Optional: page-intelligence features (promote-page extraction).
      - HYPERBROWSER_API_KEY=${HYPERBROWSER_API_KEY:-}
      # TEMP test shim: SearchPlaces/SearchAPI env fallback when no credential is stored.
      - SERVER_WS_URL=ws://server:4100
      # In-process loopback: the engine runs IN THIS container and binds :4101.
      - WORKFLOW_SERVICE_URL=http://127.0.0.1:4101
      # Lifecycle bridge: subscribes gravity:client:* and projects WORKFLOW_STATE
      # onto MCP /stream sessions (lifecycleBridge.ts).
      - REDIS_HOST=${REDIS_HOST:-}
      - REDIS_PORT=${REDIS_PORT:-}
      - REDIS_PASSWORD=${REDIS_PASSWORD:-}
      - REDIS_TLS=${REDIS_TLS:-}
      # ── ONE_ENGINE CUTOVER ACTIVE: DATABASE_URL is the capability gate that boots
      # the in-process engine (the retired workflow block above is the rollback).
      - DATABASE_URL=${DATABASE_URL:-}
      # Connection budget (INFRASTRUCTURE.md, Postgres law) — per-size values
      # rendered by the infra Terraform; unset in local dev = code defaults (10).
      - DB_POOL_ENGINE=${DB_POOL_ENGINE:-}
      - DB_POOL_ENGINE_LEGACY=${DB_POOL_ENGINE_LEGACY:-}
      # Encrypts stored credentials at rest. WITHOUT this the engine falls back to a
      # key committed in source (SECURITY.md § Credential encryption at rest) — set it
      # in every deployment: openssl rand -base64 32
      - CREDENTIAL_ENCRYPTION_KEY=${CREDENTIAL_ENCRYPTION_KEY:-}
      - REDIS_NAMESPACE=${REDIS_NAMESPACE:-}
      - UMAP_SERVICE_URL=http://umap:5001
      # The document service, reached the same way: the engine proxies the marketplace's
      # document nodes to it (routes/documents.ts), because a node call must be https or
      # loopback and this service is neither.
      - DOCUMENTS_SERVICE_URL=http://documents:5002
      # Agent nodes POST conversation turns to the memory server (ingestConversationTurn).
      # Without this the code falls back to http://localhost:4104 — nothing inside THIS
      # container — so every ingest silently fails (fire-and-forget) and memory never
      # updates on deployed envs. The memory server is the sibling `memory` container.
      - MEMORY_SERVICE_URL=http://memory:4114
      # Marketplace installs node packages here at runtime (npm) — nodes are NOT baked
      # into the image; only the core workflow nodes ship with the platform (in the
      # workflow image). This volume persists installs across restarts.
      - PLUGINS_DIR=/app/plugins
      # Dev-editable folders are mounted over the image below; NODE_PATH resolves
      # their hoisted deps from the local workspace node_modules. The plugins dir is
      # also on NODE_PATH so locally-built component nodes (design/marketplace/nodes/*, which
      # live OUTSIDE /app/plugins) can resolve @unoverse-platform/plugin-base — the only
      # copy carrying a built dist. Without this the runtime hits MODULE_NOT_FOUND and
      # the nodes show "Package not installed".
      - NODE_PATH=/app/host_node_modules:/app/plugins/node_modules
    volumes:
      - unoverse_plugins:/app/plugins
      # THE NODE RUNTIME IS IN THE IMAGE (MARKETPLACE.md §5a, reversed 2026-09-03). There was
      # a `unoverse_base:/app/base` volume here between 2026-08-30 and 2026-09-03, holding a
      # base the entrypoint installed from npm at startup. Both are gone: base is compiled in
      # from the workspace, so the image and its runtime cannot disagree.
      # ── Platform-dev mounts (monorepo only; sync-starter strips these two
      # lines from the starter's copy — a customer universe serves what the DB
      # says). Local edits are picked up on restart; design/prompts are data, no build.
      - ./apps/unoverse/design:/app/apps/unoverse/design # every authored kind: components, templates, styles, skills, blocks, nodes
      - ./node_modules:/app/host_node_modules # Hoisted deps for locally-built nodes
    healthcheck:
      # Ready = internal runtime port up (nodes are loaded into memory BEFORE it binds).
      test:
        ["CMD", "node", "-e", "fetch('http://127.0.0.1:4106/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
      interval: 5s
      timeout: 3s
      retries: 12
      start_period: 10s
    labels:
      - "com.docker.compose.service=unoverse"
    logging: *default-logging
    restart: unless-stopped

  # RETIRED (2026-07): the standalone mcp-server (external OpenAI-Apps/Claude MCP on
  # :4103) is superseded by the unoverse server's NATIVE MCP — public :4105/mcp and
  # internal :4106/mcp (the app-discovery/workflow-MCP path). Source moved to
  # _legacy/mcp-server; the last gravity-repo/mcp-server image stays in DOCR as the
  # rollback hatch. ROLLBACK: restore apps/mcp-server from _legacy, re-add to the CI
  # matrix, uncomment this block.
  # mcp-server:
  #   image: registry.digitalocean.com/gravity-repo/mcp-server:latest
  #   ports:
  #     - "4103:4103"
  #   environment:
  #     - NODE_ENV=production
  #     - DOMAIN=${DOMAIN:-}
  #     - WORKFLOW_SERVICE_URL=http://unoverse:4101
  #     - AUTH_ISSUER=${AUTH_ISSUER:-}
  #     - AUTH_AUDIENCE=${AUTH_AUDIENCE:-}
  #   labels:
  #     - "com.docker.compose.service=mcp-server"
  #   depends_on:
  #     - unoverse
  #   logging: *default-logging
  #   restart: unless-stopped

  # Memory Server - Evidence-based user memory system (ambient pub/sub, Redis Streams)
  memory:
    image: registry.digitalocean.com/gravity-repo/memory:${TAG:-latest}
    ports:
      - "4104:4104"
    environment:
      # PRODUCTION UNLESS THE DEVELOPER SAYS OTHERWISE. This was fixed at "production", and
      # auth-off refuses to boot in production — correctly — so a LOCAL universe running these
      # images could never turn auth off at all: AUTH_ENABLED=false and NODE_ENV=production
      # are a permanent contradiction, and the platform was right to refuse both ways round.
      #
      # The guard is not weakened: unset still means production, and terraform's rendered env
      # never sets this, so a deployed universe cannot drift. Turning it off is a deliberate
      # line in a developer's own .env.
      - NODE_ENV=${NODE_ENV:-production}
      - PORT=4104
      - DATABASE_URL=${DATABASE_URL:-}
      # Connection budget (INFRASTRUCTURE.md) — Terraform-rendered per size.
      - DB_POOL_MEMORY=${DB_POOL_MEMORY:-}
      - REDIS_HOST=${REDIS_HOST:-}
      - REDIS_PORT=${REDIS_PORT:-}
      - REDIS_PASSWORD=${REDIS_PASSWORD:-}
      - REDIS_TLS=${REDIS_TLS:-}
      - REDIS_NAMESPACE=${REDIS_NAMESPACE:-}
      # THE FLAG TRAVELS WITH THE ISSUER. .env could say AUTH_ENABLED=false and no
      # container ever saw it: compose forwarded the issuer and audience and not the flag,
      # so the server defaulted to enabled — correctly — and refused to boot on a local
      # universe with no OIDC. The developer's own setting was invisible to the thing it
      # configures.
      - AUTH_ENABLED=${AUTH_ENABLED:-}
      - AUTH_ISSUER=${AUTH_ISSUER:-}
      # AND SO DOES THE CLIENT ID. It is documented everywhere as a browser value
      # (VITE_AUTH_CLIENT_ID) because for a long time only the browser needed it. The
      # server needs it now: `/.well-known/unoverse-universe` is how Studio learns where
      # to send someone to sign in, and it answers from AUTH_CLIENT_ID. Unforwarded, that
      # endpoint returned `clientId: null` and Studio correctly refused to connect,
      # against a universe whose .env had the value all along. Public by definition — an
      # OAuth client id is not a secret.
      - AUTH_AUDIENCE=${AUTH_AUDIENCE:-}
      - AUTH_CLIENT_ID=${AUTH_CLIENT_ID:-}
      - OPENAI_API_KEY=${OPENAI_API_KEY:-}
      - WORKFLOW_SERVICE_URL=http://unoverse:4101
    labels:
      - "com.docker.compose.service=memory"
    depends_on:
      - unoverse
    logging: *default-logging
    restart: unless-stopped

  # unoverse-runtime REMOVED 2026-07-28: WIP, excluded from the platform
  # (docs/architecture/deployment/INFRASTRUCTURE.md). Returns when the user declares it ready.

  # ===========================================================================
  # OBSERVABILITY — log viewer (runs by default)
  # ===========================================================================
  # Dozzle: a single ~30 MB container that tails/searches every container's logs
  # in a live web UI. Stores NOTHING (streams from the Docker socket on demand),
  # needs no config, no datasource, no dashboards. Replaces the old
  # Promtail→Loki→Grafana stack. Long-term retention is bounded by the json-file
  # log rotation set on each service (x-logging anchor above).
  #
  # No `profiles:` — logs are core, so dozzle starts with a plain `up -d` and
  # survives `unoverse update` (a profiled service is skipped by up -d and would
  # silently stay down after every update).
  dozzle:
    image: amir20/dozzle:latest
    container_name: gravity-dozzle
    ports:
      - "8080:8080"
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
    logging: *default-logging
    restart: unless-stopped

volumes:
  umap_models:
  documents_cache: # parsed replies by content hash plus parser version
  unoverse_plugins: # Marketplace-installed node packages (persisted across restarts)
