carlos / platform Public

console: account tier column with operator door

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 90428b8740681b84febeb1f5ecabd4da46143487 parent ee969c7
4 files changed, +253 −0
  • internal/console/economicspass.go +69 −0
  • internal/console/economicspass_test.go +125 −0
  • internal/console/server.go +3 −0
  • internal/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) {