Transports, lifecycle and exposure
The verb surface, the bind address, the start gate, the access log, tunnels, and what is pre-auth by construction.
The verb surface
Bind address: loopback by default, widening is a deliberate act
Default bind is 127.0.0.1:7654 — loopback, never wider. The listen address resolves
through the precedence ladder — --listen flag → SAPHAN_SERVER_LISTEN env →
server.listen config key → built-in default — and the refusal policy binds every tier
alike: a host-less value (":7654") from any source would bind every interface, so it is
refused, class listen-ambiguous, with a paste-ready remedy naming the offending tier.
Configuration cannot widen the bind any more quietly than the flag can. server status
reports the effective address a fresh start would bind, with its provenance tier.
Start gate: no identity, no listener
Before the listener accepts a single connection, the server checks that the runtime
identity registry is provisioned — at least one actor row, at least one binding row, and a
loadable trust-root pin. A missing registry refuses the start by name
(registry: runtime registry not provisioned) with the remediation spelled out
(saphan identity import). This is a deploy-fault check run once, at start, not per
request.
The access log is audit material
--access-log append-opens its path at start; an unwritable path refuses the start, class
access-log-unwritable — a server that cannot append to its audit log must not start.
off disables it deliberately. Under --stdio the default log stream is stderr, because
stdout is the MCP wire.
--detach: never a fork-and-lie
--detach re-execs the binary detached (new session, log redirected) and returns only
after a bounded readiness handshake — the child confirmed alive and holding the pidfile
within two seconds, or a named failure with the log tail. The pidfile is always written by
the foreground child, never by the detaching parent. --detach --stdio is refused (class
detach-stdio-conflict): stdio's whole point is that the invoking terminal owns the wire
for the life of the process, which a detached process can never provide.
--tunnel: reaching the server from outside, for evaluation
⚠ What this is and is not. --tunnel is a convenience for reaching a running server
from outside the machine without changing how it binds — for a demo, an evaluation, or a
developer working against a colleague's instance. It is not the way a production
deployment is exposed. It is documented here with its limits rather than left as a flag
someone discovers.
What it does. It launches cloudflared as a supervised child process fronting the
loopback listener with a quick tunnel.
The server itself never opens a wider bind and never terminates TLS —
cloudflared owns TLS, DNS and network exposure entirely, and every route it forwards
still sits behind the bearer middleware exactly as on loopback. A cloudflared binary
missing from PATH refuses the start by name, never a silent no-op front door.
--tunnel --stdio is refused (class tunnel-needs-listener). One deliberate interaction:
the self-issued authorization server refuses to operate over a forwarded hop — see
Authentication.
Pre-auth by construction: the short list
Everything on the HTTP mux is behind the bearer middleware except routes that cannot require a token by their nature:
| Route | Why pre-auth |
|---|---|
GET /.well-known/oauth-protected-resource (+ /mcp twin) | RFC 9728 discovery — the client needs it to obtain a token |
GET /.well-known/oauth-authorization-server | RFC 8414 discovery |
GET /authorize, POST /token | The authorization flow itself |
GET /icon.svg, favicons | Cosmetics — served pre-auth so a connector card can render its icon |
GET /get/install.sh, /get/SHA256SUMS, /get/{platform}/{bin} | Install bootstrap: a machine installing its first saphan binary has no token yet (Deployment) |
GET /console/login, POST /console/login, POST /console/logout | The console's own sign-in. Every other /console route requires a console session (not a bearer token) — see the console |
POST /console/spark | ⚠ Not behind the console session. It writes nothing: every request is refused with 409, because the console never signs |
GET /metrics | Deliberately pre-auth: a Prometheus scrape target carrying only aggregate counts — class names, never slugs, paths, or actor names (see below). No token can be required of a scraper, and there are no secret values to protect; treat network reachability as the control. |
The /get/* bootstrap checks its {platform}/{bin} wildcards against closed known sets
before they ever reach a filesystem path — traversal-safe by construction, not by cleaning
— and the install script's self-referencing URL is spliced only from a Host header
matching a strict shape, since that string lands inside a shell assignment about to be
piped to sh.
The metrics endpoint
GET /metrics on the serving daemon is a classic Prometheus text-exposition surface
(Content-Type: text/plain; version=0.0.4). It sits on the open mux; a non-GET method answers
405, and a failed
source query answers 500 with the error in the body — never a partial 200 page (a
half-scrape that reads as a healthy small fleet is the failure this surface refuses).
Every family is a gauge — the record's rows update in place (a run moves running → ok),
so per-status counts can fall; a counter would misreport a projection of current state:
| Family | Labels | What it projects |
|---|---|---|
saphan_runs | status, backend | recorded runs, by status and backend |
saphan_refusals | rule | recorded refusals, by rule — first-class, because on this architecture a refusal is a security signal, not noise |
saphan_gates | gate, decision | recorded gate decisions, by gate and decision |
saphan_owner_block_elements | state | owner-block elements, by state |
saphan_store_schema_version | — | the store's current schema-migration version |
saphan_delivery_measured_runs · saphan_delivery_unmeasured_runs · saphan_delivery_contradictory_runs | — | how many run records carry a delivery measurement, how many do not, and how many contradict themselves — the population the delivery figures below cover |
saphan_delivery_files_changed · _lines_added · _lines_deleted · _binary_files · _renames · _commits | — | delivery figures summed over the measured runs only; an unmeasured run is never counted as zero |
Label values are class names only — rule, status, backend, gate, decision, state — never
stream slugs, paths, or actor names. Cardinality is bounded and there is nothing to leak:
secret values are structurally absent (Secrets), and the projection is aggregate
counts, not rows. Values are escaped per the exposition format (\ → \\, " → \").
Scrape it like any Prometheus target:
or check it by hand: curl -s http://<serving-daemon-host>:<port>/metrics.
Security posture. The endpoint is unauthenticated by design — an open-mux projection (see the pre-auth table above). It exposes no secret values, but it does reveal fleet shape (counts of runs, refusals, gates, delivery volume), so keep it inside your perimeter and scope who can reach it.
Before you use --tunnel, four things it does not give you
- No named tunnel, no account, no DNS you control. A quick tunnel's address is issued for that run and is gone when the process ends. ⇒ It is not an address to put in a document, a client configuration, or a firewall rule.
- No audit of who reached you. Exposure is owned entirely by the tunnel provider: it owns TLS, DNS and the network path. Every route still sits behind the same bearer check as on loopback, so authorisation is unchanged — but the reachability is somebody else's.
- No production posture. ⛔ Do not front a production installation this way. A deployment that must be reachable belongs behind your own proxy, with your own certificate and your own access policy — see the deployment section.
- One deliberate refusal you will meet: the product's own authorization server refuses to operate over a forwarded hop. That is not a bug to work around; minting first-party credentials through somebody else's front door is exactly the shape it declines.
Why it is documented at all, given the above: because it exists in the binary. A flag that ships and is not written down is a flag someone finds at the worst moment — and the honest version of "internal convenience" is a page saying so, not silence.