carlos / platform Public

feat(console): CLI API trash and restore doors — bucket first, production refuses

POST /api/cli/apps/{account}/{app}/delete writes the bucket's trash marker
and then marks the console row; /restore removes it and clears the row.
Both are member-level with a bearer token — the 30-day cooloff is what
makes the reversible verb cheap — and neither can purge.

The delete door's gates, in order: token, the account+app 404, the
address-keyed write budget, a 503 when no release store is configured (a
row-only trash would un-hide itself at the next materialize pass), then
the production flag — ON or UNREADABLE refuses with a 409 naming the
off-switch, per the deletion spec's §5 and bake.go's fail-toward direction.

Restore resolves through a new apiTrashedApp, which shares apiAccountApp's
body and differs only in the final lookup, so an app that is not in the
trash is the byte-identical 404 an unknown or foreign one gets. Its answer
carries the note that restore resurrects no routes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 7ec5daf488f15f72ff26506469e014441535dddf parent 938d638
3 files changed, +505 −2
  • internal/console/apicli.go +187 −2
  • internal/console/apicli_test.go +312 −0
  • internal/console/authcli.go +6 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 9d5a4d479..d20a3fd5e 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -46,6 +46,23 @@ import (
// settle an ambiguity can never itself be ambiguous. It never widens access
// — a non-member's probe meets the same 404 either way.
func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ return s.apiAccountAppBy(w, r, s.store.HasAppIn)
+}
+
+// apiTrashedApp is apiAccountApp for the ONE door whose subject is an app
+// that is not live: restore. Everything about it — the symmetric resolution,
+// the ambiguity 409's wording, the byte-identical 404 — is apiAccountApp's,
+// by construction rather than by copy, because the moment the two diverge
+// the restore door becomes an oracle: a caller could tell "no such account",
+// "not your account" and "that app is not in the trash" apart by their
+// answers. The ONLY difference is the final lookup.
+func (s *Server) apiTrashedApp(w http.ResponseWriter, r *http.Request) (string, *Account, string, bool) {
+ return s.apiAccountAppBy(w, r, s.store.HasTrashedAppIn)
+}
+
+// apiAccountAppBy is the shared body of the two resolvers above. `has` is
+// the last gate and the only variable: whether THIS door's subject exists.
+func (s *Server) apiAccountAppBy(w http.ResponseWriter, r *http.Request, has func(int64, string) (bool, error)) (string, *Account, string, bool) {
addr, ok := s.cliUser(w, r)
if !ok {
return "", nil, "", false
@@ -90,11 +107,11 @@ func (s *Server) apiAccountApp(w http.ResponseWriter, r *http.Request) (string,
http.NotFound(w, r)
return "", nil, "", false
}
- has, err := s.store.HasAppIn(acct.ID, app)
+ found, err := has(acct.ID, app)
if err != nil {
log.Printf("console: api has-app %s/%s: %v", acct.Sqid, app, err)
}
- if err != nil || !has {
+ if err != nil || !found {
http.NotFound(w, r)
return "", nil, "", false
}
@@ -341,6 +358,174 @@ func (s *Server) handleAPIRollback(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"result": "rolled back"})
}
+// ---- trash and restore ------------------------------------------------------
+
+// handleAPITrashApp answers `carlos apps delete`: it moves an app to the
+// Trash by writing the bucket's trash marker, and marks the console row to
+// match. Nothing is destroyed here — the 30-day cooloff (release.TrashCooloff)
+// is what makes this a cheap, reversible verb any MEMBER may reach for with
+// nothing but a bearer token. The irreversible half (purge) is console-only
+// and ceremonial, and deliberately has no API at all.
+//
+// The doors, in order, and why this order:
+//
+// token — cliUser's one 401, before any name is resolved.
+// account+app 404 — apiAccountApp: an unknown account, someone else's
+// account and an app that isn't there are one answer, so
+// probing this door teaches a stranger nothing.
+// rate limit — keyed on the ADDRESS, like handleAPIPromote: a write
+// budget belongs to the person, not the terminal.
+// Releases nil — 503. A console with no bucket cannot trash at all: the
+// marker IS the trash state, and a row-only trash would
+// un-hide itself at the next materialize pass (which
+// reconciles rows FROM markers), silently un-deleting the
+// app the caller was told was gone.
+// production — ON, or UNREADABLE, refuses (409). Spec §5: the
+// production flag is the deletion protection — no
+// checkbox, no break-glass — and when the bucket cannot
+// say "not production", the platform acts as if it had
+// said "production", the same direction bake.go fails.
+// TrashApp — the BUCKET first, then the row. Same order as account
+// deletion (ownership.go) and for the same reason: this
+// console's rows are materialized from the bucket, so a
+// row written first and a marker that never lands is an
+// app that deletes itself from the console and comes back
+// on the next pass. Marker-then-row fails the other way —
+// visibly, recoverably, and self-healing.
+// TrashAppRow — the row follows the marker, carrying the MARKER's
+// timestamp, so both halves agree on when the clock
+// started.
+// audit — who trashed what, kept past the purge.
+func (s *Server) handleAPITrashApp(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiAccountApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — this console cannot delete an app")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ // (nil, nil) is the flag genuinely ABSENT, which is the default and not
+ // production — most apps never had one. Every other outcome is an error
+ // and refuses.
+ flag, err := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app)
+ appURL := s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(app)
+ if err != nil {
+ // Logged, not returned: a store error carries bucket names, endpoints
+ // and request ids, and this caller gets the same fixed sentence every
+ // other bucket failure in this console gets.
+ log.Printf("console: api trash %s/%s: production flag: %v", acct.Sqid, app, err)
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": "could not read the production flag for " + app +
+ " — an unreadable flag is treated as production, so deleting is refused." +
+ " Re-declare it with the production switch on the app page, then try again.",
+ "console_url": appURL,
+ })
+ return
+ }
+ if flag != nil && flag.Production {
+ writeJSON(w, http.StatusConflict, map[string]string{
+ "error": app + " is flagged production, so deleting is refused." +
+ " Turn production off first — the off-switch on the app page (account owner," +
+ " passkey, and a hold on every box) is the only way through — then try again.",
+ "console_url": appURL,
+ })
+ return
+ }
+ now := time.Now()
+ if err := release.TrashApp(ctx, s.cfg.Releases, acct.Sqid, app, addr, now); err != nil {
+ log.Printf("console: api trash %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not write the trash marker — nothing was deleted; try again")
+ return
+ }
+ // Re-read, because TrashApp is create-only: an app already in the trash
+ // keeps its ORIGINAL clock, and the caller must be told the deadline that
+ // actually applies rather than the one this request would have set. A
+ // marker that cannot be re-read is not worth failing over — it is
+ // WRITTEN, the app is trashed — so the answer falls back to this
+ // request's own clock, which is right in every case but the idempotent
+ // re-trash.
+ deletedAt := now
+ tomb, err := release.ReadAppTombstone(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: api trash %s/%s: re-read marker: %v", acct.Sqid, app, err)
+ } else if tomb != nil {
+ deletedAt = tomb.DeletedAt
+ }
+ if err := s.store.TrashAppRow(acct.ID, app, deletedAt); err != nil {
+ log.Printf("console: api trash row %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError,
+ "the trash marker was written, but this console's own record did not update —"+
+ " it will catch up on the next materialize pass")
+ return
+ }
+ s.audit(acct, app, addr, "trash", "moved "+app+" to the trash (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "trashed",
+ "restorable_until": deletedAt.Add(release.TrashCooloff).UTC().Format(time.RFC3339),
+ })
+}
+
+// handleAPIRestoreApp answers `carlos apps restore`: the undo button. Same
+// doors and same order as trash, minus the production gate — an app in the
+// trash cannot be flagged production (§3 refuses ship, promote and
+// SetProduction to a trashed app), and refusing to UNDO a deletion is
+// protecting nothing.
+//
+// It resolves through apiTrashedApp, so an app that is not in the trash is
+// the byte-identical 404 an unknown one gets: this door must not become the
+// way to ask whether somebody's app has been deleted.
+//
+// Restoring resurrects NO routes — not the platform apex, not custom domains
+// (spec §3, the anti-ghost rule: reconcile's minting is route-driven, and
+// re-minting from pointers would resurrect every deliberately-retired app
+// forever). The response says so, because nothing else in the terminal will.
+func (s *Server) handleAPIRestoreApp(w http.ResponseWriter, r *http.Request) {
+ addr, acct, app, ok := s.apiTrashedApp(w, r)
+ if !ok {
+ return
+ }
+ if !s.cli.write.allow(addr, time.Now()) {
+ jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again")
+ return
+ }
+ if s.cfg.Releases == nil {
+ jsonError(w, http.StatusServiceUnavailable,
+ "no deployment bucket on this host — this console cannot restore an app")
+ return
+ }
+ ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
+ defer cancel()
+ // The bucket first, for trash's reason mirrored: the row is materialized
+ // from the marker, so a row restored while the marker survives is an app
+ // that comes back and then vanishes again on the next pass.
+ if err := release.RestoreApp(ctx, s.cfg.Releases, acct.Sqid, app); err != nil {
+ log.Printf("console: api restore %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusBadGateway, "could not remove the trash marker — nothing was restored; try again")
+ return
+ }
+ if err := s.store.RestoreAppRow(acct.ID, app); err != nil {
+ log.Printf("console: api restore row %s/%s: %v", acct.Sqid, app, err)
+ jsonError(w, http.StatusInternalServerError,
+ "the trash marker was removed, but this console's own record did not update —"+
+ " it will catch up on the next materialize pass")
+ return
+ }
+ s.audit(acct, app, addr, "restore", "restored "+app+" from the trash (cli)")
+ writeJSON(w, http.StatusOK, map[string]any{
+ "result": "restored",
+ "note": app + " is restored with its releases and channels intact, and with NO routes:" +
+ " run `carlos add <host>` to serve it again.",
+ })
+}
+
// ---- ship (multipart upload) ------------------------------------------------
// apiShipMaxBytes bounds one ship request across every part TOGETHER — meta
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 464f0c472..cda333a2c 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -15,6 +15,7 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
+ "errors"
"fmt"
"io"
"mime/multipart"
@@ -1204,3 +1205,314 @@ func TestAPIShipConcurrencyLimit(t *testing.T) {
}
}
}
+
+// ---- trash and restore ------------------------------------------------------
+
+// trashFixture is a console with titogo claimed in the ops account and a
+// MEMBER (not owner) token for it: trashing is a member action on purpose —
+// the 30-day window is what makes the reversible verb cheap.
+func trashFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ now := time.Now()
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", now); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.st.AddMember(OpsAccountID, "alice@keymail.dev", RoleMember, now); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "alice@keymail.dev", code)
+}
+
+// tombstone reads the trash marker straight from the fixture's own bucket —
+// never through s.cfg.Releases, which some tests below deliberately break.
+func tombstone(t *testing.T, fx *consoleFixture, app string) *release.AppTombstone {
+ t.Helper()
+ tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, release.OpsAccount, app)
+ if err != nil {
+ t.Fatalf("read trash marker for %s: %v", app, err)
+ }
+ return tomb
+}
+
+// TestAPITrashAndRestoreApp is the whole round trip: a member trashes an app,
+// the marker lands in the BUCKET (which is where trash state actually
+// lives), the row follows, every other CLI door stops answering for that
+// app — and restore puts it all back.
+func TestAPITrashAndRestoreApp(t *testing.T) {
+ fx, token := trashFixture(t, "code-trash-roundtrip")
+
+ status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("delete = %d %s, want 200", status, out)
+ }
+ var res struct {
+ Result string `json:"result"`
+ RestorableUntil string `json:"restorable_until"`
+ }
+ if err := json.Unmarshal(out, &res); err != nil {
+ t.Fatalf("delete body %s: %v", out, err)
+ }
+ if res.Result != "trashed" {
+ t.Fatalf("delete result = %q, want trashed", res.Result)
+ }
+ until, err := time.Parse(time.RFC3339, res.RestorableUntil)
+ if err != nil {
+ t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err)
+ }
+ if want := time.Now().Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute {
+ t.Fatalf("restorable_until = %s, want ~%s (now + the cooloff)", until, want)
+ }
+
+ tomb := tombstone(t, fx, "titogo")
+ if tomb == nil {
+ t.Fatal("no trash marker in the bucket after a 200 delete")
+ }
+ if tomb.Actor != "alice@keymail.dev" {
+ t.Fatalf("marker actor = %q, want the token's own identity", tomb.Actor)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || has {
+ t.Fatalf("HasAppIn after trash = %v %v, want false", has, err)
+ }
+ if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || !has {
+ t.Fatalf("HasTrashedAppIn after trash = %v %v, want true", has, err)
+ }
+
+ // Every other door resolves through the same live-apps-only lookup, so a
+ // trashed app is simply not there any more.
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusNotFound {
+ t.Fatalf("channels on a trashed app = %d %s, want 404", status, body)
+ }
+ if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/promote", token,
+ `{"version":"v1","channel":"canary/eu"}`); status != http.StatusNotFound {
+ t.Fatalf("promote to a trashed app = %d %s, want 404", status, body)
+ }
+ if status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/releases", token, ""); status != http.StatusNotFound {
+ t.Fatalf("ship to a trashed app = %d %s, want 404", status, body)
+ }
+
+ status, out = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("restore = %d %s, want 200", status, out)
+ }
+ var back struct {
+ Result string `json:"result"`
+ Note string `json:"note"`
+ }
+ if err := json.Unmarshal(out, &back); err != nil {
+ t.Fatalf("restore body %s: %v", out, err)
+ }
+ if back.Result != "restored" {
+ t.Fatalf("restore result = %q, want restored", back.Result)
+ }
+ // Restore resurrects NO routes (spec §3, the anti-ghost rule) — the
+ // answer has to say so, because nothing else will.
+ if !strings.Contains(back.Note, "carlos add") {
+ t.Fatalf("restore note = %q, want it to name `carlos add`", back.Note)
+ }
+ if tomb := tombstone(t, fx, "titogo"); tomb != nil {
+ t.Fatalf("trash marker survived a restore: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
+ t.Fatalf("HasAppIn after restore = %v %v, want true", has, err)
+ }
+ if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); err != nil || has {
+ t.Fatalf("HasTrashedAppIn after restore = %v %v, want false", has, err)
+ }
+ if status, body := getRaw(t, fx.ts.URL+"/api/cli/apps/bab/titogo/channels", token); status != http.StatusOK {
+ t.Fatalf("channels after restore = %d %s, want 200", status, body)
+ }
+}
+
+// TestAPITrashTwiceKeepsTheClock: the second trash must not buy another 30
+// days. TrashApp is create-only, so the answer has to come from the marker
+// that is actually there, not from this request's own clock.
+func TestAPITrashTwiceKeepsTheClock(t *testing.T) {
+ fx, token := trashFixture(t, "code-trash-twice")
+
+ status, first := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("first delete = %d %s", status, first)
+ }
+ // The row is trashed now, so the second call resolves through the trashed
+ // door… which delete does NOT use: a re-delete of a trashed app is a 404,
+ // exactly like every other verb. Restore it first, trash it again, and
+ // assert the marker's original clock is what comes back — the marker is
+ // deliberately not re-written by a second trash.
+ if status, out := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, ""); status != http.StatusOK {
+ t.Fatalf("restore = %d %s", status, out)
+ }
+ // Write a marker by hand with an OLD timestamp, then trash through the
+ // API: the API must report the existing marker's window, not a fresh one.
+ old := time.Now().Add(-10 * 24 * time.Hour)
+ if err := release.TrashApp(context.Background(), fx.rel, release.OpsAccount, "titogo", "paul@keymail.dev", old); err != nil {
+ t.Fatal(err)
+ }
+ status, second := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusOK {
+ t.Fatalf("second delete = %d %s, want 200 (idempotent)", status, second)
+ }
+ var res struct {
+ RestorableUntil string `json:"restorable_until"`
+ }
+ if err := json.Unmarshal(second, &res); err != nil {
+ t.Fatal(err)
+ }
+ until, err := time.Parse(time.RFC3339, res.RestorableUntil)
+ if err != nil {
+ t.Fatalf("restorable_until %q: %v", res.RestorableUntil, err)
+ }
+ if want := old.Add(release.TrashCooloff); until.Sub(want) > time.Minute || want.Sub(until) > time.Minute {
+ t.Fatalf("restorable_until = %s, want the EXISTING marker's window ~%s", until, want)
+ }
+}
+
+// TestAPITrashNonMemberIs404: a token holder who is not a member of the
+// account learns nothing at all — not that the account exists, not that the
+// app does, and not that either has a trash. Byte-identical to the 404 a
+// member gets for a name that does not exist.
+func TestAPITrashNonMemberIs404(t *testing.T) {
+ fx, member := trashFixture(t, "code-trash-member")
+ stranger := cliToken(t, fx, "bob@keymail.dev", "code-trash-stranger")
+
+ mStatus, mBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/delete", member, "")
+ sStatus, sBody := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", stranger, "")
+ if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound {
+ t.Fatalf("delete: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus)
+ }
+ if string(mBody) != string(sBody) {
+ t.Fatalf("delete 404s differ:\n member: %q\n stranger: %q", mBody, sBody)
+ }
+ // The restore door answers the same way, through its own resolver.
+ mStatus, mBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", member, "")
+ sStatus, sBody = postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", stranger, "")
+ if mStatus != http.StatusNotFound || sStatus != http.StatusNotFound {
+ t.Fatalf("restore: member/unknown-app = %d, stranger/real-app = %d, want 404 both", mStatus, sStatus)
+ }
+ if string(mBody) != string(sBody) {
+ t.Fatalf("restore 404s differ:\n member: %q\n stranger: %q", mBody, sBody)
+ }
+ if tomb := tombstone(t, fx, "titogo"); tomb != nil {
+ t.Fatalf("a stranger's delete wrote a marker: %+v", tomb)
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
+ t.Fatal("a stranger's delete trashed the row")
+ }
+}
+
+// TestAPIRestoreLiveAppIs404: restore answers only for apps that are
+// actually in the trash — a live app is not restorable, and saying so with
+// anything other than the standard 404 would make this door an oracle for
+// trash state.
+func TestAPIRestoreLiveAppIs404(t *testing.T) {
+ fx, token := trashFixture(t, "code-restore-live")
+
+ status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/restore", token, "")
+ if status != http.StatusNotFound {
+ t.Fatalf("restore of a live app = %d %s, want 404", status, body)
+ }
+ if _, unknown := postBody(t, fx.ts.URL+"/api/cli/apps/bab/nosuchapp/restore", token, ""); string(unknown) != string(body) {
+ t.Fatalf("restore 404s differ:\n live: %q\n unknown: %q", body, unknown)
+ }
+}
+
+// TestAPITrashProductionRefused: the production flag IS the deletion
+// protection (spec §5) — no checkbox, no break-glass. The refusal has to
+// name the off-switch, because "no" without a way forward is a support
+// ticket.
+func TestAPITrashProductionRefused(t *testing.T) {
+ fx, token := trashFixture(t, "code-trash-production")
+ if err := release.SetProduction(context.Background(), fx.rel, fx.priv,
+ release.OpsAccount, "titogo", true, "paul@keymail.dev"); err != nil {
+ t.Fatal(err)
+ }
+
+ status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusConflict {
+ t.Fatalf("delete of a production app = %d %s, want 409", status, body)
+ }
+ if !strings.Contains(string(body), "production") {
+ t.Fatalf("refusal = %s, want it to name the production off-switch", body)
+ }
+ if tomb := tombstone(t, fx, "titogo"); tomb != nil {
+ t.Fatalf("a refused delete wrote a marker: %+v", tomb)
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
+ t.Fatal("a refused delete trashed the row")
+ }
+}
+
+// unreadableProductionStore breaks exactly one read — the production flag's
+// object — which is the state anyone with bucket write but no signing key
+// can create at will.
+type unreadableProductionStore struct{ release.Store }
+
+func (u unreadableProductionStore) Get(ctx context.Context, key string) ([]byte, string, error) {
+ if strings.HasSuffix(key, "/production.json") {
+ return nil, "", errors.New("AccessDenied: s3://carlos-prod-secret/production.json request id ABC123")
+ }
+ return u.Store.Get(ctx, key)
+}
+
+// TestAPITrashProductionUnreadableRefused: when the bucket cannot say "not
+// production", the platform acts as if it had said "production" (spec §5) —
+// and NOTHING is trashed on the way out.
+func TestAPITrashProductionUnreadableRefused(t *testing.T) {
+ fx, token := trashFixture(t, "code-trash-unreadable")
+ fx.srv.cfg.Releases = unreadableProductionStore{fx.rel}
+
+ status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusConflict {
+ t.Fatalf("delete with an unreadable flag = %d %s, want 409", status, body)
+ }
+ if !strings.Contains(string(body), "production") {
+ t.Fatalf("refusal = %s, want it to name the production flag", body)
+ }
+ if strings.Contains(string(body), "AccessDenied") || strings.Contains(string(body), "s3://") {
+ t.Fatalf("refusal leaks the store's own error text: %s", body)
+ }
+ if tomb := tombstone(t, fx, "titogo"); tomb != nil {
+ t.Fatalf("a refused delete wrote a marker: %+v", tomb)
+ }
+ if has, err := fx.st.HasAppIn(OpsAccountID, "titogo"); err != nil || !has {
+ t.Fatalf("HasAppIn after a refused delete = %v %v, want the row still live", has, err)
+ }
+ if has, _ := fx.st.HasTrashedAppIn(OpsAccountID, "titogo"); has {
+ t.Fatal("a refused delete trashed the row")
+ }
+}
+
+// TestAPITrashWithoutAReleaseStore: a console with no bucket cannot trash at
+// all. A row-only trash would un-hide itself at the next materialize pass —
+// the bucket is where trash state lives.
+func TestAPITrashWithoutAReleaseStore(t *testing.T) {
+ fx, token := trashFixture(t, "code-trash-nobucket")
+ fx.srv.cfg.Releases = nil
+
+ status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", token, "")
+ if status != http.StatusServiceUnavailable {
+ t.Fatalf("delete with no release store = %d %s, want 503", status, body)
+ }
+ if has, _ := fx.st.HasAppIn(OpsAccountID, "titogo"); !has {
+ t.Fatal("a 503 delete trashed the row anyway")
+ }
+}
+
+// TestAPITrashNoToken: both doors answer an anonymous caller with cliUser's
+// one 401 — before any name in the path is resolved.
+func TestAPITrashNoToken(t *testing.T) {
+ fx, _ := trashFixture(t, "code-trash-anon")
+
+ for _, verb := range []string{"delete", "restore"} {
+ status, body := postBody(t, fx.ts.URL+"/api/cli/apps/bab/titogo/"+verb, "", "")
+ if status != http.StatusUnauthorized {
+ t.Fatalf("%s with no token = %d %s, want 401", verb, status, body)
+ }
+ if !strings.Contains(string(body), "carlos auth login") {
+ t.Fatalf("%s with no token = %s, want cliUser's own wording", verb, body)
+ }
+ }
+ if tomb := tombstone(t, fx, "titogo"); tomb != nil {
+ t.Fatalf("an anonymous delete wrote a marker: %+v", tomb)
+ }
+}
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 60db52c59..3ef875068 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -180,6 +180,12 @@ func (s *Server) registerCLIRoutes() {
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/promote", s.handleAPIPromote)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/rollback", s.handleAPIRollback)
s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/releases", s.handleAPIShip)
+ // Trash and restore (apicli.go). Delete resolves through the LIVE-apps
+ // door and restore through the trashed-apps one, so each verb answers
+ // only for an app in the state it can act on — and both give a stranger
+ // the same 404.
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/delete", s.handleAPITrashApp)
+ s.mux.HandleFunc("POST /api/cli/apps/{account}/{app}/restore", s.handleAPIRestoreApp)
// The ledger API (apiledger.go) — the only write path to ledgers/ from
// outside a box. The literal `publish` segment is kept adjacent to the
// {ledger} wildcards for readability; the mux prefers it regardless of