# SVOS front door on nh3-svos (eshpfi configs/svos-front/), 2026-10-05. Prime: mesh-only, svos.phasefinal.com.
# - Listens ONLY on the tailnet address (100.64.0.9:443). There is no LAN or internet listener and no :80 redirect server.
# - Cert: Let's Encrypt via Cloudflare DNS-01 with a phasefinal.com-only token (env CF_API_TOKEN, /etc/caddy/env).
# - Identity: HTTP Basic auth (Prime 2026-10-05: "Basic auth is fine, saved in browser is fine"; mesh-only because we
#   don't host externally). One account, `Vuong` (Prime is how SVOS addresses him). argon2id hash in
#   /etc/caddy/svos-basic-auth.accounts (root:caddy 0640, NOT in git); plaintext vaulted nh3-svos/basic-auth-vuong.
#   The WhoIs/forward_auth gate that preceded this was retired the same day (every mesh node is login `pfi`).
# - Client-supplied identity/forwarding headers are deleted BEFORE auth. After auth, Tailscale-User-Login is set to
#   the authenticated user ({http.auth.user.id}) and Authorization is deleted: the kernel's human path (INV-U1-06)
#   refuses any request that carries an Authorization header, and the app has no business seeing the password.
#   reverse_proxy sets X-Forwarded-Proto=https and X-Forwarded-For itself; Host passes through; Origin untouched.
# - App side (svos-dev): kernel 127.0.0.1:8400 (/api/* with the prefix stripped, /api/health is the kernel's),
#   connector 127.0.0.1:8403 (/health, /verbs/ping|pins|pin|unpin, /openapi.json), static UI /opt/svos/current/static.
{
	email lkraven@lkraven.com
	auto_https disable_redirects
	admin localhost:2019
	# Hosting bounds (svos-dev 2026-10-05, svoperatingsystem web/README.md Hosting contract): request headers within 5 s,
	# the whole request body within 15 s. NO write/response deadline: /api/stream (SSE) must stay open. Measured on a
	# replica 2026-10-05 before deploy: SSE 25 s and a 20 s slow response survive on h1 and h2; a stalled body is cut at
	# 15 s (h1 504, h2 502); unfinished headers are closed at 5 s.
	servers {
		timeouts {
			read_header 5s
			read_body 15s
		}
	}
}

