console: store reply names the backend; prefix lock covers the mid-grant window
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+116
−7
cmd/console/main.go+10 −0internal/console/apistore.go+40 −7internal/console/apistore_test.go+66 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go| index 9696def09..b42f710fa 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -377,6 +377,16 @@ func storeBackend() (objstore.Provisioner, objstore.Deployment) { |
| if prefix == "" { |
| log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)") |
| } |
| + // The prefix is the head of every bucket name this console will ever mint |
| + // (objstore.MintedBucket), so a prefix that cannot BEGIN a legal S3 name — |
| + // "Carlos-", "carlos_", a leading dot — is a first grant that fails deep |
| + // inside the walk, against a live cloud account, for whoever tries first. |
| + // Checked against the shortest name it could produce (a 4-char account |
| + // sqid and a 1-char app), because that is the tightest case the same |
| + // function will judge later. |
| + if !objstore.ValidBucketName(prefix + "aaaa-a") { |
| + log.Fatalf("console: CARLOS_STORE_BUCKET_PREFIX %q cannot start an S3 bucket name — lowercase letters, digits, dots and hyphens only, starting alphanumeric (e.g. carlos-)", prefix) |
| + } |
| endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT")) |
| p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint) |
| if err != nil { |
diff --git a/internal/console/apistore.go b/internal/console/apistore.go| index e1aae5bc6..7df12beac 100644 |
| --- a/internal/console/apistore.go |
| +++ b/internal/console/apistore.go |
| @@ -22,7 +22,8 @@ |
| // changes nothing a box reads. Delivery happens later, through the app's |
| // config bundle, and THAT write does the announcing (objstore.Grant). |
| // |
| -// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive.md §4, §7. |
| +// Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md |
| +// §4, §7. |
| package console |
| import ( |
| @@ -90,6 +91,16 @@ func storeEffectivePrefix(rec *objstore.Record) string { |
| // missing variables would describe a healthy waiting-for-an-operator store as |
| // a broken one. Both lists are always present and never null — a client reads |
| // them rather than testing for absence. |
| +// |
| +// backend is the DEPLOYMENT's backend name, "" when none is configured, and |
| +// it is here because without it `status` cannot answer the question a waiting |
| +// member actually has: is my declaration queued behind an operator, or does |
| +// this deployment have no object storage at all and never will? The |
| +// no-infra-access rule (CLAUDE.md) says the command alone must complete the |
| +// job, and "declared, nothing happening" with no way to tell those apart |
| +// fails it. Disclosure-wise it is the same class as secrets_pubkey and the |
| +// apps domain: a deployment-wide fact about the platform, carrying nothing |
| +// about any tenant, and every member of it can already see the consequences. |
| func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec *objstore.Record) (map[string]any, error) { |
| prefix := storeEffectivePrefix(rec) |
| wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != "" |
| @@ -109,6 +120,7 @@ func (s *Server) storeReply(ctx context.Context, acct *Account, app string, rec |
| } |
| return map[string]any{ |
| "app": app, |
| + "backend": s.cfg.StoreDeployment.Backend, |
| "declared": rec.Declared, |
| "grant": rec.Grant, |
| "provisioned": rec.Provisioned, |
| @@ -201,12 +213,33 @@ func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { |
| if rec == nil { |
| rec = &objstore.Record{} |
| } |
| - // THE PREFIX IS FIXED ONCE A BUCKET EXISTS. The delivered names are what a |
| - // RUNNING app reads; renaming them would leave it looking for variables |
| - // nothing will write again, while the old ones linger in its config |
| - // pointing at a live credential. The refusal names the only honest way out |
| - // rather than pretending this is a retry-able race. |
| - if rec.Provisioned != nil && storeEffectivePrefix(rec) != prefix { |
| + // THE PREFIX IS FIXED ONCE AN OPERATOR HAS GRANTED. The delivered names |
| + // are what a RUNNING app reads; renaming them would leave it looking for |
| + // variables nothing will write again, while the old ones linger in its |
| + // config pointing at a live credential. The refusal names the only honest |
| + // way out rather than pretending this is a retry-able race. |
| + // |
| + // The guard keys on the GRANT, not on `provisioned`, because the grant |
| + // walk is a sequence and its middle is exactly where a rename does the |
| + // damage: the grant block is written first (§5 step 1) and the delivered |
| + // variables land several steps before `provisioned` does. A re-declare |
| + // squeezed into that window would be accepted under the old rule, and the |
| + // vars already written under the OLD prefix would then be orphaned — |
| + // nothing rewrites them, nothing reaps them, and they name a key the next |
| + // pass revokes. Same widening Tasks 5 and 6 made to their own guards, for |
| + // the same window. |
| + if (rec.Grant != nil || rec.Provisioned != nil) && storeEffectivePrefix(rec) != prefix { |
| + // Two sentences for two true states: an operator has GRANTED but the |
| + // walk has not finished, versus a store that is fully provisioned. The |
| + // way out is the same either way, and it is the half of the sentence |
| + // that matters — but telling someone their store is "already |
| + // provisioned" when no bucket exists yet is exactly the kind of |
| + // confident lie an operator then has to talk them out of. |
| + if rec.Provisioned == nil { |
| + jsonError(w, http.StatusConflict, |
| + "a store grant is already under way — the env prefix is fixed; destroy and re-create to change it") |
| + return |
| + } |
| jsonError(w, http.StatusConflict, |
| "store already provisioned — the env prefix is fixed; destroy and re-create to change it") |
| return |
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go| index 86d87c813..78df0c231 100644 |
| --- a/internal/console/apistore_test.go |
| +++ b/internal/console/apistore_test.go |
| @@ -119,6 +119,12 @@ func TestAPIStoreDeclareWritesTheRecord(t *testing.T) { |
| if out["grant"] != nil || out["provisioned"] != nil { |
| t.Fatalf("grant/provisioned = %v/%v, want null on a fresh declaration", out["grant"], out["provisioned"]) |
| } |
| + // This fixture configures no backend, and the answer says so — the field |
| + // is what separates "queued behind an operator" from "this deployment |
| + // will never provision one". |
| + if out["backend"] != "" { |
| + t.Fatalf("backend = %v, want \"\" on a deployment with no object storage", out["backend"]) |
| + } |
| // No endpoint name: this deployment provisions none, and a name the app |
| // will never be given would send someone hunting for a missing variable. |
| wantNames := []string{"CARLOS_STORE_BUCKET", "CARLOS_STORE_REGION", "CARLOS_STORE_ACCESS_KEY", "CARLOS_STORE_SECRET_KEY"} |
| @@ -203,6 +209,66 @@ func TestAPIStoreDeclareRefusesAnInvalidPrefix(t *testing.T) { |
| } |
| } |
| +// TestAPIStoreNamesTheDeploymentBackend: a member with no infra access has no |
| +// other way to tell a declaration waiting on an operator from one waiting on a |
| +// deployment that has no object storage at all — and both doors have to agree |
| +// about it, since `carlos store create` is where most people will look first. |
| +func TestAPIStoreNamesTheDeploymentBackend(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-backend") |
| + fx.srv.cfg.StoreDeployment = objstore.Deployment{ |
| + Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-", |
| + } |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("declare = %d %v", status, out) |
| + } |
| + if out["backend"] != "aws" { |
| + t.Fatalf("declare backend = %v, want aws", out["backend"]) |
| + } |
| + status, out = getJSON(t, storeURL(fx, "titogo"), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("status = %d %v", status, out) |
| + } |
| + if out["backend"] != "aws" { |
| + t.Fatalf("status backend = %v, want aws", out["backend"]) |
| + } |
| +} |
| + |
| +// TestAPIStorePrefixIsFixedOnceGranted: the mid-grant window. The grant block |
| +// is written FIRST and the variables are delivered several steps before |
| +// `provisioned` lands, so a guard keyed on `provisioned` alone would accept a |
| +// rename in exactly the window where vars already exist under the old prefix — |
| +// orphaning them against a key the next pass reaps. |
| +func TestAPIStorePrefixIsFixedOnceGranted(t *testing.T) { |
| + fx, token := storeFixture(t, "code-store-grant-locked") |
| + now := time.Now() |
| + putRecord(t, fx, "titogo", &objstore.Record{ |
| + Declared: &objstore.Declared{EnvPrefix: "BLOBS", At: now, By: "alice@keymail.dev"}, |
| + Grant: &objstore.GrantBlock{Backend: "aws", At: now, By: "paul@keymail.dev"}, |
| + }) |
| + |
| + status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token) |
| + if status != http.StatusConflict { |
| + t.Fatalf("re-declare mid-grant = %d %v, want 409", status, out) |
| + } |
| + if msg := str(t, out, "error"); !strings.Contains(msg, "destroy and re-create") { |
| + t.Fatalf("error = %q, want the way out named", msg) |
| + } |
| + // It must not claim a bucket exists — nothing has been provisioned yet. |
| + if msg := str(t, out, "error"); strings.Contains(msg, "already provisioned") { |
| + t.Fatalf("error = %q, but this store is granted, not provisioned", msg) |
| + } |
| + if rec := storedRecord(t, fx, "titogo"); rec.Declared.EnvPrefix != "BLOBS" { |
| + t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix) |
| + } |
| + // The same prefix still refreshes: a re-run of `carlos store create` must |
| + // not turn into an error the moment an operator picks the request up. |
| + if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token); status != http.StatusOK { |
| + t.Fatalf("same-prefix re-declare mid-grant = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| // TestAPIStorePrefixIsFixedOnceProvisioned: the delivered variable names are |
| // what a RUNNING app reads. Renaming them under a provisioned store would |
| // leave the app looking for variables nothing will ever write again, so the |