carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

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
37Append 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.
44func 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
73func 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.
83func 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
104func 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
117Also 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
121Run: `go test ./internal/registry/ -run 'Gate|UpsertRoundTrips' -count=1`
122Expected: compile error, `Route` has no field `Gate`.
123
124- [ ] **Step 3: Implement**
125
126In `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
140Migration 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
150New 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.
157func (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
173Search 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
177Run: `go test ./internal/registry/ -count=1`
178Expected: PASS, including `TestUpsertRoundTripsAllColumns`.
179
180- [ ] **Step 5: Commit**
181
182```bash
183git add internal/registry/registry.go internal/registry/registry_test.go
184git 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
202In `internal/provision/provision_test.go` add:
203
204```go
205func 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
217In `internal/provision/reconcile_test.go` add (mirrors `TestReconcileConvergesSteering`):
218
219```go
220func 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
267Run: `go test ./internal/provision/ -run 'Gate' -count=1`
268Expected: 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
297Import `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
313and 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.
318func gateWord(hash string) string {
319 if hash == "" {
320 return "open"
321 }
322 return "set"
323}
324```
325
326Also 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
330Run: `go test ./internal/provision/ -count=1`
331Expected: PASS.
332
333- [ ] **Step 5: Commit**
334
335```bash
336git add internal/provision/
337git 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
361package gate
362
363import (
364 "net/http"
365 "net/http/httptest"
366 "net/url"
367 "strings"
368 "testing"
369 "time"
370)
371
372func 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
384func 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
397func 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
417func 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
437func 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
443func 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
480func 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
490func 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
524func 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
535func 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
558func 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
571Run: `go test ./internal/gate/ -count=1`
572Expected: 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.
589package gate
590
591import (
592 "fmt"
593 "net"
594 "net/http"
595 "strings"
596 "time"
597
598 "golang.org/x/crypto/bcrypt"
599)
600
601const (
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.
619func 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).
632func 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
643func 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.
649type 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.
656func 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).
670func (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
683func (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 "/".
713func 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).
723func 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.
732func 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
750package gate
751
752import (
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
769const cookieVersion = "v1"
770
771func 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
777func 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.
786func 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.
794func 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
814package gate
815
816import (
817 "sync"
818 "time"
819)
820
821const (
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.
831type Limiter struct {
832 mu sync.Mutex
833 now func() time.Time
834 buckets map[string]*bucket
835}
836
837type bucket struct {
838 tokens float64
839 last time.Time
840}
841
842func 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.
847func (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.
871func (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
883package gate
884
885import (
886 _ "embed"
887 "html/template"
888 "log"
889 "net/http"
890)
891
892//go:embed login.html
893var loginHTML string
894
895var loginTmpl = template.Must(template.New("login").Parse(loginHTML))
896
897const (
898 msgWrong = "That password is not right."
899 msgTooMany = "Too many attempts. Try again in a minute."
900)
901
902type 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.
911func 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; }
948body {
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}
953main { width: 100%; max-width: 22rem; }
954h1 { font-size: 1.25rem; margin-bottom: 0.5rem; }
955p { color: var(--muted); margin-bottom: 1.25rem; }
956p code { color: var(--ink); font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.95em; }
957label { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.35rem; }
958input[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}
962input[type=password]:focus { outline: 2px solid var(--accent); outline-offset: 1px; border-color: var(--accent); }
963button {
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
988Run: `go test ./internal/gate/ -count=1 && go vet ./internal/gate/`
989Expected: PASS.
990
991- [ ] **Step 5: Commit**
992
993```bash
994git add internal/gate/
995git 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
1014package edge
1015
1016import (
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
1029func 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.
1054func 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
1065func 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
1081func 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
1099func 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
1112func 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
1125func 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
1151func 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
1165func 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
1190func 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
1213Run: `go test ./internal/edge/ -run 'Gated|Ungated' -count=1`
1214Expected: 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
1247Import `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
1251Run: `go test ./internal/edge/ -count=1`
1252Expected: PASS, every pre-existing test included.
1253
1254- [ ] **Step 5: Commit**
1255
1256```bash
1257git add internal/edge/edge.go internal/edge/gate_test.go
1258git 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
1275Append to `internal/console/apiinstances_test.go`:
1276
1277```go
1278func 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
1341func 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
1366Also 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
1370Run: `go test ./internal/console/ -run 'InstanceUpdateGate' -count=1`
1371Expected: FAIL — PATCH answers 400 "nothing to update".
1372
1373- [ ] **Step 3: Implement**
1374
1375Record 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
1385Set `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.
1391type apiRecordView struct {
1392 provision.Record
1393 HiddenGate string `json:"gate,omitempty"`
1394 Gated bool `json:"gated"`
1395}
1396
1397func recordView(rec provision.Record) apiRecordView {
1398 return apiRecordView{Record: rec, Gated: rec.Gate != ""}
1399}
1400```
1401
1402Change `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
1404PATCH. 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
1414Extend 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
1416In 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
1438In 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
1440Import `github.com/carlosframework/platform/internal/gate`.
1441
1442- [ ] **Step 4: Run the package tests**
1443
1444Run: `go test ./internal/console/ -count=1`
1445Expected: PASS.
1446
1447- [ ] **Step 5: Commit**
1448
1449```bash
1450git add internal/console/apiinstances.go internal/console/apiinstances_test.go
1451git 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
1473package console
1474
1475import (
1476 "context"
1477 "net/http"
1478 "net/url"
1479 "strings"
1480 "testing"
1481
1482 "github.com/carlosframework/platform/internal/provision"
1483)
1484
1485func 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
1554func 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
1570Run: `go test ./internal/console/ -run 'AppSettingsGate' -count=1`
1571Expected: 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.
1589type GateRow struct {
1590 Host string
1591 Gated bool
1592}
1593```
1594
1595In `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
1601and:
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.
1606func (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
1624Handler, 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.
1633func (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
1714Route table, after the `/place` line: `s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/gate", s.handleAppGate)`.
1715
1716Template, 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
1721site or a prototype that should not be world-readable. Visitors see a plain
1722page 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
1758The section is inside no `{{if .IsOwner}}` — members see it, matching the handler.
1759
1760- [ ] **Step 4: Run the package tests**
1761
1762Run: `go test ./internal/console/ -count=1`
1763Expected: PASS.
1764
1765- [ ] **Step 5: Commit**
1766
1767```bash
1768git add internal/console/appsettings.go internal/console/server.go internal/console/templates/app_settings.html internal/console/appsettings_gate_test.go
1769git 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
1791package main
1792
1793import (
1794 "strings"
1795 "testing"
1796)
1797
1798func 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
1810func 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
1817func 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
1834Run: `go test . -run 'Gate' -count=1`
1835Expected: 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
1848and 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.
1855func (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.
1885package main
1886
1887import (
1888 "bufio"
1889 "context"
1890 "errors"
1891 "flag"
1892 "fmt"
1893 "io"
1894 "log"
1895 "os"
1896 "strings"
1897)
1898
1899const 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
1912Signed-in only (carlos auth login). Visitors to a gated host see a plain
1913page asking for the password and stay signed in for a week; changing or
1914clearing the password signs everyone out at once. The box picks the change
1915up within seconds of the next converge tick.
1916`
1917
1918func 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
1935func 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.
1951func 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.
1968func 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
1986func 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
1996func 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
2032func 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
2053func 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
2090Puts one shared password in front of an app's hostnames — for a static site
2091or a prototype that should not be world-readable. Visitors see a plain page
2092asking for the password and stay signed in for a week; the app behind it
2093never sees the cookie.
2094
2095```sh
2096carlos gate set --app site
2097carlos gate list --app site
2098carlos gate clear --app site
2099```
2100
2101`set` reads the password from `--password` or, without it, one line from
2102stdin, so it need not land in your shell history. Without `--host` the
2103change applies to every instance of the app; name one to narrow it. Changing
2104or clearing the password signs every visitor out at once. Custom domains
2105attached to the app are gated along with the platform hostname. This is a
2106curtain in front of the whole hostname, not accounts for the app's own
2107users; an app with real sign-in keeps doing that itself.
2108```
2109
2110In `### 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
2114Run: `go test . -run 'Gate' -count=1 && go test ./internal/docsite/ -count=1 && go build ./... && go vet ./... && gofmt -l .`
2115Expected: PASS, no unformatted files.
2116
2117- [ ] **Step 5: Commit**
2118
2119```bash
2120git add gate_cli.go gate_cli_test.go apimode.go main.go docs/site/cli.md
2121git 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