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>
4 files changed,
+105
−13
internal/console/server.go+30 −1internal/console/server_test.go+59 −5internal/console/templates/app_new.html+15 −0internal/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"> |