| new file mode 100644 |
| index 000000000..ea810a912 |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-10-object-storage-primitive.md |
| @@ -0,0 +1,870 @@ |
| +# Object-Storage Primitive Implementation Plan |
| + |
| +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. |
| + |
| +**Goal:** Issue #55 — apps declare an object store, an allowlisted operator grants it, the console provisions bucket + prefix-scoped IAM credentials and delivers them as sealed config vars; rotation and teardown are platform verbs. |
| + |
| +**Architecture:** New `internal/objstore` package owns the `store.json` record, a `Provisioner` seam (AWS impl + in-memory fake), and four idempotent walks (Grant, Rotate, RotateFinish, Teardown) that the console calls synchronously. Delivery rides the existing `internal/appconfig` rails untouched. `release.PurgeApp` gains a refuse-if-provisioned guard so box-side auto-purge can never strand a bucket. |
| + |
| +**Tech Stack:** Go, aws-sdk-go-v2 (s3 + new iam dependency), existing `release.Store` CAS discipline, existing appconfig seal/deliver crypto, Minio in CI for the contract test. |
| + |
| +**Spec:** `docs/superpowers/specs/2026-08-10-object-storage-primitive-design.md` — read it before starting any task. Section references (§N) below point there. |
| + |
| +## Global Constraints |
| + |
| +- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, `gofmt -l` clean for touched files, and `GOOS=darwin go build ./...` compiles (`internal/host` links into the Mac CLI). |
| +- Sandbox: prefix go commands with `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off` (except the one `go get` in Task 7, which needs `GOPROXY` unset and the sandbox disabled). |
| +- Wire shapes are additive: `store.json` is a new object; never change the shape of any existing bucket object. |
| +- Absence idiom: a missing bucket object is `errors.Is(err, os.ErrNotExist)` on `Store.Get`. CAS losses are `release.ErrCASConflict`. `Store.Put` with `ifMatch=""` is create-only. |
| +- Console gate order (existing rule): session → CSRF → owner (byte-identical 404) → `requireFreshAuth` LAST. |
| +- Env var name charset for the prefix: `^[A-Z][A-Z0-9_]{0,31}$`. |
| +- Every console write handler: rate-limit via `s.cli.write.allow(addr, time.Now())`, `s.audit(...)` on success, `s.bumpSerial(ctx)` only when a box-consumed object changed. |
| +- Commit after every task with a message shaped like the repo's (`objstore: <what>`); never commit with the gate red. |
| +- Suggested dispatch models are per-task hints (Paul's delegation policy); the reviewer for console/security tasks is Opus or better. |
| + |
| +--- |
| + |
| +### Task 1: `internal/objstore` — record, keys, validation |
| + |
| +**Suggested model:** Sonnet. |
| + |
| +**Files:** |
| +- Create: `internal/objstore/objstore.go` |
| +- Create: `internal/objstore/objstore_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `internal/release` — `release.Store`, `release.AppPrefix(acct, app string) string`, `release.ErrCASConflict`, `release.ValidAccount`, `release.ValidApp`. |
| +- Produces (later tasks rely on these exact names): |
| + - `const Schema = 1` |
| + - `type Declared struct { EnvPrefix string; At time.Time; By string }` (json: `env_prefix`, `at`, `by`) |
| + - `type GrantBlock struct { Backend string; Bucket string; At time.Time; By string }` (json: `backend`, `bucket,omitempty`, `at`, `by`) |
| + - `type Provisioned struct { Bucket, Region, Endpoint, IAMUser, UserARN, KeyID, PrevKeyID string; At time.Time }` (json: `bucket`, `region`, `endpoint,omitempty`, `iam_user`, `user_arn`, `key_id`, `prev_key_id,omitempty`, `at`) |
| + - `type Record struct { Schema int; App string; Declared *Declared; Grant *GrantBlock; Provisioned *Provisioned }` (json: `schema`, `app`, `declared,omitempty`, `grant,omitempty`, `provisioned,omitempty`) |
| + - `func Key(acct, app string) string` → `release.AppPrefix(acct, app) + "store.json"` |
| + - `func Get(ctx context.Context, s release.Store, acct, app string) (*Record, string, error)` — `(nil, "", nil)` when absent; validates acct/app names first like `appconfig.getIfExists`. |
| + - `func Put(ctx context.Context, s release.Store, acct, app string, rec *Record, etag string) (string, error)` — marshals indented, CAS via etag (`""` = create-only), stamps `rec.Schema = Schema`. |
| + - `func ValidEnvPrefix(p string) bool` |
| + - `const DefaultEnvPrefix = "CARLOS_STORE"` |
| + - `func EnvNames(prefix string) (bucket, region, endpoint, accessKey, secretKey string)` — returns `prefix+"_BUCKET"` etc. (`_REGION`, `_ENDPOINT`, `_ACCESS_KEY`, `_SECRET_KEY`). |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +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) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run tests, verify they fail** — `go test ./internal/objstore/ -count=1`; expected: compile error (package empty). |
| + |
| +- [ ] **Step 3: Implement `objstore.go`** |
| + |
| +```go |
| +// 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) |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests, verify pass** — `go test ./internal/objstore/ -count=1`; expected: PASS. |
| +- [ ] **Step 5: Gate + commit** — full gate from Global Constraints, then `git add internal/objstore && git commit -m "objstore: store.json record, keys, env-name rendering"`. |
| + |
| +--- |
| + |
| +### Task 2: Provisioner seam + in-memory fake |
| + |
| +**Suggested model:** Sonnet. |
| + |
| +**Files:** |
| +- Create: `internal/objstore/provisioner.go` |
| +- Create: `internal/objstore/memprovisioner.go` |
| +- Create: `internal/objstore/memprovisioner_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `type Provisioner interface` exactly as below. |
| + - `type MemProvisioner struct` with exported state maps and `FailNext map[string]error` fault injection keyed by method name (`"EnsureBucket"`, `"EnsureUser"`, `"MintKey"`, `"DeleteKey"`, `"ReapKeys"`, `"EmptyBucket"`, `"DeleteBucket"`, `"DeleteUser"`), consumed one-shot. |
| + - `func NewMemProvisioner(region string) *MemProvisioner`. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +```go |
| +package objstore |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| +) |
| + |
| +func TestMemProvisionerIdempotence(t *testing.T) { |
| + ctx := context.Background() |
| + p := NewMemProvisioner("eu-west-1") |
| + for i := 0; i < 2; i++ { // every verb is safe to repeat |
| + if region, err := p.EnsureBucket(ctx, "b1"); err != nil || region != "eu-west-1" { |
| + t.Fatalf("EnsureBucket #%d: %q %v", i, region, err) |
| + } |
| + if arn, err := p.EnsureUser(ctx, "u1", "/carlos-store/", "arn:aws:s3:::b1"); err != nil || arn == "" { |
| + t.Fatalf("EnsureUser #%d: %v", i, err) |
| + } |
| + } |
| + id1, sec1, err := p.MintKey(ctx, "u1") |
| + if err != nil || id1 == "" || sec1 == "" { |
| + t.Fatal(err) |
| + } |
| + id2, _, err := p.MintKey(ctx, "u1") |
| + if err != nil || id2 == id1 { |
| + t.Fatalf("second mint: %q vs %q, %v", id2, id1, err) |
| + } |
| + if err := p.DeleteKey(ctx, "u1", id1); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteKey(ctx, "u1", id1); err != nil { // already gone == success |
| + t.Fatal(err) |
| + } |
| + id3, _, err := p.MintKey(ctx, "u1") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.ReapKeys(ctx, "u1", []string{id3}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := len(p.Users["u1"].Keys); got != 1 { // id2 reaped, id3 kept |
| + t.Fatalf("after reap: %d keys", got) |
| + } |
| + if err := p.EmptyBucket(ctx, "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteBucket(ctx, "b1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteBucket(ctx, "b1"); err != nil { // already gone == success |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteUser(ctx, "u1"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := p.DeleteUser(ctx, "u1"); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func TestMemProvisionerFailNext(t *testing.T) { |
| + ctx := context.Background() |
| + p := NewMemProvisioner("eu-west-1") |
| + boom := errors.New("boom") |
| + p.FailNext["EnsureBucket"] = boom |
| + if _, err := p.EnsureBucket(ctx, "b1"); !errors.Is(err, boom) { |
| + t.Fatalf("want injected failure, got %v", err) |
| + } |
| + if _, err := p.EnsureBucket(ctx, "b1"); err != nil { // one-shot |
| + t.Fatalf("retry should succeed: %v", err) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run, verify fail** — `go test ./internal/objstore/ -count=1`; expected: FAIL (undefined types). |
| +- [ ] **Step 3: Implement** |
| + |
| +`provisioner.go`: |
| + |
| +```go |
| +package objstore |
| + |
| +import "context" |
| + |
| +// Provisioner is the backend seam (§3): everything the console needs a |
| +// backend to do, and nothing an app ever calls. Every method is idempotent — |
| +// "already exists" and "already gone" are successes — because every walk |
| +// that drives one must be re-runnable after a half-failure (§5). |
| +// |
| +// v1 ships the AWS implementation and this package's in-memory fake. A |
| +// Hetzner/R2/Minio provisioner arrives here later without touching a walk. |
| +type Provisioner interface { |
| + // EnsureBucket creates the bucket if absent and answers its region. |
| + EnsureBucket(ctx context.Context, name string) (region string, err error) |
| + // EnsureUser creates the credential principal if absent, scoped to |
| + // exactly bucketARN (ListBucket on it; Get/Put/DeleteObject under it). |
| + EnsureUser(ctx context.Context, name, path, bucketARN string) (arn string, err error) |
| + MintKey(ctx context.Context, user string) (id, secret string, err error) |
| + DeleteKey(ctx context.Context, user, id string) error |
| + // ReapKeys deletes every access key of user NOT named in keep. The grant |
| + // walk's crash-safety depends on it: a key minted before a failed |
| + // delivery is recorded nowhere, so only list-and-delete can find it. |
| + ReapKeys(ctx context.Context, user string, keep []string) error |
| + // EmptyBucket deletes every object; paginated and resumable. |
| + EmptyBucket(ctx context.Context, name string) error |
| + DeleteBucket(ctx context.Context, name string) error |
| + // DeleteUser removes the user's keys and inline policy first. |
| + DeleteUser(ctx context.Context, user string) error |
| +} |
| +``` |
| + |
| +`memprovisioner.go`: maps guarded by a `sync.Mutex` — `Buckets map[string]map[string][]byte` (bucket → key → body), `Users map[string]memUser` where `memUser` holds `ARN string` and `Keys map[string]string` (key id → secret); `nextKey int` for deterministic ids (`"MEMKEY1"`, `"MEMSEC1"`, …); every method first checks-and-consumes `FailNext[method]`. `EnsureBucket` returns the constructor's region. `EnsureUser` ARN = `"arn:mem:iam::user" + path + name`. |
| + |
| +- [ ] **Step 4: Run, verify pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: Provisioner seam and in-memory fake"`. |
| + |
| +--- |
| + |
| +### Task 3: Grant walk — provision + deliver |
| + |
| +**Suggested model:** Opus (this is the walk everything else trusts). |
| + |
| +**Files:** |
| +- Create: `internal/objstore/walk.go` |
| +- Create: `internal/objstore/walk_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1 record + Task 2 provisioner; `appconfig.Apply(ctx, s, acct, app, sets map[string]appconfig.Var, unsets []string) (int, error)`; `appconfig.Var{Kind, Value, Envelope}`; `appconfig.Seal(pub *ecdh.PublicKey, plaintext []byte) (string, error)`; `appconfig.ParsePublicKey(b64, src string) (*ecdh.PublicKey, error)`; `appconfig.Get(ctx, s, acct, app) (*Manifest, error)`. |
| +- Produces: |
| + - `type Deployment struct { Backend, Region, Endpoint, BucketPrefix string }` |
| + - `func MintedBucket(d Deployment, acct, app string) string` → `d.BucketPrefix + acct + "-" + app` |
| + - `func UserName(acct, app string) string` → `"carlos-store-" + acct + "-" + app` |
| + - `const UserPath = "/carlos-store/"` |
| + - `func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error)` |
| + - Sentinel errors: `ErrNotDeclared`, `ErrEnvPrefixLocked` (Task 8/9 map them to HTTP). |
| + |
| +**The walk, in spec §5 order** (each numbered step is check-then-create): |
| + |
| +```go |
| +func Grant(ctx context.Context, s release.Store, p Provisioner, d Deployment, |
| + sealPub *ecdh.PublicKey, acct, app, actor, adoptBucket string, now time.Time) (*Record, error) { |
| + // 1. resolve + write the grant block |
| + rec, etag, err := Get(ctx, s, acct, app) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if rec == nil || rec.Declared == nil { |
| + return nil, ErrNotDeclared |
| + } |
| + bucket := adoptBucket |
| + if rec.Grant != nil && rec.Grant.Bucket != "" { |
| + bucket = rec.Grant.Bucket // a re-run never re-chooses the bucket |
| + } |
| + if bucket == "" { |
| + bucket = MintedBucket(d, acct, app) |
| + } |
| + if rec.Grant == nil { |
| + rec.Grant = &GrantBlock{Backend: d.Backend, Bucket: bucket, At: now, By: actor} |
| + if etag, err = Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + } |
| + // Idempotence short-circuit: a healthy provisioned store makes a grant |
| + // re-run a no-op — no fresh key, no rewrite. "Healthy" means the record |
| + // says provisioned AND every delivered var is present in config.json |
| + // (envDelivered is a small helper diffing EnvNames against |
| + // appconfig.Get's Vars, shared with Task 8's missing_vars). |
| + if rec.Provisioned != nil { |
| + ok, err := envDelivered(ctx, s, acct, app, rec.Declared.EnvPrefix, rec.Provisioned.Endpoint != "") |
| + if err != nil { |
| + return nil, err |
| + } |
| + if ok { |
| + return rec, nil |
| + } |
| + } |
| + // 2–3. backend resources |
| + region, err := p.EnsureBucket(ctx, bucket) |
| + if err != nil { |
| + return nil, fmt.Errorf("objstore: ensure bucket %s: %w", bucket, err) |
| + } |
| + user := UserName(acct, app) |
| + arn, err := p.EnsureUser(ctx, user, UserPath, "arn:aws:s3:::"+bucket) |
| + if err != nil { |
| + return nil, fmt.Errorf("objstore: ensure user %s: %w", user, err) |
| + } |
| + // 4. key. Reap first: reaching here means delivery is missing, so any |
| + // existing CURRENT key is either undelivered (its secret died with a |
| + // failed config write — unrecoverable) or stranded by a crash between |
| + // mint and record, which no record can name. Only list-and-delete finds |
| + // both; a pending-finish key from an old rotation is kept for its |
| + // explicit finish. |
| + keep := []string(nil) |
| + if rec.Provisioned != nil && rec.Provisioned.PrevKeyID != "" { |
| + keep = append(keep, rec.Provisioned.PrevKeyID) |
| + } |
| + if err := p.ReapKeys(ctx, user, keep); err != nil { |
| + return nil, fmt.Errorf("objstore: reap keys for %s: %w", user, err) |
| + } |
| + id, secret, err := p.MintKey(ctx, user) |
| + if err != nil { |
| + return nil, fmt.Errorf("objstore: mint key for %s: %w", user, err) |
| + } |
| + // 5. deliver — the secret's ONLY landing place (spec §7) |
| + if err := writeEnv(ctx, s, sealPub, acct, app, rec.Declared.EnvPrefix, bucket, region, d.Endpoint, id, secret); err != nil { |
| + return nil, err |
| + } |
| + // 6. record success — after the secret is recorded, never before (§5). |
| + // PrevKeyID carries over untouched: the reap above already removed the |
| + // only stale current key, and a pending-finish key from an old rotation |
| + // still deserves its explicit finish. |
| + prev := "" |
| + if rec.Provisioned != nil { |
| + prev = rec.Provisioned.PrevKeyID |
| + } |
| + rec.Provisioned = &Provisioned{Bucket: bucket, Region: region, Endpoint: d.Endpoint, |
| + IAMUser: user, UserARN: arn, KeyID: id, PrevKeyID: prev, At: now} |
| + if _, err := Put(ctx, s, acct, app, rec, etag); err != nil { |
| + return nil, err |
| + } |
| + return rec, nil |
| +} |
| +``` |
| + |
| +`writeEnv` builds the sets map with `EnvNames(prefix)`: coordinates as `appconfig.Var{Kind: "env", Value: …}`, both key halves sealed via `appconfig.Seal(sealPub, []byte(v))` into `appconfig.Var{Kind: "secret", Envelope: …}`; `_ENDPOINT` only when endpoint non-empty; one `appconfig.Apply` call (which also touches config-gen). |
| + |
| +- [ ] **Step 1: Write the failing tests** — in `walk_test.go`, with a helper `testSealKeypair(t)` using `appconfig.GenerateKey()` + `appconfig.ParsePublicKey`/`ParsePrivateKey`: |
| + - `TestGrantUnDeclaredRefused`: no record → `ErrNotDeclared`. |
| + - `TestGrantMintsAndDelivers`: declare (write a Record with `Declared`), run `Grant` with `MemProvisioner` and `Deployment{Backend: "aws", Region: "eu-west-1", BucketPrefix: "carlos-store-tito-"}`, adoptBucket `""`; assert bucket named `carlos-store-tito-bab-titogo` exists in fake; user `carlos-store-bab-titogo` exists; record's `Provisioned.KeyID` matches the fake's minted id; `appconfig.Get` shows `TITO_S3_BUCKET` env var and `TITO_S3_SECRET_KEY` envelope that `appconfig.Open(priv, …)` decrypts to the fake's secret; no `TITO_S3_ENDPOINT` key (empty endpoint). |
| + - `TestGrantAdoptsExistingBucket`: adoptBucket `"titogo-uploads"` → provisioned bucket is `titogo-uploads`, minted name unused. |
| + - `TestGrantResumesAfterEachStepFailure`: loop over method names `EnsureBucket, EnsureUser, MintKey`; inject `FailNext[m]`, run `Grant`, assert error; run again, assert success and exactly one bucket + one user in the fake. For the delivery step, use a store wrapper failing the first `Put` to `config.json` (a small `failingStore struct{ release.Store; failKeyOnce string }` in the test file), assert the retry completes, the delivered `_ACCESS_KEY` matches the SECOND minted id, and the fake holds exactly ONE key (the undelivered first was reaped). |
| + - `TestGrantRerunKeepsBucketChoice`: grant with adoptBucket, then re-run with adoptBucket `""` — bucket unchanged. |
| + - `TestGrantIdempotentWhenHealthy`: grant, then grant again — same `KeyID` in the record, exactly one key in the fake, config untouched (compare the sealed envelope strings before/after: unchanged). |
| +- [ ] **Step 2: Run, verify fail.** |
| +- [ ] **Step 3: Implement `walk.go`** (code above + `writeEnv` + sentinels). |
| +- [ ] **Step 4: Run, verify pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: grant walk — provision, mint, deliver, record"`. |
| + |
| +--- |
| + |
| +### Task 4: Rotate + RotateFinish walks |
| + |
| +**Suggested model:** Opus. |
| + |
| +**Files:** |
| +- Modify: `internal/objstore/walk.go` |
| +- Modify: `internal/objstore/walk_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: |
| + - `func Rotate(ctx context.Context, s release.Store, p Provisioner, sealPub *ecdh.PublicKey, acct, app string, now time.Time) (*Record, error)` |
| + - `func RotateFinish(ctx context.Context, s release.Store, p Provisioner, acct, app string) (*Record, error)` |
| + - Sentinels: `ErrNotProvisioned`, `ErrNoPendingFinish`. |
| + |
| +**Behaviour (spec §8):** `Rotate`: record must be `Provisioned` else `ErrNotProvisioned`; if `PrevKeyID != ""` delete it first (the 2-key ceiling is the state machine); then `ReapKeys(user, keep=[KeyID])` — a crash in an earlier rotate between mint and reseal leaves a stray key no record names, and without the reap the next mint wedges at the ceiling; mint new key; reseal both key vars via the same `writeEnv` names (coordinates unchanged — pass current bucket/region/endpoint from the record); update record `PrevKeyID = old KeyID`, `KeyID = new`, CAS. Old key stays ACTIVE. `RotateFinish`: `PrevKeyID == ""` → `ErrNoPendingFinish`; delete prev key, clear `PrevKeyID`, CAS. No config change on finish. |
| + |
| +- [ ] **Step 1: Failing tests** — `TestRotateKeepsOldKeyActive` (after rotate: fake user has BOTH keys; config's sealed `_ACCESS_KEY` decrypts to the NEW id; record PrevKeyID = old id), `TestRotateTwiceReapsPending` (rotate; rotate again → first key deleted from fake, second pends, third live: exactly 2 keys in fake at all times), `TestRotateResumesAfterResealFailure` (failingStore wrapper fails the config write once: first run errs, re-run succeeds, fake never exceeds 2 keys, delivered `_ACCESS_KEY` decrypts to the final `KeyID`), `TestRotateFinishDeletesPrev` (+ `ErrNoPendingFinish` on repeat), `TestRotateUnprovisionedRefused`. |
| +- [ ] **Step 2: Run, verify fail.** **Step 3: Implement.** **Step 4: Run, verify pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: two-key rotation with explicit finish"`. |
| + |
| +--- |
| + |
| +### Task 5: Teardown walk |
| + |
| +**Suggested model:** Opus. |
| + |
| +**Files:** |
| +- Modify: `internal/objstore/walk.go` |
| +- Modify: `internal/objstore/walk_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `func Teardown(ctx context.Context, s release.Store, p Provisioner, acct, app string) error`. |
| +- Consumes: `appconfig.Apply` with `unsets` (Task 3's import set). |
| + |
| +**Behaviour (spec §9):** read record; absent or no `Provisioned` → delete `store.json` if it exists (declared-only records die trivially) and return nil. Otherwise, in order: `EmptyBucket` → `DeleteBucket` → `DeleteUser` (which reaps keys+policy) → unset all five delivered var names via `appconfig.Apply(ctx, s, acct, app, nil, unsets)` (unsetting an absent var is a no-op — pass all five including `_ENDPOINT`) → delete `store.json` LAST (`s.Delete(ctx, Key(acct, app))`). Any failure returns with the record intact — the ordering rule stated once in §9: read first, delete the record last. |
| + |
| +- [ ] **Step 1: Failing tests** — `TestTeardownFullSweep` (grant, put an object into the fake bucket, teardown: fake has no bucket/user, config has no `TITO_S3_*` vars, `Get` returns nil record), `TestTeardownResumable` (inject failure into each of `EmptyBucket, DeleteBucket, DeleteUser` in turn: first run errs, record still provisioned; re-run completes), `TestTeardownDeclaredOnly` (record with only `Declared` → record deleted, no provisioner calls needed — assert via a `FailNext` on every method that would explode if called... simpler: assert fake state unchanged), `TestTeardownIdempotentOnAbsent` (no record → nil). |
| +- [ ] **Step 2–4: fail → implement → pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "objstore: teardown walk, record deleted last"`. |
| + |
| +--- |
| + |
| +### Task 6: `release.PurgeApp` refuses a provisioned store |
| + |
| +**Suggested model:** Opus (root-side blast radius; this is the guard the rollout order depends on). |
| + |
| +**Files:** |
| +- Modify: `internal/release/trash.go` (PurgeApp, after the production-flag check, before the List) |
| +- Modify: `internal/release/trash_test.go` |
| + |
| +**Interfaces:** |
| +- Produces: `var ErrStoreProvisioned = errors.New("release: app has a provisioned object store — tear it down via the console before purging")` in `trash.go`. |
| +- NOTE for the implementer: `release` cannot import `objstore` (objstore imports release). PurgeApp parses the ONE fact it needs from `store.json` with a local anonymous struct. This makes `release` a parser of the store record's shape — recorded in comments on BOTH sides (the "consumer list is everything that PARSES the shape" rule this repo has paid for three times). |
| + |
| +- [ ] **Step 1: Failing test** in `trash_test.go`: |
| + |
| +```go |
| +func TestPurgeRefusesProvisionedStore(t *testing.T) { |
| + ctx := context.Background() |
| + s := NewDirStore(t.TempDir()) |
| + pub, priv := testSigningKey(t) // use this file's existing key helper; adjust the call to match it |
| + _ = priv |
| + if err := TrashApp(ctx, s, "bab", "titogo", "who", time.Now().Add(-31*24*time.Hour)); err != nil { |
| + t.Fatal(err) |
| + } |
| + // provisioned store.json → refuse |
| + body := []byte(`{"schema":1,"app":"titogo","provisioned":{"bucket":"b","key_id":"k"}}`) |
| + if _, err := s.Put(ctx, "apps/bab/titogo/store.json", body, ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "titogo", time.Now()); !errors.Is(err, ErrStoreProvisioned) { |
| + t.Fatalf("want ErrStoreProvisioned, got %v", err) |
| + } |
| + // declared-only store.json → purge proceeds and sweeps it |
| + body = []byte(`{"schema":1,"app":"titogo","declared":{"env_prefix":"X"}}`) |
| + if _, err := s.Put(ctx, "apps/bab/titogo/store.json", body, etagOfExisting(t, s, "apps/bab/titogo/store.json")); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "titogo", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| +``` |
| + |
| +(The implementer adapts the two helpers — signing-key setup and the etag fetch — to whatever this test file already uses for `TestPurgeApp*`; both exist there. TrashApp's timestamp comes from its `now` parameter, so backdating is the argument, not a sleep.) |
| + |
| +- [ ] **Step 2: Run, verify fail** — `go test ./internal/release/ -run TestPurgeRefuses -count=1`. |
| +- [ ] **Step 3: Implement the guard** in `PurgeApp` between the production check and the `s.List`: |
| + |
| +```go |
| + // A provisioned object store refuses the purge outright (objstore spec |
| + // §9): auto-purge runs on boxes, boxes hold no backend authority, and a |
| + // swept store.json is a paid bucket nothing remembers. The console's |
| + // purge door tears the store down first and only then re-enters here. |
| + // Parsed minimally in place — release cannot import objstore (it imports |
| + // release), so this file is on store.json's parser list, deliberately. |
| + if raw, _, err := s.Get(ctx, prefix+"store.json"); err == nil { |
| + var rec struct { |
| + Provisioned *struct{} `json:"provisioned"` |
| + } |
| + if jerr := json.Unmarshal(raw, &rec); jerr == nil && rec.Provisioned != nil { |
| + return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return fmt.Errorf("release: cannot read %sstore.json — refusing to purge: %w", prefix, err) |
| + } |
| +``` |
| + |
| +An UNREADABLE record refuses too — same direction as the production flag. |
| + |
| +- [ ] **Step 4: Run package tests, verify pass** — `go test ./internal/release/ -count=1`. |
| +- [ ] **Step 5: Add a mirror test in objstore** (`walk_test.go`): `TestPurgeGuardSeesOurRecord` — write a provisioned `Record` via `Put`, call `release.PurgeApp` (after `release.TrashApp` backdated past cooloff, with a test key), assert `errors.Is(err, release.ErrStoreProvisioned)`. This pins the two packages' parse agreement. |
| +- [ ] **Step 6: Gate + commit** — `git commit -m "release: PurgeApp refuses a provisioned object store"`. |
| + |
| +--- |
| + |
| +### Task 7: AWS provisioner |
| + |
| +**Suggested model:** Sonnet (mechanical SDK plumbing against a fixed interface). |
| + |
| +**Files:** |
| +- Create: `internal/objstore/aws.go` |
| +- Create: `internal/objstore/aws_test.go` (construction-only — no AWS calls in CI) |
| +- Modify: `go.mod`/`go.sum` (new dependency) |
| + |
| +**Interfaces:** |
| +- Produces: `func NewAWSProvisioner(ctx context.Context, region, endpoint string) (Provisioner, error)` — loads default AWS config in `region`; S3 client applies `s3compat`-style options when `endpoint != ""` (BaseEndpoint + `UsePathStyle`); IAM client is plain (IAM has no S3-compatible twin — a non-empty endpoint plus backend `aws` is still IAM-against-AWS in v1). |
| + |
| +- [ ] **Step 1: Add the dependency** (sandbox off for this one command): `go get github.com/aws/aws-sdk-go-v2/service/iam@latest && go mod tidy`. |
| +- [ ] **Step 2: Implement `aws.go`** — `awsProvisioner{s3 *s3.Client, iam *iam.Client, region string}`: |
| + - `EnsureBucket`: `CreateBucket` (with `CreateBucketConfiguration{LocationConstraint}` unless region is `us-east-1`); treat `BucketAlreadyOwnedByYou`/`BucketAlreadyExists`-owned as success (`errors.As` on the typed errors); return the configured region. |
| + - `EnsureUser`: `CreateUser` with `Path`, `EntityAlreadyExistsException` = success (then `GetUser` for the ARN); `PutUserPolicy` (idempotent overwrite) with policy name `carlos-store` and document: |
| + |
| +```json |
| +{"Version": "2012-10-17", "Statement": [ |
| + {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": "<bucketARN>"}, |
| + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": "<bucketARN>/*"} |
| +]} |
| +``` |
| + |
| + - `MintKey`: `CreateAccessKey`. |
| + - `DeleteKey`: `DeleteAccessKey`, `NoSuchEntityException` = success. |
| + - `ReapKeys`: `ListAccessKeys` (`NoSuchEntity` = success, nothing to reap) → `DeleteAccessKey` for every id not in `keep`. |
| + - `EmptyBucket`: `ListObjectsV2` paginator + `DeleteObjects` batches of 1000; `NoSuchBucket` = success. |
| + - `DeleteBucket`: `NoSuchBucket` = success. |
| + - `DeleteUser`: `ListAccessKeys` → `DeleteAccessKey` each → `DeleteUserPolicy` (`NoSuchEntity` ok) → `DeleteUser` (`NoSuchEntity` ok). |
| +- [ ] **Step 3: Construction test** — `NewAWSProvisioner(ctx, "eu-west-1", "")` returns a non-nil `Provisioner` with no error (config load works offline); type-assert it satisfies the interface at compile time with `var _ Provisioner = (*awsProvisioner)(nil)`. |
| +- [ ] **Step 4: Gate + commit** — the darwin cross-compile matters here (new dependency). `git commit -m "objstore: AWS provisioner (S3 + IAM, path-scoped)"`. |
| + |
| +--- |
| + |
| +### Task 8: Console — config plumbing + member endpoints (create/status) |
| + |
| +**Suggested model:** Opus (console security surface). |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go` (Config fields + 2 route registrations in the `/api/cli` block near `authcli.go`'s siblings — put the `HandleFunc` calls where the config ones live, `internal/console/authcli.go:277-279`) |
| +- Create: `internal/console/apistore.go` |
| +- Create: `internal/console/apistore_test.go` |
| +- Modify: `cmd/console/main.go` (env → Config wiring) |
| + |
| +**Interfaces:** |
| +- Consumes: `objstore.Get/Put/Record/ValidEnvPrefix/DefaultEnvPrefix/EnvNames`, `appconfig.Get`. |
| +- Produces: |
| + - `console.Config` gains: `StoreProvisioner objstore.Provisioner` (nil = backend unconfigured), `StoreDeployment objstore.Deployment`, `StoreOperators []string`. |
| + - Routes: `POST /api/cli/apps/{account}/{app}/store` → `handleAPIStoreCreate`; `GET /api/cli/apps/{account}/{app}/store` → `handleAPIStoreGet`. |
| + - JSON reply shape both endpoints share (Task 11's CLI parses this): `{"app": …, "declared": {...}|null, "grant": {...}|null, "provisioned": {...}|null, "env_names": [...], "pending_finish": bool, "missing_vars": [...]}` — provisioned echoed WITHOUT `key_id`s? No: key ids are not secrets and `status` prints them; echo the full record minus nothing (the secret never enters the record). |
| + |
| +**handleAPIStoreCreate** (member door, gates in `handleAPIRestart`'s order): `apiAccountApp` → rate limit → `Releases == nil` 503 → decode body `{"env_prefix": string}` (empty → `DefaultEnvPrefix`; invalid → 400 naming the charset) → read record; if `Provisioned != nil` and prefix differs from `Declared.EnvPrefix` → 409 "store already provisioned — the env prefix is fixed; destroy and re-create to change it" → else write/update `Declared{EnvPrefix, At: time.Now(), By: addr}` (CAS; conflict → 409 "changed underneath you — try again") → audit `store-create` → 200 with the shared reply shape. No serial bump (nothing box-visible changed). |
| + |
| +**handleAPIStoreGet**: `apiAccountApp` → read record (absent → 404 `jsonError` "no store is declared for this app") → compute `missing_vars` by diffing `EnvNames(prefix)` against `appconfig.Get`'s Vars (skip `_ENDPOINT` when `Provisioned.Endpoint` is empty; skip the whole diff when not provisioned) and `pending_finish = Provisioned != nil && Provisioned.PrevKeyID != ""` → 200. |
| + |
| +**cmd/console/main.go** wiring, next to the `CARLOS_SECRETS_PUBKEY` block (`cmd/console/main.go:304`): read `CARLOS_STORE_BACKEND`; if set it must be `"aws"` (anything else: `log.Fatal` — a typo'd backend must not boot a console that answers "not configured"); require `CARLOS_STORE_REGION` and `CARLOS_STORE_BUCKET_PREFIX` non-empty, read optional `CARLOS_STORE_ENDPOINT`, build `objstore.NewAWSProvisioner`, fill `StoreDeployment`; parse `CARLOS_STORE_OPERATORS` (split on `,`, trim, drop empties) regardless of backend. |
| + |
| +- [ ] **Step 1: Failing tests** in `apistore_test.go`, following `apirestart_test.go`'s harness (in-process server, DirStore, bearer token helper): declare happy path (+ default prefix), invalid prefix 400, non-member 404 byte-identical to unknown-account 404 (copy the existing byte-identical assertion pattern from `authowner_test.go`), prefix-locked 409 after hand-writing a provisioned record, status absent 404, status shows `missing_vars` when config lacks the delivered names and `pending_finish` when `PrevKeyID` set. |
| +- [ ] **Step 2: Run, verify fail.** **Step 3: Implement.** **Step 4: Run, verify pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "console: store declare/status member endpoints + backend config plumbing"`. |
| + |
| +--- |
| + |
| +### Task 9: Console — grant, rotate, rotate-finish endpoints |
| + |
| +**Suggested model:** Opus (the authority door). |
| + |
| +**Files:** |
| +- Modify: `internal/console/apistore.go`, `internal/console/apistore_test.go` |
| +- Modify: `internal/console/server.go` (3 routes: `POST …/store/grant`, `POST …/store/rotate`, `POST …/store/rotate/finish`) |
| + |
| +**Interfaces:** |
| +- Consumes: `objstore.Grant/Rotate/RotateFinish`, `appconfig.ParsePublicKey(s.cfg.SecretsPub, "CARLOS_SECRETS_PUBKEY")`. |
| +- Produces: same JSON reply shape as Task 8. |
| + |
| +**handleAPIStoreGrant** gates, in order: `apiAccountApp` (member first — so the 404 posture is identical to every sibling) → **allowlist**: `addr` must be in `s.cfg.StoreOperators` else 403 `jsonError` "store grants need a deployment operator (CARLOS_STORE_OPERATORS)" — after the 404 gate, a member learning this door exists reveals nothing about other accounts → rate limit → `Releases == nil` 503 → `StoreProvisioner == nil` 503 "object storage is not configured for this deployment (CARLOS_STORE_BACKEND)" → `SecretsPub == ""` 503 (cannot seal → cannot deliver → must not mint) → decode body `{"bucket": string}` (optional adopt) → `objstore.Grant(ctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreDeployment, pub, acct.Sqid, app, addr, bucket, time.Now())` with a 60s context timeout (bucket creation is slow) → map `ErrNotDeclared` to 409 "no store is declared — run carlos store create first"; other errors log + 502 with the fixed sentence pattern ("could not provision the store — nothing was granted that isn't recorded; re-run to resume") → audit `store-grant` with the bucket name in the detail → `s.bumpSerial(ctx)` → 200. |
| + |
| +**handleAPIStoreRotate**: member gates as in create → `StoreProvisioner`/`SecretsPub` 503s → `objstore.Rotate` → `ErrNotProvisioned` 409 → audit `store-rotate` → bump → 200. **handleAPIStoreRotateFinish**: same, `objstore.RotateFinish`, `ErrNoPendingFinish` → 409, audit, **no bump** (no box-consumed object changed). |
| + |
| +- [ ] **Step 1: Failing tests**: grant as non-allowlisted member → 403 (and the member-vs-stranger 404 still byte-identical); grant with no backend → 503; grant happy path against `MemProvisioner` (assert: fake has bucket+user, config has sealed vars, record provisioned, serial bumped — read `control/serial` before/after); grant maps `ErrNotDeclared` → 409; rotate happy + finish happy + finish-twice 409; rotate keeps serial-bump discipline (bump on rotate, none on finish). |
| +- [ ] **Step 2–4: fail → implement → pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "console: store grant behind operator allowlist, rotate + finish"`. |
| + |
| +--- |
| + |
| +### Task 10: Console browser — purge integration, destroy door, Settings card |
| + |
| +**Suggested model:** Opus, and this task's review is the one to be pickiest about (gate order + destruction). |
| + |
| +**Files:** |
| +- Modify: `internal/console/ownership.go` (`handlePurgeApp`) |
| +- Modify: `internal/console/appsettings.go` (`SettingsView`, `assembleSettings`, new `handleStoreDestroy`) |
| +- Modify: `internal/console/templates/app_settings.html` |
| +- Modify: `internal/console/server.go` (route `POST /a/{sqid}/apps/{app}/store/destroy`) |
| +- Modify: `internal/console/deletion_test.go` or `appsettings_test.go` (whichever file's harness fits; follow the existing purge tests) |
| + |
| +**Interfaces:** |
| +- Consumes: `objstore.Get/Teardown`, `release.ErrStoreProvisioned`. |
| +- Produces: `SettingsView` gains `Store *StoreCard` where `type StoreCard struct { Declared bool; EnvPrefix string; Granted bool; Backend string; Bucket string; Provisioned bool; KeyID string; KeyAge string; PendingFinish bool; CanDestroy bool }` (`CanDestroy` = provisioned && `s.cfg.StoreProvisioner != nil`). |
| + |
| +**handlePurgeApp change** — insert between `requireFreshAuth` (`internal/console/ownership.go:308`) and `release.PurgeApp` (`:315`): |
| + |
| +```go |
| + // A provisioned store is torn down BEFORE the prefix sweep — this door |
| + // is the only purge path with backend authority (objstore spec §9; |
| + // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure |
| + // aborts the purge with the record intact: re-entering resumes. |
| + if rec, _, rerr := objstore.Get(r.Context(), s.cfg.Releases, acct.Sqid, app); rerr == nil && rec != nil && rec.Provisioned != nil { |
| + if s.cfg.StoreProvisioner == nil { |
| + fail("“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down.") |
| + return |
| + } |
| + if terr := objstore.Teardown(r.Context(), s.cfg.Releases, s.cfg.StoreProvisioner, acct.Sqid, app); terr != nil { |
| + log.Printf("console: purge store teardown %s/%s: %v", acct.Sqid, app, terr) |
| + fail("Could not tear down “" + app + "”'s object store — nothing was deleted; try again.") |
| + return |
| + } |
| + s.audit(acct, app, addr, "store-destroy", "tore down the object store (bucket "+rec.Provisioned.Bucket+") during permanent deletion") |
| + } else if rerr != nil { |
| + log.Printf("console: purge store read %s/%s: %v", acct.Sqid, app, rerr) |
| + fail("Could not read “" + app + "”'s object-store record — nothing was deleted; try again.") |
| + return |
| + } |
| +``` |
| + |
| +Also extend `purgeAdvice` (same file) to map `release.ErrStoreProvisioned` to the backend-unconfigured sentence above — belt and braces if the guard and this door ever race. |
| + |
| +**handleStoreDestroy** — clone `handlePurgeApp`'s ceremony exactly (`s.owner` → CSRF `subtle.ConstantTimeCompare` → typed-name check → `requireFreshAuth` LAST), with the typed confirmation being the BUCKET name (`r.FormValue("confirm")` vs `rec.Provisioned.Bucket`); refusals flash back to `/a/{sqid}/apps/{app}/settings` (write a local `fail` helper mirroring `trashFailure`'s shape but redirecting to the settings tab); then `objstore.Teardown` → audit `store-destroy` → `s.bumpSerial(r.Context())` (env vars were unset — boxes must converge) → flash ok → redirect to settings. A declared-only store (nothing provisioned) is destroyable without the bucket ceremony? No — keep one door: `confirm` must equal the bucket when provisioned, or the APP name when declared-only. |
| + |
| +**Settings card** — `assembleSettings` fills `StoreCard` from `objstore.Get` (best-effort like the rest of that function: read failure logs and leaves `Store` nil); template section appended to `app_settings.html` following its existing card markup conventions (match the surrounding HTML structure and class names when editing; keep icons to the vendored Lucide set): shows declared prefix, granted backend/bucket, key id + age, a "finish pending" nag naming `carlos store rotate -finish`, and the destroy form (bucket-name input + CSRF hidden field, matching the delete form above it). Not provisioned + not declared → the card renders one line naming `carlos store create`. |
| + |
| +- [ ] **Step 1: Failing tests**: purge of a provisioned-store app with `StoreProvisioner: nil` → flash failure, bucket objects intact; purge with `MemProvisioner` → store gone from fake AND app prefix swept; destroy door: wrong typed bucket refused before fresh-auth (assert no step-up redirect happened), happy path tears down + bumps serial; settings page renders the card fields for a provisioned record. |
| +- [ ] **Step 2–4: fail → implement → pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "console: store teardown in purge door, destroy ceremony, settings card"`. |
| + |
| +--- |
| + |
| +### Task 11: CLI — `carlos store` verb |
| + |
| +**Suggested model:** Sonnet (precise brief, established three-layer pattern). |
| + |
| +**Files:** |
| +- Create: `store.go` (repo root, next to `restart.go`) |
| +- Create: `store_test.go` |
| +- Modify: `apimode.go` (apiSession methods + reply struct) |
| +- Modify: `main.go` (dispatch `case "store": runStore(args)` + usage line in the help text where sibling verbs list theirs) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 8/9's JSON reply shape; `apiSession.postJSON/getJSON`, `apiPath(a, app, "/store")`, `requireRestartAPISession`-style session acquisition (write a `requireStoreAPISession` mirroring it with a store-specific refusal sentence for direct-bucket mode), `accountFlags`, `apiAccount`, `validAppName`, `printUsing(stdout, sess)`. |
| +- Produces in `apimode.go`: |
| + |
| +```go |
| +type apiStoreReply struct { |
| + App string `json:"app"` |
| + Declared *objstore.Declared `json:"declared"` |
| + Grant *objstore.GrantBlock `json:"grant"` |
| + Provisioned *objstore.Provisioned `json:"provisioned"` |
| + EnvNames []string `json:"env_names"` |
| + PendingFinish bool `json:"pending_finish"` |
| + MissingVars []string `json:"missing_vars"` |
| +} |
| +func (s *apiSession) storeCreate(ctx context.Context, a apiAcct, app, envPrefix string) (apiStoreReply, error) |
| +func (s *apiSession) storeGet(ctx context.Context, a apiAcct, app string) (apiStoreReply, error) |
| +func (s *apiSession) storeGrant(ctx context.Context, a apiAcct, app, bucket string) (apiStoreReply, error) |
| +func (s *apiSession) storeRotate(ctx context.Context, a apiAcct, app string, finish bool) (apiStoreReply, error) |
| +``` |
| + |
| +Each mirrors `restart` (`apimode.go:437`): postJSON/getJSON, 200 decode, 404/other via `apiErrorFromBody`. |
| + |
| +**`store.go`** — `runStore(args)`: first positional arg is the subcommand (`create|status|grant|rotate`), remaining args flag-parsed per subcommand (the `carlos apps` file is the dispatch precedent — mirror its arg slicing). Flags: all take `-app`, account flags, `-console`; `create` adds `-env-prefix`, `grant` adds `-bucket`, `rotate` adds `-finish`. Output is honest about what happened vs what converges (the `restart.go` lesson): create prints the declared prefix + "nothing is provisioned until an operator grants it"; grant prints bucket/region/user + "credentials are sealed into the app's config — instances pick them up within seconds"; rotate prints the new key id + "the old key stays active until `carlos store rotate -finish -app …`"; finish prints "previous key deleted"; status prints the record blocks, `missing_vars` as a drift warning, and the pending-finish nag. |
| + |
| +- [ ] **Step 1: Failing tests** — follow `restart_test.go`'s harness (httptest console stub + `carlosTestEnv` + `cmd.Dir` scratch rule for anything that shells out; prefer in-process function tests like the sibling verbs use): subcommand routing, usage error on missing `-app`, one happy-path per subcommand against a stub answering the reply shape, rotate `-finish` hits `/rotate/finish`. |
| +- [ ] **Step 2–4: fail → implement → pass.** |
| +- [ ] **Step 5: Gate + commit** — `git commit -m "cli: carlos store create/status/grant/rotate"`. |
| + |
| +--- |
| + |
| +### Task 12: Minio contract test + CI |
| + |
| +**Suggested model:** Sonnet. |
| + |
| +**Files:** |
| +- Create: `internal/objstore/contract_test.go` |
| +- Modify: `.github/workflows/ci.yml` |
| + |
| +**Interfaces:** none new — this pins spec §1's delivered contract from the outside. |
| + |
| +- [ ] **Step 1: Write the test** — `TestDeliveredContractMinio` skips unless `CARLOS_MINIO_TEST=1`; reads `MINIO_ENDPOINT`, `MINIO_SCOPED_KEY`, `MINIO_SCOPED_SECRET`, `MINIO_BUCKET`, `MINIO_DENIED_BUCKET` from env; builds an S3 client exactly the way a consuming app would from delivered env (static creds, BaseEndpoint, `UsePathStyle`, region `us-east-1`); asserts: PutObject, GetObject round-trips the body, ListObjectsV2 shows the key, DeleteObject removes it, and PutObject to `MINIO_DENIED_BUCKET` fails with an access-denied API error (assert via `smithy.APIError` code `AccessDenied`). |
| +- [ ] **Step 2: CI job** — new job `minio-contract` in `ci.yml` alongside the existing gate job: |
| + |
| +```yaml |
| + minio-contract: |
| + runs-on: ubuntu-latest |
| + steps: |
| + - uses: actions/checkout@v4 |
| + - uses: actions/setup-go@v5 |
| + with: { go-version-file: go.mod } |
| + - name: start minio |
| + run: | |
| + docker run -d --name minio -p 9000:9000 \ |
| + -e MINIO_ROOT_USER=root -e MINIO_ROOT_PASSWORD=rootsecret \ |
| + quay.io/minio/minio server /data |
| + for i in $(seq 1 30); do curl -sf http://127.0.0.1:9000/minio/health/live && break; sleep 1; done |
| + - name: mint scoped user |
| + run: | |
| + curl -sfL https://dl.min.io/client/mc/release/linux-amd64/mc -o mc && chmod +x mc |
| + ./mc alias set local http://127.0.0.1:9000 root rootsecret |
| + ./mc mb local/contract-bucket local/denied-bucket |
| + cat > /tmp/policy.json <<'EOF' |
| + {"Version": "2012-10-17", "Statement": [ |
| + {"Effect": "Allow", "Action": ["s3:ListBucket"], "Resource": ["arn:aws:s3:::contract-bucket"]}, |
| + {"Effect": "Allow", "Action": ["s3:GetObject", "s3:PutObject", "s3:DeleteObject"], "Resource": ["arn:aws:s3:::contract-bucket/*"]} |
| + ]} |
| + EOF |
| + ./mc admin policy create local contract-only /tmp/policy.json |
| + ./mc admin user add local scopeduser scopedsecret1 |
| + ./mc admin policy attach local contract-only --user scopeduser |
| + - name: contract test |
| + env: |
| + CARLOS_MINIO_TEST: "1" |
| + MINIO_ENDPOINT: http://127.0.0.1:9000 |
| + MINIO_SCOPED_KEY: scopeduser |
| + MINIO_SCOPED_SECRET: scopedsecret1 |
| + MINIO_BUCKET: contract-bucket |
| + MINIO_DENIED_BUCKET: denied-bucket |
| + run: go test ./internal/objstore/ -run TestDeliveredContractMinio -count=1 -v |
| +``` |
| + |
| +- [ ] **Step 3: Verify locally if docker is available** (`docker run` as above + the env + `go test -run TestDeliveredContractMinio`); if the sandbox has no docker, verify the skip path runs green and note in the commit body that CI exercises the live path. |
| +- [ ] **Step 4: Gate + commit** — `git commit -m "objstore: minio contract test pinning the delivered S3 contract"`. |
| + |
| +--- |
| + |
| +### Task 13: Whole-branch review + gate + PR |
| + |
| +**Suggested model:** Fable (whole-branch final review — the tier-changes-outcome case). |
| + |
| +- [ ] **Step 1: Full gate** — `go build ./... && go vet ./... && go test ./... -count=1`, `gofmt -l` on all touched files, `GOOS=darwin go build ./...`. |
| +- [ ] **Step 2: Spec conformance read** — re-read the spec top to bottom against the diff; every §-numbered behaviour must have a test or a named justification in the PR body. Known deliberate deltas to re-verify are still deltas-with-reasons: grant allowlist (spec §4 amendment), destroy-is-browser-only (spec §9 amendment), grant operator must be an account member (v1 limit, §4 amendment). |
| +- [ ] **Step 3: Invoke the requesting-code-review skill**, then open a draft PR titled `objstore: object-storage primitive (#55)` with body covering: the three-actor record, the walks, the purge guard + rollout order (box binaries BEFORE first grant — pin this in its own bolded paragraph), the pinfra follow-ups (console role IAM path grant, `titogo-uploads` ARN, `CARLOS_STORE_*` env on tito), and `Closes #55`. |
| +- [ ] **Step 4: Post-merge/deploy checklist recorded in the PR body** (not executed by this plan): roll boxes → roll consoles → tofu → `carlos store create`/`grant` on tito → verify titogo uploads end-to-end → file the teamtito/titogo migration issue (session task #7). |
| + |
| +--- |
| + |
| +## Self-review notes (already applied) |
| + |
| +- Spec §5 walk order vs Task 3 code: the grant block lands before backend calls so a crash between steps 1 and 2 leaves an auditable intent — consistent with "nothing provisioned that isn't recorded". |
| +- Key-leak analysis for Task 3: a crash between mint and record leaves an active key no record names, so re-run repair cannot rely on recorded ids — that is why `Provisioner` carries `ReapKeys` (list-and-delete-except) rather than the walk guessing. A grant re-run only reaches the reap+mint leg when delivery is provably missing; the reap keeps only a pending-finish rotation key. Net: no unrecorded key survives a repair run, and the estate never exceeds AWS's 2-key ceiling. |
| +- Type names cross-checked: `Deployment`/`StoreDeployment`, `GrantBlock` (not `Grant` — the function owns that name), reply struct shared by Tasks 8/9/11. |
| +- Every spec §11 test row maps to a task step; the §11 "purge split" row is Tasks 6 + 10 together. |