carlos / platform Public

console: store declare/status member endpoints + backend config plumbing

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 57fc8cc94a1c74b09f1b591d3819d36bbb7696ec parent 11b42ed
5 files changed, +816 −10
  • cmd/console/main.go +83 −10
  • internal/console/apistore.go +287 −0
  • internal/console/apistore_test.go +417 −0
  • internal/console/authcli.go +6 −0
  • internal/console/server.go +23 −0
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 467268d8d..9696def09 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -39,6 +39,14 @@
// (comma-separated retired hostnames this console 301/308s to CONSOLE_ORIGIN
// — opt-in per host, so a host the API must keep answering on, like
// platform.carlosframework.com for CLI tokens, is simply left off).
+//
+// Object storage (issue #55) is off unless CARLOS_STORE_BACKEND is set, in
+// which case it must be "aws" and CARLOS_STORE_REGION and
+// CARLOS_STORE_BUCKET_PREFIX are required (CARLOS_STORE_ENDPOINT is optional
+// — set it for an S3-compatible backend). CARLOS_STORE_OPERATORS
+// (comma-separated addresses) is who may grant, rotate and destroy a store;
+// it is read whether or not a backend is configured.
+//
// grant/revoke/owners need only CONSOLE_DB.
package main
@@ -55,6 +63,7 @@ import (
"github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/console"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
)
@@ -225,17 +234,21 @@ func serve() {
log.Print("console: CARLOS_RELEASE_KEY unset — promotions disabled, running read-only")
}
st := openConsoleDB()
+ storeProvisioner, storeDeployment := storeBackend()
srv, err := console.NewServer(console.Config{
- Origin: origin,
- Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
- Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
- Releases: openStore(),
- ReleasePub: pub,
- Key: key,
- EdgeIPs: edgeIPs(),
- SecretsPub: secretsPub(),
- AppsDomain: appsDomain(),
- RedirectHosts: redirectHosts(),
+ Origin: origin,
+ Keymail: envOr("CONSOLE_KEYMAIL", "https://keymail.dev"),
+ Registry: envOr("CARLOS_REGISTRY", "/data/registry.db"),
+ Releases: openStore(),
+ ReleasePub: pub,
+ Key: key,
+ EdgeIPs: edgeIPs(),
+ SecretsPub: secretsPub(),
+ AppsDomain: appsDomain(),
+ RedirectHosts: redirectHosts(),
+ StoreProvisioner: storeProvisioner,
+ StoreDeployment: storeDeployment,
+ StoreOperators: storeOperators(),
}, st)
if err != nil {
log.Fatal(err)
@@ -331,6 +344,66 @@ func appsDomain() string {
return d
}
+// storeBackend builds the object-storage half of the console's config from
+// CARLOS_STORE_* (issue #55): the Provisioner that creates buckets and mints
+// keys, and the deployment-wide coordinates every store inherits. Unset
+// CARLOS_STORE_BACKEND is a legal deployment — members may still declare
+// stores, and the operator doors refuse with a nil provisioner.
+//
+// EVERY DEFECT IS FATAL HERE, at boot, rather than surfaced as a refusal
+// later, and the backend name most of all: a typo'd backend that fell back to
+// "unconfigured" would boot a console that answers "this deployment has no
+// object storage" to an operator who configured one — the single most
+// confusing answer available, on the box least able to explain it. The
+// coordinates are fatal for a harder reason: region and bucket prefix are
+// stamped into the bucket names and IAM policies this console creates, so a
+// console booted without them would create an estate somewhere the operator
+// did not choose, and the RECORD would then say that is where the app's data
+// lives. CARLOS_STORE_ENDPOINT stays optional: an empty endpoint is what
+// "real AWS S3" means (objstore.NewAWSProvisioner).
+func storeBackend() (objstore.Provisioner, objstore.Deployment) {
+ backend := strings.TrimSpace(os.Getenv("CARLOS_STORE_BACKEND"))
+ if backend == "" {
+ return nil, objstore.Deployment{}
+ }
+ if backend != "aws" {
+ log.Fatalf("console: CARLOS_STORE_BACKEND %q is not a backend this build knows — the only value is \"aws\" (leave it unset for a deployment with no object storage)", backend)
+ }
+ region := strings.TrimSpace(os.Getenv("CARLOS_STORE_REGION"))
+ if region == "" {
+ log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_REGION (the region buckets are created in, e.g. eu-west-1)")
+ }
+ prefix := strings.TrimSpace(os.Getenv("CARLOS_STORE_BUCKET_PREFIX"))
+ if prefix == "" {
+ log.Fatal("console: CARLOS_STORE_BACKEND is set — also set CARLOS_STORE_BUCKET_PREFIX (bucket names are globally unique; e.g. carlos-)")
+ }
+ endpoint := strings.TrimSpace(os.Getenv("CARLOS_STORE_ENDPOINT"))
+ p, err := objstore.NewAWSProvisioner(context.Background(), region, endpoint)
+ if err != nil {
+ log.Fatal(err)
+ }
+ return p, objstore.Deployment{Backend: backend, Region: region, Endpoint: endpoint, BucketPrefix: prefix}
+}
+
+// storeOperators parses CARLOS_STORE_OPERATORS (comma-separated addresses) —
+// who may grant, rotate and destroy an app's store. Read REGARDLESS of the
+// backend, and deliberately so: the list is an authority statement about
+// people, it costs nothing to hold, and an operator who sets it before wiring
+// the backend should not have to remember to come back for it.
+//
+// Unset is fail-closed rather than fail-open — no operators means no grants —
+// which is the right direction for a list whose whole purpose is to gate
+// spending money in the deployment's own cloud account.
+func storeOperators() []string {
+ var out []string
+ for _, a := range strings.Split(os.Getenv("CARLOS_STORE_OPERATORS"), ",") {
+ if a = strings.TrimSpace(a); a != "" {
+ out = append(out, a)
+ }
+ }
+ return out
+}
+
// edgeIPs parses PLATFORM_EDGE_IPS (comma-separated) — the SAME variable the
// agent's domains reconcile loop reads, so the console's point-your-DNS-here
// guidance and the agent's DNS health checks can never name different
diff --git a/internal/console/apistore.go b/internal/console/apistore.go
new file mode 100644
index 000000000..e1aae5bc6
--- /dev/null
+++ b/internal/console/apistore.go
@@ -0,0 +1,287 @@
+// The CLI's object-storage API, member half (issue #55): `carlos store
+// create` declares that an app wants a bucket, and `carlos store status`
+// answers what became of that request. Both 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.
+//
+// THE MEMBER HALF WRITES ONE BLOCK AND READS ALL THREE. A store record has
+// three authors (spec §4): the member DECLARES, a deployment operator GRANTS,
+// and the console's own walk records what it PROVISIONED. This file only ever
+// writes `declared` — nothing here creates a bucket, mints a key or spends a
+// cent, which is why declaring is a member action needing nothing but a
+// bearer token. The operator doors (Task 9) are a separate authority.
+//
+// NOTHING SECRET CROSSES THIS DOOR, 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.
+//
+// NO SERIAL BUMP on the write, unlike promote/config/restart: a declaration
+// 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.
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// 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".
+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.
+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 != ""
+ 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 {
+ got, err := objstore.EnvDelivered(ctx, s.cfg.Releases, acct.Sqid, app, prefix, wantEndpoint)
+ if err != nil {
+ return nil, err
+ }
+ missing = append(missing, got...)
+ }
+ return map[string]any{
+ "app": app,
+ "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,
+ }, 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.
+//
+// IDEMPOTENT by design: re-declaring the same prefix refreshes `at`/`by` and
+// answers 200. `carlos store create` is a thing people re-run — after a failed
+// grant, from a second terminal, from a deploy script — and turning the second
+// run into an error would make the safe verb the scary one.
+func (s *Server) handleAPIStoreCreate(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, 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
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ rec, etag, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ 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 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 {
+ jsonError(w, http.StatusConflict,
+ "store already provisioned — the env prefix is fixed; destroy and re-create to change it")
+ return
+ }
+ rec.Declared = &objstore.Declared{EnvPrefix: prefix, At: time.Now().UTC(), By: addr}
+ if _, err := objstore.Put(ctx, s.cfg.Releases, acct.Sqid, app, 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+" (cli)")
+ // No bumpSerial: nothing a box reads has changed. See the file comment.
+ out, err := s.storeReply(ctx, acct, app, 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
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), storeTimeout)
+ defer cancel()
+ rec, _, err := objstore.Get(ctx, s.cfg.Releases, acct.Sqid, app)
+ 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.
+ jsonError(w, http.StatusNotFound, "no store is declared for this app — run `carlos store create`")
+ return
+ }
+ out, err := s.storeReply(ctx, acct, app, 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 config delivery — try again")
+ return
+ }
+ writeJSON(w, http.StatusOK, out)
+}
diff --git a/internal/console/apistore_test.go b/internal/console/apistore_test.go
new file mode 100644
index 000000000..86d87c813
--- /dev/null
+++ b/internal/console/apistore_test.go
@@ -0,0 +1,417 @@
+package console
+
+// The member half of the object-storage door (issue #55): declare a store,
+// then ask what became of it. What is pinned here, in the order it would hurt
+// if lost: a stranger probing either door learns nothing (the byte-identical
+// 404 the whole /api/cli surface keeps); the env prefix a provisioned store
+// delivers under cannot be changed out from under the app; and `status` tells
+// the truth about delivery — which variables have NOT arrived, and whether a
+// rotation is still half-done — because that is the only sight a member with
+// no box access has of either.
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func storeFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ // A MEMBER, not an owner: declaring your own app's store is a member
+ // action, like promote below stable — the operator half (grant, rotate,
+ // destroy) is a different door with a different authority.
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+func storeURL(fx *consoleFixture, app string) string {
+ return fx.ts.URL + "/api/cli/apps/bab/" + app + "/store"
+}
+
+// storedRecord reads the record straight out of the bucket — the check that a
+// 200 actually landed, and that a refusal left nothing behind. nil means no
+// object at all.
+func storedRecord(t *testing.T, fx *consoleFixture, app string) *objstore.Record {
+ t.Helper()
+ rec, _, err := objstore.Get(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatalf("read store record: %v", err)
+ }
+ return rec
+}
+
+// putRecord hand-writes a record the console itself would only reach through
+// the operator doors (Task 9's grant/rotate) — the state this door has to
+// REPORT on, and refuse to contradict, long before that door exists.
+func putRecord(t *testing.T, fx *consoleFixture, app string, rec *objstore.Record) {
+ t.Helper()
+ if _, err := objstore.Put(context.Background(), fx.rel, release.OpsAccount, app, rec, ""); err != nil {
+ t.Fatalf("seed store record: %v", err)
+ }
+}
+
+func strList(t *testing.T, out map[string]any, key string) []string {
+ t.Helper()
+ raw, ok := out[key].([]any)
+ if !ok {
+ t.Fatalf("%s = %#v, want a list", key, out[key])
+ }
+ got := make([]string, len(raw))
+ for i, v := range raw {
+ s, ok := v.(string)
+ if !ok {
+ t.Fatalf("%s[%d] = %#v, want a string", key, i, v)
+ }
+ got[i] = s
+ }
+ return got
+}
+
+func block(t *testing.T, out map[string]any, key string) map[string]any {
+ t.Helper()
+ m, ok := out[key].(map[string]any)
+ if !ok {
+ t.Fatalf("%s = %#v, want an object", key, out[key])
+ }
+ return m
+}
+
+// ---- declare ----------------------------------------------------------------
+
+// TestAPIStoreDeclareWritesTheRecord: the whole happy path. An empty body
+// means the default prefix, the record lands in the bucket with the caller as
+// its author, the answer names the variables the app will eventually be given,
+// and NO serial is bumped — nothing a box reads has changed, so waking the
+// fleet would be noise.
+func TestAPIStoreDeclareWritesTheRecord(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-declare")
+ if rec := storedRecord(t, fx, "titogo"); rec != nil {
+ t.Fatalf("a store record exists before any declaration: %+v", rec)
+ }
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token)
+ if status != http.StatusOK {
+ t.Fatalf("declare = %d %v, want 200", status, out)
+ }
+ if str(t, out, "app") != "titogo" {
+ t.Fatalf("app = %v, want titogo", out["app"])
+ }
+ declared := block(t, out, "declared")
+ if declared["env_prefix"] != objstore.DefaultEnvPrefix {
+ t.Fatalf("env_prefix = %v, want the default %q", declared["env_prefix"], objstore.DefaultEnvPrefix)
+ }
+ if declared["by"] != "alice@keymail.dev" {
+ t.Fatalf("declared.by = %v, want the caller", declared["by"])
+ }
+ if out["grant"] != nil || out["provisioned"] != nil {
+ t.Fatalf("grant/provisioned = %v/%v, want null on a fresh declaration", out["grant"], out["provisioned"])
+ }
+ // 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"}
+ if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
+ t.Fatalf("env_names = %v, want %v", got, wantNames)
+ }
+ if out["pending_finish"] != false {
+ t.Fatalf("pending_finish = %v, want false", out["pending_finish"])
+ }
+ if got := strList(t, out, "missing_vars"); len(got) != 0 {
+ t.Fatalf("missing_vars = %v, want empty until the store is provisioned", got)
+ }
+
+ rec := storedRecord(t, fx, "titogo")
+ if rec == nil || rec.Declared == nil {
+ t.Fatalf("record after a 200 = %+v, want a declaration in the bucket", rec)
+ }
+ if rec.App != "titogo" || rec.Schema != objstore.Schema {
+ t.Fatalf("record = %+v, want app titogo at schema %d", rec, objstore.Schema)
+ }
+ if rec.Declared.EnvPrefix != objstore.DefaultEnvPrefix || rec.Declared.By != "alice@keymail.dev" {
+ t.Fatalf("record declaration = %+v", rec.Declared)
+ }
+ // Nothing box-visible changed: no version moved, no config was written.
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial after a declaration = %d, want 0 — declaring delivers nothing to a box", got)
+ }
+ auditedBy(t, fx, "titogo", "store-create", "alice@keymail.dev", objstore.DefaultEnvPrefix)
+}
+
+// TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent: a chosen prefix renames
+// every delivered variable, and re-declaring the SAME prefix is a refresh, not
+// a conflict — `carlos store create` has to be safe to run twice, including by
+// a second member of the same account.
+func TestAPIStoreDeclareHonoursThePrefixAndIsIdempotent(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-prefix")
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("declare = %d %v", status, out)
+ }
+ wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
+ if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
+ t.Fatalf("env_names = %v, want %v", got, wantNames)
+ }
+
+ if err := fx.st.AddMember(OpsAccountID, "bob@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ bob := cliToken(t, fx, "bob@keymail.dev", "code-store-prefix-bob")
+ status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, bob)
+ if status != http.StatusOK {
+ t.Fatalf("same-prefix re-declare = %d %v, want 200", status, out)
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec == nil || rec.Declared == nil || rec.Declared.EnvPrefix != "BLOBS" {
+ t.Fatalf("record after re-declare = %+v", rec)
+ }
+ // Refreshed, not merely tolerated: the trail says who asked most recently.
+ if rec.Declared.By != "bob@keymail.dev" {
+ t.Fatalf("declared.by = %q, want the second caller", rec.Declared.By)
+ }
+}
+
+// TestAPIStoreDeclareRefusesAnInvalidPrefix: the refusal names the charset,
+// because a caller who typed a lowercase prefix has to learn the rule from the
+// answer — and nothing is written, so a corrected retry starts from nothing.
+func TestAPIStoreDeclareRefusesAnInvalidPrefix(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-badprefix")
+
+ for _, bad := range []string{"blobs", "9BLOBS", "BLOBS-X", strings.Repeat("A", 33)} {
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": bad}, token)
+ if status != http.StatusBadRequest {
+ t.Fatalf("declare %q = %d %v, want 400", bad, status, out)
+ }
+ if !strings.Contains(str(t, out, "error"), "^[A-Z][A-Z0-9_]{0,31}$") {
+ t.Fatalf("declare %q error = %q, want the charset named", bad, out["error"])
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec != nil {
+ t.Fatalf("a refused declaration wrote a record: %+v", rec)
+ }
+ }
+}
+
+// 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
+// door refuses and says how to actually change it. Same prefix still passes:
+// a re-run of `carlos store create` must not become an error the day the
+// store gets provisioned.
+func TestAPIStorePrefixIsFixedOnceProvisioned(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-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"},
+ Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-titogo", Region: "eu-west-1", IAMUser: "carlos-store-bab-titogo", KeyID: "AKIAEXAMPLE", At: now},
+ })
+
+ status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "OTHER"}, token)
+ if status != http.StatusConflict {
+ t.Fatalf("re-declare with a new prefix = %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)
+ }
+ rec := storedRecord(t, fx, "titogo")
+ if rec.Declared.EnvPrefix != "BLOBS" {
+ t.Fatalf("a refused re-declare changed the prefix to %q", rec.Declared.EnvPrefix)
+ }
+
+ status, out = postJSON(t, storeURL(fx, "titogo"), map[string]any{"env_prefix": "BLOBS"}, token)
+ if status != http.StatusOK {
+ t.Fatalf("same-prefix re-declare on a provisioned store = %d %v, want 200", status, out)
+ }
+ // The provisioning survives the refresh — the reply echoes the whole
+ // record, not just the half this door writes.
+ if got := block(t, out, "provisioned")["bucket"]; got != "carlos-bab-titogo" {
+ t.Fatalf("provisioned.bucket = %v after a re-declare", got)
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec.Provisioned == nil || rec.Provisioned.KeyID != "AKIAEXAMPLE" {
+ t.Fatalf("re-declare clobbered the provisioning: %+v", rec)
+ }
+}
+
+// ---- status -----------------------------------------------------------------
+
+// TestAPIStoreStatusIs404BeforeAnyDeclaration: an app with no store answers
+// 404 — but a SPEAKING one. This 404 fires only after the member gate has
+// passed, so the caller is entitled to the sentence; the gate's own refusals
+// (below) stay byte-identical with the stdlib's.
+func TestAPIStoreStatusIs404BeforeAnyDeclaration(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-absent")
+
+ status, out := getJSON(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusNotFound {
+ t.Fatalf("status with no store = %d %v, want 404", status, out)
+ }
+ if msg := str(t, out, "error"); !strings.Contains(msg, "no store is declared") {
+ t.Fatalf("status error = %q, want it to say no store is declared", msg)
+ }
+}
+
+// TestAPIStoreStatusReportsDeliveryAndPendingFinish: the two facts 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 holding the
+// old key alive. missing_vars is computed against the app's real manifest, so
+// a half-delivered store reads as half-delivered rather than "provisioned".
+func TestAPIStoreStatusReportsDeliveryAndPendingFinish(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-status")
+ 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"},
+ Provisioned: &objstore.Provisioned{
+ Bucket: "carlos-bab-titogo", Region: "eu-west-1", Endpoint: "https://s3.example.com",
+ IAMUser: "carlos-store-bab-titogo", UserARN: "arn:aws:iam::1:user/carlos-store-bab-titogo",
+ KeyID: "AKIANEW", PrevKeyID: "AKIAOLD", At: now,
+ },
+ })
+ // Only the bucket coordinate ever landed in the app's config.
+ if _, err := appconfig.Apply(context.Background(), fx.rel, release.OpsAccount, "titogo",
+ map[string]appconfig.Var{"BLOBS_BUCKET": {Kind: "env", Value: "carlos-bab-titogo"}}, nil); err != nil {
+ t.Fatal(err)
+ }
+
+ status, out := getJSON(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v", status, out)
+ }
+ prov := block(t, out, "provisioned")
+ if prov["key_id"] != "AKIANEW" || prov["prev_key_id"] != "AKIAOLD" {
+ t.Fatalf("provisioned = %v, want both key ids echoed (they are not secrets)", prov)
+ }
+ if out["pending_finish"] != true {
+ t.Fatalf("pending_finish = %v, want true while a previous key is still live", out["pending_finish"])
+ }
+ // The endpoint name appears only because this store HAS an endpoint.
+ wantNames := []string{"BLOBS_BUCKET", "BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
+ if got := strList(t, out, "env_names"); strings.Join(got, ",") != strings.Join(wantNames, ",") {
+ t.Fatalf("env_names = %v, want %v", got, wantNames)
+ }
+ wantMissing := []string{"BLOBS_REGION", "BLOBS_ENDPOINT", "BLOBS_ACCESS_KEY", "BLOBS_SECRET_KEY"}
+ if got := strList(t, out, "missing_vars"); strings.Join(got, ",") != strings.Join(wantMissing, ",") {
+ t.Fatalf("missing_vars = %v, want %v", got, wantMissing)
+ }
+}
+
+// TestAPIStoreStatusOfADeclaredButUnprovisionedStore: no bucket exists yet, so
+// there is nothing to be missing — an app that has merely ASKED for a store
+// must not read as four undelivered variables, which is what a caller would
+// chase down.
+func TestAPIStoreStatusOfADeclaredButUnprovisionedStore(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-declared-only")
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("declare = %d %v", status, out)
+ }
+
+ status, out := getJSON(t, storeURL(fx, "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("status = %d %v", status, out)
+ }
+ if got := strList(t, out, "missing_vars"); len(got) != 0 {
+ t.Fatalf("missing_vars = %v, want empty for a store nobody has provisioned", got)
+ }
+ if out["pending_finish"] != false {
+ t.Fatalf("pending_finish = %v, want false", out["pending_finish"])
+ }
+}
+
+// ---- the gate ---------------------------------------------------------------
+
+// TestAPIStoreGateIsByteIdentical: on BOTH doors, a token that belongs to no
+// account, an account segment that doesn't exist and an app the account never
+// claimed are one and the same 404 — byte-identical to http.NotFound's own
+// output, so probing this API teaches a token holder nothing about which
+// accounts or apps exist, and never that one has a store.
+func TestAPIStoreGateIsByteIdentical(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-gate")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-store-gate-eve")
+
+ doors := []struct {
+ name string
+ call func(u, bearer string) (int, string)
+ }{
+ {"GET", func(u, bearer string) (int, string) { return getRaw(t, u, bearer) }},
+ {"POST", func(u, bearer string) (int, string) {
+ return postRaw(t, u, map[string]any{"env_prefix": "BLOBS"}, bearer)
+ }},
+ }
+ for _, d := range doors {
+ t.Run(d.name, func(t *testing.T) {
+ // (a) a member of nothing, against a real account and a real app.
+ statusA, bodyA := d.call(storeURL(fx, "titogo"), eve)
+ // (b) a member, against an account that does not exist.
+ statusB, bodyB := d.call(fx.ts.URL+"/api/cli/apps/zzzz/titogo/store", token)
+ // (c) a member of the account, against an app it never claimed.
+ statusC, bodyC := d.call(storeURL(fx, "ghost-app"), token)
+ if statusA != http.StatusNotFound || statusB != http.StatusNotFound || statusC != http.StatusNotFound {
+ t.Fatalf("%s gate = %d / %d / %d, want 404 for all three", d.name, statusA, statusB, statusC)
+ }
+ if bodyA != bodyB || bodyB != bodyC {
+ t.Fatalf("%s refusals are not byte-identical:\n(a) %q\n(b) %q\n(c) %q", d.name, bodyA, bodyB, bodyC)
+ }
+ if bodyA != "404 page not found\n" {
+ t.Fatalf("%s refusal body = %q, want the stdlib http.NotFound output", d.name, bodyA)
+ }
+ })
+ }
+ if rec := storedRecord(t, fx, "titogo"); rec != nil {
+ t.Fatalf("a refused request wrote a record: %+v", rec)
+ }
+}
+
+// TestAPIStoreNoTokenIs401: the token gate is first, before any name is
+// resolved — cliUser's one 401 on both doors.
+func TestAPIStoreNoTokenIs401(t *testing.T) {
+ fx, _ := storeFixture(t, "code-store-notoken")
+
+ if status, _ := getJSON(t, storeURL(fx, "titogo"), ""); status != http.StatusUnauthorized {
+ t.Fatalf("status with no token = %d, want 401", status)
+ }
+ if status, _ := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, ""); status != http.StatusUnauthorized {
+ t.Fatalf("declare with no token = %d, want 401", status)
+ }
+}
+
+// TestAPIStoreDeclareIsRateLimited: the shared write budget, keyed on the
+// ADDRESS like every other write door here — a budget belongs to the person,
+// not the terminal they typed on.
+func TestAPIStoreDeclareIsRateLimited(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-limit")
+ fx.srv.cli.write = newRateLimiter(1, time.Minute)
+
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusOK {
+ t.Fatalf("first declare = %d %v", status, out)
+ }
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusTooManyRequests {
+ t.Fatalf("second declare on an exhausted budget = %d %v, want 429", status, out)
+ }
+}
+
+// TestAPIStoreWithoutABucketIs503: the record IS a bucket object. A console
+// with nowhere to put it — or to read it from — must say so rather than
+// answer for a store it cannot see.
+func TestAPIStoreWithoutABucketIs503(t *testing.T) {
+ fx, token := storeFixture(t, "code-store-nobucket")
+ fx.srv.cfg.Releases = nil
+
+ if status, out := postJSON(t, storeURL(fx, "titogo"), map[string]any{}, token); status != http.StatusServiceUnavailable {
+ t.Fatalf("declare with no bucket = %d %v, want 503", status, out)
+ }
+ if status, out := getJSON(t, storeURL(fx, "titogo"), token); status != http.StatusServiceUnavailable {
+ t.Fatalf("status with no bucket = %d %v, want 503", status, out)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 168d53380..6dd477e03 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -280,6 +280,12 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/config", s.handleAPIConfigGet)
s.mux.HandleFunc("PUT /api/cli/apps/{account}/{app}/config", s.handleAPIConfigPut)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/config/sync", s.handleAPIConfigSync)
+ // The object-storage API, member half (apistore.go, issue #55) — declare
+ // a store and read what became of it. Beside the config routes on
+ // purpose: a store's credentials are DELIVERED as config, so these two
+ // doors are two halves of the same story for whoever reads this list.
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/store", s.handleAPIStoreCreate)
+ s.mux.HandleFunc("GET /api/cli/apps/{account}/{app}/store", s.handleAPIStoreGet)
}
// clientIP is who the rate limiter counts against.
diff --git a/internal/console/server.go b/internal/console/server.go
index 931103b06..03a6a4fd8 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -24,6 +24,7 @@ import (
"github.com/carlosframework/platform/internal/applog"
"github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/objstore"
"github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/status"
@@ -190,6 +191,28 @@ type Config struct {
// host-agnostic, and POSTs through an unexpected redirect are how
// clients break) simply aren't listed.
RedirectHosts []string
+ // StoreProvisioner is the backend the object-storage doors provision
+ // through (issue #55), built at boot from CARLOS_STORE_BACKEND. nil is an
+ // ordinary deployment shape, not a broken one: members may still DECLARE
+ // a store — a declaration is a request an operator fulfils, and a member
+ // cannot see whether a backend exists — while the operator doors that
+ // create buckets and mint keys refuse. The console holds the identity
+ // that MINTS app credentials and never an app's own.
+ StoreProvisioner objstore.Provisioner
+ // StoreDeployment is the deployment-wide half of every store: which
+ // backend, which region and endpoint buckets are made in, and the prefix
+ // minted bucket names carry. Threaded into every walk so this config is
+ // the single place that knows where this deployment's object estate
+ // lives — a walk that guessed would create the next bucket somewhere
+ // else, and the record would say so afterwards.
+ StoreDeployment objstore.Deployment
+ // StoreOperators are the addresses allowed to drive the grant, rotate and
+ // destroy doors (CARLOS_STORE_OPERATORS). Its own list rather than
+ // console ownership on purpose: provisioning spends real money in the
+ // DEPLOYMENT's cloud account, so the authority belongs to whoever runs
+ // the deployment, not to a tenant who owns an account inside it. Empty is
+ // fail-closed — nobody may grant.
+ StoreOperators []string
}
// Server is the console's http.Handler.