carlos / platform Public

carlos store: the verbs for applying, and the sentences that say where you stand

Stage 6 of #381. The CLI side of the two doors: apply for an account, the
operator's approvals queue, approve and decline. The status line grows the
entitlement, so `carlos store status` answers the question a member actually
has — not just "is there a bucket" but "may this account have one, and if
not, who is that waiting on".

The two sentences §6 named change with it. Both used to end a member's
information at "an operator must act", which is true and useless; they now
name the door that member can walk through themselves.

Approve and decline take the account sqid explicitly and never default it
from the caller's memberships, matching the doors they call: an operator is
not a member of the accounts they decide about, so a membership-shaped
default would resolve to the wrong account or to none.

Built by codex (gpt-6-astra) from the brief committed at 7c93be2.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CfUNFHm89j2mj5q7vBHsfH
Paul Campbell pushed by paul@keymail.dev c48d0ee853bdd14003c3b108f0f040df938e7235 parent 12f9044
4 files changed, +514 −16
  • apimode.go +88 −0
  • main.go +1 −1
  • store.go +210 −12
  • store_test.go +215 −3
diff --git a/apimode.go b/apimode.go
index e346114ac..dbb8e305b 100644
--- a/apimode.go
+++ b/apimode.go
@@ -420,6 +420,14 @@ func apiPath(a apiAcct, app, tail string) string {
return p
}
+func apiAccountStorePath(a apiAcct, tail string) string {
+ p := "/api/cli/accounts/" + url.PathEscape(a.Seg) + "/store/" + tail
+ if a.Exact {
+ p += "?exact=sqid"
+ }
+ return p
+}
+
// listAccounts fetches the caller's own memberships: GET /api/cli/apps'
// "accounts" array (handleAPIApps — live since main 5856dc5, 2026-08-03).
// supported=false means the console predates the endpoint: the bare mux
@@ -1561,6 +1569,86 @@ type apiStoreReply struct {
const storeProvisionHTTPTimeout = 75 * time.Second
+type apiStoreAccountReply struct {
+ Account string `json:"account"`
+ Entitlement *objstore.Entitlement `json:"entitlement"`
+}
+
+type apiStoreApprovalsReply struct {
+ Applications []*objstore.Entitlement `json:"applications"`
+}
+
+func decodeStoreAccountReply(action string, status int, body []byte, err error) (apiStoreAccountReply, error) {
+ if err != nil {
+ return apiStoreAccountReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreAccountReply{}, storeOperatorError(status, body)
+ }
+ var out apiStoreAccountReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreAccountReply{}, fmt.Errorf("store %s: unreadable response: %w", action, err)
+ }
+ return out, nil
+}
+
+func storeOperatorError(status int, body []byte) error {
+ var e struct {
+ Error string `json:"error"`
+ }
+ if json.Unmarshal(body, &e) == nil && e.Error != "" {
+ return errors.New(e.Error)
+ }
+ if status == http.StatusNotFound {
+ return errors.New("not found: check the account sqid and that your login has object storage operator access")
+ }
+ return fmt.Errorf("unexpected response (status %d)", status)
+}
+
+func (s *apiSession) storeApply(ctx context.Context, a apiAcct, note string) (apiStoreAccountReply, error) {
+ status, body, err := s.postJSON(ctx, apiAccountStorePath(a, "apply"), struct {
+ Note string `json:"note,omitempty"`
+ }{note})
+ if err != nil {
+ return apiStoreAccountReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreAccountReply{}, apiErrorFromBody(status, body)
+ }
+ var out apiStoreAccountReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreAccountReply{}, fmt.Errorf("store apply: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
+func (s *apiSession) storeApprove(ctx context.Context, account string) (apiStoreAccountReply, error) {
+ status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/approve", struct{}{})
+ return decodeStoreAccountReply("approve", status, body, err)
+}
+
+func (s *apiSession) storeDecline(ctx context.Context, account, reason string) (apiStoreAccountReply, error) {
+ status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/store/decline", struct {
+ Reason string `json:"reason"`
+ }{reason})
+ return decodeStoreAccountReply("decline", status, body, err)
+}
+
+func (s *apiSession) storeApprovals(ctx context.Context) (apiStoreApprovalsReply, error) {
+ status, body, err := s.getJSON(ctx, "/api/cli/store/approvals")
+ if err != nil {
+ return apiStoreApprovalsReply{}, err
+ }
+ if status != http.StatusOK {
+ return apiStoreApprovalsReply{}, storeOperatorError(status, body)
+ }
+ var out apiStoreApprovalsReply
+ if err := json.Unmarshal(body, &out); err != nil {
+ return apiStoreApprovalsReply{}, fmt.Errorf("store approvals: unreadable response: %w", err)
+ }
+ return out, nil
+}
+
// 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
diff --git a/main.go b/main.go
index 71aa9a3c6..1a9898439 100644
--- a/main.go
+++ b/main.go
@@ -167,7 +167,7 @@ Your apps — ship and run software on a CARLOS deployment
deploy ship + promote + wait until the URL serves the new version
restart cycle an app's instances — no new version, no config change
schedule recurring or one-shot work per app: ls | set | rm | run
- store object storage for an app: create | status | grant | rotate
+ store object storage: create | status | apply | approvals | approve | decline | grant | rotate
email tenant email sending: enable | domains add | status | test |
credentials create/list/revoke | rotate | pause | resume
rollback point a channel back at an earlier version
diff --git a/store.go b/store.go
index ef141f7f9..2781bc93d 100644
--- a/store.go
+++ b/store.go
@@ -29,6 +29,7 @@ package main
import (
"context"
+ "errors"
"flag"
"fmt"
"io"
@@ -36,19 +37,29 @@ import (
"os"
"strings"
"time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/objstore"
)
-const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags]
+const storeUsage = `usage: carlos store <create|status|apply|approvals|approve|decline|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.
+ an env prefix (default CARLOS_STORE). For an
+ approved account, this also provisions the store.
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.
+ apply [--note <text>] apply for object storage for an account you own.
+ approvals list pending account applications (operator only).
+ approve --account <sqid>
+ approve an account for object storage (operator
+ only; an explicit account sqid is required).
+ decline --account <sqid> --reason <text>
+ decline an account application (operator only;
+ an explicit account sqid and reason are required).
grant --app <a> [--bucket <name>]
provision the declared store and deliver its
credentials. Needs a deployment operator
@@ -68,10 +79,13 @@ const storeUsage = `usage: carlos store <create|status|grant|rotate> [flags]
--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
+Apply, create, status, grant and rotate resolve accounts through your own
+memberships. Approve and decline take an explicit sqid and do not require
+membership in the target account.
+
+Signed-in only (carlos auth login): storage records live 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.
+cloud account — there is no direct-bucket equivalent for these verbs.
`
// runStore dispatches the store sub-verbs, mirroring RunApps's arg slicing
@@ -91,6 +105,14 @@ func runStore(args []string) {
runStoreGrant(args[1:])
case "rotate":
runStoreRotate(args[1:])
+ case "apply":
+ runStoreApply(args[1:])
+ case "approvals":
+ runStoreApprovals(args[1:])
+ case "approve":
+ runStoreApprove(args[1:])
+ case "decline":
+ runStoreDecline(args[1:])
default:
fmt.Fprint(os.Stderr, storeUsage)
os.Exit(1)
@@ -165,7 +187,7 @@ func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct
if err != nil {
return err
}
- printStoreGrant(out, res)
+ printStoreCreate(out, res)
return nil
}
@@ -249,6 +271,117 @@ func runStoreRotate(args []string) {
printStoreRotate(stdout, res, *finish)
}
+func runStoreApply(args []string) {
+ fs := flag.NewFlagSet("store apply", flag.ExitOnError)
+ note := fs.String("note", "", "what you plan to use object storage for")
+ acctRaw, acctIDRaw, _ := accountFlags(fs)
+ consoleFlag := fs.String("console", "", "console origin to apply through")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if fs.NArg() != 0 {
+ log.Fatalf("store apply: unexpected argument %q", fs.Arg(0))
+ }
+ sess := requireStoreAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ res, err := sess.storeApply(context.Background(), acct, strings.TrimSpace(*note))
+ if err != nil {
+ log.Fatalf("store apply: %v", err)
+ }
+ printStoreApplication(stdout, res)
+}
+
+func runStoreApprovals(args []string) {
+ fs := flag.NewFlagSet("store approvals", flag.ExitOnError)
+ consoleFlag := fs.String("console", "", "console origin to read through")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if fs.NArg() != 0 {
+ log.Fatalf("store approvals: unexpected argument %q", fs.Arg(0))
+ }
+ sess := requireStoreAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ res, err := sess.storeApprovals(context.Background())
+ if err != nil {
+ log.Fatalf("store approvals: %v", err)
+ }
+ printStoreApprovals(stdout, res)
+}
+
+func explicitStoreAccount(raw string) (string, error) {
+ account := strings.TrimSpace(raw)
+ if account == "" {
+ return "", fmt.Errorf("--account requires an account sqid")
+ }
+ if !domains.ValidSqid(account) {
+ return "", fmt.Errorf("--account %q is not an account sqid", account)
+ }
+ return account, nil
+}
+
+func runStoreApprove(args []string) {
+ if err := runStoreApproveWithSession(context.Background(), nil, args, stdout); err != nil {
+ if errors.Is(err, flag.ErrHelp) {
+ return
+ }
+ log.Fatalf("store approve: %v", err)
+ }
+}
+
+func runStoreApproveWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error {
+ fs := flag.NewFlagSet("store approve", flag.ContinueOnError)
+ fs.SetOutput(out)
+ accountFlag := fs.String("account", "", "account sqid to approve")
+ consoleFlag := fs.String("console", "", "console origin to approve through")
+ fs.Usage = flagUsage(fs, "")
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+ if fs.NArg() != 0 {
+ return fmt.Errorf("unexpected argument %q", fs.Arg(0))
+ }
+ account, err := explicitStoreAccount(*accountFlag)
+ if err != nil {
+ return err
+ }
+ if sess == nil {
+ sess = requireStoreAPISession(*consoleFlag)
+ printUsing(out, sess)
+ }
+ res, err := sess.storeApprove(ctx, account)
+ if err != nil {
+ return err
+ }
+ printStoreApplication(out, res)
+ return nil
+}
+
+func runStoreDecline(args []string) {
+ fs := flag.NewFlagSet("store decline", flag.ExitOnError)
+ accountFlag := fs.String("account", "", "account sqid to decline")
+ reason := fs.String("reason", "", "reason for declining object storage")
+ consoleFlag := fs.String("console", "", "console origin to decline through")
+ fs.Usage = flagUsage(fs, "")
+ fs.Parse(args)
+ if fs.NArg() != 0 {
+ log.Fatalf("store decline: unexpected argument %q", fs.Arg(0))
+ }
+ account, err := explicitStoreAccount(*accountFlag)
+ if err != nil {
+ log.Fatalf("store decline: %v", err)
+ }
+ if strings.TrimSpace(*reason) == "" {
+ log.Fatal("store decline: --reason is required")
+ }
+ sess := requireStoreAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ res, err := sess.storeDecline(context.Background(), account, strings.TrimSpace(*reason))
+ if err != nil {
+ log.Fatalf("store decline: %v", err)
+ }
+ printStoreApplication(stdout, res)
+}
+
// ---- output -----------------------------------------------------------------
//
// Every function below takes the reply the console already sent and prints
@@ -273,8 +406,23 @@ func printStoreCreate(w io.Writer, res apiStoreReply) {
}
fmt.Fprintf(w, "%s %s under prefix %s — delivers %s\n",
c.ok("declared a store for"), res.App, prefix, strings.Join(res.EnvNames, ", "))
+ if res.Provisioned != nil {
+ p := res.Provisioned
+ fmt.Fprintf(w, " provisioned: bucket %s in %s, user %s\n", p.Bucket, p.Region, p.IAMUser)
+ if len(res.MissingVars) > 0 {
+ verb := "grant"
+ if res.Entitlement.Approved() {
+ verb = "create"
+ }
+ warnf(w, "credentials are not fully delivered: %s — re-run `carlos store %s --app %s` to repair",
+ strings.Join(res.MissingVars, ", "), verb, res.App)
+ } else {
+ mutedf(w, "credentials are sealed into the app's config — running instances pick them up within seconds")
+ }
+ return
+ }
if !res.Entitlement.Approved() {
- mutedf(w, "nothing is provisioned until a deployment operator grants it (carlos store grant --app %s)", res.App)
+ mutedf(w, "this account is not approved for object storage — `carlos store apply`")
}
if res.Backend == "" {
warnf(w, "this deployment has no object storage backend configured yet (the operator sets "+
@@ -339,6 +487,7 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
if res.Backend == "" {
warnf(w, "this deployment has no object storage backend configured yet (the operator sets CARLOS_STORE_BACKEND)")
}
+ printStoreEntitlement(w, res.Entitlement)
if res.Declared == nil {
mutedf(w, "not declared — carlos store create --app %s", res.App)
return
@@ -362,7 +511,11 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
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 res.Entitlement.Approved() {
+ mutedf(w, " nothing is provisioned yet — re-run carlos store create --app %s", res.App)
+ } else {
+ mutedf(w, " nothing is provisioned yet — waiting on a deployment operator (carlos store grant --app %s)", res.App)
+ }
}
if len(res.EnvNames) > 0 {
@@ -378,7 +531,52 @@ func printStoreStatus(w io.Writer, res apiStoreReply) {
// 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)
+ verb := "grant"
+ if res.Entitlement.Approved() {
+ verb = "create"
+ }
+ warnf(w, "drift: %s not yet delivered to this app's config — re-run `carlos store %s --app %s` to "+
+ "repair (this rotates the credential)", strings.Join(res.MissingVars, ", "), verb, res.App)
+ }
+}
+
+func printStoreEntitlement(w io.Writer, entitlement *objstore.Entitlement) {
+ switch {
+ case entitlement.Approved():
+ case entitlement.Declined():
+ warnf(w, "object storage was declined: %s — the account owner may apply again",
+ entitlement.Decision.Reason)
+ case entitlement.Pending():
+ mutedf(w, "applied by %s on %s, waiting for a deployment operator",
+ entitlement.Applied.By, entitlement.Applied.At.Format(time.RFC3339))
+ default:
+ mutedf(w, "this account is not approved for object storage — `carlos store apply`")
+ }
+}
+
+func printStoreApplication(w io.Writer, res apiStoreAccountReply) {
+ e := res.Entitlement
+ switch {
+ case e != nil && e.Approved():
+ fmt.Fprintf(w, "%s %s for object storage\n", colorFor(w).ok("approved account"), res.Account)
+ case e != nil && e.Declined():
+ fmt.Fprintf(w, "%s %s for object storage — %s\n", colorFor(w).ok("declined account"), res.Account, e.Decision.Reason)
+ default:
+ fmt.Fprintf(w, "%s %s — waiting for a deployment operator\n", colorFor(w).ok("applied for object storage for account"), res.Account)
+ }
+}
+
+func printStoreApprovals(w io.Writer, res apiStoreApprovalsReply) {
+ if len(res.Applications) == 0 {
+ mutedf(w, "no pending object storage applications")
+ return
+ }
+ for _, e := range res.Applications {
+ fmt.Fprintf(w, "%s — applied by %s on %s\n", e.Account, e.Applied.By, e.Applied.At.Format(time.RFC3339))
+ if e.Applied.Note != "" {
+ fmt.Fprintf(w, " note: %s\n", e.Applied.Note)
+ }
+ mutedf(w, " carlos store approve --account %s", e.Account)
+ mutedf(w, " carlos store decline --account %s --reason <text>", e.Account)
}
}
diff --git a/store_test.go b/store_test.go
index 05c21a354..29416223a 100644
--- a/store_test.go
+++ b/store_test.go
@@ -11,6 +11,9 @@ import (
"bytes"
"context"
"encoding/json"
+ "errors"
+ "flag"
+ "io"
"net/http"
"net/http/httptest"
"os/exec"
@@ -195,6 +198,122 @@ func TestStoreProvisionUsesLongWalkClient(t *testing.T) {
}
}
+func TestStoreOperatorDoorsUseExplicitSqidWithoutMembershipResolution(t *testing.T) {
+ var paths []string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.Method+" "+r.URL.RequestURI())
+ json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, err := s.storeApprove(context.Background(), "bac"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.storeDecline(context.Background(), "bac", "not yet"); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{
+ "POST /api/cli/accounts/bac/store/approve",
+ "POST /api/cli/accounts/bac/store/decline",
+ }
+ if strings.Join(paths, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("paths = %#v, want %#v", paths, want)
+ }
+ if s.acctsFetched {
+ t.Fatal("operator decision fetched memberships")
+ }
+}
+
+func TestRunStoreApproveWithSessionDoesNotResolveMemberships(t *testing.T) {
+ var path string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ path = r.URL.RequestURI()
+ json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ var out bytes.Buffer
+ if err := runStoreApproveWithSession(context.Background(), s, []string{"--account", "bac"}, &out); err != nil {
+ t.Fatal(err)
+ }
+ if path != "/api/cli/accounts/bac/store/approve" {
+ t.Fatalf("path = %q", path)
+ }
+ if s.acctsFetched {
+ t.Fatal("store approve fetched memberships")
+ }
+}
+
+func TestRunStoreApproveHelpAndValidationStopBeforeNetwork(t *testing.T) {
+ hits := 0
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ hits++
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+
+ var help bytes.Buffer
+ if err := runStoreApproveWithSession(context.Background(), s, []string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) {
+ t.Fatalf("--help error = %v, want flag.ErrHelp", err)
+ }
+ if !strings.Contains(help.String(), "usage: carlos store approve") || !strings.Contains(help.String(), "--account") {
+ t.Fatalf("--help output = %q", help.String())
+ }
+ for _, args := range [][]string{{"--account", "account-name"}, {"--account", "bac", "extra"}, {}} {
+ if err := runStoreApproveWithSession(context.Background(), s, args, io.Discard); err == nil {
+ t.Fatalf("args %q succeeded", args)
+ }
+ }
+ if hits != 0 {
+ t.Fatalf("validation made %d requests", hits)
+ }
+}
+
+func TestStoreApplyUsesOwnerAccountPathAndApprovalsUseFleetPath(t *testing.T) {
+ var paths []string
+ ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ paths = append(paths, r.Method+" "+r.URL.RequestURI())
+ if r.URL.Path == "/api/cli/store/approvals" {
+ json.NewEncoder(w).Encode(apiStoreApprovalsReply{Applications: []*objstore.Entitlement{{Account: "bac"}}})
+ return
+ }
+ json.NewEncoder(w).Encode(apiStoreAccountReply{Account: "bac", Entitlement: &objstore.Entitlement{Account: "bac"}})
+ }))
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ if _, err := s.storeApply(context.Background(), apiAcct{Seg: "bac", Exact: true}, "needed for backups"); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.storeApprovals(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ want := []string{"POST /api/cli/accounts/bac/store/apply?exact=sqid", "GET /api/cli/store/approvals"}
+ if strings.Join(paths, "\n") != strings.Join(want, "\n") {
+ t.Fatalf("paths = %#v, want %#v", paths, want)
+ }
+}
+
+func TestStoreOperatorBareNotFoundDoesNotSuggestMembership(t *testing.T) {
+ ts := httptest.NewServer(http.NotFoundHandler())
+ defer ts.Close()
+ s := newTestSession(ts.URL)
+ for name, call := range map[string]func() error{
+ "approve": func() error { _, err := s.storeApprove(context.Background(), "bac"); return err },
+ "decline": func() error { _, err := s.storeDecline(context.Background(), "bac", "no"); return err },
+ "approvals": func() error { _, err := s.storeApprovals(context.Background()); return err },
+ } {
+ t.Run(name, func(t *testing.T) {
+ err := call()
+ if err == nil || !strings.Contains(err.Error(), "operator access") {
+ t.Fatalf("error = %v, want operator guidance", err)
+ }
+ if strings.Contains(err.Error(), "member") {
+ t.Fatalf("operator error suggests target membership: %v", err)
+ }
+ })
+ }
+}
+
// TestAPISessionStoreGetWireShape pins handleAPIStoreGet's contract: a GET
// to the same .../store path, decoding the full reply including drift and
// pending-finish.
@@ -205,7 +324,7 @@ func TestAPISessionStoreGetWireShape(t *testing.T) {
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"},
+ "grant": map[string]any{"backend": "aws", "at": "2026-08-10T12:01:00Z", "by": "ops@keymail.dev", "source": "account-approval"},
"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,
@@ -230,6 +349,9 @@ func TestAPISessionStoreGetWireShape(t *testing.T) {
if res.Provisioned == nil || res.Provisioned.KeyID != "AKIA1" {
t.Fatalf("Provisioned = %+v, want the echoed key id", res.Provisioned)
}
+ if res.Grant == nil || res.Grant.Source != "account-approval" {
+ t.Fatalf("Grant = %+v, want account-approval source", res.Grant)
+ }
}
// TestAPISessionStoreGetNotDeclaredSurfacesServerText pins the real (not
@@ -417,7 +539,7 @@ func TestPrintStoreCreate(t *testing.T) {
})
out := buf.String()
for _, want := range []string{"keymail", "CARLOS_STORE", "CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION",
- "nothing is provisioned", "operator grants it"} {
+ "this account is not approved for object storage", "carlos store apply"} {
if !strings.Contains(out, want) {
t.Fatalf("printStoreCreate missing %q:\n%s", want, out)
}
@@ -593,6 +715,96 @@ func TestPrintStoreStatusDeclaredNotProvisioned(t *testing.T) {
}
}
+func TestPrintStoreEntitlementStates(t *testing.T) {
+ when := time.Date(2026, 9, 2, 15, 4, 5, 0, time.UTC)
+ tests := []struct {
+ name string
+ ent *objstore.Entitlement
+ want []string
+ absent []string
+ }{
+ {name: "not applied", want: []string{"this account is not approved for object storage", "carlos store apply"}},
+ {name: "pending", ent: &objstore.Entitlement{Applied: &objstore.Applied{By: "owner@example.com", At: when}}, want: []string{"applied by owner@example.com on 2026-09-02T15:04:05Z", "waiting for a deployment operator"}},
+ {name: "approved decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Approved: true}}, absent: []string{"not approved", "waiting for a deployment operator", "declined"}},
+ {name: "declined decision only", ent: &objstore.Entitlement{Decision: &objstore.Decision{Reason: "need billing details"}}, want: []string{"declined", "need billing details", "apply again"}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ var out bytes.Buffer
+ printStoreStatus(&out, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Entitlement: tt.ent})
+ for _, want := range tt.want {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("missing %q:\n%s", want, out.String())
+ }
+ }
+ for _, absent := range tt.absent {
+ if strings.Contains(out.String(), absent) {
+ t.Errorf("contains %q:\n%s", absent, out.String())
+ }
+ }
+ })
+ }
+}
+
+func TestPrintStoreStatusRepairVerbFollowsCurrentApproval(t *testing.T) {
+ for _, tt := range []struct {
+ name string
+ ent *objstore.Entitlement
+ want string
+ }{
+ {name: "approved", ent: approvedEntitlement(), want: "carlos store create --app keymail"},
+ {name: "revoked", ent: &objstore.Entitlement{Applied: &objstore.Applied{}, Decision: &objstore.Decision{Approved: false}}, want: "carlos store grant --app keymail"},
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ var partial bytes.Buffer
+ printStoreStatus(&partial, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Grant: &apiStoreGrantBlock{}, Entitlement: tt.ent})
+ if !strings.Contains(partial.String(), tt.want) {
+ t.Fatalf("partial grant output missing %q:\n%s", tt.want, partial.String())
+ }
+ var drift bytes.Buffer
+ printStoreStatus(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: tt.ent})
+ if !strings.Contains(drift.String(), tt.want) {
+ t.Fatalf("drift output missing %q:\n%s", tt.want, drift.String())
+ }
+ })
+ }
+}
+
+func TestPrintStoreCreateProvisionedAndDrift(t *testing.T) {
+ var healthy bytes.Buffer
+ printStoreCreate(&healthy, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b", Region: "eu-west-1", IAMUser: "u"}})
+ if !strings.Contains(healthy.String(), "bucket b") || strings.Contains(healthy.String(), "nothing is provisioned") {
+ t.Fatalf("healthy provisioned output = %q", healthy.String())
+ }
+
+ var drift bytes.Buffer
+ printStoreCreate(&drift, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}, Entitlement: approvedEntitlement()})
+ if !strings.Contains(drift.String(), "CARLOS_STORE_SECRET_KEY") || !strings.Contains(drift.String(), "carlos store create --app keymail") {
+ t.Fatalf("provisioned drift output = %q", drift.String())
+ }
+ if strings.Contains(drift.String(), "credentials are sealed") {
+ t.Fatalf("drift output claims healthy delivery: %q", drift.String())
+ }
+
+ var revoked bytes.Buffer
+ printStoreCreate(&revoked, apiStoreReply{App: "keymail", Backend: "aws", Declared: &apiStoreDeclared{EnvPrefix: "CARLOS_STORE"}, Provisioned: &apiStoreProvisioned{Bucket: "b"}, MissingVars: []string{"CARLOS_STORE_SECRET_KEY"}})
+ if !strings.Contains(revoked.String(), "carlos store grant --app keymail") {
+ t.Fatalf("unapproved provisioned drift output = %q, want operator repair", revoked.String())
+ }
+}
+
+func TestPrintStoreApprovalQueue(t *testing.T) {
+ var out bytes.Buffer
+ printStoreApprovals(&out, apiStoreApprovalsReply{Applications: []*objstore.Entitlement{
+ {Account: "bac", Applied: &objstore.Applied{By: "owner@example.com", At: time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC), Note: "backups"}},
+ }})
+ for _, want := range []string{"bac", "owner@example.com", "2026-09-01T09:00:00Z", "backups", "carlos store approve --account bac", "carlos store decline --account bac --reason"} {
+ if !strings.Contains(out.String(), want) {
+ t.Errorf("missing %q:\n%s", want, out.String())
+ }
+ }
+}
+
// ---- runStore dispatch / refusals (real binary) -----------------------------
// TestStoreVerbDispatchAndRefusals drives the real binary through
@@ -618,7 +830,7 @@ func TestStoreVerbDispatchAndRefusals(t *testing.T) {
if err == nil {
t.Fatalf("carlos %v exited 0:\n%s", args, out)
}
- if !strings.Contains(out, "usage: carlos store <create|status|grant|rotate>") {
+ if !strings.Contains(out, "usage: carlos store <create|status|apply|approvals|approve|decline|grant|rotate>") {
t.Fatalf("carlos %v = %q, want the store usage block", args, out)
}
}