carlos / platform Public

console: CLI create door refuses reserved app names too

claimApp's switch in handleAPICreateApp had no claimReserved case, so
`carlos apps create -app new` got 200 OK with a zero-length body — a
refusal wearing a success status. Add the missing case (409, "that
name is reserved"), a default case so a future claimOutcome can't
silently 200 either, and a refusal-matrix test leg covering it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 29ce1685c9b0a9fed55983029de64bbcad455a57 parent 4248148
2 files changed, +13 −0
  • internal/console/apicli.go +6 −0
  • internal/console/apicli_test.go +7 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go
index 7afd4bb13..101bccba3 100644
--- a/internal/console/apicli.go
+++ b/internal/console/apicli.go
@@ -170,6 +170,8 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
case claimInvalid:
jsonError(w, http.StatusBadRequest,
"invalid app name — lowercase letters, digits, and dashes, starting with a letter")
+ case claimReserved:
+ jsonError(w, http.StatusConflict, "that name is reserved")
case claimTaken:
jsonError(w, http.StatusConflict, "that name is taken")
case claimCheckFailed, claimCreateFailed:
@@ -184,6 +186,10 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) {
"account": acct.Sqid, "app": name,
"url": s.cfg.Origin + "/a/" + url.PathEscape(acct.Sqid) + "/apps/" + url.PathEscape(name),
})
+ default:
+ // Any future claimOutcome lands here rather than silently 200ing
+ // with an empty body — see the reserved-name finding this guards.
+ jsonError(w, http.StatusInternalServerError, "could not create the app — try again")
}
}
diff --git a/internal/console/apicli_test.go b/internal/console/apicli_test.go
index 6efab0315..1f0407a8b 100644
--- a/internal/console/apicli_test.go
+++ b/internal/console/apicli_test.go
@@ -2043,6 +2043,13 @@ func TestAPICreateAppRefusalMatrix(t *testing.T) {
if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "9lives"}, token); status != http.StatusBadRequest {
t.Fatalf("invalid name = %d %v, want 400", status, out)
}
+ // "new" is the literal name the console's own routes shadow — claimApp's
+ // claimReserved leg, refused before any store read/write.
+ if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "new"}, token); status != http.StatusConflict {
+ t.Fatalf("reserved name = %d %v, want 409", status, out)
+ } else if !strings.Contains(str(t, out, "error"), "reserved") {
+ t.Fatalf("reserved name error = %v, want it to say reserved", out)
+ }
// kass is the fixture's already-claimed app.
if status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/new", map[string]string{"name": "kass"}, token); status != http.StatusConflict {
t.Fatalf("claimed name = %d %v, want 409", status, out)