instances: a trashed app's platform apex stays its own until purge
The instances create door refuses a host at a sibling app's platform apex (<app>.<sqid>.<AppsDomain>), because a record there mints a route the domains pass then defers to for ever — one app serving another's public URL. It asked HasAppIn, which counts live apps only, so while an app sat in the trash its apex read as free: a sibling, even with only its own app token, could create an instance there and keep it across the restore. That was always a way to serve another app's URL. It matters more now, because `carlos email events push` posts an app's recipients' addresses to exactly that host: after the restore they would go to the sibling (discussion 153). A trashed name is reserved until purge — CreateAppIn and RenameAppIn both already refuse it — so the apex is too. The guard now also asks HasTrashedAppIn, and says the app is in the trash when it refuses. A purge still frees the label. The existing test now trashes the sibling (409, naming the trash), restores it (409) and purges it (201). With the trashed check removed, the trashed case returns 201 and the test fails.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2 files changed,
+47
−2
internal/console/apiinstances.go+19 −2internal/console/apiinstancesenable_test.go+28 −0
CI passed — run details
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index 017f47a61..e8dda693e 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -613,6 +613,14 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| // to that app. Custom-domain manifests never enter apex space (ValidHost |
| // pinned the suffix above), so this check cannot touch them. |
| // |
| + // "Claimed" includes an app in the TRASH. A trashed app's name is |
| + // reserved until purge — CreateAppIn and RenameAppIn both refuse it — and |
| + // so is its apex: were the check live apps only, a sibling could take the |
| + // apex while the app sat in the trash and keep it across a restore, with |
| + // the domains pass deferring to the sibling's route for ever. That is |
| + // also where `carlos email events push` sends the restored app's |
| + // recipients' addresses (discussion 153). Only a purge frees the apex. |
| + // |
| // The same question answers a second one further down (the steering |
| // default), so it is asked once, here, and named: is this manifest |
| // pinned to the account's platform subdomain, or to a domain of the |
| @@ -620,16 +628,25 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| platformNamespace := s.cfg.AppsDomain != "" && m.Domain == acct.Sqid+"."+s.cfg.AppsDomain |
| if platformNamespace { |
| if label := strings.TrimSuffix(host, "."+m.Domain); label != app { |
| - claimed, err := s.store.HasAppIn(acct.ID, label) |
| + live, err := s.store.HasAppIn(acct.ID, label) |
| + trashed := false |
| + if err == nil && !live { |
| + trashed, err = s.store.HasTrashedAppIn(acct.ID, label) |
| + } |
| if err != nil { |
| log.Printf("console: api instances create sibling check %s/%s: %v", acct.Sqid, label, err) |
| jsonError(w, http.StatusInternalServerError, "could not check the host — try again") |
| return |
| } |
| - if claimed { |
| + switch { |
| + case live: |
| jsonError(w, http.StatusConflict, |
| host+" is app "+label+"'s platform apex — create it as an instance of "+label+" instead") |
| return |
| + case trashed: |
| + jsonError(w, http.StatusConflict, |
| + host+" is app "+label+"'s platform apex, and "+label+" is in the trash — its apex stays reserved until it is purged") |
| + return |
| } |
| } |
| } |
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go| index 7271aee5b..4444b57a9 100644 |
| --- a/internal/console/apiinstancesenable_test.go |
| +++ b/internal/console/apiinstancesenable_test.go |
| @@ -207,6 +207,34 @@ func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) { |
| t.Fatalf("app-token sibling apex create = %d %s, want 409", status, out) |
| } |
| + // A sibling in the TRASH still owns its apex: the name is reserved until |
| + // purge, and an apex taken while it is trashed would be kept across a |
| + // restore (discussion 153). Only a purge frees it. |
| + if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + status, _, out = rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), appTok, `{"host":"marketing.`+domain+`"}`) |
| + if status != http.StatusConflict || !strings.Contains(out, "trash") { |
| + t.Fatalf("trashed sibling apex create = %d %s, want 409 naming the trash", status, out) |
| + } |
| + if err := fx.st.RestoreAppRow(OpsAccountID, "marketing"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"marketing.`+domain+`"}`); status != http.StatusConflict { |
| + t.Fatalf("restored sibling apex create = %d %s, want 409", status, out) |
| + } |
| + if err := fx.st.TrashAppRow(OpsAccountID, "marketing", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := fx.st.PurgeAppRow(OpsAccountID, "marketing"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |
| + `{"host":"marketing.`+domain+`"}`); status != http.StatusCreated { |
| + t.Fatalf("a purged sibling's label create = %d %s, want 201 — a purge frees the apex", status, out) |
| + } |
| + |
| // The app's own apex and an unclaimed label are exactly what the door |
| // is for. |
| if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "titogo"), member, |