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