carlos / platform Public

objstore: store.json record, keys, env-name rendering

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell e846b3d82a5d8b9b347f1f7827df523896870abe parent 1c5f7ac
2 files changed, +179 −0
  • internal/objstore/objstore.go +107 −0
  • internal/objstore/objstore_test.go +72 −0
diff --git a/internal/objstore/objstore.go b/internal/objstore/objstore.go
new file mode 100644
index 000000000..9458c4d94
--- /dev/null
+++ b/internal/objstore/objstore.go
@@ -0,0 +1,107 @@
+// Package objstore is the object-storage primitive's control plane (issue
+// #55, spec 2026-08-10): the per-app store record, the Provisioner seam the
+// console drives, and the idempotent walks that provision, rotate, and tear
+// down a store. It never touches the data plane — apps talk to their bucket
+// directly with the delivered credentials.
+package objstore
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "regexp"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const Schema = 1
+
+// DefaultEnvPrefix names the delivered env set when a declaration doesn't
+// choose its own (§7 of the spec).
+const DefaultEnvPrefix = "CARLOS_STORE"
+
+type Declared struct {
+ EnvPrefix string `json:"env_prefix"`
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type GrantBlock struct {
+ Backend string `json:"backend"`
+ Bucket string `json:"bucket,omitempty"` // set = adopt this existing bucket
+ At time.Time `json:"at"`
+ By string `json:"by"`
+}
+
+type Provisioned struct {
+ Bucket string `json:"bucket"`
+ Region string `json:"region"`
+ Endpoint string `json:"endpoint,omitempty"`
+ IAMUser string `json:"iam_user"`
+ UserARN string `json:"user_arn"`
+ KeyID string `json:"key_id"`
+ PrevKeyID string `json:"prev_key_id,omitempty"`
+ At time.Time `json:"at"`
+}
+
+// Record is apps/<acct>/<app>/store.json — three blocks for three actors
+// (member declares, operator grants, console provisions). The secret key is
+// never here; it exists only sealed inside config.json.
+type Record struct {
+ Schema int `json:"schema"`
+ App string `json:"app"`
+ Declared *Declared `json:"declared,omitempty"`
+ Grant *GrantBlock `json:"grant,omitempty"`
+ Provisioned *Provisioned `json:"provisioned,omitempty"`
+}
+
+func Key(acct, app string) string { return release.AppPrefix(acct, app) + "store.json" }
+
+var envPrefixRe = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,31}$`)
+
+func ValidEnvPrefix(p string) bool { return envPrefixRe.MatchString(p) }
+
+// EnvNames renders the delivered variable names for a prefix (§7).
+func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string) {
+ return prefix + "_BUCKET", prefix + "_REGION", prefix + "_ENDPOINT",
+ prefix + "_ACCESS_KEY", prefix + "_SECRET_KEY"
+}
+
+// Get reads the record. Absent is (nil, "", nil) — same idiom as
+// appconfig.getIfExists, because "no store" is a state, not an error.
+func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error) {
+ if !release.ValidAccount(acct) {
+ return nil, "", fmt.Errorf("objstore: invalid account %q", acct)
+ }
+ if !release.ValidApp(app) {
+ return nil, "", fmt.Errorf("objstore: invalid app %q", app)
+ }
+ raw, etag, err := s.Get(ctx, Key(acct, app))
+ if err != nil {
+ if errors.Is(err, os.ErrNotExist) {
+ return nil, "", nil
+ }
+ return nil, "", err
+ }
+ var rec Record
+ if err := json.Unmarshal(raw, &rec); err != nil {
+ return nil, "", fmt.Errorf("objstore: %s: %w", Key(acct, app), err)
+ }
+ return &rec, etag, nil
+}
+
+// Put writes the record with the caller's CAS position; etag "" is
+// create-only. Every writer is a read-modify-write, so a lost race is
+// release.ErrCASConflict for the caller to surface, never to absorb.
+func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error) {
+ rec.Schema = Schema
+ rec.App = app
+ body, err := json.MarshalIndent(rec, "", " ")
+ if err != nil {
+ return "", err
+ }
+ return s.Put(ctx, Key(acct, app), append(body, '\n'), etag)
+}
diff --git a/internal/objstore/objstore_test.go b/internal/objstore/objstore_test.go
new file mode 100644
index 000000000..10f0c4444
--- /dev/null
+++ b/internal/objstore/objstore_test.go
@@ -0,0 +1,72 @@
+package objstore
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestKey(t *testing.T) {
+ if got := Key("bab", "titogo"); got != "apps/bab/titogo/store.json" {
+ t.Fatalf("Key = %q", got)
+ }
+}
+
+func TestValidEnvPrefix(t *testing.T) {
+ for p, want := range map[string]bool{
+ "TITO_S3": true, "CARLOS_STORE": true, "A": true,
+ "tito": false, "1X": false, "": false, "_X": false,
+ "TOO_LONG_" + string(make([]byte, 40)): false,
+ } {
+ if ValidEnvPrefix(p) != want {
+ t.Errorf("ValidEnvPrefix(%q) != %v", p, want)
+ }
+ }
+}
+
+func TestEnvNames(t *testing.T) {
+ b, r, e, ak, sk := EnvNames("TITO_S3")
+ if b != "TITO_S3_BUCKET" || r != "TITO_S3_REGION" || e != "TITO_S3_ENDPOINT" ||
+ ak != "TITO_S3_ACCESS_KEY" || sk != "TITO_S3_SECRET_KEY" {
+ t.Fatalf("EnvNames = %q %q %q %q %q", b, r, e, ak, sk)
+ }
+}
+
+func TestGetAbsentIsNilNil(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ rec, etag, err := Get(context.Background(), s, "bab", "titogo")
+ if rec != nil || etag != "" || err != nil {
+ t.Fatalf("absent: rec=%v etag=%q err=%v", rec, etag, err)
+ }
+}
+
+func TestPutGetRoundTripAndCAS(t *testing.T) {
+ ctx := context.Background()
+ s := release.NewDirStore(t.TempDir())
+ rec := &Record{App: "titogo", Declared: &Declared{EnvPrefix: "TITO_S3"}}
+ etag, err := Put(ctx, s, "bab", "titogo", rec, "")
+ if err != nil {
+ t.Fatal(err)
+ }
+ got, gotTag, err := Get(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || got.Schema != Schema || got.Declared.EnvPrefix != "TITO_S3" {
+ t.Fatalf("Get = %+v, %v", got, err)
+ }
+ if gotTag != etag {
+ t.Fatalf("etag mismatch %q vs %q", gotTag, etag)
+ }
+ // create-only Put on an existing record loses
+ if _, err := Put(ctx, s, "bab", "titogo", rec, ""); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("create-only over existing: %v", err)
+ }
+ // stale etag loses
+ rec.Grant = &GrantBlock{Backend: "aws"}
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := Put(ctx, s, "bab", "titogo", rec, etag); !errors.Is(err, release.ErrCASConflict) {
+ t.Fatalf("stale etag: %v", err)
+ }
+}