| 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) |
| +} |