carlos / platform Public

console: create-app is its own page

GET /a/{sqid}/apps/new; the claim POST stays put. 'new' becomes a
reserved app name because the literal route shadows apps/{app}.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 4248148715816fd3afd306b4158ca01004fb695b parent e84b7e9
4 files changed, +105 −13
  • internal/console/server.go +30 −1
  • internal/console/server_test.go +59 −5
  • internal/console/templates/app_new.html +15 −0
  • internal/console/templates/apps.html +1 −7
diff --git a/internal/console/server.go b/internal/console/server.go
index dbbb7a068..31a859973 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -252,6 +252,10 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
// Account-scoped and explicit: /a/<sqid>/apps/<app> rather than
// account-by-session, so a link a teammate opens lands where you meant.
s.mux.HandleFunc("GET /a/{sqid}/{$}", s.handleAccountHome)
+ // Go 1.22's mux prefers this literal over apps/{app} below — exactly why
+ // "new" must be a reserved app name (claimApp): an app so named could
+ // never be viewed.
+ s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
s.mux.HandleFunc("POST /a/{sqid}/apps/new", s.handleNewApp)
s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote)
@@ -316,6 +320,8 @@ type page struct {
Error string
Welcome bool // one-shot "account created" banner
+ ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+
SubmittedAddr string // interstitial: the address the user typed
KeymailURL string // interstitial: link to that address's keymail host
@@ -1333,6 +1339,7 @@ type claimOutcome int
const (
claimOK claimOutcome = iota
claimInvalid // not a ValidApp name
+ claimReserved // the literal name the console's own routes shadow (e.g. "new")
claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race
claimCheckFailed // a database lookup failed
claimStoreFailed // the release-store listing failed
@@ -1355,6 +1362,12 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string)
if !release.ValidApp(name) {
return claimInvalid
}
+ // "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
+ }
// Taken means taken IN THIS ACCOUNT — app names are unique per account,
// never globally, so another account's "blog" is not a conflict.
if has, err := s.store.HasAppIn(acct.ID, name); err != nil {
@@ -1413,6 +1426,19 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr string)
return claimOK
}
+// handleNewAppForm is the create-app page; the claim itself stays on
+// POST /a/{sqid}/apps/new (handleNewApp).
+func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Create an app"
+ p.ActiveNav = "apps"
+ s.render(w, http.StatusOK, "app_new", p)
+}
+
// handleNewApp is the browser door onto claimApp: the account page's New app
// form. Any member may claim — creation grants nothing promotion doesn't
// already gate — and unlike the CLI door it carries no rate limit: a session
@@ -1428,14 +1454,17 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) {
return
}
home := "/a/" + url.PathEscape(acct.Sqid) + "/"
+ formPage := home + "apps/new"
fail := func(msg string) {
s.setFlash(w, "err", msg)
- http.Redirect(w, r, home, http.StatusSeeOther)
+ http.Redirect(w, r, formPage, http.StatusSeeOther)
}
name := normalizeAppName(r.FormValue("name"))
switch s.claimApp(r.Context(), acct, name, addr) {
case claimInvalid:
fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.")
+ case claimReserved:
+ fail("That name is reserved.")
case claimTaken:
fail("That name is taken.")
case claimCheckFailed:
diff --git a/internal/console/server_test.go b/internal/console/server_test.go
index 29955b01a..79f69efc7 100644
--- a/internal/console/server_test.go
+++ b/internal/console/server_test.go
@@ -387,7 +387,7 @@ func TestEmptyStateWelcomesNewUser(t *testing.T) {
}
for _, want := range []string{
"Account created", "Welcome to the CARLOS platform",
- "bob@keymail.dev", "Create app", `action="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
+ "bob@keymail.dev", "Create app", `href="` + strings.TrimSuffix(home, "/") + `/apps/new"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("empty state missing %q in:\n%s", want, body)
@@ -449,10 +449,10 @@ func TestCreateApp(t *testing.T) {
t.Fatal(err)
}
for _, name := range []string{"zephyr", "kass", "orphan", "cligranted"} {
- if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/") {
- t.Fatalf("claim %s = %d -> %q, want 303 -> the account home", name, status, loc)
+ if status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {name}}); status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("claim %s = %d -> %q, want 303 -> the create form", name, status, loc)
}
- if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "That name is taken.") {
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "That name is taken.") {
t.Fatalf("claim %s: no taken flash", name)
}
}
@@ -461,7 +461,7 @@ func TestCreateApp(t *testing.T) {
if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"Bad Name!"}}); status != http.StatusSeeOther {
t.Fatalf("invalid name = %d, want redirect with flash", status)
}
- if _, _, body := get(t, c, fx.u("/")); !strings.Contains(body, "Invalid app name") {
+ if _, _, body := get(t, c, fx.u("/apps/new")); !strings.Contains(body, "Invalid app name") {
t.Fatal("no invalid-name flash")
}
if status, _, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {"wrong"}, "name": {"fine-name"}}); status != http.StatusForbidden {
@@ -469,6 +469,60 @@ func TestCreateApp(t *testing.T) {
}
}
+// assertNonMember404 signs bob (no membership in the fixture account) in and
+// asserts the account-scoped path answers him byte-identically to an account
+// that does not exist at all.
+func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) {
+ t.Helper()
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-nm-"+t.Name())
+ s1, _, b1 := get(t, c, fx.u(sub))
+ s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub)
+ if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 {
+ t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2)
+ }
+}
+
+func TestCreateAppPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != 200 {
+ t.Fatalf("GET /apps/new = %d", status)
+ }
+ for _, want := range []string{
+ "Create an app", `action="` + fx.path("/apps/new") + `"`,
+ "lowercase letters, digits, and dashes",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("create-app page missing %q", want)
+ }
+ }
+ // The account home now links here instead of embedding the form.
+ _, _, home := get(t, c, fx.u("/"))
+ if !strings.Contains(home, `/apps/new"`) {
+ t.Fatal("account home does not link to /apps/new")
+ }
+ if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) {
+ t.Fatal("account home still embeds the create form")
+ }
+ assertNonMember404(t, fx, "/apps/new")
+}
+
+func TestAppNamedNewIsRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("claiming 'new' = %d -> %q, want 303 back to the form", status, loc)
+ }
+ if apps, _ := fx.st.AppsIn(OpsAccountID); strings.Contains(strings.Join(apps, " "), "new") {
+ t.Fatal("reserved name 'new' was claimed")
+ }
+}
+
func TestPromoteFlow(t *testing.T) {
fx := newConsoleFixture(t)
c := signInPaul(t, fx)
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html
new file mode 100644
index 000000000..f558958d2
--- /dev/null
+++ b/internal/console/templates/app_new.html
@@ -0,0 +1,15 @@
+{{define "app_new"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Account · {{.Account.Name}}</p>
+ <h1>Create an app</h1>
+ <p class="lede">Claim a name here; ship a release to it from your terminal.</p>
+</div>
+<form class="signin" method="post" action="{{.Base}}/apps/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="name" placeholder="my-app" autofocus>
+ <button type="submit">Create app</button>
+</form>
+<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>
+{{template "bottom" .}}{{end}}
diff --git a/internal/console/templates/apps.html b/internal/console/templates/apps.html
index 748ae19e6..e6c156bcd 100644
--- a/internal/console/templates/apps.html
+++ b/internal/console/templates/apps.html
@@ -25,13 +25,7 @@
{{end}}
{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps,
restorable until the platform destroys them.</p>{{end}}
-<h2>Create an app</h2>
-<form class="signin" method="post" action="{{.Base}}/apps/new">
- <input type="hidden" name="csrf" value="{{.CSRF}}">
- <input type="text" name="name" placeholder="my-app">
- <button type="submit">Create app</button>
-</form>
-<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>
+<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
<h2>Members</h2>
<div class="tablewrap">