# Production mode config
# https://frankenphp.dev/docs/config
# https://caddyserver.com/docs/caddyfile

{
    skip_install_trust
    # Round 952: no listener on port 80 for redirects. The one way in from
    # outside this container is the TLS listener below.
    auto_https disable_redirects
    # TCP only: the load balancer speaks HTTP/1.1 and HTTP/2 to its targets,
    # so no UDP listener for HTTP/3 is opened.
    servers {
        protocols h1 h2
    }

    frankenphp {

    }
}

# ROUND 1024 (ASVS 5.0 V3.4.1, V3.4.3 to V3.4.6; found by the self-review
# before the penetration test). What this server answers BY ITSELF (the
# stylesheets, scripts and icons under public/, robots.txt, and the 413 page
# below) went out with none of the headers every page PHP answers carries
# (SecurityHeaderPolicy): no HSTS, no nosniff, no Content-Security-Policy, no
# Referrer-Policy. Measured with this file before the change.
#
# Each one is a DEFAULT (`?`): written only where the response has none, so a
# page PHP answered keeps exactly the headers the application chose, its
# nonce and its framing allowance included. X-Frame-Options is deliberately
# not defaulted: the career pages a workspace lets other sites frame are
# answered WITHOUT it on purpose, and a default here would put it back. The
# policy below says frame-ancestors 'none' for everything this server serves
# itself. Inline styles are allowed because the 413 page has its own; no
# script runs from anything served here.
#
# SecurityHeadersAtTheEdgeTest holds these values to the application's.
(served_by_the_server_itself) {
    header {
        ?Strict-Transport-Security "max-age=15768000"
        ?X-Content-Type-Options "nosniff"
        ?Content-Security-Policy "default-src 'none'; style-src 'self' 'unsafe-inline'; img-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'"
        ?Referrer-Policy "strict-origin-when-cross-origin"
    }
}

(app) {
    import served_by_the_server_itself
    encode zstd br gzip

    # F11, closed round 615. Nothing at the edge bounded a request body, and
    # Caddy has no default limit of its own — so the only bound on a PUBLIC,
    # unauthenticated, whole-stream-buffering endpoint (/careers/{tenant}/jobs/{id})
    # was PHP's compiled post_max_size, a value nothing stated, tested, or would
    # have noticed changing.
    #
    # 20MB is go-humanize's SI megabyte: 20,000,000 bytes. That is deliberately
    # ABOVE post_max_size (16M = 16,777,216) so PHP's own error path handles the
    # in-band cases and this only stops what should never have been sent. Over it,
    # Caddy answers 413 without occupying a PHP worker.
    #
    # UploadLimitChainTest asserts this stays above post_max_size, which in turn
    # stays above upload_max_filesize, which stays above every MAX_BYTES the
    # domain declares. Loosening any one of them alone fails the build.
    request_body {
        max_size 20MB
    }

    # **ROUND 805: THE CAP ABOVE NEVER FIRED, MEASURED.** The first time this
    # file ran (round 805), a 21,000,000-byte post went straight through it:
    # `request_body` limits what is READ, and under FrankenPHP nothing reads a
    # body PHP has already refused at start-up for exceeding post_max_size. So
    # the claim above — "Caddy answers 413 without occupying a PHP worker" —
    # was false: PHP was occupied, and answered with its own warning.
    #
    # This refuses on the DECLARED length, before a byte of the body is read or
    # a worker is involved. Every browser form post declares one. A chunked
    # body declares none and is left to `request_body`, which then has a reader
    # to stop. The number is the same 20,000,000 as above;
    # EdgeErrorPageTest holds the two equal. A chunked 21 MB post was measured
    # too: refused, with the form's own 422 page, since what PHP received
    # carried no token.
    @declaredOverTheCap expression {http.request.header.Content-Length} != "" && int({http.request.header.Content-Length}) > 20000000
    handle @declaredOverTheCap {
        # file_server serves GET and HEAD only, and answered this POST 405
        # until it was told to read the page rather than the request (measured).
        method GET
        rewrite * /413.html
        file_server {
            root /app/public
            status 413
        }
    }


    # Cham's decision (2026-09-09): a body over the cap gets a PAGE, not a bare
    # status line. Caddy's default 413 body is empty, so a candidate whose phone
    # photo is 25MB sees a blank screen and learns nothing.
    #
    # `handle_errors` catches what `request_body` rejects and serves a static
    # file. Static, deliberately: the whole point of stopping it here is that no
    # PHP worker is occupied, and routing the error page through php_server
    # would give that back on exactly the requests designed to exhaust it.
    #
    # Authored unexecuted in round 615; first parsed and run in round 805,
    # where Caddy accepted it. CI job `web-prod-image` now builds the image
    # and sends it an oversized body on every run.
    handle_errors 413 {
        # Error routes run outside the routes above, so they import the
        # defaults themselves (round 1024).
        import served_by_the_server_itself
        method GET
        rewrite * /413.html
        file_server {
            root /app/public
        }
    }

    php_server {
        try_files {path} index.php
        root /app/public
    }
}

# THE TWO WAYS IN (round 952; Cham's Q148 (1): the load balancer -> application
# hop is encrypted too, not left to the private network).
#
# 1. From outside the container: TLS on 8443, the one port docker/prod/
#    compose.yml publishes and the load balancer is sent to. The key and
#    certificate are made inside this container at every start
#    (docker/prod/serve.sh); the site has no host name, so every request the
#    load balancer forwards is answered whatever its Host header says.
https://:8443 {
    tls /tmp/nexhire-tls/cert.pem /tmp/nexhire-tls/key.pem {
        protocols tls1.2 tls1.3
    }
    import app
}

# 2. From inside the container only: plain HTTP on the loopback address, for the
#    image's own health check (docker/Dockerfile) and nothing else — no other
#    machine, node or container can open it.
http://:80 {
    bind 127.0.0.1
    import app
}
