// The CLI's object-storage API (issue #55): `carlos store create` declares // that an app wants a bucket, `carlos store status` answers what became of // that request, and grant/rotate/finish drive the walks that make it real. // Every door here sits behind the same apiAccountApp gate as // ship/promote/config — an unknown account, an unknown app and someone else's // account are one byte-identical 404. // // THREE AUTHORS, TWO AUTHORITIES. A store record has three blocks for three // actors (spec §4): the member DECLARES, a deployment operator GRANTS, and // the console's own walk records what it PROVISIONED. Declaring and rotating // are member verbs. Provisioning also admits members once a deployment // operator has approved their account (apistore_provision.go). // Per-app granting needs an address on CARLOS_STORE_OPERATORS as well // (Config.StoreOperators). That check runs AFTER the member gate on purpose: // a 403 handed to a stranger would confirm which accounts and apps exist. // This is the deployment's first explicit operator-identity gate (spec §3). // // NOTHING SECRET CROSSES THESE DOORS, in either direction. The record holds // bucket names, an IAM user, and access key IDs; the secret half of a key // exists only sealed inside the app's config bundle and is never written to // the record at all (objstore.Record's doc comment). Key IDs are echoed // deliberately — `status` prints them so a member can tell which credential // their app is running on, and match it against a rotation. // // THE SERIAL BUMPS EXACTLY WHERE CONFIG MOVED. Declaring changes nothing a // box reads, and neither does finishing a rotation (it deletes a key in the // backend and forgets it in the record); both are silent. Grant and rotate // write the app's config bundle, so both announce, and the fleet picks the new // credentials up in seconds instead of at the hourly floor. // // Spec: docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md // §3, §4, §5, §7, §8. package console import ( "context" "crypto/ecdh" "encoding/json" "errors" "fmt" "log" "net/http" "os" "strings" "time" "github.com/carlosframework/platform/internal/appconfig" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/usage" ) // storeTimeout bounds every store round trip these handlers make — the same // 15s its siblings in apicli.go and apiconfig.go use, for the same reason: a // wedged bucket must cost one request 15 seconds, not a console goroutine // forever. Each handler derives its context from the request's, so a client // that hangs up still cancels immediately; this only adds a ceiling. const storeTimeout = 15 * time.Second // storeNoBucket is the 503 both doors give when this console has no // deployment bucket. The record IS a bucket object: there is nowhere to write // one and nothing to read one from, and answering "no store is declared" // instead would be a lie a member could act on (they'd declare again, forever). func storeNoBucket(w http.ResponseWriter) { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot serve object storage") } // storeEffectivePrefix is the prefix a record's variables are delivered // under. A record with no declaration at all is not a shape this file can // write — the create door always writes `declared` — but the operator walks // and hand-repaired records both exist, so the fallback is the same default a // fresh declaration would take rather than an empty prefix that would render // variables named "_BUCKET". // storeEnvParam reads the environment a store door is aimed at, from // ?environment=. Absent is "" — the app's own store, and every request made // before environment stores existed. // // One helper for all six doors on purpose: the create door is the only one // with a body, and rotate and finish document that they read none at all // ("a rotation has no arguments"), so a body field would have made the // spelling differ per door for no reason a member could predict. It is the // same query parameter, with the same name, that the config plane already // uses (apiconfig.go's ?environment=) — a store delivers INTO a bundle, so // the two planes naming it differently would be a seam with no meaning. // // Validated here rather than in the walk: a refusal costs nothing, and an // invalid name reaching objstore would be refused there anyway, one round // trip later and with a less useful sentence. func (s *Server) storeEnvParam(w http.ResponseWriter, r *http.Request) (string, bool) { env := strings.TrimSpace(r.URL.Query().Get("environment")) if env == "" { return "", true } if !appconfig.ValidEnvironmentName(env) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid environment %q — want ^[a-z0-9-]{1,32}$, and not \"default\"", env)) return "", false } return env, true } func storeEffectivePrefix(rec *objstore.Record) string { if rec != nil && rec.Declared != nil && rec.Declared.EnvPrefix != "" { return rec.Declared.EnvPrefix } return objstore.DefaultEnvPrefix } // storeReply is the ONE answer shape both doors give, and the one the CLI // (`carlos store status`) and the operator doors parse. Kept in a single // function so create and status can never drift into describing the same // record differently — a create that reported a different `pending_finish` // from the status call right after it would be worse than no field at all. // // The record is echoed whole. Its three blocks are null-or-present, which is // exactly the tri-state a client needs to say "declared, waiting for an // operator" apart from "provisioned": an empty object for an absent block // would make those indistinguishable without inspecting fields. // // env_names is the SET OF NAMES this store delivers under its effective // prefix, in delivery order, and it includes the endpoint name only when the // store is provisioned WITH one. A deployment that provisions no endpoint // never writes that variable, so listing it would send someone hunting for a // variable nothing will ever set. // // missing_vars is the same list filtered to what has NOT arrived in the app's // config bundle, and it is computed ONLY for a provisioned store: before // provisioning there is no bucket, no key and nothing owed, so reporting four // 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 (AGENTS.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. // storeEntitlementWire is the TENANT-FACING projection of an Entitlement, and // exists because the record is operator commentary as much as it is tenant // fact. Decision.By is the address of the person who decided; Decision.Reason // is what they said about this account. §4 of the self-serve spec keeps both // out of the fleet read ACL by putting the record under store/ — this keeps // them out of the console's own answer, which is the other road a tenant could // read them by, and the one an Admin service credential travels. // // What travels, and to whom: // // Decision.By never. The operator stays answerable to the deployment, // not to the account they just declined. // Decision.Reason to a PERSON only. Requiring a reason on decline is // pointless if the account never learns it — that is the // four-day silence of §1 in a new hat — but it is written // for someone to read and act on, not for an app to branch // on. // Applied.* the applicant's own text about themselves, so it goes // back to a person untouched. A machine gets the At alone: // it needs the STATE, and free text a colleague typed is // not state. // // The shape mirrors Entitlement's tags exactly, so the CLI decodes this into // an objstore.Entitlement and the four-state printers keep working — a // withheld field simply arrives as its zero value. type storeEntitlementWire struct { Applied *storeAppliedWire `json:"applied,omitempty"` Decision *storeDecisionWire `json:"decision,omitempty"` } type storeAppliedWire struct { By string `json:"by,omitempty"` At time.Time `json:"at"` Note string `json:"note,omitempty"` } type storeDecisionWire struct { Approved bool `json:"approved"` At time.Time `json:"at"` Reason string `json:"reason,omitempty"` } // The caller is read from ctx rather than passed: every door that renders a // store reply builds its ctx from the request, so the principal is already // there, and a parameter threaded through five call sites is five chances to // pass the wrong one. A ctx with NO principal is treated as a machine — the // fail-closed direction, and the operator walk doors are the only callers in // that position, where the withheld reason is one they wrote themselves. func tenantEntitlement(e *objstore.Entitlement, ctx context.Context) *storeEntitlementWire { if e == nil { return nil } p, ok := principalFrom(ctx) person := ok && p.Kind == PrincipalUser out := &storeEntitlementWire{} if e.Applied != nil { out.Applied = &storeAppliedWire{At: e.Applied.At} if person { out.Applied.By, out.Applied.Note = e.Applied.By, e.Applied.Note } } if e.Decision != nil { out.Decision = &storeDecisionWire{Approved: e.Decision.Approved, At: e.Decision.At} if person { out.Decision.Reason = e.Decision.Reason } } return out } func (s *Server) storeReply(ctx context.Context, acct *Account, app, env string, rec *objstore.Record) (map[string]any, error) { entitlement, _, err := objstore.GetEntitlement(ctx, s.cfg.Releases, acct.Sqid) if err != nil { return nil, err } prefix := storeEffectivePrefix(rec) wantEndpoint := rec.Provisioned != nil && rec.Provisioned.Endpoint != "" bucket, region, endpoint, accessKey, secretKey := objstore.EnvNames(prefix) names := []string{bucket, region} if wantEndpoint { names = append(names, endpoint) } names = append(names, accessKey, secretKey) missing := []string{} if rec.Provisioned != nil { // The bundle this store DELIVERS into, which for an environment store // is not the app's default one. Asking the default bundle would // report every delivered variable missing, on every status call, for // every environment store. got, err := objstore.EnvDeliveredIn(ctx, s.cfg.Releases, acct.Sqid, app, env, prefix, wantEndpoint) if err != nil { return nil, err } missing = append(missing, got...) } return map[string]any{ "app": app, // "" for the app's own store. Omitting it when empty would make an // old CLI and a new one disagree about which store a reply describes, // so it is always present and always says which. "environment": env, "entitlement": tenantEntitlement(entitlement, ctx), "backend": s.cfg.StoreDeployment.Backend, "declared": rec.Declared, "grant": rec.Grant, "provisioned": rec.Provisioned, "env_names": names, // A rotation that minted a new key but has not yet deleted the old // one. The member-visible half of §6's two-step: until it is finished, // two credentials open this bucket. "pending_finish": rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "", "missing_vars": missing, // Additive, and additive is the ONLY safe move here: release.PurgeApp // parses this same object with its own struct and freezes the `grant` // and `provisioned` names. A new key it does not know about is // ignored; a renamed one it does know about is a purge that stops // working. Once scanning exists, a status that said nothing about it // would be a half-truth — but it is nil on every store that never // opted in, which is all of them by default. "scanning": rec.Scanning, // The bucket's size from the daily storage sweep, absent until a // sweep has measured this store. See storeSize. "size": s.storeSize(ctx, acct, app, rec), }, nil } // storeSizeLookback is how many UTC days storeSize walks back looking for a // storage sweep. The sweep runs on the console's first hourly economics wake // of each day, so today or yesterday normally answers; a week covers a // console that was down for a while, and anything older is too stale to // print as the bucket's size. const storeSizeLookback = 7 // storeSizeReply is the "size" block of `carlos store status`. It is copied // from the storage sweep (usage/storage/.json) and is NOT measured on // this request: sizing a bucket means listing every object in it, which is // what the daily sweep already does once. Unswept means the sweep ran but // could not size this store (bad credential, endpoint down), and then // StoreBytes is meaningless. BucketBytes is the app's own files in the // deployment bucket (releases, config), a separate figure from its store. type storeSizeReply struct { AsOf string `json:"as_of"` StoreBytes int64 `json:"store_bytes"` Unswept bool `json:"unswept,omitempty"` BucketBytes int64 `json:"bucket_bytes"` } // storeSize finds the newest storage sweep from the last storeSizeLookback // days that has an answer for this app's store, or nil. A sweep that did not // see the store provisioned (it ran before the grant, or predates the // store_provisioned field) has no answer: its StoreBytes of 0 would read as // an empty bucket. Read failures are logged and answer nil; the size is an // annotation on status, never a reason for status to fail. func (s *Server) storeSize(ctx context.Context, acct *Account, app string, rec *objstore.Record) *storeSizeReply { if rec.Provisioned == nil { return nil } key := acct.Sqid + "/" + app now := time.Now() for i := 0; i < storeSizeLookback; i++ { date := usage.DayOf(now.AddDate(0, 0, -i)) raw, _, err := s.cfg.Releases.Get(ctx, usage.StorageDayKey(date)) if errors.Is(err, os.ErrNotExist) { continue } if err != nil { log.Printf("console: api store size %s: %v", usage.StorageDayKey(date), err) return nil } var day usage.StorageDay if err := json.Unmarshal(raw, &day); err != nil { log.Printf("console: api store size: parse %s: %v", usage.StorageDayKey(date), err) return nil } st, ok := day.Apps[key] if !ok || (!st.StoreProvisioned && !st.StoreUnswept) { // The newest sweep did not see this store; an older one cannot // have either. return nil } return &storeSizeReply{AsOf: date, StoreBytes: st.StoreBytes, Unswept: st.StoreUnswept, BucketBytes: st.BucketBytes} } return nil } // handleAPIStoreCreate answers `carlos store create`: record that this app // wants object storage, under a chosen (or default) env prefix. It is a // REQUEST, not a provisioning — nothing exists in the backend when this // returns 200, and on a deployment with no backend configured at all the // declaration simply waits for an operator who can grant it. That is // deliberate: a member cannot see whether their deployment has a backend, and // making them find out by being refused would teach them nothing they could // act on, while the declaration itself is what an operator needs to act. // // The doors, in order, and why this order — handleAPIRestart's exactly: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp: an unknown account, someone else's // account and an app that isn't there are one answer, so // probing this door teaches a stranger nothing. It also // means a TRASHED app cannot declare a store: the live-apps // lookup simply doesn't answer for it. // rate limit — the shared `write` budget, keyed on the ADDRESS like // promote's: a budget belongs to the person, not the // terminal. // Releases nil — 503. The declaration IS a bucket object; a console with // no bucket has nowhere to put it and must not answer as // if it had. // prefix — refused before the first store call, so a corrected // retry starts from exactly the state the caller left. // // A JSON BODY IS REQUIRED, even to take the default: readJSON's // content-type-and-decode is shared by every CLI door, so `{}` is the empty // request here — not a zero-length body, which decodes as malformed like // anywhere else. // // Re-declaring the same prefix preserves the author, timestamp and etag. // A no-op must not make an in-flight provision lose its record write. Members // re-run create after a failed walk, from second terminals and deploy scripts. func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it object storage") { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { storeNoBucket(w) return } var req struct { EnvPrefix string `json:"env_prefix"` } if !readJSON(w, r, &req) { return } prefix := strings.TrimSpace(req.EnvPrefix) if prefix == "" { prefix = objstore.DefaultEnvPrefix } if !objstore.ValidEnvPrefix(prefix) { // The charset is named rather than described: the caller has to be // able to fix their own prefix from this sentence alone, and the // regexp is shorter than the English for it. jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid env prefix %q — want ^[A-Z][A-Z0-9_]{0,31}$ (e.g. %s)", req.EnvPrefix, objstore.DefaultEnvPrefix)) return } env, ok := s.storeEnvParam(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() // The environment-store ceiling (issue #412 §8) is enforced HERE as well // as on the automatic path. A cap that only the create-an-instance door // respected would be a cap anyone could step over by hand, and the // footprint it bounds — a bucket and an IAM user per store, against // IAM's per-account limits — does not care which door minted it. // Re-declaring a store that already exists is never capped; storeCeiling // carries that carve-out. if ref := s.storeCeiling(ctx, acct, app, env); ref != nil { jsonError(w, ref.Status, ref.Msg) return } rec, etag, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) if err != nil { // Logged, not returned: a store error carries bucket names, endpoints // and request ids. The caller gets the fixed sentence, and a retry is // safe — nothing was written. log.Printf("console: api store read %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the store record — try again") return } if rec == nil { rec = &objstore.Record{} } // 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 } if rec.Declared == nil || storeEffectivePrefix(rec) != prefix { rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr} if _, err := claimAppCall(s, ctx, acct, app, "store-declare", func(ctx context.Context) (string, error) { return objstore.PutIn(ctx, s.cfg.Releases, acct.Sqid, app, env, rec, etag) }); err != nil { if errors.Is(err, release.ErrCASConflict) { // Someone else wrote this record between our read and our write — // plausibly the operator's grant landing. Re-read and re-ask; this // door deliberately does not retry, because the thing it would be // overwriting is the half it doesn't own. jsonError(w, http.StatusConflict, "the store record changed underneath you. try again") return } log.Printf("console: api store create %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not record the store. nothing was declared; try again") return } // Audited, unlike a read: this is the request an operator will act on by // spending money in the deployment's own cloud account, so who asked and // under what prefix has to be visible to anyone reading rows. s.audit(acct, app, addr, "store-create", "declared a store with env prefix "+prefix+storeEnvSuffix(env)+" (cli)") } // No bumpSerial: nothing a box reads has changed. See the file comment. out, err := s.storeReply(ctx, acct, app, env, rec) if err != nil { log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the store was declared, but its status could not be read — try `carlos store status`") return } writeJSON(w, http.StatusOK, out) } // handleAPIStoreGet answers `carlos store status`: the whole record plus the // two things a member with no box access cannot see any other way — which // delivered variables have not arrived in the app's config, and whether a // rotation is still half-done. // // THE 404 HERE SPEAKS, and that is not a weakening of the gate above it. It // fires only AFTER apiAccountApp has proved the caller is a member of the // account and that the app exists, so the sentence tells them nothing they // were not already entitled to. apiAccountApp's OWN refusals stay // byte-identical with http.NotFound's, which is what keeps a stranger from // learning that an account, an app or a store exists at all. // // Not rate limited, matching every other read door: an exhausted write budget // must not blind a member to the state of what they already asked for. func (s *Server) handleAPIStoreGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.cfg.Releases == nil { storeNoBucket(w) return } env, ok := s.storeEnvParam(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeTimeout) defer cancel() rec, _, err := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) if err != nil { log.Printf("console: api store status %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the store record — try again") return } if rec == nil { // A state, not a failure — and a 404 rather than an empty record, // because "no store" and "a store with nothing filled in yet" are // genuinely different things for the CLI to print. if env != "" { jsonError(w, http.StatusNotFound, "no store is declared for this app's "+env+" environment — run `carlos store create --environment "+env+"`") return } // The app has no store of its OWN — but it may have environment // stores, and this is the shape where answering "no store" is most // wrong. An app whose storage is entirely per-environment (a managed // product's, where every tenant is a bundle) would have every one of // its buckets hidden behind a 404 that says it has none. That is the // exact opposite of what this listing exists for: a store nobody // reads still bills, and teardown is explicit. envs, lerr := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if lerr != nil { log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, lerr) jsonError(w, http.StatusBadGateway, "could not read the store record — try again") return } if len(envs) == 0 { jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`") return } // A zero record, not a nil one: every block reads as absent, which is // true, and the environments are named beside it. out, rerr := s.storeReply(ctx, acct, app, "", &objstore.Record{}) if rerr != nil { log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, rerr) jsonError(w, http.StatusBadGateway, "could not read the store's status. try again") return } out["environments"] = envs writeJSON(w, http.StatusOK, out) return } out, err := s.storeReply(ctx, acct, app, env, rec) if err != nil { log.Printf("console: api store delivery %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the store's status. try again") return } // Asked about the app's own store, a member is told what ELSE this app // has: an environment store nobody remembers is a bucket nobody // remembers, and this is the one screen where it can be seen (spec §8). // Only on the app-store call — a request aimed at one environment // answers about that environment. if env == "" { envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api store environments %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the store's status. try again") return } out["environments"] = envs } writeJSON(w, http.StatusOK, out) } // storeEnvSuffix names the environment in an audit row, or says nothing at // all for the app's own store — so rows written before environment stores // existed and rows written after read identically for the same action. func storeEnvSuffix(env string) string { if env == "" { return "" } return " in environment " + env } // ---- the walk doors: grant, rotate, finish ----------------------------------- // storeNameTakenMsg answers objstore.ErrStoreNameTaken (#218). Bucket and IAM // user names join account, app and environment with "-", so app "shop" with // environment "eu" and app "shop-eu" would get one estate; the walk refuses // the second before creating anything, and the sentence says why. const storeNameTakenMsg = "another store already uses this store's bucket or IAM user name (app and environment names are joined with \"-\"). nothing was created" // storeGrantTimeout bounds the grant walk. Longer than storeTimeout because a // grant is not one round trip: it creates a bucket, an IAM principal with an // inline policy, and a key, then writes config and the record. Bucket creation // alone can take seconds on a cold region, and a walk cut off mid-sequence // costs the operator a re-run they shouldn't need. const storeGrantTimeout = 60 * time.Second // storeRotateTimeout bounds rotate and finish. Both act on an estate that // already exists — delete, reap, mint, deliver — so they are shorter than a // grant but still well past the 15s a single object write gets. const storeRotateTimeout = 30 * time.Second // storeTeardownTimeout bounds a Teardown walk, wherever it is driven from (the // browser destroy door and the purge door, appsettings.go and ownership.go). // A grant's ceiling for a walk with a grant's shape and one step a grant does // not have: EmptyBucket is PAGINATED precisely because a real bucket does not // empty in one call, so a customer's store can be many round trips before the // bucket, the principal and the config write even begin. Under storeTimeout's // 15s — a ceiling documented for a SINGLE round trip — the deadline would land // mid-walk and be reported as a backend that could not be reached, which is // the wrong thing to tell someone whose store is simply large. const storeTeardownTimeout = 60 * time.Second // storeOperator reports whether this address may drive the grant door. // // EXACT MATCH ON THE CANONICAL FORM, never a fold. Every address entry point // in this codebase ASCII-lowercases on the way in (sign-in, team-add, the // materialize pass, `console member`), so the address on a CLI token is // already canonical; the list entry is lowercased and trimmed here so an // operator who typed Alice@Keymail.dev into a unit file still matches. // // strings.EqualFold would be the obvious way to write that and is WRONG for // the highest-authority gate in the API: it applies Unicode simple folding, in // which U+017F (ſ, long s) folds to "s" — and ſ is already lowercase, so it // survives every ToLower on the way in. ValidAddress permits it (anything // without whitespace and with an @). With a fold, owner@example.teſt would // match an allowlist entry of owner@example.test and be handed the authority // to spend the deployment's money. // // Empty list is fail-closed — the loop simply never matches — which is the // right direction for a gate on spending. func (s *Server) storeOperator(addr string) bool { for _, op := range s.cfg.StoreOperators { if strings.ToLower(strings.TrimSpace(op)) == addr { return true } } return false } // storeWalkGates are the gates every store WALK door shares, after the caller // has resolved the member (and, for grant, checked the operator allowlist): // the write budget, a bucket to record into, and a backend to act on. // // The provisioner check is here rather than inside the walks because a // deployment with no object storage is a configuration fact, not a failure of // this request: the member's declaration still stands and the sentence names // the variable that would wire it up, which is the only thing the person // reading it can do about it. func (s *Server) storeWalkGates(w http.ResponseWriter, p Principal) bool { if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return false } if s.cfg.Releases == nil { storeNoBucket(w) return false } if s.cfg.StoreProvisioner == nil { jsonError(w, http.StatusServiceUnavailable, "object storage is not configured for this deployment (CARLOS_STORE_BACKEND)") return false } return true } // storeSealKey is the gate that says: no sealing key, no minting. // // A credential this console cannot seal is one it cannot DELIVER, and a key // minted without delivery is the worst state in the system — it exists in the // backend, it is billable, its secret died in this process, and only a reap // finds it again. So grant and rotate refuse before the first backend call // rather than after. Finish does not call this at all: it seals nothing. func (s *Server) storeSealKey(w http.ResponseWriter) (*ecdh.PublicKey, bool) { if s.cfg.SecretsPub == "" { jsonError(w, http.StatusServiceUnavailable, "this console has no secrets key (CARLOS_SECRETS_PUBKEY) — it cannot deliver a store credential, so it will not mint one") return nil, false } pub, err := appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY") if err != nil { // Logged with the parse detail, answered without it: the caller cannot // fix this console's configuration and the sentence they get is the one // that tells the operator where to look. log.Printf("console: api store seal key: %v", err) jsonError(w, http.StatusServiceUnavailable, "this console's secrets key (CARLOS_SECRETS_PUBKEY) cannot be read — it cannot deliver a store credential, so it will not mint one") return nil, false } return pub, true } // storeBucketName is the bucket a record names, for an audit line: what was // provisioned if the walk got that far, otherwise what the grant chose. func storeBucketName(rec *objstore.Record) string { switch { case rec == nil: return "" case rec.Provisioned != nil: return rec.Provisioned.Bucket case rec.Grant != nil: return rec.Grant.Bucket } return "" } // storeWalkReply is the shared tail of all three walk doors: render the same // answer shape the member doors give, from the record the walk just wrote. // A walk that succeeded and a reply that could not be rendered are different // events, and the sentence says so — the thing was done, go and look at it — // because telling someone their grant failed when it didn't is how a second // grant gets run over a live estate. func (s *Server) storeWalkReply(ctx context.Context, w http.ResponseWriter, acct *Account, app, env string, rec *objstore.Record) { out, err := s.storeReply(ctx, acct, app, env, rec) if err != nil { log.Printf("console: api store reply %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "the store changed, but its status could not be read — run `carlos store status`") return } writeJSON(w, http.StatusOK, out) } // handleAPIStoreGrant answers `carlos store grant`: provision the declared // store and deliver its credentials. THIS IS THE AUTHORITY DOOR — the one // request in the whole API that creates a bucket, mints an IAM key and starts // a bill in the deployment's own cloud account. // // The doors, in order, and why this order: // // token — cliUser's one 401, before any name is resolved. // account+app 404 — apiAccountApp, FIRST and before the allowlist. An // operator is not automatically a member: eve holding the // operator role must still get the same 404 for an account // she isn't in that a stranger gets, or this door becomes // the one place in the API that confirms an account or an // app exists. Spec §3 wants both — the operator allowlist // AND account membership. // allowlist — 403, naming CARLOS_STORE_OPERATORS. Safe to speak here: // the caller has already proved membership, so all they // learn is what authority THEY lack. // rate limit — the shared write budget, keyed on the ADDRESS. // Releases nil — 503; the record and the delivered config are both bucket // objects. // backend nil — 503, naming CARLOS_STORE_BACKEND: this deployment has no // object storage at all, which is the one thing the member // who declared could not see for themselves. // seal key — 503. Cannot seal → cannot deliver → MUST NOT MINT. // bucket name — 400 before any backend call. An adopt name is // interpolated into the IAM policy's resource ARN, so an // unchecked one is a scope nobody meant to grant. The walk // validates it too (objstore.ValidBucketName, the same // function) — this copy exists so the refusal is a clean // 400 with the name in it rather than a 502. // // IDEMPOTENT, because the walk is: re-running over a healthy store is a no-op // that mints nothing. A re-run over a DRIFTED one — a delivered variable // unset, a key gone from the backend — repairs it by reaping and re-minting, // which means the app's key CHANGES. That is the repair working as designed // (§5), and it is why the client sentence for a failed grant says "re-run to // resume" rather than promising nothing will move. func (s *Server) handleAPIStoreGrant(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.refuseTemporary(w, acct, app, "give it object storage") { return } if !s.storeOperator(addr) { jsonError(w, http.StatusForbidden, "store grants need a deployment operator (CARLOS_STORE_OPERATORS)") return } p, _ := principalFrom(r.Context()) if !s.storeWalkGates(w, p) { return } pub, ok := s.storeSealKey(w) if !ok { return } // The body carries one optional field: a bucket to ADOPT instead of // minting. `{}` is the ordinary request, as on the create door. var req struct { Bucket string `json:"bucket"` } if !readJSON(w, r, &req) { return } bucket := strings.TrimSpace(req.Bucket) if bucket != "" && !objstore.ValidBucketName(bucket) { jsonError(w, http.StatusBadRequest, fmt.Sprintf("invalid bucket name %q — want 3-63 characters of lowercase letters, digits, dots and hyphens, starting and ending alphanumeric", req.Bucket)) return } env, ok := s.storeEnvParam(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeGrantTimeout) defer cancel() defer s.storeWalks.lock(acct.Sqid, app, env)() rec, err := claimAppCall(s, ctx, acct, app, "store-grant", func(ctx context.Context) (*objstore.Record, error) { return objstore.GrantIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, pub, acct.Sqid, app, env, addr, objstore.GrantSourceOperator, bucket, time.Now()) }) switch { case errors.Is(err, objstore.ErrNotDeclared): // The operator's verb never invents the member's half: the env prefix // is the member's choice and there is nothing to deliver into. jsonError(w, http.StatusConflict, "no store is declared for this app. run `carlos store create` first") return case errors.Is(err, objstore.ErrStoreNameTaken): // Refused before anything was created (#218): the bucket, or the IAM // user this store would be given, is already another store's. log.Printf("console: api store grant %s/%s (bucket %q): %v", acct.Sqid, app, bucket, err) jsonError(w, http.StatusConflict, storeNameTakenMsg) return case err != nil: // Logged, not returned: a backend error carries bucket names, ARNs, // endpoints and request ids. The client sentence promises exactly what // the walk's ordering guarantees — every resource this walk created is // named by a record it wrote before creating it, so nothing is stranded // where only a bill would find it — and the fix is to run the same verb // again, which resumes from wherever it stopped. log.Printf("console: api store grant %s/%s (bucket %q): %v", acct.Sqid, app, bucket, err) jsonError(w, http.StatusBadGateway, "could not provision the store — nothing unrecorded was left behind; re-run the grant to resume") return } s.audit(acct, app, addr, "store-grant", "granted a store in bucket "+storeBucketName(rec)+storeEnvSuffix(env)+" (cli)") // The app's config bundle changed: boxes read config only when the serial // moves, so without this the credentials sit in the bucket until the hourly // pass and the app keeps failing to reach a store it has been given. s.bumpSerial(ctx) s.storeWalkReply(ctx, w, acct, app, env, rec) } // handleAPIStoreRotate answers `carlos store rotate`: mint a fresh key, // deliver it, and leave the OLD key live until an explicit finish. // // A MEMBER VERB, not an operator one (spec §8, and the route table in §10). // Rotation spends nothing new — it cycles a credential inside an estate an // operator already granted — and the person who needs it most is a member who // has just leaked a key at 2am. Making them find a deployment operator first // would make the safety verb the slow one. The gates are therefore create's, // plus the two 503s that any minting door owes: a backend to mint in and a key // to seal with. // // The overlap the walk leaves behind is the point: adoption swaps a symlink, // it does not restart processes, so at the instant new credentials land there // are still hosts and hibernating tenants holding the old pair. `pending_finish` // in the reply is how a member sees that window is open. func (s *Server) handleAPIStoreRotate(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.storeWalkGates(w, p) { return } pub, ok := s.storeSealKey(w) if !ok { return } // No body: a rotation has no arguments, and handleAPIRestart — the other // verb whose whole request is "do the thing to this app" — reads none // either, so a CLI can POST with nothing at all. env, ok := s.storeEnvParam(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout) defer cancel() defer s.storeWalks.lock(acct.Sqid, app, env)() rec, err := claimAppCall(s, ctx, acct, app, "store-rotate", func(ctx context.Context) (*objstore.Record, error) { return objstore.RotateIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, pub, acct.Sqid, app, env, time.Now()) }) switch { case errors.Is(err, objstore.ErrNotProvisioned), errors.Is(err, objstore.ErrNotDeclared): // Rotation edits an existing estate; it never creates one, because the // coordinates it reseals come from the record and there is no // defensible guess for them. The repair for this state is a grant. jsonError(w, http.StatusConflict, "no provisioned store to rotate — grant one first") return case errors.Is(err, release.ErrCASConflict): // A concurrent write to the record. NOT a failed rotation: by the time // the walk reaches its final Put it has already minted a key and // delivered it into the app's config, so telling the caller nothing // happened would send them looking for a key that is already live. The // honest instruction is to run it again, which converges. log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusConflict, "another rotation is in progress — re-run to converge") return case err != nil: log.Printf("console: api store rotate %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not rotate the store credential — nothing unrecorded was left behind; re-run the rotation to resume") return } key := "" if rec.Provisioned != nil { key = rec.Provisioned.KeyID } // Key ids are not secrets — `status` prints them — and the audit row is // where a member later matches "which credential did we cut over to". s.audit(acct, app, addr, "store-rotate", "rotated the store credential to key "+key+storeEnvSuffix(env)+" (cli)") s.bumpSerial(ctx) s.storeWalkReply(ctx, w, acct, app, env, rec) } // handleAPIStoreRotateFinish answers `carlos store rotate -finish`: delete the // previous key and stop naming it. The member's explicit "nothing is using it // any more" — the one destructive step in the rotation state machine, which is // why it is a verb someone asks for rather than a timer. // // TWO DIFFERENCES from its siblings, both deliberate: // // It needs NO SEALING KEY. Finishing delivers nothing — the app has been // reading the current key since the rotation — so a console that has lost its // CARLOS_SECRETS_PUBKEY can still close a two-key window. Requiring the key // uniformly would be tidier and would strand exactly the deployment that most // needs to retire a credential. // // It does NOT bump the serial. No object a box reads changed: the delivered // config is untouched, and the record is console-side. Waking the fleet to // tell it a key it never held is gone would be pure noise. func (s *Server) handleAPIStoreRotateFinish(w http.ResponseWriter, r *http.Request) { addr, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.storeWalkGates(w, p) { return } env, ok := s.storeEnvParam(w, r) if !ok { return } ctx, cancel := context.WithTimeout(r.Context(), storeRotateTimeout) defer cancel() defer s.storeWalks.lock(acct.Sqid, app, env)() rec, err := claimAppCall(s, ctx, acct, app, "store-rotate-finish", func(ctx context.Context) (*objstore.Record, error) { return objstore.RotateFinishIn(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app, env) }) switch { case errors.Is(err, objstore.ErrNotProvisioned): jsonError(w, http.StatusConflict, "no provisioned store to rotate — grant one first") return case errors.Is(err, objstore.ErrNoPendingFinish): // Refused rather than succeeding vacuously: a member who believes they // just retired the old key, when an earlier finish already did, has a // different picture of the estate than the estate does. jsonError(w, http.StatusConflict, "no rotation is pending finish") return case err != nil: log.Printf("console: api store rotate finish %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not finish the rotation — the previous key may still be live; re-run to resume") return } key := "" if rec.Provisioned != nil { key = rec.Provisioned.KeyID } s.audit(acct, app, addr, "store-rotate-finish", "finished the rotation — the store now runs on key "+key+" alone"+storeEnvSuffix(env)+" (cli)") // No bumpSerial: see the doc comment. s.storeWalkReply(ctx, w, acct, app, env, rec) }