carlos / platform Public

feat: auto-purge — trashed 30 days becomes destroyed, guards fail toward keeping data

release.PurgeApp is the platform's first destructive automation surface, so
every guard fails toward keeping data: no valid marker (re-read immediately
before destruction, so a late restore wins) refuses with ErrNotTrashed; no
release public key refuses, because an app whose production flag cannot be
verified cannot be proven safe to destroy; a production flag that is ON — or
merely UNREADABLE — refuses. Flag ABSENT purges, since most apps never had
one. The marker is deleted LAST, so a purge that dies half-way leaves a
trashed app with fewer objects rather than a live app with holes.

The reconcile pass consumes the trashed map it already builds: past
TrashCooloff, purge; the marker's own deleted_at is the clock. nil ReleasePub
leaves the app hidden but intact, which is the reversible half of deletion —
the agent arms purging from CARLOS_RELEASE_PUBKEY and says so when it cannot.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 453bf806ed087760dfa103518a791dd94d0eb288 parent ecdf5b8
6 files changed, +513 −0
  • internal/domains/reconcile.go +26 −0
  • internal/domains/reconcile_test.go +167 −0
  • internal/host/agent.go +11 −0
  • internal/host/host_test.go +40 −0
  • internal/release/trash.go +66 −0
  • internal/release/trash_test.go +203 −0
