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>
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. |