# Takeal — Caddy reverse proxy / TLS terminator.
#
# Topology: Cloudflare (proxied, orange cloud) -> Caddy origin.
# Caddy serves a self-signed cert via the internal CA (`tls internal`); set
# the Cloudflare SSL/TLS mode to "Full" (not "Full (strict)") so CF trusts
# the origin cert without a public CA chain.
#
# Only this container publishes 80/443 to the host. All upstreams are reached
# by their docker-compose service name on the internal `takeal` network.
#
# Domain-agnostic (ADR 0038): every site is `<svc>.{$TAKEAL_DOMAIN}`, with
# TAKEAL_DOMAIN + the CSP report URIs injected as container env by compose.
# `{$VAR}` is substituted when Caddy parses the file, so a change needs
# `docker compose restart caddy` (release.sh does it when the file hash or
# the env changes).

{
	# Internal CA for all sites (origin cert behind Cloudflare).
	# Replace with ACME (email ...) + remove `tls internal` per-site if you
	# ever move to "Full (strict)" or expose Caddy directly.
	local_certs

	# Caddy admin API is not needed at runtime; turn it off for a smaller
	# attack surface. (Re-enable if you script reloads via the API.)
	admin off

	# Send Caddy's own logs to stdout for `docker logs caddy`.
	log {
		output stdout
		format console
	}
}

