feat(console): the Trash — restore is cheap, Delete now is ceremonial
GET /a/{sqid}/trash lists an account's deleted apps with who deleted each
one (read from the BUCKET marker, which is where the deletion was actually
recorded), when, and the date the platform will destroy it unasked. The page
is best-effort against the bucket: an unreadable marker costs an actor and
falls back to the row's date, because reading a page destroys nothing and a
Trash that goes blank during a bucket hiccup hides the restore button at
exactly the moment somebody needs it.
Restore is the undo button and is priced like one: member gate, CSRF, bucket
marker deleted first, row second, and NO step-up. Its flash says the one
thing nothing else will — a restored app has no routes until `carlos add`.
Delete now is handleDeleteAccount's idiom applied to an app: owner (a member
gets the same 404 a stranger does) -> CSRF -> type the APP's name (before
step-up, so a slip costs no passkey touch) -> requireFreshAuth LAST ->
release.PurgeApp, which re-reads the marker and re-checks the production flag
itself immediately before destroying anything. The production refusal reuses
the CLI's 409 sentence verbatim, so an operator meeting both doors hears one
instruction.
The account home links to the Trash only when N > 0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+626
−0
internal/console/deletion_test.go+300 −0internal/console/ownership.go+207 −0internal/console/server.go+17 −0internal/console/templates/apps.html+2 −0internal/console/templates/trash.html+54 −0internal/console/view.go+46 −0
diff --git a/internal/console/deletion_test.go b/internal/console/deletion_test.go| index 3d4c42e39..6e8813d5d 100644 |
| --- a/internal/console/deletion_test.go |
| +++ b/internal/console/deletion_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "net/http" |
| "net/url" |
| + "strings" |
| "testing" |
| "time" |
| @@ -135,3 +136,302 @@ func readManifest(t *testing.T, s release.Store, sqid string) domains.AccountMan |
| } |
| return m |
| } |
| + |
| +// ---- the Trash page --------------------------------------------------------- |
| +// |
| +// Restore is the undo button — any member, no passkey. "Delete now" is the |
| +// ceremonial end of the feature: owner, the app's own name typed out, a |
| +// passkey, and only then is anything destroyed. The tests below are mostly |
| +// about what must NOT happen, because every refusal here is the difference |
| +// between a recoverable mistake and an unrecoverable one. |
| + |
| +// trashKass puts the fixture's app in the Trash the way every other door |
| +// does: the bucket marker first, then the row, both carrying one clock. |
| +func trashKass(t *testing.T, fx *consoleFixture, actor string, now time.Time) { |
| + t.Helper() |
| + ctx := context.Background() |
| + if err := release.TrashApp(ctx, fx.rel, fx.sqid, "kass", actor, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.TrashAppRow(OpsAccountID, "kass", now); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +// trashPageFixture is the owner signed in with kass already in the Trash. |
| +func trashPageFixture(t *testing.T) (*consoleFixture, *http.Client, string) { |
| + t.Helper() |
| + fx := newConsoleFixture(t) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-trash") |
| + trashKass(t, fx, "paul@keymail.dev", time.Now()) |
| + return fx, c, sessionToken(t, fx, c) |
| +} |
| + |
| +// appKeys is everything the bucket still holds for an app — the yardstick for |
| +// "nothing was destroyed" and for "everything was". |
| +func appKeys(t *testing.T, fx *consoleFixture, app string) []string { |
| + t.Helper() |
| + keys, err := fx.rel.List(context.Background(), "apps/"+fx.sqid+"/"+app+"/") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return keys |
| +} |
| + |
| +// The page itself: the trashed app, who trashed it, when the platform will |
| +// destroy it on its own, and the one thing a restore does not bring back. |
| +func TestTrashPageListsWhatIsInIt(t *testing.T) { |
| + fx, c, _ := trashPageFixture(t) |
| + |
| + status, _, body := get(t, c, fx.u("/trash")) |
| + if status != http.StatusOK { |
| + t.Fatalf("trash page = %d:\n%s", status, body) |
| + } |
| + tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass") |
| + if err != nil || tomb == nil { |
| + t.Fatalf("marker = %+v %v", tomb, err) |
| + } |
| + purgesAt := tomb.DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") |
| + for _, want := range []string{"kass", "paul@keymail.dev", purgesAt, "carlos add"} { |
| + if !strings.Contains(body, want) { |
| + t.Fatalf("trash page missing %q in:\n%s", want, body) |
| + } |
| + } |
| + |
| + // The account home links here — and only while there is something to see. |
| + _, _, home := get(t, c, fx.u("/")) |
| + if !strings.Contains(home, "Trash (1)") { |
| + t.Fatalf("account home has no Trash link:\n%s", home) |
| + } |
| + if strings.Contains(home, `<a href="`+fx.path("")+`/apps/kass">`) { |
| + t.Fatal("a trashed app is still listed among the live ones") |
| + } |
| + if err := fx.st.RestoreAppRow(OpsAccountID, "kass"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, home := get(t, c, fx.u("/")); strings.Contains(home, "Trash (") { |
| + t.Fatalf("an empty trash still advertises itself:\n%s", home) |
| + } |
| +} |
| + |
| +// The page renders when the bucket cannot be read: the actor is unknown, the |
| +// row's own date stands in, and nothing about the page is destructive. Only |
| +// destruction requires believing the marker. |
| +func TestTrashPageSurvivesAnUnreadableMarker(t *testing.T) { |
| + fx, c, _ := trashPageFixture(t) |
| + ctx := context.Background() |
| + key := release.AppTombstoneKey(fx.sqid, "kass") |
| + if err := fx.rel.Delete(ctx, key); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := fx.rel.Put(ctx, key, []byte("{ not json"), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + status, _, body := get(t, c, fx.u("/trash")) |
| + if status != http.StatusOK { |
| + t.Fatalf("trash page with an unreadable marker = %d:\n%s", status, body) |
| + } |
| + if !strings.Contains(body, "kass") || !strings.Contains(body, "<td>—</td>") { |
| + t.Fatalf("trash page did not degrade to an em-dash actor:\n%s", body) |
| + } |
| + // The ROW's own date stands in, so the page still says when this will be |
| + // destroyed rather than showing the zero time. |
| + rows, err := fx.st.TrashedAppsIn(OpsAccountID) |
| + if err != nil || len(rows) != 1 { |
| + t.Fatalf("TrashedAppsIn = %+v %v", rows, err) |
| + } |
| + fromRow := rows[0].DeletedAt.Add(release.TrashCooloff).UTC().Format("2006-01-02 15:04 UTC") |
| + if !strings.Contains(body, fromRow) { |
| + t.Fatalf("trash page did not fall back to the row's date (%s):\n%s", fromRow, body) |
| + } |
| +} |
| + |
| +// Neither the page nor either action may tell a stranger this account exists. |
| +func TestTrashIsNotAnOracle(t *testing.T) { |
| + fx, _, _ := trashPageFixture(t) |
| + stranger := newBrowser(t) |
| + fx.setUser("stranger@keymail.dev") |
| + signUp(t, fx, stranger, "code-trash-stranger") |
| + csrf := sessionToken(t, fx, stranger) |
| + |
| + _, _, missingGet := get(t, stranger, fx.ts.URL+"/a/zzzzz/trash") |
| + if _, _, body := get(t, stranger, fx.u("/trash")); body != missingGet { |
| + t.Fatalf("the trash page answers a non-member differently from a nonexistent account:\n%q\nvs\n%q", |
| + body, missingGet) |
| + } |
| + _, _, missingPost := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/trash/restore", |
| + url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| + for _, path := range []string{"/trash/restore", "/trash/delete"} { |
| + status, loc, body := postForm(t, stranger, fx.u(path), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc) |
| + } |
| + if body != missingPost { |
| + t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q", |
| + path, body, missingPost) |
| + } |
| + } |
| + if len(appKeys(t, fx, "kass")) == 0 { |
| + t.Fatal("a stranger's post destroyed something") |
| + } |
| +} |
| + |
| +// Restore is cheap: any member, no passkey, and it says what it did not do. |
| +func TestRestoreNeedsNoPasskey(t *testing.T) { |
| + fx, _, _ := trashPageFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-restore") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // postForm, deliberately: a step-up redirect here would be a bug. |
| + status, loc, body := postForm(t, c, fx.u("/trash/restore"), url.Values{"csrf": {csrf}, "app": {"kass"}}) |
| + if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("restore = %d -> %q, want a plain redirect with no passkey:\n%s", status, loc, body) |
| + } |
| + if tomb, err := release.ReadAppTombstone(context.Background(), fx.rel, fx.sqid, "kass"); err != nil || tomb != nil { |
| + t.Fatalf("the trash marker survived the restore: %+v %v", tomb, err) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "kass"); err != nil || !has { |
| + t.Fatalf("HasAppIn after restore = %v %v, want the row live again", has, err) |
| + } |
| + // The one thing restore does NOT do, said where the person can read it. |
| + _, _, page := get(t, c, fx.ts.URL+loc) |
| + if !strings.Contains(page, "carlos add") { |
| + t.Fatalf("the restore flash does not mention that routes need carlos add:\n%s", page) |
| + } |
| + auditedBy(t, fx, "kass", "restore", "sam@keymail.dev", "kass") |
| +} |
| + |
| +// A member who is not an owner meets the 404, before a passkey redirect could |
| +// confirm anything. |
| +func TestDeleteNowIsOwnerOnly(t *testing.T) { |
| + fx, _, _ := trashPageFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-purge") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + before := appKeys(t, fx, "kass") |
| + status, _, memberBody := postForm(t, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member deleting permanently = %d, want 404", status) |
| + } |
| + strangerBody := strangerRefusal(t, fx, "code-zoe-purge", fx.u("/trash/delete"), |
| + url.Values{"app": {"kass"}, "confirm": {"kass"}}) |
| + if memberBody != strangerBody { |
| + t.Fatalf("the two refusals are not byte-identical:\n(member) %q\n(stranger) %q", memberBody, strangerBody) |
| + } |
| + if len(appKeys(t, fx, "kass")) != len(before) { |
| + t.Fatal("a member's refused delete destroyed objects") |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| + t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) |
| + } |
| +} |
| + |
| +// The typed name is checked BEFORE the passkey, so a slip costs nothing but a |
| +// retry — and destroys nothing. |
| +func TestDeleteNowRequiresTheAppsOwnName(t *testing.T) { |
| + fx, c, csrf := trashPageFixture(t) |
| + before := appKeys(t, fx, "kass") |
| + |
| + status, loc, body := postForm(t, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kas"}}) |
| + if status != http.StatusSeeOther || strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("mistyped delete = %d -> %q, want a flash and no passkey demand:\n%s", status, loc, body) |
| + } |
| + if len(appKeys(t, fx, "kass")) != len(before) { |
| + t.Fatal("a mistyped delete destroyed objects") |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| + t.Fatalf("HasTrashedAppIn = %v %v, want the row untouched", has, err) |
| + } |
| + var stashed int |
| + fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed) |
| + if stashed != 0 { |
| + t.Fatal("a mistyped delete parked a pending action — the passkey was asked for anyway") |
| + } |
| +} |
| + |
| +// The whole ceremony, and what is left afterwards: nothing in the bucket, no |
| +// row, and a name anyone in the account may claim again. |
| +func TestDeleteNowDestroysEverythingAndFreesTheName(t *testing.T) { |
| + fx, c, csrf := trashPageFixture(t) |
| + |
| + // Without the passkey, nothing moves. |
| + status, loc, _ := postForm(t, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("un-stepped-up delete = %d -> %q, want a redirect into Keymail", status, loc) |
| + } |
| + if len(appKeys(t, fx, "kass")) == 0 { |
| + t.Fatal("objects were destroyed before the passkey confirmation") |
| + } |
| + |
| + // With it, the held action resumes and lands. |
| + status, loc, body := completeStepUp(t, fx, c, loc) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("resumed delete = %d:\n%s", status, body) |
| + } |
| + if keys := appKeys(t, fx, "kass"); len(keys) != 0 { |
| + t.Fatalf("the bucket still holds %v", keys) |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || has { |
| + t.Fatalf("HasTrashedAppIn after purge = %v %v, want the row gone", has, err) |
| + } |
| + created, err := fx.st.CreateAppIn(OpsAccountID, "kass", "paul@keymail.dev", time.Now()) |
| + if err != nil || !created { |
| + t.Fatalf("re-claiming the purged name = %v %v, want it free", created, err) |
| + } |
| + auditedBy(t, fx, "kass", "purge", "paul@keymail.dev", "kass") |
| + if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "free to claim again") { |
| + t.Fatalf("the purge flash does not say the name is free:\n%s", page) |
| + } |
| +} |
| + |
| +// The production flag is the deletion protection, all the way to the last |
| +// door: a trashed app that is flagged production is not destroyed, however |
| +// much ceremony is performed at it. |
| +func TestDeleteNowRefusesAProductionApp(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + ctx := context.Background() |
| + // Flagged BEFORE the trash, bucket-direct: the API door refuses to trash a |
| + // production app at all, so this is the state that can only arrive by a |
| + // flag set after the fact — the impossible one the purge path still guards. |
| + if err := release.SetProduction(ctx, fx.rel, fx.priv, release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-trash-prod") |
| + trashKass(t, fx, "paul@keymail.dev", time.Now()) |
| + csrf := sessionToken(t, fx, c) |
| + before := appKeys(t, fx, "kass") |
| + |
| + status, loc, body := postGated(t, fx, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {csrf}, "app": {"kass"}, "confirm": {"kass"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("delete of a production app = %d:\n%s", status, body) |
| + } |
| + if keys := appKeys(t, fx, "kass"); len(keys) != len(before) { |
| + t.Fatalf("a refused delete destroyed objects: %v -> %v", before, keys) |
| + } |
| + if has, err := fx.st.HasTrashedAppIn(OpsAccountID, "kass"); err != nil || !has { |
| + t.Fatalf("HasTrashedAppIn = %v %v, want the row still trashed", has, err) |
| + } |
| + // The same sentence the CLI's 409 gives, so an operator meeting both hears |
| + // one instruction. |
| + if _, _, page := get(t, c, fx.ts.URL+loc); !strings.Contains(page, "the off-switch on the app page") { |
| + t.Fatalf("the refusal does not name the off-switch on the app page:\n%s", page) |
| + } |
| +} |
diff --git a/internal/console/ownership.go b/internal/console/ownership.go| index a98d66983..f88dbe2d3 100644 |
| --- a/internal/console/ownership.go |
| +++ b/internal/console/ownership.go |
| @@ -18,10 +18,12 @@ import ( |
| "fmt" |
| "log" |
| "net/http" |
| + "net/url" |
| "strings" |
| "time" |
| "github.com/carlosframework/platform/internal/domains" |
| + "github.com/carlosframework/platform/internal/release" |
| ) |
| // handleRemoveMember drops someone's membership. Owner-only, CSRF-checked and |
| @@ -154,6 +156,211 @@ func (s *Server) handleDeleteAccount(w http.ResponseWriter, r *http.Request) { |
| s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther) |
| } |
| +// ---- the Trash -------------------------------------------------------------- |
| + |
| +// handleTrash is the account's Trash: what has been deleted, who deleted it, |
| +// when the platform will destroy it unasked, and the two buttons that end the |
| +// wait early in either direction. |
| +// |
| +// Any MEMBER may look, through the SAME gate the account home opens with |
| +// (s.account) — so account names resolve here exactly as they do everywhere |
| +// else, and a non-member gets the byte-identical 404 an unrouted URL gets |
| +// rather than anything that confirms this account, or its trash, exists. |
| +func (s *Server) handleTrash(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + rows, err := s.store.TrashedAppsIn(acct.ID) |
| + if err != nil { |
| + log.Printf("console: trash %s: %v", acct.Sqid, err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", |
| + "Could not read this account's trash. Please try again.") |
| + return |
| + } |
| + p := s.accountPage(w, r, addr, raw, acct, role) |
| + p.Title = "Trash" |
| + p.Trashed = TrashedApps(r.Context(), s.cfg.Releases, acct.Sqid, rows) |
| + s.render(w, http.StatusOK, "trash", p) |
| +} |
| + |
| +// handleRestoreApp is the undo button, and it is priced like one: a member, a |
| +// CSRF token, and nothing else. |
| +// |
| +// The gates, in this order and for these reasons: |
| +// |
| +// member (s.account) — first, always: a non-member must meet the |
| +// byte-identical 404 before any other answer exists. |
| +// CSRF — constant-time, before anything is written. |
| +// in this trash? — the app arrives in the FORM, not the path, so it is |
| +// checked here. A refusal is a FLASH, not a 404: the |
| +// viewer already proved membership, so a name that is |
| +// not in this trash is a mistake to explain rather than |
| +// a fact to withhold. |
| +// bucket, then row — the marker IS the trash state and rows are |
| +// materialized from it (materialize.go), so a row |
| +// restored while the marker survived would un-restore |
| +// itself on the next pass. |
| +// |
| +// There is deliberately NO step-up. A passkey demand on the one action that |
| +// puts things back would make the cheap half of this feature expensive, and |
| +// restoring destroys nothing that a second restore could not undo. |
| +func (s *Server) handleRestoreApp(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, _, ok := s.account(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + fail := s.trashFailure(w, r, acct) |
| + app := strings.TrimSpace(r.FormValue("app")) |
| + has, err := s.store.HasTrashedAppIn(acct.ID, app) |
| + if err != nil { |
| + log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err) |
| + fail("Could not read this account's trash. Nothing was restored.") |
| + return |
| + } |
| + if !has { |
| + fail("There is nothing called “" + app + "” in this account's trash.") |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + fail("There is no deployment bucket on this host, so nothing can be restored here.") |
| + return |
| + } |
| + if err := release.RestoreApp(r.Context(), s.cfg.Releases, acct.Sqid, app); err != nil { |
| + log.Printf("console: restore %s/%s: %v", acct.Sqid, app, err) |
| + fail("Could not remove the trash marker, so nothing was restored. Try again once the bucket is reachable.") |
| + return |
| + } |
| + if err := s.store.RestoreAppRow(acct.ID, app); err != nil { |
| + log.Printf("console: restore row %s/%s: %v", acct.Sqid, app, err) |
| + fail("The trash marker was removed, but this console's own record did not update — " + |
| + "it will catch up on the next restart.") |
| + return |
| + } |
| + s.audit(acct, app, addr, "restore", "restored "+app+" from the trash") |
| + // The anti-ghost rule, said where the person can read it: restoring brings |
| + // back releases and channels and no routes at all (spec §3), so an app |
| + // restored and left alone serves nothing and nobody can tell why. |
| + s.setFlash(w, "ok", "Restored “"+app+"”, with its releases and channels intact and with NO routes: "+ |
| + "run `carlos add <host>` to serve it again.") |
| + // Home, not back to the Trash: the app is not in the trash any more, and |
| + // the list it rejoined is the thing worth looking at. |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/", http.StatusSeeOther) |
| +} |
| + |
| +// handlePurgeApp is "Delete now" — the one irreversible button in the |
| +// console, and the only one that destroys an app's objects rather than |
| +// marking them. |
| +// |
| +// The gates are handleDeleteAccount's idiom (see above) applied to an app, |
| +// in exactly this order: |
| +// |
| +// owner (s.owner) — a member probing this gets the same 404 a stranger |
| +// does; "not allowed" and "no such account" are one |
| +// answer. |
| +// CSRF — constant-time, before any pending action can be |
| +// parked by a cross-site post. |
| +// type the APP name — not a security control, a "did you mean this app?" |
| +// control, and checked BEFORE step-up so a slip does not |
| +// cost a passkey touch. |
| +// requireFreshAuth — LAST of the auth gates, so no redirect into Keymail |
| +// ever happens for someone who should have got a 404. |
| +// |
| +// Only then does anything get destroyed, and PurgeApp re-reads the marker and |
| +// re-checks the production flag itself, immediately before deleting: a |
| +// restore that landed while this form sat open wins, and an app somebody |
| +// flagged production after trashing it survives its own deletion ceremony. |
| +func (s *Server) handlePurgeApp(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, ok := s.owner(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + fail := s.trashFailure(w, r, acct) |
| + app := strings.TrimSpace(r.FormValue("app")) |
| + // An empty app name would make the confirmation below compare "" against |
| + // "" and pass — a ceremony that confirms nothing. Refused here rather than |
| + // left to PurgeApp, which would only fail later and after a passkey. |
| + if app == "" { |
| + fail("Choose an app to delete permanently.") |
| + return |
| + } |
| + if strings.TrimSpace(r.FormValue("confirm")) != app { |
| + fail("Type “" + app + "” exactly to delete it permanently. Nothing was deleted.") |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + if s.cfg.Releases == nil { |
| + fail("There is no deployment bucket on this host, so nothing can be deleted here.") |
| + return |
| + } |
| + if err := release.PurgeApp(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, time.Now()); err != nil { |
| + log.Printf("console: purge %s/%s: %v", acct.Sqid, app, err) |
| + fail(purgeAdvice(err, app)) |
| + return |
| + } |
| + // The row LAST, mirroring the bucket-first rule the rest of this feature |
| + // keeps: the row is the name reservation, and freeing a name while objects |
| + // survived would let the next claim ship into another app's prefix. |
| + if err := s.store.PurgeAppRow(acct.ID, app); err != nil { |
| + log.Printf("console: purge row %s/%s: %v", acct.Sqid, app, err) |
| + fail("“" + app + "” was deleted from the deployment bucket, but this console's own record " + |
| + "did not update — it will catch up on the next restart.") |
| + return |
| + } |
| + s.audit(acct, app, addr, "purge", "permanently deleted "+app) |
| + s.setFlash(w, "ok", "Deleted “"+app+"” permanently. The name is free to claim again.") |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/trash", http.StatusSeeOther) |
| +} |
| + |
| +// trashFailure is the shared refusal for both trash actions: a flash and a |
| +// redirect back to the Trash page. It is not a 404 — every caller has already |
| +// passed the membership or ownership gate, so the person reading it is |
| +// entitled to know what went wrong. |
| +func (s *Server) trashFailure(w http.ResponseWriter, r *http.Request, acct *Account) func(string) { |
| + return func(msg string) { |
| + s.setFlash(w, "err", msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/trash", http.StatusSeeOther) |
| + } |
| +} |
| + |
| +// purgeAdvice turns release.PurgeApp's refusals into something the person who |
| +// pressed the button can act on. The production wording is deliberately the |
| +// same sentence the CLI's 409 gives (apicli.go), so an operator who meets both |
| +// doors hears one instruction rather than two. |
| +func purgeAdvice(err error, app string) string { |
| + switch { |
| + case errors.Is(err, release.ErrNotTrashed): |
| + return "“" + app + "” is not in the trash, so nothing was deleted. It may have been " + |
| + "restored, or already deleted, since this page was loaded." |
| + case strings.Contains(err.Error(), "cannot read the production flag"): |
| + return "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." |
| + case strings.Contains(err.Error(), "flagged production"): |
| + return "“" + 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." |
| + case strings.Contains(err.Error(), "no release public key"): |
| + return "This console has no release public key, so it cannot prove “" + app + "” is out of " + |
| + "production. Nothing was deleted." |
| + } |
| + // Mid-purge failures land here. The marker is deleted LAST, so the app is |
| + // still in the trash and a retry finishes the job — which is what this |
| + // says, rather than claiming nothing happened. |
| + return "Could not finish deleting “" + app + "”. It is still in the trash — try again once the " + |
| + "deployment bucket is reachable." |
| +} |
| + |
| // tombstoneAccount retires the account where identity actually lives. A |
| // deployment with no release store configured cannot delete an account at all, |
| // and says so: the alternative is a console-only deletion that undoes itself, |
diff --git a/internal/console/server.go b/internal/console/server.go| index e0031150a..edff66afa 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -241,6 +241,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/members/remove", s.handleRemoveMember) |
| s.mux.HandleFunc("POST /a/{sqid}/members/transfer", s.handleTransferOwnership) |
| s.mux.HandleFunc("POST /a/{sqid}/delete", s.handleDeleteAccount) |
| + s.mux.HandleFunc("GET /a/{sqid}/trash", s.handleTrash) |
| + s.mux.HandleFunc("POST /a/{sqid}/trash/restore", s.handleRestoreApp) |
| + s.mux.HandleFunc("POST /a/{sqid}/trash/delete", s.handlePurgeApp) |
| s.mux.HandleFunc("GET /invite/{token}", s.handleAcceptInvite) |
| s.mux.HandleFunc("GET /accounts/new", s.handleNewAccountForm) |
| s.mux.HandleFunc("POST /accounts/new", s.handleNewAccount) |
| @@ -311,6 +314,12 @@ type page struct { |
| Members []Account // account home: memberships (Name carries the address) |
| Invites []Invite // account home, owners only: pending invitations |
| + // The Trash (ownership.go): the account's deleted-but-restorable apps on |
| + // /a/{sqid}/trash, and the count the account home links with. Zero hides |
| + // the link entirely — an empty trash is not a place worth sending anyone. |
| + Trashed []TrashedAppView |
| + TrashN int |
| + |
| // The picker: /a/{seg}/ where seg matches more than one of the viewer's |
| // accounts, as a name, a sqid, or one of each. |
| PickerSeg string // the ambiguous segment the picker is disambiguating |
| @@ -1093,6 +1102,14 @@ func (s *Server) handleAccountHome(w http.ResponseWriter, r *http.Request) { |
| p.Apps = append(p.Apps, summarize(v)) |
| } |
| } |
| + // The Trash link, and only when there is something in it. Best-effort like |
| + // the members and invites reads below: a failed count costs a link, never |
| + // the page. |
| + if trashed, err := s.store.TrashedAppsIn(acct.ID); err != nil { |
| + log.Printf("console: trashed apps: %v", err) |
| + } else { |
| + p.TrashN = len(trashed) |
| + } |
| if role == RoleOwner { |
| if invites, err := s.store.PendingInvites(acct.ID); err != nil { |
| log.Printf("console: pending invites: %v", err) |
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html| index 3f6c4017e..748ae19e6 100644 |
| --- a/internal/console/templates/apps.html |
| +++ b/internal/console/templates/apps.html |
| @@ -23,6 +23,8 @@ |
| <h1>Welcome to the CARLOS platform</h1> |
| <p>The account <strong>{{.Account.Name}}</strong> is ready. No apps live here yet — claim a name below and it's yours.</p> |
| {{end}} |
| +{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps, |
| + restorable until the platform destroys them.</p>{{end}} |
| <h2>Create an app</h2> |
| <form class="signin" method="post" action="{{.Base}}/apps/new"> |
| <input type="hidden" name="csrf" value="{{.CSRF}}"> |
diff --git a/internal/console/templates/trash.html b/internal/console/templates/trash.html| new file mode 100644 |
| index 000000000..5e3f9be48 |
| --- /dev/null |
| +++ b/internal/console/templates/trash.html |
| @@ -0,0 +1,54 @@ |
| +{{define "trash"}}{{template "top" .}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +<h1>Trash</h1> |
| +{{if .Trashed}} |
| +<p>A deleted app serves nothing and is hidden from the app list, but everything it had — |
| + its releases, its channels, its history — is still here. Restoring one brings all of that |
| + back with <strong>no routes</strong>: run <code>carlos add <host></code> afterwards to |
| + serve it again.</p> |
| +<p class="small">Left alone, the platform destroys a trashed app on its own 30 days after it |
| + was deleted, on the date in the last column. That is permanent — the objects go, and the |
| + name is free for anyone in this account to claim again.</p> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th>App</th><th>Deleted by</th><th>Deleted</th><th>Destroyed on</th><th></th></tr> |
| + {{range .Trashed}} |
| + <tr> |
| + <td>{{.App}}</td> |
| + <td>{{emptyOrDash .Actor}}</td> |
| + <td>{{fmtTime .DeletedAt}}</td> |
| + <td>{{fmtTime .PurgesAt}}</td> |
| + <td> |
| + <form method="post" action="{{$.Base}}/trash/restore"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="app" value="{{.App}}"> |
| + <button class="linkish" type="submit">Restore</button> |
| + </form> |
| + </td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +{{if .IsOwner}} |
| +<h2>Delete now</h2> |
| +<p class="small">Deleting now does what the 30-day wait would eventually do, immediately and |
| + permanently: every object the app owns is destroyed and its name is freed. There is no undo |
| + and no copy anywhere. Type the app's name to confirm, then confirm with your passkey — a |
| + signed-in browser on its own isn't enough. An app that is still flagged production is |
| + refused whatever you type.</p> |
| +{{range .Trashed}} |
| +<form class="signin" method="post" action="{{$.Base}}/trash/delete"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="app" value="{{.App}}"> |
| + <label class="visually-hidden" for="confirm-{{.App}}">Type {{.App}} to delete it permanently</label> |
| + <input id="confirm-{{.App}}" type="text" name="confirm" placeholder="{{.App}}"> |
| + <button type="submit">Delete “{{.App}}” permanently</button> |
| +</form> |
| +{{end}} |
| +{{end}} |
| +{{else}} |
| +<p>Nothing here. An app deleted with <code>carlos apps delete</code> waits in this Trash for |
| + 30 days — restorable the whole time — before the platform destroys it.</p> |
| +{{end}} |
| +{{template "bottom" .}}{{end}} |
diff --git a/internal/console/view.go b/internal/console/view.go| index 2f34e51ed..0603eed1b 100644 |
| --- a/internal/console/view.go |
| +++ b/internal/console/view.go |
| @@ -9,6 +9,7 @@ import ( |
| "crypto/ed25519" |
| "encoding/json" |
| "errors" |
| + "log" |
| "os" |
| "sort" |
| "strings" |
| @@ -182,6 +183,51 @@ type VersionView struct { |
| Err string |
| } |
| +// TrashedAppView is one row of the Trash page: the app, who put it there, |
| +// when, and the date the platform will destroy it on its own. |
| +type TrashedAppView struct { |
| + App string |
| + // Actor is who trashed it, read from the BUCKET marker rather than the |
| + // row — the marker is where the deletion was actually recorded, and the |
| + // row only knows a timestamp. "" is missing information (an unreadable or |
| + // absent marker), rendered as the em-dash convention, never guessed at. |
| + Actor string |
| + DeletedAt time.Time |
| + PurgesAt time.Time |
| +} |
| + |
| +// TrashedApps folds an account's trashed rows together with what the bucket |
| +// says about each one. Best effort by design: a marker that will not read |
| +// costs this page an actor and nothing else, and the ROW's timestamp stands |
| +// in for the marker's. |
| +// |
| +// The asymmetry with PurgeApp is deliberate. Reading a page is not |
| +// destroying anything, so it may proceed on partial information; destruction |
| +// re-reads the marker itself and refuses without one. A Trash page that went |
| +// blank whenever the bucket hiccuped would hide the restore button at exactly |
| +// the moment somebody needed it. |
| +func TrashedApps(ctx context.Context, s release.Store, acct string, rows []TrashedApp) []TrashedAppView { |
| + out := make([]TrashedAppView, 0, len(rows)) |
| + for _, row := range rows { |
| + v := TrashedAppView{App: row.App, DeletedAt: row.DeletedAt} |
| + if s != nil { |
| + switch tomb, err := release.ReadAppTombstone(ctx, s, acct, row.App); { |
| + case err != nil: |
| + log.Printf("console: trash marker %s/%s: %v", acct, row.App, err) |
| + case tomb != nil: |
| + // The marker's clock is the ONE clock this feature has: the |
| + // auto-purge pass destroys at the marker's DeletedAt + |
| + // TrashCooloff, so the date shown here must come from there |
| + // too or the page would promise a deadline nobody honours. |
| + v.Actor, v.DeletedAt = tomb.Actor, tomb.DeletedAt |
| + } |
| + } |
| + v.PurgesAt = v.DeletedAt.Add(release.TrashCooloff) |
| + out = append(out, v) |
| + } |
| + return out |
| +} |
| + |
| // registry.Registry has no Close, so opening per page would leak a handle |
| // each request; one cached handle per path instead. Freshness is not |
| // sacrificed: Registry.List reads over a fresh connection every call. |