package console // The three owner-only account-administration actions the multi-account spec // names (§1) and the step-up spec gates (§7a): remove a member, transfer // ownership, delete the account. They live together because they share one // shape — owner gate, CSRF, passkey step-up, then a store call that cannot // leave an account with nobody able to administer it. // // All three were previously reachable only through `carlos members …` or not at // all. Putting them behind the console's gates is what makes "membership says // who you are, not whether you are here now" something a browser can act on. import ( "context" "crypto/subtle" "database/sql" "errors" "fmt" "log" "net/http" "net/url" "os" "strings" "time" "github.com/carlosframework/platform/internal/domains" "github.com/carlosframework/platform/internal/hostops" "github.com/carlosframework/platform/internal/mail" "github.com/carlosframework/platform/internal/objstore" "github.com/carlosframework/platform/internal/release" ) // handleRemoveMember drops someone's membership. Owner-only, CSRF-checked and // step-up gated, in that order: a non-owner must meet the byte-identical 404 // before step-up is ever mentioned, or the redirect itself would confirm the // account exists. func (s *Server) handleRemoveMember(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 } if !s.requireFreshAuth(w, r, addr, raw) { return } target := strings.TrimSpace(r.FormValue("address")) if err := s.store.RemoveMember(acct.ID, target); err != nil { log.Printf("console: remove member: %v", err) s.setFlash(w, "err", removalAdvice(err, target)) } else { s.setFlash(w, "ok", target+" is no longer a member of this account.") } http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther) } // handleTransferOwnership hands the account to another member and steps the // current owner down to member — one transaction, so there is no instant with // two owners by accident or none by failure. func (s *Server) handleTransferOwnership(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 } if !s.requireFreshAuth(w, r, addr, raw) { return } target := strings.TrimSpace(r.FormValue("address")) if err := s.store.TransferOwnership(acct.ID, addr, target, time.Now()); err != nil { log.Printf("console: transfer ownership: %v", err) s.setFlash(w, "err", transferAdvice(err, target)) } else { s.setFlash(w, "ok", target+" now owns this account. You are a member of it.") } http.Redirect(w, r, "/a/"+acct.Sqid+"/team", http.StatusSeeOther) } // handleDeleteAccount retires an EMPTY account. Deliberately narrow: an account // that still holds apps is refused rather than cascading, because the objects, // registry routes and platform domains an app owns are not this handler's to // destroy and undoing that is not a thing anyone can do. The useful case — a // mistyped or abandoned account — is fully served, and the dangerous case is // left to a deliberate migration. // // A control-plane app is refused by NAME on top of that, before the passkey: // the flag's whole job is to stop the platform deleting itself, and a guard on // the app's doors that an account deletion walks around would read as // protection without being it (controlplane.go). func (s *Server) handleDeleteAccount(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 } // An account holding a control-plane app is not retirable: deleting the // account is the same destruction the app's own doors refuse, reached one // level up (controlplane.go). Refused here — after the owner gate, before // the typed name and before requireFreshAuth — because no gesture answers // the flag, and the refusal must not cost a passkey touch. if msg := s.controlPlaneAccountRefusal(acct.ID); msg != "" { s.setFlash(w, "err", msg) http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } // Typing the name is not a security control — it is a "did you mean this // account?" control, and it is checked before step-up so a slip does not // cost a passkey touch. if strings.TrimSpace(r.FormValue("confirm")) != acct.Name { s.setFlash(w, "err", "Type the account's name exactly to delete it.") http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } if !s.requireFreshAuth(w, r, addr, raw) { return } // Both refusals that DeleteAccount enforces are re-checked HERE, before the // bucket is touched. They are cheap, and getting them in the wrong order is // expensive: tombstoning an account the store then refuses to delete would // leave a live account marked retired, which the next reconcile pass would // act on by dropping its registry row. if acct.ID == OpsAccountID { s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: ops"))) http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } if n, err := s.store.AppCount(acct.ID); err != nil || n > 0 { if err != nil { log.Printf("console: count apps for %s: %v", acct.Sqid, err) s.setFlash(w, "err", "Could not check whether this account still holds apps. Nothing was deleted.") } else { s.setFlash(w, "err", deletionAdvice(fmt.Errorf("console: still holds %d app(s)", n))) } http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } // The bucket first, and the deletion is abandoned if it will not take. // // This console's rows are not where account identity lives — every console // start materializes accounts from accounts//account.json, so a // deletion that only emptied these tables would be undone by the next // deploy, silently, re-creating the account AND re-adopting the owner the // manifest names. That is not the person who deleted it if ownership was // ever transferred. // // Ordering is the safe way round. Tombstone-then-rows can fail after the // tombstone, leaving an account that still works here and is retired in the // bucket — visibly wrong, recoverable, and harmless while it lasts, because // an account with apps never reaches this line. Rows-then-tombstone fails // the other way: the account is gone from under the operator and comes back // on its own, which is the bug this replaces. if err := s.tombstoneAccount(r.Context(), acct); err != nil { log.Printf("console: tombstone account %s: %v", acct.Sqid, err) s.setFlash(w, "err", "Could not retire this account in the deployment bucket, so nothing "+ "was deleted — an account deleted only here would come back on the next restart. "+ "Try again once the bucket is reachable.") http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } if err := s.store.DeleteAccount(acct.ID, time.Now()); err != nil { log.Printf("console: delete account: %v", err) s.setFlash(w, "err", deletionAdvice(err)) http.Redirect(w, r, "/a/"+acct.Sqid+"/settings", http.StatusSeeOther) return } s.setFlash(w, "ok", "Deleted “"+acct.Name+"”.") 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.ActiveNav = "apps" 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 } // restoreApp (restore.go) is the one restore every door runs: it locks, // settles, checks the temporary cap and orders the row and marker writes. var planCap *planCapError var capErr *temporaryCapError var rf *restoreFailure switch err := s.restoreApp(r.Context(), acct, app, time.Now()); { case err == nil: case errors.Is(err, errNotInTrash): fail("There is nothing called “" + app + "” in this account's trash.") return case errors.As(err, &planCap): fail(planCap.Browser()) return case errors.As(err, &capErr): fail(capErr.Error()) return case errors.As(err, &rf) && rf.Marker: 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 default: log.Printf("console: restore row %s/%s: %v", acct.Sqid, app, err) // The marker is gone, so the bucket already says restored: tell the // fleet now rather than at the hourly floor. The row catches up on // the next settle. s.bumpSerial(r.Context()) 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") // Box-consumed (carlos-deploy §3): removing the trash marker re-enables // instance minting for this app, and minting from records is leg 1 of the // fast pass's reconcile. Without the bump a restore is invisible to the // fleet until the hourly floor comes round. Trash (handleAppDelete) bumps // the same way now — symmetric in and out; purge (handlePurgeApp, below) // still does not, because by then the fleet has nothing left to converge // on. s.bumpSerial(r.Context()) // 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. if release.IsTemporaryName(app) { s.setFlash(w, "ok", restoredTemporaryNote(app)) } else { s.setFlash(w, "ok", "Restored “"+app+"”, with its releases and channels intact and with NO routes: "+ "run `carlos add ` 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. // control plane — refused outright, before the typed name and before // step-up: an app flagged as the control plane holds the // release history the fleet is running, and clearing the // flag is the separate act that unlocks this door. // 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. // // What gets destroyed, and in what order, is the other half of the design: the // app's OBJECT STORE goes first (see the block below), because it is the one // piece of the app that lives outside the deployment bucket — a real bucket // with a real credential — and store.json, which is the only thing naming it, // is inside the prefix this purge is about to sweep. // // 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 } // The control plane is the one app this door must never destroy: its // prefix holds the release history the fleet is RUNNING. Refused before // the typed name and before the passkey — neither gesture can answer the // flag, and asking for one would only spend it (controlplane.go). if msg, _ := s.controlPlaneRefusal(acct, app, "deleted"); msg != "" { fail(msg) 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 } // Every check above ran before the lock and is only a hint (spec §2). // From here the settle, the re-check, PurgeReady, the email and store // checks, every teardown and the sweep all run under the app's one lock. defer s.lockApp(acct.Sqid, app)() if _, ref := s.recheckUnderLock(r.Context(), acct, app, "deleted", true, time.Now()); ref != nil { switch ref.Kind { case lockedControlPlane: fail(ref.Msg) case lockedGone: // Existing copy: purgeAdvice's "not in the trash ... may have been // restored, or already deleted". fail(purgeAdvice(release.ErrNotTrashed, app)) default: // lockedWrongState carries ErrNotTrashed; lockedUnreadable carries // the read error, which purgeAdvice answers with its existing // "still in the trash, try again" sentence. fail(purgeAdvice(ref.Err, app)) } return } // PurgeApp's OWN preconditions, asked BEFORE anything is destroyed. The // teardown below is irreversible and reaches outside the deployment bucket // — a customer's objects, their IAM principal, the credentials their live // hosts are holding — so it must not run in front of a purge that PurgeApp // would then refuse: an app restored while this form sat open, a production // flag set after trashing, an unreadable flag, a console with no release // key. Without this, every one of those refusals would arrive AFTER the // store was destroyed, on an app that is still alive and (purge deliberately // not bumping the serial) whose boxes would keep serving dead credentials // until the hourly floor. // // One list, not two: PurgeReady is the block PurgeApp itself runs, so the // two cannot drift. PurgeApp re-runs it immediately before destroying // anything, which keeps the residual race to the one round trip between // here and there — the design's stated tolerance. if err := release.PurgeReady(r.Context(), s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app); err != nil { log.Printf("console: purge preflight %s/%s: %v", acct.Sqid, app, err) fail(purgeAdvice(err, app)) return } if s.cfg.HostClaim { if s.cfg.ClaimPurge == nil { s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract)) return } id, err := s.cfg.ClaimPurge.Request(r.Context(), acct.Sqid, app, s.cfg.ClaimPurge.Controller.NewID(), "owner", addr) if err != nil && !errors.Is(err, hostops.ErrPending) { s.renderDoorRefusal(w, r, claimResponseRefusal(err)) return } if id == "" { s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrPending)) return } journal, _, readErr := hostops.ReadPurge(r.Context(), s.cfg.Releases, acct.Sqid, app) if readErr == nil && journal.ID == id { s.renderClaimAccepted(w, r, app, id, journal.Phase) return } if errors.Is(readErr, os.ErrNotExist) { // Only this call's successful final completion reaches this branch. if err == nil { s.renderMessage(w, r, http.StatusOK, "Operation complete", "The app was permanently deleted.") return } } s.renderDoorRefusal(w, r, claimResponseRefusal(hostops.ErrContract)) return } // R5 (review follow-up): this refusal — "there is an email estate but // no MailProvisioner to tear it down with" — is read and checked BEFORE // the store teardown below, not after. It used to sit next to the // mail.Teardown call further down, which is AFTER objstore.Teardown // already destroys the object store — so a purge that was always going // to be refused destroyed the store first. Hoisted here, matching how // appsettings.go's store-destroy door already orders its own // backend-missing checks before either destructive call. mrec and // mailEstate are reused below, at the actual mail.Teardown call, so the // two can never disagree about what estate exists. mrec, _, mrerr := mail.Get(r.Context(), s.cfg.Releases, acct.Sqid, app) if mrerr != nil { log.Printf("console: purge mail read %s/%s: %v", acct.Sqid, app, mrerr) fail("Could not read “" + app + "”'s email record — nothing was deleted; try again.") return } mailEstate := mrec != nil && (mrec.Provisioned != nil || len(mrec.Standalone) > 0) if !mailEstate { // A domain claim can exist with no delivered credential at all — // `carlos email domains add` alone, never followed by `enable` — // and mail.Teardown releases that claim too. The audit trail // below should say so. if doms, derr := s.mailAppDomains(r.Context(), acct, app); derr == nil && len(doms) > 0 { mailEstate = true } } if mailEstate && s.cfg.MailProvisioner == nil { fail("“" + app + "” has a provisioned email credential, and this console has no SES backend configured — it cannot be deleted here until email is torn down.") return } // EVERY store this app has is torn down BEFORE the prefix sweep — this // door is the only purge path with backend authority (objstore spec §9; // box-side auto-purge refuses via release.ErrStoreProvisioned). Failure // aborts the purge with the records intact: re-entering resumes. if msg := s.purgeAppStores(r.Context(), acct, app, addr); msg != "" { fail(msg) return } // (purgeAppStores is below.) // // An email estate is torn down BEFORE the prefix sweep too, for the // identical reason the store is above (I2, review follow-up): // mail.Teardown is the ONLY place that releases this app's domain // claims — the create-only global gate at // email/domain-claims/.json (spec §4a) — and release.PurgeApp // below deletes the sender record (email/senders/app.json, plus a // legacy email.json if one is still there) out from under it. Without // this, a purged app leaves a live IAM user with an active key, its // SES identity, its published DNS, and — worst — a domain claim that // DomainRecord.Apps can never again name this (now-deleted) app to // release: the domain is denied to every CARLOS account, including // its rightful owner, permanently. // // mail.Teardown is nil-record-safe (it sweeps this account's domain // records and this app's ORPHANED claims even when the app never // declared email at all — its own doc comment), so it runs whenever a // MailProvisioner is configured, not only when a Provisioned/Standalone // estate is already known to exist. mrec and mailEstate were already // computed and refused on above (R5), before the store teardown ran. if s.cfg.MailProvisioner != nil { mctx, mcancel := context.WithTimeout(r.Context(), storeTeardownTimeout) defer mcancel() mdep, mwriter := s.mailDeploymentFor() if terr := mail.Teardown(mctx, s.cfg.Releases, s.cfg.MailProvisioner, mwriter, mdep, acct.Sqid, app); terr != nil { log.Printf("console: purge mail teardown %s/%s: %v", acct.Sqid, app, terr) fail("Could not tear down “" + app + "”'s email estate — nothing was deleted; try again.") return } if mailEstate { s.audit(acct, app, addr, "email-teardown", "tore down the email estate (credentials, IAM principal, domain claims) during permanent deletion") } } 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 relay's authority record is DERIVED from the relay.json objects // under apps///, and the sweep above just deleted this app's. // Republish now so the app is out of every relay's authority at once // (relay spec §5, "app deletion") rather than keeping its credentials // valid until the five-minute reconcile. Only the lease holder may // publish, and a failure here is a log line, not a failed purge: the // bucket is already swept, and the next holder's unconditional rebuild — // which lists records rather than hunting for unfinished ones — is // exactly the repair for this case. if s.relayHold() { if err := s.relayPublishAggregate(r.Context()); err != nil { log.Printf("console: purge %s/%s: relay aggregate (the reconcile republishes within five minutes): %v", acct.Sqid, app, err) } } // No bumpSerial here, deliberately: PurgeApp does delete box-parsed // shapes (instance records, config objects) under apps///, // but the fleet-visible teardown already happened, and was already // announced, at TRASH time (domains/reconcile.go leg 1c, // provision/reconcile.go's trashedFor bumped then). Once the trash // marker itself is gone, a bump here buys nothing — reconcile's // trashed-set no longer contains this app to converge on either way. // 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 } // PurgeAppRow revoked the app's door keys; tell the boxes now. s.republishDoorKeys(r, acct, app) 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 errors.Is(err, release.ErrStoreProvisioned): // Belt and braces: handlePurgeApp tears a store down before it gets // here, so reaching this means the record changed under the door — or // that PurgeApp could not READ it. The guard wraps this sentinel for // an unreadable or unparseable store.json as well as for a store that // really is there (release/trash.go), so the sentence must not claim // certainty it does not have: "has (or may have)" is the whole truth, // and both ways out are named because only one of them will apply. return "“" + app + "” has (or may have) a provisioned object store that this deletion could " + "not verify as gone — tear it down on the app's Settings page, or try again once the " + "store record is readable. Nothing was deleted." case strings.Contains(err.Error(), "cannot read the production flag"): return "Could not read the production setting for “" + app + "” — an unreadable flag is treated " + "as production, so deleting is refused. Set it again with the production switch on the " + "app page, then try again." case strings.Contains(err.Error(), "flagged production"): return "“" + app + "” is marked as 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, // which is worse than a refusal because it looks like it worked. func (s *Server) tombstoneAccount(ctx context.Context, acct *Account) error { if s.cfg.Releases == nil { return errors.New("console: no deployment release store is configured on this box") } return domains.TombstoneAccount(ctx, s.cfg.Releases, acct.Sqid, time.Now()) } // AppCount reports how many apps are homed in an account. Read-only pre-flight // for deletion; DeleteAccount re-checks it inside its own transaction, which is // the check that actually binds. // // Deliberately UNFILTERED by deleted_at, unlike AppsIn/HasAppIn: a trashed // app's payload — its objects, routes, everything that made it expensive in // the first place — still exists until purge, so account deletion must keep // refusing while trashed rows remain. Counting only live apps would let an // owner trash every app, delete the account out from under them, and strand // a name-reservation (and, before purge, real bucket state) with no account // left to hold it accountable. func (s *Store) AppCount(accountID int64) (int, error) { var n int err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ?`, accountID).Scan(&n) return n, err } // removalAdvice / transferAdvice / deletionAdvice turn a store error into // something a human can act on. The store's own messages are written for a log; // these are written for the person who just pressed the button. func removalAdvice(err error, target string) string { if strings.Contains(err.Error(), "last owner") { return target + " is the last owner of this account — transfer ownership first." } return "Could not remove " + target + " from this account." } func transferAdvice(err error, target string) string { if strings.Contains(err.Error(), "not a member") { return target + " is not a member of this account. Invite them first, then transfer." } if strings.Contains(err.Error(), "already") { return target + " already owns this account." } return "Could not transfer ownership to " + target + "." } func deletionAdvice(err error) string { if strings.Contains(err.Error(), "still holds") { return "This account still holds apps. Move or retire them first — deleting an account " + "never deletes an app." } if strings.Contains(err.Error(), "ops") { return "The ops account is the platform's own and cannot be deleted." } return "Could not delete this account." } // ---- store ------------------------------------------------------------------ // TransferOwnership promotes `to` and demotes `from`, atomically. Both must // already be members: ownership is handed to someone who is here, never used as // a back door for adding a person. The order inside the transaction matters — // promote first, so lastOwnerGuard sees two owners when the demotion runs and // there is no window in which the account has none. func (s *Store) TransferOwnership(accountID int64, from, to string, now time.Time) error { if strings.EqualFold(from, to) { return fmt.Errorf("console: %s already owns this account", to) } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var role string err = tx.QueryRow(`SELECT role FROM memberships WHERE account_id = ? AND address = ?`, accountID, to).Scan(&role) if err == sql.ErrNoRows { return fmt.Errorf("console: %s is not a member of this account", to) } if err != nil { return err } if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`, RoleOwner, accountID, to); err != nil { return err } if err := lastOwnerGuard(tx, accountID, from); err != nil { return err } if _, err := tx.Exec(`UPDATE memberships SET role = ? WHERE account_id = ? AND address = ?`, RoleMember, accountID, from); err != nil { return err } return tx.Commit() } // DeleteAccount retires an account that owns nothing. It refuses the ops // account (id 1 is the platform's own) and refuses while any app is still // homed here — an account row is cheap to delete and an app's objects, routes // and domains are not, so the two are never destroyed by one click. // // The accounts ROW is MARKED (deleted_at set), never removed — this is the // console's own path, and the identical resurrection hole the spec's §8 // describes for materialization exists here too if the row simply vanishes: // account identity is minted into the bucket and re-read into this table on // every start (MaterializeAccounts), so a console that forgets a sqid was // ever seen would let anyone with bucket write access rewrite the manifest // un-tombstoned, name themselves owner, and get the account BACK on the next // restart — same id, same sqid, same platform hostnames. Keeping the marked // row is what lets AccountAnyBySqid answer "seen, and deleted" instead of // "never seen", which is the only way materialization's birth-only adoption // rule stays safe. An account id is spent once; there is no un-deleting one. // // Audit rows are deliberately left behind: they are the record of what was // done, and a deletion is not a reason to lose it. func (s *Store) DeleteAccount(accountID int64, now time.Time) error { if accountID == OpsAccountID { return fmt.Errorf("console: the ops account cannot be deleted") } tx, err := s.db.Begin() if err != nil { return err } defer tx.Rollback() var apps int if err := tx.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ?`, accountID).Scan(&apps); err != nil { return err } if apps > 0 { return fmt.Errorf("console: this account still holds %d app(s)", apps) } for _, stmt := range []string{ `DELETE FROM memberships WHERE account_id = ?`, `DELETE FROM invites WHERE account_id = ?`, } { if _, err := tx.Exec(stmt, accountID); err != nil { return err } } if _, err := tx.Exec(`UPDATE accounts SET deleted_at = ? WHERE id = ?`, now.Unix(), accountID); err != nil { return err } // Anyone whose landing spot was this account gets sent back to the picker // rather than to a 404 they cannot explain. if _, err := tx.Exec(`UPDATE users SET last_account = 0 WHERE last_account = ?`, accountID); err != nil { return err } return tx.Commit() } // purgeAppStores tears down every store this app has — its own and one per // config environment — and returns the flash to show on failure, "" on // success. // // Since 2026-09-19 an app may have more than one store, and release.PurgeApp // refuses the box-side auto-purge while ANY of them holds a grant or a // provisioned block. So a purge that tore down only the app's own store would // not leave a partial cleanup behind: it would leave an app that can never be // purged at all, by this door or any other. // // One lock per store, held across that store's read AND its walk — the estate // check is made from the record, and a grant landing in between would leave // the sweep deleting a prefix whose bucket and IAM user were just re-created // (objstore/walk.go's window, from the other end). Taken in turn rather than // all at once: two stores of one app share no estate, so there is nothing to // serialise between them, and holding several locks to do nothing with them // is how a deadlock gets written later. func (s *Server) purgeAppStores(ctx context.Context, acct *Account, app, addr string) string { envs, err := objstore.StoreEnvironments(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { // Refusing on an unreadable listing is the same direction as // refusing on an unreadable record: "I could not ask" is not "no // store", and the sweep below is irreversible. log.Printf("console: purge store list %s/%s: %v", acct.Sqid, app, err) return "Could not list “" + app + "”'s object stores — nothing was deleted; try again." } // The app's own store LAST. It is the one whose grant block the purge // guard has always keyed on, so a run that stops part way leaves a state // that still reads as "this app has a store" — which is true, and which // re-entering resumes. for _, env := range append(envs, "") { if msg := s.purgeOneStore(ctx, acct, app, env, addr); msg != "" { return msg } } return "" } // purgeOneStore is purgeAppStores' body for a single store. Guard on // Provisioned OR Grant (controller ruling, Task 5 review): a grant block // without a provisioned block is a crashed grant whose backend estate is // real. func (s *Server) purgeOneStore(ctx context.Context, acct *Account, app, env, addr string) string { defer s.storeWalks.lock(acct.Sqid, app, env)() rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, env) if rerr != nil { log.Printf("console: purge store read %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), rerr) return "Could not read “" + app + "”'s object-store record — nothing was deleted; try again." } if rec == nil || (rec.Provisioned == nil && rec.Grant == nil) { return "" } if s.cfg.StoreProvisioner == nil { return "“" + app + "” has a provisioned object store, and this console has no storage backend configured (CARLOS_STORE_BACKEND) — it cannot be deleted here until the store is torn down." } // The walk gets its own ceiling rather than the request's open-ended // context: a teardown is many round trips (EmptyBucket is paginated), and // it must not be able to hold this handler open indefinitely. tctx, cancel := context.WithTimeout(ctx, storeTeardownTimeout) defer cancel() if terr := objstore.TeardownIn(tctx, s.cfg.Releases, s.cfg.StoreProvisioner, s.cfg.StoreScanner, acct.Sqid, app, env); terr != nil { log.Printf("console: purge store teardown %s/%s%s: %v", acct.Sqid, app, storeEnvSuffix(env), terr) return "Could not tear down “" + app + "”'s object store — nothing was deleted; try again." } // rec.Provisioned may be nil here (crashed-grant shape) — name the bucket // from Grant, which the guard above proves non-nil when Provisioned is. bucket := "" if rec.Provisioned != nil { bucket = rec.Provisioned.Bucket } else if rec.Grant != nil { bucket = rec.Grant.Bucket } s.audit(acct, app, addr, "store-destroy", "tore down the object store (bucket "+bucket+")"+storeEnvSuffix(env)+" during permanent deletion") return "" }