| new file mode 100644 |
| index 000000000..5ba48c00d |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-09-07-password-gate.md |
| @@ -0,0 +1,2130 @@ |
| +# Password Gate Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** A member can put one shared password in front of an instance's hostname (static site or prototype) from `carlos gate` or the console's app Settings page, and the edge shows a neutral login page to anyone without the cookie. |
| + |
| +**Architecture:** The bcrypt hash lives on the instance record (`provision.Record.Gate`), reconcile converges it into a new `routes.gate` column, and the edge enforces it in `ServeHTTP` ahead of the WebSocket bypass, the cache and the activator. A new package `internal/gate` owns the cookie (HMAC keyed off the hash, so a password change invalidates every cookie), the attempt limiter, the login page and the password rules; the console's API PATCH and Settings form both call those rules. |
| + |
| +**Tech Stack:** Go 1.26, `golang.org/x/crypto/bcrypt` (already a dependency), `html/template`, `embed`, SQLite via `internal/registry`. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-09-07-password-gate-design.md` — read it first; every task below cites a section. |
| + |
| +## Global Constraints |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean before every commit (AGENTS.md "Build & test"). |
| +- Sandbox: run go with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; if the toolchain reports "read-only file system", run that command with the sandbox disabled. |
| +- Cross-compile gate: no Linux-only syscalls anywhere in this change (`internal/gate` and the CLI are linked into the Mac binary). Nothing in this plan needs `//go:build`. |
| +- Wire shapes are additive: `gate` is `omitempty` on the record; the registry column defaults to `''`. |
| +- Never log, audit, echo or store a plaintext password. Log lines about the gate print `open` or `set`, never the hash. |
| +- Copy is fixed by the spec §4.4 and §5.5; do not reword it. |
| +- Commit messages: end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. Commit on the branch `worktree-password-gate`; never touch `main`. |
| +- Model per AGENTS.md: Tasks 1, 2, 6, 7 → Sonnet; Tasks 3, 4, 5 → Opus. Opus reviews Tasks 3, 4, 5 (security- and production-touching). |
| + |
| +--- |
| + |
| +### Task 1: Registry column `gate` (spec §3.2) |
| + |
| +**Files:** |
| +- Modify: `internal/registry/registry.go` — `Route` struct (near line 124, `Hibernate bool`), migration list (line ~417), `routeCols` (line ~683), `scanRoute` (line ~689), `Upsert` VALUES and `ON CONFLICT` (lines ~885-919), `Fingerprint` (line ~1918), new `SetGate` beside `SetSteering` (line ~1093). |
| +- Test: `internal/registry/registry_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `Route.Gate string`; `func (r *Registry) SetGate(host, hash string) error` (narrow UPDATE, error on unmatched host). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/registry/registry_test.go`: |
| + |
| +```go |
| +// Gate round-trips through Upsert (TestUpsertRoundTripsAllColumns is the |
| +// column-shift canary; this pins the column's own semantics), a full-row |
| +// Upsert with an empty gate PRESERVES a set one, and SetGate is the one |
| +// door that clears it. |
| +func TestGateColumnPreservedByUpsertClearedBySetGate(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready", Gate: "$2a$10$hash"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" { |
| + t.Fatalf("Gate after upsert = %q", got) |
| + } |
| + // A writer that knows nothing about the column must not open the host. |
| + if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" { |
| + t.Fatalf("Gate after a gate-less upsert = %q, want it preserved", got) |
| + } |
| + if err := reg.SetGate("g.example.com", ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, "g.example.com").Gate; got != "" { |
| + t.Fatalf("Gate after SetGate(\"\") = %q, want cleared", got) |
| + } |
| + if err := reg.SetGate("g.example.com", "$2a$10$other"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$other" { |
| + t.Fatalf("Gate after SetGate = %q", got) |
| + } |
| +} |
| + |
| +func TestSetGateErrorsOnUnmatchedHost(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.SetGate("nope.example.com", "$2a$10$hash"); err == nil { |
| + t.Fatal("SetGate on an unmatched host: want error, got nil") |
| + } |
| +} |
| + |
| +// The edge reloads only when Fingerprint moves; a gate set or cleared with |
| +// the column left out would take effect on the next unrelated reload — an |
| +// operator turning the password off and watching the page keep asking. |
| +func TestFingerprintSeesGateChanges(t *testing.T) { |
| + reg := openTestRegistry(t) |
| + if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp1, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.SetGate("g.example.com", "$2a$10$hash"); err != nil { |
| + t.Fatal(err) |
| + } |
| + fp2, err := reg.Fingerprint() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if fp1 == fp2 { |
| + t.Fatal("gate change invisible to Fingerprint — the edge would keep serving the old answer") |
| + } |
| +} |
| + |
| +func mustRoute(t *testing.T, reg *Registry, host string) Route { |
| + t.Helper() |
| + got, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got == nil { |
| + t.Fatalf("no route %s", host) |
| + } |
| + return *got |
| +} |
| +``` |
| + |
| +Also add `Gate: "$2a$10$distinct"` to the `want` literal in `TestUpsertRoundTripsAllColumns` (every column there carries a distinct value). |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/registry/ -run 'Gate|UpsertRoundTrips' -count=1` |
| +Expected: compile error, `Route` has no field `Gate`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `Route` (after `Hibernate bool`, line ~124): |
| + |
| +```go |
| + // Gate is the bcrypt hash of the host's shared password ("" = open), |
| + // converged from provision.Record.Gate by SetGate and read by the edge |
| + // on every request to the host (internal/gate). Validated at the |
| + // console door, never here — SetCaps's rule. IN Fingerprint(): a gate |
| + // set or cleared changes what the edge serves, and must reload it. |
| + // Upsert preserves a non-empty value against an empty one (the |
| + // caps/tunnel/unit idiom), so a full-row writer that predates the |
| + // column can never silently open a gated host. |
| + Gate string |
| +``` |
| + |
| +Migration list: append `` `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, `` as the LAST entry. |
| + |
| +`routeCols`: change the tail to `... addrs, health, steering, gate, created_at`. |
| + |
| +`scanRoute`: scan `&t.Steering, &t.Gate, &t.CreatedAt`. |
| + |
| +`Upsert`: one more `?` in VALUES (37 total); in `ON CONFLICT` add after the `steering=excluded.steering` line: `, gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`; argument list `t.Steering, t.Gate, t.CreatedAt`. |
| + |
| +`Fingerprint`: add `gate` to the SELECT after `health`, scan into a new `gate string`, and append `\x1f%s` with `gate` to the `Fprintf` format and args. |
| + |
| +New setter beside `SetSteering`: |
| + |
| +```go |
| +// SetGate stamps or clears a route's password gate — the narrow UPDATE the |
| +// provision converge uses (never a full-row Upsert, which preserves a set |
| +// gate by design and so could not clear one). Errors on an unmatched host, |
| +// SetBacking's contract. |
| +func (r *Registry) SetGate(host, hash string) error { |
| + res, err := r.sql.Exec(`UPDATE routes SET gate = ? WHERE host = ?`, hash, host) |
| + if err != nil { |
| + return err |
| + } |
| + n, err := res.RowsAffected() |
| + if err != nil { |
| + return err |
| + } |
| + if n == 0 { |
| + return fmt.Errorf("no route for host %q", host) |
| + } |
| + return nil |
| +} |
| +``` |
| + |
| +Search the package for other places that enumerate columns (`grep -n "steering" internal/registry/*.go`) — `SnapshotFingerprint` uses `routeCols` and needs nothing; any other hand-written SELECT that lists every column must gain `gate`. |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/registry/ -count=1` |
| +Expected: PASS, including `TestUpsertRoundTripsAllColumns`. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/registry/registry.go internal/registry/registry_test.go |
| +git commit -m "registry: gate column, SetGate, and the fingerprint sees it" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Record field, validation and converge (spec §3.1, §3.3) |
| + |
| +**Files:** |
| +- Modify: `internal/provision/provision.go` — `Record` (after `Steering`, line ~123), `ValidateShape` (line ~380). |
| +- Modify: `internal/provision/reconcile.go` — the owned-converge arm beside the hibernate block (line ~805-820). |
| +- Test: `internal/provision/provision_test.go`, `internal/provision/reconcile_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `registry.SetGate` (Task 1). |
| +- Produces: `Record.Gate string \`json:"gate,omitempty"\``; `ValidateShape` refuses a non-bcrypt gate. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +In `internal/provision/provision_test.go` add: |
| + |
| +```go |
| +func TestValidateShapeGate(t *testing.T) { |
| + ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"} |
| + if err := ValidateShape(ok); err != nil { |
| + t.Fatalf("bcrypt gate refused: %v", err) |
| + } |
| + bad := Record{Kind: "static", Gate: "hunter2"} |
| + if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") { |
| + t.Fatalf("plaintext gate accepted: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +In `internal/provision/reconcile_test.go` add (mirrors `TestReconcileConvergesSteering`): |
| + |
| +```go |
| +func TestReconcileConvergesGate(t *testing.T) { |
| + s, sqid := setup(t) |
| + rec := acmeRecord(sqid) |
| + if err := CreateRecord(ctx, s, rec); err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := regWith(t) |
| + cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + before := *mustGet(t, reg, "acme.go.tito.io") |
| + if before.Gate != "" { |
| + t.Fatalf("minted Gate = %q, want open", before.Gate) |
| + } |
| + |
| + const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" |
| + rec.Gate = hash |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + after := *mustGet(t, reg, "acme.go.tito.io") |
| + if after.Gate != hash { |
| + t.Errorf("gate = %q, want the hash", after.Gate) |
| + } |
| + after.Gate = before.Gate |
| + if after != before { |
| + t.Errorf("converging gate touched another column:\n before %+v\n after %+v", before, after) |
| + } |
| + |
| + // A record with no gate field is an OPEN host — it is a string, not a |
| + // pointer: absent and "" are the same fact, and clearing must reach the |
| + // row through SetGate. |
| + rec.Gate = "" |
| + putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| + if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" { |
| + t.Errorf("gate = %q, want it cleared", got) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/provision/ -run 'Gate' -count=1` |
| +Expected: compile error, no field `Gate`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`Record`, after `Steering`: |
| + |
| +```go |
| + // Gate is the bcrypt hash of the host's shared password ("" = open, |
| + // the value every record written before this field existed decodes |
| + // to). The ONLY place the secret material lives in the bucket; the |
| + // plaintext crosses the console once, at the door that hashes it |
| + // (internal/gate.Hash), and is never stored, logged or echoed — the |
| + // console's record views shadow this field (apiinstances.go). A |
| + // string, not a pointer: absent and "" are the same fact, an open |
| + // host. Converged into routes.gate by convergeGate and read by the |
| + // edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| + Gate string `json:"gate,omitempty"` |
| +``` |
| + |
| +`ValidateShape`, after the steering check: |
| + |
| +```go |
| + if rec.Gate != "" { |
| + if _, err := bcrypt.Cost([]byte(rec.Gate)); err != nil { |
| + return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear") |
| + } |
| + } |
| +``` |
| + |
| +Import `golang.org/x/crypto/bcrypt`. (Do not import `internal/gate` here: `internal/gate` will import nothing from provision, but keeping provision free of it keeps the record package's dependency list what it is.) |
| + |
| +`reconcile.go`, in the `existing.Provisioned && existing.App == rec.App && ...` arm, directly after the hibernate block: |
| + |
| +```go |
| + if existing.Gate != rec.Gate { |
| + if cfg.DryRun { |
| + log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| + } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil { |
| + log.Printf("provision: converge gate on %s: %v", rec.Host, err) |
| + } else { |
| + log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| + } |
| + } |
| +``` |
| + |
| +and at file scope: |
| + |
| +```go |
| +// gateWord renders a gate column for a log line: "open" or "set", never the |
| +// hash — it has no business in a journal. |
| +func gateWord(hash string) string { |
| + if hash == "" { |
| + return "open" |
| + } |
| + return "set" |
| +} |
| +``` |
| + |
| +Also in `routeFor` (reconcile.go:289) set `Gate: rec.Gate` on the minted route so a record created already gated (a future create-with-gate) mints closed; today the create door never sets it, so this is a one-line consistency edit. |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/provision/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/provision/ |
| +git commit -m "provision: Record.Gate, validated as a bcrypt hash and converged into routes.gate" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: `internal/gate` — rules, cookie, limiter, page, Admit (spec §4.2–§4.4, §5.1) |
| + |
| +**Files:** |
| +- Create: `internal/gate/gate.go`, `internal/gate/cookie.go`, `internal/gate/limiter.go`, `internal/gate/page.go`, `internal/gate/login.html` |
| +- Test: `internal/gate/gate_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `const CookieName = "carlos_gate"`, `const LoginPath = "/.carlos/gate"`, `const Lifetime = 7 * 24 * time.Hour` |
| + - `func CheckPassword(p string) error`, `func Hash(p string) (string, error)` |
| + - `func Mint(host, hash string, expiry time.Time) string`, `func Verify(value, host, hash string, now time.Time) bool` |
| + - `type Limiter`, `func NewLimiter(now func() time.Time) *Limiter`, `func (l *Limiter) Allow(key string) bool` |
| + - `type Gate`, `func New() *Gate`, `func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool` |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +`internal/gate/gate_test.go`: |
| + |
| +```go |
| +package gate |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestCheckPassword(t *testing.T) { |
| + if err := CheckPassword("short7!"); err == nil { |
| + t.Fatal("7 chars accepted") |
| + } |
| + if err := CheckPassword(strings.Repeat("x", 73)); err == nil { |
| + t.Fatal("73 bytes accepted — bcrypt would silently truncate") |
| + } |
| + if err := CheckPassword("longenough"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestHashThenVerifyPassword(t *testing.T) { |
| + h, err := Hash("correct horse") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(h, "$2a$10$") { |
| + t.Fatalf("hash = %q, want bcrypt cost 10", h) |
| + } |
| + if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") { |
| + t.Fatal("comparePassword disagrees with Hash") |
| + } |
| +} |
| + |
| +func TestCookieRoundTrip(t *testing.T) { |
| + now := time.Unix(1_800_000_000, 0) |
| + v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) |
| + if !Verify(v, "site.example", "$2a$10$hash", now) { |
| + t.Fatal("fresh cookie refused") |
| + } |
| + if Verify(v, "other.example", "$2a$10$hash", now) { |
| + t.Fatal("cookie accepted for another host") |
| + } |
| + if Verify(v, "site.example", "$2a$10$rotated", now) { |
| + t.Fatal("cookie accepted after the password changed") |
| + } |
| + if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) { |
| + t.Fatal("expired cookie accepted") |
| + } |
| + if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) { |
| + t.Fatal("malformed cookie accepted") |
| + } |
| +} |
| + |
| +func TestLimiterTenPerMinute(t *testing.T) { |
| + now := time.Unix(0, 0) |
| + l := NewLimiter(func() time.Time { return now }) |
| + for i := 0; i < 10; i++ { |
| + if !l.Allow("k") { |
| + t.Fatalf("attempt %d refused", i+1) |
| + } |
| + } |
| + if l.Allow("k") { |
| + t.Fatal("11th attempt allowed") |
| + } |
| + if !l.Allow("other") { |
| + t.Fatal("another key shares the bucket") |
| + } |
| + now = now.Add(6 * time.Second) |
| + if !l.Allow("k") { |
| + t.Fatal("no refill after 6s") |
| + } |
| +} |
| + |
| +func testGate(hash string) *Gate { |
| + g := New() |
| + g.now = func() time.Time { return time.Unix(1_800_000_000, 0) } |
| + return g |
| +} |
| + |
| +func TestAdmitNoCookieIsTheLoginPage(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} { |
| + rec := httptest.NewRecorder() |
| + r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil) |
| + if g.Admit(rec, r, "site.example", h) { |
| + t.Fatalf("%s without a cookie admitted", method) |
| + } |
| + if rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("%s status = %d, want 401", method, rec.Code) |
| + } |
| + if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| + t.Fatalf("Cache-Control = %q", cc) |
| + } |
| + if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" { |
| + t.Fatalf("X-Robots-Tag = %q", rt) |
| + } |
| + body := rec.Body.String() |
| + if method == http.MethodHead { |
| + if body != "" { |
| + t.Fatal("HEAD carried a body") |
| + } |
| + continue |
| + } |
| + if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") { |
| + t.Fatalf("page missing copy: %s", body) |
| + } |
| + if !strings.Contains(body, `name="next" value="/docs?x=1"`) { |
| + t.Fatalf("page does not carry next: %s", body) |
| + } |
| + if !strings.Contains(body, `action="/.carlos/gate"`) { |
| + t.Fatalf("form action wrong: %s", body) |
| + } |
| + } |
| +} |
| + |
| +func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder { |
| + rec := httptest.NewRecorder() |
| + form := url.Values{"password": {password}, "next": {next}} |
| + r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + r.RemoteAddr = "203.0.113.9:4242" |
| + g.Admit(rec, r, host, hash) |
| + return rec |
| +} |
| + |
| +func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1") |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" { |
| + t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location")) |
| + } |
| + var c *http.Cookie |
| + for _, k := range rec.Result().Cookies() { |
| + if k.Name == CookieName { |
| + c = k |
| + } |
| + } |
| + if c == nil { |
| + t.Fatal("no gate cookie set") |
| + } |
| + if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) { |
| + t.Fatalf("cookie attributes: %+v", c) |
| + } |
| + if !Verify(c.Value, "site.example", h, g.now()) { |
| + t.Fatal("minted cookie does not verify") |
| + } |
| + // And the cookie is admitted, and stripped from what the app sees. |
| + rec2 := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) |
| + r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1") |
| + if !g.Admit(rec2, r, "site.example", h) { |
| + t.Fatal("valid cookie refused") |
| + } |
| + if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" { |
| + t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got) |
| + } |
| +} |
| + |
| +func TestAdmitBadNextBecomesRoot(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} { |
| + rec := postLogin(g, "site.example", h, "correct horse", next) |
| + if loc := rec.Header().Get("Location"); loc != "/" { |
| + t.Fatalf("next %q -> Location %q, want /", next, loc) |
| + } |
| + } |
| +} |
| + |
| +func TestAdmitWrongPasswordThenLimit(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + compares := 0 |
| + g.compare = func(hash, password string) bool { compares++; return false } |
| + for i := 0; i < 10; i++ { |
| + rec := postLogin(g, "site.example", h, "nope nope", "/") |
| + if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") { |
| + t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String()) |
| + } |
| + } |
| + rec := postLogin(g, "site.example", h, "nope nope", "/") |
| + if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" { |
| + t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After")) |
| + } |
| + if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") { |
| + t.Fatalf("429 page copy: %s", rec.Body.String()) |
| + } |
| + if compares != 10 { |
| + t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares) |
| + } |
| +} |
| + |
| +func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) { |
| + g := testGate("") |
| + h, _ := Hash("correct horse") |
| + rec := httptest.NewRecorder() |
| + r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil) |
| + if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized { |
| + t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/gate/ -count=1` |
| +Expected: build fails, package does not exist. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`internal/gate/gate.go`: |
| + |
| +```go |
| +// Package gate is the password curtain the edge holds in front of a gated |
| +// host: one shared password per hostname, a neutral page that asks for it, |
| +// and a cookie that remembers the answer for a week. Spec: |
| +// docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| +// |
| +// The edge calls Admit once per request to a route whose registry row |
| +// carries a gate hash. Everything else here — the rules the console |
| +// applies before hashing, the cookie's shape, the attempt limiter — is |
| +// owned by this package so the two console doors and the edge cannot |
| +// drift from one another. |
| +package gate |
| + |
| +import ( |
| + "fmt" |
| + "net" |
| + "net/http" |
| + "strings" |
| + "time" |
| + |
| + "golang.org/x/crypto/bcrypt" |
| +) |
| + |
| +const ( |
| + // CookieName is the browser cookie a passed gate sets; stripped from |
| + // the request before it reaches the app. |
| + CookieName = "carlos_gate" |
| + // LoginPath is the form's action on a gated host. Ordinary on every |
| + // other host and for every other method. |
| + LoginPath = "/.carlos/gate" |
| + // Lifetime is how long a cookie stays valid; the expiry is inside the |
| + // value, so a stolen cookie is bounded even if the browser keeps it. |
| + Lifetime = 7 * 24 * time.Hour |
| + |
| + bcryptCost = 10 |
| + minPassword = 8 |
| + maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated |
| + maxFormBody = 4 << 10 |
| +) |
| + |
| +// CheckPassword is the door's rule, applied by the console before hashing. |
| +func CheckPassword(p string) error { |
| + if len(p) < minPassword { |
| + return fmt.Errorf("password must be at least %d characters", minPassword) |
| + } |
| + if len(p) > maxPassword { |
| + return fmt.Errorf("password must be at most %d bytes", maxPassword) |
| + } |
| + return nil |
| +} |
| + |
| +// Hash is bcrypt at cost 10. The console calls it once per set; the edge |
| +// never does (it compares on the login POST and verifies an HMAC cookie |
| +// on every other request). |
| +func Hash(p string) (string, error) { |
| + if err := CheckPassword(p); err != nil { |
| + return "", err |
| + } |
| + b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost) |
| + if err != nil { |
| + return "", err |
| + } |
| + return string(b), nil |
| +} |
| + |
| +func comparePassword(hash, password string) bool { |
| + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil |
| +} |
| + |
| +// Gate is the per-edge instance: one limiter shared by every gated host on |
| +// the box (keys carry the host), a clock and a comparer for tests. |
| +type Gate struct { |
| + limiter *Limiter |
| + now func() time.Time |
| + compare func(hash, password string) bool |
| +} |
| + |
| +// New is the edge's constructor. There is no configuration. |
| +func New() *Gate { |
| + g := &Gate{now: time.Now, compare: comparePassword} |
| + g.limiter = NewLimiter(func() time.Time { return g.now() }) |
| + return g |
| +} |
| + |
| +// Admit decides one request to a gated host. host is the route's own |
| +// hostname (the row's, not the request's — an alias and a path-routed |
| +// prefix must agree on which key signs), hash the row's gate. |
| +// |
| +// true: the request carries a valid cookie, now stripped from the Cookie |
| +// header; the caller proxies it exactly as it would have. false: Admit has |
| +// written the whole response — the login page (401), the redirect after a |
| +// successful POST (303), or the too-many-attempts page (429). |
| +func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool { |
| + if r.Method == http.MethodPost && r.URL.Path == LoginPath { |
| + g.login(w, r, host, hash) |
| + return false |
| + } |
| + if c, err := r.Cookie(CookieName); err == nil && Verify(c.Value, host, hash, g.now()) { |
| + stripCookie(r) |
| + return true |
| + } |
| + writePage(w, r, host, http.StatusUnauthorized, "", safeNext(r.URL.RequestURI())) |
| + return false |
| +} |
| + |
| +func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) { |
| + r.Body = http.MaxBytesReader(w, r.Body, maxFormBody) |
| + if err := r.ParseForm(); err != nil { |
| + writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/") |
| + return |
| + } |
| + next := safeNext(r.PostForm.Get("next")) |
| + if !g.limiter.Allow(clientKey(r) + "\x1f" + host) { |
| + w.Header().Set("Retry-After", "60") |
| + writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next) |
| + return |
| + } |
| + if !g.compare(hash, r.PostForm.Get("password")) { |
| + writePage(w, r, host, http.StatusUnauthorized, msgWrong, next) |
| + return |
| + } |
| + http.SetCookie(w, &http.Cookie{ |
| + Name: CookieName, |
| + Value: Mint(host, hash, g.now().Add(Lifetime)), |
| + Path: "/", |
| + MaxAge: int(Lifetime / time.Second), |
| + HttpOnly: true, |
| + Secure: true, |
| + SameSite: http.SameSiteLaxMode, |
| + }) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, next, http.StatusSeeOther) |
| +} |
| + |
| +// safeNext is the entire open-redirect surface: a path on this host, or "/". |
| +func safeNext(next string) string { |
| + if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { |
| + return "/" |
| + } |
| + return next |
| +} |
| + |
| +// clientKey is the remote address without its port. A backhauled request's |
| +// address is the sibling edge's, which folds every hop from that edge into |
| +// one bucket — stricter, never looser, and named in the spec (§4.2). |
| +func clientKey(r *http.Request) string { |
| + if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| + return h |
| + } |
| + return r.RemoteAddr |
| +} |
| + |
| +// stripCookie removes the gate cookie from the request so the app behind |
| +// the curtain never sees it. Other cookies pass through in their order. |
| +func stripCookie(r *http.Request) { |
| + cookies := r.Cookies() |
| + r.Header.Del("Cookie") |
| + parts := make([]string, 0, len(cookies)) |
| + for _, c := range cookies { |
| + if c.Name != CookieName { |
| + parts = append(parts, c.Name+"="+c.Value) |
| + } |
| + } |
| + if len(parts) > 0 { |
| + r.Header.Set("Cookie", strings.Join(parts, "; ")) |
| + } |
| +} |
| +``` |
| + |
| +`internal/gate/cookie.go`: |
| + |
| +```go |
| +package gate |
| + |
| +import ( |
| + "crypto/hmac" |
| + "crypto/sha256" |
| + "encoding/base64" |
| + "strconv" |
| + "strings" |
| + "time" |
| +) |
| + |
| +// The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))> |
| +// with key derived from the stored bcrypt hash and nothing else. That is |
| +// what makes the design stateless and gives it its one rotation rule: |
| +// change or clear the password and every cookie minted under the old one |
| +// stops verifying at once, on every box, with no key to mint or mirror. |
| +// Anyone who can read the hash can mint a cookie — root on the box and a |
| +// bucket reader, who already hold the tenant's sealed secrets (spec §4.3). |
| + |
| +const cookieVersion = "v1" |
| + |
| +func cookieKey(hash string) []byte { |
| + m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1")) |
| + m.Write([]byte(hash)) |
| + return m.Sum(nil) |
| +} |
| + |
| +func cookieMAC(key []byte, host string, expiry int64) []byte { |
| + m := hmac.New(sha256.New, key) |
| + m.Write([]byte(host)) |
| + m.Write([]byte{'\n'}) |
| + m.Write([]byte(strconv.FormatInt(expiry, 10))) |
| + return m.Sum(nil) |
| +} |
| + |
| +// Mint produces a cookie value for host, valid until expiry. |
| +func Mint(host, hash string, expiry time.Time) string { |
| + exp := expiry.Unix() |
| + mac := cookieMAC(cookieKey(hash), host, exp) |
| + return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac) |
| +} |
| + |
| +// Verify reports whether value was minted for host under hash and has not |
| +// expired at now. One HMAC and a constant-time compare; no bcrypt. |
| +func Verify(value, host, hash string, now time.Time) bool { |
| + parts := strings.Split(value, ".") |
| + if len(parts) != 3 || parts[0] != cookieVersion { |
| + return false |
| + } |
| + exp, err := strconv.ParseInt(parts[1], 10, 64) |
| + if err != nil || exp <= now.Unix() { |
| + return false |
| + } |
| + got, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| + if err != nil { |
| + return false |
| + } |
| + return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp)) |
| +} |
| +``` |
| + |
| +`internal/gate/limiter.go`: |
| + |
| +```go |
| +package gate |
| + |
| +import ( |
| + "sync" |
| + "time" |
| +) |
| + |
| +const ( |
| + limitBurst = 10 |
| + limitPeriod = time.Minute |
| + limitPrune = 10_000 |
| +) |
| + |
| +// Limiter is a per-key token bucket: limitBurst attempts, refilling at |
| +// limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of |
| +// milliseconds a comparison; this exists so the CPU is spent on people and |
| +// not on a script. |
| +type Limiter struct { |
| + mu sync.Mutex |
| + now func() time.Time |
| + buckets map[string]*bucket |
| +} |
| + |
| +type bucket struct { |
| + tokens float64 |
| + last time.Time |
| +} |
| + |
| +func NewLimiter(now func() time.Time) *Limiter { |
| + return &Limiter{now: now, buckets: map[string]*bucket{}} |
| +} |
| + |
| +// Allow spends one token for key, reporting whether one was available. |
| +func (l *Limiter) Allow(key string) bool { |
| + l.mu.Lock() |
| + defer l.mu.Unlock() |
| + now := l.now() |
| + b, ok := l.buckets[key] |
| + if !ok { |
| + if len(l.buckets) >= limitPrune { |
| + l.prune(now) |
| + } |
| + b = &bucket{tokens: limitBurst, last: now} |
| + l.buckets[key] = b |
| + } |
| + refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds()) |
| + b.tokens = min(limitBurst, b.tokens+refill) |
| + b.last = now |
| + if b.tokens < 1 { |
| + return false |
| + } |
| + b.tokens-- |
| + return true |
| +} |
| + |
| +// prune drops every bucket that has been idle long enough to be full again; |
| +// keeping it would change no answer. |
| +func (l *Limiter) prune(now time.Time) { |
| + for k, b := range l.buckets { |
| + if now.Sub(b.last) >= limitPeriod { |
| + delete(l.buckets, k) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +`internal/gate/page.go`: |
| + |
| +```go |
| +package gate |
| + |
| +import ( |
| + _ "embed" |
| + "html/template" |
| + "log" |
| + "net/http" |
| +) |
| + |
| +//go:embed login.html |
| +var loginHTML string |
| + |
| +var loginTmpl = template.Must(template.New("login").Parse(loginHTML)) |
| + |
| +const ( |
| + msgWrong = "That password is not right." |
| + msgTooMany = "Too many attempts. Try again in a minute." |
| +) |
| + |
| +type pageData struct { |
| + Host string |
| + Error string |
| + Next string |
| + Action string |
| +} |
| + |
| +// writePage renders the login page at status. Every page this package |
| +// writes is no-store and noindex: it is a curtain, not content. |
| +func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) { |
| + h := w.Header() |
| + h.Set("Cache-Control", "no-store") |
| + h.Set("X-Robots-Tag", "noindex, nofollow") |
| + h.Set("Content-Type", "text/html; charset=utf-8") |
| + w.WriteHeader(status) |
| + if r.Method == http.MethodHead { |
| + return |
| + } |
| + if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil { |
| + log.Printf("gate: render login page for %s: %v", host, err) |
| + } |
| +} |
| +``` |
| + |
| +`internal/gate/login.html` — the palette is `internal/status/assets/status.css`'s, inline, so the page is one response: |
| + |
| +```html |
| +<!doctype html> |
| +<html lang="en"> |
| +<head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<meta name="robots" content="noindex, nofollow"> |
| +<title>Password required</title> |
| +<style> |
| +:root { |
| + --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85; |
| + --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a; |
| +} |
| +@media (prefers-color-scheme: dark) { |
| + :root { |
| + --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd; |
| + --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76; |
| + } |
| +} |
| +* { margin: 0; padding: 0; box-sizing: border-box; } |
| +body { |
| + background: var(--bg); color: var(--ink); |
| + font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; |
| + min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem; |
| +} |
| +main { width: 100%; max-width: 22rem; } |
| +h1 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
| +p { color: var(--muted); margin-bottom: 1.25rem; } |
| +p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; } |
| +label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; } |
| +input[type=password] { |
| + width: 100%; font: inherit; color: var(--ink); background: var(--bg); |
| + border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem; |
| +} |
| +input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } |
| +button { |
| + width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent); |
| + border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer; |
| +} |
| +.error { color: var(--bad); margin: 0 0 0.9rem; } |
| +</style> |
| +</head> |
| +<body> |
| +<main> |
| + <h1>Password required</h1> |
| + <p><code>{{.Host}}</code> is private. Enter the password to continue.</p> |
| + <form method="post" action="{{.Action}}"> |
| + <input type="hidden" name="next" value="{{.Next}}"> |
| + {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| + <label for="password">Password</label> |
| + <input type="password" id="password" name="password" autocomplete="current-password" autofocus required> |
| + <button type="submit">Continue</button> |
| + </form> |
| +</main> |
| +</body> |
| +</html> |
| +``` |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/gate/ -count=1 && go vet ./internal/gate/` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/gate/ |
| +git commit -m "gate: password rules, HMAC cookie keyed off the hash, limiter and login page" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: The edge enforces it (spec §4.1) |
| + |
| +**Files:** |
| +- Modify: `internal/edge/edge.go` — `Edge` struct (line ~86), `New` (line ~274), `ServeHTTP` between the usage-meter wrap and the `isUpgradeRequest` branch (line ~1232-1250). |
| +- Test: `internal/edge/gate_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `gate.New`, `(*gate.Gate).Admit`, `gate.CookieName`, `gate.Mint`, `gate.Hash`, `gate.LoginPath` (Task 3); `registry.Route.Gate` (Task 1). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +`internal/edge/gate_test.go`: |
| + |
| +```go |
| +package edge |
| + |
| +import ( |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/gate" |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) { |
| + t.Helper() |
| + backend, seenCookie := cookieEchoBackend(t, "secret page") |
| + h, err := gate.Hash("correct horse") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), |
| + Status: "ready", Hibernate: hibernate, Gate: h}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, act) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + t.Cleanup(srv.Close) |
| + return e, srv, h, seenCookie |
| +} |
| + |
| +// cookieEchoBackend records the Cookie header it saw. |
| +func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) { |
| + t.Helper() |
| + seen := new(string) |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + *seen = r.Header.Get("Cookie") |
| + io.WriteString(w, body) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return ts, seen |
| +} |
| + |
| +func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response { |
| + t.Helper() |
| + req, _ := http.NewRequest(method, srv.URL+path, nil) |
| + req.Host = host |
| + if cookie != "" { |
| + req.Header.Set("Cookie", cookie) |
| + } |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { resp.Body.Close() }) |
| + return resp |
| +} |
| + |
| +func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) { |
| + _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "") |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("status = %d, want 401", resp.StatusCode) |
| + } |
| + b, _ := io.ReadAll(resp.Body) |
| + if !strings.Contains(string(b), "Password required") { |
| + t.Fatalf("body: %s", b) |
| + } |
| + if resp.Header.Get("Cache-Control") != "no-store" { |
| + t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control")) |
| + } |
| + if *seen != "" || strings.Contains(string(b), "secret page") { |
| + t.Fatal("upstream was reached without a cookie") |
| + } |
| +} |
| + |
| +func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) { |
| + _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false) |
| + cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie) |
| + b, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != 200 || string(b) != "secret page" { |
| + t.Fatalf("got %d %q", resp.StatusCode, b) |
| + } |
| + if *seen != "a=1" { |
| + t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen) |
| + } |
| +} |
| + |
| +func TestGatedAliasIsGatedToo(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode) |
| + } |
| + // The MAC is bound to the ROUTE host, so a cookie minted for the primary |
| + // verifies when presented on the alias. |
| + cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| + if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 { |
| + t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode) |
| + } |
| +} |
| + |
| +func TestGatedLoginPostSetsCookie(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} |
| + req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| + req.Host = "gated.example.com" |
| + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| + resp, err := c.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + defer resp.Body.Close() |
| + if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { |
| + t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) |
| + } |
| + var got string |
| + for _, k := range resp.Cookies() { |
| + if k.Name == gate.CookieName { |
| + got = k.Value |
| + } |
| + } |
| + if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| + t.Fatalf("cookie %q does not verify", got) |
| + } |
| +} |
| + |
| +func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) { |
| + e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false) |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{ |
| + StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"), |
| + StoredAt: time.Now(), TTL: time.Minute, |
| + }) |
| + resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "") |
| + b, _ := io.ReadAll(resp.Body) |
| + if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") { |
| + t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b) |
| + } |
| +} |
| + |
| +func TestGatedWebSocketHandshakeIsGated(t *testing.T) { |
| + _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| + req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil) |
| + req.Host = "gated.example.com" |
| + req.Header.Set("Connection", "Upgrade") |
| + req.Header.Set("Upgrade", "websocket") |
| + resp, err := http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode != http.StatusUnauthorized { |
| + t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode) |
| + } |
| + req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))) |
| + resp, err = http.DefaultClient.Do(req) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode == http.StatusUnauthorized { |
| + t.Fatal("upgrade with cookie was refused") |
| + } |
| +} |
| + |
| +func TestUngatedRouteUnchanged(t *testing.T) { |
| + backend, seen := cookieEchoBackend(t, "open page") |
| + reg := openReg(t) |
| + if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, &fakeActivator{}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + srv := httptest.NewServer(e) |
| + defer srv.Close() |
| + resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1") |
| + if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" { |
| + t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen) |
| + } |
| +} |
| +``` |
| + |
| +`registry.RouteAlias` has more fields than `Alias` and `Host`; check its definition (`grep -n "type RouteAlias" internal/registry/registry.go`) and set any that the edge's alias fold requires (a status, for instance) the way `internal/edge`'s existing alias tests do (`grep -n "UpsertAlias" internal/edge/*_test.go`). |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/edge/ -run 'Gated|Ungated' -count=1` |
| +Expected: the no-cookie tests fail with 200 (the page is served open). |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`Edge` struct, near `act`: |
| + |
| +```go |
| + // gate is the password curtain (internal/gate) for routes whose row |
| + // carries a gate hash. Always constructed: the check is one string |
| + // compare on up.route.Gate for every ungated host, and the limiter it |
| + // carries is shared across the box's gated hosts. |
| + gate *gate.Gate |
| +``` |
| + |
| +`New`: `e := &Edge{reg: reg, act: act, gate: gate.New(), probers: ...}`. |
| + |
| +`ServeHTTP`, immediately after the usage-meter block (the `if m := e.usageMeter.Load(); ...` statement) and before the `isUpgradeRequest` branch: |
| + |
| +```go |
| + // The password gate (internal/gate; spec 2026-09-07-password-gate-design |
| + // §4.1). Placed HERE and the neighbours are load-bearing: after the |
| + // meter, so the login page's bytes bill to the route that caused them; |
| + // before the upgrade branch, so a WebSocket handshake is gated; before |
| + // the cache check, so a cache hit is only ever served to a request that |
| + // already passed (the LRU keys on host and path, not on cookies); and |
| + // before the activator, so a stranger — a crawler, a scanner, a guessed |
| + // hostname — never wakes a sleeping instance. Admit strips the cookie |
| + // on the way through, so the app never sees it. |
| + if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) { |
| + return |
| + } |
| +``` |
| + |
| +Import `github.com/carlosframework/platform/internal/gate`. Note the receiver field `e.gate` and the package `gate` share a name; Go resolves `e.gate` as the field and `gate.New()` as the package — this is fine, but if a reviewer prefers, name the field `pw`. |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/edge/ -count=1` |
| +Expected: PASS, every pre-existing test included. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/edge/edge.go internal/edge/gate_test.go |
| +git commit -m "edge: enforce the password gate ahead of the upgrade branch, the cache and the activator" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Console API — PATCH `gate`, and the hash never leaves (spec §5.2, §5.4) |
| + |
| +**Files:** |
| +- Modify: `internal/console/apiinstances.go` — `instanceListRecord` (line ~133), `instanceGetRecord` (line ~480), `handleAPIInstanceCreate`'s `writeJSON` (line ~757), `handleAPIInstanceUpdate` (req struct ~line 920, mutation ~line 960, `changed` list ~line 1105, `writeJSON` ~line 1119). |
| +- Test: `internal/console/apiinstances_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `gate.CheckPassword`, `gate.Hash` (Task 3). |
| +- Produces: PATCH body field `gate` (string: password to set, `""` to clear, absent to leave); every record response carries `"gated": bool` and never a `"gate"` key. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/console/apiinstances_test.go`: |
| + |
| +```go |
| +func TestAPIInstanceUpdateGateRoundTrip(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-gate-round") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"gate.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + pu := updateURL(fx, "bab", "titogo", "gate.go.tito.io") |
| + |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"gate":"correct horse"}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch gate = %d %s, want 200", status, out) |
| + } |
| + var echoed map[string]any |
| + if err := json.Unmarshal([]byte(out), &echoed); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, leaked := echoed["gate"]; leaked { |
| + t.Fatalf("patch reply carries the gate hash: %s", out) |
| + } |
| + if echoed["gated"] != true { |
| + t.Fatalf("patch reply gated = %v, want true", echoed["gated"]) |
| + } |
| + stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(stored.Gate, "$2a$10$") { |
| + t.Fatalf("stored gate = %q, want a bcrypt hash", stored.Gate) |
| + } |
| + if strings.Contains(stored.Gate, "correct horse") { |
| + t.Fatal("stored gate carries the plaintext") |
| + } |
| + if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance gate.go.tito.io (gate) (cli)" { |
| + t.Fatalf("audit detail = %q", detail) |
| + } |
| + |
| + // The list and the GET shadow the hash the same way. |
| + for _, path := range []string{u, pu} { |
| + _, _, body := rawReq(t, http.MethodGet, path, token, "") |
| + if strings.Contains(body, "$2a$") { |
| + t.Fatalf("GET %s leaks the hash: %s", path, body) |
| + } |
| + if !strings.Contains(body, `"gated":true`) { |
| + t.Fatalf("GET %s lacks gated:true: %s", path, body) |
| + } |
| + } |
| + |
| + status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"gate":""}`) |
| + if status != http.StatusOK { |
| + t.Fatalf("patch clear = %d %s", status, out) |
| + } |
| + stored, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| + if stored.Gate != "" { |
| + t.Fatalf("stored gate after clear = %q", stored.Gate) |
| + } |
| + _, _, body := rawReq(t, http.MethodGet, pu, token, "") |
| + if !strings.Contains(body, `"gated":false`) { |
| + t.Fatalf("GET after clear: %s", body) |
| + } |
| +} |
| + |
| +func TestAPIInstanceUpdateGateRefusals(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-inst-gate-refuse") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + u := instancesURL(fx, "bab", "titogo") |
| + if status, _, out := rawReq(t, http.MethodPost, u, token, |
| + `{"host":"gr.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + pu := updateURL(fx, "bab", "titogo", "gr.go.tito.io") |
| + for _, body := range []string{`{"gate":"short7!"}`, `{"gate":"` + strings.Repeat("x", 73) + `"}`} { |
| + status, _, out := rawReq(t, http.MethodPatch, pu, token, body) |
| + if status != http.StatusBadRequest || !strings.Contains(out, "password") { |
| + t.Fatalf("%s -> %d %s, want 400 naming the password rule", body, status, out) |
| + } |
| + if strings.Contains(out, "short7!") || strings.Contains(out, "xxxxxxxx") { |
| + t.Fatalf("refusal echoes the password: %s", out) |
| + } |
| + } |
| + // The empty-body rail names gate among the patchable fields. |
| + if _, _, out := rawReq(t, http.MethodPatch, pu, token, `{}`); !strings.Contains(out, "gate") { |
| + t.Fatalf("empty-body refusal should name gate: %s", out) |
| + } |
| +} |
| +``` |
| + |
| +Also extend `TestAPIInstanceUpdateAuditNamesChangedFields`'s expectation comment if it enumerates the field order (`addrs, probe, steering, environment, gate`). |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/console/ -run 'InstanceUpdateGate' -count=1` |
| +Expected: FAIL — PATCH answers 400 "nothing to update". |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Record views. Add to `instanceListRecord` and `instanceGetRecord`, after the embedded `provision.Record`: |
| + |
| +```go |
| + // HiddenGate shadows Record.Gate for encoding/json (a shallower field of |
| + // the same name wins), so the bcrypt hash never leaves the console; it |
| + // is always "" and omitted. Gated is what a member may know. |
| + HiddenGate string `json:"gate,omitempty"` |
| + Gated bool `json:"gated"` |
| +``` |
| + |
| +Set `Gated: rec.Gate != ""` wherever each is constructed (list loop, GET). Add a view for the write replies: |
| + |
| +```go |
| +// apiRecordView is a record as the write doors answer it: the whole record |
| +// with the gate hash shadowed and a gated flag in its place. Every handler |
| +// that used to writeJSON a bare record answers through this. |
| +type apiRecordView struct { |
| + provision.Record |
| + HiddenGate string `json:"gate,omitempty"` |
| + Gated bool `json:"gated"` |
| +} |
| + |
| +func recordView(rec provision.Record) apiRecordView { |
| + return apiRecordView{Record: rec, Gated: rec.Gate != ""} |
| +} |
| +``` |
| + |
| +Change `writeJSON(w, http.StatusCreated, rec)` in create to `writeJSON(w, http.StatusCreated, recordView(rec))` (adjust for pointer/value as the variable is typed) and `writeJSON(w, http.StatusOK, rec)` at the end of update to `writeJSON(w, http.StatusOK, recordView(*rec))`. Grep the package for any other `writeJSON(..., rec)` of a provision record (steering, channel, environment, move handlers) and route them through `recordView` too. |
| + |
| +PATCH. In the `req` struct add `Gate *string \`json:"gate"\`` with a comment: |
| + |
| +```go |
| + // Gate is a *string like Steering: absent leaves the gate alone, |
| + // "" opens the host, anything else is the new password — hashed |
| + // here, never stored or echoed (spec §5.2). The plaintext crosses |
| + // the wire once, over TLS, the same as a sign-in. |
| + Gate *string `json:"gate"` |
| +``` |
| + |
| +Extend the all-nil check with `&& req.Gate == nil` and the message to `"nothing to update — send addrs, probe, steering, environment, gate, or a combination"`. |
| + |
| +In the mutation block, after the Environment case: |
| + |
| +```go |
| + if req.Gate != nil { |
| + if *req.Gate == "" { |
| + rec.Gate = "" |
| + } else { |
| + if err := gate.CheckPassword(*req.Gate); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + h, err := gate.Hash(*req.Gate) |
| + if err != nil { |
| + log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err) |
| + jsonError(w, http.StatusInternalServerError, "could not set the password — try again") |
| + return |
| + } |
| + rec.Gate = h |
| + } |
| + } |
| +``` |
| + |
| +In the `changed` list, after steering (and environment if it is listed; add it if the existing code omits it): `if req.Gate != nil { changed = append(changed, "gate") }`. |
| + |
| +Import `github.com/carlosframework/platform/internal/gate`. |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/apiinstances.go internal/console/apiinstances_test.go |
| +git commit -m "console: PATCH gate hashes the password; record replies carry gated, never the hash" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Console Settings page — set or turn off the password (spec §5.5) |
| + |
| +**Files:** |
| +- Modify: `internal/console/appsettings.go` — `SettingsView` (line ~47), `assembleSettings` (line ~202); new `handleAppGate` in the same file. |
| +- Modify: `internal/console/server.go` — route table after `/place` (line ~1070). |
| +- Modify: `internal/console/templates/app_settings.html` — new section after "Domains & TLS"'s attach form. |
| +- Test: `internal/console/appsettings_gate_test.go` (new) |
| + |
| +**Interfaces:** |
| +- Consumes: `gate.CheckPassword`, `gate.Hash`; `provision.ListRecords`, `provision.GetRecordETag`, `provision.UpdateRecord`; `s.audit`, `s.setFlash`, `s.bumpSerial`, `s.accountApp`. |
| +- Produces: `POST /a/{sqid}/apps/{app}/gate` with form fields `csrf`, `host`, `action` (`set`|`clear`), `password`. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`internal/console/appsettings_gate_test.go` (fixture pattern from `TestAppPlacePageMovesAndClears`, fleets_test.go:1244 — `placeFixture` gives a console with a deployment bucket; use `enableInstances` + the API create to seed a record, then the browser session for the form): |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +func TestAppSettingsGateSetAndClear(t *testing.T) { |
| + fx, token := instanceFixture(t, "code-settings-gate") |
| + enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, |
| + `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| + t.Fatalf("create = %d %s", status, out) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-settings-gate-browser") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // The page shows the host as open, with a form. |
| + _, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") { |
| + t.Fatalf("settings page lacks the gate section: %s", body) |
| + } |
| + |
| + status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}}) |
| + if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") { |
| + t.Fatalf("set = %d -> %q", status, loc) |
| + } |
| + rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !strings.HasPrefix(rec.Gate, "$2a$10$") { |
| + t.Fatalf("stored gate = %q", rec.Gate) |
| + } |
| + _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") { |
| + t.Fatalf("after set: %s", body) |
| + } |
| + if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") { |
| + t.Fatal("page leaks the hash or the password") |
| + } |
| + |
| + // A short password is refused with the rule, and nothing changes. |
| + postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}}) |
| + _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| + if !strings.Contains(body, "at least 8 characters") { |
| + t.Fatalf("short password not refused on the page: %s", body) |
| + } |
| + |
| + status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("clear = %d", status) |
| + } |
| + rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| + if rec.Gate != "" { |
| + t.Fatalf("gate after clear = %q", rec.Gate) |
| + } |
| + events, _ := fx.st.Events(OpsAccountID, "titogo", 20) |
| + var saw int |
| + for _, e := range events { |
| + if e.Action == "gate" { |
| + saw++ |
| + if strings.Contains(e.Detail, "correct horse") { |
| + t.Fatal("audit carries the password") |
| + } |
| + } |
| + } |
| + if saw != 2 { |
| + t.Fatalf("gate audit rows = %d, want 2", saw) |
| + } |
| +} |
| + |
| +func TestAppSettingsGateNeedsCSRF(t *testing.T) { |
| + fx, _ := instanceFixture(t, "code-settings-gate-csrf") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-settings-gate-csrf-browser") |
| + status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"), |
| + url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| + if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") { |
| + t.Fatalf("got %d %s", status, body) |
| + } |
| +} |
| +``` |
| + |
| +`get` is server_test.go's existing GET helper (status, location, body). If `instanceFixture` does not create the `titogo` app for the browser account, look at how `TestAppPlacePageMovesAndClears`'s `placeFixture` seeds it and use that instead; the point is one fixture with a bucket, a record, and a signed-in browser. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test ./internal/console/ -run 'AppSettingsGate' -count=1` |
| +Expected: FAIL — 404 on the POST, no section on the page. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`SettingsView` gains: |
| + |
| +```go |
| + // Gates is the password-gate section (spec §5.5): one row per instance |
| + // record, with whether a password is set. GatesUnavailable carries the |
| + // reason when the records could not be read (no bucket, a store error) |
| + // and the section says so instead of rendering an empty list as "no |
| + // instances". |
| + Gates []GateRow |
| + GatesUnavailable string |
| +``` |
| + |
| +```go |
| +// GateRow is one instance on the Settings page's password-gate section. |
| +type GateRow struct { |
| + Host string |
| + Gated bool |
| +} |
| +``` |
| + |
| +In `assembleSettings`, before the `if s.cfg.Releases == nil` branch (so it runs regardless of what that branch does with RenameBlocked), add: |
| + |
| +```go |
| + sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName) |
| +``` |
| + |
| +and: |
| + |
| +```go |
| +// settingsGates lists the app's instance records for the password-gate |
| +// section. The hash never reaches the template: GateRow carries a bool. |
| +func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) { |
| + if s.cfg.Releases == nil { |
| + return nil, "No deployment bucket on this host, so instance records cannot be read." |
| + } |
| + recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| + if err != nil { |
| + log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err) |
| + return nil, "Instance records could not be read just now." |
| + } |
| + rows := make([]GateRow, 0, len(recs)) |
| + for _, rec := range recs { |
| + rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) |
| + } |
| + sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| + return rows, "" |
| +} |
| +``` |
| + |
| +Handler, in `appsettings.go`: |
| + |
| +```go |
| +// handleAppGate is the Settings page's password-gate form: set a password |
| +// on one instance host, or turn it off. Member-gated, matching the API |
| +// PATCH's door — the CLI already lets a member do this, and a form that |
| +// refuses what the CLI allows is a rule in the wrong place. CSRF first, |
| +// then the same read-mutate-validate-write-audit sequence as the PATCH, |
| +// against one field. The password is never logged, audited or echoed. |
| +func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", |
| + http.StatusSeeOther) |
| + } |
| + if s.cfg.Releases == nil { |
| + back("err", "No deployment bucket on this host, so the gate cannot be changed here.") |
| + return |
| + } |
| + host := strings.TrimSpace(r.FormValue("host")) |
| + if !provision.ValidHostShape(host) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + var hash string |
| + switch r.FormValue("action") { |
| + case "clear": |
| + case "set": |
| + p := r.FormValue("password") |
| + if err := gate.CheckPassword(p); err != nil { |
| + back("err", err.Error()+".") |
| + return |
| + } |
| + h, err := gate.Hash(p) |
| + if err != nil { |
| + log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not set the password — try again.") |
| + return |
| + } |
| + hash = h |
| + default: |
| + back("err", "Choose set or clear.") |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| + defer cancel() |
| + rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if err != nil { |
| + log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not read the instance — try again.") |
| + return |
| + } |
| + rec.Gate = hash |
| + if err := provision.ValidateShape(*rec); err != nil { |
| + back("err", err.Error()+".") |
| + return |
| + } |
| + if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| + if errors.Is(err, release.ErrCASConflict) { |
| + back("err", "The instance changed while this request was in flight — try again.") |
| + return |
| + } |
| + log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err) |
| + back("err", "Could not update the instance — try again.") |
| + return |
| + } |
| + if hash == "" { |
| + s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", "Password turned off on "+host+".") |
| + return |
| + } |
| + s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)") |
| + s.bumpSerial(ctx) |
| + back("ok", "Password set on "+host+".") |
| +} |
| +``` |
| + |
| +Route table, after the `/place` line: `s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)`. |
| + |
| +Template, inserted after the "Attach a custom domain" block's closing `{{end}}` and before `{{if and .Settings .Settings.MismatchTarget}}`: |
| + |
| +```html |
| +<h2>Password gate</h2> |
| +<p class="small">One shared password in front of a hostname — for a static |
| +site or a prototype that should not be world-readable. Visitors see a plain |
| +page asking for it and stay signed in for a week. It is set per instance.</p> |
| +{{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p> |
| +{{else if .Settings.Gates}} |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>Host</th><th>State</th><th></th></tr> |
| + {{range $i, $g := .Settings.Gates}} |
| + <tr> |
| + <td><code>{{$g.Host}}</code></td> |
| + <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td> |
| + <td> |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="set"> |
| + <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label> |
| + <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72" |
| + autocomplete="new-password" placeholder="password" required> |
| + <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button> |
| + </form> |
| + {{if $g.Gated}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="host" value="{{$g.Host}}"> |
| + <input type="hidden" name="action" value="clear"> |
| + <button type="submit" class="btn-sm btn-danger">Turn off</button> |
| + </form> |
| + {{end}} |
| + </td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}} |
| +``` |
| + |
| +The section is inside no `{{if .IsOwner}}` — members see it, matching the handler. |
| + |
| +- [ ] **Step 4: Run the package tests** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/appsettings.go internal/console/server.go internal/console/templates/app_settings.html internal/console/appsettings_gate_test.go |
| +git commit -m "console: password gate section on app Settings — set, change, turn off" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: `carlos gate` and the docs (spec §5.3, §6) |
| + |
| +**Files:** |
| +- Create: `gate_cli.go`, `gate_cli_test.go` |
| +- Modify: `apimode.go` — `apiInstanceRecord` (line ~2485, add `Gated`), new `instanceSetGate` beside `instanceSetSteering` (line ~2699). |
| +- Modify: `main.go` — `case "gate":` after `features` (line ~60); the `usage` text (line ~195, one line beside `features`). |
| +- Modify: `docs/site/cli.md` — new `### carlos gate` after `### carlos features`; one sentence in `### carlos instances`. |
| + |
| +**Interfaces:** |
| +- Consumes: PATCH `gate` (Task 5); `instanceList`, `patchJSON`, `apiPath`, `resolvedAccount`, `resolvedApp`, `printUsing`, `mutedf`, `appFlag`, `newTable`, `colorFor` (existing). |
| +- Produces: `carlos gate set|clear|list`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +`gate_cli_test.go`: |
| + |
| +```go |
| +package main |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func TestGateNeedsASubVerb(t *testing.T) { |
| + out, err := runCarlosCLI(t, "gate") |
| + if err == nil { |
| + t.Fatalf("bare `gate` exited 0: %s", out) |
| + } |
| + for _, w := range []string{"set", "clear", "list"} { |
| + if !strings.Contains(out, w) { |
| + t.Fatalf("usage should name %s: %s", w, out) |
| + } |
| + } |
| +} |
| + |
| +func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) { |
| + out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "") |
| + if err == nil || !strings.Contains(out, "password") { |
| + t.Fatalf("empty -password: err=%v out=%s", err, out) |
| + } |
| +} |
| + |
| +func TestReadGatePassword(t *testing.T) { |
| + if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" { |
| + t.Fatalf("flag: %q %v", got, err) |
| + } |
| + if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" { |
| + t.Fatalf("stdin: %q %v", got, err) |
| + } |
| + if _, err := readGatePassword("", strings.NewReader("\n")); err == nil { |
| + t.Fatal("empty stdin line accepted") |
| + } |
| +} |
| +``` |
| + |
| +`runCarlosCLI` — reuse `runFeaturesCLI`'s body (features_cli_test.go:44) if no generic one exists: build with `buildCarlos`, env from `carlosTestEnv`, `CombinedOutput`. Name it `runCarlosCLI` in `gate_cli_test.go` only if nothing else defines it. |
| + |
| +- [ ] **Step 2: Run to verify it fails** |
| + |
| +Run: `go test . -run 'Gate' -count=1` |
| +Expected: compile error, `readGatePassword` undefined. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +`apimode.go`: add to `apiInstanceRecord`: |
| + |
| +```go |
| + // Gated says whether the host has a password gate (spec |
| + // 2026-09-07-password-gate-design §5.4). The hash itself never leaves |
| + // the console. False from a console that predates the field. |
| + Gated bool `json:"gated"` |
| +``` |
| + |
| +and beside `instanceSetSteering`: |
| + |
| +```go |
| +// instanceSetGate sets ("" clears) one instance's password gate through |
| +// the console, which hashes it; the plaintext crosses the wire once, over |
| +// TLS. A console older than the field answers 400 "nothing to update", |
| +// which is reported as exactly that. |
| +func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host, password string) (apiInstanceRecord, error) { |
| + in := struct { |
| + Gate string `json:"gate"` |
| + }{password} |
| + status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| + if err != nil { |
| + return apiInstanceRecord{}, err |
| + } |
| + if status != http.StatusOK { |
| + if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") { |
| + return apiInstanceRecord{}, fmt.Errorf("this console does not know about password gates yet — it needs a newer carlos") |
| + } |
| + return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiInstanceRecord |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiInstanceRecord{}, fmt.Errorf("gate: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| +``` |
| + |
| +`gate_cli.go`: |
| + |
| +```go |
| +// The `carlos gate` verb family: one shared password in front of an |
| +// instance's hostname, for a static site or a prototype that should not be |
| +// world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md). |
| +// API mode only, like `carlos features` and for the same reason: setting a |
| +// gate is a console-authorized write with an audit row. |
| +package main |
| + |
| +import ( |
| + "bufio" |
| + "context" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "os" |
| + "strings" |
| +) |
| + |
| +const gateUsage = `usage: carlos gate <set|clear|list> [flags] |
| + |
| + set -app <a> [-host <h>] [-password <p>] |
| + put a password in front of the app's instances (or one host); |
| + without -password, one line is read from stdin |
| + clear -app <a> [-host <h>] |
| + turn the password off |
| + list -app <a> which hosts have a password set |
| + |
| + -account <a> account (sqid or name; default "ops") |
| + -account-id <id> account sqid, matched as a sqid ONLY |
| + -console <origin> console to act through (default: the only logged-in one) |
| + |
| +Signed-in only (carlos auth login). Visitors to a gated host see a plain |
| +page asking for the password and stay signed in for a week; changing or |
| +clearing the password signs everyone out at once. The box picks the change |
| +up within seconds of the next converge tick. |
| +` |
| + |
| +func runGate(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "set": |
| + runGateSet(args[1:]) |
| + return |
| + case "clear": |
| + runGateClear(args[1:]) |
| + return |
| + case "list": |
| + runGateList(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(gateUsage) |
| +} |
| + |
| +func requireGateAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" + |
| + "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" + |
| + "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| + } |
| + return sess |
| +} |
| + |
| +// readGatePassword is the flag, else one line from stdin — so a password |
| +// can be piped or typed without landing in shell history or ps output. An |
| +// empty line is refused; the console's own length rule is applied there. |
| +func readGatePassword(flagged string, stdin io.Reader) (string, error) { |
| + if flagged != "" { |
| + return flagged, nil |
| + } |
| + line, err := bufio.NewReader(stdin).ReadString('\n') |
| + if err != nil && !errors.Is(err, io.EOF) { |
| + return "", err |
| + } |
| + line = strings.TrimRight(line, "\r\n") |
| + if line == "" { |
| + return "", errors.New("no password given — pass -password or pipe one line on stdin") |
| + } |
| + return line, nil |
| +} |
| + |
| +// gateHosts resolves the hosts a set/clear applies to: -host alone, else |
| +// every instance record of the app. |
| +func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string { |
| + if host != "" { |
| + return []string{host} |
| + } |
| + recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "") |
| + if err != nil { |
| + log.Fatalf("gate: list instances: %v", err) |
| + } |
| + if len(recs) == 0 { |
| + log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app) |
| + } |
| + hosts := make([]string, 0, len(recs)) |
| + for _, r := range recs { |
| + hosts = append(hosts, r.Host) |
| + } |
| + return hosts |
| +} |
| + |
| +func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) { |
| + fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) |
| + app := appFlag(fs, "app whose instances to gate") |
| + host := fs.String("host", "", "one instance host (default: every instance of the app)") |
| + consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| + acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| + acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| + return fs, app, host, consoleFlag, acctRaw, acctIDRaw |
| +} |
| + |
| +func runGateSet(args []string) { |
| + fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set") |
| + pw := fs.String("password", "", "the password (default: one line from stdin)") |
| + passwordGiven := false |
| + fs.Parse(args) |
| + fs.Visit(func(f *flag.Flag) { |
| + if f.Name == "password" { |
| + passwordGiven = true |
| + } |
| + }) |
| + if passwordGiven && *pw == "" { |
| + log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters") |
| + } |
| + password, err := readGatePassword(*pw, os.Stdin) |
| + if err != nil { |
| + log.Fatalf("gate set: %v", err) |
| + } |
| + sess := requireGateAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, gateUsage) |
| + failed := 0 |
| + for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) { |
| + if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, password); err != nil { |
| + failed++ |
| + fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not set on"), h, err) |
| + continue |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password set on"), h) |
| + } |
| + mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out") |
| + if failed > 0 { |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +func runGateClear(args []string) { |
| + fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear") |
| + fs.Parse(args) |
| + sess := requireGateAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, gateUsage) |
| + failed := 0 |
| + for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) { |
| + if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, ""); err != nil { |
| + failed++ |
| + fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not cleared on"), h, err) |
| + continue |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password turned off on"), h) |
| + } |
| + if failed > 0 { |
| + os.Exit(1) |
| + } |
| +} |
| + |
| +func runGateList(args []string) { |
| + fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list") |
| + fs.Parse(args) |
| + sess := requireGateAPISession(*consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, gateUsage) |
| + recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "") |
| + if err != nil { |
| + log.Fatalf("gate list: %v", err) |
| + } |
| + if len(recs) == 0 { |
| + fmt.Fprintln(stdout, "no instances") |
| + return |
| + } |
| + w := newTable(stdout) |
| + fmt.Fprintln(w, "HOST\tGATE") |
| + for _, r := range recs { |
| + state := "open" |
| + if r.Gated { |
| + state = "password set" |
| + } |
| + fmt.Fprintf(w, "%s\t%s\n", r.Host, state) |
| + } |
| + w.Flush() |
| +} |
| +``` |
| + |
| +`colorFor(stdout).bad` and `.ok` exist (color.go:117-119). `newTable` (color.go:206) returns a `*table`; finish it the way `printFeatures` does (check the method it calls after the rows — `grep -n "func (t \*table)" color.go`). |
| + |
| +`main.go`: after `case "features": runFeatures(args)` add `case "gate": runGate(args)`; in the `usage` text, after the `features` line, add ` gate a shared password in front of an app's hostnames` (match the column alignment of the neighbours). |
| + |
| +`docs/site/cli.md`: after the `### carlos features` section (before `### carlos releases`): |
| + |
| +```markdown |
| +### carlos gate |
| + |
| +Puts one shared password in front of an app's hostnames — for a static site |
| +or a prototype that should not be world-readable. Visitors see a plain page |
| +asking for the password and stay signed in for a week; the app behind it |
| +never sees the cookie. |
| + |
| +```sh |
| +carlos gate set --app site |
| +carlos gate list --app site |
| +carlos gate clear --app site |
| +``` |
| + |
| +`set` reads the password from `--password` or, without it, one line from |
| +stdin, so it need not land in your shell history. Without `--host` the |
| +change applies to every instance of the app; name one to narrow it. Changing |
| +or clearing the password signs every visitor out at once. Custom domains |
| +attached to the app are gated along with the platform hostname. This is a |
| +curtain in front of the whole hostname, not accounts for the app's own |
| +users; an app with real sign-in keeps doing that itself. |
| +``` |
| + |
| +In `### carlos instances`, append one sentence to its first paragraph: `A password in front of an instance's hostname is `carlos gate`.` |
| + |
| +- [ ] **Step 4: Run the tests and the docs gate** |
| + |
| +Run: `go test . -run 'Gate' -count=1 && go test ./internal/docsite/ -count=1 && go build ./... && go vet ./... && gofmt -l .` |
| +Expected: PASS, no unformatted files. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add gate_cli.go gate_cli_test.go apimode.go main.go docs/site/cli.md |
| +git commit -m "carlos gate: set, clear and list an app's password gates; documented" |
| +``` |
| + |
| +--- |
| + |
| +### Task 8: Whole-tree verification |
| + |
| +- [ ] **Step 1:** `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .` — all clean. |
| +- [ ] **Step 2:** `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...` — the cross-compile gate. |
| +- [ ] **Step 3:** Push the branch to `origin` and to `amadan`; open a draft PR titled "Password gate: a shared password in front of an instance" whose body links the spec and this plan and lists the roll order from spec §9 (boxes before console). Read the amadan run. |