oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev 0c62fdaa73c7e8e09415e0c78807bed9ac9ca339 parent cc97e7d
8 files changed, +255 −41
  • .amadan/README.md +15 −0
  • .amadan/ci.d/10-gate +34 −0
  • AGENTS.md +27 −0
  • cmd/docs/devauth_flag.go +32 −0
  • cmd/docs/devauth_flag_release.go +55 −0
  • cmd/docs/main.go +22 −32
  • internal/docs/app.go +30 −9
  • scripts/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}"