# Dezky — Local Development Stack
#
# Start: docker compose up -d
# Logs:  docker compose logs -f [service]
# Stop:  docker compose down
# Reset: docker compose down -v   (WARNING: deletes all data)
#
# Prerequisites:
#   1. mkcert root CA installed (mkcert -install)
#   2. Wildcard cert generated in ./certs/dezky.local.pem
#   3. /etc/hosts entries added (run scripts/setup-hosts.sh)
#   4. .env file created from .env.example

name: dezky

networks:
  dezky:
    name: dezky
    driver: bridge

volumes:
  postgres_data:
  mongo_data:
  redis_data:
  authentik_media:
  authentik_certs:
  authentik_templates:
  stalwart_data:
  ocis_config:
  ocis_data:
  ocis_audit_log:
  portal_node_modules:
  platform_api_node_modules:
  operator_node_modules:
  website_node_modules:
  booking_node_modules:
  # MinIO data (S3-compatible cold storage for audit archives). Production
  # swaps the endpoint to Hetzner Object Storage and this volume goes away.
  minio_data:

services:
  # ─────────────────────────────────────────────────────────────────
  # Traefik — Reverse proxy with TLS termination via mkcert
  # ─────────────────────────────────────────────────────────────────
  traefik:
    image: traefik:v3.7
    container_name: dezky-traefik
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "8443:8080"  # Dashboard
    volumes:
      - ${DOCKER_SOCKET:-/var/run/docker.sock}:/var/run/docker.sock:ro
      - ./configs/traefik/traefik.yml:/etc/traefik/traefik.yml:ro
      - ./configs/traefik/dynamic.yml:/etc/traefik/dynamic.yml:ro
      - ./certs:/certs:ro
    networks:
      dezky:
        aliases:
          - traefik.dezky.local
          - auth.dezky.local
          - dezky.local
          - www.dezky.local
          - app.dezky.local
          - operator.dezky.local
          - api.dezky.local
          - files.dezky.local
          - mail.dezky.local
          - office.dezky.local
          - collaboration.dezky.local
    labels:
      - traefik.enable=true
      - traefik.http.routers.dashboard.rule=Host(`traefik.dezky.local`)
      - traefik.http.routers.dashboard.service=api@internal
      - traefik.http.routers.dashboard.tls=true

  # ─────────────────────────────────────────────────────────────────
  # PostgreSQL — Shared RDBMS (Authentik, OCIS)
  # ─────────────────────────────────────────────────────────────────
  postgres:
    image: postgres:16-alpine
    container_name: dezky-postgres
    restart: unless-stopped
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_ROOT_PASSWORD}
      POSTGRES_DB: postgres
      AUTHENTIK_DB_PASSWORD: ${AUTHENTIK_DB_PASSWORD}
      OCIS_DB_PASSWORD: ${OCIS_DB_PASSWORD}
    volumes:
      - postgres_data:/var/lib/postgresql/data
      - ./configs/postgres/init.sh:/docker-entrypoint-initdb.d/init.sh:ro
    networks: [dezky]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ─────────────────────────────────────────────────────────────────
  # MongoDB — Portal application data
  # ─────────────────────────────────────────────────────────────────
  mongo:
    image: mongo:7
    container_name: dezky-mongo
    restart: unless-stopped
    environment:
      MONGO_INITDB_ROOT_USERNAME: root
      MONGO_INITDB_ROOT_PASSWORD: ${MONGO_ROOT_PASSWORD}
      MONGO_INITDB_DATABASE: dezky
    volumes:
      - mongo_data:/data/db
    networks: [dezky]
    healthcheck:
      test: ["CMD", "mongosh", "--eval", "db.adminCommand('ping')"]
      interval: 10s
      timeout: 5s
      retries: 5

  # ─────────────────────────────────────────────────────────────────
  # Redis — Cache + session store
  # ─────────────────────────────────────────────────────────────────
  redis:
    image: redis:7-alpine
    container_name: dezky-redis
    restart: unless-stopped
    command: redis-server --save 60 1 --loglevel warning --requirepass ${REDIS_PASSWORD}
    volumes:
      - redis_data:/data
    networks: [dezky]
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"]
      interval: 10s
      timeout: 3s
      retries: 5

  # ─────────────────────────────────────────────────────────────────
  # MinIO — S3-compatible cold storage for audit archives (Phase 4).
  # Production swaps endpoint to Hetzner Object Storage; same protocol.
  # ─────────────────────────────────────────────────────────────────
  minio:
    image: minio/minio:latest
    container_name: dezky-minio
    restart: unless-stopped
    command: server /data --console-address ":9001"
    environment:
      MINIO_ROOT_USER: ${MINIO_ROOT_USER}
      MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}
    volumes:
      - minio_data:/data
    networks: [dezky]
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 10s
      timeout: 3s
      retries: 5
    labels:
      - traefik.enable=true
      # Optional: expose MinIO admin UI behind Traefik. Dev only — production
      # uses Hetzner's console.
      - traefik.http.routers.minio.rule=Host(`minio.dezky.local`)
      - traefik.http.routers.minio.tls=true
      - traefik.http.services.minio.loadbalancer.server.port=9001

  # One-shot init container that creates the audit bucket if it doesn't
  # exist. Idempotent — re-running is a no-op. Exits cleanly so docker
  # doesn't restart it.
  minio-init:
    image: minio/mc:latest
    container_name: dezky-minio-init
    depends_on:
      minio:
        condition: service_healthy
    networks: [dezky]
    entrypoint: >
      sh -c "
      mc alias set local http://minio:9000 ${MINIO_ROOT_USER} ${MINIO_ROOT_PASSWORD} &&
      mc mb --ignore-existing local/dezky-audit &&
      echo 'MinIO bucket dezky-audit ready'
      "
    restart: "no"

  # ─────────────────────────────────────────────────────────────────
  # Authentik — Identity provider (OIDC/SAML SSO)
  # ─────────────────────────────────────────────────────────────────
  authentik-server:
    image: ghcr.io/goauthentik/server:2025.10
    container_name: dezky-authentik
    restart: unless-stopped
    # Override the image's entrypoint to run rebrand-web.sh first, then
    # chain to dumb-init -- ak (the image's normal entrypoint). The script
    # patches "Powered by authentik" → "Powered by Dezky" in the served
    # web bundles on every container start, since those filenames are
    # version-stamped and can't be safely bind-mounted across upgrades.
    # Runs as root (user "0") so the sed can write to /web/dist; the script
    # drops privileges back to uid 1000 before exec'ing ak.
    user: "0"
    entrypoint: ["/lifecycle/rebrand-web.sh"]
    command: ["server"]
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
      AUTHENTIK_DISABLE_UPDATE_CHECK: "true"
      AUTHENTIK_BOOTSTRAP_EMAIL: admin@dezky.local
      AUTHENTIK_BOOTSTRAP_PASSWORD: ${AUTHENTIK_BOOTSTRAP_PASSWORD}
      AUTHENTIK_BOOTSTRAP_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
      # Consumed by the operator-application blueprint's !Env references when
      # it create-provisions the dezky-operator OAuth provider on a fresh
      # environment. On an existing environment the provider already exists and
      # the blueprint's state:created leaves it untouched, so these are unused.
      OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
      OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
    volumes:
      - authentik_media:/media
      - authentik_certs:/certs
      - authentik_templates:/templates
      - ./configs/authentik/blueprints:/blueprints/custom:ro
      - ./configs/authentik/rebrand-web.sh:/lifecycle/rebrand-web.sh:ro
    networks: [dezky]
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.http.routers.authentik.rule=Host(`auth.dezky.local`)
      - traefik.http.routers.authentik.tls=true
      - traefik.http.services.authentik.loadbalancer.server.port=9000
      # Hide Authentik's end-user dashboard. Anything hitting
      # auth.dezky.local/ (root) or /if/user/* gets bounced to
      # app.dezky.local. Login flow paths (/flows/, /application/o/,
      # /api/, /static/, /if/flow/) and the admin UI (/if/admin/) keep
      # working. After OIDC sign-in the relying party callback fires
      # first, so users never land on /if/user/ during a normal flow —
      # this only matters for users who type auth.dezky.local directly.
      - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.regex=^https://auth\\.dezky\\.local(/|/if/user(/.*)?)$"
      - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.replacement=https://app.dezky.local/"
      - "traefik.http.middlewares.authentik-hide-dashboard.redirectregex.permanent=false"
      - traefik.http.routers.authentik.middlewares=authentik-hide-dashboard

  authentik-worker:
    image: ghcr.io/goauthentik/server:2025.10
    container_name: dezky-authentik-worker
    restart: unless-stopped
    command: worker
    environment:
      AUTHENTIK_REDIS__HOST: redis
      AUTHENTIK_REDIS__PASSWORD: ${REDIS_PASSWORD}
      AUTHENTIK_POSTGRESQL__HOST: postgres
      AUTHENTIK_POSTGRESQL__USER: authentik
      AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_DB_PASSWORD}
      AUTHENTIK_POSTGRESQL__NAME: authentik
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY}
      AUTHENTIK_ERROR_REPORTING__ENABLED: "false"
      # The worker is what discovers and applies blueprints, so it needs both
      # the blueprints mount (below) and the !Env values the operator-application
      # blueprint references when provisioning the provider on a fresh env.
      OPERATOR_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
      OPERATOR_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
    volumes:
      - authentik_media:/media
      - authentik_certs:/certs
      - authentik_templates:/templates
      - ./configs/authentik/blueprints:/blueprints/custom:ro
    networks: [dezky]
    depends_on:
      postgres:
        condition: service_healthy
      redis:
        condition: service_healthy

  # ─────────────────────────────────────────────────────────────────
  # Stalwart Mail — Mail server (SMTP/IMAP/JMAP/CalDAV/CardDAV/ActiveSync)
  # ─────────────────────────────────────────────────────────────────
  stalwart:
    image: stalwartlabs/stalwart:v0.16
    container_name: dezky-stalwart
    restart: unless-stopped
    ports:
      - "25:25"       # SMTP
      - "465:465"     # SMTPS
      - "587:587"     # Submission
      - "143:143"     # IMAP
      - "993:993"     # IMAPS
      - "4190:4190"   # ManageSieve
    environment:
      STALWART_FQDN: mail.dezky.local
      # Pin the recovery admin so it survives restarts. Without this, Stalwart
      # generates a one-time-shown password at first boot and discards it after
      # initial setup.
      STALWART_RECOVERY_ADMIN: admin:${STALWART_ADMIN_PASSWORD}
      # Shared HMAC secret for the audit webhook POSTed to platform-api.
      # config.toml references this via %{env:STALWART_WEBHOOK_SECRET}%.
      STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
    volumes:
      - stalwart_data:/opt/stalwart
      - ./configs/stalwart/config.toml:/opt/stalwart/etc/config.toml:ro
    networks: [dezky]
    labels:
      - traefik.enable=true
      - traefik.http.routers.stalwart.rule=Host(`mail.dezky.local`)
      - traefik.http.routers.stalwart.tls=true
      - traefik.http.services.stalwart.loadbalancer.server.port=8080

  # ─────────────────────────────────────────────────────────────────
  # OCIS — File storage with S3-compatible backend
  # ─────────────────────────────────────────────────────────────────
  ocis:
    image: owncloud/ocis:7.0
    container_name: dezky-ocis
    restart: unless-stopped
    entrypoint: /bin/sh
    command: ["-c", "ocis init --insecure true || true && ocis server"]
    environment:
      OCIS_URL: https://files.dezky.local
      OCIS_LOG_LEVEL: warn
      OCIS_INSECURE: "true"  # dev only — self-signed certs
      PROXY_HTTP_ADDR: 0.0.0.0:9200
      PROXY_TLS: "false"  # Traefik terminates TLS; OCIS speaks plain HTTP internally
      OCIS_OIDC_ISSUER: https://auth.dezky.local/application/o/ocis/
      WEB_OIDC_CLIENT_ID: ocis-web
      # Request offline_access so the web client gets a refresh token and renews
      # silently instead of dropping the session (no surprise logouts). The
      # ocis-provider already has the offline_access scope mapping + a 30-day
      # refresh validity; default scope is "openid profile email".
      WEB_OIDC_SCOPE: openid profile email offline_access
      PROXY_AUTOPROVISION_ACCOUNTS: "true"
      PROXY_USER_OIDC_CLAIM: preferred_username
      PROXY_USER_CS3_CLAIM: username
      # Grant the OCIS admin role to the platform-api service user (autoprovisioned
      # OCIS account of svc-platform-api). Admin is required for libregraph
      # ListAllDrives, which powers the customer-admin Storage page. The UUID is
      # the svc user's OCIS account id; stable as long as the OCIS data volume
      # persists. Empty in fresh setups until the OCIS bootstrap has run.
      OCIS_ADMIN_USER_ID: ${OCIS_ADMIN_USER_ID:-}
      IDM_CREATE_DEMO_USERS: "false"
      IDM_ADMIN_PASSWORD: ${OCIS_ADMIN_PASSWORD}
      STORAGE_USERS_DRIVER: ocis  # Local filesystem in dev
      STORAGE_SYSTEM_DRIVER: ocis
      OCIS_CONFIG_DIR: /etc/ocis
      OCIS_BASE_DATA_PATH: /var/lib/ocis
      # Extend CSP so the OCIS web SPA can reach Authentik for OIDC metadata.
      # Enforced by the OCIS proxy service, not web — env var is PROXY_-prefixed.
      PROXY_CSP_CONFIG_FILE_LOCATION: /etc/ocis/csp.yaml
      # Expose the embedded NATS service registry and gRPC services on the Docker
      # network so the collaboration container can talk to OCIS internals.
      NATS_NATS_HOST: 0.0.0.0
      NATS_NATS_PORT: 9233
      GATEWAY_GRPC_ADDR: 0.0.0.0:9142
      MICRO_GRPC_CLIENT_DNS_CACHE_TIMEOUT: 10s
      # Audit service — JSON Lines to a shared volume that platform-api also
      # mounts read-only. Used by the OCIS ingest worker to fold file/share
      # mutations into the global audit timeline. The audit microservice is
      # NOT part of the default `ocis server` set so we opt in explicitly.
      OCIS_ADD_RUN_SERVICES: audit
      AUDIT_LOG_FILE_PATH: /var/log/ocis/audit.log
      AUDIT_LOG_FORMAT: json
      # Whitelabel: serve our dezky theme dir under /themes/ and make it the
      # active web theme (replaces the built-in ownCloud branding — name, logos,
      # favicon, colours). Apache-2.0 lets us drop the ownCloud marks entirely.
      WEB_ASSET_THEMES_PATH: /etc/ocis/web-themes
      WEB_UI_THEME_PATH: /themes/dezky/theme.json
      # Override ONLY index.html (OCIS layers WEB_ASSET_CORE_PATH over the
      # embedded core via a fallback FS, so every other asset still serves from
      # the binary). Our index hides the version footer (.versions) and sets the
      # pre-hydration <title>/theme-color to dezky. NOTE: index.html pins the
      # built bundle hashes — refresh it from the live `/` after an OCIS image
      # bump (or drop this mount) so the script refs stay valid.
      WEB_ASSET_CORE_PATH: /etc/ocis/web-core
    volumes:
      - ocis_config:/etc/ocis
      - ocis_data:/var/lib/ocis
      - ocis_audit_log:/var/log/ocis
      - ./configs/ocis/csp.yaml:/etc/ocis/csp.yaml:ro
      - ./configs/ocis/themes:/etc/ocis/web-themes:ro
      - ./configs/ocis/web-core:/etc/ocis/web-core:ro
    networks: [dezky]
    depends_on:
      - authentik-server
    labels:
      - traefik.enable=true
      - traefik.http.routers.ocis.rule=Host(`files.dezky.local`)
      - traefik.http.routers.ocis.tls=true
      - traefik.http.services.ocis.loadbalancer.server.port=9200

  # ─────────────────────────────────────────────────────────────────
  # Collabora — Office document editor (loaded inside an iframe by OCIS)
  # ─────────────────────────────────────────────────────────────────
  collabora:
    image: collabora/code:latest
    container_name: dezky-collabora
    restart: unless-stopped
    cap_add:
      - MKNOD
    environment:
      # Allow the WOPI host (OCIS collaboration service) to talk to Collabora
      aliasgroup1: https://collaboration.dezky.local:443
      DONT_GEN_SSL_CERT: "true"
      # frame_ancestors: which hosts may embed Collabora in an iframe.
      # Without this set, browsers block the iframe with a Danish "Indhold blokeret".
      # home_mode.enable=true disables the "Explore The New" welcome popup AND the
      # feedback prompt; caps at 20 connections / 10 documents (fine for dev).
      extra_params: --o:ssl.enable=false --o:ssl.termination=true --o:ssl.ssl_verification=false --o:home_mode.enable=true --o:feedback.show=false --o:net.frame_ancestors=files.dezky.local
      username: admin
      password: ${COLLABORA_ADMIN_PASSWORD}
    # Generate a fresh WOPI proof key on every start so the public key Collabora
    # advertises matches the private key it signs with. Without this, the OCIS
    # collaboration service rejects WOPI calls with "ProofKeys verification failed".
    entrypoint: ["/bin/bash", "-c"]
    command: ["coolconfig generate-proof-key && /start-collabora-online.sh"]
    networks: [dezky]
    labels:
      - traefik.enable=true
      - traefik.http.routers.collabora.rule=Host(`office.dezky.local`)
      - traefik.http.routers.collabora.tls=true
      - traefik.http.services.collabora.loadbalancer.server.port=9980

  # ─────────────────────────────────────────────────────────────────
  # OCIS Collaboration — WOPI bridge between OCIS storage and Collabora.
  # Shares the ocis_config volume so it picks up the same JWT/transfer secrets
  # that ocis init generated. Without this, opening a .docx just downloads.
  # ─────────────────────────────────────────────────────────────────
  collaboration:
    image: owncloud/ocis:7.0
    container_name: dezky-collaboration
    restart: unless-stopped
    entrypoint: ["/bin/sh", "-c"]
    command: ["ocis collaboration server"]
    environment:
      OCIS_URL: https://files.dezky.local
      OCIS_LOG_LEVEL: warn
      OCIS_INSECURE: "true"
      OCIS_CONFIG_DIR: /etc/ocis
      OCIS_BASE_DATA_PATH: /var/lib/ocis
      COLLABORATION_HTTP_ADDR: 0.0.0.0:9300
      COLLABORATION_GRPC_ADDR: 0.0.0.0:9301
      COLLABORATION_WOPI_SRC: https://collaboration.dezky.local
      COLLABORATION_APP_NAME: Collabora
      COLLABORATION_APP_PRODUCT: Collabora
      COLLABORATION_APP_DESCRIPTION: Collabora Online
      # APP_ADDR is sent to the browser as the iframe src — must be public, not the
      # Docker-internal hostname. Collaboration → Collabora traffic goes via Traefik.
      COLLABORATION_APP_ADDR: https://office.dezky.local
      COLLABORATION_APP_INSECURE: "true"
      COLLABORATION_CS3API_DATAGATEWAY_INSECURE: "true"
      # Match OCIS JWT/secrets via the shared config volume.
      # Service registry: connect to OCIS's embedded NATS (not localhost).
      MICRO_REGISTRY: nats-js-kv
      MICRO_REGISTRY_ADDRESS: ocis:9233
      # Direct gRPC pointer to OCIS's gateway (collaboration can't rely on registry
      # lookups returning the right hostname for cross-container traffic).
      REVA_GATEWAY: ocis:9142
    volumes:
      - ocis_config:/etc/ocis
      - ocis_data:/var/lib/ocis
    networks: [dezky]
    depends_on:
      - ocis
      - collabora
    labels:
      - traefik.enable=true
      - traefik.http.routers.collaboration.rule=Host(`collaboration.dezky.local`)
      - traefik.http.routers.collaboration.tls=true
      - traefik.http.services.collaboration.loadbalancer.server.port=9300

  # ─────────────────────────────────────────────────────────────────
  # Portal — Nuxt 3 customer portal (development mode with HMR)
  # ─────────────────────────────────────────────────────────────────
  portal:
    image: node:20-alpine
    container_name: dezky-portal
    restart: unless-stopped
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
    environment:
      NODE_ENV: development
      NUXT_HOST: 0.0.0.0
      NUXT_PORT: 3000
      NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
      NUXT_PUBLIC_PORTAL_URL: https://app.dezky.local
      NUXT_API_BASE: http://platform-api:3001
      PLATFORM_API_INTERNAL_URL: http://platform-api:3001
      MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
      # OIDC (confidential client) — used by Nuxt server middleware
      NUXT_OIDC_CLIENT_ID: ${PORTAL_OIDC_CLIENT_ID}
      NUXT_OIDC_CLIENT_SECRET: ${PORTAL_OIDC_CLIENT_SECRET}
      NUXT_OIDC_ISSUER: ${PORTAL_OIDC_ISSUER}
      NUXT_OIDC_REDIRECT_URI: https://app.dezky.local/auth/oidc/callback
      # Session encryption (required by nuxt-oidc-auth)
      NUXT_OIDC_TOKEN_KEY: ${NUXT_OIDC_TOKEN_KEY}
      NUXT_OIDC_SESSION_SECRET: ${NUXT_OIDC_SESSION_SECRET}
      NUXT_OIDC_AUTH_SESSION_SECRET: ${NUXT_OIDC_AUTH_SESSION_SECRET}
      # Trust mkcert root CA for Node fetch (dev only)
      NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
    volumes:
      - ../../apps/portal:/app
      # Read-only mount of the monorepo's shared packages so Nuxt's
      # components.dirs entry (/shared-packages/ui/components) resolves
      # inside this container. Same mount is added to the operator service.
      - ../../packages:/shared-packages:ro
      - portal_node_modules:/app/node_modules
      - ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
    networks: [dezky]
    depends_on:
      mongo:
        condition: service_healthy
    labels:
      - traefik.enable=true
      # Root dezky.local + www now belong to the public marketing site; the
      # customer portal lives only on app.dezky.local.
      - traefik.http.routers.portal.rule=Host(`app.dezky.local`)
      - traefik.http.routers.portal.tls=true
      - traefik.http.services.portal.loadbalancer.server.port=3000

  # ─────────────────────────────────────────────────────────────────
  # Operator portal — internal admin app at operator.dezky.local.
  # Separate from the customer portal: own OAuth client (dezky-operator),
  # own session secrets, own cookie domain. Audience-gated mutations on
  # platform-api require the token this app mints.
  # ─────────────────────────────────────────────────────────────────
  operator:
    image: node:20-alpine
    container_name: dezky-operator
    restart: unless-stopped
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
    environment:
      NODE_ENV: development
      NUXT_HOST: 0.0.0.0
      NUXT_PORT: 3000
      NUXT_PUBLIC_AUTH_URL: https://auth.dezky.local
      # OIDC — dezky-operator OAuth client (separate from dezky-portal)
      NUXT_OIDC_CLIENT_ID: ${OPERATOR_OIDC_CLIENT_ID}
      NUXT_OIDC_CLIENT_SECRET: ${OPERATOR_OIDC_CLIENT_SECRET}
      NUXT_OIDC_ISSUER: ${OPERATOR_OIDC_ISSUER}
      NUXT_OIDC_REDIRECT_URI: https://operator.dezky.local/auth/oidc/callback
      # Session encryption — distinct from portal so the two surfaces can't
      # decrypt each other's session cookies
      NUXT_OIDC_TOKEN_KEY: ${OPERATOR_NUXT_OIDC_TOKEN_KEY}
      NUXT_OIDC_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_SESSION_SECRET}
      NUXT_OIDC_AUTH_SESSION_SECRET: ${OPERATOR_NUXT_OIDC_AUTH_SESSION_SECRET}
      # Reach platform-api internally for the server-side token-forwarding proxy
      PLATFORM_API_INTERNAL_URL: http://platform-api:3001
      NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
    volumes:
      - ../../apps/operator:/app
      # Shared packages — see portal service for explanation.
      - ../../packages:/shared-packages:ro
      - operator_node_modules:/app/node_modules
      - ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
    networks: [dezky]
    labels:
      - traefik.enable=true
      - traefik.http.routers.operator.rule=Host(`operator.dezky.local`)
      - traefik.http.routers.operator.tls=true
      - traefik.http.services.operator.loadbalancer.server.port=3000

  # ─────────────────────────────────────────────────────────────────
  # Website — public marketing site at dezky.local / www.dezky.local.
  # Fully public: no OIDC, no Mongo, no platform-api coupling. Runs in dev
  # mode here; in production it can be statically generated and served from
  # a CDN/edge independently of this stack.
  # ─────────────────────────────────────────────────────────────────
  website:
    image: node:20-alpine
    container_name: dezky-website
    restart: unless-stopped
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
    environment:
      NODE_ENV: development
      NUXT_HOST: 0.0.0.0
      NUXT_PORT: 3000
    volumes:
      - ../../apps/website:/app
      # Shared packages — see portal service for explanation.
      - ../../packages:/shared-packages:ro
      - website_node_modules:/app/node_modules
    networks: [dezky]
    labels:
      - traefik.enable=true
      - traefik.http.routers.website.rule=Host(`dezky.local`) || Host(`www.dezky.local`)
      - traefik.http.routers.website.tls=true
      - traefik.http.services.website.loadbalancer.server.port=3000

  # ─────────────────────────────────────────────────────────────────
  # Booking — dezky Scheduling public booking app (booking.dezky.local).
  # Fully public, no OIDC: only calls platform-api's /api/v1/public/* via its
  # own nitro proxy. Whitelabel-themed per tenant from the API response.
  # ─────────────────────────────────────────────────────────────────
  booking:
    image: node:20-alpine
    container_name: dezky-booking
    restart: unless-stopped
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm dev"
    environment:
      NODE_ENV: development
      NUXT_HOST: 0.0.0.0
      NUXT_PORT: 3000
      # Served behind Traefik TLS → Vite HMR over wss:443.
      DEZKY_TRAEFIK: "1"
      # How nitro reaches platform-api inside the docker network.
      PLATFORM_API_INTERNAL_URL: http://platform-api:3001
      # Optional Cloudflare Turnstile site-key — when set, the booking form
      # renders a captcha widget. Empty by default (no widget).
      NUXT_PUBLIC_TURNSTILE_SITE_KEY: ${NUXT_PUBLIC_TURNSTILE_SITE_KEY:-}
    volumes:
      - ../../apps/booking:/app
      - booking_node_modules:/app/node_modules
    networks: [dezky]
    depends_on:
      mongo:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.http.routers.booking.rule=Host(`booking.dezky.local`)
      - traefik.http.routers.booking.tls=true
      - traefik.http.services.booking.loadbalancer.server.port=3000

  # ─────────────────────────────────────────────────────────────────
  # platform-api — NestJS service. Owns tenants, partners, users,
  # subscriptions, and provisioning orchestration.
  # ─────────────────────────────────────────────────────────────────
  platform-api:
    image: node:20-alpine
    container_name: dezky-platform-api
    restart: unless-stopped
    working_dir: /app
    command: sh -c "corepack enable && corepack prepare pnpm@latest --activate && pnpm install && pnpm start:dev"
    environment:
      NODE_ENV: development
      PORT: 3001
      MONGODB_URI: mongodb://root:${MONGO_ROOT_PASSWORD}@mongo:27017/dezky?authSource=admin
      AUTHENTIK_API_URL: https://auth.dezky.local/api/v3
      AUTHENTIK_API_TOKEN: ${AUTHENTIK_BOOTSTRAP_TOKEN}
      # Internal hostname (NOT https://mail.dezky.local — that's Traefik + a
      # mkcert cert Node's fetch rejects). Stalwart's HTTP/JMAP listener is :8080.
      STALWART_API_URL: http://stalwart:8080
      STALWART_ADMIN_USER: admin
      STALWART_ADMIN_PASSWORD: ${STALWART_ADMIN_PASSWORD}
      # Gates real domain provisioning (x:Domain/set via JMAP). Off → domain
      # steps record 'skipped' and the Domains page works without a live Stalwart.
      STALWART_PROVISIONING_ENABLED: ${STALWART_PROVISIONING_ENABLED:-false}
      # HMAC secret Stalwart signs its webhook POSTs with; we verify on
      # /ingest/stalwart/webhook. Both ends read the same env var.
      STALWART_WEBHOOK_SECRET: ${STALWART_WEBHOOK_SECRET}
      OCIS_API_URL: https://files.dezky.local
      # Service-user auth for libregraph read calls (drive quotas powering the
      # customer-admin Storage page). OCIS has no backend service-account grant
      # and trusts a single issuer, so we run an OIDC password grant against the
      # SAME provider OCIS trusts (client `ocis-web`) as a dedicated service user
      # that holds the OCIS admin role. See docs/NEXT-STEPS.md.
      OCIS_OIDC_TOKEN_URL: https://auth.dezky.local/application/o/token/
      OCIS_OIDC_CLIENT_ID: ocis-web
      OCIS_SVC_USERNAME: ${OCIS_SVC_USERNAME}
      OCIS_SVC_PASSWORD: ${OCIS_SVC_PASSWORD}
      # JWT validation against Authentik for portal-issued access tokens.
      # Issuers are comma-separated — each Authentik OAuth provider issues tokens
      # with its own per-app issuer URL, so we accept both portal and operator.
      AUTHENTIK_ISSUER: https://auth.dezky.local/application/o/dezky-portal/,https://auth.dezky.local/application/o/dezky-operator/
      # Comma-separated list of accepted JWT audiences. Tokens issued for either
      # the customer portal or the operator portal are valid against this service;
      # per-endpoint guards further restrict operator-only mutations.
      AUTHENTIK_AUDIENCE: dezky-portal,dezky-operator
      AUTHENTIK_JWKS_URI: https://auth.dezky.local/application/o/dezky-portal/jwks/
      # Trust mkcert root CA for Node fetch (dev only)
      NODE_EXTRA_CA_CERTS: /etc/ssl/mkcert-root.pem
      # Path to the OCIS audit log inside this container. The same shared
      # volume is mounted on the OCIS service writeable; here it's read-only.
      OCIS_AUDIT_LOG_PATH: /var/log/ocis/audit.log
      # Tamper-evidence signing key for the audit hash chain. Rotation closes
      # out the current segment with a key-rotation checkpoint (not in scope
      # for Phase 3). Prod swaps HMAC for ed25519 from an HSM.
      AUDIT_SIGNING_KEY: ${AUDIT_SIGNING_KEY}
      # Cold storage (Phase 4). Dev uses MinIO on the docker network; prod
      # swaps endpoint to Hetzner Object Storage and provides real IAM keys.
      # ARCHIVE_ENABLED defaults to false in dev so the worker doesn't move
      # data we still want to query while building. The UI "Run archive now"
      # button bypasses this gate.
      AUDIT_COLD_ENDPOINT: http://minio:9000
      AUDIT_COLD_REGION: us-east-1
      AUDIT_COLD_BUCKET: dezky-audit
      AUDIT_COLD_ACCESS_KEY: ${MINIO_ROOT_USER}
      AUDIT_COLD_SECRET_KEY: ${MINIO_ROOT_PASSWORD}
      AUDIT_HOT_RETENTION_DAYS: "90"
      ARCHIVE_ENABLED: "false"
      # Stripe billing. Dark-launched: when BILLING_STRIPE_ENABLED != "true" or
      # the secret is empty, billing runs on derived data (Subscription + Price +
      # marginPct). Values come from the gitignored root .env. Webhook secret is
      # only needed once the signature-verified /stripe/webhook path goes live.
      STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
      # Publishable key is safe to expose to the browser — the portal fetches it
      # from platform-api (via the setup-intent response) to mount Stripe Elements.
      STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-}
      STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-}
      BILLING_STRIPE_ENABLED: ${BILLING_STRIPE_ENABLED:-false}
      # dezky Scheduling. AES-256-GCM key (64 hex chars) encrypting per-host
      # Stalwart app passwords at rest — never logged. Public booking app origin
      # for manage links; Jitsi base for generated meeting rooms.
      SCHEDULING_CREDENTIAL_KEY: ${SCHEDULING_CREDENTIAL_KEY}
      BOOKING_PUBLIC_URL: ${BOOKING_PUBLIC_URL:-https://booking.dezky.local}
      MEET_PUBLIC_URL: ${MEET_PUBLIC_URL:-https://meet.dezky.local}
      # Optional Cloudflare Turnstile secret. When set, the public booking holds
      # and bookings endpoints verify the visitor's captcha token; unset = no-op.
      TURNSTILE_SECRET: ${TURNSTILE_SECRET:-}
    volumes:
      - ../../services/platform-api:/app
      - platform_api_node_modules:/app/node_modules
      - ./certs/mkcert-root.pem:/etc/ssl/mkcert-root.pem:ro
      - ocis_audit_log:/var/log/ocis:ro
    networks: [dezky]
    depends_on:
      mongo:
        condition: service_healthy
    labels:
      - traefik.enable=true
      - traefik.http.routers.api.rule=Host(`api.dezky.local`)
      - traefik.http.routers.api.tls=true
      - traefik.http.services.api.loadbalancer.server.port=3001

