package release import ( "context" "crypto/ed25519" "encoding/json" "errors" "fmt" "os" "time" ) // AppTombstone is the wire shape at apps///deleted.json — the // trash marker. Its PRESENCE means the app is in the Trash: consumers drop // its routes and hide it; the 30-day clock (DeletedAt + TrashCooloff) runs // bucket-side. There is no box-side memory to ratchet: restore deletes the // marker outright, so a marker that exists IS the current state. // // ADDITIVE by design: nothing written before this type changes shape, and a // consumer that has never heard of deleted.json keeps serving the app until // it is upgraded — visible and harmless, per the wire-additive rule. type AppTombstone struct { Schema int `json:"schema"` Account string `json:"account"` App string `json:"app"` DeletedAt time.Time `json:"deleted_at"` Actor string `json:"actor"` } // TrashCooloff is how long a trashed app stays restorable before the // auto-purge pass may destroy it. A constant, deliberately: a deployment // that wants a different number edits it here. const TrashCooloff = 30 * 24 * time.Hour // AppTombstoneKey is where an app's trash marker lives in the bucket. func AppTombstoneKey(acct, app string) string { return "apps/" + acct + "/" + app + "/deleted.json" } // TrashApp writes the trash marker, create-only. Trashing an app that is // already in the trash is a no-op that does NOT move the cooloff clock or // rewrite the actor — the first trash is the one on record. func claimRawTrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) error { body, err := json.Marshal(AppTombstone{ Schema: 1, Account: acct, App: app, DeletedAt: now.UTC(), Actor: actor, }) if err != nil { return err } if _, err := s.Put(ctx, AppTombstoneKey(acct, app), body, ""); err != nil { if errors.Is(err, ErrCASConflict) { return nil // already trashed; the existing marker stands } return fmt.Errorf("release: write trash marker for %s/%s: %w", acct, app, err) } return nil } // ReadAppTombstone reads and VALIDATES the marker. (nil, nil) when absent. // Anything else short of a well-formed marker whose body agrees with its // path and carries a real timestamp is an ERROR — a caller about to destroy // or hide an app must never act on a marker it cannot fully believe. func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombstone, error) { body, _, err := s.Get(ctx, AppTombstoneKey(acct, app)) if errors.Is(err, os.ErrNotExist) { return nil, nil } if err != nil { return nil, fmt.Errorf("release: read trash marker for %s/%s: %w", acct, app, err) } var t AppTombstone if err := json.Unmarshal(body, &t); err != nil { return nil, fmt.Errorf("release: malformed trash marker for %s/%s: %w", acct, app, err) } // The schema is PINNED, and an unknown one refuses. A future schema 2 // could mean anything — a scoped marker, a partial deletion, an expiry // this box has never heard of — and the two things a marker drives are // hiding an app and destroying it. Refusing fails additive-safe, the way // the whole wire rule wants: an old box keeps serving and keeps the // bytes, loudly, until it is upgraded. Believing a shape we cannot read // would be the one direction that is not recoverable. if t.Schema != 1 { return nil, fmt.Errorf("release: trash marker for %s/%s has schema %d — this box only understands schema 1; refusing to act on it", acct, app, t.Schema) } if t.Account != acct || t.App != app { return nil, fmt.Errorf("release: trash marker at %s claims %s/%s — path and body disagree", AppTombstoneKey(acct, app), t.Account, t.App) } if t.DeletedAt.IsZero() { return nil, fmt.Errorf("release: trash marker for %s/%s has no deleted_at", acct, app) } return &t, nil } // RestoreApp deletes the marker. Store.Delete is idempotent by contract, so // restoring an app that is not in the trash is a no-op, not an error. func RestoreApp(ctx context.Context, s Store, acct, app string) error { return s.Delete(ctx, AppTombstoneKey(acct, app)) } // ErrNotTrashed is PurgeApp's refusal to destroy an app that has no valid // trash marker: purge without an instruction is not a purge, it is a bug. // Distinguishable on purpose — a caller sweeping the trash reads it as // "already done", not as a failure worth alarming about. var ErrNotTrashed = errors.New("release: app is not in the trash") // ErrStoreProvisioned is PurgeApp's refusal to destroy an app whose object // store has (or may have) backend estate behind it — see the guard in // PurgeApp for why a box is the wrong actor to decide otherwise. var ErrStoreProvisioned = errors.New("release: app has a provisioned object store — tear it down via the console before purging") // PurgeReady answers "would PurgeApp destroy this app right now?" — the // preconditions and nothing else: a valid trash marker, a release public key to // prove production with, and a production flag that is readable and off. It is // PurgeApp's OWN precondition block, extracted so a caller that must act BEFORE // PurgeApp can ask the same question from the same list. // // The caller it exists for is the console's purge door (console/ownership.go), // which tears an object store down before re-entering PurgeApp. That teardown // destroys a bucket, an IAM principal and the app's delivered credentials — all // of it outside the deployment bucket, and none of it undoable — so it must not // happen in front of a purge that PurgeApp then refuses: a restore that landed // while the form sat open, a production flag set after trashing, an unreadable // flag, a console with no public key. Asking here first keeps those refusals // exactly as strong as they read in PurgeApp's doc comment. // // The store guard and the sweep are deliberately NOT here: the store guard is // the box's refusal to act without backend authority (the console door's whole // purpose is to have already dealt with it), and the sweep is the destruction // itself. // // This narrows the window; it does not close it. PurgeApp re-checks all of it // immediately before destroying, so the residual race is the one round trip // between the two calls — the design's stated tolerance, and the same shape // objstore.Teardown's final re-read settles for. func PurgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string) error { return purgeReady(ctx, s, pub, acct, app, nil) } // purgeReady is PurgeReady with an optional extra precondition on the marker // it has just read. check sees the SAME tombstone the rest of this block // trusts, so a caller binding a purge to one particular trash // (PurgeAppIfTrashedAt) adds no second read. func purgeReady(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { tomb, err := ReadAppTombstone(ctx, s, acct, app) if err != nil { return err } if tomb == nil { return ErrNotTrashed } if check != nil { if err := check(tomb); err != nil { return err } } if len(pub) != ed25519.PublicKeySize { return fmt.Errorf("release: no release public key — cannot prove %s/%s is not in production, refusing to purge", acct, app) } // (nil, nil) here means the object is genuinely absent, which is the // default and not production. Every other outcome — unreadable, // unverifiable, misnamed — arrives as an error and refuses, because an // unreadable flag is exactly the state an attacker who can delete but not // sign can create. flag, err := GetProduction(ctx, s, pub, acct, app) if err != nil { return fmt.Errorf("release: cannot read the production flag for %s/%s — refusing to purge: %w", acct, app, err) } if flag != nil && flag.Production { return fmt.Errorf("release: %s/%s is flagged production — a trashed production app should be impossible; refusing to purge", acct, app) } return nil } // PurgeApp irreversibly destroys a trashed app's bucket objects: every key // under apps/// and ledgers///, the trash marker LAST, // so a purge that dies half-way leaves a trashed app with fewer objects // rather than a live app with holes — the next pass finishes the job. // // This is the platform's first destructive automation surface, so every // guard fails toward keeping data: // - no valid marker → ErrNotTrashed (re-read HERE, immediately before // destruction — a restore that landed after the caller's listing wins); // - nil pub, production flag ON, or production flag UNREADABLE → refuse. // Flag ABSENT is fine: most apps never had one, and GetProduction says so // with (nil, nil). // // The AGE of the marker is deliberately the caller's decision, not this // function's: the auto-purge pass enforces TrashCooloff, and the console's // "Delete now" purges early behind its own ceremony. now is therefore taken // and not read — it is here so that every caller passes its OWN clock seam // and no future guard added inside this function has to reach for // time.Now() behind the tests' backs. func PurgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, now time.Time) error { return purgeApp(ctx, s, pub, acct, app, nil) } // purgeApp is PurgeApp's body with purgeReady's optional check threaded // through. Everything below the precondition call is PurgeApp as it was. func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app string, check func(*AppTombstone) error) error { // The preconditions live in purgeReady so that the console's purge door, // which must satisfy them BEFORE it tears an object store down, checks the // same list this function does. One list, one place: a guard added there is // a guard both callers get, and neither can drift from the other. if err := purgeReady(ctx, s, pub, acct, app, check); err != nil { return err } prefix := appPrefix(acct, app) // An object store with backend estate 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 and a live IAM user that nothing // remembers. The console's purge door tears the store down first and only // then re-enters here. // // BOTH blocks count. `provisioned` is the finished store; `grant` on its // own is a grant that crashed after creating the bucket and the user but // before recording them — the earliest bucket-visible witness that estate // exists. Refusing on either keeps the box on the safe side of a crash. // // Parsed minimally IN PLACE: release cannot import objstore (objstore // imports release), so this file is on store.json's parser list, // deliberately — the consumer list for a storage shape is everything that // PARSES it, not everything that calls its API. objstore.Record carries // the matching note; a block renamed there without changing this struct // silently disarms the guard. objstore/walk_test.go pins the agreement. // // Unreadable — transport error or bytes that will not parse — refuses too, // the same direction as the production flag: "I could not ask" is not "no // store". Absent is fine; most apps never declare one. // EVERY store of the app, not just store.json. Since 2026-09-19 a store // may belong to a named config environment, at // apps///stores/.json, and each one names backend estate // exactly as the app's own does. A guard that read store.json alone would // let a box sweep an app whose environment stores are live — this guard's // own failure, reached through the door the newer shape opened. // // The listing is part of the guard, so a listing that FAILS refuses the // purge, the same direction as an unreadable record: "I could not ask" is // not "no store". List returns nested keys under a prefix in both store // implementations (DirStore walks the root; S3Store paginates with no // delimiter), which is what makes one listing enough. storeKeys := []string{prefix + "store.json"} envStores, lerr := s.List(ctx, prefix+"stores/") if lerr != nil { return fmt.Errorf("release: %s/%s: cannot list %sstores/ — refusing to purge: %w: %v", acct, app, prefix, ErrStoreProvisioned, lerr) } storeKeys = append(storeKeys, envStores...) for _, storeKey := range storeKeys { raw, _, err := s.Get(ctx, storeKey) if err != nil { if errors.Is(err, os.ErrNotExist) { continue // absent is fine; most apps never declare one } return fmt.Errorf("release: %s/%s: cannot read %s — refusing to purge: %w: %v", acct, app, storeKey, ErrStoreProvisioned, err) } var rec struct { Grant *json.RawMessage `json:"grant"` Provisioned *json.RawMessage `json:"provisioned"` } if jerr := json.Unmarshal(raw, &rec); jerr != nil { return fmt.Errorf("release: %s/%s: unparseable %s — refusing to purge: %w: %v", acct, app, storeKey, ErrStoreProvisioned, jerr) } if rec.Provisioned != nil || rec.Grant != nil { return fmt.Errorf("%s/%s: %w", acct, app, ErrStoreProvisioned) } } marker := AppTombstoneKey(acct, app) keys, err := s.List(ctx, prefix) if err != nil { return fmt.Errorf("release: list %s: %w", prefix, err) } // The app's open ledgers too (discussion #219, item 2). They live // outside the app's prefix, keyed by the same (account, app), so a sweep // of apps/ alone left them for the next app of this name: its ledger // writes would chain onto the purged app's history, and the edge would // keep serving that history if the old manifest said public. Swept // before the marker, so a purge that dies here is retried by the next // pass. The ledger spec's "publication is forever" holds as far as it // ever did: the bucket is versioned, and that is where it is kept. ledgerKeys, err := s.List(ctx, LedgerAppPrefix(acct, app)) if err != nil { return fmt.Errorf("release: list %s: %w", LedgerAppPrefix(acct, app), err) } keys = append(keys, ledgerKeys...) // The app's promotion records (settings spec §4.1) live outside its // prefix too, for the same reason as the ledgers: a new app of this name // must not find them. promoted, err := s.List(ctx, PromotedPrefix(acct, app)) if err != nil { return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err) } keys = append(keys, promoted...) for _, key := range keys { if key == marker { continue // last, below } if err := s.Delete(ctx, key); err != nil { return fmt.Errorf("release: purge %s: %w", key, err) } } return s.Delete(ctx, marker) } func TrashApp(ctx context.Context, s Store, acct, app, actor string, now time.Time) (claimResult0 error) { claimResult0 = WithAppMutation(ctx, s, acct, app, "release.TrashApp", func(claimContext context.Context) error { claimResult0 = claimRawTrashApp(claimContext, s, acct, app, actor, now) return claimResult0 }) return }