diff --git a/internal/domains/reconcile.go b/internal/domains/reconcile.go
index cda5ff5d9..4a35c4c2f 100644
--- a/internal/domains/reconcile.go
+++ b/internal/domains/reconcile.go
@@ -2,6 +2,7 @@ package domains
import (
"context"
+ "crypto/ed25519"
"crypto/x509"
"encoding/json"
"encoding/pem"
@@ -37,6 +38,11 @@ type Config struct {
AcmeDir string
// Now is the clock seam; nil = time.Now.
Now func() time.Time
+ // ReleasePub verifies the production flag before the auto-purge pass
+ // destroys anything. nil disables purging entirely — a box that cannot
+ // verify must not destroy — and the app simply stays hidden, which is
+ // the reversible half of deletion and costs nothing to leave in place.
+ ReleasePub ed25519.PublicKey
}
// Reconcile runs the spec §3 steps in order, idempotently: bucket account
@@ -222,6 +228,26 @@ func Reconcile(ctx context.Context, s release.Store, reg *registry.Registry, cfg
routes = keptLive
}
+ // --- 1d. auto-purge ------------------------------------------------------
+ // Trashed longer than TrashCooloff → destroy. The marker's own deleted_at
+ // is the clock (bucket-side; restore deletes the marker, so there is
+ // nothing to replay). PurgeApp RE-READS the marker immediately before
+ // destroying — this listing is only a hint — and re-checks the production
+ // flag; both guards fail toward keeping data. Idempotent and race-proof:
+ // concurrent boxes converge because every step tolerates already-gone and
+ // the marker goes last.
+ for k, tomb := range trashed {
+ if cfg.Now().Sub(tomb.DeletedAt) < release.TrashCooloff {
+ continue
+ }
+ if err := release.PurgeApp(ctx, s, cfg.ReleasePub, k.acct, k.app, cfg.Now()); err != nil {
+ log.Printf("domains: auto-purge %s/%s: %v", k.acct, k.app, err)
+ continue
+ }
+ log.Printf("domains: purged %s/%s — trashed %s, past the %s cooloff",
+ k.acct, k.app, tomb.DeletedAt.Format(time.RFC3339), release.TrashCooloff)
+ }
+
// --- 2. platform-domain minting ------------------------------------------
byHost := map[string]bool{}
// hasApex tracks the (ACCOUNT, app) pairs that already own a platform
diff --git a/internal/domains/reconcile_test.go b/internal/domains/reconcile_test.go
index 370ecb56c..2afece8df 100644
--- a/internal/domains/reconcile_test.go
+++ b/internal/domains/reconcile_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"crypto/ecdsa"
+ "crypto/ed25519"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
@@ -830,3 +831,169 @@ func TestReconcileIgnoresRetiredAppAccountManifests(t *testing.T) {
}
mustGet(t, reg, "kass.bab.oncarlos.test")
}
+
+// releaseKeys mints a root release key pair the way the deployment does, so
+// the purge pass has something real to verify a production flag against.
+func releaseKeys(t *testing.T) (ed25519.PrivateKey, ed25519.PublicKey) {
+ t.Helper()
+ seedB64, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ priv, err := release.ParseSigningKey(seedB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ pub, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ return priv, pub
+}
+
+// appKeysLeft is what is still in the bucket under an app — the purge's only
+// observable effect.
+func appKeysLeft(t *testing.T, s release.Store, acct, app string) []string {
+ t.Helper()
+ keys, err := s.List(ctx, "apps/"+acct+"/"+app+"/")
+ if err != nil {
+ t.Fatal(err)
+ }
+ slices.Sort(keys)
+ return keys
+}
+
+// Past the cooloff, the pass DESTROYS: the app's objects and, last, its
+// marker. The neighbour app in the same account is untouched, and the pass
+// converges — a second run over an empty prefix changes nothing and does not
+// error.
+func TestReconcilePurgesAppsPastTheCooloff(t *testing.T) {
+ _, pub := releaseKeys(t)
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "derp.custom", Kind: "static", App: "derp",
+ Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)},
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json")
+ putKey(t, s, "apps/"+sqid+"/derp/releases/1.0.0/manifest.json")
+ putKey(t, s, "apps/"+sqid+"/keep/channels/stable.json")
+ // Trashed 31 days before the config's clock.
+ deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour)
+ if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil {
+ t.Fatal(err)
+ }
+
+ c := cfg(t)
+ c.ReleasePub = pub
+ for pass := 1; pass <= 2; pass++ {
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatalf("pass %d: %v", pass, err)
+ }
+ if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 0 {
+ t.Fatalf("pass %d: purge left objects behind: %v", pass, got)
+ }
+ if got := routesFor(t, reg, "derp"); len(got) != 0 {
+ t.Errorf("pass %d: purged app still routed: %+v", pass, got)
+ }
+ if got := appKeysLeft(t, s, sqid, "keep"); len(got) != 1 {
+ t.Fatalf("pass %d: the purge reached the neighbour app: %v", pass, got)
+ }
+ }
+}
+
+// Inside the cooloff the app is hidden but INTACT — that window is the whole
+// point of a trash, and restoring it must give the owner their bytes back.
+func TestReconcileDoesNotPurgeInsideTheCooloff(t *testing.T) {
+ _, pub := releaseKeys(t)
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "derp.custom", Kind: "static", App: "derp",
+ Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)},
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json")
+ deleted := time.Unix(1754000000, 0).Add(-29 * 24 * time.Hour)
+ if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil {
+ t.Fatal(err)
+ }
+
+ c := cfg(t)
+ c.ReleasePub = pub
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatal(err)
+ }
+ // Routes gone (the app is hidden), objects and marker still there.
+ if got := routesFor(t, reg, "derp"); len(got) != 0 {
+ t.Errorf("trashed app still routed: %+v", got)
+ }
+ want := []string{
+ "apps/" + sqid + "/derp/channels/stable.json",
+ release.AppTombstoneKey(sqid, "derp"),
+ }
+ slices.Sort(want)
+ if got := appKeysLeft(t, s, sqid, "derp"); !reflect.DeepEqual(got, want) {
+ t.Fatalf("inside the cooloff the bucket = %v, want %v — nothing may be destroyed yet", got, want)
+ }
+ if tomb, err := release.ReadAppTombstone(ctx, s, sqid, "derp"); err != nil || tomb == nil {
+ t.Fatalf("marker gone inside the cooloff: %v, %v", tomb, err)
+ }
+}
+
+// A box with no release public key cannot verify a production flag, so it
+// cannot prove an app is safe to destroy: purging is OFF. Hiding still
+// happens — that is reversible, destruction is not.
+func TestReconcileWithoutAReleaseKeyHidesButNeverDestroys(t *testing.T) {
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "derp.custom", Kind: "static", App: "derp",
+ Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)},
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json")
+ deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour)
+ if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil {
+ t.Fatal(err)
+ }
+
+ c := cfg(t) // no ReleasePub
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatal(err)
+ }
+ if got := routesFor(t, reg, "derp"); len(got) != 0 {
+ t.Errorf("trashed app still routed: %+v", got)
+ }
+ if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 2 {
+ t.Fatalf("a keyless box destroyed objects: %v", got)
+ }
+}
+
+// A trashed app that is ALSO flagged production is a contradiction the
+// console refuses to create — but if one ever exists in a bucket, the flag
+// wins and the bytes stay. Same for a flag that will not verify.
+func TestReconcileNeverPurgesAProductionFlaggedApp(t *testing.T) {
+ priv, pub := releaseKeys(t)
+ s, reg := dirStore(t), regWith(t,
+ registry.Route{Host: "derp.custom", Kind: "static", App: "derp",
+ Status: "ready", Channel: "stable", Account: domains.Sqid(793481920157)},
+ )
+ sqid := writeAccountManifest(t, s, 793481920157, "Kass")
+ putKey(t, s, "apps/"+sqid+"/derp/channels/stable.json")
+ // The flag goes on FIRST: SetProduction refuses a trashed app.
+ if err := release.SetProduction(ctx, s, priv, sqid, "derp", true, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ deleted := time.Unix(1754000000, 0).Add(-31 * 24 * time.Hour)
+ if err := release.TrashApp(ctx, s, sqid, "derp", "paul@keymail.dev", deleted); err != nil {
+ t.Fatal(err)
+ }
+
+ c := cfg(t)
+ c.ReleasePub = pub
+ if err := domains.Reconcile(ctx, s, reg, c); err != nil {
+ t.Fatal(err)
+ }
+ if got := appKeysLeft(t, s, sqid, "derp"); len(got) != 3 {
+ t.Fatalf("a production-flagged app was purged: %v", got)
+ }
+ if tomb, err := release.ReadAppTombstone(ctx, s, sqid, "derp"); err != nil || tomb == nil {
+ t.Fatalf("marker gone: %v, %v", tomb, err)
+ }
+}
diff --git a/internal/host/agent.go b/internal/host/agent.go
index 49d4d016d..e9b50ca04 100644
--- a/internal/host/agent.go
+++ b/internal/host/agent.go
@@ -419,6 +419,14 @@ func domainsTickConfig(getenv func(string) string, publicIP string) domains.Conf
if len(ips) > 0 {
cfg.Resolve = resolveHost
}
+ // The root release public key, same source and same parser as adoptLoop's.
+ // Absence is TOLERATED and leaves ReleasePub nil, which switches the
+ // auto-purge off: a box that cannot verify an app's production flag must
+ // not destroy its objects. Everything else about the pass — including
+ // hiding trashed apps, the reversible half — carries on regardless.
+ if pub, err := release.ParsePublicKey(getenv("CARLOS_RELEASE_PUBKEY"), "CARLOS_RELEASE_PUBKEY"); err == nil {
+ cfg.ReleasePub = pub
+ }
return cfg
}
@@ -506,6 +514,9 @@ func domainsLoop(reg *registry.Registry, acmeCacheFlag string) {
return
}
log.Printf("agent: domains loop armed (5min + jitter), apps domain %s, edge IPs %v", cfg.AppsDomain, cfg.EdgeIPs)
+ if cfg.ReleasePub == nil {
+ log.Printf("agent: domains loop auto-purge disabled: CARLOS_RELEASE_PUBKEY is not set — trashed apps will be hidden but never destroyed")
+ }
delay := time.Minute
for {
time.Sleep(delay)
diff --git a/internal/host/host_test.go b/internal/host/host_test.go
index a94cc4d65..2f806c5f7 100644
--- a/internal/host/host_test.go
+++ b/internal/host/host_test.go
@@ -354,6 +354,46 @@ func TestDomainsTickConfig(t *testing.T) {
}
}
+// TestDomainsTickConfigReleasePubKey pins the auto-purge's arming condition.
+// The reconcile pass destroys objects only when it can VERIFY an app's
+// production flag, so the key is what switches purging on — and a missing or
+// unparseable one leaves it off rather than failing the loop, because hiding
+// a trashed app (reversible) must keep working on a box that may not destroy
+// anything (irreversible).
+func TestDomainsTickConfigReleasePubKey(t *testing.T) {
+ env := func(m map[string]string) func(string) string {
+ return func(k string) string { return m[k] }
+ }
+ _, pubB64, err := release.GenerateKey()
+ if err != nil {
+ t.Fatal(err)
+ }
+ got := domainsTickConfig(env(map[string]string{
+ "PLATFORM_APPS_DOMAIN": "oncarlos.test",
+ "CARLOS_RELEASE_PUBKEY": pubB64,
+ }), "9.9.9.9")
+ want, err := release.ParsePublicKey(pubB64, "test")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !got.ReleasePub.Equal(want) {
+ t.Errorf("ReleasePub = %x, want %x", got.ReleasePub, want)
+ }
+
+ for _, key := range []string{"", "not-base64!!", base64.StdEncoding.EncodeToString([]byte("short"))} {
+ got = domainsTickConfig(env(map[string]string{
+ "PLATFORM_APPS_DOMAIN": "oncarlos.test",
+ "CARLOS_RELEASE_PUBKEY": key,
+ }), "9.9.9.9")
+ if got.ReleasePub != nil {
+ t.Errorf("CARLOS_RELEASE_PUBKEY=%q armed purging with %x", key, got.ReleasePub)
+ }
+ if got.AppsDomain != "oncarlos.test" {
+ t.Errorf("CARLOS_RELEASE_PUBKEY=%q disabled the whole pass", key)
+ }
+ }
+}
+
func TestBootstrapIdempotent(t *testing.T) {
root := t.TempDir()
if err := bootstrap(root); err != nil {
diff --git a/internal/release/trash.go b/internal/release/trash.go
index 8d158c9f9..f8a67b2ae 100644
--- a/internal/release/trash.go
+++ b/internal/release/trash.go
@@ -2,6 +2,7 @@ package release
import (
"context"
+ "crypto/ed25519"
"encoding/json"
"errors"
"fmt"
@@ -86,3 +87,68 @@ func ReadAppTombstone(ctx context.Context, s Store, acct, app string) (*AppTombs
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")
+
+// PurgeApp irreversibly destroys a trashed app's bucket objects: every key
+// under apps/<acct>/<app>/, 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 {
+ tomb, err := ReadAppTombstone(ctx, s, acct, app)
+ if err != nil {
+ return err
+ }
+ if tomb == nil {
+ return ErrNotTrashed
+ }
+ 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)
+ }
+ prefix := appPrefix(acct, app)
+ marker := AppTombstoneKey(acct, app)
+ keys, err := s.List(ctx, prefix)
+ if err != nil {
+ return fmt.Errorf("release: list %s: %w", prefix, err)
+ }
+ 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)
+}
diff --git a/internal/release/trash_test.go b/internal/release/trash_test.go
index 104017c04..116b2793a 100644
--- a/internal/release/trash_test.go
+++ b/internal/release/trash_test.go
@@ -2,10 +2,17 @@ package release
import (
"context"
+ "crypto/ed25519"
+ "errors"
+ "slices"
"testing"
"time"
)
+// ctx is the package's test context; the older tests in this file shadow it
+// with their own local one, harmlessly.
+var ctx = context.Background()
+
func TestTrashAppRoundTrip(t *testing.T) {
ctx := context.Background()
s := NewDirStore(t.TempDir())
@@ -101,3 +108,199 @@ func TestReadAppTombstoneRefusesDisagreement(t *testing.T) {
t.Fatal("marker with zero deleted_at was not refused")
}
}
+
+// recordingStore is a Store that remembers the ORDER of Delete calls. The
+// purge's marker-last discipline is not observable from final state (in the
+// happy path everything is gone either way) — only from the sequence.
+type recordingStore struct {
+ Store
+ deletes *[]string
+}
+
+func (r recordingStore) Delete(ctx context.Context, key string) error {
+ *r.deletes = append(*r.deletes, key)
+ return r.Store.Delete(ctx, key)
+}
+
+// seedApp lays down a plausible app: two channel pointers, a manifest, an
+// artifact. The purge has to take all of them.
+func seedApp(t *testing.T, s Store, acct, app string) []string {
+ t.Helper()
+ keys := []string{
+ "apps/" + acct + "/" + app + "/channels/stable.json",
+ "apps/" + acct + "/" + app + "/channels/canary/x.json",
+ "apps/" + acct + "/" + app + "/releases/1.0.0/manifest.json",
+ "apps/" + acct + "/" + app + "/releases/1.0.0/app.tar.gz",
+ }
+ for _, k := range keys {
+ if _, err := s.Put(context.Background(), k, []byte("{}"), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ return keys
+}
+
+func listUnder(t *testing.T, s Store, prefix string) []string {
+ t.Helper()
+ keys, err := s.List(context.Background(), prefix)
+ if err != nil {
+ t.Fatal(err)
+ }
+ slices.Sort(keys)
+ return keys
+}
+
+// Contract 1: purge without a marker is destruction without an instruction.
+func TestPurgeAppRefusesWhenNotTrashed(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ _, pub := testKeys(t)
+ keys := seedApp(t, s, "bab", "derp")
+ err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now())
+ if !errors.Is(err, ErrNotTrashed) {
+ t.Fatalf("PurgeApp on a live app = %v, want ErrNotTrashed", err)
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys) {
+ t.Fatalf("a refused purge destroyed objects: %v", got)
+ }
+ // A marker that exists but cannot be believed is not an instruction
+ // either: ReadAppTombstone's error propagates and nothing is destroyed.
+ if _, err := s.Put(ctx, AppTombstoneKey("bab", "derp"), []byte("{"), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatal("purge acted on an unparseable marker")
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys)+1 {
+ t.Fatalf("a refused purge destroyed objects: %v", got)
+ }
+}
+
+// Contract 2: no public key means the production flag cannot be verified,
+// which means the app cannot be PROVEN non-production. Refuse.
+func TestPurgeAppRefusesWithoutAPublicKey(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ keys := seedApp(t, s, "bab", "derp")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ for _, pub := range []ed25519.PublicKey{nil, {}, make([]byte, 8)} {
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatalf("purge with a %d-byte key was allowed", len(pub))
+ }
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != len(keys)+1 {
+ t.Fatalf("a refused purge destroyed objects: %v", got)
+ }
+}
+
+// Contract 3: production ON refuses; an UNREADABLE flag refuses (unreadable
+// is not off); ABSENT is fine, because most apps never had a flag at all.
+func TestPurgeAppRefusesProductionAndUnreadableFlags(t *testing.T) {
+ priv, pub := testKeys(t)
+
+ // ON.
+ s := NewDirStore(t.TempDir())
+ seedApp(t, s, "bab", "derp")
+ if err := SetProduction(ctx, s, priv, "bab", "derp", true, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatal("purge destroyed a production-flagged app")
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) == 0 {
+ t.Fatal("a refused purge destroyed objects")
+ }
+
+ // UNREADABLE: the object is there but does not verify.
+ s = NewDirStore(t.TempDir())
+ seedApp(t, s, "bab", "derp")
+ if _, err := s.Put(ctx, ProductionKey("bab", "derp"), []byte(`{"payload":"zz","sig":"zz"}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil {
+ t.Fatal("purge treated an unreadable production flag as off")
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) == 0 {
+ t.Fatal("a refused purge destroyed objects")
+ }
+
+ // OFF, explicitly: purges.
+ s = NewDirStore(t.TempDir())
+ seedApp(t, s, "bab", "derp")
+ if err := SetProduction(ctx, s, priv, "bab", "derp", false, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("purge of an explicitly non-production app: %v", err)
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 {
+ t.Fatalf("purge left objects behind: %v", got)
+ }
+}
+
+// Contract 4: everything under the prefix goes, and the MARKER GOES LAST, so
+// a purge that dies half-way leaves a trashed app with fewer objects rather
+// than a live app with holes.
+func TestPurgeAppDestroysEverythingMarkerLast(t *testing.T) {
+ base := NewDirStore(t.TempDir())
+ var deletes []string
+ s := recordingStore{Store: base, deletes: &deletes}
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "derp")
+ // A neighbouring app in the same account must be untouched: the prefix
+ // ends in "/" precisely so "derp" never reaches "derpy".
+ seedApp(t, s, "bab", "derpy")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("PurgeApp: %v", err)
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 {
+ t.Fatalf("purge left objects behind: %v", got)
+ }
+ if got := listUnder(t, s, "apps/bab/derpy/"); len(got) != 4 {
+ t.Fatalf("purge reached the neighbouring app: %v", got)
+ }
+ marker := AppTombstoneKey("bab", "derp")
+ if len(deletes) == 0 || deletes[len(deletes)-1] != marker {
+ t.Fatalf("delete order %v — the marker must go LAST", deletes)
+ }
+ if slices.Index(deletes, marker) != len(deletes)-1 {
+ t.Fatalf("marker deleted more than once / early: %v", deletes)
+ }
+}
+
+// Contract 5: idempotent. A half-purged app converges on a second pass, and
+// an already-purged one reports ErrNotTrashed — which the caller reads as
+// "done", not as a failure.
+func TestPurgeAppIsIdempotent(t *testing.T) {
+ s := NewDirStore(t.TempDir())
+ _, pub := testKeys(t)
+ seedApp(t, s, "bab", "derp")
+ if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ // Half-purged: some objects already gone, marker still standing.
+ if err := s.Delete(ctx, "apps/bab/derp/channels/stable.json"); err != nil {
+ t.Fatal(err)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil {
+ t.Fatalf("purge of a half-purged app: %v", err)
+ }
+ if got := listUnder(t, s, "apps/bab/derp/"); len(got) != 0 {
+ t.Fatalf("purge left objects behind: %v", got)
+ }
+ if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); !errors.Is(err, ErrNotTrashed) {
+ t.Fatalf("second purge = %v, want ErrNotTrashed", err)
+ }
+}