# 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

The hash rides `up.route.Gate` — the `upstream` entry already carries the
whole row, and an alias copies the entry, so nothing new is folded.
`ServeHTTP` gains one step, placed **after** `lookupUpstreamPath` resolves
`up` and the usage meter wraps `w`, and **before** the upgrade branch:

```go
if up.route.Gate != "" && !e.gate.Admit(w, r, up.route.Host, up.route.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`. `next` is accepted only if it starts with a single `/`
(not `//`, not a scheme, no control bytes), else it becomes `/`; that is
the entire open-redirect surface and it is closed at the door.

**The edge routes a login POST by `next`, not by its own path** (review
finding, 2026-09-07). A hostname that routes by path prefix resolves each
request's first segment to a target route; `/.carlos/gate` has no such
segment and would fall through to the front host's own route — so a gated
prefix target could never be logged into, and the password would be
proxied to whatever app fronts the hostname. So, for exactly `POST
/.carlos/gate`, the edge parses the form (bounded, 4 KiB) and resolves the
upstream against the path in `next`, which is the path the visitor was
refused on. On a host with no prefix map this resolves to the same route
the request path would have; on a prefix host it resolves to the target
the visitor was trying to reach, whose hash judges the password and whose
host signs the cookie (§4.3).

**A login POST to an ungated route is a 404, never proxied.** The page is
only ever rendered by a gated route, so a POST that reaches an ungated one
is a stray or a probe; forwarding a form field named `password` to an app
that never asked for it is the one outcome to rule out. On every other
method the path is ordinary and reaches the app.

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 set of rules, two doors

The password rules live in `internal/gate` and both doors call them:

```go
// CheckPassword is the door's rule: 8..72 bytes (72 is bcrypt's input
// limit; longer is refused rather than silently truncated).
func CheckPassword(p string) error
// Hash is bcrypt at cost 10.
func Hash(p string) (string, error)
```

The API PATCH applies them inside its existing read-mutate-validate-write
flow (a PATCH may carry other fields in the same body, so it keeps its one
write). The Settings form handler has the same read, write and audit
sequence of its own, against one field. Shared, and enforced at both:

- `CheckPassword` before anything is hashed;
- 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 detail names `gate` among the changed fields (PATCH) or reads
  `set password gate on <host>` / `cleared password gate on <host>` with a
  `(console)` suffix (form). 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. When stdin is a terminal the verb prints `Password:`
to stderr first; what is typed echoes, deliberately, because a no-echo
prompt 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. The session is resolved before the read, so a
logged-out user is told before typing a secret. 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 one of three states:
`open`, `password set`, or `unknown — console predates gates` when the
reply carries no `gated` key at all (§9).

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