mail,console: the console's own sending domain survives any app action in bab
Teardown of the ops account's console-mail referrer keeps its domain record, claim, identity and DNS (credentials and records still go); RemoveDomain refuses it, including its orphan claim. Rename shares claimApp's reservedAppName predicate, so neither door lands on the name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+236
−17
internal/console/appsettings.go+6 −4internal/console/consolemail.go+4 −3internal/console/consolemail_test.go+44 −0internal/console/server.go+17 −10internal/mail/consolemail.go+25 −0internal/mail/consolemail_test.go+118 −0internal/mail/walk.go+22 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 3f03d1ad1..064c98c6b 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -771,16 +771,18 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| // Normalized exactly as the create doors normalize (normalizeAppName), |
| - // and refused exactly where claimApp refuses: "new" is the create form's |
| - // own literal route, and an app renamed onto it would be unreachable — |
| - // the mux prefers the literal, so no console page could ever show it. |
| + // and refused exactly where claimApp refuses (reservedAppName): "new" is |
| + // the create form's own literal route, and an app renamed onto it would |
| + // be unreachable — the mux prefers the literal, so no console page could |
| + // ever show it; consoleMailApp in the ops account is the console's own |
| + // mail referrer. |
| to := normalizeAppName(r.FormValue("to")) |
| if !release.ValidApp(to) { |
| s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| "App names are lowercase letters, digits and dashes.", map[string]string{"rename": r.FormValue("to")}) |
| return |
| } |
| - if to == "new" { |
| + if reservedAppName(acct, to) { |
| s.renderAppSettingsForm(w, r, http.StatusUnprocessableEntity, "general", |
| "That name is reserved.", map[string]string{"rename": r.FormValue("to")}) |
| return |
diff --git a/internal/console/consolemail.go b/internal/console/consolemail.go| index 102ac6063..b0c309d3b 100644 |
| --- a/internal/console/consolemail.go |
| +++ b/internal/console/consolemail.go |
| @@ -19,9 +19,10 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -// consoleMailApp is the referrer name on the console's domain record. claimApp |
| -// refuses it in the ops account, so no app delete can tear the identity down. |
| -const consoleMailApp = "console-mail" |
| +// consoleMailApp is the referrer name on the console's domain record. |
| +// reservedAppName refuses it in the ops account, and the mail layer refuses |
| +// to tear down its domain, so no app delete can take the identity with it. |
| +const consoleMailApp = mail.ConsoleMailApp |
| // consoleMailPendingRecheck is how long a not-yet-verified answer is trusted. |
| const consoleMailPendingRecheck = 5 * time.Minute |
diff --git a/internal/console/consolemail_test.go b/internal/console/consolemail_test.go| index 7b5103fc2..777e1a4f9 100644 |
| --- a/internal/console/consolemail_test.go |
| +++ b/internal/console/consolemail_test.go |
| @@ -3,6 +3,9 @@ package console |
| import ( |
| "context" |
| "errors" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| "testing" |
| "time" |
| @@ -191,3 +194,44 @@ func TestConsoleMailAppIsReservedInOps(t *testing.T) { |
| t.Fatalf("claimApp(bab, %q) = %v, want claimReserved", consoleMailApp, got) |
| } |
| } |
| + |
| +// Final review Important #1: rename was the other door onto the reserved |
| +// name. Refused in the ops account, the same words as "new". |
| +func TestRenameOntoConsoleMailIsRefusedInOps(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + freshApp(t, fx, "sprout") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, body := postForm(t, c, fx.u("/apps/sprout/rename"), |
| + url.Values{"csrf": {csrf}, "to": {consoleMailApp}, "confirmed": {"1"}}) |
| + if status != http.StatusUnprocessableEntity || !strings.Contains(body, "That name is reserved.") { |
| + t.Fatalf("rename onto %q = %d, want 422 with the reserved refusal:\n%s", consoleMailApp, status, body) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, consoleMailApp); err != nil || has { |
| + t.Fatalf("HasAppIn(%s) = %v %v, want no rename", consoleMailApp, has, err) |
| + } |
| + if has, err := fx.st.HasAppIn(OpsAccountID, "sprout"); err != nil || !has { |
| + t.Fatalf("HasAppIn(sprout) = %v %v, want the app untouched", has, err) |
| + } |
| +} |
| + |
| +func TestReservedAppName(t *testing.T) { |
| + ops := &Account{Sqid: release.OpsAccount} |
| + other := &Account{Sqid: "xyz"} |
| + for _, tc := range []struct { |
| + acct *Account |
| + name string |
| + want bool |
| + }{ |
| + {ops, "new", true}, |
| + {other, "new", true}, |
| + {ops, consoleMailApp, true}, |
| + {other, consoleMailApp, false}, |
| + {ops, "blog", false}, |
| + } { |
| + if got := reservedAppName(tc.acct, tc.name); got != tc.want { |
| + t.Errorf("reservedAppName(%s, %q) = %v, want %v", tc.acct.Sqid, tc.name, got, tc.want) |
| + } |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 959a6dbe2..46c7903d5 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3683,20 +3683,27 @@ func ownerExempt(prefix, ownPrefix string) bool { return prefix == ownPrefix } |
| // |
| // held is set only with claimApexHeld: who holds the name's platform apex, so |
| // a door can say so without asking again. |
| +// reservedAppName reports whether no app in acct may take name, by create or |
| +// by rename. |
| +// |
| +// "new" is the literal path segment GET /a/{sqid}/apps/new resolves to — |
| +// Go 1.22's mux prefers it over apps/{app}, so an app named "new" could |
| +// never be viewed. |
| +// |
| +// The console's own mail identity hangs off consoleMailApp in the ops |
| +// account (consolemail.go). The mail layer refuses to tear that domain down |
| +// whatever app holds the name, but an app wearing it would still read as |
| +// the domain's owner everywhere else. |
| +func reservedAppName(acct *Account, name string) bool { |
| + return name == "new" || (acct.Sqid == release.OpsAccount && name == consoleMailApp) |
| +} |
| + |
| func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placement string, ownerClaim bool, rq regionReq) (outcome claimOutcome, placed string, held *apexHold) { |
| if !release.ValidApp(name) { |
| return claimInvalid, "", nil |
| } |
| - // "new" is the literal path segment GET /a/{sqid}/apps/new resolves to — |
| - // Go 1.22's mux prefers it over apps/{app}, so an app named "new" could |
| - // never be viewed. Reserved before any store write, same as an invalid name. |
| - if name == "new" { |
| - return claimReserved, "", nil |
| - } |
| - // The console's own mail identity hangs off this name in the ops |
| - // account (consolemail.go). An app so named could be deleted, and an app |
| - // delete tears down every domain whose last referrer it was. |
| - if acct.Sqid == release.OpsAccount && name == consoleMailApp { |
| + // Reserved before any store write, same as an invalid name. |
| + if reservedAppName(acct, name) { |
| return claimReserved, "", nil |
| } |
| // Taken means taken IN THIS ACCOUNT — app names are unique per account, |
diff --git a/internal/mail/consolemail.go b/internal/mail/consolemail.go| new file mode 100644 |
| index 000000000..5f3c4a0ed |
| --- /dev/null |
| +++ b/internal/mail/consolemail.go |
| @@ -0,0 +1,25 @@ |
| +package mail |
| + |
| +import ( |
| + "errors" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// ConsoleMailApp is the referrer the console's own sending domain hangs off |
| +// in the ops account (internal/console/consolemail.go). It is not a real app: |
| +// the console claims the domain under this name so that no app's estate |
| +// owns it. The console refuses to create or rename an app onto it, and the |
| +// release walks here refuse to tear its domain down, so an app that reaches |
| +// the name some other way still cannot take the console's identity with it. |
| +const ConsoleMailApp = "console-mail" |
| + |
| +// ErrConsoleMailReserved is RemoveDomain's refusal of the console's own |
| +// sending domain: the ops account's ConsoleMailApp referrer, or a claim that |
| +// referrer made. |
| +var ErrConsoleMailReserved = errors.New("mail: the console's own sending domain cannot be removed") |
| + |
| +// isConsoleMail reports whether acct/app is the console's own referrer. |
| +func isConsoleMail(acct, app string) bool { |
| + return acct == release.OpsAccount && app == ConsoleMailApp |
| +} |
diff --git a/internal/mail/consolemail_test.go b/internal/mail/consolemail_test.go| new file mode 100644 |
| index 000000000..648764e6c |
| --- /dev/null |
| +++ b/internal/mail/consolemail_test.go |
| @@ -0,0 +1,118 @@ |
| +package mail |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/dnsrec" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +const consoleMailDomain = "console-mail.bab.oncarlos.com" |
| + |
| +// consoleMailFixture ensures the console's own sending domain the way the |
| +// console does: ops account, referrer ConsoleMailApp, a platform name. |
| +func consoleMailFixture(t *testing.T) (context.Context, release.Store, *MemProvisioner, *dnsrec.MemWriter) { |
| + t.Helper() |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) |
| + if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain, now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + return ctx, s, p, w |
| +} |
| + |
| +func assertConsoleMailIntact(t *testing.T, ctx context.Context, s release.Store, p *MemProvisioner) { |
| + t.Helper() |
| + rec, _, err := GetDomain(ctx, s, release.OpsAccount, consoleMailDomain) |
| + if err != nil || rec == nil { |
| + t.Fatalf("the console's domain record is gone: %+v (err %v)", rec, err) |
| + } |
| + if len(rec.Apps) != 1 || rec.Apps[0] != ConsoleMailApp { |
| + t.Fatalf("referrers = %v, want [%s]", rec.Apps, ConsoleMailApp) |
| + } |
| + if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok { |
| + t.Fatal("the console's SES identity was deleted") |
| + } |
| + if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil { |
| + t.Fatalf("the console's domain claim was released: %+v (err %v)", claim, err) |
| + } |
| +} |
| + |
| +// An app that lands on the reserved name in the ops account (a move, a |
| +// migrate, a row from before the reservation) is still an app, and deleting |
| +// it runs Teardown. That must not take the console's own sending domain |
| +// with it. |
| +func TestTeardownKeepsTheConsoleMailDomain(t *testing.T) { |
| + ctx, s, p, w := consoleMailFixture(t) |
| + if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil { |
| + t.Fatalf("Teardown: %v", err) |
| + } |
| + assertConsoleMailIntact(t, ctx, s, p) |
| +} |
| + |
| +// The same name in any other account is an ordinary app: its Teardown |
| +// releases its domains as usual. |
| +func TestTeardownOfConsoleMailNameElsewhereIsOrdinary(t *testing.T) { |
| + ctx := context.Background() |
| + s, p, w := release.NewDirStore(t.TempDir()), NewMemProvisioner("shared-aws-account"), dnsrec.NewMemWriter() |
| + now := time.Date(2026, 10, 2, 12, 0, 0, 0, time.UTC) |
| + if _, _, err := EnsureDomain(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp, "console-mail.aaa.oncarlos.com", now); err != nil { |
| + t.Fatalf("EnsureDomain: %v", err) |
| + } |
| + if err := Teardown(ctx, s, p, w, testDeployment(), "aaa", ConsoleMailApp); err != nil { |
| + t.Fatalf("Teardown: %v", err) |
| + } |
| + if rec, _, err := GetDomain(ctx, s, "aaa", "console-mail.aaa.oncarlos.com"); err != nil || rec != nil { |
| + t.Fatalf("an ordinary app's domain survived its teardown: %+v (err %v)", rec, err) |
| + } |
| +} |
| + |
| +func TestRemoveDomainRefusesTheConsoleMailDomain(t *testing.T) { |
| + ctx, s, p, w := consoleMailFixture(t) |
| + out, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp, consoleMailDomain) |
| + if !errors.Is(err, ErrConsoleMailReserved) { |
| + t.Fatalf("RemoveDomain = %+v, %v; want ErrConsoleMailReserved", out, err) |
| + } |
| + assertConsoleMailIntact(t, ctx, s, p) |
| +} |
| + |
| +// A claim with no record (an ensure that failed after claiming) is released |
| +// by RemoveDomain for any app in the account. The console's claim is not: |
| +// the console re-runs its own ensure and picks it back up. |
| +func TestRemoveDomainRefusesTheConsoleMailOrphanClaim(t *testing.T) { |
| + ctx, s, p, w := consoleMailFixture(t) |
| + if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := RemoveDomain(ctx, s, p, w, testDeployment(), release.OpsAccount, "blog", consoleMailDomain); !errors.Is(err, ErrConsoleMailReserved) { |
| + t.Fatalf("RemoveDomain(orphan) = %v, want ErrConsoleMailReserved", err) |
| + } |
| + if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil { |
| + t.Fatalf("the console's orphan claim was released: %+v (err %v)", claim, err) |
| + } |
| + if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok { |
| + t.Fatal("the console's SES identity was deleted") |
| + } |
| +} |
| + |
| +// Teardown's orphan-claim sweep is scoped to the app that made the claim, |
| +// so it too must skip the console's. |
| +func TestTeardownKeepsTheConsoleMailOrphanClaim(t *testing.T) { |
| + ctx, s, p, w := consoleMailFixture(t) |
| + if err := s.Delete(ctx, DomainKey(release.OpsAccount, consoleMailDomain)); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := Teardown(ctx, s, p, w, testDeployment(), release.OpsAccount, ConsoleMailApp); err != nil { |
| + t.Fatalf("Teardown: %v", err) |
| + } |
| + if claim, err := getDomainClaim(ctx, s, consoleMailDomain); err != nil || claim == nil { |
| + t.Fatalf("the console's orphan claim was swept: %+v (err %v)", claim, err) |
| + } |
| + if _, ok := p.Identities[identityKey("eu-west-1", consoleMailDomain)]; !ok { |
| + t.Fatal("the console's SES identity was deleted") |
| + } |
| +} |
diff --git a/internal/mail/walk.go b/internal/mail/walk.go| index 13a19511b..adb114bbb 100644 |
| --- a/internal/mail/walk.go |
| +++ b/internal/mail/walk.go |
| @@ -1490,6 +1490,14 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ |
| } |
| } |
| + // The console's own sending domain (ConsoleMailApp) is never torn down |
| + // with an app: its record, claim, identity and DNS are the console's, |
| + // and an app that reached the reserved name by a move or a migrate is |
| + // not their owner. Its credentials above and its records below still go. |
| + if isConsoleMail(acct, app) { |
| + return deleteMailRecords(ctx, s, acct, app, rec) |
| + } |
| + |
| domains, err := ListDomains(ctx, s, acct) |
| if err != nil { |
| return err |
| @@ -1589,6 +1597,11 @@ func Teardown(ctx context.Context, s release.Store, p Provisioner, w dnsrec.Writ |
| } |
| } |
| + return deleteMailRecords(ctx, s, acct, app, rec) |
| +} |
| + |
| +// deleteMailRecords is Teardown's last step: the app's own email records. |
| +func deleteMailRecords(ctx context.Context, s release.Store, acct, app string, rec *Record) error { |
| if rec != nil { |
| // Both objects: the sender the app is on, and the legacy one the |
| // migration deliberately leaves standing until the sweep. Leaving |
| @@ -1686,6 +1699,10 @@ func RemoveDomain(ctx context.Context, s release.Store, p Provisioner, w dnsrec. |
| if !ValidDomain(domain) { |
| return nil, fmt.Errorf("mail: invalid domain %q", domain) |
| } |
| + // The console's own referrer never releases its domain (see Teardown). |
| + if isConsoleMail(acct, app) { |
| + return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved) |
| + } |
| // The From check runs FIRST, before anything is read about the domain |
| // and long before anything is written: a refusal must cost the caller |
| @@ -1775,6 +1792,11 @@ func removeOrphanedClaim(ctx context.Context, s release.Store, p Provisioner, |
| if claim == nil || claim.Account != acct { |
| return nil, fmt.Errorf("mail: no sending domain %s for this account: %w", domain, ErrDomainNotReferenced) |
| } |
| + // The console's own claim with no record is an ensure the console will |
| + // retry; releasing it here would delete the identity from under it. |
| + if isConsoleMail(acct, claim.App) { |
| + return nil, fmt.Errorf("mail: %s: %w", domain, ErrConsoleMailReserved) |
| + } |
| // Only an identity this claim's walk created, as in Teardown's sweep. |
| if claim.createdIdentityIn(d.Region) { |
| if err := p.DeleteIdentity(ctx, d.Region, domain); err != nil { |