console, cli: the operator door for account plans
GET/POST /api/cli/accounts/{account}/plan for fleet operators, carlos
accounts plan, and the Plans docs page. All human text is from Paul's
two approved copy reviews of 2026-10-20/21, applied verbatim.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
15 files changed,
+948
−1
accounts.go+12 −1accounts_plan.go+288 −0accounts_plan_copy.go+43 −0accounts_plan_test.go+125 −0docs/site/cli.md+12 −0docs/site/nav.json+5 −0docs/site/plans.md+55 −0internal/console/apiaccountplan.go+191 −0internal/console/apiaccountplan_copy.go+14 −0internal/console/apiaccountplan_test.go+182 −0internal/console/authcli.go+2 −0internal/console/claim_invariant_fixtures_test.go+2 −0internal/console/claim_invariant_probes_nonapp_test.go+13 −0internal/console/claim_invariant_probes_read_test.go+2 −0internal/console/testdata/claim-routes.txt+2 −0
diff --git a/accounts.go b/accounts.go| index b36beccf9..acc557932 100644 |
| --- a/accounts.go |
| +++ b/accounts.go |
| @@ -29,7 +29,7 @@ import ( |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| -const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| +const accountsUsage = `usage: carlos accounts <create|list|migrate|plan> |
| create --name <name> mint an account: the next free id, its |
| frozen sqid, and the bucket manifest every |
| @@ -52,6 +52,15 @@ const accountsUsage = `usage: carlos accounts <create|list|migrate> |
| [--from <sqid>] source account (default: the legacy, |
| account-less keys) |
| [--dry-run] print the plan and touch nothing |
| + plan --account <sqid> show or change an account's plan (deployment operators) |
| + [--set <plan>] put the account on this plan. needs --reason |
| + [--pack <name=quantity>] add a pack. repeat it for more than one |
| + [--sponsored <file.json>] limits for a sponsored account, used where |
| + they are more generous than the plan's |
| + [--reason <text>] why the plan is changing |
| + [--console <url>] which logged-in console to use |
| + |
| +plan only works where the console has a plan catalog (CARLOS_PLAN_CATALOG). |
| create always names an owner, one way or the other: log this terminal in with |
| "carlos auth login" and you become the owner, or pass --owner. It refuses |
| @@ -76,6 +85,8 @@ func RunAccounts(args []string) { |
| runAccountsList(args[1:]) |
| case "migrate": |
| runAccountsMigrate(args[1:]) |
| + case "plan": |
| + runAccountsPlan(args[1:]) |
| default: |
| fmt.Fprint(os.Stderr, accountsUsage) |
| os.Exit(1) |
diff --git a/accounts_plan.go b/accounts_plan.go| new file mode 100644 |
| index 000000000..7c88a453c |
| --- /dev/null |
| +++ b/accounts_plan.go |
| @@ -0,0 +1,288 @@ |
| +package main |
| + |
| +// `carlos accounts plan`: a deployment operator reads or sets an account's |
| +// plan, packs and sponsored limits through the console's plan door |
| +// (internal/console/apiaccountplan.go; plans-caps-packs spec §4, §8.7). |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "fmt" |
| + "io" |
| + "log" |
| + "net/http" |
| + "net/url" |
| + "os" |
| + "slices" |
| + "strconv" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// accountPlanReply and setAccountPlanRequest mirror the console door's wire |
| +// shapes. |
| +type accountPlanReply struct { |
| + Account string `json:"account"` |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Source string `json:"source"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + UpdatedBy string `json:"updated_by,omitempty"` |
| + Revision int64 `json:"revision"` |
| + PublishedRevision int64 `json:"published_revision"` |
| + Limits *plans.Limits `json:"limits,omitempty"` |
| + LimitsError string `json:"limits_error,omitempty"` |
| +} |
| + |
| +type setAccountPlanRequest struct { |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs,omitempty"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +// packFlags collects repeated --pack name=quantity values. |
| +type packFlags map[string]int |
| + |
| +func (p *packFlags) String() string { |
| + if p == nil { |
| + return "" |
| + } |
| + return formatPacks(*p) |
| +} |
| + |
| +func (p *packFlags) Set(v string) error { |
| + name, qty, ok := strings.Cut(v, "=") |
| + n, err := strconv.Atoi(qty) |
| + if !ok || name == "" || err != nil { |
| + return fmt.Errorf(cliPlanCopyPackFormat, v) |
| + } |
| + if *p == nil { |
| + *p = packFlags{} |
| + } |
| + (*p)[name] = n |
| + return nil |
| +} |
| + |
| +func formatPacks(p map[string]int) string { |
| + names := make([]string, 0, len(p)) |
| + for name := range p { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + parts := make([]string, 0, len(names)) |
| + for _, name := range names { |
| + parts = append(parts, fmt.Sprintf("%s %d", name, p[name])) |
| + } |
| + return strings.Join(parts, ", ") |
| +} |
| + |
| +func runAccountsPlan(args []string) { |
| + if err := runAccountsPlanWithSession(context.Background(), nil, args, stdout); err != nil { |
| + if errors.Is(err, flag.ErrHelp) { |
| + return |
| + } |
| + log.Fatalf("accounts plan: %v", err) |
| + } |
| +} |
| + |
| +func runAccountsPlanWithSession(ctx context.Context, sess *apiSession, args []string, out io.Writer) error { |
| + fs := flag.NewFlagSet("accounts plan", flag.ContinueOnError) |
| + fs.SetOutput(out) |
| + account := fs.String("account", "", cliPlanCopyFlagAccount) |
| + set := fs.String("set", "", cliPlanCopyFlagSet) |
| + var packs packFlags |
| + fs.Var(&packs, "pack", cliPlanCopyFlagPack) |
| + sponsored := fs.String("sponsored", "", cliPlanCopyFlagSponsored) |
| + reason := fs.String("reason", "", cliPlanCopyFlagReason) |
| + consoleFlag := consoleOption(fs, "console origin to use") |
| + fs.Usage = flagUsage(fs, "") |
| + if err := fs.Parse(args); err != nil { |
| + return err |
| + } |
| + if fs.NArg() != 0 { |
| + return fmt.Errorf("unexpected argument %q", fs.Arg(0)) |
| + } |
| + acct, err := explicitStoreAccount(*account) |
| + if err != nil { |
| + return err |
| + } |
| + var req *setAccountPlanRequest |
| + if *set == "" { |
| + if len(packs) > 0 || *sponsored != "" || *reason != "" { |
| + return errors.New(cliPlanCopyNeedsSet) |
| + } |
| + } else { |
| + if strings.TrimSpace(*reason) == "" { |
| + return errors.New(cliPlanCopyReasonRequired) |
| + } |
| + req = &setAccountPlanRequest{Plan: *set, Packs: packs, Reason: *reason} |
| + if *sponsored != "" { |
| + b, err := os.ReadFile(*sponsored) |
| + if err == nil { |
| + req.Sponsored = &plans.Limits{} |
| + err = json.Unmarshal(b, req.Sponsored) |
| + } |
| + if err != nil { |
| + return fmt.Errorf(cliPlanCopySponsoredFile, *sponsored, err) |
| + } |
| + } |
| + } |
| + if sess == nil { |
| + sess = requirePlanAPISession(*consoleFlag) |
| + printUsing(out, sess) |
| + } |
| + var res accountPlanReply |
| + if req == nil { |
| + res, err = sess.accountPlan(ctx, acct) |
| + } else { |
| + res, err = sess.setAccountPlan(ctx, acct, *req) |
| + } |
| + if err != nil { |
| + return err |
| + } |
| + printAccountPlan(out, res) |
| + return nil |
| +} |
| + |
| +func (s *apiSession) accountPlan(ctx context.Context, account string) (accountPlanReply, error) { |
| + status, body, err := s.getJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan") |
| + return decodeAccountPlanReply(status, body, err) |
| +} |
| + |
| +func (s *apiSession) setAccountPlan(ctx context.Context, account string, req setAccountPlanRequest) (accountPlanReply, error) { |
| + status, body, err := s.postJSON(ctx, "/api/cli/accounts/"+url.PathEscape(account)+"/plan", req) |
| + return decodeAccountPlanReply(status, body, err) |
| +} |
| + |
| +func decodeAccountPlanReply(status int, body []byte, err error) (accountPlanReply, error) { |
| + if err != nil { |
| + return accountPlanReply{}, err |
| + } |
| + if status != http.StatusOK { |
| + return accountPlanReply{}, apiErrorFromBody(status, body) |
| + } |
| + var out accountPlanReply |
| + if err := json.Unmarshal(body, &out); err != nil { |
| + return accountPlanReply{}, fmt.Errorf("accounts plan: unreadable response: %w", err) |
| + } |
| + return out, nil |
| +} |
| + |
| +func printAccountPlan(w io.Writer, r accountPlanReply) { |
| + fmt.Fprintln(w, colorFor(w).ok(fmt.Sprintf(cliPlanCopyHeading, r.Account, r.Plan))) |
| + if len(r.Packs) > 0 { |
| + fmt.Fprintf(w, cliPlanCopyPacks+"\n", formatPacks(r.Packs)) |
| + } |
| + switch r.Source { |
| + case "operator": |
| + fmt.Fprintf(w, cliPlanCopySourceOperator+"\n", r.UpdatedBy, r.Reason) |
| + case "sponsored": |
| + fmt.Fprintf(w, cliPlanCopySourceSponsored+"\n", r.UpdatedBy, r.Reason) |
| + case "billing": |
| + fmt.Fprintln(w, cliPlanCopySourceBilling) |
| + default: |
| + fmt.Fprintln(w, cliPlanCopySourceDefault) |
| + } |
| + if r.Revision > 0 && r.PublishedRevision < r.Revision { |
| + mutedf(w, "%s", cliPlanCopyUnpublished) |
| + } |
| + if r.Limits == nil { |
| + fmt.Fprintf(w, cliPlanCopyLimitsError+"\n", r.LimitsError) |
| + return |
| + } |
| + l := r.Limits |
| + fmt.Fprintln(w) |
| + fmt.Fprintln(w, cliPlanCopyLimitsHeading) |
| + storage := planBytes(l.StorageBytes) |
| + if !l.StorageSelfServe { |
| + storage += ", " + cliPlanCopyStorageOperator |
| + } |
| + history := cliPlanCopyLatestOnly |
| + if l.HistoryDays > 0 { |
| + history = plural(l.HistoryDays, "day") |
| + } |
| + rows := [][2]string{ |
| + {cliPlanCopyLimitRunning, strconv.Itoa(l.RunningThings)}, |
| + {cliPlanCopyLimitSidecars, strconv.Itoa(l.SidecarsPerApp)}, |
| + {cliPlanCopyLimitIdle, humanSeconds(l.IdleSeconds)}, |
| + {cliPlanCopyLimitMemory, l.MemoryTier}, |
| + {cliPlanCopyLimitAwake, strconv.Itoa(l.AwakeHoursMonth)}, |
| + {cliPlanCopyLimitDisk, planBytes(l.DiskBytes)}, |
| + {cliPlanCopyLimitStorage, storage}, |
| + {cliPlanCopyLimitEmail, strconv.Itoa(l.EmailPerDay)}, |
| + {cliPlanCopyLimitBandwidth, planBytes(l.BandwidthBytesMonth)}, |
| + {cliPlanCopyLimitDomains, allowed(l.CustomDomains)}, |
| + {cliPlanCopyLimitFleets, allowed(l.Fleets)}, |
| + {cliPlanCopyLimitSchedule, humanSeconds(l.MinScheduleSeconds)}, |
| + {cliPlanCopyLimitHistory, history}, |
| + } |
| + width := 0 |
| + for _, row := range rows { |
| + width = max(width, len(row[0])) |
| + } |
| + for _, row := range rows { |
| + fmt.Fprintf(w, " %-*s %s\n", width, row[0], row[1]) |
| + } |
| +} |
| + |
| +func allowed(b bool) string { |
| + if b { |
| + return cliPlanCopyAllowed |
| + } |
| + return cliPlanCopyNotAllowed |
| +} |
| + |
| +func plural(n int, unit string) string { |
| + if n == 1 { |
| + return "1 " + unit |
| + } |
| + return strconv.Itoa(n) + " " + unit + "s" |
| +} |
| + |
| +// humanSeconds renders a duration in the largest whole unit. |
| +func humanSeconds(s int) string { |
| + d := time.Duration(s) * time.Second |
| + switch { |
| + case s == 0: |
| + return "0 seconds" |
| + case d%time.Hour == 0: |
| + return plural(int(d/time.Hour), "hour") |
| + case d%time.Minute == 0: |
| + return plural(int(d/time.Minute), "minute") |
| + default: |
| + return plural(s, "second") |
| + } |
| +} |
| + |
| +// planBytes renders a size in GB, or MB below one GB (binary units, as |
| +// the catalog's numbers are). |
| +func planBytes(b int64) string { |
| + const mb, gb = 1 << 20, 1 << 30 |
| + if b >= gb { |
| + if b%gb == 0 { |
| + return strconv.FormatInt(b/gb, 10) + " GB" |
| + } |
| + return strconv.FormatFloat(float64(b)/gb, 'f', 1, 64) + " GB" |
| + } |
| + return strconv.FormatInt(b/mb, 10) + " MB" |
| +} |
| + |
| +// requirePlanAPISession is the console session the plan door needs. Plans |
| +// live in the console's database, so direct bucket mode has no equivalent. |
| +func requirePlanAPISession(consoleFlag string) *apiSession { |
| + sess, err := resolveMode(consoleFlag) |
| + if err != nil { |
| + log.Fatal(err) |
| + } |
| + if sess == nil { |
| + log.Fatal(cliPlanCopyNeedsConsole) |
| + } |
| + return sess |
| +} |
diff --git a/accounts_plan_copy.go b/accounts_plan_copy.go| new file mode 100644 |
| index 000000000..9873643cf |
| --- /dev/null |
| +++ b/accounts_plan_copy.go |
| @@ -0,0 +1,43 @@ |
| +// Code generated from the approved copy review of 2026-10-20 (plans 1). Apply |
| +// later edits through copy review, not by hand. |
| + |
| +package main |
| + |
| +const ( |
| + cliPlanCopyFlagAccount = "account sqid to show or change" |
| + cliPlanCopyFlagSet = "plan to put the account on. leave it out to show the current plan" |
| + cliPlanCopyFlagPack = "a pack and how many, as name=quantity. repeat it for more than one pack" |
| + cliPlanCopyFlagSponsored = "a JSON file of limits for a sponsored account. each one replaces the plan's where it is more generous" |
| + cliPlanCopyFlagReason = "why the plan is changing. it is kept with the change" |
| + cliPlanCopyPackFormat = "--pack %q: write it as name=quantity, for example apps=2" |
| + cliPlanCopyReasonRequired = "--reason is required when you change a plan" |
| + cliPlanCopyNeedsSet = "--pack, --sponsored and --reason change a plan. add --set with the plan to put the account on" |
| + cliPlanCopySponsoredFile = "--sponsored %s: %v" |
| + cliPlanCopyHeading = "account %s is on the %s plan" |
| + cliPlanCopyPacks = "packs: %s" |
| + cliPlanCopySourceDefault = "nobody has assigned a plan, so it is on the default plan" |
| + cliPlanCopySourceOperator = "set by %s: %s" |
| + cliPlanCopySourceSponsored = "sponsored by %s: %s" |
| + cliPlanCopySourceBilling = "set by its subscription" |
| + cliPlanCopyUnpublished = "the boxes do not have this change yet. it is retried every minute" |
| + cliPlanCopyLimitsHeading = "limits" |
| + cliPlanCopyLimitRunning = "apps and instances" |
| + cliPlanCopyLimitSidecars = "workers per app" |
| + cliPlanCopyLimitIdle = "sleeps after" |
| + cliPlanCopyLimitMemory = "memory per app" |
| + cliPlanCopyLimitAwake = "awake hours a month" |
| + cliPlanCopyLimitDisk = "disk per app" |
| + cliPlanCopyLimitStorage = "object storage" |
| + cliPlanCopyStorageOperator = "if an operator approves it" |
| + cliPlanCopyLimitEmail = "emails a day" |
| + cliPlanCopyLimitBandwidth = "bandwidth a month" |
| + cliPlanCopyLimitDomains = "custom domains" |
| + cliPlanCopyLimitFleets = "own boxes (fleets)" |
| + cliPlanCopyLimitSchedule = "schedules at most every" |
| + cliPlanCopyLimitHistory = "database history" |
| + cliPlanCopyLatestOnly = "latest copy only" |
| + cliPlanCopyAllowed = "allowed" |
| + cliPlanCopyNotAllowed = "not allowed" |
| + cliPlanCopyLimitsError = "these limits cannot be worked out: %s. the boxes keep the last limits they were given until the plan is fixed" |
| + cliPlanCopyNeedsConsole = "carlos accounts plan works through a console, and this shell is in direct bucket mode\n(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). plans are kept by the console.\nunset the store env and run: carlos auth login --console <url>" |
| +) |
diff --git a/accounts_plan_test.go b/accounts_plan_test.go| new file mode 100644 |
| index 000000000..5b6ed5767 |
| --- /dev/null |
| +++ b/accounts_plan_test.go |
| @@ -0,0 +1,125 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "context" |
| + "encoding/json" |
| + "errors" |
| + "flag" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "os" |
| + "path/filepath" |
| + "reflect" |
| + "strings" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +func TestAccountsPlanParsesPacks(t *testing.T) { |
| + var p packFlags |
| + for _, v := range []string{"apps=2", "email=1"} { |
| + if err := p.Set(v); err != nil { |
| + t.Fatalf("Set(%q): %v", v, err) |
| + } |
| + } |
| + if !reflect.DeepEqual(map[string]int(p), map[string]int{"apps": 2, "email": 1}) { |
| + t.Fatalf("packs %v", p) |
| + } |
| + for _, bad := range []string{"apps", "apps=x", "=2", "apps=2=3"} { |
| + var q packFlags |
| + if err := q.Set(bad); err == nil { |
| + t.Errorf("Set(%q) accepted", bad) |
| + } |
| + } |
| +} |
| + |
| +// planServer answers the plan door with reply and records each request. |
| +func planServer(t *testing.T, reply accountPlanReply) (*httptest.Server, *[]string, *[]setAccountPlanRequest) { |
| + t.Helper() |
| + var paths []string |
| + var bodies []setAccountPlanRequest |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + paths = append(paths, r.Method+" "+r.URL.RequestURI()) |
| + if r.Method == http.MethodPost { |
| + var req setAccountPlanRequest |
| + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { |
| + t.Errorf("decode request: %v", err) |
| + } |
| + bodies = append(bodies, req) |
| + } |
| + json.NewEncoder(w).Encode(reply) |
| + })) |
| + t.Cleanup(ts.Close) |
| + return ts, &paths, &bodies |
| +} |
| + |
| +func TestAccountsPlanShowAndSet(t *testing.T) { |
| + reply := accountPlanReply{ |
| + Account: "bac", Plan: "personal", Packs: map[string]int{"apps": 2}, Source: "operator", |
| + Reason: "upgraded", UpdatedBy: "ops@example.com", Revision: 2, PublishedRevision: 2, |
| + Limits: &plans.Limits{RunningThings: 35, IdleSeconds: 900, MemoryTier: "default", DiskBytes: 10 << 30, |
| + EmailPerDay: 1000, CustomDomains: true, HistoryDays: 7}, |
| + } |
| + ts, paths, bodies := planServer(t, reply) |
| + s := newTestSession(ts.URL) |
| + |
| + var out bytes.Buffer |
| + if err := runAccountsPlanWithSession(context.Background(), s, []string{"--account", "bac"}, &out); err != nil { |
| + t.Fatal(err) |
| + } |
| + text := out.String() |
| + for _, want := range []string{"bac", "personal", "apps 2", "35", "15 minutes", "10 GB", "1000", "7 days", "ops@example.com", "upgraded"} { |
| + if !strings.Contains(text, want) { |
| + t.Errorf("show output lacks %q:\n%s", want, text) |
| + } |
| + } |
| + |
| + sponsored := filepath.Join(t.TempDir(), "limits.json") |
| + if err := os.WriteFile(sponsored, []byte(`{"running_things": 40}`), 0o600); err != nil { |
| + t.Fatal(err) |
| + } |
| + args := []string{"--account", "bac", "--set", "personal", "--pack", "apps=2", "--sponsored", sponsored, "--reason", "existing"} |
| + if err := runAccountsPlanWithSession(context.Background(), s, args, io.Discard); err != nil { |
| + t.Fatal(err) |
| + } |
| + want := []string{"GET /api/cli/accounts/bac/plan", "POST /api/cli/accounts/bac/plan"} |
| + if !reflect.DeepEqual(*paths, want) { |
| + t.Fatalf("paths %v, want %v", *paths, want) |
| + } |
| + got := (*bodies)[0] |
| + if got.Plan != "personal" || got.Reason != "existing" || got.Packs["apps"] != 2 || |
| + got.Sponsored == nil || got.Sponsored.RunningThings != 40 { |
| + t.Fatalf("request %+v", got) |
| + } |
| +} |
| + |
| +func TestAccountsPlanValidationStopsBeforeNetwork(t *testing.T) { |
| + hits := 0 |
| + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hits++ })) |
| + defer ts.Close() |
| + s := newTestSession(ts.URL) |
| + missing := filepath.Join(t.TempDir(), "missing.json") |
| + for _, args := range [][]string{ |
| + {}, // no account |
| + {"--account", "bac", "--set", "free"}, // no reason |
| + {"--account", "bac", "--pack", "apps=1"}, // pack without set |
| + {"--account", "bac", "--reason", "r"}, // reason without set |
| + {"--account", "bac", "--set", "free", "--pack", "apps", "--reason", "r"}, |
| + {"--account", "bac", "--set", "free", "--sponsored", missing, "--reason", "r"}, |
| + {"--account", "bac", "extra"}, |
| + } { |
| + if err := runAccountsPlanWithSession(context.Background(), s, args, io.Discard); err == nil { |
| + t.Errorf("%v: accepted", args) |
| + } |
| + } |
| + var help bytes.Buffer |
| + if err := runAccountsPlanWithSession(context.Background(), s, []string{"--help"}, &help); !errors.Is(err, flag.ErrHelp) { |
| + t.Fatalf("--help: %v", err) |
| + } |
| + if hits != 0 { |
| + t.Fatalf("%d requests reached the console", hits) |
| + } |
| +} |
diff --git a/docs/site/cli.md b/docs/site/cli.md| index 194ae0780..f6e608b39 100644 |
| --- a/docs/site/cli.md |
| +++ b/docs/site/cli.md |
| @@ -1734,6 +1734,18 @@ logged-in one. |
| prefix and re-stamps its routes. Run it with `--dry-run` first; it prints the |
| plan and touches nothing. |
| +### carlos accounts plan |
| + |
| +Shows or changes an account's plan. It is a deployment-operator command, and it only works where the console has a plan catalog (see [Plans](plans)). |
| + |
| +```sh |
| +carlos accounts plan --account bac |
| +carlos accounts plan --account bac --set personal --pack apps=2 --reason "needs more apps" |
| +carlos accounts plan --account bac --set free --sponsored limits.json --reason "existing account, sponsored" |
| +``` |
| + |
| +Without `--set` it prints the plan, who set it and why, and every limit the account has. With `--set` it changes the plan and prints the result. `--reason` is required for a change. `--pack` can be repeated. `--sponsored` names a JSON file of limits; each one replaces the plan's limit where it is more generous. |
| + |
| ### carlos fleets |
| A fleet is a group of remote boxes an account owns, dialing in to this |
diff --git a/docs/site/nav.json b/docs/site/nav.json| index 616eb25f0..5d7d1c55b 100644 |
| --- a/docs/site/nav.json |
| +++ b/docs/site/nav.json |
| @@ -7,6 +7,11 @@ |
| "slug": "cli", |
| "label": "The CLI", |
| "blurb": "Every carlos command, grouped by whether you are shipping an app, running a deployment, or on a box." |
| + }, |
| + { |
| + "slug": "plans", |
| + "label": "Plans", |
| + "blurb": "Give accounts limits, packs and sponsorship. Plans stay off until you configure a catalog." |
| } |
| ] |
| } |
diff --git a/docs/site/plans.md b/docs/site/plans.md| new file mode 100644 |
| index 000000000..006392789 |
| --- /dev/null |
| +++ b/docs/site/plans.md |
| @@ -0,0 +1,55 @@ |
| +# Plans |
| + |
| +A deployment can give each account a plan. A plan sets limits: how many apps and instances an account runs, how soon its apps sleep, how much disk, storage, email and bandwidth it gets, and whether it can use custom domains or its own boxes. Packs raise some of those limits. |
| + |
| +Plans are off unless you turn them on. A deployment with no plan catalog has no limits, which is how every deployment has run until now. |
| + |
| +## Turning plans on |
| + |
| +Write a catalog file and point the console at it: |
| + |
| +```sh |
| +CARLOS_PLAN_CATALOG=/etc/carlos/plans.json |
| +``` |
| + |
| +The console reads the file when it starts. If the file is missing or wrong, the console refuses to start rather than run with no limits. |
| + |
| +## The catalog |
| + |
| +```json |
| +{ |
| + "version": "2026-10-20-v1", |
| + "default_plan": "free", |
| + "free_accounts_per_person": 1, |
| + "plans": { |
| + "free": {"paid": false, "limits": {"running_things": 3, "idle_seconds": 90, "memory_tier": "small"}}, |
| + "personal": {"paid": true, "limits": {"running_things": 25, "idle_seconds": 900}, "packs": ["apps"]} |
| + }, |
| + "packs": { |
| + "apps": {"adds": {"running_things": 5}, "max_quantity": 20} |
| + }, |
| + "ceilings": {"running_things": 200} |
| +} |
| +``` |
| + |
| +Every account without a plan of its own is on `default_plan`. Packs may only raise counts and sizes: apps and instances, disk, storage, email and bandwidth. A ceiling is the most any plan with packs can reach. |
| + |
| +Change `version` whenever you change the file. The console notices an edit that kept the old version and treats it as a new catalog anyway, so every account's limits are written again. |
| + |
| +## Assigning a plan |
| + |
| +```sh |
| +carlos accounts plan --account bac --set personal --reason "upgraded" |
| +``` |
| + |
| +The console writes each account's limits to `accounts/<sqid>/limits.json` in the deployment bucket, and the boxes read them from there. A change reaches the bucket straight away, or within a minute if the bucket was unavailable. |
| + |
| +## Sponsored accounts |
| + |
| +A sponsored account keeps its plan but gets limits of its own where those are more generous, for example the usage it already had when plans were turned on: |
| + |
| +```sh |
| +carlos accounts plan --account bac --set free --sponsored limits.json --reason "existing account" |
| +``` |
| + |
| +`limits.json` holds any of the limit names from the catalog. A sponsored limit can go above a ceiling: that is a decision you made for that account. |
diff --git a/internal/console/apiaccountplan.go b/internal/console/apiaccountplan.go| new file mode 100644 |
| index 000000000..0181e0ca0 |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan.go |
| @@ -0,0 +1,191 @@ |
| +package console |
| + |
| +import ( |
| + "fmt" |
| + "log" |
| + "net/http" |
| + "slices" |
| + "strings" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/plans" |
| +) |
| + |
| +// The account plan door (plans-caps-packs spec §4, §8.7, §9.4): a fleet |
| +// operator reads or sets an account's plan, packs and sponsored limits. |
| +// Everyone else gets 404, the store-approval doors' convention. With no |
| +// catalog configured it answers 409: plans are off. |
| + |
| +type accountPlanReply struct { |
| + Account string `json:"account"` |
| + CatalogID string `json:"catalog_id"` |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Source string `json:"source"` |
| + Sponsored *plans.Limits `json:"sponsored,omitempty"` |
| + Reason string `json:"reason,omitempty"` |
| + UpdatedBy string `json:"updated_by,omitempty"` |
| + Revision int64 `json:"revision"` |
| + PublishedRevision int64 `json:"published_revision"` |
| + Limits *plans.Limits `json:"limits,omitempty"` |
| + LimitsError string `json:"limits_error,omitempty"` |
| +} |
| + |
| +type setAccountPlanRequest struct { |
| + Plan string `json:"plan"` |
| + Packs map[string]int `json:"packs"` |
| + Sponsored *plans.Limits `json:"sponsored"` |
| + Reason string `json:"reason"` |
| +} |
| + |
| +// accountPlanDoor is the shared gate: an operator, a known account, and a |
| +// catalog. It writes the refusal itself. |
| +func (s *Server) accountPlanDoor(w http.ResponseWriter, r *http.Request) (string, *Account, bool) { |
| + addr, ok := s.cliUser(w, r) |
| + if !ok { |
| + return "", nil, false |
| + } |
| + isOperator, err := s.store.IsOperator(addr) |
| + if err != nil { |
| + log.Printf("console: api account plan: is-operator: %v", err) |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if !isOperator { |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + acct, err := s.store.AccountBySqid(r.PathValue("account")) |
| + if err != nil { |
| + log.Printf("console: api account plan: account lookup: %v", err) |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if acct == nil { |
| + http.NotFound(w, r) |
| + return "", nil, false |
| + } |
| + if s.cfg.PlanCatalog == nil { |
| + jsonError(w, http.StatusConflict, planCopyNoCatalog) |
| + return "", nil, false |
| + } |
| + return addr, acct, true |
| +} |
| + |
| +func (s *Server) handleAPIAccountPlanGet(w http.ResponseWriter, r *http.Request) { |
| + _, acct, ok := s.accountPlanDoor(w, r) |
| + if !ok { |
| + return |
| + } |
| + reply, err := s.accountPlanReplyFor(acct) |
| + if err != nil { |
| + log.Printf("console: api account plan %s: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, reply) |
| +} |
| + |
| +func (s *Server) handleAPIAccountPlanSet(w http.ResponseWriter, r *http.Request) { |
| + addr, acct, ok := s.accountPlanDoor(w, r) |
| + if !ok { |
| + return |
| + } |
| + if !s.allowWrite(Principal{Address: addr}, time.Now()) { |
| + jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") |
| + return |
| + } |
| + var req setAccountPlanRequest |
| + if !readJSON(w, r, &req) { |
| + return |
| + } |
| + reason := strings.TrimSpace(req.Reason) |
| + if reason == "" { |
| + jsonError(w, http.StatusBadRequest, planCopyReasonRequired) |
| + return |
| + } |
| + if msg := planRequestProblem(s.cfg.PlanCatalog, req); msg != "" { |
| + jsonError(w, http.StatusBadRequest, msg) |
| + return |
| + } |
| + if _, err := s.cfg.PlanCatalog.Effective(req.Plan, req.Packs, req.Sponsored); err != nil { |
| + jsonError(w, http.StatusBadRequest, err.Error()) |
| + return |
| + } |
| + source := PlanSourceOperator |
| + if req.Sponsored != nil { |
| + source = PlanSourceSponsored |
| + } |
| + now := time.Now() |
| + if _, err := s.store.SetAccountPlan(acct.ID, req.Plan, req.Packs, source, req.Sponsored, reason, addr, now); err != nil { |
| + log.Printf("console: api account plan %s: save: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + if row, err := s.store.AccountPlan(acct.ID); err != nil { |
| + log.Printf("console: api account plan %s: reread: %v", acct.Sqid, err) |
| + } else if row != nil && s.cfg.Releases != nil { |
| + // Best effort: the pass retries anything this misses. |
| + if err := s.publishAccountLimits(r.Context(), *row, now); err != nil { |
| + log.Printf("console: api account plan %s: publish: %v", acct.Sqid, err) |
| + } |
| + } |
| + reply, err := s.accountPlanReplyFor(acct) |
| + if err != nil { |
| + log.Printf("console: api account plan %s: %v", acct.Sqid, err) |
| + jsonError(w, http.StatusInternalServerError, planCopySaveFailed) |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, reply) |
| +} |
| + |
| +// planRequestProblem names what is wrong with a requested plan and packs in |
| +// words an operator can act on, or "" when the catalog can price it. |
| +func planRequestProblem(cat *plans.Catalog, req setAccountPlanRequest) string { |
| + p, ok := cat.Plans[req.Plan] |
| + if !ok { |
| + return fmt.Sprintf(planCopyUnknownPlan, req.Plan) |
| + } |
| + names := make([]string, 0, len(req.Packs)) |
| + for name := range req.Packs { |
| + names = append(names, name) |
| + } |
| + slices.Sort(names) |
| + for _, name := range names { |
| + pk, ok := cat.Packs[name] |
| + if !ok { |
| + return fmt.Sprintf(planCopyUnknownPack, name) |
| + } |
| + if !slices.Contains(p.Packs, name) { |
| + return fmt.Sprintf(planCopyPackNotOffered, req.Plan, name) |
| + } |
| + if q := req.Packs[name]; q < 1 || q > pk.MaxQuantity { |
| + return fmt.Sprintf(planCopyPackQuantity, name, pk.MaxQuantity) |
| + } |
| + } |
| + return "" |
| +} |
| + |
| +func (s *Server) accountPlanReplyFor(acct *Account) (accountPlanReply, error) { |
| + cat := s.cfg.PlanCatalog |
| + row, err := s.store.AccountPlan(acct.ID) |
| + if err != nil { |
| + return accountPlanReply{}, err |
| + } |
| + reply := accountPlanReply{Account: acct.Sqid, CatalogID: cat.ID(), Plan: cat.DefaultPlan, |
| + Packs: map[string]int{}, Source: PlanSourceDefault} |
| + if row != nil { |
| + reply.Plan, reply.Packs, reply.Source = row.Plan, row.Packs, row.Source |
| + reply.Sponsored, reply.Reason, reply.UpdatedBy = row.Sponsored, row.Reason, row.UpdatedBy |
| + reply.Revision, reply.PublishedRevision = row.Revision, row.PublishedRevision |
| + if row.PublishedCatalog != cat.ID() { |
| + reply.PublishedRevision = 0 |
| + } |
| + } |
| + if l, err := cat.Effective(reply.Plan, reply.Packs, reply.Sponsored); err != nil { |
| + reply.LimitsError = err.Error() |
| + } else { |
| + reply.Limits = &l |
| + } |
| + return reply, nil |
| +} |
diff --git a/internal/console/apiaccountplan_copy.go b/internal/console/apiaccountplan_copy.go| new file mode 100644 |
| index 000000000..e9e62051f |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan_copy.go |
| @@ -0,0 +1,14 @@ |
| +// Code generated from the approved copy review of 2026-10-20 (plans 1). Apply |
| +// later edits through copy review, not by hand. |
| + |
| +package console |
| + |
| +const ( |
| + planCopyNoCatalog = "plans are off on this deployment. set CARLOS_PLAN_CATALOG on the console to turn them on" |
| + planCopyReasonRequired = "a reason is required to change an account's plan" |
| + planCopyUnknownPlan = "there is no %q plan in this deployment's catalog" |
| + planCopyUnknownPack = "there is no %q pack in this deployment's catalog" |
| + planCopyPackNotOffered = "the %s plan does not offer the %s pack" |
| + planCopyPackQuantity = "the %s pack can be bought 1 to %d times" |
| + planCopySaveFailed = "could not save the plan. try again" |
| +) |
diff --git a/internal/console/apiaccountplan_test.go b/internal/console/apiaccountplan_test.go| new file mode 100644 |
| index 000000000..b4abe289f |
| --- /dev/null |
| +++ b/internal/console/apiaccountplan_test.go |
| @@ -0,0 +1,182 @@ |
| +package console |
| + |
| +import ( |
| + "net/http" |
| + "testing" |
| + "time" |
| +) |
| + |
| +func accountPlanURL(fx *consoleFixture, sqid string) string { |
| + return fx.ts.URL + "/api/cli/accounts/" + sqid + "/plan" |
| +} |
| + |
| +func planOperatorToken(t *testing.T, fx *consoleFixture, address, code string) string { |
| + t.Helper() |
| + if err := fx.st.SetOperator(address, true); err != nil { |
| + t.Fatal(err) |
| + } |
| + return cliToken(t, fx, address, code) |
| +} |
| + |
| +func planNum(t *testing.T, m map[string]any, key string) float64 { |
| + t.Helper() |
| + v, ok := m[key].(float64) |
| + if !ok { |
| + t.Fatalf("%s missing or not a number in %v", key, m) |
| + } |
| + return v |
| +} |
| + |
| +func TestAccountPlanDoorIsOperatorOnly(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + owner := "owner" + acct.Sqid + "@example.com" |
| + token := cliToken(t, fx, owner, "code-plan-owner") |
| + if status, body := getRaw(t, accountPlanURL(fx, acct.Sqid), token); status != http.StatusNotFound || body != "404 page not found\n" { |
| + t.Fatalf("GET by the account's owner = %d %q, want the stdlib 404", status, body) |
| + } |
| + body := map[string]any{"plan": "personal", "reason": "r"} |
| + if status, out := postRaw(t, accountPlanURL(fx, acct.Sqid), body, token); status != http.StatusNotFound || out != "404 page not found\n" { |
| + t.Fatalf("POST by the account's owner = %d %q, want the stdlib 404", status, out) |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("refused POST wrote %+v", row) |
| + } |
| +} |
| + |
| +func TestAccountPlanDoorWithoutACatalog(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-nocat") |
| + status, out := getJSON(t, accountPlanURL(fx, acct.Sqid), token) |
| + if status != http.StatusConflict || out["error"] == nil { |
| + t.Fatalf("GET with no catalog = %d %v, want 409 with an error", status, out) |
| + } |
| + if status, _ := postJSON(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "free", "reason": "r"}, token); status != http.StatusConflict { |
| + t.Fatalf("POST with no catalog = %d, want 409", status) |
| + } |
| +} |
| + |
| +func TestAccountPlanDoorUnknownAccount(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-unknown") |
| + if status, _ := getRaw(t, accountPlanURL(fx, "zzzzzz"), token); status != http.StatusNotFound { |
| + t.Fatalf("unknown account = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestAccountPlanGetDefaults(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-get") |
| + status, out := getJSON(t, accountPlanURL(fx, acct.Sqid), token) |
| + if status != http.StatusOK { |
| + t.Fatalf("GET = %d %v", status, out) |
| + } |
| + if out["plan"] != "free" || out["source"] != PlanSourceDefault || planNum(t, out, "revision") != 0 || |
| + out["catalog_id"] != fx.srv.cfg.PlanCatalog.ID() || out["account"] != acct.Sqid { |
| + t.Fatalf("reply %v", out) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 3 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetAndPublish(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-set") |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), |
| + map[string]any{"plan": "personal", "packs": map[string]int{"apps": 1}, "reason": "upgraded"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if out["source"] != PlanSourceOperator || planNum(t, out, "revision") != 1 || planNum(t, out, "published_revision") != 1 || |
| + out["reason"] != "upgraded" { |
| + t.Fatalf("reply %v", out) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 30 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| + r, _ := readLimits(t, fx, acct.Sqid) |
| + if r == nil || r.Revision != 1 || r.Plan != "personal" || r.Limits.RunningThings != 30 { |
| + t.Fatalf("bucket record %+v", r) |
| + } |
| + row, _ := fx.st.AccountPlan(acct.ID) |
| + if row.UpdatedBy != "ops@keymail.dev" { |
| + t.Fatalf("updated_by %q", row.UpdatedBy) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetSponsored(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-sponsor") |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), |
| + map[string]any{"plan": "free", "sponsored": map[string]any{"running_things": 12}, "reason": "existing account"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if out["source"] != PlanSourceSponsored { |
| + t.Fatalf("source %v", out["source"]) |
| + } |
| + if limits := block(t, out, "limits"); planNum(t, limits, "running_things") != 12 { |
| + t.Fatalf("limits %v", limits) |
| + } |
| + if sp := block(t, out, "sponsored"); planNum(t, sp, "running_things") != 12 { |
| + t.Fatalf("sponsored %v", sp) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetRefuses(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-refuse") |
| + cases := map[string]map[string]any{ |
| + "unknown plan": {"plan": "gold", "reason": "r"}, |
| + "unknown pack": {"plan": "personal", "packs": map[string]int{"seats": 1}, "reason": "r"}, |
| + "pack not offered": {"plan": "free", "packs": map[string]int{"apps": 1}, "reason": "r"}, |
| + "zero quantity": {"plan": "personal", "packs": map[string]int{"apps": 0}, "reason": "r"}, |
| + "negative": {"plan": "personal", "packs": map[string]int{"apps": -1}, "reason": "r"}, |
| + "above max": {"plan": "personal", "packs": map[string]int{"apps": 21}, "reason": "r"}, |
| + "no reason": {"plan": "personal"}, |
| + "blank reason": {"plan": "personal", "reason": " "}, |
| + } |
| + for name, body := range cases { |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), body, token) |
| + if status != http.StatusBadRequest || out["error"] == nil { |
| + t.Errorf("%s: %d %v, want 400 with an error", name, status, out) |
| + } |
| + } |
| + if row, _ := fx.st.AccountPlan(acct.ID); row != nil { |
| + t.Fatalf("a refused POST wrote %+v", row) |
| + } |
| +} |
| + |
| +func TestAccountPlanSetSurvivesABucketFailure(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.PlanCatalog = examplePlanCatalog(t) |
| + acct := planAccount(t, fx, 501) |
| + token := planOperatorToken(t, fx, "ops@keymail.dev", "code-plan-bucket") |
| + fp := &planFailingPuts{Store: fx.rel} |
| + fp.n.Store(1000) |
| + fx.srv.cfg.Releases = fp |
| + status, out := postJSON(t, accountPlanURL(fx, acct.Sqid), map[string]any{"plan": "personal", "reason": "r"}, token) |
| + if status != http.StatusOK { |
| + t.Fatalf("POST = %d %v", status, out) |
| + } |
| + if planNum(t, out, "published_revision") >= planNum(t, out, "revision") { |
| + t.Fatalf("claimed published although the write failed: %v", out) |
| + } |
| + fp.n.Store(0) |
| + fx.srv.planLimitsPass(t.Context(), time.Now()) |
| + if r, _ := readLimits(t, fx, acct.Sqid); r == nil || r.Plan != "personal" { |
| + t.Fatalf("the next pass did not publish: %+v", r) |
| + } |
| +} |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index efcdb15d7..e44fd7a25 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -929,6 +929,8 @@ func (s *Server) registerCLIRoutes() { |
| // resolve their target without requiring operator membership in it. |
| s.cliRoute("POST /api/cli/accounts/{account}/store/apply", svcrole.Set{}, s.handleAPIStoreApply) |
| s.cliRoute("POST /api/cli/accounts/{account}/store/approve", svcrole.Set{}, s.handleAPIStoreApprove) |
| + s.cliRoute("GET /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanGet) |
| + s.cliRoute("POST /api/cli/accounts/{account}/plan", svcrole.Set{}, s.handleAPIAccountPlanSet) |
| s.cliRoute("POST /api/cli/accounts/{account}/store/decline", svcrole.Set{}, s.handleAPIStoreDecline) |
| s.cliRoute("GET /api/cli/store/approvals", svcrole.Set{}, s.handleAPIStoreApprovals) |
| // The scan family (internal/console/apiscan.go). `scan` itself is the |
diff --git a/internal/console/claim_invariant_fixtures_test.go b/internal/console/claim_invariant_fixtures_test.go| index 8c750d87c..c1d35d51c 100644 |
| --- a/internal/console/claim_invariant_fixtures_test.go |
| +++ b/internal/console/claim_invariant_fixtures_test.go |
| @@ -770,6 +770,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route GET /api/cli/status/instances": claimRead("operator live tenancy view"), |
| "route GET /api/cli/status/layout": claimRead("operator layout view"), |
| "route GET /api/cli/store/approvals": claimRead("operator entitlement queue"), |
| + "route GET /api/cli/accounts/{account}/plan": claimRead("operator view of an account's plan and limits"), |
| "route GET /api/cli/system/antivirus": claimRead("reads the deployment scanning setting"), |
| "route GET /api/cli/system/geoip": claimRead("operator view of the GeoIP pointer, box heartbeats and feed status (control/geoip/, status/, edge files); writes nothing"), |
| "route GET /api/cli/system/jobs/{id}": claimRead("reads a system update job"), |
| @@ -1144,6 +1145,7 @@ var claimFixtures = map[string]claimFixture{ |
| "route POST /accounts/new": claimNonApp("creates an account and its owner membership"), |
| "route POST /api/cli/accounts/{account}/store/apply": claimNonApp("applies for the account's storage entitlement"), |
| "route POST /api/cli/accounts/{account}/store/approve": claimNonApp("operator approves an account's storage entitlement"), |
| + "route POST /api/cli/accounts/{account}/plan": claimNonApp("operator sets an account's plan, packs and sponsored limits; names no app"), |
| "route POST /api/cli/accounts/{account}/store/decline": claimNonApp("operator declines an account's storage entitlement"), |
| "route POST /api/cli/fleets/{account}/new": claimNonApp("creates a customer fleet in the registry"), |
| "route POST /api/cli/fleets/{account}/{fleet}/boxes": claimNonApp("enrolls a fleet box"), |
diff --git a/internal/console/claim_invariant_probes_nonapp_test.go b/internal/console/claim_invariant_probes_nonapp_test.go| index d9aa1c8ae..8f3c7651f 100644 |
| --- a/internal/console/claim_invariant_probes_nonapp_test.go |
| +++ b/internal/console/claim_invariant_probes_nonapp_test.go |
| @@ -19,6 +19,7 @@ import ( |
| "github.com/carlosframework/platform/internal/economics" |
| "github.com/carlosframework/platform/internal/geoip" |
| "github.com/carlosframework/platform/internal/infraview" |
| + "github.com/carlosframework/platform/internal/plans" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/seeds" |
| "github.com/carlosframework/platform/internal/telemetry" |
| @@ -600,6 +601,18 @@ var claimNonAppProbes = map[string]claimProbe{ |
| return "" |
| }, |
| }, |
| + "route POST /api/cli/accounts/{account}/plan": { |
| + setup: func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + do: claimAPIReply(http.MethodPost, "/api/cli/accounts/bab/plan", `{"plan":"personal","reason":"census"}`), |
| + body: []string{`"plan":"personal"`}, |
| + effect: func(t *testing.T, cx *claimCase, r claimReply) string { |
| + var rec plans.Record |
| + if !cx.bucketJSON(plans.RecordKey("bab"), &rec) || rec.Plan != "personal" { |
| + return "the personal plan was not published for bab" |
| + } |
| + return "" |
| + }, |
| + }, |
| "route POST /api/cli/accounts/{account}/store/approve": { |
| setup: func(t *testing.T, cx *claimCase) { |
| cx.mustAPI(t, http.MethodPost, "/api/cli/accounts/bab/store/apply", `{"note":"census"}`, http.StatusOK) |
diff --git a/internal/console/claim_invariant_probes_read_test.go b/internal/console/claim_invariant_probes_read_test.go| index c801bd0b6..10e635c44 100644 |
| --- a/internal/console/claim_invariant_probes_read_test.go |
| +++ b/internal/console/claim_invariant_probes_read_test.go |
| @@ -225,6 +225,8 @@ var claimReadProbes = map[string]claimProbe{ |
| "route GET /api/cli/status/instances": claimAPIRead(nil, "/api/cli/status/instances", `"instances"`), |
| "route GET /api/cli/status/layout": claimAPIRead(nil, "/api/cli/status/layout", `"boxes"`), |
| "route GET /api/cli/store/approvals": claimAPIRead(claimWorldApply, "/api/cli/store/approvals", `"applications"`, claimFxOwner), |
| + "route GET /api/cli/accounts/{account}/plan": claimAPIRead(func(t *testing.T, cx *claimCase) { cx.srv.cfg.PlanCatalog = examplePlanCatalog(t) }, |
| + "/api/cli/accounts/bab/plan", `"plan":"free"`), |
| "route GET /api/cli/system/antivirus": claimAPIRead(nil, "/api/cli/system/antivirus", `"wired":true`), |
| "route GET /api/cli/system/geoip": claimAPIRead(claimWorldGeoip, "/api/cli/system/geoip", `"pointer"`, "GeoIP2-City"), |
| "route GET /api/cli/system/jobs/{id}": { |
diff --git a/internal/console/testdata/claim-routes.txt b/internal/console/testdata/claim-routes.txt| index 614166d94..87bee1a32 100644 |
| --- a/internal/console/testdata/claim-routes.txt |
| +++ b/internal/console/testdata/claim-routes.txt |
| @@ -37,6 +37,7 @@ route GET /a/{sqid}/team |
| route GET /a/{sqid}/trash |
| route GET /a/{sqid}/{$} |
| route GET /accounts/new |
| +route GET /api/cli/accounts/{account}/plan |
| route GET /api/cli/apps |
| route GET /api/cli/apps/{account}/list |
| route GET /api/cli/apps/{account}/{app}/analytics |
| @@ -156,6 +157,7 @@ route POST /a/{sqid}/store/apply |
| route POST /a/{sqid}/trash/delete |
| route POST /a/{sqid}/trash/restore |
| route POST /accounts/new |
| +route POST /api/cli/accounts/{account}/plan |
| route POST /api/cli/accounts/{account}/store/apply |
| route POST /api/cli/accounts/{account}/store/approve |
| route POST /api/cli/accounts/{account}/store/decline |