https://svos.phasefinal.com:443 {
	bind 100.64.0.9
	tls {
		dns cloudflare {env.CF_API_TOKEN}
		resolvers 1.1.1.1 8.8.8.8
	}
	route {
		# Whole families by prefix (Caddy header-delete wildcard; tested 2026-10-05: case-insensitive, and
		# X-Forwardedness / Tailscale-Other survive), plus the named strays outside them.
		request_header -Tailscale-User-*
		request_header -X-Forwarded-*
		request_header -Tailscale-Headers-Info
		request_header -X-Real-IP
		request_header -Forwarded
		request_header -X-SVOS-Peer

		# Request bodies capped at exactly 1 MiB (1,048,576 B, = the kernel dispatcher's own cap) on every route;
		# only /api/* and the connector read bodies at all. Over the cap -> 413. Responses are not limited.
		request_body {
			max_size 1MiB
		}

		# Credential-free offline shell (svos-dev 2026-10-05): the service worker fetches its hash-pinned INVENTORY
		# with credentials:'omit', so exactly those files plus /sw.js are served BEFORE Basic auth, GET/HEAD only,
		# exact files (no fallback). Public build bytes only; no API or connector path. The path list is GENERATED
		# from the live release's sw.js by /usr/local/sbin/svos-front-inventory, which must run after every release.
		@public_shell {
			method GET HEAD
			import /etc/caddy/svos-public-inventory
		}
		handle @public_shell {
			@revalidate path /sw.js /index.html /manifest.webmanifest /favicon.svg /icons/*
			header @revalidate Cache-Control no-cache
			root * /opt/svos/current/static
			file_server
		}
		# Previous release's hashed assets across a rollout (svos-dev 2026-10-05): open workers restore evicted inventory
		# credential-free. Generated by the same tool: a matcher + handle rooted in THAT release's static dir, or
		# comments only when there is no previous release.
		import /etc/caddy/svos-public-previous

		basic_auth argon2id SVOS {
			import /etc/caddy/svos-basic-auth.accounts
		}
		request_header Tailscale-User-Login {http.auth.user.id}
		request_header -Authorization

		handle_path /api/* {
			reverse_proxy 127.0.0.1:8400
		}
		@connector path /health /verbs/ping /verbs/pins /verbs/pin /verbs/unpin /openapi.json
		handle @connector {
			reverse_proxy 127.0.0.1:8403
		}
		# Static routes per svoperatingsystem web/README.md "Hosting contract" (gated):
		# / and /status -> index.html; the static route space -> exact files, a missing file is 404 (never index.html);
		# other HTML document requests -> index.html (the app renders Page not found); everything else -> 404.
		@shell_doc path / /status
		handle @shell_doc {
			header Cache-Control no-cache
			root * /opt/svos/current/static
			rewrite * /index.html
			file_server
		}
		@static path /assets/* /icons/* /favicon.svg /manifest.webmanifest /index.html /sw.js
		handle @static {
			@revalidate path /sw.js /index.html /manifest.webmanifest /favicon.svg /icons/*
			header @revalidate Cache-Control no-cache
			root * /opt/svos/current/static
			file_server
		}
		@other_doc expression header({'Sec-Fetch-Dest': 'document'}) || header({'Accept': '*text/html*'})
		handle @other_doc {
			header Cache-Control no-cache
			root * /opt/svos/current/static
			rewrite * /index.html
			file_server
		}
		handle {
			respond 404
		}
	}
}

# app-terminal's own origin (svos-dev 2026-10-07, U8 + U10c "a pane needs its own origin"). The interface embeds this
# page in a cross-origin iframe; the browser derives its URL from the registry's base_url
# (instance.toml: base_url = "https://svos-terminal.phasefinal.com"), so the kernel's signed calls (/health, /verbs/*)
# also arrive here, from the box itself, and pass to the connector on 127.0.0.1:8404.
# - NO Basic auth and no identity header: Chromium cancels Basic prompts for cross-origin frames and subresources, so a
#   gate here kills the pane. The lease is the page's auth (GET / without one is a waiting page by ruling A105;
#   /terminal/session|stream|close refuse without the bearer); /verbs/* and /health refuse without the kernel's
#   Ed25519 signature. As defence in depth, /verbs/* and /health are refused unless they come from the box itself.
# - The app sets Content-Security-Policy: frame-ancestors https://svos.phasefinal.com (its frame_ancestors config);
#   it passes through unmodified. /terminal/stream is a long-lived ndjson stream: no buffering (flush_interval -1)
#   and no write/idle deadline (none is set anywhere in this file). Same 1 MiB body cap.
# - Mesh-only like the main site: headscale MagicDNS extra record -> 100.64.0.9; nh3-svos resolves it via /etc/hosts.
(svos_terminal_upstream) {
	reverse_proxy 127.0.0.1:8404 {
		flush_interval -1
	}
}

https://svos-terminal.phasefinal.com:443 {
	bind 100.64.0.9
	tls {
		dns cloudflare {env.CF_API_TOKEN}
		resolvers 1.1.1.1 8.8.8.8
	}
	route {
		request_header -Tailscale-User-*
		request_header -X-Forwarded-*
		request_header -Tailscale-Headers-Info
		request_header -X-Real-IP
		request_header -Forwarded
		request_header -X-SVOS-Peer

		request_body {
			max_size 1MiB
		}

		# The kernel's signed calls come from the box itself (its source on the mesh address); anything else is refused.
		@not_from_box {
			path /verbs/* /health
			not remote_ip 100.64.0.9 127.0.0.1
		}
		handle @not_from_box {
			respond 403
		}

		# Exactly the paths the connector serves (svos-dev 2026-10-07), method-pinned; everything else is 404.
		@page {
			method GET HEAD
			path / /assets/*
		}
		handle @page {
			import svos_terminal_upstream
		}
		@lease_get {
			method GET
			path /terminal/session /terminal/stream
		}
		handle @lease_get {
			import svos_terminal_upstream
		}
		@lease_close {
			method POST
			path /terminal/close
		}
		handle @lease_close {
			import svos_terminal_upstream
		}
		@verbs {
			method POST
			path /verbs/*
		}
		handle @verbs {
			import svos_terminal_upstream
		}
		@health {
			method GET
			path /health
		}
		handle @health {
			import svos_terminal_upstream
		}
		handle {
			respond 404
		}
	}
}
