carlos / platform Public

console: plan creation checks

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 149076db507d7ed8d87398b0b4da0a8748867771 parent 8fd9d4e
4 files changed, +413 −0
  • internal/console/accountplans.go +26 −0
  • internal/console/plancaps.go +192 −0
  • internal/console/plancaps_copy.go +6 −0
  • internal/console/plancaps_test.go +189 −0
diff --git a/internal/console/accountplans.go b/internal/console/accountplans.go
index 2a2cac308..4917eb1b7 100644
--- a/internal/console/accountplans.go
+++ b/internal/console/accountplans.go
@@ -156,3 +156,29 @@ func (s *Store) MarkPlanPublished(accountID, revision int64, catalogID string) e
WHERE account_id = ? AND revision = ?`, revision, catalogID, accountID, revision)
return err
}
+
+// LiveAppCount counts the account's running apps for the creation caps:
+// live rows and rows being restored (temporary apps included).
+func (s *Store) LiveAppCount(accountID int64) (int, error) {
+ var n int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND (deleted_at = 0 OR restoring = 1)`, accountID).Scan(&n)
+ return n, err
+}
+
+// RestoringAppsIn names the account's apps that are mid-restore.
+func (s *Store) RestoringAppsIn(accountID int64) ([]string, error) {
+ rows, err := s.db.Query(`SELECT app FROM apps WHERE account_id = ? AND deleted_at != 0 AND restoring = 1 ORDER BY app`, accountID)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []string
+ for rows.Next() {
+ var a string
+ if err := rows.Scan(&a); err != nil {
+ return nil, err
+ }
+ out = append(out, a)
+ }
+ return out, rows.Err()
+}
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
new file mode 100644
index 000000000..64617218c
--- /dev/null
+++ b/internal/console/plancaps.go
@@ -0,0 +1,192 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// Creation caps (plans-caps-packs spec §5.1, §8.1, §8.4; plans 3a). Every
+// check passes when plans are off (EffectiveLimits nil), so a deployment
+// without a catalog sees no change. A refusal is a *planCapError carrying
+// approved copy; JSON doors answer it 409 {"code":"cap"} and browser doors
+// flash it. Creation refusals have no grace: the cap refuses what is new and
+// never touches what already runs.
+
+// planCapError carries the CLI wording (Error) and, where a browser door can
+// meet it, the browser wording (Browser).
+type planCapError struct{ msg, browser string }
+
+func (e *planCapError) Error() string { return e.msg }
+
+// Browser is the sentence-case wording for a browser door.
+func (e *planCapError) Browser() string {
+ if e.browser != "" {
+ return e.browser
+ }
+ return e.msg
+}
+
+// capMessage formats approved copy by its review id (plancaps_copy.go).
+func capMessage(id string, args ...any) string {
+ format, ok := capCopy[id]
+ if !ok {
+ format = id
+ }
+ return fmt.Sprintf(format, args...)
+}
+
+// runningThings is the account's live apps (temporary and restoring
+// included) plus the instance records of those apps. Trashed apps and their
+// instances do not count. One key-only list of the account's prefix.
+func (s *Server) runningThings(ctx context.Context, acct *Account) (int, error) {
+ apps, err := s.store.LiveAppCount(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ live, err := s.store.AppsIn(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ restoring, err := s.store.RestoringAppsIn(acct.ID)
+ if err != nil {
+ return 0, err
+ }
+ liveSet := map[string]bool{}
+ for _, a := range append(live, restoring...) {
+ liveSet[a] = true
+ }
+ if s.cfg.Releases == nil {
+ return apps, nil
+ }
+ keys, err := s.cfg.Releases.List(ctx, "apps/"+acct.Sqid+"/")
+ if err != nil {
+ return 0, err
+ }
+ known := map[string]bool{acct.Sqid: true}
+ instances := 0
+ for _, k := range keys {
+ if _, app, _, ok := provision.ParseRecordKey(k, known); ok && liveSet[app] {
+ instances++
+ }
+ }
+ return apps + instances, nil
+}
+
+// checkRunningThings refuses when adding more running things would pass
+// the account's cap.
+func (s *Server) checkRunningThings(ctx context.Context, acct *Account, adding int) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil {
+ return err
+ }
+ n, err := s.runningThings(ctx, acct)
+ if err != nil {
+ return err
+ }
+ if n+adding > l.RunningThings {
+ return &planCapError{capMessage("cli.cap.running_things", l.RunningThings), capMessage("browser.cap.running_things", l.RunningThings)}
+ }
+ return nil
+}
+
+// checkRestore is checkRunningThings for restoring app, with the restore
+// wording.
+func (s *Server) checkRestore(ctx context.Context, acct *Account, app string) error {
+ err := s.checkRunningThings(ctx, acct, 1)
+ if !isPlanCap(err) {
+ return err
+ }
+ l, _ := s.EffectiveLimits(acct)
+ return &planCapError{capMessage("cli.cap.restore", app, l.RunningThings), capMessage("browser.cap.restore", app, l.RunningThings)}
+}
+
+// checkSidecars refuses a new worker when app already has as many as the
+// plan allows.
+func (s *Server) checkSidecars(ctx context.Context, acct *Account, app string) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil {
+ return err
+ }
+ if s.cfg.Releases == nil {
+ return nil
+ }
+ keys, err := s.cfg.Releases.List(ctx, provision.SidecarsPrefix(acct.Sqid, app))
+ if err != nil {
+ return err
+ }
+ n := 0
+ for _, k := range keys {
+ if strings.HasSuffix(k, ".json") {
+ n++
+ }
+ }
+ if n >= l.SidecarsPerApp {
+ return &planCapError{msg: capMessage("cli.cap.sidecars", app, l.SidecarsPerApp)}
+ }
+ return nil
+}
+
+// checkNewFreeAccount refuses a person a further account on a free plan once
+// they own the catalog's allowance of them (spec §8.1; creation only).
+func (s *Server) checkNewFreeAccount(addr string) error {
+ cat := s.cfg.PlanCatalog
+ if cat == nil || cat.FreeAccountsPerPerson <= 0 {
+ return nil
+ }
+ if p, ok := cat.Plans[cat.DefaultPlan]; ok && p.Paid {
+ return nil // new accounts start on a paid plan here: nothing to cap
+ }
+ accts, err := s.store.AccountsFor(addr)
+ if err != nil {
+ return err
+ }
+ free := 0
+ for _, a := range accts {
+ if a.Role != RoleOwner {
+ continue
+ }
+ plan := cat.DefaultPlan
+ row, err := s.store.AccountPlan(a.ID)
+ if err != nil {
+ return err
+ }
+ if row != nil {
+ plan = row.Plan
+ }
+ if p, ok := cat.Plans[plan]; !ok || !p.Paid {
+ free++
+ }
+ }
+ if free >= cat.FreeAccountsPerPerson {
+ return &planCapError{capMessage("cli.cap.free_account"), capMessage("browser.cap.free_account")}
+ }
+ return nil
+}
+
+// checkFleets refuses a fleet on a plan without fleets.
+func (s *Server) checkFleets(acct *Account) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil || l.Fleets {
+ return err
+ }
+ return &planCapError{msg: capMessage("cli.cap.fleets")}
+}
+
+// checkCustomDomains refuses a custom domain on a plan without them.
+// Platform hostnames are not custom domains; callers check those first.
+func (s *Server) checkCustomDomains(acct *Account) error {
+ l, err := s.EffectiveLimits(acct)
+ if err != nil || l == nil || l.CustomDomains {
+ return err
+ }
+ return &planCapError{capMessage("cli.cap.custom_domain"), capMessage("browser.cap.custom_domain")}
+}
+
+func isPlanCap(err error) bool {
+ var c *planCapError
+ return errors.As(err, &c)
+}
diff --git a/internal/console/plancaps_copy.go b/internal/console/plancaps_copy.go
new file mode 100644
index 000000000..d5550283f
--- /dev/null
+++ b/internal/console/plancaps_copy.go
@@ -0,0 +1,6 @@
+package console
+
+// capCopy holds the creation-cap refusals by copy-review id. It is
+// generated from the approved review (plans 3a); until then it is empty and
+// capMessage falls back to the id.
+var capCopy = map[string]string{}
diff --git a/internal/console/plancaps_test.go b/internal/console/plancaps_test.go
new file mode 100644
index 000000000..ca7b920e5
--- /dev/null
+++ b/internal/console/plancaps_test.go
@@ -0,0 +1,189 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/plans"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func capsApp(t *testing.T, fx *consoleFixture, acct *Account, app string) {
+ t.Helper()
+ if _, err := fx.st.CreateAppIn(acct.ID, app, "owner@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func capsInstance(t *testing.T, fx *consoleFixture, acct *Account, app, host string) {
+ t.Helper()
+ if _, err := fx.rel.Put(context.Background(), provision.RecordKey(acct.Sqid, app, host), []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func capsSidecar(t *testing.T, fx *consoleFixture, acct *Account, app, name string) {
+ t.Helper()
+ if _, err := fx.rel.Put(context.Background(), provision.SidecarRecordKey(acct.Sqid, app, name), []byte(`{}`), ""); err != nil {
+ t.Fatal(err)
+ }
+}
+
+func isCap(err error) bool {
+ var c *planCapError
+ return errors.As(err, &c)
+}
+
+func TestPlanChecksPassWithoutACatalog(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ for i := range 5 {
+ capsApp(t, fx, acct, "app"+string(rune('a'+i)))
+ }
+ capsSidecar(t, fx, acct, "appa", "w1")
+ capsSidecar(t, fx, acct, "appa", "w2")
+ ctx := context.Background()
+ for name, err := range map[string]error{
+ "running things": fx.srv.checkRunningThings(ctx, acct, 1),
+ "sidecars": fx.srv.checkSidecars(ctx, acct, "appa"),
+ "free account": fx.srv.checkNewFreeAccount("owner" + acct.Sqid + "@example.com"),
+ "fleets": fx.srv.checkFleets(acct),
+ "custom domains": fx.srv.checkCustomDomains(acct),
+ } {
+ if err != nil {
+ t.Errorf("%s with no catalog: %v", name, err)
+ }
+ }
+}
+
+func TestLiveAppCountCountsRestoringNotTrashed(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ for _, app := range []string{"live", "gone", "coming-back"} {
+ capsApp(t, fx, acct, app)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE apps SET deleted_at = ? WHERE account_id = ? AND app IN ('gone', 'coming-back')`, planNow.Unix(), acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := fx.st.db.Exec(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = 'coming-back'`, acct.ID); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := fx.st.LiveAppCount(acct.ID); err != nil || n != 2 {
+ t.Fatalf("LiveAppCount = %d, %v; want 2 (live + restoring)", n, err)
+ }
+}
+
+func TestRunningThingsCountsAppsAndInstances(t *testing.T) {
+ fx := newConsoleFixture(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "shop")
+ capsApp(t, fx, acct, "blog")
+ capsApp(t, fx, acct, "old")
+ capsInstance(t, fx, acct, "shop", "a.shop.example.com")
+ capsInstance(t, fx, acct, "shop", "b.shop.example.com")
+ capsInstance(t, fx, acct, "blog", "c.blog.example.com")
+ capsInstance(t, fx, acct, "old", "d.old.example.com")
+ if err := fx.st.TrashAppRow(acct.ID, "old", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if n, err := fx.srv.runningThings(context.Background(), acct); err != nil || n != 5 {
+ t.Fatalf("runningThings = %d, %v; want 5 (2 apps + 3 instances; the trashed app and its instance do not count)", n, err)
+ }
+}
+
+func TestRunningThingsCapAtAndBelow(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "one")
+ capsApp(t, fx, acct, "two")
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil {
+ t.Fatalf("at 2 of 3: %v", err)
+ }
+ capsApp(t, fx, acct, "three")
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); !isCap(err) {
+ t.Fatalf("at 3 of 3: %v, want a plan cap", err)
+ }
+}
+
+func TestRunningThingsCapUsesEffectiveLimits(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ if _, err := fx.st.SetAccountPlan(acct.ID, "free", nil, PlanSourceSponsored, &plans.Limits{RunningThings: 300}, "existing account", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ for i := range 4 {
+ capsApp(t, fx, acct, "app"+string(rune('a'+i)))
+ }
+ if err := fx.srv.checkRunningThings(context.Background(), acct, 1); err != nil {
+ t.Fatalf("sponsored 300, running 4: %v", err)
+ }
+}
+
+func TestSidecarCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ capsApp(t, fx, acct, "shop")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil {
+ t.Fatalf("free, no sidecars: %v", err)
+ }
+ capsSidecar(t, fx, acct, "shop", "w1")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) {
+ t.Fatalf("free, one sidecar: %v, want a plan cap", err)
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ for _, n := range []string{"w2", "w3", "w4"} {
+ capsSidecar(t, fx, acct, "shop", n)
+ }
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); err != nil {
+ t.Fatalf("personal, four sidecars: %v", err)
+ }
+ capsSidecar(t, fx, acct, "shop", "w5")
+ if err := fx.srv.checkSidecars(context.Background(), acct, "shop"); !isCap(err) {
+ t.Fatalf("personal, five sidecars: %v, want a plan cap", err)
+ }
+}
+
+func TestNewFreeAccountCap(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ owner := "owner" + acct.Sqid + "@example.com"
+ if err := fx.srv.checkNewFreeAccount(owner); !isCap(err) {
+ t.Fatalf("owner of one free account: %v, want a plan cap", err)
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.checkNewFreeAccount(owner); err != nil {
+ t.Fatalf("owner of one paid account: %v", err)
+ }
+ member := "member@example.com"
+ other := planAccount(t, fx, 502)
+ if err := fx.st.AddMember(other.ID, member, RoleMember, planNow); err != nil {
+ t.Fatal(err)
+ }
+ if err := fx.srv.checkNewFreeAccount(member); err != nil {
+ t.Fatalf("member, not owner, of a free account: %v", err)
+ }
+}
+
+func TestFleetsAndCustomDomainsFollowThePlan(t *testing.T) {
+ fx := newConsoleFixture(t)
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct := planAccount(t, fx, 501)
+ if !isCap(fx.srv.checkFleets(acct)) || !isCap(fx.srv.checkCustomDomains(acct)) {
+ t.Fatal("free plan allowed fleets or custom domains")
+ }
+ if _, err := fx.st.SetAccountPlan(acct.ID, "personal", nil, PlanSourceOperator, nil, "r", "ops@example.com", planNow); err != nil {
+ t.Fatal(err)
+ }
+ if fx.srv.checkFleets(acct) != nil || fx.srv.checkCustomDomains(acct) != nil {
+ t.Fatal("personal plan refused fleets or custom domains")
+ }
+}