# Snippet: shared hardening + compression applied to every site.
(common) {
	encode zstd gzip
	header {
		# Basic hardening. HSTS is safe behind CF "Full".
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
}

# Snippets: branded 404 for paths with no application behind them.
#
#   import not_found        — negotiates on Accept: browsers get the HTML page
#                             (deploy/errors/404.html, bind-mounted at
#                             /srv/errors, rendered through `templates` so the
#                             domain + branding logo resolve per deployment);
#                             everything else gets the same JSON envelope the
#                             apps use (`code = not_found`).
#
# Meister / b2b-api / Hub / Merphy answer their own 404s (with "did you mean"
# suggestions); this covers Caddy-terminated paths only.
(not_found_html) {
	root * /srv/errors
	rewrite * /404.html
	templates
	header Cache-Control "no-store"
	file_server {
		status 404
	}
}
# Snippet: /favicon.ico on hosts with no app behind them → the deployment's
# branding favicon (browsers request it for any page, JSON included).
(favicon) {
	handle /favicon.ico {
		redir https://hub.{$TAKEAL_DOMAIN}/branding/favicon.png 302
	}
}
(not_found) {
	@html header Accept *text/html*
	handle @html {
		import not_found_html
	}
	handle {
		header Content-Type "application/json"
		header Cache-Control "no-store"
		respond `{"code":"not_found","message":"no route for {method} {path}","path":"{path}","did_you_mean":null}` 404
	}
}

# Snippet: CSP violation reporting to GlitchTip, per site.
#
#   import csp_report <report-uri>
#
# Deliberately Report-Only. Pass the deployment's GlitchTip security endpoint
# (blank = header still set, no report target, nothing breaks).
# A blocking CSP on a Next.js app needs nonce
# plumbing through the whole render tree; getting that wrong takes the console
# down, and the point here is to *learn* what the real policy should be. The
# policy below describes what these apps actually load today — anything else
# shows up as a report instead of a broken page.
(csp_report) {
	header Content-Security-Policy-Report-Only "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: blob: https:; connect-src 'self' https://api.{$TAKEAL_DOMAIN} https://glitch.wiseless.xyz; report-uri {args[0]}"
}

# ---------------------------------------------------------------------------
# api.<domain> — Meister REST (gRPC :7778 stays internal, not proxied here)
# ---------------------------------------------------------------------------
api.{$TAKEAL_DOMAIN} {
	import common
	import csp_report "{$CSP_REPORT_URI_API}"
	tls internal

	# Defence-in-depth: /internal/* (ADR 0012 connector->Meister webhook
	# ingestion) is NOT a public contract. Meister also CIDR-ACLs it, but we
	# refuse it at the edge so it's never reachable from the internet.
	handle /internal/* {
		import not_found
	}

	# Smith read-only audit API, mounted under /smith/* (Hub reads it here).
	# handle_path strips the prefix → smith:7902/audit, /events, etc.
	# (No separate subdomain: keeps the DNS surface to a fixed set of hosts.)
	handle_path /smith/* {
		reverse_proxy smith:7902
	}

	# Everything else → Meister REST. (gRPC :7778 stays internal, not here.)
	handle {
		reverse_proxy meister-api:7777 {
			# Caddy sets X-Forwarded-For / -Proto / -Host automatically.
			transport http {
				dial_timeout 5s
				response_header_timeout 60s
			}
		}
	}
}

# ---------------------------------------------------------------------------
# org.<domain> — Organization (VCI) resale API + RapiDoc (#23).
# The clean external S2S surface: /v1/* + /docs (ADR 0039; the old /org/*
# is a deprecated alias for one release). Disjoint from api.<domain> so a B2B
# client can never reach /admin/* or /merchants/*.
# ---------------------------------------------------------------------------
org.{$TAKEAL_DOMAIN} {
	import common
	tls internal

	reverse_proxy b2b-api:7779 {
		transport http {
			dial_timeout 5s
			response_header_timeout 60s
		}
	}
}

# ---------------------------------------------------------------------------
# hub.<domain> — operator console (Next.js :3000)
# ---------------------------------------------------------------------------
hub.{$TAKEAL_DOMAIN} {
	import common
	import csp_report "{$CSP_REPORT_URI_HUB}"
	tls internal

	# This box's brand assets (logo.png, favicon.png, …) served straight from
	# deploy/branding/ (not versioned) — the URLs go into the
	# `branding.logo_url` / `branding.favicon_url` runtime settings.
	handle_path /branding/* {
		root * /srv/branding
		# no-cache = every fetch revalidates with the ETag (Caddy answers 304 when
		# unchanged), and Cloudflare does not park a copy on the edge. A swapped
		# logo.png must show up on the next reload, not after a day-long TTL.
		header Cache-Control "no-cache"
		file_server
	}

	handle {
		reverse_proxy hub:3000
	}
}

# ---------------------------------------------------------------------------
# merphy.<domain> / b2b.<domain> — merchant portal (Next.js :3001).
# Two names for the same site: `b2b.` is the brand-neutral host a deployment
# gives its merchants (no Takeal product name in the URL); `merphy.` stays for
# deployments that already published it. DNS decides which one is live.
# ---------------------------------------------------------------------------
merphy.{$TAKEAL_DOMAIN}, b2b.{$TAKEAL_DOMAIN} {
	import common
	import csp_report "{$CSP_REPORT_URI_MERPHY}"
	tls internal

	reverse_proxy merphy:3001
}

# ---------------------------------------------------------------------------
# hooks.<domain> — public webhook ingress for connectors.
#
# Path-routed so new providers slot in easily. spool-okcard's Go webhook
# server registers the handler at the FULL path `/webhooks/okcard`, so we use
# `handle` (NOT handle_path) to keep the prefix when proxying.
# ---------------------------------------------------------------------------
hooks.{$TAKEAL_DOMAIN} {
	import common
	import favicon
	tls internal

	# OKCard panel -> spool-okcard webhook server (:7611). Only meaningful when
	# the `okcard` compose profile is on; otherwise the upstream is absent and
	# Caddy answers 502 for this path, which is the right answer.
	handle /webhooks/okcard* {
		reverse_proxy spool-okcard:7611
	}

	# Future providers: add another `handle /webhooks/<provider>* { ... }`
	# block above this fallthrough.

	# Anything else under hooks.* is not a registered webhook path.
	handle {
		import not_found
	}
}

# ---------------------------------------------------------------------------
# app.<domain> — historical Cusfront host. With a Cusfront configured it
# redirects to the apex (landing + cabinet live there, routed by the app);
# without one it keeps the placeholder.
# ---------------------------------------------------------------------------
app.{$TAKEAL_DOMAIN} {
	import common
	import favicon
	tls internal

	@cusfront expression `"{$CUSFRONT_UPSTREAM}" != ""`
	handle @cusfront {
		redir https://{$TAKEAL_DOMAIN}{uri} permanent
	}
	handle {
		respond "Cusfront — coming soon" 200
	}
}

# ---------------------------------------------------------------------------
# <domain> (apex) — the deployment's end-user front (Cusfront) when one is
# configured, otherwise a redirect to the operator console.
#
# CUSFRONT_UPSTREAM (deploy/.env, e.g. `cusfront:3000`) is the switch: the
# Cusfront is the partner's own image, rolled independently of Takeal
# releases (see deploy/cusfront-deploy.sh). Blank = no Cusfront on this box.
# ---------------------------------------------------------------------------
{$TAKEAL_DOMAIN} {
	import common
	tls internal

	@cusfront expression `"{$CUSFRONT_UPSTREAM}" != ""`
	handle @cusfront {
		# Default is only a parse-time placeholder; unreachable when blank.
		reverse_proxy {$CUSFRONT_UPSTREAM:127.0.0.1:1} {
			transport http {
				dial_timeout 5s
				response_header_timeout 60s
			}
		}
	}
	handle {
		redir https://hub.{$TAKEAL_DOMAIN}{uri} permanent
	}
}

# www — canonical host is the apex.
www.{$TAKEAL_DOMAIN} {
	import common
	tls internal

	@cusfront expression `"{$CUSFRONT_UPSTREAM}" != ""`
	handle @cusfront {
		redir https://{$TAKEAL_DOMAIN}{uri} permanent
	}
	handle {
		redir https://hub.{$TAKEAL_DOMAIN}{uri} permanent
	}
}
