carlos / platform Public

cli: carlos store create/status/grant/rotate

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 56a3f88467b0d3dcf122d94e72dee880ec0d3c40 parent 7da98a6
4 files changed, +1082 −0
  • apimode.go +156 −0
  • main.go +3 −0
  • store.go +357 −0
  • store_test.go +566 −0
diff --git a/apimode.go b/apimode.go
index 6ae3887e6..55f89e685 100644
--- a/apimode.go
+++ b/apimode.go
@@ -668,6 +668,162 @@ func (s *apiSession) restoreApp(ctx context.Context, a apiAcct, app string) (api
return apiRestoreResult{Note: res.Note}, nil
}
+// ---- object storage (issue #55) ---------------------------------------------
+//
+// apiStoreReply mirrors (*console.Server).storeReply's JSON exactly
+// (internal/console/apistore.go): app, this DEPLOYMENT's backend (""
+// unconfigured), the three record blocks (declared/grant/provisioned,
+// null-or-present — a client reads them rather than testing for absence),
+// the env names this store delivers, whether a rotation is only half
+// finished, and which delivered variables have not reached the app's config
+// yet.
+//
+// The three block types are redeclared here rather than imported from
+// internal/objstore: this package (main, the CLI binary) does not otherwise
+// import that package, and pulling it in for three struct shapes this client
+// only ever decodes (never constructs server-side logic from) would be
+// coupling this binary to the console's storage internals for no reason a
+// wire client has. Field names and JSON tags are kept byte-for-byte
+// identical to objstore.Declared/GrantBlock/Provisioned so a decode here can
+// never silently drop a field the server sends.
+type apiStoreDeclared struct {
+ EnvPrefix string `json:"env_prefix"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type apiStoreGrantBlock struct {
+ Backend string `json:"backend"`
+ Bucket string `json:"bucket,omitempty"` // set only when the grant ADOPTED an existing bucket
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type apiStoreProvisioned struct {
+ Bucket string `json:"bucket"`
+ Region string `json:"region"`
+ Endpoint string `json:"endpoint,omitempty"`
+ IAMUser string `json:"iam_user"`
+ UserARN string `json:"user_arn"`
+ KeyID string `json:"key_id"`
+ PrevKeyID string `json:"prev_key_id,omitempty"`
+ At time.Time `json:"at"`
+}
+
+type apiStoreReply struct {
+ App string `json:"app"`
+ Backend string `json:"backend"`
+ Declared *apiStoreDeclared `json:"declared"`
+ Grant *apiStoreGrantBlock `json:"grant"`
+ Provisioned *apiStoreProvisioned `json:"provisioned"`
+ EnvNames []string `json:"env_names"`
+ PendingFinish bool `json:"pending_finish"`
+ MissingVars []string `json:"missing_vars"`
+}
+
+// storeCreate asks the console to declare (or re-declare) a store for app,
+// under envPrefix ("" takes the console's own default — objstore.
+// DefaultEnvPrefix). Mirrors restart's shape (apimode.go:438): postJSON, a
+// 200 decodes, anything else is apiErrorFromBody's job.
+//
+// The request omits env_prefix entirely (json:",omitempty") when envPrefix
+// is "", so a bare `carlos store create -app x` sends the literal `{}` body
+// handleAPIStoreCreate documents as its own default case — never a
+// zero-length body, which that door's readJSON would refuse as malformed.
+func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix string) (apiStoreReply, error) {
+ in := struct {
+ EnvPrefix string `json:"env_prefix,omitempty"`
+ }{envPrefix}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/store"), in)
+ if err != nil {
+ return apiStoreReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreReply{}, fmt.Errorf("store create: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// storeGet asks the console for app's store record (`carlos store status`).
+// A 404 here is a real diagnosis, not the byte-identical membership 404
+// apiAccountApp gives — handleAPIStoreGet answers it with an actual
+// {"error": "no store is declared..."} body once membership is already
+// proven, so apiErrorFromBody's json-first decode surfaces it verbatim like
+// any other server diagnosis; no special case is needed here.
+func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/store"))
+ if err != nil {
+ return apiStoreReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreReply{}, fmt.Errorf("store status: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// storeGrant asks the console to provision app's declared store and deliver
+// its credentials (`carlos store grant`) — the one call in this file that
+// spends money in the deployment's own cloud account, gated server-side on
+// CARLOS_STORE_OPERATORS (a 403 for anyone else, member or not, surfaced
+// verbatim by apiErrorFromBody like every other server diagnosis here: this
+// client never pattern-matches the message, only prints it).
+//
+// bucket == "" mints a fresh bucket; a non-empty bucket asks the walk to
+// ADOPT that existing one instead. Same omitempty shape as storeCreate's
+// prefix: a bare `-app x` with no `-bucket` sends `{}`.
+func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket string) (apiStoreReply, error) {
+ in := struct {
+ Bucket string `json:"bucket,omitempty"`
+ }{bucket}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/store/grant"), in)
+ if err != nil {
+ return apiStoreReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreReply{}, fmt.Errorf("store grant: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+// storeRotate asks the console to cycle app's store credential
+// (`carlos store rotate`), or — with finish — to delete the previous key and
+// close the two-key window a rotation leaves open
+// (`carlos store rotate -finish`). Two different doors
+// (POST .../store/rotate vs POST .../store/rotate/finish), chosen here
+// rather than by a body field, matching the server's own route table
+// (internal/console/authcli.go) and restart's "no body, the whole request is
+// which door you POST to" shape (handleAPIStoreRotate's doc comment).
+func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiStoreReply, error) {
+ tail := "/store/rotate"
+ if finish {
+ tail = "/store/rotate/finish"
+ }
+ status, body, err := s.postJSON(ctx, apiPath(a, app, tail), struct{}{})
+ if err != nil {
+ return apiStoreReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreReply{}, fmt.Errorf("store rotate: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// ledgerAppend asks the console to CAS-append one entry to name's chain,
// stamping writer from the caller's own authenticated identity server-side
// (never client-supplied — apiledger.go). A 503 here is contention (too many
diff --git a/main.go b/main.go
index 62106f5e4..0e14ff07c 100644
--- a/main.go
+++ b/main.go
@@ -61,6 +61,8 @@ func main() {
runDeploy(args)
case "restart":
runRestart(args)
+ case "store":
+ runStore(args)
case "logs":
runLogs(args)
case "channels":
@@ -124,6 +126,7 @@ Your apps — ship and run software on a CARLOS deployment
(reaching stable cuts the release's semver tag)
deploy ship + promote + wait until the URL serves the new version
restart cycle an app's instances — no new version, no config change
+ store object storage for an app: create | status | grant | rotate
rollback point a channel back at an earlier version
channels show what each of an app's channels is serving
releases every version shipped, newest first, with labels and tags
diff --git a/store.go b/store.go
new file mode 100644
index 000000000..ae24517f8
--- /dev/null
+++ b/store.go
@@ -0,0 +1,357 @@
+// store.go is `carlos store`: declare, inspect, grant and rotate an app's
+// object-storage estate (issue #55). API mode ONLY, like restart and apps
+// create — the record lives in the console's database (well, its deployment
+// bucket, but there is no local copy to write bucket-direct against) and the
+// grant/rotate walks spend money in the deployment's own cloud account, so
+// there is nothing a headless box could do here that this CLI could
+// meaningfully drive on its own.
+//
+// Four sub-verbs, one flag family:
+//
+// create declare that an app wants a store, under an env prefix. A
+// REQUEST, not a provision — see printStoreCreate.
+// status the record's three blocks plus drift (missing_vars) and any
+// half-finished rotation (pending_finish) — the two facts a
+// member with no box access cannot see any other way.
+// grant operator-only: provision the declared store and deliver its
+// credentials. The one verb here that spends the deployment's
+// own money.
+// rotate mint a fresh credential; -finish retires the previous one.
+//
+// The output-honesty rule every line here follows is restart.go's: say what
+// was REQUESTED or RECORDED, never what a box has since done with it. A grant
+// or a rotate is answered by the console's own walk (it already ran, inline,
+// before this process gets its 200), so those two verbs get to say more than
+// restart can — but delivery into a running instance's environment is still a
+// converge a box does on its own schedule, which is why grant's own line
+// says "pick them up within seconds" rather than claiming they already have.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "io"
+ "log"
+ "os"
+ "strings"
+ "time"
+)
+
+const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags]
+
+ create -app <a> [-env-prefix <PREFIX>]
+ declare that this app wants object storage, under
+ an env prefix (default CARLOS_STORE). A request,
+ not a provision: nothing exists in any cloud
+ account until an operator grants it.
+ status -app <a> show what's declared, granted and provisioned,
+ which delivered variables have not reached the
+ app's config yet, and whether a rotation is only
+ half finished.
+ grant -app <a> [-bucket <name>]
+ provision the declared store and deliver its
+ credentials. Needs a deployment operator
+ (CARLOS_STORE_OPERATORS) — a 403 for anyone else,
+ member or not. -bucket adopts an existing bucket
+ instead of minting one.
+ rotate -app <a> [-finish]
+ mint a fresh credential, leaving the old one live
+ until -finish deletes it. A member verb: spends
+ nothing new, so there is no operator gate here.
+
+ -account <sqid|name> account the app belongs to (default: ops) —
+ resolved by the console against your own
+ memberships; signed-in mode only.
+ -account-id <sqid> account sqid, matched as a sqid ONLY.
+ -console <origin> which logged-in console to use (only needed with
+ several)
+
+Signed-in only (carlos auth login): the store record lives in the console's
+own deployment bucket and its grant/rotate walks touch the deployment's own
+cloud account — there is no direct-bucket equivalent for any of these four
+verbs.
+`
+
+// runStore dispatches the store sub-verbs, mirroring RunApps's arg slicing
+// (apps.go): args[0] is the sub-verb, the rest is that sub-verb's own flag
+// set.
+func runStore(args []string) {
+ if len(args) == 0 {
+ fmt.Fprint(os.Stderr, storeUsage)
+ os.Exit(1)
+ }
+ switch args[0] {
+ case "create":
+ runStoreCreate(args[1:])
+ case "status":
+ runStoreStatus(args[1:])
+ case "grant":
+ runStoreGrant(args[1:])
+ case "rotate":
+ runStoreRotate(args[1:])
+ default:
+ fmt.Fprint(os.Stderr, storeUsage)
+ os.Exit(1)
+ }
+}
+
+// requireStoreAPISession resolves signed-in mode for every store sub-verb,
+// refusing direct-bucket mode with store's OWN message — the pattern
+// requireRestartAPISession sets (restart.go), and for the same reason: a
+// refusal that names another verb's advice sends someone to a fix that
+// cannot help them.
+//
+// There is no headless fallback, and for a stronger reason than restart's:
+// restart's request is a console write a fleet listens for, but grant and
+// rotate don't just write an object — they call out to the deployment's own
+// cloud account (objstore.Grant/Rotate), an authority this binary has no
+// other way to exercise. A bucket-mode operator asking for a store has no
+// walk to drive bucket-direct at all.
+func requireStoreAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos store works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). store is a member/operator verb with\n" +
+ "no direct-bucket equivalent — the record lives in the console's deployment bucket, and grant\n" +
+ "and rotate call out to the deployment's own cloud account, an authority only the console can\n" +
+ "exercise. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
+
+func runStoreCreate(args []string) {
+ fs := flag.NewFlagSet("store create", flag.ExitOnError)
+ app := fs.String("app", "", "app to declare a store for")
+ envPrefix := fs.String("env-prefix", "", "env var prefix to deliver under (default CARLOS_STORE)")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to declare through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ if name == "" {
+ log.Fatal("usage: carlos store create -app <a> [-env-prefix <PREFIX>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+ // Client-side gate before the server's, the same one every write verb in
+ // this file applies: a name the console is certain to refuse should cost
+ // a typo nothing.
+ if err := validAppName(name); err != nil {
+ log.Fatalf("store create: %v", err)
+ }
+
+ sess := requireStoreAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ res, err := sess.storeCreate(context.Background(), acct, name, strings.TrimSpace(*envPrefix))
+ if err != nil {
+ log.Fatalf("store create: %v", err)
+ }
+ printStoreCreate(stdout, res)
+}
+
+func runStoreStatus(args []string) {
+ fs := flag.NewFlagSet("store status", flag.ExitOnError)
+ app := fs.String("app", "", "app whose store to show")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to read through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ if name == "" {
+ log.Fatal("usage: carlos store status -app <a> [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+ if err := validAppName(name); err != nil {
+ log.Fatalf("store status: %v", err)
+ }
+
+ sess := requireStoreAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ res, err := sess.storeGet(context.Background(), acct, name)
+ if err != nil {
+ log.Fatalf("store status: %v", err)
+ }
+ printStoreStatus(stdout, res)
+}
+
+func runStoreGrant(args []string) {
+ fs := flag.NewFlagSet("store grant", flag.ExitOnError)
+ app := fs.String("app", "", "app to grant a store for")
+ bucket := fs.String("bucket", "", "adopt this existing bucket instead of minting one")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to grant through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ if name == "" {
+ log.Fatal("usage: carlos store grant -app <a> [-bucket <name>] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+ if err := validAppName(name); err != nil {
+ log.Fatalf("store grant: %v", err)
+ }
+
+ sess := requireStoreAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ res, err := sess.storeGrant(context.Background(), acct, name, strings.TrimSpace(*bucket))
+ if err != nil {
+ log.Fatalf("store grant: %v", err)
+ }
+ printStoreGrant(stdout, res)
+}
+
+func runStoreRotate(args []string) {
+ fs := flag.NewFlagSet("store rotate", flag.ExitOnError)
+ app := fs.String("app", "", "app whose store credential to rotate")
+ finish := fs.Bool("finish", false, "delete the previous key instead of minting a new one")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to rotate through (API mode only; default: the only logged-in one)")
+ fs.Parse(args)
+ name := strings.TrimSpace(*app)
+ if name == "" {
+ log.Fatal("usage: carlos store rotate -app <a> [-finish] [-account <sqid-or-name>] [-account-id <sqid>] [-console <origin>]")
+ }
+ if err := validAppName(name); err != nil {
+ log.Fatalf("store rotate: %v", err)
+ }
+
+ sess := requireStoreAPISession(*consoleFlag)
+ acct := apiAccount(acctRaw, acctIDRaw)
+ printUsing(stdout, sess)
+ res, err := sess.storeRotate(context.Background(), acct, name, *finish)
+ if err != nil {
+ log.Fatalf("store rotate: %v", err)
+ }
+ printStoreRotate(stdout, res, *finish)
+}
+
+// ---- output -----------------------------------------------------------------
+//
+// Every function below takes the reply the console already sent and prints
+// it — no field here is guessed or extrapolated, and no line claims a box has
+// converged. That split (network in run*, rendering in print*) is what lets
+// the honesty rules below be tested directly against a literal apiStoreReply,
+// with no stub server or credential file in the loop at all.
+
+// printStoreCreate answers `carlos store create`: the prefix now declared,
+// the env names it will eventually deliver, and the one sentence that keeps
+// this from reading like a provision — nothing exists in any cloud account
+// yet. When the deployment itself has no backend at all (Backend == ""),
+// that fact is added too: a member cannot see CARLOS_STORE_BACKEND for
+// themselves, and "declared, nothing happening" with no way to tell a queued
+// request from a deployment that will never grant one fails the no-infra-
+// access rule (CLAUDE.md) just as surely as a missing CLI command would.
+func printStoreCreate(w io.Writer, res apiStoreReply) {
+ c := colorFor(w)
+ prefix := ""
+ if res.Declared != nil {
+ prefix = res.Declared.EnvPrefix
+ }
+ fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n",
+ c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", "))
+ mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant -app %s)", res.App)
+ if res.Backend == "" {
+ warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+
+ "CARLOS_STORE_BACKEND) — the declaration will simply wait until one is")
+ }
+}
+
+// printStoreGrant answers `carlos store grant`: the walk already ran inline
+// (the console's handleAPIStoreGrant does not return 200 until it has),
+// which is why this can name the bucket/region/user rather than hedging like
+// create does. What it does NOT claim is that a running instance has the new
+// credential yet — that is still a converge a box does on its own poll, spec
+// unchanged by grant having succeeded here.
+func printStoreGrant(w io.Writer, res apiStoreReply) {
+ c := colorFor(w)
+ if res.Provisioned == nil {
+ // Defensive only: handleAPIStoreGrant never answers 200 without a
+ // provisioned block. If a future console did, this is still an
+ // honest thing to print — the record's own state, nothing invented.
+ fmt.Fprintf(w, "%s %s\n", c.ok("store granted for"), res.App)
+ return
+ }
+ p := res.Provisioned
+ fmt.Fprintf(w, "%s %s — bucket %s in %s, user %s\n",
+ c.ok("store granted for"), res.App, p.Bucket, p.Region, p.IAMUser)
+ mutedf(w, "credentials are sealed into the app's config — running instances pick them up within seconds")
+}
+
+// printStoreRotate answers both `carlos store rotate` and `carlos store
+// rotate -finish` — two different confirmations of two different walks, kept
+// in one function because they share the app name and the ok() styling and
+// nothing else worth factoring further.
+func printStoreRotate(w io.Writer, res apiStoreReply, finish bool) {
+ c := colorFor(w)
+ if finish {
+ // The walk deletes the previous key; the reply's own Provisioned
+ // block (PrevKeyID now empty, PendingFinish now false) is the
+ // record of that, not a claim this line re-derives.
+ fmt.Fprintf(w, "%s for %s\n", c.ok("previous key deleted"), res.App)
+ return
+ }
+ key := ""
+ if res.Provisioned != nil {
+ key = res.Provisioned.KeyID
+ }
+ fmt.Fprintf(w, "%s %s — new key %s\n", c.ok("rotated the store credential for"), res.App, key)
+ mutedf(w, "the old key stays active until `carlos store rotate -finish -app %s`", res.App)
+}
+
+// printStoreStatus answers `carlos store status` — the one sub-verb a member
+// with no box access has for seeing all three of a record's blocks, plus the
+// two facts nothing else in the CLI surfaces: config-delivery drift
+// (missing_vars) and a rotation stuck mid-window (pending_finish).
+//
+// The provisioned timestamp is labelled "last credential change", never
+// "provisioned at" — review finding from the walk design: it MOVES on every
+// rotate and every drift repair, so a fixed-sounding label would misdescribe
+// it the very first time either happens.
+func printStoreStatus(w io.Writer, res apiStoreReply) {
+ c := colorFor(w)
+ fmt.Fprintf(w, "%s %s\n", c.head("store for"), res.App)
+ if res.Backend == "" {
+ warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)")
+ }
+ if res.Declared == nil {
+ mutedf(w, "not declared — carlos store create -app %s", res.App)
+ return
+ }
+ fmt.Fprintf(w, " declared: prefix %s, by %s at %s\n",
+ res.Declared.EnvPrefix, res.Declared.By, res.Declared.At.Format(time.RFC3339))
+
+ if res.Grant != nil {
+ fmt.Fprintf(w, " grant: by %s at %s", res.Grant.By, res.Grant.At.Format(time.RFC3339))
+ if res.Grant.Bucket != "" {
+ // Only set when the grant ADOPTED an existing bucket — a minted
+ // one's name lives in Provisioned.Bucket, printed below.
+ fmt.Fprintf(w, " (adopting bucket %s)", res.Grant.Bucket)
+ }
+ fmt.Fprintln(w)
+ }
+
+ if res.Provisioned != nil {
+ p := res.Provisioned
+ fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s, key %s — last credential change %s\n",
+ p.Bucket, p.Region, p.IAMUser, p.KeyID, p.At.Format(time.RFC3339))
+ } else {
+ mutedf(w, " nothing is provisioned yet — waiting on a deployment operator (carlos store grant -app %s)", res.App)
+ }
+
+ if len(res.EnvNames) > 0 {
+ fmt.Fprintf(w, " delivers: %s\n", strings.Join(res.EnvNames, ", "))
+ }
+ if res.PendingFinish {
+ warnf(w, "a rotation is pending finish — the previous key is still live: carlos store rotate -finish -app %s", res.App)
+ }
+ if len(res.MissingVars) > 0 {
+ // Named as drift, not as a fresh failure: this is the same state a
+ // healthy store passes through for a few seconds after every grant
+ // or rotate, until the config reaches the app. It only means
+ // something is actually wrong once it persists — which this CLI has
+ // no way to know from one snapshot, so the line names the repair
+ // rather than pronouncing a verdict.
+ warnf(w, "drift: %s not yet delivered to this app's config — re-run `carlos store grant -app %s` to "+
+ "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), res.App)
+ }
+}
diff --git a/store_test.go b/store_test.go
new file mode 100644
index 000000000..db046ab76
--- /dev/null
+++ b/store_test.go
@@ -0,0 +1,566 @@
+// Tests for `carlos store` (issue #55): the wire shape the four apiSession
+// methods send (request body/method/path, matching apistore_test.go's server
+// side), the honesty rules the print* functions must follow (create/status/
+// grant/rotate — no line claims a box has converged), and the two ways the
+// verb refuses before it ever dials anything (no -app, direct bucket mode) —
+// driven through the real binary via carlosTestEnv + a scratch cmd.Dir, the
+// same isolation restart_test.go's refusal tests use.
+package main
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os/exec"
+ "strings"
+ "testing"
+)
+
+// ---- apiSession.store* wire shape -------------------------------------------
+
+// TestAPISessionStoreCreateWireShape pins handleAPIStoreCreate's contract: a
+// bearer POST to .../store, and — the review-tested case — a bare create
+// (no -env-prefix) sends the literal `{}` body, never a key with an empty
+// string value, since the door treats an ABSENT key and an empty one the
+// same way but a differently-shaped body is still the wrong thing to send.
+func TestAPISessionStoreCreateWireShape(t *testing.T) {
+ var gotPath, gotMethod, gotAuth, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod, gotAuth = r.URL.RequestURI(), r.Method, r.Header.Get("Authorization")
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = body.String()
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail", "backend": "aws",
+ "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
+ "grant": nil,
+ "provisioned": nil,
+ "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"},
+ "pending_finish": false,
+ "missing_vars": []string{},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "keymail-team"}, "keymail", "")
+ if err != nil {
+ t.Fatalf("storeCreate: %v", err)
+ }
+ if gotMethod != http.MethodPost {
+ t.Fatalf("method = %s, want POST", gotMethod)
+ }
+ if gotPath != "/api/cli/apps/keymail-team/keymail/store" {
+ t.Fatalf("path = %s", gotPath)
+ }
+ if gotAuth != "Bearer cli-token" {
+ t.Fatalf("auth = %q", gotAuth)
+ }
+ if strings.TrimSpace(gotBody) != "{}" {
+ t.Fatalf("body = %q, want the bare {} a default create sends", gotBody)
+ }
+ if res.App != "keymail" || res.Backend != "aws" {
+ t.Fatalf("result = %+v, want the console's echo", res)
+ }
+ if res.Declared == nil || res.Declared.EnvPrefix != "CARLOS_STORE" {
+ t.Fatalf("Declared = %+v, want the echoed prefix", res.Declared)
+ }
+}
+
+// TestAPISessionStoreCreateSendsChosenPrefix pins the other half: a chosen
+// -env-prefix travels verbatim, not silently defaulted client-side (the
+// server owns the default, per handleAPIStoreCreate's own doc comment).
+func TestAPISessionStoreCreateSendsChosenPrefix(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = body.String()
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail", "backend": "aws",
+ "declared": map[string]any{"env_prefix": "TITO_S3", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
+ "env_names": []string{"TITO_S3_BUCKET"},
+ "pending_finish": false,
+ "missing_vars": []string{},
+ })
+ }))
+ defer ts.Close()
+
+ if _, err := newTestSession(ts.URL).storeCreate(context.Background(), apiAcct{Seg: "acct"}, "keymail", "TITO_S3"); err != nil {
+ t.Fatalf("storeCreate: %v", err)
+ }
+ var sent struct {
+ EnvPrefix string `json:"env_prefix"`
+ }
+ if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.EnvPrefix != "TITO_S3" {
+ t.Fatalf("body = %q, want env_prefix TITO_S3", gotBody)
+ }
+}
+
+// TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET
+// to the same .../store path, decoding the full reply including drift and
+// pending-finish.
+func TestAPISessionStoreGetWireShape(t *testing.T) {
+ var gotPath, gotMethod string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath, gotMethod = r.URL.RequestURI(), r.Method
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail", "backend": "aws",
+ "declared": map[string]any{"env_prefix": "CARLOS_STORE", "at": "2026-08-10T12:00:00Z", "by": "a@keymail.dev"},
+ "grant": map[string]any{"backend": "aws", "at": "2026-08-10T12:01:00Z", "by": "ops@keymail.dev"},
+ "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "iam_user": "u1", "user_arn": "arn:aws:iam::1:user/u1", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"},
+ "env_names": []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"},
+ "pending_finish": true,
+ "missing_vars": []string{"CARLOS_STORE_SECRET_KEY"},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail")
+ if err != nil {
+ t.Fatalf("storeGet: %v", err)
+ }
+ if gotMethod != http.MethodGet || gotPath != "/api/cli/apps/acct/keymail/store" {
+ t.Fatalf("method/path = %s %s, want GET .../store", gotMethod, gotPath)
+ }
+ if !res.PendingFinish {
+ t.Fatal("PendingFinish = false, want true")
+ }
+ if len(res.MissingVars) != 1 || res.MissingVars[0] != "CARLOS_STORE_SECRET_KEY" {
+ t.Fatalf("MissingVars = %v, want the one undelivered var", res.MissingVars)
+ }
+ if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA1" {
+ t.Fatalf("Provisioned = %+v, want the echoed key id", res.Provisioned)
+ }
+}
+
+// TestAPISessionStoreGetNotDeclaredSurfacesServerText pins the real (not
+// byte-identical-membership) 404 handleAPIStoreGet answers once membership
+// is already proven: "no store is declared" reaches the terminal verbatim.
+func TestAPISessionStoreGetNotDeclaredSurfacesServerText(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]string{"error": "no store is declared for this app — run `carlos store create`"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).storeGet(context.Background(), apiAcct{Seg: "acct"}, "keymail")
+ if err == nil || !strings.Contains(err.Error(), "no store is declared") {
+ t.Fatalf("storeGet 404 = %v, want the server's own text", err)
+ }
+}
+
+// TestAPISessionStoreGrantWireShape pins handleAPIStoreGrant's contract: a
+// POST to .../store/grant, and — the review-tested case — a bare grant (no
+// -bucket) sends `{}`.
+func TestAPISessionStoreGrantWireShape(t *testing.T) {
+ var gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.RequestURI()
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = body.String()
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail", "backend": "aws",
+ "provisioned": map[string]any{"bucket": "carlos-keymail-abc", "region": "eu-west-1", "iam_user": "carlos-keymail", "user_arn": "arn:aws:iam::1:user/carlos-keymail", "key_id": "AKIA1", "at": "2026-08-10T12:02:00Z"},
+ "env_names": []string{"CARLOS_STORE_BUCKET"},
+ "pending_finish": false,
+ "missing_vars": []string{},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "")
+ if err != nil {
+ t.Fatalf("storeGrant: %v", err)
+ }
+ if gotPath != "/api/cli/apps/acct/keymail/store/grant" {
+ t.Fatalf("path = %q, want the grant door", gotPath)
+ }
+ if strings.TrimSpace(gotBody) != "{}" {
+ t.Fatalf("body = %q, want the bare {} a default grant sends", gotBody)
+ }
+ if res.Provisioned == nil || res.Provisioned.Bucket != "carlos-keymail-abc" {
+ t.Fatalf("Provisioned = %+v, want the minted bucket echoed", res.Provisioned)
+ }
+}
+
+// TestAPISessionStoreGrantSendsChosenBucket pins the adopt case: a -bucket
+// travels verbatim in the request body.
+func TestAPISessionStoreGrantSendsChosenBucket(t *testing.T) {
+ var gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = body.String()
+ json.NewEncoder(w).Encode(map[string]any{"app": "keymail", "env_names": []string{}, "missing_vars": []string{}})
+ }))
+ defer ts.Close()
+
+ if _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "my-existing-bucket"); err != nil {
+ t.Fatalf("storeGrant: %v", err)
+ }
+ var sent struct {
+ Bucket string `json:"bucket"`
+ }
+ if err := json.Unmarshal([]byte(gotBody), &sent); err != nil || sent.Bucket != "my-existing-bucket" {
+ t.Fatalf("body = %q, want bucket my-existing-bucket", gotBody)
+ }
+}
+
+// TestAPISessionStoreGrant403SurfacesVerbatim pins the operator-allowlist
+// refusal: printed exactly as the console worded it, never pattern-matched
+// or rewritten — the same rule apiErrorFromBody already gives every other
+// verb's server diagnosis.
+func TestAPISessionStoreGrant403SurfacesVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ json.NewEncoder(w).Encode(map[string]string{"error": "store grants need a deployment operator (CARLOS_STORE_OPERATORS)"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).storeGrant(context.Background(), apiAcct{Seg: "acct"}, "keymail", "")
+ if err == nil || err.Error() != "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" {
+ t.Fatalf("storeGrant 403 = %v, want the console's own text byte-for-byte", err)
+ }
+}
+
+// TestAPISessionStoreRotateWireShape pins handleAPIStoreRotate's contract:
+// POST .../store/rotate, no body.
+func TestAPISessionStoreRotateWireShape(t *testing.T) {
+ var gotPath, gotBody string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.RequestURI()
+ body := new(bytes.Buffer)
+ body.ReadFrom(r.Body)
+ gotBody = body.String()
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail",
+ "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "prev_key_id": "AKIA1", "at": "2026-08-10T12:03:00Z"},
+ "pending_finish": true,
+ "env_names": []string{},
+ "missing_vars": []string{},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false)
+ if err != nil {
+ t.Fatalf("storeRotate: %v", err)
+ }
+ if gotPath != "/api/cli/apps/acct/keymail/store/rotate" {
+ t.Fatalf("path = %q, want the rotate door (not /finish)", gotPath)
+ }
+ if strings.TrimSpace(gotBody) != "{}" {
+ t.Fatalf("body = %q, want no arguments", gotBody)
+ }
+ if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA2" {
+ t.Fatalf("Provisioned = %+v, want the new key echoed", res.Provisioned)
+ }
+}
+
+// TestAPISessionStoreRotateFinishHitsFinishDoor pins the ONE thing that
+// distinguishes rotate from rotate -finish on the wire: which path is
+// POSTed. Everything else about the request is identical.
+func TestAPISessionStoreRotateFinishHitsFinishDoor(t *testing.T) {
+ var gotPath string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ gotPath = r.URL.RequestURI()
+ json.NewEncoder(w).Encode(map[string]any{
+ "app": "keymail",
+ "provisioned": map[string]any{"bucket": "b1", "region": "eu-west-1", "key_id": "AKIA2", "at": "2026-08-10T12:04:00Z"},
+ "pending_finish": false,
+ "env_names": []string{},
+ "missing_vars": []string{},
+ })
+ }))
+ defer ts.Close()
+
+ res, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", true)
+ if err != nil {
+ t.Fatalf("storeRotate(finish=true): %v", err)
+ }
+ if gotPath != "/api/cli/apps/acct/keymail/store/rotate/finish" {
+ t.Fatalf("path = %q, want the finish door", gotPath)
+ }
+ if res.PendingFinish {
+ t.Fatal("PendingFinish = true after finish, want false")
+ }
+}
+
+// TestAPISessionStoreRotateConflictSurfacesVerbatim pins the "nothing
+// provisioned to rotate" 409, printed as the console worded it.
+func TestAPISessionStoreRotateConflictSurfacesVerbatim(t *testing.T) {
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusConflict)
+ json.NewEncoder(w).Encode(map[string]string{"error": "no provisioned store to rotate — grant one first"})
+ }))
+ defer ts.Close()
+
+ _, err := newTestSession(ts.URL).storeRotate(context.Background(), apiAcct{Seg: "acct"}, "keymail", false)
+ if err == nil || !strings.Contains(err.Error(), "grant one first") {
+ t.Fatalf("storeRotate 409 = %v, want the console's own text", err)
+ }
+}
+
+// ---- print* output honesty ---------------------------------------------------
+//
+// Each print function is driven directly against a literal apiStoreReply —
+// no stub server, no credential file — so the wording rules stay pinned
+// independent of the wire plumbing above.
+
+func TestPrintStoreCreate(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreCreate(&buf, apiStoreReply{
+ App: "keymail",
+ Backend: "aws",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ EnvNames: []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION"},
+ })
+ out := buf.String()
+ for _, want := range []string{"keymail", "CARLOS_STORE", "CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION",
+ "nothing is provisioned", "operator grants it"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("printStoreCreate missing %q:\n%s", want, out)
+ }
+ }
+ if strings.Contains(out, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("printStoreCreate with a configured backend must not print the not-configured line:\n%s", out)
+ }
+}
+
+// TestPrintStoreCreateNoBackendWarnsOperatorGap pins the honesty rule: a
+// deployment with NO object storage backend at all must say so, naming the
+// env var an operator sets — a member cannot see CARLOS_STORE_BACKEND for
+// themselves, and "declared, nothing happening" with no way to tell a queued
+// request from one that will never be granted fails the no-infra-access rule.
+func TestPrintStoreCreateNoBackendWarnsOperatorGap(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreCreate(&buf, apiStoreReply{
+ App: "keymail",
+ Backend: "",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ EnvNames: []string{"CARLOS_STORE_BUCKET"},
+ })
+ out := buf.String()
+ if !strings.Contains(out, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("printStoreCreate with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out)
+ }
+ if !strings.Contains(out, "no object storage backend") {
+ t.Fatalf("printStoreCreate with backend==\"\" must say this deployment has none:\n%s", out)
+ }
+}
+
+func TestPrintStoreGrant(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreGrant(&buf, apiStoreReply{
+ App: "keymail",
+ Provisioned: &apiStoreProvisioned{
+ Bucket: "carlos-keymail-abc", Region: "eu-west-1", IAMUser: "carlos-keymail",
+ },
+ })
+ out := buf.String()
+ for _, want := range []string{"keymail", "carlos-keymail-abc", "eu-west-1", "carlos-keymail",
+ "sealed into the app's config", "within seconds"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("printStoreGrant missing %q:\n%s", want, out)
+ }
+ }
+ // The lesson restart.go paid for: never claim a box has already acted.
+ for _, mustNotClaim := range []string{"instance has the", "instances have the", "delivered"} {
+ if strings.Contains(strings.ToLower(out), strings.ToLower(mustNotClaim)) {
+ t.Fatalf("printStoreGrant over-claims delivery already happened (%q):\n%s", mustNotClaim, out)
+ }
+ }
+}
+
+func TestPrintStoreRotate(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreRotate(&buf, apiStoreReply{
+ App: "keymail",
+ Provisioned: &apiStoreProvisioned{KeyID: "AKIANEW"},
+ }, false)
+ out := buf.String()
+ for _, want := range []string{"keymail", "AKIANEW", "old key stays active", "carlos store rotate -finish -app keymail"} {
+ if !strings.Contains(out, want) {
+ t.Fatalf("printStoreRotate missing %q:\n%s", want, out)
+ }
+ }
+}
+
+func TestPrintStoreRotateFinish(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreRotate(&buf, apiStoreReply{App: "keymail"}, true)
+ out := buf.String()
+ if !strings.Contains(out, "previous key deleted") || !strings.Contains(out, "keymail") {
+ t.Fatalf("printStoreRotate(finish) = %q, want the previous-key-deleted confirmation", out)
+ }
+ if strings.Contains(out, "stays active") {
+ t.Fatalf("finish output must not repeat the pending-finish nag:\n%s", out)
+ }
+}
+
+// TestPrintStoreStatusPendingFinishNag pins the nag: a status read on a
+// half-rotated store must name the exact finish command.
+func TestPrintStoreStatusPendingFinishNag(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{
+ App: "keymail",
+ Backend: "aws",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ Provisioned: &apiStoreProvisioned{
+ Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA2", PrevKeyID: "AKIA1",
+ },
+ PendingFinish: true,
+ })
+ out := buf.String()
+ if !strings.Contains(out, "pending finish") {
+ t.Fatalf("status output missing the pending-finish nag:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos store rotate -finish -app keymail") {
+ t.Fatalf("status output does not name the exact finish command:\n%s", out)
+ }
+ if !strings.Contains(out, "last credential change") {
+ t.Fatalf("status output must label the provisioned timestamp \"last credential change\":\n%s", out)
+ }
+ if strings.Contains(out, "provisioned at") {
+ t.Fatalf("status output must NOT say \"provisioned at\" — that label goes stale on rotate/repair:\n%s", out)
+ }
+}
+
+// TestPrintStoreStatusMissingVarsWarnsDriftAndRepair pins the drift warning:
+// it must name `carlos store grant` as the repair AND note that a re-run
+// rotates the credential — the two facts the reviewer required together, so
+// nobody re-runs grant expecting a no-op.
+func TestPrintStoreStatusMissingVarsWarnsDriftAndRepair(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{
+ App: "keymail",
+ Backend: "aws",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ Provisioned: &apiStoreProvisioned{
+ Bucket: "b1", Region: "eu-west-1", IAMUser: "u1", KeyID: "AKIA1",
+ },
+ MissingVars: []string{"CARLOS_STORE_SECRET_KEY"},
+ })
+ out := buf.String()
+ if !strings.Contains(out, "CARLOS_STORE_SECRET_KEY") {
+ t.Fatalf("status output missing the drifted variable's name:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos store grant -app keymail") {
+ t.Fatalf("status output does not name the repair command:\n%s", out)
+ }
+ if !strings.Contains(out, "rotates the credential") {
+ t.Fatalf("status output does not warn that the repair rotates the credential:\n%s", out)
+ }
+}
+
+// TestPrintStoreStatusNoBackend pins the same not-configured line status
+// owes that create does.
+func TestPrintStoreStatusNoBackend(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}})
+ out := buf.String()
+ if !strings.Contains(out, "CARLOS_STORE_BACKEND") {
+ t.Fatalf("status output with backend==\"\" must name CARLOS_STORE_BACKEND:\n%s", out)
+ }
+}
+
+// TestPrintStoreStatusNotDeclared pins the "nothing to show" shape — no
+// panics on nil blocks, and the create command named as the way forward.
+func TestPrintStoreStatusNotDeclared(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{App: "keymail", Backend: "aws"})
+ out := buf.String()
+ if !strings.Contains(out, "not declared") || !strings.Contains(out, "carlos store create -app keymail") {
+ t.Fatalf("status output for an undeclared store = %q, want the create pointer", out)
+ }
+}
+
+// TestPrintStoreStatusDeclaredNotProvisioned pins the waiting-on-an-operator
+// state: a declared-but-not-granted store must say it is waiting, not
+// silently omit the provisioned section.
+func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) {
+ var buf bytes.Buffer
+ printStoreStatus(&buf, apiStoreReply{
+ App: "keymail", Backend: "aws",
+ Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"},
+ })
+ out := buf.String()
+ if !strings.Contains(out, "waiting on a deployment operator") {
+ t.Fatalf("status output for a declared-not-granted store missing the waiting line:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos store grant -app keymail") {
+ t.Fatalf("status output missing the grant pointer:\n%s", out)
+ }
+}
+
+// ---- runStore dispatch / refusals (real binary) -----------------------------
+
+// TestStoreVerbDispatchAndRefusals drives the real binary through
+// carlosTestEnv with a scratch cmd.Dir — the repo rule (CLAUDE.md) — so a
+// logged-in laptop's real credential and console can never be what this test
+// exercises: unknown sub-verb, missing -app on each sub-verb, and direct
+// bucket mode refused with store's own sentence.
+func TestStoreVerbDispatchAndRefusals(t *testing.T) {
+ bin := buildCarlos(t)
+ scratch := t.TempDir()
+ run := func(env []string, args ...string) (string, error) {
+ t.Helper()
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t, env...)
+ cmd.Dir = scratch
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+ }
+
+ // No sub-verb at all, and an unknown one: both print the usage block.
+ for _, args := range [][]string{{"store"}, {"store", "wat"}} {
+ out, err := run(nil, args...)
+ if err == nil {
+ t.Fatalf("carlos %v exited 0:\n%s", args, out)
+ }
+ if !strings.Contains(out, "usage: carlos store <create|status|grant|rotate>") {
+ t.Fatalf("carlos %v = %q, want the store usage block", args, out)
+ }
+ }
+
+ // Missing -app, one representative per sub-verb (each builds its own
+ // usage line — the shape restart.go's own missing-app test pins).
+ for _, tc := range []struct {
+ sub, want string
+ }{
+ {"create", "usage: carlos store create -app"},
+ {"status", "usage: carlos store status -app"},
+ {"grant", "usage: carlos store grant -app"},
+ {"rotate", "usage: carlos store rotate -app"},
+ } {
+ out, err := run(nil, "store", tc.sub)
+ if err == nil {
+ t.Fatalf("carlos store %s with no -app exited 0:\n%s", tc.sub, out)
+ }
+ if !strings.Contains(out, tc.want) {
+ t.Fatalf("carlos store %s with no -app = %q, want %q", tc.sub, out, tc.want)
+ }
+ }
+
+ // Direct bucket mode: refused in store's OWN words, naming the login
+ // that fixes it — never another verb's advice.
+ out, err := run([]string{"CARLOS_DEPLOYMENT_DIR=" + t.TempDir()}, "store", "status", "-app", "keymail")
+ if err == nil {
+ t.Fatalf("store in direct bucket mode exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "carlos store works through a console") || !strings.Contains(out, "carlos auth login") {
+ t.Fatalf("direct-mode refusal = %q, want store's own message and the login fix", out)
+ }
+
+ // A malshaped app name is refused at the terminal before any dial.
+ out, err = run(nil, "store", "status", "-app", "Not An App")
+ if err == nil {
+ t.Fatalf("store status with a bad app name exited 0:\n%s", out)
+ }
+ if !strings.Contains(out, "not a usable app name") {
+ t.Fatalf("bad app name = %q, want the name gate", out)
+ }
+}