oficina / docs Public

app: a development-only identity, guarded so it cannot ship

Spec §8.1 puts sign-in at the home's credential origin and the home does
not exist yet. This refuses to start outside development rather than
silently authenticating everyone, and it is deleted when assertion
verification lands.

- devauth.go: guardDevAuth is the single choke point every path to the
  fixed dev identity passes through (New itself, not just
  NewWithDevAuth), so DevAuth=true with any Env other than exactly
  "development" refuses with a named reason. Fail-closed: New already
  defaults an empty Env to "production" before the guard runs, and
  there is no allowlist to fall out of loosely, so a typo refuses
  identically to a deliberate "production". A loud slog.Warn fires on
  every boot where it does activate.
- cmd/docs/main.go: wires this behind an explicit -dev-auth flag,
  stripped out of os.Args before rastrillo.Resolve parses argv (its
  own flag.FlagSet errors on unknown flags otherwise). No flags means
  Env "production", DevAuth false — unchanged from before this task.
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 cc97e7d29a9e391e4f12ccc8420388f813e9eb64 parent 527b034
4 files changed, +239 −13
  • cmd/docs/main.go +44 −4
  • internal/docs/app.go +34 −9
  • internal/docs/devauth.go +81 −0
  • internal/docs/devauth_test.go +80 −0
