apex: a name arriving after a sibling's instance is not free either (review fixes)
From a Fable review of this branch. The instances door now refuses a sibling's apex, trashed or live — but only names that exist when the instance is created. The other order was open: a sibling registers an instance at a free label, and then an app arrives under that name — created, renamed onto it, or moved into the account — and never gets its own URL, because the domains pass defers to the route already at the host, for ever. `carlos email events push` would post that app's recipients to the sibling. The branch's own test built exactly this state: purge marketing, titogo takes marketing's label, re-create marketing. apex.go holds the check the three doors now share. apexHolder names the app, other than the name, holding the name's platform apex in an account: a route at the host from any source, or an instance record at it under any other app, live or trashed, that the reconciler would mint. It fails closed. claimApp (both the CLI and the browser create door) refuses with a new claimApexHeld outcome, inside its release-store deadline; handleAppRename refuses a rename onto such a name; the move handler refuses a move into an account where the app's apex is already another app's. All three say which address and who holds it. The instances guard's live-then-trashed pair of queries is now one (Store.AppNameState), so a restore landing between them cannot read as neither. Anything already squatting from before these checks is reported, not repaired: at boot the console logs every route at an app's apex, live or trashed, that belongs to another app. Moving a live route is a person's call. Tests: creating over a sibling's record and over a bare route (409 naming both), a free name still created, the re-create-after-purge case, a rename and a move refused before their confirm cards, and the startup report naming live and trashed squats and nothing else. Each checked to fail with its check removed. Operator store-mode `carlos instances create` stays unguarded, consistent with how operators are trusted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 files changed,
+352
−5
cmd/console/main.go+3 −0internal/console/apex.go+153 −0internal/console/apex_test.go+138 −0internal/console/apicli.go+3 −0internal/console/apiinstances.go+3 −5internal/console/apiinstancesenable_test.go+6 −0internal/console/appsettings.go+19 −0internal/console/server.go+12 −0internal/console/store.go+15 −0
CI passed — run details
diff --git a/cmd/console/main.go b/cmd/console/main.go| index f70be5037..6edc35e4d 100644 |
| --- a/cmd/console/main.go |
| +++ b/cmd/console/main.go |
| @@ -355,6 +355,9 @@ func serve() { |
| // SES outcomes an app asked to be told about. Same placement rule as |
| // every pass above; with nothing queued, a pass is two indexed queries. |
| go srv.StartMailEventPush(context.Background()) |
| + // Once, at boot: log any route already sitting at another app's platform |
| + // apex (internal/console/apex.go). Reports; repairs nothing. |
| + go srv.ReportApexSquats(context.Background()) |
| addr := envOr("CONSOLE_ADDR", "127.0.0.1:8090") |
| ln, err := net.Listen("tcp", addr) |
| if err != nil { |
diff --git a/internal/console/apex.go b/internal/console/apex.go| new file mode 100644 |
| index 000000000..74e833156 |
| --- /dev/null |
| +++ b/internal/console/apex.go |
| @@ -0,0 +1,153 @@ |
| +package console |
| + |
| +// An app's platform apex is <app>.<sqid>.<AppsDomain>, and it is that app's. |
| +// Two things can put somebody else there. The instances create door is one: |
| +// a sibling registering an instance at another app's apex, which that door |
| +// refuses (live or trashed — apiinstances.go). The other is the name arriving |
| +// AFTER the instance: an app created, renamed or moved to a name whose apex a |
| +// sibling already registered while the name was free. Nothing mints a route |
| +// over a host that already has one (the domains pass defers to byHost for |
| +// ever), so the new app would never have its own URL — and `carlos email |
| +// events push` would post its recipients' addresses to the sibling. This file |
| +// is the check those doors share, and a startup report for anything that got |
| +// there before the check existed. |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "log" |
| + "os" |
| + "strings" |
| + |
| + "github.com/carlosframework/platform/internal/provision" |
| +) |
| + |
| +// apexHost is the platform apex app would have in the account with this |
| +// sqid, or "" on a deployment with no apps domain (there is no apex space to |
| +// collide in). |
| +func (s *Server) apexHost(sqid, app string) string { |
| + domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| + if domain == "" { |
| + return "" |
| + } |
| + return app + "." + sqid + "." + domain |
| +} |
| + |
| +// apexHolder names the app, other than name, that already holds the platform |
| +// apex name would have in acct — "" when nobody does. It looks where a holder |
| +// could be: a route at the host (whatever put it there), and an instance |
| +// record at the host under any other app of the account, live or trashed, |
| +// which the provisioning reconciler would turn into a route. |
| +// |
| +// It fails CLOSED: an error is an error, never "free". |
| +func (s *Server) apexHolder(ctx context.Context, acct *Account, name string) (string, error) { |
| + host := s.apexHost(acct.Sqid, name) |
| + if host == "" { |
| + return "", nil |
| + } |
| + if s.cfg.Registry != "" { |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + return "", err |
| + } |
| + rt, err := reg.Get(host) |
| + if err != nil { |
| + return "", err |
| + } |
| + if rt != nil && rt.App != name { |
| + return rt.App, nil |
| + } |
| + } |
| + if s.cfg.Releases == nil { |
| + return "", nil |
| + } |
| + live, err := s.store.AppsIn(acct.ID) |
| + if err != nil { |
| + return "", err |
| + } |
| + trashed, err := s.store.TrashedAppsIn(acct.ID) |
| + if err != nil { |
| + return "", err |
| + } |
| + apps := live |
| + for _, t := range trashed { |
| + apps = append(apps, t.App) |
| + } |
| + for _, other := range apps { |
| + if other == name { |
| + continue |
| + } |
| + rec, err := provision.GetRecord(ctx, s.cfg.Releases, acct.Sqid, other, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + continue |
| + } |
| + if err != nil { |
| + return "", err |
| + } |
| + if rec != nil { |
| + return other, nil |
| + } |
| + } |
| + return "", nil |
| +} |
| + |
| +// apexHeldSentence is the refusal, in one voice for every door that gives it. |
| +func apexHeldSentence(host, holder string) string { |
| + return host + " — this app's platform address — is already an instance of " + holder + |
| + "; remove that instance first, or choose another name" |
| +} |
| + |
| +// ReportApexSquats logs, once, every route that sits at an app's platform apex |
| +// but belongs to a different app — the state the checks above now prevent, |
| +// left over from before they existed. It reports and repairs nothing: which |
| +// app is in the wrong place is a person's call, and moving a live route is |
| +// not something to do quietly at boot. Started from cmd/console, never from |
| +// NewServer. |
| +func (s *Server) ReportApexSquats(ctx context.Context) { |
| + for _, line := range s.apexSquats(ctx) { |
| + log.Printf("console: apex squat: %s", line) |
| + } |
| +} |
| + |
| +// apexSquats is ReportApexSquats' finding, one line per squat. |
| +func (s *Server) apexSquats(ctx context.Context) []string { |
| + domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)) |
| + if domain == "" || s.cfg.Registry == "" { |
| + return nil |
| + } |
| + reg, err := openRegistry(s.cfg.Registry) |
| + if err != nil { |
| + log.Printf("console: apex squat check: open registry: %v", err) |
| + return nil |
| + } |
| + routes, err := reg.List() |
| + if err != nil { |
| + log.Printf("console: apex squat check: list routes: %v", err) |
| + return nil |
| + } |
| + var out []string |
| + for _, rt := range routes { |
| + rest, ok := strings.CutSuffix(rt.Host, "."+domain) |
| + if !ok { |
| + continue |
| + } |
| + label, sqid, ok := strings.Cut(rest, ".") |
| + if !ok || strings.Contains(sqid, ".") || label == rt.App { |
| + continue |
| + } |
| + acct, err := s.store.AccountBySqid(sqid) |
| + if err != nil || acct == nil { |
| + continue |
| + } |
| + live, trashed, err := s.store.AppNameState(acct.ID, label) |
| + if err != nil || (!live && !trashed) { |
| + continue |
| + } |
| + state := "live" |
| + if trashed { |
| + state = "in the trash" |
| + } |
| + out = append(out, rt.Host+" is app "+label+"'s platform apex ("+label+" is "+state+"), but the route belongs to "+rt.App+" in account "+rt.Account) |
| + } |
| + return out |
| +} |
diff --git a/internal/console/apex_test.go b/internal/console/apex_test.go| new file mode 100644 |
| index 000000000..517cd8b46 |
| --- /dev/null |
| +++ b/internal/console/apex_test.go |
| @@ -0,0 +1,138 @@ |
| +package console |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// A name whose platform apex a sibling already holds is not free. The |
| +// instance door refuses a sibling's apex once the sibling exists; this is the |
| +// other order — the instance first, while the name was free, and the app after |
| +// it — which would leave the new app with no URL of its own for ever. |
| +func TestANameWhoseApexASiblingHoldsIsNotFree(t *testing.T) { |
| + fx, member := instanceFixture(t, "code-apex-claim") |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + domain := fx.sqid + ".oncarlos.test" |
| + if status, _, out := rawReq(t, http.MethodPut, enableURL(fx, fx.sqid, "titogo"), member, `{}`); status != http.StatusOK { |
| + t.Fatalf("enable = %d %s", status, out) |
| + } |
| + // While no app is called acme, titogo may take acme's label. |
| + if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, fx.sqid, "titogo"), member, `{"host":"acme.`+domain+`"}`); status != http.StatusCreated { |
| + t.Fatalf("instance at a free label = %d %s", status, out) |
| + } |
| + newApp := fx.ts.URL + "/api/cli/apps/" + fx.sqid + "/new" |
| + |
| + // An instance record is enough: the reconciler would make it a route. |
| + status, out := postJSON(t, newApp, map[string]string{"name": "acme"}, member) |
| + if status != http.StatusConflict { |
| + t.Fatalf("creating acme over titogo's instance = %d %v, want 409", status, out) |
| + } |
| + if msg, _ := out["error"].(string); !strings.Contains(msg, "acme."+domain) || !strings.Contains(msg, "titogo") { |
| + t.Errorf("the refusal should name the address and who holds it: %q", msg) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "acme"); has { |
| + t.Error("acme was created") |
| + } |
| + |
| + // So is a route on its own, whatever put it there. |
| + fx.addRoute(t, registry.Route{Host: "beta." + domain, App: "titogo", Account: fx.sqid, Kind: "instance"}) |
| + if status, out := postJSON(t, newApp, map[string]string{"name": "beta"}, member); status != http.StatusConflict { |
| + t.Fatalf("creating beta over titogo's route = %d %v, want 409", status, out) |
| + } |
| + |
| + // A name nobody holds is still just a name. |
| + if status, out := postJSON(t, newApp, map[string]string{"name": "gamma"}, member); status != http.StatusOK { |
| + t.Fatalf("creating a free name = %d %v, want 200", status, out) |
| + } |
| +} |
| + |
| +// Renaming onto a name whose apex a sibling holds is the same arrival by |
| +// another door, and is refused the same way. |
| +func TestRenameOntoAHeldApexIsRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + freshApp(t, fx, "sprout") |
| + fx.addRoute(t, registry.Route{Host: "sapling." + fx.sqid + ".oncarlos.test", App: "kass", Account: fx.sqid, Kind: "instance"}) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + form := url.Values{"csrf": {csrf}, "to": {"sapling"}, "confirmed": {"1"}} |
| + if status, loc, body := postForm(t, c, fx.u("/apps/sprout/rename"), form); strings.Contains(body, "Rename sprout to sapling?") { |
| + t.Fatalf("offered the rename onto a held apex = %d -> %q", status, loc) |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "sapling"); has { |
| + t.Error("sprout was renamed onto an apex kass holds") |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "sprout"); !has { |
| + t.Error("sprout should be untouched") |
| + } |
| +} |
| + |
| +// Moving an app into an account where another app holds its apex there is |
| +// the third arrival, and is refused before the card. |
| +func TestMoveIntoAnAccountWhereTheApexIsHeldIsRefused(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + const destID = 4242 |
| + if err := fx.st.CreateAccount(destID, "cdef", "Second", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + stampKassRoutes(t, fx, "cdef") |
| + fx.addRoute(t, registry.Route{Host: "kass.cdef.oncarlos.test", App: "other", Account: "cdef", Kind: "instance"}) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + // Not confirmed: without the check this renders the move card, so the |
| + // card's absence is the refusal. |
| + status, _, body := postForm(t, c, fx.u("/apps/kass/move"), url.Values{"csrf": {csrf}}) |
| + if strings.Contains(body, "Move kass to Second?") { |
| + t.Fatalf("offered the move into an account where the apex is held = %d", status) |
| + } |
| + if has, _ := fx.st.HasAppIn(destID, "kass"); has { |
| + t.Error("kass was moved") |
| + } |
| + if has, _ := fx.st.HasAppIn(OpsAccountID, "kass"); !has { |
| + t.Error("kass should still be in its own account") |
| + } |
| +} |
| + |
| +// At boot the console names every route already sitting at another app's |
| +// apex — live or trashed — and nothing else: an app's own apex, a label no |
| +// app wears, and hosts outside the apps domain are not squats. |
| +func TestTheStartupReportNamesSquatsAndNothingElse(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.AppsDomain = "oncarlos.test" |
| + now := time.Now() |
| + for _, app := range []string{"acme", "parked"} { |
| + if _, err := fx.st.CreateAppIn(OpsAccountID, app, "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + } |
| + if err := fx.st.TrashAppRow(OpsAccountID, "parked", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + d := fx.sqid + ".oncarlos.test" |
| + for _, rt := range []registry.Route{ |
| + {Host: "acme." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // squat on a live app |
| + {Host: "parked." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // squat on a trashed one |
| + {Host: "titogo." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // its own apex |
| + {Host: "nobody." + d, App: "titogo", Account: fx.sqid, Kind: "instance"}, // no app by that name |
| + {Host: "acme.example.com", App: "titogo", Account: fx.sqid, Kind: "instance"}, |
| + } { |
| + fx.addRoute(t, rt) |
| + } |
| + got := fx.srv.apexSquats(context.Background()) |
| + if len(got) != 2 { |
| + t.Fatalf("report = %q, want the two squats", got) |
| + } |
| + joined := strings.Join(got, "\n") |
| + for _, want := range []string{"acme." + d, "parked." + d, "in the trash", "belongs to titogo"} { |
| + if !strings.Contains(joined, want) { |
| + t.Errorf("report missing %q:\n%s", want, joined) |
| + } |
| + } |
| +} |
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index fb43a2099..16d711558 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -421,6 +421,9 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| jsonError(w, http.StatusConflict, "that name is reserved") |
| case claimTaken: |
| jsonError(w, http.StatusConflict, "that name is taken") |
| + case claimApexHeld: |
| + holder, _ := s.apexHolder(r.Context(), acct, name) |
| + jsonError(w, http.StatusConflict, "that name is not free: "+apexHeldSentence(s.apexHost(acct.Sqid, name), holder)) |
| case claimCheckFailed, claimCreateFailed: |
| jsonError(w, http.StatusInternalServerError, "could not create the app — try again") |
| case claimStoreFailed: |
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go| index e8dda693e..73d22b141 100644 |
| --- a/internal/console/apiinstances.go |
| +++ b/internal/console/apiinstances.go |
| @@ -620,6 +620,8 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request) |
| // 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 name arriving after the instance — a create, rename or move — is |
| + // the other way in, and apex.go closes it. |
| // |
| // The same question answers a second one further down (the steering |
| // default), so it is asked once, here, and named: is this manifest |
| @@ -628,11 +630,7 @@ 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 { |
| - live, err := s.store.HasAppIn(acct.ID, label) |
| - trashed := false |
| - if err == nil && !live { |
| - trashed, err = s.store.HasTrashedAppIn(acct.ID, label) |
| - } |
| + live, trashed, err := s.store.AppNameState(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") |
diff --git a/internal/console/apiinstancesenable_test.go b/internal/console/apiinstancesenable_test.go| index 4444b57a9..2a9c6857a 100644 |
| --- a/internal/console/apiinstancesenable_test.go |
| +++ b/internal/console/apiinstancesenable_test.go |
| @@ -234,6 +234,12 @@ func TestAPIInstanceCreateSiblingApexRefused(t *testing.T) { |
| `{"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) |
| } |
| + // ...and having taken it, titogo holds it: an app created under that |
| + // name afterwards would never get its own URL, so the name is not free |
| + // (apex.go) until titogo's instance there is gone. |
| + if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "marketing"}, member); status != http.StatusConflict { |
| + t.Fatalf("re-creating marketing under titogo's instance = %d %v, want 409", status, out) |
| + } |
| // The app's own apex and an unclaimed label are exactly what the door |
| // is for. |
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go| index 5a38a41af..6ea0059f7 100644 |
| --- a/internal/console/appsettings.go |
| +++ b/internal/console/appsettings.go |
| @@ -644,6 +644,15 @@ func (s *Server) handleAppRename(w http.ResponseWriter, r *http.Request) { |
| back("err", "A host still routes this name — remove its routes first.") |
| return |
| } |
| + // The new name's platform apex must not already be another app's (apex.go). |
| + switch holder, err := s.apexHolder(ctx, acct, to); { |
| + case err != nil: |
| + back("err", "Could not check "+to+"'s platform address — nothing was renamed. Try again.") |
| + return |
| + case holder != "": |
| + back("err", "Cannot rename to "+to+": "+apexHeldSentence(s.apexHost(acct.Sqid, to), holder)+".") |
| + return |
| + } |
| if !confirmed(r) { |
| cv := &ConfirmView{ |
| Title: "Rename " + app + " to " + to + "?", |
| @@ -1122,6 +1131,16 @@ func (s *Server) handleAppMoveAccount(w http.ResponseWriter, r *http.Request) { |
| back("err", "The routes name account "+target+", which this console does not know — that is an operator repair.") |
| return |
| } |
| + // In the destination, this app's platform apex must not already be |
| + // another app's (apex.go). |
| + switch holder, err := s.apexHolder(r.Context(), dest, app); { |
| + case err != nil: |
| + back("err", "Could not check "+app+"'s platform address in "+dest.Name+" — nothing was moved. Try again.") |
| + return |
| + case holder != "": |
| + back("err", "Cannot move "+app+" to "+dest.Name+": "+apexHeldSentence(s.apexHost(dest.Sqid, app), holder)+".") |
| + return |
| + } |
| if drole, err := s.store.Role(dest.ID, addr); err != nil || drole == "" { |
| back("err", "You are not a member of "+dest.Name+" ("+target+"), so moving the record there would strand the app for you. Ask an owner of that account, or an operator, to finish the move.") |
| return |
diff --git a/internal/console/server.go b/internal/console/server.go| index 7efe304fc..58844a340 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -3332,6 +3332,7 @@ const ( |
| claimCheckFailed // a database lookup failed |
| claimStoreFailed // the release-store listing failed |
| claimCreateFailed // the insert itself failed |
| + claimApexHeld // another app already holds the name's platform apex (apex.go) |
| ) |
| // normalizeAppName is the one normalization both claim doors apply before |
| @@ -3472,6 +3473,14 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // and the database checks above stay outside it, as they always were. |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| + // A name whose platform apex another app of the account already holds is |
| + // not free either, whatever the tables say: the new app would never get |
| + // its own URL (apex.go). |
| + if holder, err := s.apexHolder(ctx, acct, name); err != nil { |
| + return claimStoreFailed, "" |
| + } else if holder != "" { |
| + return claimApexHeld, "" |
| + } |
| for _, prefix := range prefixes { |
| keys, err := s.cfg.Releases.List(ctx, prefix) |
| if err != nil { |
| @@ -3584,6 +3593,9 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("That name is reserved.") |
| case claimTaken: |
| fail("That name is taken.") |
| + case claimApexHeld: |
| + holder, _ := s.apexHolder(r.Context(), acct, name) |
| + fail("That name is not free: " + apexHeldSentence(s.apexHost(acct.Sqid, name), holder) + ".") |
| case claimCheckFailed: |
| fail("Could not check that name. Please try again.") |
| case claimStoreFailed: |
diff --git a/internal/console/store.go b/internal/console/store.go| index fd47e4e41..eca0917ce 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -1468,6 +1468,21 @@ func (s *Store) TrashedAppsIn(accountID int64) ([]TrashedApp, error) { |
| return out, rows.Err() |
| } |
| +// AppNameState reports, in one read, whether an account holds app live, in |
| +// the trash, or not at all — for a caller that must tell the three apart |
| +// without a restore or a trash landing between two separate questions. |
| +func (s *Store) AppNameState(accountID int64, app string) (live, trashed bool, err error) { |
| + var deletedAt int64 |
| + err = s.db.QueryRow(`SELECT deleted_at FROM apps WHERE account_id = ? AND app = ?`, accountID, app).Scan(&deletedAt) |
| + if errors.Is(err, sql.ErrNoRows) { |
| + return false, false, nil |
| + } |
| + if err != nil { |
| + return false, false, err |
| + } |
| + return deletedAt == 0, deletedAt != 0, nil |
| +} |
| + |
| // HasTrashedAppIn reports whether an account holds a TRASHED row for app — |
| // the check the claim-once door uses to explain a refusal ("that name is in |
| // the trash") rather than just saying no. |