cli: carlos instances door-key create, list, revoke (#175 P7)
Task 3 of the P7 plan: the CLI front over the three console doors. Built by a Sonnet subagent from the plan; reviewed before commit. Its user-facing strings are drafts pending the Task 8 copy review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Human
Prompt
Go ahead. (start P7)
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
P7 Task 3: the door-key CLI verbs.
3 files changed,
+440
−0
cli.go+3 −0doorkeys_cli.go+238 −0doorkeys_cli_test.go+199 −0
diff --git a/cli.go b/cli.go| index ad82f5462..9b96c907f 100644 |
| --- a/cli.go |
| +++ b/cli.go |
| @@ -301,6 +301,9 @@ func runInstances(args []string) { |
| case "delete": |
| runInstancesDelete(args[1:]) |
| return |
| + case "door-key": |
| + runInstancesDoorKey(args[1:]) |
| + return |
| case "set-upstreams": |
| runInstancesSetUpstreams(args[1:]) |
| return |
diff --git a/doorkeys_cli.go b/doorkeys_cli.go| new file mode 100644 |
| index 000000000..40aafd064 |
| --- /dev/null |
| +++ b/doorkeys_cli.go |
| @@ -0,0 +1,238 @@ |
| +// The `carlos instances door-key` verb family (instance-moves P7, #175): the |
| +// CLI front over the console's three door-key doors |
| +// (internal/console/apidoorkeys.go) — mint, list and revoke the app-scoped |
| +// credential that authenticates an app's own processes on a box to the |
| +// edge-local door (a unix socket), whatever box the instance lives on. API |
| +// mode only, the same as `carlos instances create/list/delete`: a door key |
| +// is a console-authorized write, the console's database is the authority, |
| +// and there is no store-mode equivalent. Modelled on runInstancesDelete |
| +// (instances_cli.go) for the verb shape and mail_cli.go's emailDeclare/ |
| +// emailStatus for the wire calls. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "strings" |
| + "time" |
| +) |
| + |
| +const doorKeyUsage = `usage: carlos instances door-key create --app <a> [--label <text>] |
| + carlos instances door-key list --app <a> |
| + carlos instances door-key revoke --app <a> <id> |
| + |
| + --account <a> account (sqid or name; default: your only account, |
| + or the configured/chosen one) |
| + --account-id <id> account sqid, matched as a sqid ONLY — supersedes |
| + --account |
| + --console <origin> console to act through (default: the only |
| + logged-in one) |
| + |
| +Signed-in only (carlos auth login): a door key is a console-authorized |
| +write — the console's own database is the authority, and there is no |
| +direct-bucket equivalent. |
| + |
| +create mints a fresh key for the app and prints it once. store it now, |
| +it cannot be shown again — a new "carlos instances door-key list" only |
| +ever shows the id and label, never the key itself. |
| + |
| +list shows the app's live keys: id, label and when each was created. |
| + |
| +revoke takes the id from list and stops that key working. |
| +` |
| + |
| +// ---- wire types ------------------------------------------------------------- |
| + |
| +// apiDoorKeyMint is handleAPIDoorKeyMint's 201 reply |
| +// (internal/console/apidoorkeys.go): the raw key, shown here and nowhere |
| +// else — the console keeps only its digest from this point on. |
| +type apiDoorKeyMint struct { |
| + Key string `json:"key"` |
| + ID string `json:"id"` |
| + Account string `json:"account"` |
| + App string `json:"app"` |
| +} |
| + |
| +// apiDoorKeyEntry is one row of handleAPIDoorKeyList's reply — never the key |
| +// itself, which the console does not keep in a recoverable form. |
| +type apiDoorKeyEntry struct { |
| + ID string `json:"id"` |
| + Label string `json:"label"` |
| + CreatedAt time.Time `json:"created_at"` |
| +} |
| + |
| +// apiDoorKeyRevokeReply is handleAPIDoorKeyRevoke's 200 reply. |
| +type apiDoorKeyRevokeReply struct { |
| + Revoked string `json:"revoked"` |
| +} |
| + |
| +// ---- apiSession wire calls --------------------------------------------------- |
| + |
| +func (s *apiSession) doorKeyMint(ctx context.Context, a apiAcct, app, label string) (apiDoorKeyMint, error) { |
| + in := struct { |
| + Label string `json:"label,omitempty"` |
| + }{label} |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys"), in) |
| + if err != nil { |
| + return apiDoorKeyMint{}, err |
| + } |
| + if status != http.StatusCreated { |
| + return apiDoorKeyMint{}, apiErrorFromBody(status, body) |
| + } |
| + var out apiDoorKeyMint |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return apiDoorKeyMint{}, fmt.Errorf("door-key create: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +func (s *apiSession) doorKeyList(ctx context.Context, a apiAcct, app string) ([]apiDoorKeyEntry, error) { |
| + status, body, err := s.getJSON(ctx, apiPath(a, app, "/door-keys")) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if status != http.StatusOK { |
| + return nil, apiErrorFromBody(status, body) |
| + } |
| + var res struct { |
| + Keys []apiDoorKeyEntry `json:"keys"` |
| + } |
| + if err := json.Unmarshal(body, &res); err != nil { |
| + return nil, fmt.Errorf("door-key list: unreadable response: %w", err) |
| + } |
| + return res.Keys, nil |
| +} |
| + |
| +func (s *apiSession) doorKeyRevoke(ctx context.Context, a apiAcct, app, id string) (string, error) { |
| + status, body, err := s.postJSON(ctx, apiPath(a, app, "/door-keys/"+pathEscape(id)+"/revoke"), struct{}{}) |
| + if err != nil { |
| + return "", err |
| + } |
| + if status != http.StatusOK { |
| + return "", apiErrorFromBody(status, body) |
| + } |
| + var out apiDoorKeyRevokeReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return "", fmt.Errorf("door-key revoke: unreadable response: %w", err) |
| + } |
| + return out.Revoked, nil |
| +} |
| + |
| +// ---- verbs -------------------------------------------------------------- |
| + |
| +// runInstancesDoorKey dispatches the door-key sub-verbs — create/list/revoke |
| +// are positional words, not flags, and must never reach fs.Parse, the same |
| +// shape runInstances and runDomains use for their own sub-verbs. |
| +func runInstancesDoorKey(args []string) { |
| + if len(args) > 0 { |
| + switch args[0] { |
| + case "create": |
| + runInstancesDoorKeyCreate(args[1:]) |
| + return |
| + case "list": |
| + runInstancesDoorKeyList(args[1:]) |
| + return |
| + case "revoke": |
| + runInstancesDoorKeyRevoke(args[1:]) |
| + return |
| + } |
| + } |
| + log.Fatal(doorKeyUsage) |
| +} |
| + |
| +func runInstancesDoorKeyCreate(args []string) { |
| + fs := flag.NewFlagSet("instances door-key create", flag.ExitOnError) |
| + app := appFlag(fs, "app to mint a door key for") |
| + label := fs.String("label", "", "short note stored alongside the key, shown by list (default: none)") |
| + consoleFlag := consoleOption(fs, "console to create 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") |
| + fs.Usage = flagUsage(fs, doorKeyUsage) |
| + fs.Parse(args) |
| + sess := requireAPISession("door-key create", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| + res, err := sess.doorKeyMint(context.Background(), acct, app2, strings.TrimSpace(*label)) |
| + if err != nil { |
| + log.Fatalf("instances door-key create: %v", err) |
| + } |
| + printDoorKeyCreate(stdout, res) |
| +} |
| + |
| +// printDoorKeyCreate prints the raw key on its own line, so it is the one |
| +// thing a script capturing this command's stdout has to parse out, then a |
| +// warning that this is the only time it will ever be shown — the console |
| +// keeps only its digest from here on, the same rule email credentials |
| +// create follows for a standalone credential's password. |
| +func printDoorKeyCreate(w io.Writer, res apiDoorKeyMint) { |
| + fmt.Fprintln(w, res.Key) |
| + warnf(w, "shown once. store it now, it cannot be shown again.") |
| +} |
| + |
| +func runInstancesDoorKeyList(args []string) { |
| + fs := flag.NewFlagSet("instances door-key list", flag.ExitOnError) |
| + app := appFlag(fs, "app whose door keys to list") |
| + consoleFlag := consoleOption(fs, "console to list 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") |
| + fs.Usage = flagUsage(fs, doorKeyUsage) |
| + fs.Parse(args) |
| + sess := requireAPISession("door-key list", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| + keys, err := sess.doorKeyList(context.Background(), acct, app2) |
| + if err != nil { |
| + log.Fatalf("instances door-key list: %v", err) |
| + } |
| + printDoorKeyList(stdout, keys) |
| +} |
| + |
| +// printDoorKeyList never prints a key — the list reply this reads from does |
| +// not carry one (apiDoorKeyEntry has no such field at all), so there is |
| +// nothing here to leak by construction. |
| +func printDoorKeyList(w io.Writer, keys []apiDoorKeyEntry) { |
| + if len(keys) == 0 { |
| + mutedf(w, "no door keys") |
| + return |
| + } |
| + t := newTable(w) |
| + fmt.Fprintf(t, "ID\tLABEL\tCREATED\n") |
| + for _, k := range keys { |
| + label := k.Label |
| + if label == "" { |
| + label = "-" |
| + } |
| + fmt.Fprintf(t, "%s\t%s\t%s\n", k.ID, label, k.CreatedAt.Format(time.RFC3339)) |
| + } |
| + t.Flush() |
| +} |
| + |
| +func runInstancesDoorKeyRevoke(args []string) { |
| + fs := flag.NewFlagSet("instances door-key revoke", flag.ExitOnError) |
| + app := appFlag(fs, "app the door key belongs to") |
| + consoleFlag := consoleOption(fs, "console to revoke 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") |
| + fs.Usage = flagUsage(fs, doorKeyUsage) |
| + fs.Parse(args) |
| + id := strings.TrimSpace(fs.Arg(0)) |
| + if id == "" || fs.NArg() > 1 { |
| + log.Fatal(doorKeyUsage) |
| + } |
| + sess := requireAPISession("door-key revoke", *consoleFlag) |
| + printUsing(stdout, sess) |
| + acct := resolvedAccount(sess, acctRaw, acctIDRaw) |
| + app2 := resolvedApp(sess, acct, *app, doorKeyUsage) |
| + revoked, err := sess.doorKeyRevoke(context.Background(), acct, app2, id) |
| + if err != nil { |
| + log.Fatalf("instances door-key revoke: %v", err) |
| + } |
| + fmt.Fprintf(stdout, "%s %s\n", colorFor(stdout).ok("revoked"), revoked) |
| +} |
diff --git a/doorkeys_cli_test.go b/doorkeys_cli_test.go| new file mode 100644 |
| index 000000000..fc8dd8a8c |
| --- /dev/null |
| +++ b/doorkeys_cli_test.go |
| @@ -0,0 +1,199 @@ |
| +// Tests for `carlos instances door-key` (instance-moves P7, #175): the |
| +// apiSession wire calls (doorkeys_cli.go) against a bare httptest server — |
| +// method, path and body round trips, the shape instances_cli_test.go and |
| +// mail_cli_test.go pin their own wire calls with — plus RunInstancesDoorKey- |
| +// level tests against a fake console with a credential written straight to |
| +// disk (fleetsCLIFixture/servicesCLIFixture's pattern), proving the key |
| +// really reaches stdout exactly once, the list table prints, and revoke's |
| +// confirmation names the id. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "net/http" |
| + "net/http/httptest" |
| + "path/filepath" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// ---- apiSession-level: path + body + wire-shape round trips --------------- |
| + |
| +func TestAPISessionDoorKeyMintWireShape(t *testing.T) { |
| + var gotPath, gotMethod string |
| + var gotBody struct { |
| + Label string `json:"label"` |
| + } |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewDecoder(r.Body).Decode(&gotBody) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "key": "cdk1.bab.titogo.0123456789abcdef." + strings.Repeat("A", 43), |
| + "id": "0123456789abcdef", "account": "bab", "app": "titogo", |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + res, err := newTestSession(ts.URL).doorKeyMint(context.Background(), apiAcct{Seg: "bab"}, "titogo", "router") |
| + if err != nil { |
| + t.Fatalf("doorKeyMint: %v", err) |
| + } |
| + if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/bab/titogo/door-keys" { |
| + t.Fatalf("method/path = %s %s", gotMethod, gotPath) |
| + } |
| + if gotBody.Label != "router" { |
| + t.Fatalf("body label = %q, want %q", gotBody.Label, "router") |
| + } |
| + if res.ID != "0123456789abcdef" || !strings.HasPrefix(res.Key, "cdk1.bab.titogo.") { |
| + t.Fatalf("result = %+v", res) |
| + } |
| +} |
| + |
| +func TestAPISessionDoorKeyListWireShape(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "keys": []map[string]any{ |
| + {"id": "0123456789abcdef", "label": "router", "created_at": "2026-09-28T00:00:00Z"}, |
| + }, |
| + }) |
| + })) |
| + defer ts.Close() |
| + |
| + keys, err := newTestSession(ts.URL).doorKeyList(context.Background(), apiAcct{Seg: "bab"}, "titogo") |
| + if err != nil { |
| + t.Fatalf("doorKeyList: %v", err) |
| + } |
| + if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/bab/titogo/door-keys" { |
| + t.Fatalf("method/path = %s %s", gotMethod, gotPath) |
| + } |
| + if len(keys) != 1 || keys[0].ID != "0123456789abcdef" || keys[0].Label != "router" { |
| + t.Fatalf("keys = %+v", keys) |
| + } |
| +} |
| + |
| +func TestAPISessionDoorKeyRevokeWireShape(t *testing.T) { |
| + var gotPath, gotMethod string |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + gotPath, gotMethod = r.URL.Path, r.Method |
| + json.NewEncoder(w).Encode(map[string]any{"revoked": "0123456789abcdef"}) |
| + })) |
| + defer ts.Close() |
| + |
| + revoked, err := newTestSession(ts.URL).doorKeyRevoke(context.Background(), apiAcct{Seg: "bab"}, "titogo", "0123456789abcdef") |
| + if err != nil { |
| + t.Fatalf("doorKeyRevoke: %v", err) |
| + } |
| + if gotMethod != http.MethodPost || gotPath != "/api/cli/apps/bab/titogo/door-keys/0123456789abcdef/revoke" { |
| + t.Fatalf("method/path = %s %s", gotMethod, gotPath) |
| + } |
| + if revoked != "0123456789abcdef" { |
| + t.Fatalf("revoked = %q", revoked) |
| + } |
| +} |
| + |
| +func TestAPISessionDoorKeyRevokeUnknownID404(t *testing.T) { |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.WriteHeader(http.StatusNotFound) |
| + json.NewEncoder(w).Encode(map[string]any{"error": "no door key with that id"}) |
| + })) |
| + defer ts.Close() |
| + |
| + if _, err := newTestSession(ts.URL).doorKeyRevoke(context.Background(), apiAcct{Seg: "bab"}, "titogo", "deadbeef"); err == nil { |
| + t.Fatal("doorKeyRevoke: want an error on 404") |
| + } |
| +} |
| + |
| +// ---- RunInstancesDoorKey-level: full verb, good args, real printing ------- |
| + |
| +// doorKeysCLIFixture wires up a fake console stubbing the three door-key |
| +// doors plus a credential pointed at it, entirely in-process — the same |
| +// shortcut fleetsCLIFixture/servicesCLIFixture take. |
| +func doorKeysCLIFixture(t *testing.T) *httptest.Server { |
| + t.Helper() |
| + home := t.TempDir() |
| + t.Setenv("HOME", home) |
| + t.Setenv("CARLOS_CONSOLE", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_BUCKET", "") |
| + t.Setenv("CARLOS_DEPLOYMENT_DIR", "") |
| + t.Chdir(t.TempDir()) |
| + creds := filepath.Join(home, "carlos-credentials") |
| + t.Setenv("CARLOS_CREDENTIALS", creds) |
| + |
| + mux := http.NewServeMux() |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/door-keys", func(w http.ResponseWriter, r *http.Request) { |
| + var req struct{ Label string } |
| + json.NewDecoder(r.Body).Decode(&req) |
| + w.WriteHeader(http.StatusCreated) |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "key": "cdk1." + r.PathValue("account") + "." + r.PathValue("app") + ".0123456789abcdef." + strings.Repeat("A", 43), |
| + "id": "0123456789abcdef", "account": r.PathValue("account"), "app": r.PathValue("app"), |
| + }) |
| + }) |
| + mux.HandleFunc("GET /api/cli/apps/{account}/{app}/door-keys", func(w http.ResponseWriter, r *http.Request) { |
| + json.NewEncoder(w).Encode(map[string]any{ |
| + "keys": []map[string]any{ |
| + {"id": "0123456789abcdef", "label": "router", "created_at": "2026-09-28T00:00:00Z"}, |
| + {"id": "fedcba9876543210", "label": "", "created_at": "2026-09-27T00:00:00Z"}, |
| + }, |
| + }) |
| + }) |
| + mux.HandleFunc("POST /api/cli/apps/{account}/{app}/door-keys/{id}/revoke", func(w http.ResponseWriter, r *http.Request) { |
| + id := r.PathValue("id") |
| + if id != "0123456789abcdef" { |
| + w.WriteHeader(http.StatusNotFound) |
| + json.NewEncoder(w).Encode(map[string]any{"error": "no door key with that id"}) |
| + return |
| + } |
| + json.NewEncoder(w).Encode(map[string]any{"revoked": id}) |
| + }) |
| + ts := httptest.NewServer(mux) |
| + t.Cleanup(ts.Close) |
| + writeTestCredential(t, creds, ts.URL) |
| + return ts |
| +} |
| + |
| +func TestRunInstancesDoorKeyCreatePrintsKeyOnce(t *testing.T) { |
| + doorKeysCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runInstancesDoorKey([]string{"create", "--app", "titogo", "--account", "bab", "--label", "router"}) |
| + |
| + out := buf.String() |
| + if got := strings.Count(out, "cdk1.bab.titogo.0123456789abcdef."); got != 1 { |
| + t.Fatalf("stdout = %q, want the key exactly once", out) |
| + } |
| + if !strings.Contains(out, "shown once") { |
| + t.Fatalf("stdout = %q, want a shown-once warning", out) |
| + } |
| +} |
| + |
| +func TestRunInstancesDoorKeyListPrintsTable(t *testing.T) { |
| + doorKeysCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runInstancesDoorKey([]string{"list", "--app", "titogo", "--account", "bab"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "0123456789abcdef") || !strings.Contains(out, "router") { |
| + t.Fatalf("stdout = %q, want the key id and label", out) |
| + } |
| + if !strings.Contains(out, "fedcba9876543210") { |
| + t.Fatalf("stdout = %q, want the second key id", out) |
| + } |
| +} |
| + |
| +func TestRunInstancesDoorKeyRevokePrintsConfirmation(t *testing.T) { |
| + doorKeysCLIFixture(t) |
| + buf := captureStdout(t) |
| + |
| + runInstancesDoorKey([]string{"revoke", "--app", "titogo", "--account", "bab", "0123456789abcdef"}) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "revoked") || !strings.Contains(out, "0123456789abcdef") { |
| + t.Fatalf("stdout = %q, want a confirmation naming the id", out) |
| + } |
| +} |