# Caddy reverse proxy for Brood Host — production (public DNS, Let's Encrypt)
#
# Topology:
#   - :80       redirect to :443 (except ACME HTTP-01 challenge, which Caddy handles automatically)
#   - :443      On-Demand TLS. Caddy asks /api/boss/check-domain before issuing a cert.
#               Public endpoints on the user's site are proxied to host-prod:3000.
#               Internal management endpoints (/api/boss/*) are blocked from the public :443.
#   - :4001     Staging (HTTP only, draft content). Same /api/boss/* block applies.
#   - admin     Unix socket at /var/run/caddy/admin.sock — reachable from host-prod
#               container (shared volume), NOT from the public internet. Used by the
#               Host to clean up cached certificates on domain deletion AND to program
#               the ADR-048 `dev`-role staging-subdomain routes via Admin /load.
#
# For local-network deploys (no public DNS, no LE) point docker-compose at Caddyfile.local.

{
	# Caddy Admin API — only on the shared Unix socket. TCP :2019 stays closed.
	#
	# `|0666`: the caddy:2-alpine container runs as root and would otherwise
	# create this socket mode 0200 root:root (owner-only). The Host process is
	# non-root (nextjs uid 1001, gid 65533/nogroup) and could not connect →
	# `connect EACCES /var/run/caddy/admin.sock` → ADR-048 dev-route /load never
	# lands → dev.<domain> falls through :443 to host-prod (published) instead
	# of host-staging (draft). The permission suffix only chmods (never chowns)
	# the socket and caddy runs as root, so there is no shared gid with the
	# non-root Host without invasive uid/gid coupling — hence world-rw, not
	# 0660. Safe: this socket lives on the Docker-internal `caddy-admin` named
	# volume, is never TCP-bound (see "TCP :2019 stays closed" above) and never
	# network-exposed (ADR-037 trust zones) — the mode on an internal-only
	# volume socket is not the security boundary. Caddy strips the `|mode`
	# suffix from the dial address since v2.7.3 (caddyserver/caddy#5694), so it
	# is safe with caddyEdge's repeated Admin /load reconcile.
	admin unix//var/run/caddy/admin.sock|0666

	# On-Demand TLS: Caddy asks the Host whether it should issue a cert for this SNI.
	# The `ask` URL goes over the internal Docker network, not :443.
	on_demand_tls {
		ask http://host-prod:3000/api/boss/check-domain
	}
}

# ADR-180 §9 — the public media block. EMPTY ON PURPOSE: the Host fills it at
# reconcile time (apps/host/src/lib/edge/mediaEdgeBlock.ts) with a block that
# reverse-proxies /media/* straight to the Garage web endpoint, so media bytes
# stop crossing the Node process that renders the site. The content depends on
# deploy-time state (MEDIA_HOST, the storage address, the bucket vhost), which
# is why it is generated in one place instead of copied into this file and its
# five siblings.
#
# An empty snippet is a valid config and behaves exactly as this file did
# before: /media/* falls through to the catch-all and the application serves
# it. That is the whole degradation story — Caddy reloads this file on every
# restart and loses what the Host pushed until the next reconcile, and in that
# window media is slow, never broken. Do NOT turn this into a bare `import` of
# a snippet the Host must define: Caddy would refuse to start.
#
# ‼️ Imported by the PRODUCTION listener only. The :4001 staging listener must
# never import it — staging sits behind BROOD_STAGING_PASSWORD, whose matcher
# covers /media explicitly, and serving it from the edge would hand a private
# preview's entire content to anonymous callers.
(brood_media_edge) {
}

