carlos / platform Public

carlos gate: set, clear and list an app's password gates; documented

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev b0e0632963a825efa4baa75d63ae98eebabf88ce parent 962c560
5 files changed, +302 −1
  • apimode.go +29 −0
  • docs/site/cli.md +23 −1
  • gate_cli.go +199 −0
  • gate_cli_test.go +48 −0
  • main.go +3 −0

CI passed — run details

diff --git a/apimode.go b/apimode.go
index fbb91f813..9446ccf8d 100644
--- a/apimode.go
+++ b/apimode.go
@@ -2533,6 +2533,10 @@ type apiInstanceRecord struct {
// the binding became something a member could set (2026-09-04), and a
// column the CLI cannot read is a column the CLI cannot show is wrong.
Environment string `json:"environment"`
+ // 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"`
}
// apiUpstreamState mirrors status.UpstreamState's wire shape (internal/status)
@@ -2714,6 +2718,31 @@ func (s *apiSession) instanceSetSteering(ctx context.Context, a apiAcct, app, ho
return out, nil
}
+// 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
+}
+
// instanceList reads the app's instance records through the console, honest
// about the server's 500-record cap. match and health are the server-side
// filters Task 9 added (q and health query params) — empty strings send
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 9b13092e1..67b037e12 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -443,7 +443,8 @@ An app's instances are the places it actually runs. `carlos instances enable`
is the app's opt-in to provisioning, and until it is set the API refuses to
create anything. After that, `create`, `list`, `delete`, `set-upstreams`,
`set-channel` and `set-environment` work on records through the console, and
-the box's reconcile pass turns a record into a live route.
+the box's reconcile pass turns a record into a live route. A password in
+front of an instance's hostname is `carlos gate`.
```sh
carlos instances enable --app hello
@@ -548,6 +549,27 @@ restart, no environment edit, no root-owned file on an edge box. Setting is
an admin-role write and every change is audited under the app's Activity;
the app's own instance credential can read the flags but never set them.
+### 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 site
+```
+
+`set` 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.
+
### carlos logs
The app's own stdout and stderr, merged with the platform's events about it:
diff --git a/gate_cli.go b/gate_cli.go
new file mode 100644
index 000000000..da4ea56e3
--- /dev/null
+++ b/gate_cli.go
@@ -0,0 +1,199 @@
+// 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()
+}
diff --git a/gate_cli_test.go b/gate_cli_test.go
new file mode 100644
index 000000000..048bca8a8
--- /dev/null
+++ b/gate_cli_test.go
@@ -0,0 +1,48 @@
+package main
+
+import (
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+func runCarlosCLI(t *testing.T, args ...string) (string, error) {
+ t.Helper()
+ bin := buildCarlos(t)
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+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")
+ }
+}
diff --git a/main.go b/main.go
index fa80dc99c..3e1fb3158 100644
--- a/main.go
+++ b/main.go
@@ -59,6 +59,8 @@ func main() {
runDomains(args)
case "features":
runFeatures(args)
+ case "gate":
+ runGate(args)
case "ship":
runShip(args)
case "promote":
@@ -194,6 +196,7 @@ Your apps — ship and run software on a CARLOS deployment
attach | detach | list
features an app's own feature flags, stored and served by the
platform, never interpreted: set | list
+ gate a shared password in front of an app's hostnames
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify