carlos / platform Public

spec: password gate — a shared password in front of an instance

Design for a per-route password gate: bcrypt hash on the instance record,
converged into a registry column, enforced by the edge ahead of the cache
and the activator, with a stateless HMAC cookie derived from the hash.
Doors: carlos gate, the instance PATCH, and the app Settings page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 284417d33c2f1adb784af2b5237a56116c84f68f parent 09a22d6
1 file changed, +422 −0
  • docs/superpowers/specs/2026-09-07-password-gate-design.md +422 −0
diff --git a/docs/superpowers/specs/2026-09-07-password-gate-design.md b/docs/superpowers/specs/2026-09-07-password-gate-design.md
new file mode 100644
index 000000000..52a884b82
--- /dev/null
+++ b/docs/superpowers/specs/2026-09-07-password-gate-design.md
@@ -0,0 +1,422 @@
+# Password gate — a shared password in front of an instance
+
+**Date:** 2026-09-07 · **Status:** approved in conversation (Paul, 2026-09-07:
+"yes, full spec, plan, and overnight implementation, but also make sure I can
+set a password or disable password protection via the web console settings
+too"). · **Requested by:** Paul.
+
+**Builds on:** the instance-record converge (`internal/provision/reconcile.go`,
+the `Hibernate` precedent), the edge's cache-before-activator ordering
+(`internal/edge/edge.go` ServeHTTP), the mesh key's HMAC discipline
+(`internal/edge/mesh.go`), the fleet status page's stylesheet
+(`internal/status/assets/status.css`), and the console's form conventions
+(`internal/console/fleets.go` handleAppPlace).
+
+## 1. What this is for
+
+A member ships a static site or a prototype and wants it reachable by the
+people they give a password to, and nobody else. No accounts, no invitations,
+no identity provider: one shared password per host, a neutral page that asks
+for it, and a cookie that remembers the answer for a week.
+
+It is not access control for the app's own users. An app with real sign-in
+keeps doing that itself. This is a curtain the platform holds in front of the
+whole hostname, so the app behind it does not have to know the curtain exists.
+
+## 2. Where it lives, and why
+
+The edge is the only thing that fronts an instance, and it decides what to do
+with a request from the registry row the host resolves to. So the gate is a
+**route property**, and it travels the path hibernation already travels:
+
+ carlos gate / console Settings
+ → console PATCH on the instance record (bucket, CAS-guarded, audited)
+ → the host's provisioning reconcile converges it into a registry column
+ → the edge reads it at reload and enforces it per request
+
+Three consequences fall out of that placement, and each is a reason to place
+it there rather than anywhere else:
+
+- **Custom domains are covered for free.** An alias maps to the primary's
+ `upstream` entry verbatim (`edge.go`, `upstream.alias`), so a gate keyed on
+ `up.route` fronts `docs.example.com` the moment it fronts the platform
+ hostname.
+- **Static sites are covered.** A static site is an instance record of kind
+ `static`; its route is served by the edge's static handler, which sits
+ behind the same gate step as a proxied instance.
+- **A gated host never wakes for a stranger.** The gate step runs before the
+ activator, so unauthenticated traffic — crawlers, scanners, someone guessing
+ a hostname — is answered by the login page without spinning the instance up.
+
+What it is deliberately NOT: an app-level feature flag (`carlos features` is
+served to the app and never read by the edge), an env var (`carlos env` is
+rendered on the host and never read by the edge), or a manifest policy (the
+manifest is read at instance create; a gate is toggled on a running host).
+
+## 3. The record and the column
+
+### 3.1 `provision.Record.Gate`
+
+```go
+// Gate is the bcrypt hash of the host's shared password ("" = open, the
+// value every record written before this field existed decodes to). It is
+// the ONLY place the secret material lives in the bucket; the plaintext
+// crosses the console once, at the door that hashes it, and is never
+// stored, logged or echoed. Converged into routes.gate by reconcile
+// (§3.3) and read by the edge (§4). Additive: omitempty, and an old
+// reader that does not know the field drops it on the floor — see §9
+// for why that means boxes roll before the console.
+Gate string `json:"gate,omitempty"`
+```
+
+Validation in `ValidateShape`: if `Gate != ""` it must parse as a bcrypt
+hash (`bcrypt.Cost(hash)` succeeds). Any kind may carry one — `instance`,
+`static`, `service` all front something a person can browse to. No kind
+refuses it.
+
+### 3.2 The registry column
+
+`ALTER TABLE routes ADD COLUMN gate TEXT NOT NULL DEFAULT ''`, appended to
+the migration list; `Route.Gate string`; added to `routeCols`, `scanRoute`,
+the `Upsert` VALUES and its `ON CONFLICT` set. The conflict rule is the
+**preserve-when-empty** one `caps`, `tunnel` and `unit` use —
+`gate=CASE WHEN excluded.gate = '' THEN routes.gate ELSE excluded.gate END`
+— so a full-row `Upsert` from a writer that does not know the column
+(reconcile's mint path via `routeFor`, an old `carlos add`) cannot silently
+open a gated host. `TestUpsertRoundTripsAllColumns` counts the column.
+
+Narrow setter, the shape of `SetSteering`:
+
+```go
+// SetGate stamps or clears a route's password gate. "" opens the host.
+func (r *Registry) SetGate(host, hash string) error
+```
+
+**`Fingerprint()` must include `gate`.** The edge reloads when the registry
+fingerprint changes, and the fingerprint hashes a fixed column list. A gate
+set or cleared with the column left out of that list would take effect only
+on the next unrelated reload — an operator would "turn the password off"
+and watch the page keep asking for it. `Route.Gate`'s doc comment says it IS
+in `Fingerprint()`, the inverse of the `Caps`/`Unit`/`Steering` comments.
+
+### 3.3 Converge
+
+In reconcile's "existing, provisioned, same app/account" arm, beside the
+hibernate converge:
+
+```go
+if existing.Gate != rec.Gate {
+ // dry-run / SetGate / log "converged %s gate %s -> %s" with the two
+ // states rendered as "open"/"set", never the hash.
+}
+```
+
+Unlike hibernate this is not a pointer with an absent-means-silent rule:
+absent decodes to `""`, and `""` means open. A record that does not name a
+gate is an open host, which is also what every record in every production
+bucket says today. The converge log line prints `open`/`set`, never the
+hash — it is not a secret in the strong sense (§4.3), but it has no business
+in a log.
+
+## 4. The edge
+
+### 4.1 The step, and its place in the chain
+
+`upstream` gains `gate string`, copied from `rt.Gate` in `Reload()` for every
+handler class the switch produces (aliases inherit it with the rest of the
+entry). `ServeHTTP` gains one step, placed **after** `lookupUpstreamPath`
+resolves `up` and the usage meter wraps `w`, and **before** the upgrade
+branch:
+
+```go
+if up.gate != "" {
+ if !e.gate.Admit(w, r, up.route.Host, up.gate) {
+ return
+ }
+}
+```
+
+The placement is the whole design and each neighbour is load-bearing:
+
+- **Before the upgrade branch**, so a WebSocket handshake is gated. The
+ handshake is a GET that carries cookies; the check reads the cookie and
+ either lets the handshake through untouched (the writer stack is exactly
+ what it was) or answers 401 before any hijack.
+- **Before the cache check**, so a cache hit is only ever served to a
+ request that already passed. The shared LRU keys on host and path, not on
+ cookies; a gate behind the cache would let one visitor's authenticated
+ fetch of `/` warm the cache for the world.
+- **Before the activator**, for the reason in §2.
+- **After the meter**, so the 401 page's bytes are billed to the route like
+ any other response the route caused. It is the tenant's page.
+- **After the steering probe, the tunnel hostname and the hop acceptance**,
+ which do not resolve a route and have nothing to gate. A backhauled
+ request is gated on the box that owns the row, where `ServeHTTP` runs
+ after `acceptHop`; the sending edge has no row and makes no decision.
+
+`Admit` returns true when the request carries a valid cookie, after
+**removing the gate cookie from the request** so the app never sees it. It
+returns false when it has already written a response: the login page, the
+redirect after a successful POST, or the too-many-attempts page.
+
+### 4.2 The reserved path
+
+`POST /.carlos/gate` on a gated host is the login form's action. Fields:
+`password`, `next`. On any other host or method the path is ordinary and
+reaches the app. `next` is accepted only if it starts with a single `/`
+(not `//`, not a scheme), else it becomes `/`; that is the entire
+open-redirect surface and it is closed at the door.
+
+Outcomes:
+
+- Right password → `Set-Cookie`, `303 See Other` to `next`.
+- Wrong password → the login page with the error line, status 401.
+- Over the attempt limit → the too-many-attempts page, status 429, with
+ `Retry-After: 60`, and the password is **not** compared.
+
+The limit is a per-client, per-host token bucket held in memory on the edge:
+10 attempts per minute, keyed on the request's remote address and the route
+host. bcrypt at cost 10 already costs tens of milliseconds a comparison; the
+bucket exists so the CPU is spent on people and not on a script. A
+backhauled request's remote address is the sibling edge's, which folds every
+hop from that edge into one bucket; accepted for now, because the fold only
+ever makes the limit stricter, and named here so it is not rediscovered.
+
+### 4.3 The cookie
+
+Name `carlos_gate`; `Path=/`; `HttpOnly`; `Secure`; `SameSite=Lax`;
+`Max-Age` seven days. Value:
+
+ v1.<expiry unix seconds>.<base64url(HMAC-SHA256(key, host "\n" expiry))>
+
+with `key = HMAC-SHA256("carlos-gate-cookie-v1", hash)` — derived from the
+stored bcrypt hash and nothing else. Verification is one HMAC and a
+constant-time compare (`hmac.Equal`), no bcrypt, no lookup.
+
+Deriving the key from the hash 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 object to mint, mirror or rotate. The mesh key exists because the edges
+have to agree on a secret nobody else holds; here the secret every edge
+already holds — the hash, in its own registry — is enough.
+
+The trust it implies, stated so it is a decision and not a surprise: anyone
+who can read the hash can mint a cookie without knowing the password. The
+hash is readable by root on the box and by a bucket reader. Both already
+hold the tenant's sealed secrets and the edge's TLS keys; a gate cookie
+adds nothing to what they could do. It is why §5.4 keeps the hash out of
+every API answer, where the reader is a member, not the platform.
+
+`host` in the MAC is `up.route.Host`, the row's host, not the request's —
+so a path-routed prefix and its target agree on which key signs. Browsers
+scope the cookie to the hostname they were served from anyway, so an alias
+and its primary each log in once.
+
+### 4.4 The page
+
+A new package, `internal/gate`, owning `Admit`, the cookie, the limiter and
+the page. One embedded `html/template` (`login.html`) with the stylesheet
+inline — a single response, no asset path to reserve — using the palette
+and dark-mode flip of `internal/status/assets/status.css`, which is the
+platform's neutral look. It is not the console's template set: the console
+is a different binary, and the edge takes no dependency on it.
+
+Response headers on every page the gate writes: `Cache-Control: no-store`,
+`X-Robots-Tag: noindex, nofollow`. `HEAD` gets the status and headers with
+no body. The 401 is deliberate over a 200-with-a-form: an uptime monitor, a
+crawler or a `curl` sees at a glance that it was not let in.
+
+The page, entire — hostname and error rendered from the template, nothing
+else dynamic:
+
+> **Password required**
+> `<host>` is private. Enter the password to continue.
+> [Password] [Continue]
+>
+> error line, wrong password: *That password is not right.*
+> error line, over the limit: *Too many attempts. Try again in a minute.*
+
+No product name, no logo, no link out. The member is fronting their own
+thing; the page belongs to their hostname.
+
+## 5. The doors
+
+### 5.1 One core, two doors
+
+```go
+// setInstanceGate is the one place a gate is set or cleared: it hashes the
+// password (bcrypt, cost 10), validates, rewrites the record under its
+// etag, and audits. Both the API PATCH and the Settings form call it, so
+// the two doors cannot disagree about a rule.
+func (s *Server) setInstanceGate(ctx, acct *Account, app, host, password string, clear bool, principal string) (*provision.Record, *doorRefusal)
+```
+
+Rules, enforced here and nowhere else:
+
+- password length 8..72 bytes (72 is bcrypt's input limit; a longer one is
+ refused rather than silently truncated);
+- `clear` and a password together is a 400;
+- the record must exist (404, byte-identical to the family's other 404s);
+- CAS loss is a 409, as for every other record write;
+- audit action `gate`, detail `set password gate on <host>` or `cleared
+ password gate on <host>`, suffixed `(cli)` or `(console)`. The password
+ is never in the detail, the log, or an error message.
+
+### 5.2 The API PATCH
+
+`handleAPIInstanceUpdate`'s body gains `Gate *string \`json:"gate"\``. Absent
+leaves the gate alone; `"gate": ""` clears; `"gate": "<password>"` sets. The
+handler routes a present `Gate` through `setInstanceGate` and appends `gate`
+to the audited `changed` list. Everything else about the PATCH is unchanged,
+including the shape rail and the all-nil 400 (which now names `gate` too).
+
+The plaintext crosses the wire once, to the console, over TLS, the same as
+a sign-in. The alternative — hashing in the CLI and sending the hash — would
+make the CLI and the Settings form two hashers to keep in step, for no gain
+a TLS client would notice.
+
+### 5.3 The CLI
+
+`carlos gate`, a new top-level verb shaped like `carlos features`: API mode
+only, sub-verbs as positional words that never reach `fs.Parse`.
+
+ carlos gate set -app <a> [-host <h>] [-password <p>]
+ carlos gate clear -app <a> [-host <h>]
+ carlos gate list -app <a>
+
+`set` without `-password` reads one line from stdin, so a password can be
+piped or typed without landing in the shell's history or `ps` output; an
+empty line is refused. There is no no-echo terminal prompt: that needs
+`golang.org/x/term`, which the module does not depend on, and a
+platform-specific termios call is exactly what the cross-compile gate
+exists to keep out. Without `-host`, `set` and `clear` apply to **every** instance of the app,
+one PATCH each, and print each host as it goes; a failure part way is
+reported per host and the verb exits non-zero, with nothing rolled back —
+the record is per host and the operator re-runs. `list` prints host and
+`open`/`password set`.
+
+`-account`, `-account-id`, `-console` as every other API verb.
+
+### 5.4 The hash never leaves the console
+
+Every response that carries a record goes out with the hash shadowed. The
+list and GET already answer with `instanceListRecord`, which embeds
+`provision.Record`; it gains two fields, and the PATCH, create and steering
+replies — which write a bare record today — answer with a small view built
+the same way:
+
+```go
+// On instanceListRecord and on apiRecordView (the write replies):
+Gate string `json:"gate,omitempty"` // shadows the embedded field; always ""
+Gated bool `json:"gated"` // what a member may know
+```
+
+An outer field shadows the embedded one for `encoding/json`, so the hash
+marshals as nothing and `Gated` says what the reader needs. A test pins
+every writer: a record with a hash serialises with `"gated":true` and no
+`"gate"` key, from each of the four handlers.
+
+### 5.5 The web console
+
+The app's **Settings** page (`app_settings.html`, `handleAppSettings`) gains a
+"Password gate" section under the existing app-level sections. One row per
+instance record: the host, its state, and a form —
+
+ <host> Open [password ........] [Set password]
+ <host> Password set [password ........] [Change] [Turn off]
+
+— `POST /a/{sqid}/apps/{app}/gate` with `csrf`, `host`, `action` (`set` or
+`clear`), `password`. The handler is `handleAppGate`, shaped exactly like
+`handleAppPlace`: `accountApp`, constant-time CSRF check, then
+`setInstanceGate`, then `setFlash` and a 303 back to Settings. Role: any
+**member**, matching the API PATCH's door (`apiAccountApp`), because the CLI
+already lets a member do this and a form that refuses what the CLI allows
+is a rule that lives in the wrong place. The password field is
+`autocomplete="new-password"` so browsers do not offer to save it against
+the console.
+
+Flash copy: *Password set on <host>.* / *Password turned off on <host>.* /
+the refusal's message.
+
+The section reads its rows from `provision.ListRecords` for the app; an app
+with no records shows one line — *No instances yet. A password gate is set
+per instance.*
+
+## 6. Docs
+
+- `docs/site/cli.md` gains `### carlos gate` under "Your apps", after
+ `### carlos features`: one paragraph and a fenced example, in the page's
+ voice. `internal/docsite`'s test fails the build without it.
+- The `### carlos instances` paragraph gets one sentence pointing at `gate`.
+- The console Settings section needs no doc; it is on the page.
+
+## 7. Tests
+
+Registry: the column round-trips (`TestUpsertRoundTripsAllColumns`),
+`SetGate` sets and clears, `Upsert` with an empty gate preserves a set one,
+`Fingerprint` changes when the gate does.
+
+Provision: `ValidateShape` accepts a bcrypt hash, refuses a non-hash;
+reconcile converges set→clear and clear→set and logs `open`/`set` only;
+an absent field leaves an open host open and closes a set one (it is a
+string, not a pointer — the test says so).
+
+Edge (`internal/edge`, alongside the cache and activator tests):
+- an ungated route is byte-for-byte unchanged (no cookie read, no header);
+- a gated route with no cookie gets 401, `no-store`, `noindex`, and **the
+ activator is never called**, **the cache is never consulted**;
+- a right POST sets the cookie and 303s to `next`; a bad `next` becomes `/`;
+- a valid cookie passes, and the upstream sees no `carlos_gate` cookie;
+- an expired cookie, a cookie for another host, a cookie minted under the
+ previous hash — all 401;
+- a WebSocket handshake without the cookie is 401, with it reaches the
+ proxy;
+- an alias of a gated primary is gated;
+- the eleventh attempt in a minute is 429 without a bcrypt compare (a
+ counting fake for the comparer);
+- a `HEAD` gets 401 and no body.
+
+Gate package: cookie mint/verify round trip, key changes with hash, MAC is
+host-bound, expiry honoured, limiter refills.
+
+Console: PATCH round-trip sets and clears, refuses short and over-long,
+refuses clear+password, audits with no password in the detail; every record
+response has `gated` and no `gate` key; the Settings form sets and clears
+behind CSRF and a session, and a non-member gets the family's 404.
+
+CLI: `gate set` fans out to every instance and stops with a non-zero exit
+naming the host that failed; `list` renders both states.
+
+## 8. Not built, on purpose
+
+- **Basic auth for `curl`.** A cookie flow serves browsers; anyone scripting
+ against a gated prototype can POST once and keep the jar.
+- **Per-path exemptions** (`/healthz` open, the rest gated). The platform's
+ own health probe goes straight to the upstream and never meets the gate;
+ an external monitor can carry the cookie.
+- **Per-user passwords, logout, "remember me" longer than a week.** Every
+ one of these is the start of an identity product; the app is where that
+ lives.
+- **A gate on the app manifest** (every instance at once, forever). The
+ CLI's fan-out gives the same day-one result without a second source of
+ truth to reconcile.
+- **`carlos route --gate` for operator routes with no record.** Every member
+ route is a record. An operator route is platform property, and the
+ first person to want one gated can add the flag beside `--hibernate`.
+- **Reporting "enforced" as distinct from "set".** `gate list` reports the
+ record; the box's converge line is the confirmation. Cross-box truth is
+ `internal/fleetapi`'s problem (#145).
+
+## 9. Rollout
+
+The column is additive and defaults to open, so nothing changes on a box
+that rolls with no records carrying a gate. The order that matters is
+**boxes before console**: a console that writes `gate` into a record before
+the boxes roll produces a record the old reconcile decodes without the
+field — and a member who just set a password on a host that is, for the
+length of the gap, still open. Roll the box binary (edge and host agent are
+one binary) across the fleet, read a converge line, then roll the console.
+
+The CLI is inert against an old console: a PATCH carrying `gate` to a
+console that does not know the field is a 400 (`nothing to update`), which
+the verb reports as the console being older than the verb.