diff --git a/cmd/docs/main.go b/cmd/docs/main.go
index 187b208..ad9f739 100644
--- a/cmd/docs/main.go
+++ b/cmd/docs/main.go
@@ -10,9 +10,34 @@ 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()
+ devAuth, rest := extractDevAuthFlag(os.Args[1:])
+ os.Args = append(os.Args[:1], rest...)
+
// Resolve applies the platform's activation argv (-socket/-addr/-db,
// or LISTEN_FDS/$STATE_DIRECTORY under the platform) — never
// hand-rolled, per SKILL.md §1.
@@ -43,14 +68,29 @@ func main() {
// — otherwise Serve would open a second connection to the file New
// already owns.
//
- // Env is "production" until Task 5's devauth.go lands and this
- // reads a flag instead.
- app, err := docs.New(docs.Config{
+ // Env is "production" unless -dev-auth was given on the command
+ // line, in which case NewWithDevAuth both sets Env to
+ // "development" and asks for the development-only identity — the
+ // only way any of this ever turns on is someone spelling that flag
+ // out explicitly; running this binary with no arguments always
+ // gets "production", DevAuth false.
+ cfg := docs.Config{
DBPath: opts.DBPath,
Env: "production",
Origin: origin,
Logger: logger,
- })
+ }
+ var app *docs.App
+ if devAuth {
+ // NewWithDevAuth itself forces DevAuth true; Env must be set
+ // to "development" here too — its guard checks both, and
+ // leaving Env at "production" would make -dev-auth refuse to
+ // start rather than silently doing nothing.
+ cfg.Env = "development"
+ app, err = docs.NewWithDevAuth(cfg)
+ } else {
+ app, err = docs.New(cfg)
+ }
if err != nil {
logger.Error("docs: new", "err", err)
os.Exit(1)
diff --git a/internal/docs/app.go b/internal/docs/app.go
index c9c8499..f7b0a14 100644
--- a/internal/docs/app.go
+++ b/internal/docs/app.go
@@ -31,15 +31,17 @@ type Config struct {
DBPath string
// Env is "development" or "production" ("" defaults to
- // "production"). A later task's development-only identity
- // (devauth.go) refuses to start unless Env is "development" — see
- // DevAuth.
+ // "production"). devauth.go's development-only identity refuses to
+ // start unless Env is exactly "development" — see DevAuth.
Env string
- // DevAuth enables that development-only identity. New itself does
- // nothing with it yet; the field exists here — rather than being
- // added when devauth.go lands — so NewForTest, which four later
- // tasks' tests already depend on, never needs a signature change.
+ // DevAuth enables devauth.go's fixed development identity —
+ // New's guardDevAuth refuses to start unless Env is exactly
+ // "development" too. Prefer NewWithDevAuth (devauth.go) over
+ // setting this by hand; it exists as its own Config field, rather
+ // than being threaded through only that constructor, so
+ // NewForTest — which four later tasks' tests depend on — never
+ // needed a signature change once devauth.go landed.
DevAuth bool
// Origin is this app's own origin (scheme://host[:port], no path)
@@ -87,8 +89,10 @@ var BootSchema = migrate.Merge(Schema)
// migrate.Apply once at boot, a chi router wrapped as an
// *http.ServeMux so httptest exercises the real router, csrf.Protect
// mounted app-wide over POST /documents (SKILL.md §5). It does not
-// wire sessions or an identity plugin — there is no signed-in route
-// yet; that lands with Task 5.
+// wire rastrillo's sessions package or a real identity plugin — there
+// is no signed-in route yet; spec §8.1's home issues the real identity
+// once it exists. devauth.go's guardDevAuth runs first, so a
+// misconfigured Config.DevAuth refuses before any of the above opens.
func New(cfg Config) (*App, error) {
logger := cfg.Logger
if logger == nil {
@@ -101,6 +105,22 @@ func New(cfg Config) (*App, error) {
cfg.Origin = DefaultOrigin
}
+ // Checked before anything else opens — including the database —
+ // so a misconfigured DevAuth refuses before New has any side
+ // effect to unwind. See devauth.go: this is the single choke
+ // point every path to the development-only identity passes
+ // through, NewWithDevAuth and NewForTest included.
+ if err := guardDevAuth(cfg); err != nil {
+ return nil, err
+ }
+ if cfg.DevAuth {
+ // Loud on purpose: an operator watching boot logs should
+ // never be able to miss this. It does not make the guard
+ // above any less necessary — logging is a tripwire, not a
+ // control.
+ logger.Warn("docs: development-only identity ENABLED — refuses to start outside Env=development", "env", cfg.Env)
+ }
+
d, err := db.Open(cfg.DBPath, logger)
if err != nil {
return nil, fmt.Errorf("docs: open database: %w", err)
@@ -136,6 +156,11 @@ func New(cfg Config) (*App, error) {
// Referer evidence; GET/HEAD/OPTIONS pass untouched, so this does
// nothing to "/" or "/d/{sqid}" and everything to "/documents".
r.Use(csrf.Protect(cfg.Origin))
+ if cfg.DevAuth {
+ // Guarded above: this only ever mounts when guardDevAuth has
+ // already confirmed Env == "development".
+ r.Use(devAuthMiddleware)
+ }
r.Get("/", h.list)
r.Post("/documents", h.create)
r.Get("/d/{sqid}", h.show)
diff --git a/internal/docs/devauth.go b/internal/docs/devauth.go
new file mode 100644
index 0000000..65a6694
--- /dev/null
+++ b/internal/docs/devauth.go
@@ -0,0 +1,81 @@
+// This file exists only because spec §8.1's home — the shared
+// credential origin that issues a signed identity assertion — does
+// not exist yet; it hands every request one fixed development
+// identity purely so the app is reachable in a browser, and it is
+// deleted the day this app verifies a real signed assertion instead.
+package docs
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+)
+
+// devSubject is the one identity devauth ever grants. Fixed rather
+// than configurable: a real identity is exactly what this stands in
+// for, so giving it knobs would just be building a worse version of
+// the thing it replaces.
+const devSubject = "dev"
+
+// DevIdentity is what devAuthMiddleware attaches to a request's
+// context — the shape spec §8.1's verified assertion will eventually
+// fill in for real.
+type DevIdentity struct {
+ Subject string
+}
+
+type devIdentityKey struct{}
+
+// CurrentDevIdentity returns the identity devAuthMiddleware attached
+// to r, if any. Nothing in this app requires a signed-in viewer yet,
+// so no handler calls this today — it exists so a later task can ask
+// "is anyone signed in" without reaching into this file's internals,
+// and it goes away with the rest of this file once §8.1 lands.
+func CurrentDevIdentity(r *http.Request) (DevIdentity, bool) {
+ id, ok := r.Context().Value(devIdentityKey{}).(DevIdentity)
+ return id, ok
+}
+
+// devAuthMiddleware stamps every request with the fixed development
+// identity. New wires this in only after guardDevAuth has approved
+// cfg — see New and NewWithDevAuth below.
+func devAuthMiddleware(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := context.WithValue(r.Context(), devIdentityKey{}, DevIdentity{Subject: devSubject})
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// guardDevAuth is the single choke point every path to the
+// development identity must pass through — New calls it directly, so
+// it applies whether an app is built via NewWithDevAuth, via New
+// itself with DevAuth set by hand, or via NewForTest.
+//
+// Fail closed: cfg.DevAuth false is always fine (there is nothing to
+// guard against), but cfg.DevAuth true demands cfg.Env be exactly
+// "development" — no allowlist of "development-like" strings to fall
+// out of, so an unrecognised value, a typo, or empty (New defaults ""
+// to "production" before this runs) all refuse identically to a
+// deliberate "production". There is exactly one string that is ever
+// permitted.
+func guardDevAuth(cfg Config) error {
+ if !cfg.DevAuth {
+ return nil
+ }
+ if cfg.Env != "development" {
+ return fmt.Errorf("docs: DevAuth requires Env %q to start development-only, insecure identity; got Env %q — refusing to start", "development", cfg.Env)
+ }
+ return nil
+}
+
+// NewWithDevAuth is New, named for the one call site that means to
+// opt into the development-only identity: cmd/docs/main.go's explicit
+// -dev-auth flag. It is not a second code path — it forces
+// cfg.DevAuth true and calls New unchanged, so the guard that
+// actually enforces "development only" (guardDevAuth, run inside New)
+// lives in exactly one place no matter which name a caller reaches it
+// through.
+func NewWithDevAuth(cfg Config) (*App, error) {
+ cfg.DevAuth = true
+ return New(cfg)
+}
diff --git a/internal/docs/devauth_test.go b/internal/docs/devauth_test.go
new file mode 100644
index 0000000..0d30ca4
--- /dev/null
+++ b/internal/docs/devauth_test.go
@@ -0,0 +1,80 @@
+package docs_test
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// TestDevAuthRefusesOutsideDevelopment is the plan's own literal test:
+// the guard is the point of this task — a build without the dev flag
+// must refuse to start rather than silently authenticating everyone.
+func TestDevAuthRefusesOutsideDevelopment(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start outside development")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthGivesAnIdentityInDevelopment is the plan's own literal
+// test: NewForTest fixes Env at "development" with DevAuth enabled, so
+// a request must not come back unauthorized.
+func TestDevAuthGivesAnIdentityInDevelopment(t *testing.T) {
+ app, err := docs.NewForTest(t) // NewForTest sets Env: development
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodGet, "/", nil)
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ if rec.Code == http.StatusUnauthorized {
+ t.Error("dev auth should provide an identity in development")
+ }
+}
+
+// TestDevAuthRefusesEmptyEnv proves the fail-closed default: an empty
+// Env must not be treated as "development-like". New defaults "" to
+// "production" before the guard runs, so this exercises that the
+// default itself is refused, not just an explicit "production".
+func TestDevAuthRefusesEmptyEnv(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: ""})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start with an empty Env")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthRefusesTypoedEnv proves there is no allowlist to fall out
+// of loosely — only the exact string "development" is ever permitted,
+// so a near-miss (a plausible env-var typo) refuses exactly like a
+// deliberate "production" rather than being treated as close enough.
+func TestDevAuthRefusesTypoedEnv(t *testing.T) {
+ _, err := docs.NewWithDevAuth(docs.Config{DevAuth: true, Env: "developement"})
+ if err == nil {
+ t.Fatal("dev auth must refuse to start with a typo'd Env")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}
+
+// TestDevAuthRefusesViaNewDirectly proves the guard is a single choke
+// point: calling New itself with DevAuth set by hand, bypassing
+// NewWithDevAuth entirely, refuses exactly the same way.
+func TestDevAuthRefusesViaNewDirectly(t *testing.T) {
+ _, err := docs.New(docs.Config{DevAuth: true, Env: "production"})
+ if err == nil {
+ t.Fatal("New itself must refuse DevAuth outside development, not just NewWithDevAuth")
+ }
+ if !strings.Contains(err.Error(), "development") {
+ t.Errorf("error should say why: %v", err)
+ }
+}