carlos / platform Public

console: one free account per person

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 949aa925a6b670fb83010bb8f52354b9453e899c parent 982ce55
3 files changed, +84 −0
  • internal/console/authcli.go +10 −0
  • internal/console/plancaps_doors_test.go +61 −0
  • internal/console/server.go +13 −0
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 90c76a5b6..79e179505 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -1774,6 +1774,16 @@ func (s *Server) handleCLINewAccount(w http.ResponseWriter, r *http.Request) {
}
ctx, cancel := context.WithTimeout(r.Context(), 20*time.Second)
defer cancel()
+ // Plans 3a: one free account per person (spec §8.1).
+ if err := s.checkNewFreeAccount(addr); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: cli mint account for %s: plan cap: %v", addr, err)
+ jsonError(w, http.StatusBadGateway, "could not create the account — try again")
+ return
+ }
a, err := s.mintAccount(ctx, name, addr)
if err != nil {
// Logged, not returned: every other failure in this console answers a
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
index 24ddf82aa..15e887bf7 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -223,3 +223,64 @@ func TestInstanceAndSidecarCreateWithoutACatalogUnchanged(t *testing.T) {
t.Fatalf("sidecar create with no catalog = %d %s", status, out)
}
}
+
+func accountCount(t *testing.T, fx *consoleFixture) int {
+ t.Helper()
+ all, err := fx.st.Accounts()
+ if err != nil {
+ t.Fatal(err)
+ }
+ return len(all)
+}
+
+func TestNewAccountRefusedForASecondFreeAccount(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ before := accountCount(t, fx)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct")
+ status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("CLI second free account = %d %v, want 409 cap", status, out)
+ }
+ c := signInPaul(t, fx)
+ st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {sessionToken(t, fx, c)}, "name": {"thirdtry"}})
+ if st != http.StatusSeeOther || loc != "/accounts/new" {
+ t.Fatalf("browser second free account = %d -> %q, want the flash redirect back to /accounts/new", st, loc)
+ }
+ if after := accountCount(t, fx); after != before {
+ t.Fatalf("accounts %d -> %d: one was minted past the cap", before, after)
+ }
+}
+
+func TestFirstAccountIsNeverRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ signInAs(t, fx, "newcomer@keymail.dev", "code-cap-first")
+ accts, err := fx.st.AccountsFor("newcomer@keymail.dev")
+ if err != nil || len(accts) != 1 {
+ t.Fatalf("first sign-in with plans on: %d accounts, %v; want the auto-minted one", len(accts), err)
+ }
+}
+
+func TestNewAccountAllowedWhenTheOtherIsPaid(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ ops, _ := fx.st.AccountBySqid(fx.sqid)
+ if _, err := fx.st.SetAccountPlan(ops.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-paid")
+ if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": "second"}, token); status != http.StatusOK {
+ t.Fatalf("a first free account beside a paid one = %d %v", status, out)
+ }
+}
+
+func TestNewAccountWithoutACatalogUnchanged(t *testing.T) {
+ fx := newConsoleFixture(t)
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cap-acct-nocat")
+ for _, name := range []string{"one", "two"} {
+ if status, out := postJSON(t, fx.ts.URL+"/auth/cli/accounts", map[string]string{"name": name}, token); status != http.StatusOK {
+ t.Fatalf("account %s with no catalog = %d %v", name, status, out)
+ }
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 7eb4cdbf7..d6abba42b 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -2499,6 +2499,19 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
return
}
+ // Plans 3a: one free account per person, checked at creation only (spec
+ // §8.1). A person's first account, minted on landing, is never refused.
+ if err := s.checkNewFreeAccount(addr); err != nil {
+ var planCap *planCapError
+ if errors.As(err, &planCap) {
+ s.setFlash(w, "err", planCap.Browser())
+ http.Redirect(w, r, "/accounts/new", http.StatusSeeOther)
+ return
+ }
+ log.Printf("console: create account for %s: plan cap: %v", addr, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not create the account. Please try again.")
+ return
+ }
a, err := s.mintAccount(r.Context(), name, addr)
if err != nil {
log.Printf("console: create account for %s: %v", addr, err)