# Production (HTTPS)
:443 {
	tls {
		on_demand
	}
	import brood_media_edge


	# Page-weight audit (ADR-094 follow-up): HTML here is heavy (0.8-1.3MB,
	# dominated by the RSC flight payload) and compresses 4-6×. Without this
	# directive every byte shipped uncompressed — the single cheapest
	# bandwidth win on the public surface. zstd preferred, gzip fallback.
	encode zstd gzip

	# F15 (docs/audits/2026-05-20-tunnel-weakpoints.md): perimeter cap on
	# public request bodies. Without this, a visitor could POST a 1GB body
	# to any page (Server Action receiver) and force Next runtime to buffer
	# it into RAM before the middleware/action-level size check fires.
	# Keep in sync with `BROOD_MAX_PUBLIC_ACTION_BYTES` (default 50MB) в
	# apps/host/src/proxy.ts; this is the perimeter complement to the
	# code-side Content-Length check.
	request_body {
		max_size 50MB
	}

	# Block management endpoints on the public surface. /api/boss/* is meant for
	# internal callers (Caddy → check-domain, spinneret → command) that go via the
	# Docker network on :3000, not via the public domain.
	# BG1 (audit 2026-06-13): block the ENTIRE /api/boss/* surface, not just
	# command/check-domain. Other boss paths (upload / status / favicon-variant
	# / multipart) otherwise reached host-*:3000, where proxy.ts trusts the
	# Caddy-forwarded Host header — a spoofed `Host: localhost` then passed the
	# ADR-035 internal-host gate. Legit internal callers (spinneret, Caddy's
	# on-demand-TLS ask) hit host-*:3000 directly over the Docker network, never
	# this public listener, so blocking the whole surface here is safe.
	# ADR-166 (audit 2026-09-05) — REFUSE a request that CLAIMS an internal host
	# name on this public listener. `Host` is client-supplied, and the BG1 note
	# above says out loud that a spoofed `Host: localhost` satisfies proxy.ts's
	# ADR-035 internal-host gate. BG1's answer was to block `/api/boss/*` here —
	# which does nothing for the DRAFT tier (ADR-166), whose surface is the page
	# tree plus /assets/*, /media/* and /_island/*. The draft now also demands a
	# valid X-Boss-Key, so this is defense in depth rather than the only lock;
	# it is here because the assumption ADR-035 always stated — "public traffic
	# cannot present an internal host name" — has to be enforced SOMEWHERE, and
	# the edge is the only place that knows which listener a request arrived on.
	# Legitimate internal callers (spinneret, Caddy's on-demand-TLS ask) reach
	# host-*:3000 directly over the Docker network and never pass through here.
	@spoofed_internal_host host localhost 127.0.0.1 host-app host-prod host-staging
	respond @spoofed_internal_host 404

	@sys_block {
		path /api/boss/*
	}
	respond @sys_block 404

	# ADR-109/118 — Live Channel SSE must stream UNBUFFERED. Without
	# `flush_interval -1` Caddy buffers the proxied response and deltas never
	# reach the browser until the stream closes. Matched before the catch-all.
	@realtime path /api/live /api/live/*
	reverse_proxy @realtime host-prod:3000 {
		flush_interval -1
	}

	reverse_proxy host-prod:3000
}

# Staging (plain HTTP on :4001) — same internal-endpoint block, auth handled by Host.
:4001 {
	# Same compression as production (staging mirrors the public surface).
	encode zstd gzip

	# F15: same perimeter cap as production.
	request_body {
		max_size 50MB
	}

	# BG1 (audit 2026-06-13): block the ENTIRE /api/boss/* surface, not just
	# command/check-domain. Other boss paths (upload / status / favicon-variant
	# / multipart) otherwise reached host-*:3000, where proxy.ts trusts the
	# Caddy-forwarded Host header — a spoofed `Host: localhost` then passed the
	# ADR-035 internal-host gate. Legit internal callers (spinneret, Caddy's
	# on-demand-TLS ask) hit host-*:3000 directly over the Docker network, never
	# this public listener, so blocking the whole surface here is safe.
	# ADR-166 (audit 2026-09-05) — REFUSE a request that CLAIMS an internal host
	# name on this public listener. `Host` is client-supplied, and the BG1 note
	# above says out loud that a spoofed `Host: localhost` satisfies proxy.ts's
	# ADR-035 internal-host gate. BG1's answer was to block `/api/boss/*` here —
	# which does nothing for the DRAFT tier (ADR-166), whose surface is the page
	# tree plus /assets/*, /media/* and /_island/*. The draft now also demands a
	# valid X-Boss-Key, so this is defense in depth rather than the only lock;
	# it is here because the assumption ADR-035 always stated — "public traffic
	# cannot present an internal host name" — has to be enforced SOMEWHERE, and
	# the edge is the only place that knows which listener a request arrived on.
	# Legitimate internal callers (spinneret, Caddy's on-demand-TLS ask) reach
	# host-*:3000 directly over the Docker network and never pass through here.
	@spoofed_internal_host host localhost 127.0.0.1 host-app host-prod host-staging
	respond @spoofed_internal_host 404

	@sys_block {
		path /api/boss/*
	}
	respond @sys_block 404

	# ADR-109/118 — Live Channel SSE must stream UNBUFFERED (see :443 block).
	@realtime path /api/live /api/live/*
	reverse_proxy @realtime host-staging:3000 {
		flush_interval -1
	}

	reverse_proxy host-staging:3000
}
