app: a one-command dev run, a real origin, and dev auth compiled out of release builds
scripts/run-dev.sh builds and runs the app with -dev-auth against a scratch database on a fixed port, printing the URL -- one command to see it in a browser. Config.Origin was a placeholder defaulting silently everywhere; it now comes from $DOCS_ORIGIN (the "<APP>_ORIGIN" convention rastrillo's own scaffold uses) or a derived -addr for local dev, and New refuses to start with an empty Origin outside Env=development rather than falling back to the wrong same-origin check -- CSRF is origin-checking, so a wrong origin either rejects everything or, loosened, protects nothing. -dev-auth is now compiled out of a release build entirely: cmd/docs/devauth_flag_release.go (-tags release) replaces the flag-parsing function with one that can never return true, closing the "flag left in a deploy script" hole at build time rather than only at runtime. The gate builds both configurations, asserts the release one refuses to start with -dev-auth, and now runs CGO_ENABLED=0 go build ./... so the app itself, not just the packages tests happen to import, is covered.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
8 files changed,
+255
−41
.amadan/README.md+15 −0.amadan/ci.d/10-gate+34 −0AGENTS.md+27 −0cmd/docs/devauth_flag.go+32 −0cmd/docs/devauth_flag_release.go+55 −0cmd/docs/main.go+22 −32internal/docs/app.go+30 −9scripts/run-dev.sh+40 −0
CI passed — run details
diff --git a/.amadan/README.md b/.amadan/README.md| index ffa66c9..d63de43 100644 |
| --- a/.amadan/README.md |
| +++ b/.amadan/README.md |
| @@ -47,3 +47,18 @@ tests will pass against it. |
| It also fails if regenerating the Yjs fixtures changes them. Fixtures are |
| the wire contract, not an output: a deliberate rule change regenerates them |
| in the same commit as the change. |
| + |
| +## The app, not just the core |
| + |
| +`go vet ./...` and `go test ./...` already cover `cmd/docs` and |
| +`internal/docs` — the rastrillo app built on top of `internal/core` — but |
| +neither one proves the app actually links into a runnable binary. The gate |
| +also runs `CGO_ENABLED=0 go build ./...` for that, and separately builds |
| +`cmd/docs` with `-tags release` and runs the result with `-dev-auth` on its |
| +command line, asserting it refuses to start: `devauth_flag_release.go` |
| +replaces the flag-parsing function with one that can never return true, so |
| +a release binary has no code path that can turn the development-only |
| +identity (`internal/docs/devauth.go`) on at all — a build-time closure of |
| +that hole, not just `guardDevAuth`'s runtime check. `scripts/run-dev.sh` |
| +builds and runs the ordinary (non-release) binary with `-dev-auth`, a fixed |
| +port, and a scratch database — one command to see the app in a browser. |
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index 0ae00a0..96728d9 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -70,4 +70,38 @@ go vet ./... |
| go test ./... -count=1 |
| test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; } |
| +echo "=== go: build ./... (the app, not just the packages tests import)" |
| +# go vet/go test above already type-check every package they touch, |
| +# but neither one proves cmd/docs itself links into a runnable binary |
| +# under CGO_ENABLED=0 with no -mod flag (this repo's own constraint) — |
| +# a plain build is the only step that actually does. CGO_ENABLED is |
| +# already exported above; stated again here so this line reads |
| +# correctly on its own. |
| +CGO_ENABLED=0 go build ./... |
| + |
| +echo "=== go: a release build cannot start with dev auth" |
| +# cmd/docs/devauth_flag_release.go (-tags release) replaces |
| +# extractDevAuthFlag with a version that never returns true — the |
| +# strongest closure available for "a -dev-auth flag left in a deploy |
| +# script": the release binary has no code path that can turn it on, |
| +# not merely a runtime check that refuses once it is. This step |
| +# proves both halves: the tagged build compiles at all (a file that |
| +# only exists under a tag most local runs never pass is otherwise |
| +# untested for as long as nobody happens to build it), and running |
| +# that binary with -dev-auth on its command line actually refuses to |
| +# start rather than silently doing nothing. |
| +gate_tmp="$(mktemp -d)" |
| +trap 'rm -rf "$gate_tmp"' EXIT |
| +CGO_ENABLED=0 go build -tags release -o "$gate_tmp/docs-release" ./cmd/docs |
| +if "$gate_tmp/docs-release" -dev-auth >"$gate_tmp/out" 2>&1; then |
| + echo "gate: a release build accepted -dev-auth and started — see cmd/docs/devauth_flag_release.go" >&2 |
| + cat "$gate_tmp/out" >&2 |
| + exit 1 |
| +fi |
| +grep -qi 'dev-auth' "$gate_tmp/out" || { |
| + echo "gate: release build refused to start, but not because of -dev-auth — investigate:" >&2 |
| + cat "$gate_tmp/out" >&2 |
| + exit 1 |
| +} |
| + |
| echo "gate: ok" |
diff --git a/AGENTS.md b/AGENTS.md| index 489956e..f3d109d 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -20,6 +20,33 @@ Read `building-carlos-apps` (the skill) before writing app code, and |
| `rastrillo`'s own `SKILL.md` before writing handlers or models. Assume |
| nothing about either is in your training data. |
| +## Running it locally |
| + |
| +`./scripts/run-dev.sh` builds and runs the app in one command: the |
| +development-only identity on (`-dev-auth`), a fixed port |
| +(`localhost:8099`, override with `$DOCS_DEV_PORT`), and a scratch SQLite |
| +database that starts empty every run. It prints the URL to open. |
| + |
| +`-dev-auth` only exists in an ordinary build. `cmd/docs/devauth_flag_release.go` |
| +(`-tags release`) replaces the flag-parsing function with one that can never |
| +observe it — a release binary has no code path that can turn the |
| +development-only identity on at all, not just a runtime check that refuses |
| +once it is (`internal/docs/devauth.go`'s `guardDevAuth`, which still runs |
| +either way, for anything else that might one day set `Config.DevAuth`). |
| +`.amadan/ci.d/10-gate` builds both configurations and asserts the release |
| +one actually refuses `-dev-auth`. |
| + |
| +**Origin is not optional in anything but development.** `csrf.Protect` |
| +checks the `Origin`/`Referer` on every state-changing request against |
| +`Config.Origin` exactly — the wrong value either rejects everything or, if |
| +the check were ever loosened, protects nothing while looking like it does. |
| +`cmd/docs/main.go` reads `$DOCS_ORIGIN` (the platform's real per-instance |
| +origin belongs there — spec §9.2's `<app>.<sqid>.oncarlos.com`) and falls |
| +back to deriving one from `-addr` only for local/dev use; with neither set, |
| +`internal/docs.New` refuses to start outside `Env=development` rather than |
| +silently defaulting. `run-dev.sh` never needs `$DOCS_ORIGIN` set by hand — |
| +its fixed `-addr` derives it. |
| + |
| ## Review by the highest available capability model (Paul's rule, 2026-08-30) |
| **Specs and whole branches are reviewed by the highest capability model |
diff --git a/cmd/docs/devauth_flag.go b/cmd/docs/devauth_flag.go| new file mode 100644 |
| index 0000000..16ea4ca |
| --- /dev/null |
| +++ b/cmd/docs/devauth_flag.go |
| @@ -0,0 +1,32 @@ |
| +//go:build !release |
| + |
| +// This file exists so a normal build (`go build ./...`, `go run`, |
| +// scripts/run-dev.sh — no build tags) can still reach the |
| +// development-only identity by spelling out -dev-auth on the command |
| +// line. devauth_flag_release.go is this file's mirror image, built |
| +// only with -tags release, where the function below does not exist at |
| +// all — see that file's doc comment for why a build tag is the |
| +// strongest closure available for this particular hole. |
| +package main |
| + |
| +// extractDevAuthFlag pulls a bare "-dev-auth"/"--dev-auth" token out |
| +// of args, returning whether it was present and everything else |
| +// unchanged (order preserved). rastrillo.Resolve parses os.Args[1:] |
| +// itself, with its own flag.FlagSet that knows only -socket/-addr/-db |
| +// and errors on anything else — so -dev-auth must be stripped out of |
| +// os.Args before Resolve ever sees it, or Resolve fails to start |
| +// outright rather than merely refusing dev auth. This is presence-only |
| +// (no "-dev-auth=false"): the deliberately narrow, explicit shape the |
| +// brief asks for — no config default, no env var, nothing a systemd |
| +// unit's boilerplate could set without someone typing this literal |
| +// flag. |
| +func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| + for _, a := range args { |
| + if a == "-dev-auth" || a == "--dev-auth" { |
| + enabled = true |
| + continue |
| + } |
| + rest = append(rest, a) |
| + } |
| + return enabled, rest |
| +} |
diff --git a/cmd/docs/devauth_flag_release.go b/cmd/docs/devauth_flag_release.go| new file mode 100644 |
| index 0000000..eb1912f |
| --- /dev/null |
| +++ b/cmd/docs/devauth_flag_release.go |
| @@ -0,0 +1,55 @@ |
| +//go:build release |
| + |
| +// A `-dev-auth` flag left in a deploy script or a systemd unit is a |
| +// residual security hole no in-process check can fully close — by the |
| +// time guardDevAuth (internal/docs/devauth.go) runs, the flag has |
| +// already been read and turned into Config.DevAuth true; a Env check |
| +// at that point catches "development" spelled wrong, not "someone |
| +// pasted the wrong invocation into a unit file that happens to set |
| +// Env=production correctly." Moving the closure to build time removes |
| +// the capability from the binary itself: a release build has no |
| +// extractDevAuthFlag that can ever observe -dev-auth and return true, |
| +// so there is no argv, env var, or config file anyone could hand this |
| +// specific binary that turns dev auth on. `go build -tags release |
| +// ./cmd/docs` is what the gate (.amadan/ci.d/10-gate) builds and |
| +// exercises; any real deploy pipeline must build the same way. |
| +// |
| +// What this still misses, honestly: |
| +// |
| +// 1. It only closes the argv path in *this* binary. Nothing enforces |
| +// that a deploy actually passes -tags release — a build run |
| +// without it still gets devauth_flag.go's real implementation. |
| +// The gate asserts the tagged build behaves correctly; it cannot |
| +// force a deploy script elsewhere to use it. That is a process |
| +// control, not a code one. |
| +// 2. internal/docs' devauth.go (NewWithDevAuth, guardDevAuth, |
| +// devAuthMiddleware, DevIdentity) is still compiled into the |
| +// binary either way — this file only removes the one path in |
| +// main() that could ever set Config.DevAuth true from outside the |
| +// process. guardDevAuth's runtime Env check (internal/docs/devauth.go) |
| +// is what still catches any *other* future path into DevAuth true |
| +// (a new flag added carelessly, a config field set by hand) — the |
| +// two guards are complementary, not substitutes for each other. |
| +// 3. The failure mode when someone does pass -dev-auth to a release |
| +// binary is a generic rastrillo.Resolve flag-parse error ("flag |
| +// provided but not defined: -dev-auth"), not a custom, friendlier |
| +// security message — deliberately: keeping this function's shape |
| +// identical to the non-release build (same signature, same |
| +// "always return false" contract when there is nothing to enable) |
| +// is what makes this file safe to review as "does nothing" rather |
| +// than as new logic that could itself have a bug. The refusal is |
| +// still loud (nonzero exit, a message naming the flag) — see |
| +// main.go's own os.Exit(1) on a Resolve error. |
| +package main |
| + |
| +// extractDevAuthFlag is the release build's version: it does not |
| +// scan args for -dev-auth at all, so `enabled` can never be anything |
| +// but false and `rest` is always the untouched input — a release |
| +// binary given "-dev-auth" on its command line has no code path that |
| +// turns it into Config.DevAuth true; the token simply reaches |
| +// rastrillo.Resolve's flag.FlagSet, which does not define it, and |
| +// Resolve fails to start (main.go's os.Exit(1)) exactly like any |
| +// other unrecognized flag. |
| +func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| + return false, args |
| +} |
diff --git a/cmd/docs/main.go b/cmd/docs/main.go| index ad9f739..8be01c6 100644 |
| --- a/cmd/docs/main.go |
| +++ b/cmd/docs/main.go |
| @@ -10,28 +10,6 @@ import ( |
| "github.com/carlosframework/docs/internal/docs" |
| ) |
| -// extractDevAuthFlag pulls a bare "-dev-auth"/"--dev-auth" token out |
| -// of args, returning whether it was present and everything else |
| -// unchanged (order preserved). rastrillo.Resolve parses os.Args[1:] |
| -// itself, with its own flag.FlagSet that knows only -socket/-addr/-db |
| -// and errors on anything else — so -dev-auth must be stripped out of |
| -// os.Args before Resolve ever sees it, or Resolve fails to start |
| -// outright rather than merely refusing dev auth. This is presence-only |
| -// (no "-dev-auth=false"): the deliberately narrow, explicit shape the |
| -// brief asks for — no config default, no env var, nothing a systemd |
| -// unit's boilerplate could set without someone typing this literal |
| -// flag. |
| -func extractDevAuthFlag(args []string) (enabled bool, rest []string) { |
| - for _, a := range args { |
| - if a == "-dev-auth" || a == "--dev-auth" { |
| - enabled = true |
| - continue |
| - } |
| - rest = append(rest, a) |
| - } |
| - return enabled, rest |
| -} |
| - |
| func main() { |
| logger := slog.Default() |
| @@ -50,16 +28,28 @@ func main() { |
| os.Exit(1) |
| } |
| - // Best-effort Origin for csrf.Protect (docs.Config.Origin's doc |
| - // comment): when the platform handed us a TCP -addr, that is |
| - // exactly the host a browser loading this instance sends back as |
| - // its Origin header, so derive from it rather than leaving the |
| - // placeholder default in place. Empty (unix socket / LISTEN_FDS, |
| - // or no -addr given) falls through to docs.DefaultOrigin — the |
| - // real per-instance origin (spec §9.2) is platform wiring Task 6 |
| - // adds, not this line. |
| - origin := docs.DefaultOrigin |
| - if opts.Addr != "" { |
| + // Origin decides csrf.Protect's same-origin check (docs.Config.Origin's |
| + // doc comment): an empty value here is not "no opinion", it is |
| + // "New must refuse to start" once Env is anything but development. |
| + // |
| + // $DOCS_ORIGIN is the explicit, deploy-time override — the |
| + // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses |
| + // (cmd/rastrillo/new.go's mainTemplate) — and always wins when set; |
| + // that is where the platform's real per-instance origin (spec |
| + // §9.2's <app>.<sqid>.oncarlos.com) belongs. Falling back to a TCP |
| + // -addr only helps the local/dev path (scripts/run-dev.sh, or a |
| + // person passing -addr by hand): that is exactly the host a |
| + // browser loading this instance sends back as its own Origin |
| + // header. The platform's real activation path is a unix socket or |
| + // LISTEN_FDS — no -addr to derive anything from — so without |
| + // $DOCS_ORIGIN set there, origin stays empty here and New refuses |
| + // to start rather than silently falling back to docs.DefaultOrigin. |
| + // That silent fallback is exactly the trap this task closes: the |
| + // wrong origin either rejects every same-origin request or, if the |
| + // check were ever loosened, protects nothing while looking like it |
| + // does. |
| + origin := os.Getenv("DOCS_ORIGIN") |
| + if origin == "" && opts.Addr != "" { |
| origin = "http://" + opts.Addr |
| } |
diff --git a/internal/docs/app.go b/internal/docs/app.go| index f7b0a14..bf84fbf 100644 |
| --- a/internal/docs/app.go |
| +++ b/internal/docs/app.go |
| @@ -47,16 +47,23 @@ type Config struct { |
| // Origin is this app's own origin (scheme://host[:port], no path) |
| // — csrf.Protect checks every POST/PUT/PATCH/DELETE's |
| // Origin/Referer against exactly this string (rastrillo SKILL.md |
| - // §5). Empty defaults to DefaultOrigin. |
| + // §5). Empty is allowed only when Env is "development" (New falls |
| + // back to DefaultOrigin there, for tests and local convenience); |
| + // with any other Env, empty is a startup refusal — see New. A |
| + // wrong-but-nonempty origin is a real trap either way: CSRF is |
| + // origin-checking, not token-based, so the wrong string means |
| + // every same-origin POST is rejected, and a loosened check with |
| + // the wrong string protects nothing while looking like it does. |
| // |
| - // This is a placeholder, not a resolved platform value: Resolve's |
| - // Options carries Addr/Socket, not an origin, and the instance's |
| - // real public hostname (spec §9.2's <app>.<sqid>.oncarlos.com) |
| - // only exists once that platform wiring lands — Task 6's |
| - // dev-run/deploy work, not this one. cmd/docs/main.go derives a |
| - // best-effort Origin from the resolved -addr when one was given |
| - // (matching what a browser hitting that address sends), and falls |
| - // back to DefaultOrigin otherwise. |
| + // cmd/docs/main.go wires this from $DOCS_ORIGIN, the same |
| + // "<APP>_ORIGIN" convention rastrillo's own `new` scaffold uses |
| + // (cmd/rastrillo/new.go's mainTemplate) — the platform's real |
| + // per-instance origin (spec §9.2's <app>.<sqid>.oncarlos.com) |
| + // is set there at deploy time. On the unix-socket/LISTEN_FDS |
| + // activation path there is no -addr to derive a fallback from, so |
| + // leaving $DOCS_ORIGIN unset must fail loudly rather than quietly |
| + // protecting the app with the wrong origin — that is exactly what |
| + // the Env-gated default below refuses to do. |
| Origin string |
| // Logger defaults to slog.Default(). |
| @@ -101,7 +108,21 @@ func New(cfg Config) (*App, error) { |
| if cfg.Env == "" { |
| cfg.Env = "production" |
| } |
| + |
| + // Fail closed, loudly: an empty Origin defaults to DefaultOrigin |
| + // only in development (tests via NewForTest, a person running |
| + // scripts/run-dev.sh). Anywhere else — Env left at its "production" |
| + // default very much included — an empty Origin is a startup |
| + // refusal, not a silent fallback to a placeholder that either |
| + // rejects every same-origin request or, worse, would protect |
| + // nothing if the check were ever loosened. This is the same |
| + // fail-closed shape as guardDevAuth below: exactly one string |
| + // ("development") is ever treated as "safe to default", checked |
| + // before anything else opens. |
| if cfg.Origin == "" { |
| + if cfg.Env != "development" { |
| + return nil, fmt.Errorf("docs: Config.Origin is empty and Env is %q, not %q — refusing to start with an unset origin; set $DOCS_ORIGIN to this instance's real origin", cfg.Env, "development") |
| + } |
| cfg.Origin = DefaultOrigin |
| } |
diff --git a/scripts/run-dev.sh b/scripts/run-dev.sh| new file mode 100755 |
| index 0000000..fc446ce |
| --- /dev/null |
| +++ b/scripts/run-dev.sh |
| @@ -0,0 +1,40 @@ |
| +#!/usr/bin/env bash |
| +# One command to see the app in a browser: build it with the |
| +# development-only identity (internal/docs/devauth.go) turned on, |
| +# against a scratch database that starts empty every run, on a fixed |
| +# local port so the URL below is always the same one to open. |
| +# |
| +# Never for anything but a laptop. -dev-auth only exists in a normal |
| +# build — cmd/docs/devauth_flag_release.go compiles it out entirely |
| +# with `-tags release`, which is what an actual deploy must build |
| +# instead; see that file for what a build tag closes and what it does |
| +# not. |
| +set -euo pipefail |
| +cd "$(dirname "$0")/.." |
| + |
| +port="${DOCS_DEV_PORT:-8099}" |
| +addr="localhost:${port}" |
| + |
| +# A scratch database per run, not docs.db in the repo root: this |
| +# script exists so anyone can see the app from nothing, not so it |
| +# accumulates state nobody wrote down. mktemp -d rather than a fixed |
| +# path under /tmp, so two concurrent runs (two people, or a person and |
| +# a CI job) never collide or clobber each other's database. |
| +scratch="$(mktemp -d "${TMPDIR:-/tmp}/docs-dev.XXXXXX")" |
| +db="${scratch}/docs.db" |
| +trap 'rm -rf "$scratch"' EXIT |
| + |
| +export CGO_ENABLED=0 |
| +go build -o "${scratch}/docs" ./cmd/docs |
| + |
| +# No $DOCS_ORIGIN needed here: -addr is a TCP address, so |
| +# cmd/docs/main.go derives the origin from it directly (the same host |
| +# a browser hitting this address sends back as its own Origin header). |
| +# That derivation only ever fires on this local/dev path — the real |
| +# platform activation is a unix socket with no -addr to derive |
| +# anything from, which is exactly why $DOCS_ORIGIN must be set |
| +# explicitly there (internal/docs/app.go's Config.Origin doc comment). |
| +echo "docs: dev database at ${db}" |
| +echo "docs: http://${addr}/" |
| + |
| +"${scratch}/docs" -dev-auth -addr "${addr}" -db "${db}" |