User Story
As an operator deploying pyrycode-relay, I want the relay to refuse to start when --insecure-listen is set in production, so that a dev-config promoted to a prod deploy fails loudly at boot rather than serving plaintext traffic.
Context
The relay currently exposes mutually-exclusive --domain (autocert TLS) and --insecure-listen (plaintext) flags. Today nothing distinguishes a dev environment from a production environment, so an operator who copy-pastes a dev manifest into prod can boot a plaintext listener facing the internet.
This ticket introduces a deterministic production-mode signal — the env var PYRYCODE_RELAY_PRODUCTION=1 — and a boot-time check that refuses to start when production-mode is on AND --insecure-listen is set.
The env var contract is also expected to be consumed by sibling startup checks (e.g. refuse-to-run-as-uid-0-in-production, #78). This ticket is the canonical place where the contract is defined.
Related: #9 (ErrCacheDirInsecure boot-time refusal pattern), #39 (multi-instance check). Split from #42.
Acceptance Criteria
Technical Notes
Size Estimate
S
User Story
As an operator deploying pyrycode-relay, I want the relay to refuse to start when
--insecure-listenis set in production, so that a dev-config promoted to a prod deploy fails loudly at boot rather than serving plaintext traffic.Context
The relay currently exposes mutually-exclusive
--domain(autocert TLS) and--insecure-listen(plaintext) flags. Today nothing distinguishes a dev environment from a production environment, so an operator who copy-pastes a dev manifest into prod can boot a plaintext listener facing the internet.This ticket introduces a deterministic production-mode signal — the env var
PYRYCODE_RELAY_PRODUCTION=1— and a boot-time check that refuses to start when production-mode is on AND--insecure-listenis set.The env var contract is also expected to be consumed by sibling startup checks (e.g. refuse-to-run-as-uid-0-in-production, #78). This ticket is the canonical place where the contract is defined.
Related: #9 (
ErrCacheDirInsecureboot-time refusal pattern), #39 (multi-instance check). Split from #42.Acceptance Criteria
internal/relayreports whether the process is in production mode by readingPYRYCODE_RELAY_PRODUCTION;"1"means production, anything else (including unset,"0", or other values) means non-production. ThePYRYCODE_RELAY_PRODUCTION=1contract is documented in the helper's Go doc comment.internal/relayreturns the exported sentinelErrInsecureListenInProduction(branchable viaerrors.Is) when production-mode is on AND--insecure-listenis set, and returns nil otherwise.cmd/pyrycode-relay/main.goafter flag parse, before any listener is started, with fail-fast on error and a structured log line naming the env var and the fix.--domain(autocert) → nil; (d) non-production +--domain→ nil. The env var is read through a test seam (injected getter or equivalent), not by mutating process env.Technical Notes
"1"-means-on semantics intentionally mirror the existingPYRYCODE_RELAY_SINGLE_INSTANCEcontract (relay: single-instance constraint — doc + startup self-check (registry is in-memory) #39 / relay: document v1 single-instance constraint in architecture.md #64) so operators only have to remember one shape.ErrInsecureListenInProduction(downstream tickets reference it).Size Estimate
S