console: account tier column with operator door
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
4 files changed,
+253
−0
internal/console/economicspass.go+69 −0internal/console/economicspass_test.go+125 −0internal/console/server.go+3 −0internal/console/store.go+56 −0
diff --git a/internal/console/economicspass.go b/internal/console/economicspass.go| index cd8aff0f7..4706e641b 100644 |
| --- a/internal/console/economicspass.go |
| +++ b/internal/console/economicspass.go |
| @@ -1240,6 +1240,75 @@ func (s *Server) handleEconomicsBillSave(w http.ResponseWriter, r *http.Request) |
| back("ok", "Saved the "+month+" bill.") |
| } |
| +// handleEconomicsTierSave sets one account's fleet-pool tier (economics-v2, |
| +// Task 4) — Task 5's placement pass reads it back via Store.AccountTiers. |
| +// Registered without a method prefix (server.go), same reasoning as |
| +// handleEconomicsScenarioSave: the method check comes FIRST, before the |
| +// session/operator gate, so a wrong-method request is byte-identical to an |
| +// unknown route. Resolves the target account by sqid regardless of viewer |
| +// (AccountAnyBySqid) — an operator must be able to set the tier on ANY |
| +// account, including one the operator does not belong to, unlike every |
| +// member-facing door which resolves through the caller's own membership. |
| +func (s *Server) handleEconomicsTierSave(w http.ResponseWriter, r *http.Request) { |
| + if r.Method != http.MethodPost { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + isOp, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: economics tier save: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !isOp { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/fleet/economics/accounts", http.StatusSeeOther) |
| + } |
| + |
| + sqid := strings.TrimSpace(r.FormValue("account")) |
| + acct, _, err := s.store.AccountAnyBySqid(sqid) |
| + if err != nil { |
| + log.Printf("console: economics tier save: lookup %s: %v", sqid, err) |
| + s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not look up the account. Please try again.") |
| + return |
| + } |
| + if acct == nil { |
| + back("err", "No such account.") |
| + return |
| + } |
| + |
| + tier := r.FormValue("tier") |
| + if err := s.store.SetAccountTier(acct.ID, tier); err != nil { |
| + back("err", "Invalid tier: "+err.Error()) |
| + return |
| + } |
| + |
| + back("ok", "Set "+sqid+"'s tier to "+tierLabel(tier)+".") |
| +} |
| + |
| +// tierLabel renders SetAccountTier's empty-string value as something a flash |
| +// message can say, rather than a blank space between two words. |
| +func tierLabel(tier string) string { |
| + if tier == "" { |
| + return "unassigned" |
| + } |
| + return tier |
| +} |
| + |
| // handleAPIEconomicsBill is the CLI's manual-bill door (issue: `carlos |
| // economics bill`, Task 15). Registered without a method prefix |
| // (server.go); the method check comes first, same reasoning as |
diff --git a/internal/console/economicspass_test.go b/internal/console/economicspass_test.go| index aea0f1ee4..a1a0134c7 100644 |
| --- a/internal/console/economicspass_test.go |
| +++ b/internal/console/economicspass_test.go |
| @@ -5,6 +5,8 @@ import ( |
| "encoding/json" |
| "errors" |
| "math" |
| + "net/http" |
| + "net/url" |
| "os" |
| "path/filepath" |
| "strings" |
| @@ -546,3 +548,126 @@ func TestStatementsPassFleetMeters(t *testing.T) { |
| func hourKey(h int) string { |
| return time.Date(2000, 1, 1, h, 0, 0, 0, time.UTC).Format("15") |
| } |
| + |
| +// TestAccountTierStore: SetAccountTier writes a validated tier that |
| +// AccountTiers then reports keyed by sqid; an invalid tier is rejected with |
| +// no write; an unknown account id is a silent no-op, matching this store's |
| +// UPDATE...WHERE convention elsewhere (e.g. ReapAccount's deleted_at guard) |
| +// rather than a distinguishable error a caller would need to branch on. |
| +func TestAccountTierStore(t *testing.T) { |
| + s := openTestStore(t) |
| + now := time.Now() |
| + id := testAccountID() |
| + sqid := "test-tier-1" |
| + if err := s.CreateAccount(id, sqid, "Tierco", "paul@keymail.dev", now); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Freshly created account defaults to the empty tier. |
| + tiers, err := s.AccountTiers() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := tiers[sqid], ""; got != want { |
| + t.Fatalf("fresh account tier = %q, want %q", got, want) |
| + } |
| + |
| + if err := s.SetAccountTier(id, "pool"); err != nil { |
| + t.Fatal(err) |
| + } |
| + tiers, err = s.AccountTiers() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := tiers[sqid], "pool"; got != want { |
| + t.Fatalf("account tier after SetAccountTier = %q, want %q", got, want) |
| + } |
| + |
| + // Invalid tier: rejected, no write. |
| + if err := s.SetAccountTier(id, "bogus"); err == nil { |
| + t.Fatal("SetAccountTier with an invalid tier returned nil error, want a rejection") |
| + } |
| + tiers, err = s.AccountTiers() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := tiers[sqid], "pool"; got != want { |
| + t.Fatalf("account tier after a rejected invalid write = %q, want unchanged %q", got, want) |
| + } |
| + |
| + // Unknown account id: no-op, no error. |
| + if err := s.SetAccountTier(id+999999, "fleet"); err != nil { |
| + t.Fatalf("SetAccountTier on an unknown id returned an error, want a silent no-op: %v", err) |
| + } |
| + |
| + // A deleted account is excluded from AccountTiers, same filter as |
| + // Accounts(). |
| + if err := s.DeleteAccount(id, now); err != nil { |
| + t.Fatal(err) |
| + } |
| + tiers, err = s.AccountTiers() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, ok := tiers[sqid]; ok { |
| + t.Fatalf("AccountTiers still reports a deleted account: %+v", tiers) |
| + } |
| +} |
| + |
| +// TestEconomicsTierDoor: POST /fleet/economics/tier is registered without a |
| +// method prefix (server.go), same reasoning as |
| +// TestEconomicsWriteDoorsRejectNonPOST — a wrong-method request must be |
| +// byte-identical to an unknown route, both signed out and signed in as a |
| +// non-operator, before the operator gate, before CSRF. An operator POST |
| +// resolves the account by sqid (regardless of viewer, via |
| +// AccountAnyBySqid), sets its tier and redirects to |
| +// /fleet/economics/accounts. |
| +func TestEconomicsTierDoor(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + out := newBrowser(t) |
| + ghostStatus, _, ghostBody := get(t, out, fx.ts.URL+"/this-route-does-not-exist") |
| + |
| + const path = "/fleet/economics/tier" |
| + |
| + // Signed out: GET and POST alike must be the ghost 404. |
| + if status, _, body := get(t, out, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("signed-out GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // Signed in, not an operator: GET is the ghost 404 (method-first). |
| + if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("non-operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + // Signed in, not an operator: POST is the same ghost 404 (gate before |
| + // CSRF). |
| + form := url.Values{"csrf": {csrf}, "account": {fx.sqid}, "tier": {"pool"}} |
| + if status, _, body := postForm(t, c, fx.ts.URL+path, form); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("non-operator POST %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + if err := fx.st.SetOperator("paul@keymail.dev", true); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Operator GET: still the ghost 404 (method check runs first). |
| + if status, _, body := get(t, c, fx.ts.URL+path); status != ghostStatus || body != ghostBody { |
| + t.Fatalf("operator GET %s = %d %q, want the same 404 as a missing route (%d %q)", path, status, body, ghostStatus, ghostBody) |
| + } |
| + |
| + // Operator POST, happy path: sets the tier and redirects. |
| + status, loc, _ := postForm(t, c, fx.ts.URL+path, form) |
| + if status != http.StatusSeeOther || loc != "/fleet/economics/accounts" { |
| + t.Fatalf("operator tier POST = %d -> %q, want 303 -> /fleet/economics/accounts", status, loc) |
| + } |
| + tiers, err := fx.st.AccountTiers() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if got, want := tiers[fx.sqid], "pool"; got != want { |
| + t.Fatalf("account tier after operator POST = %q, want %q", got, want) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 362bf51f9..03a05580e 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -660,6 +660,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("/fleet/economics/scenarios", s.handleEconomicsScenarioSave) |
| s.mux.HandleFunc("/fleet/economics/bill", s.handleEconomicsBillSave) |
| s.mux.HandleFunc("/api/economics/bill", s.handleAPIEconomicsBill) |
| + // Account tier (economics-v2, Task 4): same bare-registration/method- |
| + // first reasoning as the two doors above. |
| + s.mux.HandleFunc("/fleet/economics/tier", s.handleEconomicsTierSave) |
| // CLI identity: the device-code login `carlos auth login` drives, the |
| // approval page, and the tokens an owner can see and kill (authcli.go). |
| s.registerCLIRoutes() |
diff --git a/internal/console/store.go b/internal/console/store.go| index eddf67286..daa7b4463 100644 |
| --- a/internal/console/store.go |
| +++ b/internal/console/store.go |
| @@ -217,6 +217,12 @@ func (s *Store) migrate() error { |
| // existing row defaults to "", identical to "never placed" under the |
| // old route-only reading. |
| `ALTER TABLE apps ADD COLUMN placement TEXT NOT NULL DEFAULT '';`, |
| + // Fleet pool assignment (economics-v2, Task 4): the operator-set |
| + // pricing/capacity tier driving Task 5's placement into a fleet pool. |
| + // "" means "never assigned" — every existing row defaults to it, |
| + // identical to "unassigned" under the old tier-less reading. See |
| + // SetAccountTier for the fixed set of non-empty values. |
| + `ALTER TABLE accounts ADD COLUMN tier TEXT NOT NULL DEFAULT '';`, |
| } { |
| if _, err := s.db.Exec(stmt); err != nil && !strings.Contains(err.Error(), "duplicate column") { |
| return err |
| @@ -778,6 +784,56 @@ WHERE deleted_at = 0 ORDER BY name`) |
| return out, rows.Err() |
| } |
| +// validAccountTiers is the fixed set SetAccountTier accepts — "" (never |
| +// assigned) plus economics-v2's fleet-pool tiers. Kept as a package-level |
| +// set rather than inlined so a future consumer (Task 5's placement pass) can |
| +// range over the same set without duplicating it. |
| +var validAccountTiers = map[string]bool{ |
| + "": true, |
| + "try": true, |
| + "pool": true, |
| + "hosted": true, |
| + "fleet": true, |
| + "dedicated": true, |
| +} |
| + |
| +// SetAccountTier records accountID's fleet-pool tier (economics-v2, Task 4) |
| +// — Task 5's placement pass reads it back via AccountTiers. Rejects any |
| +// value outside validAccountTiers with a clear error rather than writing an |
| +// unrecognized string a downstream placement pass would silently ignore. An |
| +// accountID matching no row is a silent no-op (the UPDATE simply touches |
| +// zero rows) — this store's existing convention for a keyed UPDATE with no |
| +// distinguishable "not found" case (see ReapAccount's deleted_at-guarded |
| +// UPDATE). |
| +func (s *Store) SetAccountTier(accountID int64, tier string) error { |
| + if !validAccountTiers[tier] { |
| + return fmt.Errorf("console: invalid account tier %q", tier) |
| + } |
| + _, err := s.db.Exec(`UPDATE accounts SET tier = ? WHERE id = ?`, tier, accountID) |
| + return err |
| +} |
| + |
| +// AccountTiers returns every live account's tier keyed by sqid — Task 5's |
| +// placement pass input. Filtered by deleted_at like Accounts(): a retired |
| +// account carries no tier into fleet-pool placement, same posture as it |
| +// carrying no usage into the economics pass. |
| +func (s *Store) AccountTiers() (map[string]string, error) { |
| + rows, err := s.db.Query(`SELECT sqid, tier FROM accounts WHERE deleted_at = 0`) |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := map[string]string{} |
| + for rows.Next() { |
| + var sqid, tier string |
| + if err := rows.Scan(&sqid, &tier); err != nil { |
| + return nil, err |
| + } |
| + out[sqid] = tier |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| // Role is address's role in the account, "" when they are not a member. It is |
| // the one permission check every account-scoped route makes. |
| func (s *Store) Role(accountID int64, address string) (string, error) { |