| 1 | # Password Gate Implementation Plan |
| 2 | |
| 3 | > **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. |
| 4 | |
| 5 | **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. |
| 6 | |
| 7 | **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. |
| 8 | |
| 9 | **Tech Stack:** Go 1.26, `golang.org/x/crypto/bcrypt` (already a dependency), `html/template`, `embed`, SQLite via `internal/registry`. |
| 10 | |
| 11 | **Spec:** `docs/superpowers/specs/2026-09-07-password-gate-design.md` — read it first; every task below cites a section. |
| 12 | |
| 13 | ## Global Constraints |
| 14 | |
| 15 | - `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .` clean before every commit (AGENTS.md "Build & test"). |
| 16 | - 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. |
| 17 | - 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`. |
| 18 | - Wire shapes are additive: `gate` is `omitempty` on the record; the registry column defaults to `''`. |
| 19 | - Never log, audit, echo or store a plaintext password. Log lines about the gate print `open` or `set`, never the hash. |
| 20 | - Copy is fixed by the spec §4.4 and §5.5; do not reword it. |
| 21 | - Commit messages: end with `Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>`. Commit on the branch `worktree-password-gate`; never touch `main`. |
| 22 | - 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). |
| 23 | |
| 24 | --- |
| 25 | |
| 26 | ### Task 1: Registry column `gate` (spec §3.2) |
| 27 | |
| 28 | **Files:** |
| 29 | - 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). |
| 30 | - Test: `internal/registry/registry_test.go` |
| 31 | |
| 32 | **Interfaces:** |
| 33 | - Produces: `Route.Gate string`; `func (r *Registry) SetGate(host, hash string) error` (narrow UPDATE, error on unmatched host). |
| 34 | |
| 35 | - [ ] **Step 1: Write the failing tests** |
| 36 | |
| 37 | Append to `internal/registry/registry_test.go`: |
| 38 | |
| 39 | ```go |
| 40 | // Gate round-trips through Upsert (TestUpsertRoundTripsAllColumns is the |
| 41 | // column-shift canary; this pins the column's own semantics), a full-row |
| 42 | // Upsert with an empty gate PRESERVES a set one, and SetGate is the one |
| 43 | // door that clears it. |
| 44 | func TestGateColumnPreservedByUpsertClearedBySetGate(t *testing.T) { |
| 45 | reg := openTestRegistry(t) |
| 46 | if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready", Gate: "$2a$10$hash"}); err != nil { |
| 47 | t.Fatal(err) |
| 48 | } |
| 49 | if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" { |
| 50 | t.Fatalf("Gate after upsert = %q", got) |
| 51 | } |
| 52 | // A writer that knows nothing about the column must not open the host. |
| 53 | if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$hash" { |
| 57 | t.Fatalf("Gate after a gate-less upsert = %q, want it preserved", got) |
| 58 | } |
| 59 | if err := reg.SetGate("g.example.com", ""); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | if got := mustRoute(t, reg, "g.example.com").Gate; got != "" { |
| 63 | t.Fatalf("Gate after SetGate(\"\") = %q, want cleared", got) |
| 64 | } |
| 65 | if err := reg.SetGate("g.example.com", "$2a$10$other"); err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | if got := mustRoute(t, reg, "g.example.com").Gate; got != "$2a$10$other" { |
| 69 | t.Fatalf("Gate after SetGate = %q", got) |
| 70 | } |
| 71 | } |
| 72 | |
| 73 | func TestSetGateErrorsOnUnmatchedHost(t *testing.T) { |
| 74 | reg := openTestRegistry(t) |
| 75 | if err := reg.SetGate("nope.example.com", "$2a$10$hash"); err == nil { |
| 76 | t.Fatal("SetGate on an unmatched host: want error, got nil") |
| 77 | } |
| 78 | } |
| 79 | |
| 80 | // The edge reloads only when Fingerprint moves; a gate set or cleared with |
| 81 | // the column left out would take effect on the next unrelated reload — an |
| 82 | // operator turning the password off and watching the page keep asking. |
| 83 | func TestFingerprintSeesGateChanges(t *testing.T) { |
| 84 | reg := openTestRegistry(t) |
| 85 | if err := reg.Upsert(Route{Host: "g.example.com", Kind: "static", Status: "ready"}); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | fp1, err := reg.Fingerprint() |
| 89 | if err != nil { |
| 90 | t.Fatal(err) |
| 91 | } |
| 92 | if err := reg.SetGate("g.example.com", "$2a$10$hash"); err != nil { |
| 93 | t.Fatal(err) |
| 94 | } |
| 95 | fp2, err := reg.Fingerprint() |
| 96 | if err != nil { |
| 97 | t.Fatal(err) |
| 98 | } |
| 99 | if fp1 == fp2 { |
| 100 | t.Fatal("gate change invisible to Fingerprint — the edge would keep serving the old answer") |
| 101 | } |
| 102 | } |
| 103 | |
| 104 | func mustRoute(t *testing.T, reg *Registry, host string) Route { |
| 105 | t.Helper() |
| 106 | got, err := reg.Get(host) |
| 107 | if err != nil { |
| 108 | t.Fatal(err) |
| 109 | } |
| 110 | if got == nil { |
| 111 | t.Fatalf("no route %s", host) |
| 112 | } |
| 113 | return *got |
| 114 | } |
| 115 | ``` |
| 116 | |
| 117 | Also add `Gate: "$2a$10$distinct"` to the `want` literal in `TestUpsertRoundTripsAllColumns` (every column there carries a distinct value). |
| 118 | |
| 119 | - [ ] **Step 2: Run to verify it fails** |
| 120 | |
| 121 | Run: `go test ./internal/registry/ -run 'Gate|UpsertRoundTrips' -count=1` |
| 122 | Expected: compile error, `Route` has no field `Gate`. |
| 123 | |
| 124 | - [ ] **Step 3: Implement** |
| 125 | |
| 126 | In `Route` (after `Hibernate bool`, line ~124): |
| 127 | |
| 128 | ```go |
| 129 | // Gate is the bcrypt hash of the host's shared password ("" = open), |
| 130 | // converged from provision.Record.Gate by SetGate and read by the edge |
| 131 | // on every request to the host (internal/gate). Validated at the |
| 132 | // console door, never here — SetCaps's rule. IN Fingerprint(): a gate |
| 133 | // set or cleared changes what the edge serves, and must reload it. |
| 134 | // Upsert preserves a non-empty value against an empty one (the |
| 135 | // caps/tunnel/unit idiom), so a full-row writer that predates the |
| 136 | // column can never silently open a gated host. |
| 137 | Gate string |
| 138 | ``` |
| 139 | |
| 140 | Migration list: append `` `ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, `` as the LAST entry. |
| 141 | |
| 142 | `routeCols`: change the tail to `... addrs, health, steering, gate, created_at`. |
| 143 | |
| 144 | `scanRoute`: scan `&t.Steering, &t.Gate, &t.CreatedAt`. |
| 145 | |
| 146 | `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`. |
| 147 | |
| 148 | `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. |
| 149 | |
| 150 | New setter beside `SetSteering`: |
| 151 | |
| 152 | ```go |
| 153 | // SetGate stamps or clears a route's password gate — the narrow UPDATE the |
| 154 | // provision converge uses (never a full-row Upsert, which preserves a set |
| 155 | // gate by design and so could not clear one). Errors on an unmatched host, |
| 156 | // SetBacking's contract. |
| 157 | func (r *Registry) SetGate(host, hash string) error { |
| 158 | res, err := r.sql.Exec(`UPDATE routes SET gate = ? WHERE host = ?`, hash, host) |
| 159 | if err != nil { |
| 160 | return err |
| 161 | } |
| 162 | n, err := res.RowsAffected() |
| 163 | if err != nil { |
| 164 | return err |
| 165 | } |
| 166 | if n == 0 { |
| 167 | return fmt.Errorf("no route for host %q", host) |
| 168 | } |
| 169 | return nil |
| 170 | } |
| 171 | ``` |
| 172 | |
| 173 | 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`. |
| 174 | |
| 175 | - [ ] **Step 4: Run the package tests** |
| 176 | |
| 177 | Run: `go test ./internal/registry/ -count=1` |
| 178 | Expected: PASS, including `TestUpsertRoundTripsAllColumns`. |
| 179 | |
| 180 | - [ ] **Step 5: Commit** |
| 181 | |
| 182 | ```bash |
| 183 | git add internal/registry/registry.go internal/registry/registry_test.go |
| 184 | git commit -m "registry: gate column, SetGate, and the fingerprint sees it" |
| 185 | ``` |
| 186 | |
| 187 | --- |
| 188 | |
| 189 | ### Task 2: Record field, validation and converge (spec §3.1, §3.3) |
| 190 | |
| 191 | **Files:** |
| 192 | - Modify: `internal/provision/provision.go` — `Record` (after `Steering`, line ~123), `ValidateShape` (line ~380). |
| 193 | - Modify: `internal/provision/reconcile.go` — the owned-converge arm beside the hibernate block (line ~805-820). |
| 194 | - Test: `internal/provision/provision_test.go`, `internal/provision/reconcile_test.go` |
| 195 | |
| 196 | **Interfaces:** |
| 197 | - Consumes: `registry.SetGate` (Task 1). |
| 198 | - Produces: `Record.Gate string \`json:"gate,omitempty"\``; `ValidateShape` refuses a non-bcrypt gate. |
| 199 | |
| 200 | - [ ] **Step 1: Write the failing tests** |
| 201 | |
| 202 | In `internal/provision/provision_test.go` add: |
| 203 | |
| 204 | ```go |
| 205 | func TestValidateShapeGate(t *testing.T) { |
| 206 | ok := Record{Kind: "static", Gate: "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"} |
| 207 | if err := ValidateShape(ok); err != nil { |
| 208 | t.Fatalf("bcrypt gate refused: %v", err) |
| 209 | } |
| 210 | bad := Record{Kind: "static", Gate: "hunter2"} |
| 211 | if err := ValidateShape(bad); err == nil || !strings.Contains(err.Error(), "gate") { |
| 212 | t.Fatalf("plaintext gate accepted: %v", err) |
| 213 | } |
| 214 | } |
| 215 | ``` |
| 216 | |
| 217 | In `internal/provision/reconcile_test.go` add (mirrors `TestReconcileConvergesSteering`): |
| 218 | |
| 219 | ```go |
| 220 | func TestReconcileConvergesGate(t *testing.T) { |
| 221 | s, sqid := setup(t) |
| 222 | rec := acmeRecord(sqid) |
| 223 | if err := CreateRecord(ctx, s, rec); err != nil { |
| 224 | t.Fatal(err) |
| 225 | } |
| 226 | reg := regWith(t) |
| 227 | cfg := ReconcileConfig{Region: "ie", Pools: []string{"tito"}, DataDir: "/data"} |
| 228 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 229 | t.Fatal(err) |
| 230 | } |
| 231 | before := *mustGet(t, reg, "acme.go.tito.io") |
| 232 | if before.Gate != "" { |
| 233 | t.Fatalf("minted Gate = %q, want open", before.Gate) |
| 234 | } |
| 235 | |
| 236 | const hash = "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy" |
| 237 | rec.Gate = hash |
| 238 | putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| 239 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 240 | t.Fatal(err) |
| 241 | } |
| 242 | after := *mustGet(t, reg, "acme.go.tito.io") |
| 243 | if after.Gate != hash { |
| 244 | t.Errorf("gate = %q, want the hash", after.Gate) |
| 245 | } |
| 246 | after.Gate = before.Gate |
| 247 | if after != before { |
| 248 | t.Errorf("converging gate touched another column:\n before %+v\n after %+v", before, after) |
| 249 | } |
| 250 | |
| 251 | // A record with no gate field is an OPEN host — it is a string, not a |
| 252 | // pointer: absent and "" are the same fact, and clearing must reach the |
| 253 | // row through SetGate. |
| 254 | rec.Gate = "" |
| 255 | putReplace(t, s, RecordKey(sqid, "tito", rec.Host), mustJSON(t, rec)) |
| 256 | if err := Reconcile(ctx, s, reg, cfg); err != nil { |
| 257 | t.Fatal(err) |
| 258 | } |
| 259 | if got := mustGet(t, reg, "acme.go.tito.io").Gate; got != "" { |
| 260 | t.Errorf("gate = %q, want it cleared", got) |
| 261 | } |
| 262 | } |
| 263 | ``` |
| 264 | |
| 265 | - [ ] **Step 2: Run to verify it fails** |
| 266 | |
| 267 | Run: `go test ./internal/provision/ -run 'Gate' -count=1` |
| 268 | Expected: compile error, no field `Gate`. |
| 269 | |
| 270 | - [ ] **Step 3: Implement** |
| 271 | |
| 272 | `Record`, after `Steering`: |
| 273 | |
| 274 | ```go |
| 275 | // Gate is the bcrypt hash of the host's shared password ("" = open, |
| 276 | // the value every record written before this field existed decodes |
| 277 | // to). The ONLY place the secret material lives in the bucket; the |
| 278 | // plaintext crosses the console once, at the door that hashes it |
| 279 | // (internal/gate.Hash), and is never stored, logged or echoed — the |
| 280 | // console's record views shadow this field (apiinstances.go). A |
| 281 | // string, not a pointer: absent and "" are the same fact, an open |
| 282 | // host. Converged into routes.gate by convergeGate and read by the |
| 283 | // edge on every request. Spec docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| 284 | Gate string `json:"gate,omitempty"` |
| 285 | ``` |
| 286 | |
| 287 | `ValidateShape`, after the steering check: |
| 288 | |
| 289 | ```go |
| 290 | if rec.Gate != "" { |
| 291 | if _, err := bcrypt.Cost([]byte(rec.Gate)); err != nil { |
| 292 | return errors.New("gate: want a bcrypt hash — the console hashes the password; a record never carries it in clear") |
| 293 | } |
| 294 | } |
| 295 | ``` |
| 296 | |
| 297 | 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.) |
| 298 | |
| 299 | `reconcile.go`, in the `existing.Provisioned && existing.App == rec.App && ...` arm, directly after the hibernate block: |
| 300 | |
| 301 | ```go |
| 302 | if existing.Gate != rec.Gate { |
| 303 | if cfg.DryRun { |
| 304 | log.Printf("provision: dry-run: would converge %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| 305 | } else if err := reg.SetGate(rec.Host, rec.Gate); err != nil { |
| 306 | log.Printf("provision: converge gate on %s: %v", rec.Host, err) |
| 307 | } else { |
| 308 | log.Printf("provision: converged %s gate %s -> %s", rec.Host, gateWord(existing.Gate), gateWord(rec.Gate)) |
| 309 | } |
| 310 | } |
| 311 | ``` |
| 312 | |
| 313 | and at file scope: |
| 314 | |
| 315 | ```go |
| 316 | // gateWord renders a gate column for a log line: "open" or "set", never the |
| 317 | // hash — it has no business in a journal. |
| 318 | func gateWord(hash string) string { |
| 319 | if hash == "" { |
| 320 | return "open" |
| 321 | } |
| 322 | return "set" |
| 323 | } |
| 324 | ``` |
| 325 | |
| 326 | 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. |
| 327 | |
| 328 | - [ ] **Step 4: Run the package tests** |
| 329 | |
| 330 | Run: `go test ./internal/provision/ -count=1` |
| 331 | Expected: PASS. |
| 332 | |
| 333 | - [ ] **Step 5: Commit** |
| 334 | |
| 335 | ```bash |
| 336 | git add internal/provision/ |
| 337 | git commit -m "provision: Record.Gate, validated as a bcrypt hash and converged into routes.gate" |
| 338 | ``` |
| 339 | |
| 340 | --- |
| 341 | |
| 342 | ### Task 3: `internal/gate` — rules, cookie, limiter, page, Admit (spec §4.2–§4.4, §5.1) |
| 343 | |
| 344 | **Files:** |
| 345 | - Create: `internal/gate/gate.go`, `internal/gate/cookie.go`, `internal/gate/limiter.go`, `internal/gate/page.go`, `internal/gate/login.html` |
| 346 | - Test: `internal/gate/gate_test.go` |
| 347 | |
| 348 | **Interfaces:** |
| 349 | - Produces: |
| 350 | - `const CookieName = "carlos_gate"`, `const LoginPath = "/.carlos/gate"`, `const Lifetime = 7 * 24 * time.Hour` |
| 351 | - `func CheckPassword(p string) error`, `func Hash(p string) (string, error)` |
| 352 | - `func Mint(host, hash string, expiry time.Time) string`, `func Verify(value, host, hash string, now time.Time) bool` |
| 353 | - `type Limiter`, `func NewLimiter(now func() time.Time) *Limiter`, `func (l *Limiter) Allow(key string) bool` |
| 354 | - `type Gate`, `func New() *Gate`, `func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool` |
| 355 | |
| 356 | - [ ] **Step 1: Write the failing tests** |
| 357 | |
| 358 | `internal/gate/gate_test.go`: |
| 359 | |
| 360 | ```go |
| 361 | package gate |
| 362 | |
| 363 | import ( |
| 364 | "net/http" |
| 365 | "net/http/httptest" |
| 366 | "net/url" |
| 367 | "strings" |
| 368 | "testing" |
| 369 | "time" |
| 370 | ) |
| 371 | |
| 372 | func TestCheckPassword(t *testing.T) { |
| 373 | if err := CheckPassword("short7!"); err == nil { |
| 374 | t.Fatal("7 chars accepted") |
| 375 | } |
| 376 | if err := CheckPassword(strings.Repeat("x", 73)); err == nil { |
| 377 | t.Fatal("73 bytes accepted — bcrypt would silently truncate") |
| 378 | } |
| 379 | if err := CheckPassword("longenough"); err != nil { |
| 380 | t.Fatal(err) |
| 381 | } |
| 382 | } |
| 383 | |
| 384 | func TestHashThenVerifyPassword(t *testing.T) { |
| 385 | h, err := Hash("correct horse") |
| 386 | if err != nil { |
| 387 | t.Fatal(err) |
| 388 | } |
| 389 | if !strings.HasPrefix(h, "$2a$10$") { |
| 390 | t.Fatalf("hash = %q, want bcrypt cost 10", h) |
| 391 | } |
| 392 | if !comparePassword(h, "correct horse") || comparePassword(h, "wrong horse") { |
| 393 | t.Fatal("comparePassword disagrees with Hash") |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | func TestCookieRoundTrip(t *testing.T) { |
| 398 | now := time.Unix(1_800_000_000, 0) |
| 399 | v := Mint("site.example", "$2a$10$hash", now.Add(Lifetime)) |
| 400 | if !Verify(v, "site.example", "$2a$10$hash", now) { |
| 401 | t.Fatal("fresh cookie refused") |
| 402 | } |
| 403 | if Verify(v, "other.example", "$2a$10$hash", now) { |
| 404 | t.Fatal("cookie accepted for another host") |
| 405 | } |
| 406 | if Verify(v, "site.example", "$2a$10$rotated", now) { |
| 407 | t.Fatal("cookie accepted after the password changed") |
| 408 | } |
| 409 | if Verify(v, "site.example", "$2a$10$hash", now.Add(Lifetime+time.Second)) { |
| 410 | t.Fatal("expired cookie accepted") |
| 411 | } |
| 412 | if Verify("v1.garbage", "site.example", "$2a$10$hash", now) || Verify("", "site.example", "$2a$10$hash", now) { |
| 413 | t.Fatal("malformed cookie accepted") |
| 414 | } |
| 415 | } |
| 416 | |
| 417 | func TestLimiterTenPerMinute(t *testing.T) { |
| 418 | now := time.Unix(0, 0) |
| 419 | l := NewLimiter(func() time.Time { return now }) |
| 420 | for i := 0; i < 10; i++ { |
| 421 | if !l.Allow("k") { |
| 422 | t.Fatalf("attempt %d refused", i+1) |
| 423 | } |
| 424 | } |
| 425 | if l.Allow("k") { |
| 426 | t.Fatal("11th attempt allowed") |
| 427 | } |
| 428 | if !l.Allow("other") { |
| 429 | t.Fatal("another key shares the bucket") |
| 430 | } |
| 431 | now = now.Add(6 * time.Second) |
| 432 | if !l.Allow("k") { |
| 433 | t.Fatal("no refill after 6s") |
| 434 | } |
| 435 | } |
| 436 | |
| 437 | func testGate(hash string) *Gate { |
| 438 | g := New() |
| 439 | g.now = func() time.Time { return time.Unix(1_800_000_000, 0) } |
| 440 | return g |
| 441 | } |
| 442 | |
| 443 | func TestAdmitNoCookieIsTheLoginPage(t *testing.T) { |
| 444 | g := testGate("") |
| 445 | h, _ := Hash("correct horse") |
| 446 | for _, method := range []string{http.MethodGet, http.MethodPost, http.MethodHead} { |
| 447 | rec := httptest.NewRecorder() |
| 448 | r := httptest.NewRequest(method, "https://site.example/docs?x=1", nil) |
| 449 | if g.Admit(rec, r, "site.example", h) { |
| 450 | t.Fatalf("%s without a cookie admitted", method) |
| 451 | } |
| 452 | if rec.Code != http.StatusUnauthorized { |
| 453 | t.Fatalf("%s status = %d, want 401", method, rec.Code) |
| 454 | } |
| 455 | if cc := rec.Header().Get("Cache-Control"); cc != "no-store" { |
| 456 | t.Fatalf("Cache-Control = %q", cc) |
| 457 | } |
| 458 | if rt := rec.Header().Get("X-Robots-Tag"); rt != "noindex, nofollow" { |
| 459 | t.Fatalf("X-Robots-Tag = %q", rt) |
| 460 | } |
| 461 | body := rec.Body.String() |
| 462 | if method == http.MethodHead { |
| 463 | if body != "" { |
| 464 | t.Fatal("HEAD carried a body") |
| 465 | } |
| 466 | continue |
| 467 | } |
| 468 | if !strings.Contains(body, "Password required") || !strings.Contains(body, "site.example") { |
| 469 | t.Fatalf("page missing copy: %s", body) |
| 470 | } |
| 471 | if !strings.Contains(body, `name="next" value="/docs?x=1"`) { |
| 472 | t.Fatalf("page does not carry next: %s", body) |
| 473 | } |
| 474 | if !strings.Contains(body, `action="/.carlos/gate"`) { |
| 475 | t.Fatalf("form action wrong: %s", body) |
| 476 | } |
| 477 | } |
| 478 | } |
| 479 | |
| 480 | func postLogin(g *Gate, host, hash, password, next string) *httptest.ResponseRecorder { |
| 481 | rec := httptest.NewRecorder() |
| 482 | form := url.Values{"password": {password}, "next": {next}} |
| 483 | r := httptest.NewRequest(http.MethodPost, "https://"+host+LoginPath, strings.NewReader(form.Encode())) |
| 484 | r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 485 | r.RemoteAddr = "203.0.113.9:4242" |
| 486 | g.Admit(rec, r, host, hash) |
| 487 | return rec |
| 488 | } |
| 489 | |
| 490 | func TestAdmitRightPasswordSetsCookieAndRedirects(t *testing.T) { |
| 491 | g := testGate("") |
| 492 | h, _ := Hash("correct horse") |
| 493 | rec := postLogin(g, "site.example", h, "correct horse", "/docs?x=1") |
| 494 | if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/docs?x=1" { |
| 495 | t.Fatalf("got %d -> %q, want 303 -> /docs?x=1", rec.Code, rec.Header().Get("Location")) |
| 496 | } |
| 497 | var c *http.Cookie |
| 498 | for _, k := range rec.Result().Cookies() { |
| 499 | if k.Name == CookieName { |
| 500 | c = k |
| 501 | } |
| 502 | } |
| 503 | if c == nil { |
| 504 | t.Fatal("no gate cookie set") |
| 505 | } |
| 506 | if !c.HttpOnly || !c.Secure || c.SameSite != http.SameSiteLaxMode || c.Path != "/" || c.MaxAge != int(Lifetime/time.Second) { |
| 507 | t.Fatalf("cookie attributes: %+v", c) |
| 508 | } |
| 509 | if !Verify(c.Value, "site.example", h, g.now()) { |
| 510 | t.Fatal("minted cookie does not verify") |
| 511 | } |
| 512 | // And the cookie is admitted, and stripped from what the app sees. |
| 513 | rec2 := httptest.NewRecorder() |
| 514 | r := httptest.NewRequest(http.MethodGet, "https://site.example/", nil) |
| 515 | r.Header.Set("Cookie", "theme=dark; "+CookieName+"="+c.Value+"; other=1") |
| 516 | if !g.Admit(rec2, r, "site.example", h) { |
| 517 | t.Fatal("valid cookie refused") |
| 518 | } |
| 519 | if got := r.Header.Get("Cookie"); got != "theme=dark; other=1" { |
| 520 | t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", got) |
| 521 | } |
| 522 | } |
| 523 | |
| 524 | func TestAdmitBadNextBecomesRoot(t *testing.T) { |
| 525 | g := testGate("") |
| 526 | h, _ := Hash("correct horse") |
| 527 | for _, next := range []string{"//evil.example/", "https://evil.example/", "docs", ""} { |
| 528 | rec := postLogin(g, "site.example", h, "correct horse", next) |
| 529 | if loc := rec.Header().Get("Location"); loc != "/" { |
| 530 | t.Fatalf("next %q -> Location %q, want /", next, loc) |
| 531 | } |
| 532 | } |
| 533 | } |
| 534 | |
| 535 | func TestAdmitWrongPasswordThenLimit(t *testing.T) { |
| 536 | g := testGate("") |
| 537 | h, _ := Hash("correct horse") |
| 538 | compares := 0 |
| 539 | g.compare = func(hash, password string) bool { compares++; return false } |
| 540 | for i := 0; i < 10; i++ { |
| 541 | rec := postLogin(g, "site.example", h, "nope nope", "/") |
| 542 | if rec.Code != http.StatusUnauthorized || !strings.Contains(rec.Body.String(), "That password is not right.") { |
| 543 | t.Fatalf("attempt %d: %d %s", i+1, rec.Code, rec.Body.String()) |
| 544 | } |
| 545 | } |
| 546 | rec := postLogin(g, "site.example", h, "nope nope", "/") |
| 547 | if rec.Code != http.StatusTooManyRequests || rec.Header().Get("Retry-After") != "60" { |
| 548 | t.Fatalf("11th attempt: %d Retry-After=%q, want 429/60", rec.Code, rec.Header().Get("Retry-After")) |
| 549 | } |
| 550 | if !strings.Contains(rec.Body.String(), "Too many attempts. Try again in a minute.") { |
| 551 | t.Fatalf("429 page copy: %s", rec.Body.String()) |
| 552 | } |
| 553 | if compares != 10 { |
| 554 | t.Fatalf("bcrypt compared %d times, want 10 — the limiter must refuse BEFORE comparing", compares) |
| 555 | } |
| 556 | } |
| 557 | |
| 558 | func TestLoginPathOnGetIsJustAGatedRequest(t *testing.T) { |
| 559 | g := testGate("") |
| 560 | h, _ := Hash("correct horse") |
| 561 | rec := httptest.NewRecorder() |
| 562 | r := httptest.NewRequest(http.MethodGet, "https://site.example"+LoginPath, nil) |
| 563 | if g.Admit(rec, r, "site.example", h) || rec.Code != http.StatusUnauthorized { |
| 564 | t.Fatalf("GET %s: admitted=%v code=%d", LoginPath, false, rec.Code) |
| 565 | } |
| 566 | } |
| 567 | ``` |
| 568 | |
| 569 | - [ ] **Step 2: Run to verify it fails** |
| 570 | |
| 571 | Run: `go test ./internal/gate/ -count=1` |
| 572 | Expected: build fails, package does not exist. |
| 573 | |
| 574 | - [ ] **Step 3: Implement** |
| 575 | |
| 576 | `internal/gate/gate.go`: |
| 577 | |
| 578 | ```go |
| 579 | // Package gate is the password curtain the edge holds in front of a gated |
| 580 | // host: one shared password per hostname, a neutral page that asks for it, |
| 581 | // and a cookie that remembers the answer for a week. Spec: |
| 582 | // docs/superpowers/specs/2026-09-07-password-gate-design.md. |
| 583 | // |
| 584 | // The edge calls Admit once per request to a route whose registry row |
| 585 | // carries a gate hash. Everything else here — the rules the console |
| 586 | // applies before hashing, the cookie's shape, the attempt limiter — is |
| 587 | // owned by this package so the two console doors and the edge cannot |
| 588 | // drift from one another. |
| 589 | package gate |
| 590 | |
| 591 | import ( |
| 592 | "fmt" |
| 593 | "net" |
| 594 | "net/http" |
| 595 | "strings" |
| 596 | "time" |
| 597 | |
| 598 | "golang.org/x/crypto/bcrypt" |
| 599 | ) |
| 600 | |
| 601 | const ( |
| 602 | // CookieName is the browser cookie a passed gate sets; stripped from |
| 603 | // the request before it reaches the app. |
| 604 | CookieName = "carlos_gate" |
| 605 | // LoginPath is the form's action on a gated host. Ordinary on every |
| 606 | // other host and for every other method. |
| 607 | LoginPath = "/.carlos/gate" |
| 608 | // Lifetime is how long a cookie stays valid; the expiry is inside the |
| 609 | // value, so a stolen cookie is bounded even if the browser keeps it. |
| 610 | Lifetime = 7 * 24 * time.Hour |
| 611 | |
| 612 | bcryptCost = 10 |
| 613 | minPassword = 8 |
| 614 | maxPassword = 72 // bcrypt's input limit; longer is refused, never truncated |
| 615 | maxFormBody = 4 << 10 |
| 616 | ) |
| 617 | |
| 618 | // CheckPassword is the door's rule, applied by the console before hashing. |
| 619 | func CheckPassword(p string) error { |
| 620 | if len(p) < minPassword { |
| 621 | return fmt.Errorf("password must be at least %d characters", minPassword) |
| 622 | } |
| 623 | if len(p) > maxPassword { |
| 624 | return fmt.Errorf("password must be at most %d bytes", maxPassword) |
| 625 | } |
| 626 | return nil |
| 627 | } |
| 628 | |
| 629 | // Hash is bcrypt at cost 10. The console calls it once per set; the edge |
| 630 | // never does (it compares on the login POST and verifies an HMAC cookie |
| 631 | // on every other request). |
| 632 | func Hash(p string) (string, error) { |
| 633 | if err := CheckPassword(p); err != nil { |
| 634 | return "", err |
| 635 | } |
| 636 | b, err := bcrypt.GenerateFromPassword([]byte(p), bcryptCost) |
| 637 | if err != nil { |
| 638 | return "", err |
| 639 | } |
| 640 | return string(b), nil |
| 641 | } |
| 642 | |
| 643 | func comparePassword(hash, password string) bool { |
| 644 | return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil |
| 645 | } |
| 646 | |
| 647 | // Gate is the per-edge instance: one limiter shared by every gated host on |
| 648 | // the box (keys carry the host), a clock and a comparer for tests. |
| 649 | type Gate struct { |
| 650 | limiter *Limiter |
| 651 | now func() time.Time |
| 652 | compare func(hash, password string) bool |
| 653 | } |
| 654 | |
| 655 | // New is the edge's constructor. There is no configuration. |
| 656 | func New() *Gate { |
| 657 | g := &Gate{now: time.Now, compare: comparePassword} |
| 658 | g.limiter = NewLimiter(func() time.Time { return g.now() }) |
| 659 | return g |
| 660 | } |
| 661 | |
| 662 | // Admit decides one request to a gated host. host is the route's own |
| 663 | // hostname (the row's, not the request's — an alias and a path-routed |
| 664 | // prefix must agree on which key signs), hash the row's gate. |
| 665 | // |
| 666 | // true: the request carries a valid cookie, now stripped from the Cookie |
| 667 | // header; the caller proxies it exactly as it would have. false: Admit has |
| 668 | // written the whole response — the login page (401), the redirect after a |
| 669 | // successful POST (303), or the too-many-attempts page (429). |
| 670 | func (g *Gate) Admit(w http.ResponseWriter, r *http.Request, host, hash string) bool { |
| 671 | if r.Method == http.MethodPost && r.URL.Path == LoginPath { |
| 672 | g.login(w, r, host, hash) |
| 673 | return false |
| 674 | } |
| 675 | if c, err := r.Cookie(CookieName); err == nil && Verify(c.Value, host, hash, g.now()) { |
| 676 | stripCookie(r) |
| 677 | return true |
| 678 | } |
| 679 | writePage(w, r, host, http.StatusUnauthorized, "", safeNext(r.URL.RequestURI())) |
| 680 | return false |
| 681 | } |
| 682 | |
| 683 | func (g *Gate) login(w http.ResponseWriter, r *http.Request, host, hash string) { |
| 684 | r.Body = http.MaxBytesReader(w, r.Body, maxFormBody) |
| 685 | if err := r.ParseForm(); err != nil { |
| 686 | writePage(w, r, host, http.StatusUnauthorized, msgWrong, "/") |
| 687 | return |
| 688 | } |
| 689 | next := safeNext(r.PostForm.Get("next")) |
| 690 | if !g.limiter.Allow(clientKey(r) + "\x1f" + host) { |
| 691 | w.Header().Set("Retry-After", "60") |
| 692 | writePage(w, r, host, http.StatusTooManyRequests, msgTooMany, next) |
| 693 | return |
| 694 | } |
| 695 | if !g.compare(hash, r.PostForm.Get("password")) { |
| 696 | writePage(w, r, host, http.StatusUnauthorized, msgWrong, next) |
| 697 | return |
| 698 | } |
| 699 | http.SetCookie(w, &http.Cookie{ |
| 700 | Name: CookieName, |
| 701 | Value: Mint(host, hash, g.now().Add(Lifetime)), |
| 702 | Path: "/", |
| 703 | MaxAge: int(Lifetime / time.Second), |
| 704 | HttpOnly: true, |
| 705 | Secure: true, |
| 706 | SameSite: http.SameSiteLaxMode, |
| 707 | }) |
| 708 | w.Header().Set("Cache-Control", "no-store") |
| 709 | http.Redirect(w, r, next, http.StatusSeeOther) |
| 710 | } |
| 711 | |
| 712 | // safeNext is the entire open-redirect surface: a path on this host, or "/". |
| 713 | func safeNext(next string) string { |
| 714 | if !strings.HasPrefix(next, "/") || strings.HasPrefix(next, "//") || strings.HasPrefix(next, "/\\") { |
| 715 | return "/" |
| 716 | } |
| 717 | return next |
| 718 | } |
| 719 | |
| 720 | // clientKey is the remote address without its port. A backhauled request's |
| 721 | // address is the sibling edge's, which folds every hop from that edge into |
| 722 | // one bucket — stricter, never looser, and named in the spec (§4.2). |
| 723 | func clientKey(r *http.Request) string { |
| 724 | if h, _, err := net.SplitHostPort(r.RemoteAddr); err == nil { |
| 725 | return h |
| 726 | } |
| 727 | return r.RemoteAddr |
| 728 | } |
| 729 | |
| 730 | // stripCookie removes the gate cookie from the request so the app behind |
| 731 | // the curtain never sees it. Other cookies pass through in their order. |
| 732 | func stripCookie(r *http.Request) { |
| 733 | cookies := r.Cookies() |
| 734 | r.Header.Del("Cookie") |
| 735 | parts := make([]string, 0, len(cookies)) |
| 736 | for _, c := range cookies { |
| 737 | if c.Name != CookieName { |
| 738 | parts = append(parts, c.Name+"="+c.Value) |
| 739 | } |
| 740 | } |
| 741 | if len(parts) > 0 { |
| 742 | r.Header.Set("Cookie", strings.Join(parts, "; ")) |
| 743 | } |
| 744 | } |
| 745 | ``` |
| 746 | |
| 747 | `internal/gate/cookie.go`: |
| 748 | |
| 749 | ```go |
| 750 | package gate |
| 751 | |
| 752 | import ( |
| 753 | "crypto/hmac" |
| 754 | "crypto/sha256" |
| 755 | "encoding/base64" |
| 756 | "strconv" |
| 757 | "strings" |
| 758 | "time" |
| 759 | ) |
| 760 | |
| 761 | // The cookie is v1.<expiry unix>.<base64url(HMAC-SHA256(key, host "\n" expiry))> |
| 762 | // with key derived from the stored bcrypt hash and nothing else. That is |
| 763 | // what makes the design stateless and gives it its one rotation rule: |
| 764 | // change or clear the password and every cookie minted under the old one |
| 765 | // stops verifying at once, on every box, with no key to mint or mirror. |
| 766 | // Anyone who can read the hash can mint a cookie — root on the box and a |
| 767 | // bucket reader, who already hold the tenant's sealed secrets (spec §4.3). |
| 768 | |
| 769 | const cookieVersion = "v1" |
| 770 | |
| 771 | func cookieKey(hash string) []byte { |
| 772 | m := hmac.New(sha256.New, []byte("carlos-gate-cookie-v1")) |
| 773 | m.Write([]byte(hash)) |
| 774 | return m.Sum(nil) |
| 775 | } |
| 776 | |
| 777 | func cookieMAC(key []byte, host string, expiry int64) []byte { |
| 778 | m := hmac.New(sha256.New, key) |
| 779 | m.Write([]byte(host)) |
| 780 | m.Write([]byte{'\n'}) |
| 781 | m.Write([]byte(strconv.FormatInt(expiry, 10))) |
| 782 | return m.Sum(nil) |
| 783 | } |
| 784 | |
| 785 | // Mint produces a cookie value for host, valid until expiry. |
| 786 | func Mint(host, hash string, expiry time.Time) string { |
| 787 | exp := expiry.Unix() |
| 788 | mac := cookieMAC(cookieKey(hash), host, exp) |
| 789 | return cookieVersion + "." + strconv.FormatInt(exp, 10) + "." + base64.RawURLEncoding.EncodeToString(mac) |
| 790 | } |
| 791 | |
| 792 | // Verify reports whether value was minted for host under hash and has not |
| 793 | // expired at now. One HMAC and a constant-time compare; no bcrypt. |
| 794 | func Verify(value, host, hash string, now time.Time) bool { |
| 795 | parts := strings.Split(value, ".") |
| 796 | if len(parts) != 3 || parts[0] != cookieVersion { |
| 797 | return false |
| 798 | } |
| 799 | exp, err := strconv.ParseInt(parts[1], 10, 64) |
| 800 | if err != nil || exp <= now.Unix() { |
| 801 | return false |
| 802 | } |
| 803 | got, err := base64.RawURLEncoding.DecodeString(parts[2]) |
| 804 | if err != nil { |
| 805 | return false |
| 806 | } |
| 807 | return hmac.Equal(got, cookieMAC(cookieKey(hash), host, exp)) |
| 808 | } |
| 809 | ``` |
| 810 | |
| 811 | `internal/gate/limiter.go`: |
| 812 | |
| 813 | ```go |
| 814 | package gate |
| 815 | |
| 816 | import ( |
| 817 | "sync" |
| 818 | "time" |
| 819 | ) |
| 820 | |
| 821 | const ( |
| 822 | limitBurst = 10 |
| 823 | limitPeriod = time.Minute |
| 824 | limitPrune = 10_000 |
| 825 | ) |
| 826 | |
| 827 | // Limiter is a per-key token bucket: limitBurst attempts, refilling at |
| 828 | // limitBurst per limitPeriod. bcrypt at cost 10 already costs tens of |
| 829 | // milliseconds a comparison; this exists so the CPU is spent on people and |
| 830 | // not on a script. |
| 831 | type Limiter struct { |
| 832 | mu sync.Mutex |
| 833 | now func() time.Time |
| 834 | buckets map[string]*bucket |
| 835 | } |
| 836 | |
| 837 | type bucket struct { |
| 838 | tokens float64 |
| 839 | last time.Time |
| 840 | } |
| 841 | |
| 842 | func NewLimiter(now func() time.Time) *Limiter { |
| 843 | return &Limiter{now: now, buckets: map[string]*bucket{}} |
| 844 | } |
| 845 | |
| 846 | // Allow spends one token for key, reporting whether one was available. |
| 847 | func (l *Limiter) Allow(key string) bool { |
| 848 | l.mu.Lock() |
| 849 | defer l.mu.Unlock() |
| 850 | now := l.now() |
| 851 | b, ok := l.buckets[key] |
| 852 | if !ok { |
| 853 | if len(l.buckets) >= limitPrune { |
| 854 | l.prune(now) |
| 855 | } |
| 856 | b = &bucket{tokens: limitBurst, last: now} |
| 857 | l.buckets[key] = b |
| 858 | } |
| 859 | refill := now.Sub(b.last).Seconds() * (limitBurst / limitPeriod.Seconds()) |
| 860 | b.tokens = min(limitBurst, b.tokens+refill) |
| 861 | b.last = now |
| 862 | if b.tokens < 1 { |
| 863 | return false |
| 864 | } |
| 865 | b.tokens-- |
| 866 | return true |
| 867 | } |
| 868 | |
| 869 | // prune drops every bucket that has been idle long enough to be full again; |
| 870 | // keeping it would change no answer. |
| 871 | func (l *Limiter) prune(now time.Time) { |
| 872 | for k, b := range l.buckets { |
| 873 | if now.Sub(b.last) >= limitPeriod { |
| 874 | delete(l.buckets, k) |
| 875 | } |
| 876 | } |
| 877 | } |
| 878 | ``` |
| 879 | |
| 880 | `internal/gate/page.go`: |
| 881 | |
| 882 | ```go |
| 883 | package gate |
| 884 | |
| 885 | import ( |
| 886 | _ "embed" |
| 887 | "html/template" |
| 888 | "log" |
| 889 | "net/http" |
| 890 | ) |
| 891 | |
| 892 | //go:embed login.html |
| 893 | var loginHTML string |
| 894 | |
| 895 | var loginTmpl = template.Must(template.New("login").Parse(loginHTML)) |
| 896 | |
| 897 | const ( |
| 898 | msgWrong = "That password is not right." |
| 899 | msgTooMany = "Too many attempts. Try again in a minute." |
| 900 | ) |
| 901 | |
| 902 | type pageData struct { |
| 903 | Host string |
| 904 | Error string |
| 905 | Next string |
| 906 | Action string |
| 907 | } |
| 908 | |
| 909 | // writePage renders the login page at status. Every page this package |
| 910 | // writes is no-store and noindex: it is a curtain, not content. |
| 911 | func writePage(w http.ResponseWriter, r *http.Request, host string, status int, msg, next string) { |
| 912 | h := w.Header() |
| 913 | h.Set("Cache-Control", "no-store") |
| 914 | h.Set("X-Robots-Tag", "noindex, nofollow") |
| 915 | h.Set("Content-Type", "text/html; charset=utf-8") |
| 916 | w.WriteHeader(status) |
| 917 | if r.Method == http.MethodHead { |
| 918 | return |
| 919 | } |
| 920 | if err := loginTmpl.Execute(w, pageData{Host: host, Error: msg, Next: next, Action: LoginPath}); err != nil { |
| 921 | log.Printf("gate: render login page for %s: %v", host, err) |
| 922 | } |
| 923 | } |
| 924 | ``` |
| 925 | |
| 926 | `internal/gate/login.html` — the palette is `internal/status/assets/status.css`'s, inline, so the page is one response: |
| 927 | |
| 928 | ```html |
| 929 | <!doctype html> |
| 930 | <html lang="en"> |
| 931 | <head> |
| 932 | <meta charset="utf-8"> |
| 933 | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| 934 | <meta name="robots" content="noindex, nofollow"> |
| 935 | <title>Password required</title> |
| 936 | <style> |
| 937 | :root { |
| 938 | --bg: #ffffff; --ink: #1d2029; --muted: #64707f; --accent: #0f5e85; |
| 939 | --accent-soft: rgba(15, 94, 133, 0.09); --rule: #e7e9f0; --bad: #b3403a; |
| 940 | } |
| 941 | @media (prefers-color-scheme: dark) { |
| 942 | :root { |
| 943 | --bg: #14151c; --ink: #eef0f6; --muted: #93a4ba; --accent: #5db8dd; |
| 944 | --accent-soft: rgba(93, 184, 221, 0.12); --rule: #2a2e38; --bad: #e07b76; |
| 945 | } |
| 946 | } |
| 947 | * { margin: 0; padding: 0; box-sizing: border-box; } |
| 948 | body { |
| 949 | background: var(--bg); color: var(--ink); |
| 950 | font: 400 1rem/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; |
| 951 | min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 1.25rem; |
| 952 | } |
| 953 | main { width: 100%; max-width: 22rem; } |
| 954 | h1 { font-size: 1.25rem; margin-bottom: 0.5rem; } |
| 955 | p { color: var(--muted); margin-bottom: 1.25rem; } |
| 956 | p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; } |
| 957 | label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; } |
| 958 | input[type=password] { |
| 959 | width: 100%; font: inherit; color: var(--ink); background: var(--bg); |
| 960 | border: 1px solid var(--rule); border-radius: 0.4rem; padding: 0.55rem 0.7rem; margin-bottom: 0.9rem; |
| 961 | } |
| 962 | input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); } |
| 963 | button { |
| 964 | width: 100%; font: inherit; font-weight: 600; color: var(--bg); background: var(--accent); |
| 965 | border: 0; border-radius: 0.4rem; padding: 0.6rem 0.9rem; cursor: pointer; |
| 966 | } |
| 967 | .error { color: var(--bad); margin: 0 0 0.9rem; } |
| 968 | </style> |
| 969 | </head> |
| 970 | <body> |
| 971 | <main> |
| 972 | <h1>Password required</h1> |
| 973 | <p><code>{{.Host}}</code> is private. Enter the password to continue.</p> |
| 974 | <form method="post" action="{{.Action}}"> |
| 975 | <input type="hidden" name="next" value="{{.Next}}"> |
| 976 | {{if .Error}}<p class="error" role="alert">{{.Error}}</p>{{end}} |
| 977 | <label for="password">Password</label> |
| 978 | <input type="password" id="password" name="password" autocomplete="current-password" autofocus required> |
| 979 | <button type="submit">Continue</button> |
| 980 | </form> |
| 981 | </main> |
| 982 | </body> |
| 983 | </html> |
| 984 | ``` |
| 985 | |
| 986 | - [ ] **Step 4: Run the package tests** |
| 987 | |
| 988 | Run: `go test ./internal/gate/ -count=1 && go vet ./internal/gate/` |
| 989 | Expected: PASS. |
| 990 | |
| 991 | - [ ] **Step 5: Commit** |
| 992 | |
| 993 | ```bash |
| 994 | git add internal/gate/ |
| 995 | git commit -m "gate: password rules, HMAC cookie keyed off the hash, limiter and login page" |
| 996 | ``` |
| 997 | |
| 998 | --- |
| 999 | |
| 1000 | ### Task 4: The edge enforces it (spec §4.1) |
| 1001 | |
| 1002 | **Files:** |
| 1003 | - 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). |
| 1004 | - Test: `internal/edge/gate_test.go` (new) |
| 1005 | |
| 1006 | **Interfaces:** |
| 1007 | - Consumes: `gate.New`, `(*gate.Gate).Admit`, `gate.CookieName`, `gate.Mint`, `gate.Hash`, `gate.LoginPath` (Task 3); `registry.Route.Gate` (Task 1). |
| 1008 | |
| 1009 | - [ ] **Step 1: Write the failing tests** |
| 1010 | |
| 1011 | `internal/edge/gate_test.go`: |
| 1012 | |
| 1013 | ```go |
| 1014 | package edge |
| 1015 | |
| 1016 | import ( |
| 1017 | "io" |
| 1018 | "net/http" |
| 1019 | "net/http/httptest" |
| 1020 | "net/url" |
| 1021 | "strings" |
| 1022 | "testing" |
| 1023 | "time" |
| 1024 | |
| 1025 | "github.com/carlosframework/platform/internal/gate" |
| 1026 | "github.com/carlosframework/platform/internal/registry" |
| 1027 | ) |
| 1028 | |
| 1029 | func gatedEdge(t *testing.T, act Activator, hibernate bool) (*Edge, *httptest.Server, string, *string) { |
| 1030 | t.Helper() |
| 1031 | backend, seenCookie := cookieEchoBackend(t, "secret page") |
| 1032 | h, err := gate.Hash("correct horse") |
| 1033 | if err != nil { |
| 1034 | t.Fatal(err) |
| 1035 | } |
| 1036 | reg := openReg(t) |
| 1037 | if err := reg.Upsert(registry.Route{Host: "gated.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), |
| 1038 | Status: "ready", Hibernate: hibernate, Gate: h}); err != nil { |
| 1039 | t.Fatal(err) |
| 1040 | } |
| 1041 | if err := reg.UpsertAlias(registry.RouteAlias{Alias: "www.custom.example", Host: "gated.example.com"}); err != nil { |
| 1042 | t.Fatal(err) |
| 1043 | } |
| 1044 | e, err := New(reg, act) |
| 1045 | if err != nil { |
| 1046 | t.Fatal(err) |
| 1047 | } |
| 1048 | srv := httptest.NewServer(e) |
| 1049 | t.Cleanup(srv.Close) |
| 1050 | return e, srv, h, seenCookie |
| 1051 | } |
| 1052 | |
| 1053 | // cookieEchoBackend records the Cookie header it saw. |
| 1054 | func cookieEchoBackend(t *testing.T, body string) (*httptest.Server, *string) { |
| 1055 | t.Helper() |
| 1056 | seen := new(string) |
| 1057 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 1058 | *seen = r.Header.Get("Cookie") |
| 1059 | io.WriteString(w, body) |
| 1060 | })) |
| 1061 | t.Cleanup(ts.Close) |
| 1062 | return ts, seen |
| 1063 | } |
| 1064 | |
| 1065 | func doHost(t *testing.T, srv *httptest.Server, method, host, path, cookie string) *http.Response { |
| 1066 | t.Helper() |
| 1067 | req, _ := http.NewRequest(method, srv.URL+path, nil) |
| 1068 | req.Host = host |
| 1069 | if cookie != "" { |
| 1070 | req.Header.Set("Cookie", cookie) |
| 1071 | } |
| 1072 | c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 1073 | resp, err := c.Do(req) |
| 1074 | if err != nil { |
| 1075 | t.Fatal(err) |
| 1076 | } |
| 1077 | t.Cleanup(func() { resp.Body.Close() }) |
| 1078 | return resp |
| 1079 | } |
| 1080 | |
| 1081 | func TestGatedRouteWithoutCookieNeverWakesOrProxies(t *testing.T) { |
| 1082 | _, srv, _, seen := gatedEdge(t, refusingActivator{t: t}, true) |
| 1083 | resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/docs", "") |
| 1084 | if resp.StatusCode != http.StatusUnauthorized { |
| 1085 | t.Fatalf("status = %d, want 401", resp.StatusCode) |
| 1086 | } |
| 1087 | b, _ := io.ReadAll(resp.Body) |
| 1088 | if !strings.Contains(string(b), "Password required") { |
| 1089 | t.Fatalf("body: %s", b) |
| 1090 | } |
| 1091 | if resp.Header.Get("Cache-Control") != "no-store" { |
| 1092 | t.Fatalf("Cache-Control = %q", resp.Header.Get("Cache-Control")) |
| 1093 | } |
| 1094 | if *seen != "" || strings.Contains(string(b), "secret page") { |
| 1095 | t.Fatal("upstream was reached without a cookie") |
| 1096 | } |
| 1097 | } |
| 1098 | |
| 1099 | func TestGatedRouteCookieAdmitsAndIsStripped(t *testing.T) { |
| 1100 | _, srv, h, seen := gatedEdge(t, &fakeActivator{}, false) |
| 1101 | cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| 1102 | resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "a=1; "+cookie) |
| 1103 | b, _ := io.ReadAll(resp.Body) |
| 1104 | if resp.StatusCode != 200 || string(b) != "secret page" { |
| 1105 | t.Fatalf("got %d %q", resp.StatusCode, b) |
| 1106 | } |
| 1107 | if *seen != "a=1" { |
| 1108 | t.Fatalf("upstream Cookie = %q, want the gate cookie stripped", *seen) |
| 1109 | } |
| 1110 | } |
| 1111 | |
| 1112 | func TestGatedAliasIsGatedToo(t *testing.T) { |
| 1113 | _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| 1114 | if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", ""); resp.StatusCode != http.StatusUnauthorized { |
| 1115 | t.Fatalf("alias without cookie = %d, want 401", resp.StatusCode) |
| 1116 | } |
| 1117 | // The MAC is bound to the ROUTE host, so a cookie minted for the primary |
| 1118 | // verifies when presented on the alias. |
| 1119 | cookie := gate.CookieName + "=" + gate.Mint("gated.example.com", h, time.Now().Add(time.Hour)) |
| 1120 | if resp := doHost(t, srv, http.MethodGet, "www.custom.example", "/", cookie); resp.StatusCode != 200 { |
| 1121 | t.Fatalf("alias with cookie = %d, want 200", resp.StatusCode) |
| 1122 | } |
| 1123 | } |
| 1124 | |
| 1125 | func TestGatedLoginPostSetsCookie(t *testing.T) { |
| 1126 | _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| 1127 | form := url.Values{"password": {"correct horse"}, "next": {"/docs"}} |
| 1128 | req, _ := http.NewRequest(http.MethodPost, srv.URL+gate.LoginPath, strings.NewReader(form.Encode())) |
| 1129 | req.Host = "gated.example.com" |
| 1130 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| 1131 | c := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} |
| 1132 | resp, err := c.Do(req) |
| 1133 | if err != nil { |
| 1134 | t.Fatal(err) |
| 1135 | } |
| 1136 | defer resp.Body.Close() |
| 1137 | if resp.StatusCode != http.StatusSeeOther || resp.Header.Get("Location") != "/docs" { |
| 1138 | t.Fatalf("got %d -> %q", resp.StatusCode, resp.Header.Get("Location")) |
| 1139 | } |
| 1140 | var got string |
| 1141 | for _, k := range resp.Cookies() { |
| 1142 | if k.Name == gate.CookieName { |
| 1143 | got = k.Value |
| 1144 | } |
| 1145 | } |
| 1146 | if got == "" || !gate.Verify(got, "gated.example.com", h, time.Now()) { |
| 1147 | t.Fatalf("cookie %q does not verify", got) |
| 1148 | } |
| 1149 | } |
| 1150 | |
| 1151 | func TestGatedRouteCacheIsNeverConsultedBeforeTheGate(t *testing.T) { |
| 1152 | e, srv, _, _ := gatedEdge(t, &fakeActivator{}, false) |
| 1153 | e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| 1154 | e.cache.Put(CacheKey{Host: "gated.example.com", Method: "GET", Path: "/"}, "", &CachedResponse{ |
| 1155 | StatusCode: 200, Header: http.Header{"Content-Type": {"text/plain"}}, Body: []byte("cached secret"), |
| 1156 | StoredAt: time.Now(), TTL: time.Minute, |
| 1157 | }) |
| 1158 | resp := doHost(t, srv, http.MethodGet, "gated.example.com", "/", "") |
| 1159 | b, _ := io.ReadAll(resp.Body) |
| 1160 | if resp.StatusCode != http.StatusUnauthorized || strings.Contains(string(b), "cached secret") { |
| 1161 | t.Fatalf("cache served past the gate: %d %s", resp.StatusCode, b) |
| 1162 | } |
| 1163 | } |
| 1164 | |
| 1165 | func TestGatedWebSocketHandshakeIsGated(t *testing.T) { |
| 1166 | _, srv, h, _ := gatedEdge(t, &fakeActivator{}, false) |
| 1167 | req, _ := http.NewRequest(http.MethodGet, srv.URL+"/ws", nil) |
| 1168 | req.Host = "gated.example.com" |
| 1169 | req.Header.Set("Connection", "Upgrade") |
| 1170 | req.Header.Set("Upgrade", "websocket") |
| 1171 | resp, err := http.DefaultClient.Do(req) |
| 1172 | if err != nil { |
| 1173 | t.Fatal(err) |
| 1174 | } |
| 1175 | resp.Body.Close() |
| 1176 | if resp.StatusCode != http.StatusUnauthorized { |
| 1177 | t.Fatalf("upgrade without cookie = %d, want 401", resp.StatusCode) |
| 1178 | } |
| 1179 | req.Header.Set("Cookie", gate.CookieName+"="+gate.Mint("gated.example.com", h, time.Now().Add(time.Hour))) |
| 1180 | resp, err = http.DefaultClient.Do(req) |
| 1181 | if err != nil { |
| 1182 | t.Fatal(err) |
| 1183 | } |
| 1184 | resp.Body.Close() |
| 1185 | if resp.StatusCode == http.StatusUnauthorized { |
| 1186 | t.Fatal("upgrade with cookie was refused") |
| 1187 | } |
| 1188 | } |
| 1189 | |
| 1190 | func TestUngatedRouteUnchanged(t *testing.T) { |
| 1191 | backend, seen := cookieEchoBackend(t, "open page") |
| 1192 | reg := openReg(t) |
| 1193 | if err := reg.Upsert(registry.Route{Host: "open.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil { |
| 1194 | t.Fatal(err) |
| 1195 | } |
| 1196 | e, err := New(reg, &fakeActivator{}) |
| 1197 | if err != nil { |
| 1198 | t.Fatal(err) |
| 1199 | } |
| 1200 | srv := httptest.NewServer(e) |
| 1201 | defer srv.Close() |
| 1202 | resp := doHost(t, srv, http.MethodGet, "open.example.com", "/", gate.CookieName+"=whatever; a=1") |
| 1203 | if resp.StatusCode != 200 || *seen != gate.CookieName+"=whatever; a=1" { |
| 1204 | t.Fatalf("ungated route touched: %d cookie=%q", resp.StatusCode, *seen) |
| 1205 | } |
| 1206 | } |
| 1207 | ``` |
| 1208 | |
| 1209 | `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`). |
| 1210 | |
| 1211 | - [ ] **Step 2: Run to verify it fails** |
| 1212 | |
| 1213 | Run: `go test ./internal/edge/ -run 'Gated|Ungated' -count=1` |
| 1214 | Expected: the no-cookie tests fail with 200 (the page is served open). |
| 1215 | |
| 1216 | - [ ] **Step 3: Implement** |
| 1217 | |
| 1218 | `Edge` struct, near `act`: |
| 1219 | |
| 1220 | ```go |
| 1221 | // gate is the password curtain (internal/gate) for routes whose row |
| 1222 | // carries a gate hash. Always constructed: the check is one string |
| 1223 | // compare on up.route.Gate for every ungated host, and the limiter it |
| 1224 | // carries is shared across the box's gated hosts. |
| 1225 | gate *gate.Gate |
| 1226 | ``` |
| 1227 | |
| 1228 | `New`: `e := &Edge{reg: reg, act: act, gate: gate.New(), probers: ...}`. |
| 1229 | |
| 1230 | `ServeHTTP`, immediately after the usage-meter block (the `if m := e.usageMeter.Load(); ...` statement) and before the `isUpgradeRequest` branch: |
| 1231 | |
| 1232 | ```go |
| 1233 | // The password gate (internal/gate; spec 2026-09-07-password-gate-design |
| 1234 | // §4.1). Placed HERE and the neighbours are load-bearing: after the |
| 1235 | // meter, so the login page's bytes bill to the route that caused them; |
| 1236 | // before the upgrade branch, so a WebSocket handshake is gated; before |
| 1237 | // the cache check, so a cache hit is only ever served to a request that |
| 1238 | // already passed (the LRU keys on host and path, not on cookies); and |
| 1239 | // before the activator, so a stranger — a crawler, a scanner, a guessed |
| 1240 | // hostname — never wakes a sleeping instance. Admit strips the cookie |
| 1241 | // on the way through, so the app never sees it. |
| 1242 | if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.Gate) { |
| 1243 | return |
| 1244 | } |
| 1245 | ``` |
| 1246 | |
| 1247 | 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`. |
| 1248 | |
| 1249 | - [ ] **Step 4: Run the package tests** |
| 1250 | |
| 1251 | Run: `go test ./internal/edge/ -count=1` |
| 1252 | Expected: PASS, every pre-existing test included. |
| 1253 | |
| 1254 | - [ ] **Step 5: Commit** |
| 1255 | |
| 1256 | ```bash |
| 1257 | git add internal/edge/edge.go internal/edge/gate_test.go |
| 1258 | git commit -m "edge: enforce the password gate ahead of the upgrade branch, the cache and the activator" |
| 1259 | ``` |
| 1260 | |
| 1261 | --- |
| 1262 | |
| 1263 | ### Task 5: Console API — PATCH `gate`, and the hash never leaves (spec §5.2, §5.4) |
| 1264 | |
| 1265 | **Files:** |
| 1266 | - 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). |
| 1267 | - Test: `internal/console/apiinstances_test.go` |
| 1268 | |
| 1269 | **Interfaces:** |
| 1270 | - Consumes: `gate.CheckPassword`, `gate.Hash` (Task 3). |
| 1271 | - 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. |
| 1272 | |
| 1273 | - [ ] **Step 1: Write the failing tests** |
| 1274 | |
| 1275 | Append to `internal/console/apiinstances_test.go`: |
| 1276 | |
| 1277 | ```go |
| 1278 | func TestAPIInstanceUpdateGateRoundTrip(t *testing.T) { |
| 1279 | fx, token := instanceFixture(t, "code-inst-gate-round") |
| 1280 | enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| 1281 | u := instancesURL(fx, "bab", "titogo") |
| 1282 | if status, _, out := rawReq(t, http.MethodPost, u, token, |
| 1283 | `{"host":"gate.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| 1284 | t.Fatalf("create = %d %s", status, out) |
| 1285 | } |
| 1286 | pu := updateURL(fx, "bab", "titogo", "gate.go.tito.io") |
| 1287 | |
| 1288 | status, _, out := rawReq(t, http.MethodPatch, pu, token, `{"gate":"correct horse"}`) |
| 1289 | if status != http.StatusOK { |
| 1290 | t.Fatalf("patch gate = %d %s, want 200", status, out) |
| 1291 | } |
| 1292 | var echoed map[string]any |
| 1293 | if err := json.Unmarshal([]byte(out), &echoed); err != nil { |
| 1294 | t.Fatal(err) |
| 1295 | } |
| 1296 | if _, leaked := echoed["gate"]; leaked { |
| 1297 | t.Fatalf("patch reply carries the gate hash: %s", out) |
| 1298 | } |
| 1299 | if echoed["gated"] != true { |
| 1300 | t.Fatalf("patch reply gated = %v, want true", echoed["gated"]) |
| 1301 | } |
| 1302 | stored, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| 1303 | if err != nil { |
| 1304 | t.Fatal(err) |
| 1305 | } |
| 1306 | if !strings.HasPrefix(stored.Gate, "$2a$10$") { |
| 1307 | t.Fatalf("stored gate = %q, want a bcrypt hash", stored.Gate) |
| 1308 | } |
| 1309 | if strings.Contains(stored.Gate, "correct horse") { |
| 1310 | t.Fatal("stored gate carries the plaintext") |
| 1311 | } |
| 1312 | if detail := lastEventDetail(t, fx, "titogo"); detail != "updated instance gate.go.tito.io (gate) (cli)" { |
| 1313 | t.Fatalf("audit detail = %q", detail) |
| 1314 | } |
| 1315 | |
| 1316 | // The list and the GET shadow the hash the same way. |
| 1317 | for _, path := range []string{u, pu} { |
| 1318 | _, _, body := rawReq(t, http.MethodGet, path, token, "") |
| 1319 | if strings.Contains(body, "$2a$") { |
| 1320 | t.Fatalf("GET %s leaks the hash: %s", path, body) |
| 1321 | } |
| 1322 | if !strings.Contains(body, `"gated":true`) { |
| 1323 | t.Fatalf("GET %s lacks gated:true: %s", path, body) |
| 1324 | } |
| 1325 | } |
| 1326 | |
| 1327 | status, _, out = rawReq(t, http.MethodPatch, pu, token, `{"gate":""}`) |
| 1328 | if status != http.StatusOK { |
| 1329 | t.Fatalf("patch clear = %d %s", status, out) |
| 1330 | } |
| 1331 | stored, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "gate.go.tito.io") |
| 1332 | if stored.Gate != "" { |
| 1333 | t.Fatalf("stored gate after clear = %q", stored.Gate) |
| 1334 | } |
| 1335 | _, _, body := rawReq(t, http.MethodGet, pu, token, "") |
| 1336 | if !strings.Contains(body, `"gated":false`) { |
| 1337 | t.Fatalf("GET after clear: %s", body) |
| 1338 | } |
| 1339 | } |
| 1340 | |
| 1341 | func TestAPIInstanceUpdateGateRefusals(t *testing.T) { |
| 1342 | fx, token := instanceFixture(t, "code-inst-gate-refuse") |
| 1343 | enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| 1344 | u := instancesURL(fx, "bab", "titogo") |
| 1345 | if status, _, out := rawReq(t, http.MethodPost, u, token, |
| 1346 | `{"host":"gr.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| 1347 | t.Fatalf("create = %d %s", status, out) |
| 1348 | } |
| 1349 | pu := updateURL(fx, "bab", "titogo", "gr.go.tito.io") |
| 1350 | for _, body := range []string{`{"gate":"short7!"}`, `{"gate":"` + strings.Repeat("x", 73) + `"}`} { |
| 1351 | status, _, out := rawReq(t, http.MethodPatch, pu, token, body) |
| 1352 | if status != http.StatusBadRequest || !strings.Contains(out, "password") { |
| 1353 | t.Fatalf("%s -> %d %s, want 400 naming the password rule", body, status, out) |
| 1354 | } |
| 1355 | if strings.Contains(out, "short7!") || strings.Contains(out, "xxxxxxxx") { |
| 1356 | t.Fatalf("refusal echoes the password: %s", out) |
| 1357 | } |
| 1358 | } |
| 1359 | // The empty-body rail names gate among the patchable fields. |
| 1360 | if _, _, out := rawReq(t, http.MethodPatch, pu, token, `{}`); !strings.Contains(out, "gate") { |
| 1361 | t.Fatalf("empty-body refusal should name gate: %s", out) |
| 1362 | } |
| 1363 | } |
| 1364 | ``` |
| 1365 | |
| 1366 | Also extend `TestAPIInstanceUpdateAuditNamesChangedFields`'s expectation comment if it enumerates the field order (`addrs, probe, steering, environment, gate`). |
| 1367 | |
| 1368 | - [ ] **Step 2: Run to verify it fails** |
| 1369 | |
| 1370 | Run: `go test ./internal/console/ -run 'InstanceUpdateGate' -count=1` |
| 1371 | Expected: FAIL — PATCH answers 400 "nothing to update". |
| 1372 | |
| 1373 | - [ ] **Step 3: Implement** |
| 1374 | |
| 1375 | Record views. Add to `instanceListRecord` and `instanceGetRecord`, after the embedded `provision.Record`: |
| 1376 | |
| 1377 | ```go |
| 1378 | // HiddenGate shadows Record.Gate for encoding/json (a shallower field of |
| 1379 | // the same name wins), so the bcrypt hash never leaves the console; it |
| 1380 | // is always "" and omitted. Gated is what a member may know. |
| 1381 | HiddenGate string `json:"gate,omitempty"` |
| 1382 | Gated bool `json:"gated"` |
| 1383 | ``` |
| 1384 | |
| 1385 | Set `Gated: rec.Gate != ""` wherever each is constructed (list loop, GET). Add a view for the write replies: |
| 1386 | |
| 1387 | ```go |
| 1388 | // apiRecordView is a record as the write doors answer it: the whole record |
| 1389 | // with the gate hash shadowed and a gated flag in its place. Every handler |
| 1390 | // that used to writeJSON a bare record answers through this. |
| 1391 | type apiRecordView struct { |
| 1392 | provision.Record |
| 1393 | HiddenGate string `json:"gate,omitempty"` |
| 1394 | Gated bool `json:"gated"` |
| 1395 | } |
| 1396 | |
| 1397 | func recordView(rec provision.Record) apiRecordView { |
| 1398 | return apiRecordView{Record: rec, Gated: rec.Gate != ""} |
| 1399 | } |
| 1400 | ``` |
| 1401 | |
| 1402 | 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. |
| 1403 | |
| 1404 | PATCH. In the `req` struct add `Gate *string \`json:"gate"\`` with a comment: |
| 1405 | |
| 1406 | ```go |
| 1407 | // Gate is a *string like Steering: absent leaves the gate alone, |
| 1408 | // "" opens the host, anything else is the new password — hashed |
| 1409 | // here, never stored or echoed (spec §5.2). The plaintext crosses |
| 1410 | // the wire once, over TLS, the same as a sign-in. |
| 1411 | Gate *string `json:"gate"` |
| 1412 | ``` |
| 1413 | |
| 1414 | 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"`. |
| 1415 | |
| 1416 | In the mutation block, after the Environment case: |
| 1417 | |
| 1418 | ```go |
| 1419 | if req.Gate != nil { |
| 1420 | if *req.Gate == "" { |
| 1421 | rec.Gate = "" |
| 1422 | } else { |
| 1423 | if err := gate.CheckPassword(*req.Gate); err != nil { |
| 1424 | jsonError(w, http.StatusBadRequest, err.Error()) |
| 1425 | return |
| 1426 | } |
| 1427 | h, err := gate.Hash(*req.Gate) |
| 1428 | if err != nil { |
| 1429 | log.Printf("console: api instances update %s/%s/%s: hash gate: %v", acct.Sqid, app, host, err) |
| 1430 | jsonError(w, http.StatusInternalServerError, "could not set the password — try again") |
| 1431 | return |
| 1432 | } |
| 1433 | rec.Gate = h |
| 1434 | } |
| 1435 | } |
| 1436 | ``` |
| 1437 | |
| 1438 | 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") }`. |
| 1439 | |
| 1440 | Import `github.com/carlosframework/platform/internal/gate`. |
| 1441 | |
| 1442 | - [ ] **Step 4: Run the package tests** |
| 1443 | |
| 1444 | Run: `go test ./internal/console/ -count=1` |
| 1445 | Expected: PASS. |
| 1446 | |
| 1447 | - [ ] **Step 5: Commit** |
| 1448 | |
| 1449 | ```bash |
| 1450 | git add internal/console/apiinstances.go internal/console/apiinstances_test.go |
| 1451 | git commit -m "console: PATCH gate hashes the password; record replies carry gated, never the hash" |
| 1452 | ``` |
| 1453 | |
| 1454 | --- |
| 1455 | |
| 1456 | ### Task 6: Console Settings page — set or turn off the password (spec §5.5) |
| 1457 | |
| 1458 | **Files:** |
| 1459 | - Modify: `internal/console/appsettings.go` — `SettingsView` (line ~47), `assembleSettings` (line ~202); new `handleAppGate` in the same file. |
| 1460 | - Modify: `internal/console/server.go` — route table after `/place` (line ~1070). |
| 1461 | - Modify: `internal/console/templates/app_settings.html` — new section after "Domains & TLS"'s attach form. |
| 1462 | - Test: `internal/console/appsettings_gate_test.go` (new) |
| 1463 | |
| 1464 | **Interfaces:** |
| 1465 | - Consumes: `gate.CheckPassword`, `gate.Hash`; `provision.ListRecords`, `provision.GetRecordETag`, `provision.UpdateRecord`; `s.audit`, `s.setFlash`, `s.bumpSerial`, `s.accountApp`. |
| 1466 | - Produces: `POST /a/{sqid}/apps/{app}/gate` with form fields `csrf`, `host`, `action` (`set`|`clear`), `password`. |
| 1467 | |
| 1468 | - [ ] **Step 1: Write the failing test** |
| 1469 | |
| 1470 | `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): |
| 1471 | |
| 1472 | ```go |
| 1473 | package console |
| 1474 | |
| 1475 | import ( |
| 1476 | "context" |
| 1477 | "net/http" |
| 1478 | "net/url" |
| 1479 | "strings" |
| 1480 | "testing" |
| 1481 | |
| 1482 | "github.com/carlosframework/platform/internal/provision" |
| 1483 | ) |
| 1484 | |
| 1485 | func TestAppSettingsGateSetAndClear(t *testing.T) { |
| 1486 | fx, token := instanceFixture(t, "code-settings-gate") |
| 1487 | enableInstances(t, fx, "titogo", sharedPoolManifest()) |
| 1488 | if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), token, |
| 1489 | `{"host":"pw.go.tito.io","region":"ie","addrs":["127.0.0.1:7001"]}`); status != http.StatusCreated { |
| 1490 | t.Fatalf("create = %d %s", status, out) |
| 1491 | } |
| 1492 | c := newBrowser(t) |
| 1493 | signUp(t, fx, c, "code-settings-gate-browser") |
| 1494 | csrf := sessionToken(t, fx, c) |
| 1495 | |
| 1496 | // The page shows the host as open, with a form. |
| 1497 | _, _, body := get(t, c, fx.u("/apps/titogo/settings")) |
| 1498 | if !strings.Contains(body, "Password gate") || !strings.Contains(body, "pw.go.tito.io") || !strings.Contains(body, "Open") { |
| 1499 | t.Fatalf("settings page lacks the gate section: %s", body) |
| 1500 | } |
| 1501 | |
| 1502 | status, loc, _ := postForm(t, c, fx.u("/apps/titogo/gate"), |
| 1503 | url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"correct horse"}}) |
| 1504 | if status != http.StatusSeeOther || loc != fx.path("/apps/titogo/settings") { |
| 1505 | t.Fatalf("set = %d -> %q", status, loc) |
| 1506 | } |
| 1507 | rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| 1508 | if err != nil { |
| 1509 | t.Fatal(err) |
| 1510 | } |
| 1511 | if !strings.HasPrefix(rec.Gate, "$2a$10$") { |
| 1512 | t.Fatalf("stored gate = %q", rec.Gate) |
| 1513 | } |
| 1514 | _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| 1515 | if !strings.Contains(body, "Password set on pw.go.tito.io.") || !strings.Contains(body, "Turn off") { |
| 1516 | t.Fatalf("after set: %s", body) |
| 1517 | } |
| 1518 | if strings.Contains(body, "$2a$") || strings.Contains(body, "correct horse") { |
| 1519 | t.Fatal("page leaks the hash or the password") |
| 1520 | } |
| 1521 | |
| 1522 | // A short password is refused with the rule, and nothing changes. |
| 1523 | postForm(t, c, fx.u("/apps/titogo/gate"), |
| 1524 | url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"set"}, "password": {"short"}}) |
| 1525 | _, _, body = get(t, c, fx.u("/apps/titogo/settings")) |
| 1526 | if !strings.Contains(body, "at least 8 characters") { |
| 1527 | t.Fatalf("short password not refused on the page: %s", body) |
| 1528 | } |
| 1529 | |
| 1530 | status, _, _ = postForm(t, c, fx.u("/apps/titogo/gate"), |
| 1531 | url.Values{"csrf": {csrf}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| 1532 | if status != http.StatusSeeOther { |
| 1533 | t.Fatalf("clear = %d", status) |
| 1534 | } |
| 1535 | rec, _ = provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "pw.go.tito.io") |
| 1536 | if rec.Gate != "" { |
| 1537 | t.Fatalf("gate after clear = %q", rec.Gate) |
| 1538 | } |
| 1539 | events, _ := fx.st.Events(OpsAccountID, "titogo", 20) |
| 1540 | var saw int |
| 1541 | for _, e := range events { |
| 1542 | if e.Action == "gate" { |
| 1543 | saw++ |
| 1544 | if strings.Contains(e.Detail, "correct horse") { |
| 1545 | t.Fatal("audit carries the password") |
| 1546 | } |
| 1547 | } |
| 1548 | } |
| 1549 | if saw != 2 { |
| 1550 | t.Fatalf("gate audit rows = %d, want 2", saw) |
| 1551 | } |
| 1552 | } |
| 1553 | |
| 1554 | func TestAppSettingsGateNeedsCSRF(t *testing.T) { |
| 1555 | fx, _ := instanceFixture(t, "code-settings-gate-csrf") |
| 1556 | c := newBrowser(t) |
| 1557 | signUp(t, fx, c, "code-settings-gate-csrf-browser") |
| 1558 | status, _, body := postForm(t, c, fx.u("/apps/titogo/gate"), |
| 1559 | url.Values{"csrf": {"not-the-token"}, "host": {"pw.go.tito.io"}, "action": {"clear"}}) |
| 1560 | if status != http.StatusForbidden || !strings.Contains(body, "Cross-site request blocked.") { |
| 1561 | t.Fatalf("got %d %s", status, body) |
| 1562 | } |
| 1563 | } |
| 1564 | ``` |
| 1565 | |
| 1566 | `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. |
| 1567 | |
| 1568 | - [ ] **Step 2: Run to verify it fails** |
| 1569 | |
| 1570 | Run: `go test ./internal/console/ -run 'AppSettingsGate' -count=1` |
| 1571 | Expected: FAIL — 404 on the POST, no section on the page. |
| 1572 | |
| 1573 | - [ ] **Step 3: Implement** |
| 1574 | |
| 1575 | `SettingsView` gains: |
| 1576 | |
| 1577 | ```go |
| 1578 | // Gates is the password-gate section (spec §5.5): one row per instance |
| 1579 | // record, with whether a password is set. GatesUnavailable carries the |
| 1580 | // reason when the records could not be read (no bucket, a store error) |
| 1581 | // and the section says so instead of rendering an empty list as "no |
| 1582 | // instances". |
| 1583 | Gates []GateRow |
| 1584 | GatesUnavailable string |
| 1585 | ``` |
| 1586 | |
| 1587 | ```go |
| 1588 | // GateRow is one instance on the Settings page's password-gate section. |
| 1589 | type GateRow struct { |
| 1590 | Host string |
| 1591 | Gated bool |
| 1592 | } |
| 1593 | ``` |
| 1594 | |
| 1595 | In `assembleSettings`, before the `if s.cfg.Releases == nil` branch (so it runs regardless of what that branch does with RenameBlocked), add: |
| 1596 | |
| 1597 | ```go |
| 1598 | sv.Gates, sv.GatesUnavailable = s.settingsGates(ctx, acct, p.AppName) |
| 1599 | ``` |
| 1600 | |
| 1601 | and: |
| 1602 | |
| 1603 | ```go |
| 1604 | // settingsGates lists the app's instance records for the password-gate |
| 1605 | // section. The hash never reaches the template: GateRow carries a bool. |
| 1606 | func (s *Server) settingsGates(ctx context.Context, acct *Account, app string) ([]GateRow, string) { |
| 1607 | if s.cfg.Releases == nil { |
| 1608 | return nil, "No deployment bucket on this host, so instance records cannot be read." |
| 1609 | } |
| 1610 | recs, _, err := provision.ListRecords(ctx, s.cfg.Releases, acct.Sqid, app, instanceListMax) |
| 1611 | if err != nil { |
| 1612 | log.Printf("console: settings gates %s/%s: %v", acct.Sqid, app, err) |
| 1613 | return nil, "Instance records could not be read just now." |
| 1614 | } |
| 1615 | rows := make([]GateRow, 0, len(recs)) |
| 1616 | for _, rec := range recs { |
| 1617 | rows = append(rows, GateRow{Host: rec.Host, Gated: rec.Gate != ""}) |
| 1618 | } |
| 1619 | sort.Slice(rows, func(i, j int) bool { return rows[i].Host < rows[j].Host }) |
| 1620 | return rows, "" |
| 1621 | } |
| 1622 | ``` |
| 1623 | |
| 1624 | Handler, in `appsettings.go`: |
| 1625 | |
| 1626 | ```go |
| 1627 | // handleAppGate is the Settings page's password-gate form: set a password |
| 1628 | // on one instance host, or turn it off. Member-gated, matching the API |
| 1629 | // PATCH's door — the CLI already lets a member do this, and a form that |
| 1630 | // refuses what the CLI allows is a rule in the wrong place. CSRF first, |
| 1631 | // then the same read-mutate-validate-write-audit sequence as the PATCH, |
| 1632 | // against one field. The password is never logged, audited or echoed. |
| 1633 | func (s *Server) handleAppGate(w http.ResponseWriter, r *http.Request) { |
| 1634 | addr, raw, acct, _, app, ok := s.accountApp(w, r) |
| 1635 | if !ok { |
| 1636 | return |
| 1637 | } |
| 1638 | if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| 1639 | s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| 1640 | return |
| 1641 | } |
| 1642 | back := func(kind, msg string) { |
| 1643 | s.setFlash(w, kind, msg) |
| 1644 | http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", |
| 1645 | http.StatusSeeOther) |
| 1646 | } |
| 1647 | if s.cfg.Releases == nil { |
| 1648 | back("err", "No deployment bucket on this host, so the gate cannot be changed here.") |
| 1649 | return |
| 1650 | } |
| 1651 | host := strings.TrimSpace(r.FormValue("host")) |
| 1652 | if !provision.ValidHostShape(host) { |
| 1653 | http.NotFound(w, r) |
| 1654 | return |
| 1655 | } |
| 1656 | var hash string |
| 1657 | switch r.FormValue("action") { |
| 1658 | case "clear": |
| 1659 | case "set": |
| 1660 | p := r.FormValue("password") |
| 1661 | if err := gate.CheckPassword(p); err != nil { |
| 1662 | back("err", err.Error()+".") |
| 1663 | return |
| 1664 | } |
| 1665 | h, err := gate.Hash(p) |
| 1666 | if err != nil { |
| 1667 | log.Printf("console: gate hash %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 1668 | back("err", "Could not set the password — try again.") |
| 1669 | return |
| 1670 | } |
| 1671 | hash = h |
| 1672 | default: |
| 1673 | back("err", "Choose set or clear.") |
| 1674 | return |
| 1675 | } |
| 1676 | ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout) |
| 1677 | defer cancel() |
| 1678 | rec, etag, err := provision.GetRecordETag(ctx, s.cfg.Releases, acct.Sqid, app, host) |
| 1679 | if errors.Is(err, os.ErrNotExist) { |
| 1680 | http.NotFound(w, r) |
| 1681 | return |
| 1682 | } |
| 1683 | if err != nil { |
| 1684 | log.Printf("console: gate read %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 1685 | back("err", "Could not read the instance — try again.") |
| 1686 | return |
| 1687 | } |
| 1688 | rec.Gate = hash |
| 1689 | if err := provision.ValidateShape(*rec); err != nil { |
| 1690 | back("err", err.Error()+".") |
| 1691 | return |
| 1692 | } |
| 1693 | if err := provision.UpdateRecord(ctx, s.cfg.Releases, *rec, etag); err != nil { |
| 1694 | if errors.Is(err, release.ErrCASConflict) { |
| 1695 | back("err", "The instance changed while this request was in flight — try again.") |
| 1696 | return |
| 1697 | } |
| 1698 | log.Printf("console: gate write %s/%s/%s: %v", acct.Sqid, app, host, err) |
| 1699 | back("err", "Could not update the instance — try again.") |
| 1700 | return |
| 1701 | } |
| 1702 | if hash == "" { |
| 1703 | s.audit(acct, app, addr, "gate", "cleared password gate on "+host+" (console)") |
| 1704 | s.bumpSerial(ctx) |
| 1705 | back("ok", "Password turned off on "+host+".") |
| 1706 | return |
| 1707 | } |
| 1708 | s.audit(acct, app, addr, "gate", "set password gate on "+host+" (console)") |
| 1709 | s.bumpSerial(ctx) |
| 1710 | back("ok", "Password set on "+host+".") |
| 1711 | } |
| 1712 | ``` |
| 1713 | |
| 1714 | Route table, after the `/place` line: `s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)`. |
| 1715 | |
| 1716 | Template, inserted after the "Attach a custom domain" block's closing `{{end}}` and before `{{if and .Settings .Settings.MismatchTarget}}`: |
| 1717 | |
| 1718 | ```html |
| 1719 | <h2>Password gate</h2> |
| 1720 | <p class="small">One shared password in front of a hostname — for a static |
| 1721 | site or a prototype that should not be world-readable. Visitors see a plain |
| 1722 | page asking for it and stay signed in for a week. It is set per instance.</p> |
| 1723 | {{if .Settings.GatesUnavailable}}<p class="small">{{.Settings.GatesUnavailable}}</p> |
| 1724 | {{else if .Settings.Gates}} |
| 1725 | <div class="tablewrap"> |
| 1726 | <table> |
| 1727 | <tr><th>Host</th><th>State</th><th></th></tr> |
| 1728 | {{range $i, $g := .Settings.Gates}} |
| 1729 | <tr> |
| 1730 | <td><code>{{$g.Host}}</code></td> |
| 1731 | <td>{{if $g.Gated}}Password set{{else}}Open{{end}}</td> |
| 1732 | <td> |
| 1733 | <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| 1734 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| 1735 | <input type="hidden" name="host" value="{{$g.Host}}"> |
| 1736 | <input type="hidden" name="action" value="set"> |
| 1737 | <label class="visually-hidden" for="gate-pw-{{$i}}">Password for {{$g.Host}}</label> |
| 1738 | <input type="password" id="gate-pw-{{$i}}" name="password" minlength="8" maxlength="72" |
| 1739 | autocomplete="new-password" placeholder="password" required> |
| 1740 | <button type="submit" class="btn-sm">{{if $g.Gated}}Change{{else}}Set password{{end}}</button> |
| 1741 | </form> |
| 1742 | {{if $g.Gated}} |
| 1743 | <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/gate" class="settings-row"> |
| 1744 | <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| 1745 | <input type="hidden" name="host" value="{{$g.Host}}"> |
| 1746 | <input type="hidden" name="action" value="clear"> |
| 1747 | <button type="submit" class="btn-sm btn-danger">Turn off</button> |
| 1748 | </form> |
| 1749 | {{end}} |
| 1750 | </td> |
| 1751 | </tr> |
| 1752 | {{end}} |
| 1753 | </table> |
| 1754 | </div> |
| 1755 | {{else}}<p class="small">No instances yet. A password gate is set per instance.</p>{{end}} |
| 1756 | ``` |
| 1757 | |
| 1758 | The section is inside no `{{if .IsOwner}}` — members see it, matching the handler. |
| 1759 | |
| 1760 | - [ ] **Step 4: Run the package tests** |
| 1761 | |
| 1762 | Run: `go test ./internal/console/ -count=1` |
| 1763 | Expected: PASS. |
| 1764 | |
| 1765 | - [ ] **Step 5: Commit** |
| 1766 | |
| 1767 | ```bash |
| 1768 | git add internal/console/appsettings.go internal/console/server.go internal/console/templates/app_settings.html internal/console/appsettings_gate_test.go |
| 1769 | git commit -m "console: password gate section on app Settings — set, change, turn off" |
| 1770 | ``` |
| 1771 | |
| 1772 | --- |
| 1773 | |
| 1774 | ### Task 7: `carlos gate` and the docs (spec §5.3, §6) |
| 1775 | |
| 1776 | **Files:** |
| 1777 | - Create: `gate_cli.go`, `gate_cli_test.go` |
| 1778 | - Modify: `apimode.go` — `apiInstanceRecord` (line ~2485, add `Gated`), new `instanceSetGate` beside `instanceSetSteering` (line ~2699). |
| 1779 | - Modify: `main.go` — `case "gate":` after `features` (line ~60); the `usage` text (line ~195, one line beside `features`). |
| 1780 | - Modify: `docs/site/cli.md` — new `### carlos gate` after `### carlos features`; one sentence in `### carlos instances`. |
| 1781 | |
| 1782 | **Interfaces:** |
| 1783 | - Consumes: PATCH `gate` (Task 5); `instanceList`, `patchJSON`, `apiPath`, `resolvedAccount`, `resolvedApp`, `printUsing`, `mutedf`, `appFlag`, `newTable`, `colorFor` (existing). |
| 1784 | - Produces: `carlos gate set|clear|list`. |
| 1785 | |
| 1786 | - [ ] **Step 1: Write the failing tests** |
| 1787 | |
| 1788 | `gate_cli_test.go`: |
| 1789 | |
| 1790 | ```go |
| 1791 | package main |
| 1792 | |
| 1793 | import ( |
| 1794 | "strings" |
| 1795 | "testing" |
| 1796 | ) |
| 1797 | |
| 1798 | func TestGateNeedsASubVerb(t *testing.T) { |
| 1799 | out, err := runCarlosCLI(t, "gate") |
| 1800 | if err == nil { |
| 1801 | t.Fatalf("bare `gate` exited 0: %s", out) |
| 1802 | } |
| 1803 | for _, w := range []string{"set", "clear", "list"} { |
| 1804 | if !strings.Contains(out, w) { |
| 1805 | t.Fatalf("usage should name %s: %s", w, out) |
| 1806 | } |
| 1807 | } |
| 1808 | } |
| 1809 | |
| 1810 | func TestGateSetRefusesAnEmptyPasswordBeforeTheNetwork(t *testing.T) { |
| 1811 | out, err := runCarlosCLI(t, "gate", "set", "-app", "site", "-password", "") |
| 1812 | if err == nil || !strings.Contains(out, "password") { |
| 1813 | t.Fatalf("empty -password: err=%v out=%s", err, out) |
| 1814 | } |
| 1815 | } |
| 1816 | |
| 1817 | func TestReadGatePassword(t *testing.T) { |
| 1818 | if got, err := readGatePassword("flagged", strings.NewReader("")); err != nil || got != "flagged" { |
| 1819 | t.Fatalf("flag: %q %v", got, err) |
| 1820 | } |
| 1821 | if got, err := readGatePassword("", strings.NewReader("from stdin\n")); err != nil || got != "from stdin" { |
| 1822 | t.Fatalf("stdin: %q %v", got, err) |
| 1823 | } |
| 1824 | if _, err := readGatePassword("", strings.NewReader("\n")); err == nil { |
| 1825 | t.Fatal("empty stdin line accepted") |
| 1826 | } |
| 1827 | } |
| 1828 | ``` |
| 1829 | |
| 1830 | `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. |
| 1831 | |
| 1832 | - [ ] **Step 2: Run to verify it fails** |
| 1833 | |
| 1834 | Run: `go test . -run 'Gate' -count=1` |
| 1835 | Expected: compile error, `readGatePassword` undefined. |
| 1836 | |
| 1837 | - [ ] **Step 3: Implement** |
| 1838 | |
| 1839 | `apimode.go`: add to `apiInstanceRecord`: |
| 1840 | |
| 1841 | ```go |
| 1842 | // Gated says whether the host has a password gate (spec |
| 1843 | // 2026-09-07-password-gate-design §5.4). The hash itself never leaves |
| 1844 | // the console. False from a console that predates the field. |
| 1845 | Gated bool `json:"gated"` |
| 1846 | ``` |
| 1847 | |
| 1848 | and beside `instanceSetSteering`: |
| 1849 | |
| 1850 | ```go |
| 1851 | // instanceSetGate sets ("" clears) one instance's password gate through |
| 1852 | // the console, which hashes it; the plaintext crosses the wire once, over |
| 1853 | // TLS. A console older than the field answers 400 "nothing to update", |
| 1854 | // which is reported as exactly that. |
| 1855 | func (s *apiSession) instanceSetGate(ctx context.Context, a apiAcct, app, host, password string) (apiInstanceRecord, error) { |
| 1856 | in := struct { |
| 1857 | Gate string `json:"gate"` |
| 1858 | }{password} |
| 1859 | status, body, err := s.patchJSON(ctx, apiPath(a, app, "/instances/"+url.PathEscape(host)), in) |
| 1860 | if err != nil { |
| 1861 | return apiInstanceRecord{}, err |
| 1862 | } |
| 1863 | if status != http.StatusOK { |
| 1864 | if status == http.StatusBadRequest && strings.Contains(string(body), "nothing to update") { |
| 1865 | return apiInstanceRecord{}, fmt.Errorf("this console does not know about password gates yet — it needs a newer carlos") |
| 1866 | } |
| 1867 | return apiInstanceRecord{}, apiErrorFromBody(status, body) |
| 1868 | } |
| 1869 | var out apiInstanceRecord |
| 1870 | if err := json.Unmarshal(body, &out); err != nil { |
| 1871 | return apiInstanceRecord{}, fmt.Errorf("gate: unreadable response: %w", err) |
| 1872 | } |
| 1873 | return out, nil |
| 1874 | } |
| 1875 | ``` |
| 1876 | |
| 1877 | `gate_cli.go`: |
| 1878 | |
| 1879 | ```go |
| 1880 | // The `carlos gate` verb family: one shared password in front of an |
| 1881 | // instance's hostname, for a static site or a prototype that should not be |
| 1882 | // world-readable (spec docs/superpowers/specs/2026-09-07-password-gate-design.md). |
| 1883 | // API mode only, like `carlos features` and for the same reason: setting a |
| 1884 | // gate is a console-authorized write with an audit row. |
| 1885 | package main |
| 1886 | |
| 1887 | import ( |
| 1888 | "bufio" |
| 1889 | "context" |
| 1890 | "errors" |
| 1891 | "flag" |
| 1892 | "fmt" |
| 1893 | "io" |
| 1894 | "log" |
| 1895 | "os" |
| 1896 | "strings" |
| 1897 | ) |
| 1898 | |
| 1899 | const gateUsage = `usage: carlos gate <set|clear|list> [flags] |
| 1900 | |
| 1901 | set -app <a> [-host <h>] [-password <p>] |
| 1902 | put a password in front of the app's instances (or one host); |
| 1903 | without -password, one line is read from stdin |
| 1904 | clear -app <a> [-host <h>] |
| 1905 | turn the password off |
| 1906 | list -app <a> which hosts have a password set |
| 1907 | |
| 1908 | -account <a> account (sqid or name; default "ops") |
| 1909 | -account-id <id> account sqid, matched as a sqid ONLY |
| 1910 | -console <origin> console to act through (default: the only logged-in one) |
| 1911 | |
| 1912 | Signed-in only (carlos auth login). Visitors to a gated host see a plain |
| 1913 | page asking for the password and stay signed in for a week; changing or |
| 1914 | clearing the password signs everyone out at once. The box picks the change |
| 1915 | up within seconds of the next converge tick. |
| 1916 | ` |
| 1917 | |
| 1918 | func runGate(args []string) { |
| 1919 | if len(args) > 0 { |
| 1920 | switch args[0] { |
| 1921 | case "set": |
| 1922 | runGateSet(args[1:]) |
| 1923 | return |
| 1924 | case "clear": |
| 1925 | runGateClear(args[1:]) |
| 1926 | return |
| 1927 | case "list": |
| 1928 | runGateList(args[1:]) |
| 1929 | return |
| 1930 | } |
| 1931 | } |
| 1932 | log.Fatal(gateUsage) |
| 1933 | } |
| 1934 | |
| 1935 | func requireGateAPISession(consoleFlag string) *apiSession { |
| 1936 | sess, err := resolveMode(consoleFlag) |
| 1937 | if err != nil { |
| 1938 | log.Fatal(err) |
| 1939 | } |
| 1940 | if sess == nil { |
| 1941 | log.Fatal("carlos gate works through a console, and this shell is in direct bucket mode\n" + |
| 1942 | "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A password gate is a console write with an\n" + |
| 1943 | "audit row. Unset the store env and run: carlos auth login -console <url>.") |
| 1944 | } |
| 1945 | return sess |
| 1946 | } |
| 1947 | |
| 1948 | // readGatePassword is the flag, else one line from stdin — so a password |
| 1949 | // can be piped or typed without landing in shell history or ps output. An |
| 1950 | // empty line is refused; the console's own length rule is applied there. |
| 1951 | func readGatePassword(flagged string, stdin io.Reader) (string, error) { |
| 1952 | if flagged != "" { |
| 1953 | return flagged, nil |
| 1954 | } |
| 1955 | line, err := bufio.NewReader(stdin).ReadString('\n') |
| 1956 | if err != nil && !errors.Is(err, io.EOF) { |
| 1957 | return "", err |
| 1958 | } |
| 1959 | line = strings.TrimRight(line, "\r\n") |
| 1960 | if line == "" { |
| 1961 | return "", errors.New("no password given — pass -password or pipe one line on stdin") |
| 1962 | } |
| 1963 | return line, nil |
| 1964 | } |
| 1965 | |
| 1966 | // gateHosts resolves the hosts a set/clear applies to: -host alone, else |
| 1967 | // every instance record of the app. |
| 1968 | func gateHosts(sess *apiSession, acct apiAcct, app, host string) []string { |
| 1969 | if host != "" { |
| 1970 | return []string{host} |
| 1971 | } |
| 1972 | recs, _, _, err := sess.instanceList(context.Background(), acct, app, "", "") |
| 1973 | if err != nil { |
| 1974 | log.Fatalf("gate: list instances: %v", err) |
| 1975 | } |
| 1976 | if len(recs) == 0 { |
| 1977 | log.Fatalf("gate: %s has no instances — a gate is set per instance; create one first (carlos instances create)", app) |
| 1978 | } |
| 1979 | hosts := make([]string, 0, len(recs)) |
| 1980 | for _, r := range recs { |
| 1981 | hosts = append(hosts, r.Host) |
| 1982 | } |
| 1983 | return hosts |
| 1984 | } |
| 1985 | |
| 1986 | func gateFlags(name string) (*flag.FlagSet, *string, *string, *string, *string, *string) { |
| 1987 | fs := flag.NewFlagSet("gate "+name, flag.ExitOnError) |
| 1988 | app := appFlag(fs, "app whose instances to gate") |
| 1989 | host := fs.String("host", "", "one instance host (default: every instance of the app)") |
| 1990 | consoleFlag := fs.String("console", "", "console to act through (default: the only logged-in one)") |
| 1991 | acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)") |
| 1992 | acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account") |
| 1993 | return fs, app, host, consoleFlag, acctRaw, acctIDRaw |
| 1994 | } |
| 1995 | |
| 1996 | func runGateSet(args []string) { |
| 1997 | fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("set") |
| 1998 | pw := fs.String("password", "", "the password (default: one line from stdin)") |
| 1999 | passwordGiven := false |
| 2000 | fs.Parse(args) |
| 2001 | fs.Visit(func(f *flag.Flag) { |
| 2002 | if f.Name == "password" { |
| 2003 | passwordGiven = true |
| 2004 | } |
| 2005 | }) |
| 2006 | if passwordGiven && *pw == "" { |
| 2007 | log.Fatal("gate set: -password is empty — give a password of 8 to 72 characters") |
| 2008 | } |
| 2009 | password, err := readGatePassword(*pw, os.Stdin) |
| 2010 | if err != nil { |
| 2011 | log.Fatalf("gate set: %v", err) |
| 2012 | } |
| 2013 | sess := requireGateAPISession(*consoleFlag) |
| 2014 | printUsing(stdout, sess) |
| 2015 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 2016 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 2017 | failed := 0 |
| 2018 | for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) { |
| 2019 | if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, password); err != nil { |
| 2020 | failed++ |
| 2021 | fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not set on"), h, err) |
| 2022 | continue |
| 2023 | } |
| 2024 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password set on"), h) |
| 2025 | } |
| 2026 | mutedf(stdout, " the box picks it up within seconds of the next converge tick; visitors already signed in are signed out") |
| 2027 | if failed > 0 { |
| 2028 | os.Exit(1) |
| 2029 | } |
| 2030 | } |
| 2031 | |
| 2032 | func runGateClear(args []string) { |
| 2033 | fs, app, host, consoleFlag, acctRaw, acctIDRaw := gateFlags("clear") |
| 2034 | fs.Parse(args) |
| 2035 | sess := requireGateAPISession(*consoleFlag) |
| 2036 | printUsing(stdout, sess) |
| 2037 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 2038 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 2039 | failed := 0 |
| 2040 | for _, h := range gateHosts(sess, acct, app2, strings.TrimSpace(*host)) { |
| 2041 | if _, err := sess.instanceSetGate(context.Background(), acct, app2, h, ""); err != nil { |
| 2042 | failed++ |
| 2043 | fmt.Fprintf(stdout, "%s %s: %v\n", colorFor(stdout).bad("gate not cleared on"), h, err) |
| 2044 | continue |
| 2045 | } |
| 2046 | fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("password turned off on"), h) |
| 2047 | } |
| 2048 | if failed > 0 { |
| 2049 | os.Exit(1) |
| 2050 | } |
| 2051 | } |
| 2052 | |
| 2053 | func runGateList(args []string) { |
| 2054 | fs, app, _, consoleFlag, acctRaw, acctIDRaw := gateFlags("list") |
| 2055 | fs.Parse(args) |
| 2056 | sess := requireGateAPISession(*consoleFlag) |
| 2057 | printUsing(stdout, sess) |
| 2058 | acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| 2059 | app2 := resolvedApp(sess, acct, *app, gateUsage) |
| 2060 | recs, _, _, err := sess.instanceList(context.Background(), acct, app2, "", "") |
| 2061 | if err != nil { |
| 2062 | log.Fatalf("gate list: %v", err) |
| 2063 | } |
| 2064 | if len(recs) == 0 { |
| 2065 | fmt.Fprintln(stdout, "no instances") |
| 2066 | return |
| 2067 | } |
| 2068 | w := newTable(stdout) |
| 2069 | fmt.Fprintln(w, "HOST\tGATE") |
| 2070 | for _, r := range recs { |
| 2071 | state := "open" |
| 2072 | if r.Gated { |
| 2073 | state = "password set" |
| 2074 | } |
| 2075 | fmt.Fprintf(w, "%s\t%s\n", r.Host, state) |
| 2076 | } |
| 2077 | w.Flush() |
| 2078 | } |
| 2079 | ``` |
| 2080 | |
| 2081 | `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`). |
| 2082 | |
| 2083 | `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). |
| 2084 | |
| 2085 | `docs/site/cli.md`: after the `### carlos features` section (before `### carlos releases`): |
| 2086 | |
| 2087 | ```markdown |
| 2088 | ### carlos gate |
| 2089 | |
| 2090 | Puts one shared password in front of an app's hostnames — for a static site |
| 2091 | or a prototype that should not be world-readable. Visitors see a plain page |
| 2092 | asking for the password and stay signed in for a week; the app behind it |
| 2093 | never sees the cookie. |
| 2094 | |
| 2095 | ```sh |
| 2096 | carlos gate set --app site |
| 2097 | carlos gate list --app site |
| 2098 | carlos gate clear --app site |
| 2099 | ``` |
| 2100 | |
| 2101 | `set` reads the password from `--password` or, without it, one line from |
| 2102 | stdin, so it need not land in your shell history. Without `--host` the |
| 2103 | change applies to every instance of the app; name one to narrow it. Changing |
| 2104 | or clearing the password signs every visitor out at once. Custom domains |
| 2105 | attached to the app are gated along with the platform hostname. This is a |
| 2106 | curtain in front of the whole hostname, not accounts for the app's own |
| 2107 | users; an app with real sign-in keeps doing that itself. |
| 2108 | ``` |
| 2109 | |
| 2110 | In `### carlos instances`, append one sentence to its first paragraph: `A password in front of an instance's hostname is `carlos gate`.` |
| 2111 | |
| 2112 | - [ ] **Step 4: Run the tests and the docs gate** |
| 2113 | |
| 2114 | Run: `go test . -run 'Gate' -count=1 && go test ./internal/docsite/ -count=1 && go build ./... && go vet ./... && gofmt -l .` |
| 2115 | Expected: PASS, no unformatted files. |
| 2116 | |
| 2117 | - [ ] **Step 5: Commit** |
| 2118 | |
| 2119 | ```bash |
| 2120 | git add gate_cli.go gate_cli_test.go apimode.go main.go docs/site/cli.md |
| 2121 | git commit -m "carlos gate: set, clear and list an app's password gates; documented" |
| 2122 | ``` |
| 2123 | |
| 2124 | --- |
| 2125 | |
| 2126 | ### Task 8: Whole-tree verification |
| 2127 | |
| 2128 | - [ ] **Step 1:** `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l .` — all clean. |
| 2129 | - [ ] **Step 2:** `GOOS=darwin go build ./... && GOOS=windows go build ./... && GOOS=linux GOARCH=arm64 go build ./...` — the cross-compile gate. |
| 2130 | - [ ] **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. |
| 2131 | |