# FrankenPHP Caddyfile for the api container.
# Serves Slim from public/ on :8081.
{
    frankenphp
    order php_server before file_server
    auto_https off
    admin off
    # SEC_REVIEW F25: trust ONLY loopback as an XFF rewriter by default.
    # The previous `trusted_proxies static private_ranges` honoured
    # X-Forwarded-For from any RFC1918 peer. Combined with the wide
    # /internal/* CIDR gate, a neighbouring container on the same docker
    # bridge could forge `REMOTE_ADDR=127.0.0.1` via XFF and pass the
    # network check. The bundled docker-compose stack has no real proxy
    # in front of the api, so loopback-only is the correct default.
    #
    # Production deployments behind a real reverse proxy override the
    # `TRUSTED_PROXIES` env var to that proxy's CIDR — for example:
    #     TRUSTED_PROXIES="10.0.0.5/32"
    # The default keeps `remote_ip` matchers below evaluating against the
    # real TCP peer regardless of any XFF header.
    servers {
        trusted_proxies static {$TRUSTED_PROXIES:127.0.0.1/32 ::1/128}
    }
}

:8081 {
    root * /app/public
    encode zstd gzip

    # ── Security headers (M14) ──────────────────────────────────────────
    # Applied to every response. The api serves JSON + the OpenAPI YAML +
    # the /api/docs viewer; everything else is locked down.
    header {
        # Identify ourselves as little as possible.
        -Server
        -X-Powered-By
        X-Content-Type-Options "nosniff"
        # The api doesn't render its own pages except /api/docs which is a
        # single embedded viewer; SAMEORIGIN is the conservative default
        # that still allows future same-origin embedding.
        X-Frame-Options "SAMEORIGIN"
        Referrer-Policy "strict-origin-when-cross-origin"
        # SEC_REVIEW F61: extended deny-list for browser features the
        # api never serves. Aligned with the ui Caddyfile so a browser
        # loading a doc page (RapiDoc viewer) sees the same hardening.
        # The api doesn't use clipboard-write so it stays denied here
        # too (the ui keeps it on its same-origin default for the
        # "copy raw token" button).
        Permissions-Policy "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), bluetooth=(), camera=(), clipboard-read=(), clipboard-write=(), display-capture=(), encrypted-media=(), fullscreen=(), gamepad=(), geolocation=(), gyroscope=(), hid=(), idle-detection=(), interest-cohort=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), serial=(), speaker-selection=(), usb=(), web-share=(), xr-spatial-tracking=()"
        # SEC_REVIEW F59: modern cross-origin isolation headers.
        # - COOP `same-origin` isolates the docs viewer / any
        #   future api-rendered page from cross-origin popups.
        # - CORP `same-origin` blocks external pages from loading
        #   the api JSON via cross-origin <script>/<img>/<link>
        #   tags (defence in depth; CORS already gates fetch).
        # - X-Permitted-Cross-Domain-Policies `none` blocks legacy
        #   Adobe Flash / Acrobat cross-domain.xml lookups.
        Cross-Origin-Opener-Policy "same-origin"
        Cross-Origin-Resource-Policy "same-origin"
        X-Permitted-Cross-Domain-Policies "none"
    }

    # HSTS: prod-only. Setting it in dev would lock you out of plain-HTTP
    # localhost on the same hostname (sticky for 1 year). Gate strictly.
    # Value is operator-tuneable via the `HSTS_HEADER` env var so a
    # deployment that wants to apply for the browser preload list can
    # opt in by setting:
    #     HSTS_HEADER="max-age=31536000; includeSubDomains; preload"
    # Default keeps the conservative no-preload value — preload-listing
    # is a one-way commitment (browser preload removals take months) so
    # we don't enable it by default (SEC_REVIEW F60).
    @prod expression `{env.APP_ENV} == "production"`
    header @prod Strict-Transport-Security "{$HSTS_HEADER:max-age=31536000; includeSubDomains}"

    # CSP: docs viewer needs RapiDoc from jsDelivr + inline styles + the
    # try-it-now feature posting to /api/v1/*. Everything else is JSON.
    @docs path /api/docs /api/v1/openapi.yaml
    header @docs Content-Security-Policy "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data: https://cdn.jsdelivr.net; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"

    @not_docs not path /api/docs /api/v1/openapi.yaml
    header @not_docs Content-Security-Policy "default-src 'none'; frame-ancestors 'none'; base-uri 'none'"

    # Internal jobs API: only callable from loopback by default
    # (SEC_REVIEW F25). The bundled sidecar scheduler joins the api's
    # network namespace via `network_mode: "service:api"`, so its calls
    # arrive on 127.0.0.1. Host-cron Option A in the SPEC also targets
    # localhost. Production topologies that genuinely need wider
    # reachability set `INTERNAL_CIDR_ALLOWLIST` for the PHP middleware
    # AND mirror the same CIDR list in this matcher (operators must
    # patch the Caddyfile or override via a custom config).
    # The PHP layer also enforces this (InternalNetworkMiddleware) —
    # Caddy is the first line of defence.
    @internal {
        path /internal/*
        remote_ip 127.0.0.1/32 ::1/128
    }
    handle @internal {
        php_server
    }

    @external_internal_blocked {
        path /internal/*
        not remote_ip 127.0.0.1/32 ::1/128
    }
    respond @external_internal_blocked 404

    php_server
}
