package console import ( "context" "errors" "fmt" "net/http" "strings" "time" "github.com/carlosframework/platform/internal/provision" "github.com/carlosframework/platform/internal/registry" "github.com/carlosframework/platform/internal/release" "github.com/carlosframework/platform/internal/schedule" ) // 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 { // Restoring brings back the app and its retained instance records // (Astra plans-3a finding 1). n, err := s.appInstanceCount(ctx, acct, app) if err != nil { return err } err = s.checkRunningThings(ctx, acct, 1+n) 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) } // planLock takes the account lock around a capped door's count and write, // so two concurrent creates cannot both pass. With plans off it takes // nothing, leaving those deployments exactly as they were. Never call it // from a door that already holds lockAccount (/new-temporary, restore). func (s *Server) planLock(acct *Account) func() { if s.cfg.PlanCatalog == nil { return func() {} } return s.lockAccount(acct.Sqid) } // customDomain reports whether host is a customer's own domain, the kind a // free plan may not attach: an open host outside the deployment's apps // domain. The account's own . addresses and the // operator's platform zones are not custom domains. func (s *Server) customDomain(host, account string) bool { if s.domainHostPolicy(host, account) != hostOpen { return false } if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) { return false } return true } // customDomainRefusal is checkCustomDomains as a door refusal, for the // shared attach paths; nil when the attach may go ahead. func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal { if !s.customDomain(host, acct.Sqid) { return nil } if err := s.checkCustomDomains(acct); err != nil { if isPlanCap(err) { return &doorRefusal{http.StatusConflict, err.Error()} } return &doorRefusal{http.StatusBadGateway, planCheckFailedCopy} } return nil } // capOrError is a door refusal as a JSON body, with "code":"cap" when it is // a plan cap (the only 409 customDomainRefusal returns). func capOrError(ref *doorRefusal) map[string]any { body := map[string]any{"error": ref.Msg} if ref.Status == http.StatusConflict { body["code"] = "cap" } return body } // checkCreateApp is the running-things check for creating (or adopting) app. func (s *Server) checkCreateApp(ctx context.Context, acct *Account, app string) error { // Adopting an app whose objects already exist brings its instance records // with it (Astra plans-3a finding 3); a brand-new name has none. n, err := s.appInstanceCount(ctx, acct, app) if err != nil { return err } return s.checkRunningThings(ctx, acct, 1+n) } // appInstanceCount counts app's instance records in the bucket, whether or // not the app has a live console row. func (s *Server) appInstanceCount(ctx context.Context, acct *Account, app string) (int, error) { if s.cfg.Releases == nil { return 0, nil } keys, err := s.cfg.Releases.List(ctx, provision.RecordsPrefix(acct.Sqid, app)) if err != nil { return 0, err } known := map[string]bool{acct.Sqid: true} n := 0 for _, k := range keys { if _, a, _, ok := provision.ParseRecordKey(k, known); ok && a == app { n++ } } return n, nil } // planPersonLock serialises a person's account creates when plans are on, // so concurrent requests cannot each see room for one more free account // (Astra plans-3a finding 2). The key cannot collide with an account sqid. func (s *Server) planPersonLock(addr string) func() { if s.cfg.PlanCatalog == nil { return func() {} } return s.acctLocks.lock("person:" + addr) } // checkDaemon refuses a release that declares the daemon capability, which // keeps an app running all the time (plans 3b; spec §5.2). func (s *Server) checkDaemon(acct *Account, app string) error { l, err := s.EffectiveLimits(acct) if err != nil || l == nil { return err } if own, err := s.onOwnBoxes(acct, app); err != nil || own { return err } return &planCapError{msg: capMessage("cli.cap.daemon")} } // checkDaemonRelease is checkDaemon for a release already shipped: a // daemon release shipped before plans were on must not be promoted onto // shared boxes under one (Astra plans-3b finding 2). A release without the // capability passes. func (s *Server) checkDaemonRelease(acct *Account, app string, m *release.Manifest) error { if m == nil || m.Capabilities == nil || !m.Capabilities.Daemon { return nil } return s.checkDaemon(acct, app) } // checkDaemonVersion is checkDaemonRelease for a version named by its // channel history, as a rollback target is (Astra plans-3b round 2 // finding 2). It reads the manifest only when a plan applies to an app on // shared boxes, so with plans off a rollback depends on nothing it did not // before (round 3 finding 1). func (s *Server) checkDaemonVersion(ctx context.Context, acct *Account, app, version string) error { l, err := s.EffectiveLimits(acct) if err != nil || l == nil { return err } if own, err := s.onOwnBoxes(acct, app); err != nil || own { return err } m, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, version) if err != nil { return err } if m.Capabilities == nil || !m.Capabilities.Daemon { return nil } return &planCapError{msg: capMessage("cli.cap.daemon")} } // planGuardError is a rollback guard that could not read what it checks. // Rollback wraps every hook failure in an AuthorizationError, so the doors // tell this apart from a deploy-scope refusal and answer it as a failed // plan check (round 3 finding 2). type planGuardError struct{ err error } func (e *planGuardError) Error() string { return e.err.Error() } func (e *planGuardError) Unwrap() error { return e.err } // rollbackDaemonGuard is the authorize hook a rollback door passes so the // daemon rule holds for the version the rollback actually writes. The door // checked the target it read, but the pointer can move before the write. // The hook runs after Rollback's own pointer read and before its // conditional write, so a pointer that moved in between fails that write, // and one that did not has the History[0] this re-read sees. nil when plans // are off or the app is on its own fleet. func (s *Server) rollbackDaemonGuard(ctx context.Context, acct *Account, app, channel string) (func(*release.Pipeline) error, error) { l, err := s.EffectiveLimits(acct) if err != nil || l == nil { return nil, err } if own, err := s.onOwnBoxes(acct, app); err != nil || own { return nil, err } return func(*release.Pipeline) error { cur, err := release.Channel(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app, channel) if err != nil { return &planGuardError{err} } if len(cur.History) == 0 { return nil // Rollback refuses this itself } m, err := release.GetManifest(ctx, s.cfg.Releases, acct.Sqid, app, cur.History[0]) if err != nil { return &planGuardError{err} } if m.Capabilities == nil || !m.Capabilities.Daemon { return nil } return &planCapError{msg: capMessage("cli.cap.daemon")} }, nil } // bothAuthorize runs a then b, either of which may be nil. func bothAuthorize(a, b func(*release.Pipeline) error) func(*release.Pipeline) error { switch { case a == nil: return b case b == nil: return a } return func(p *release.Pipeline) error { if err := a(p); err != nil { return err } return b(p) } } // checkAlwaysOnCreate refuses a new sidecar that would never sleep (its // app's manifest says hibernate:false) on shared boxes (plans 3b; Astra // plans-3b finding 3). A new sidecar of an app on its own fleet lands there. func (s *Server) checkAlwaysOnCreate(acct *Account, app string) error { l, err := s.EffectiveLimits(acct) if err != nil || l == nil { return err } if own, err := s.onOwnBoxes(acct, app); err != nil || own { return err } return &planCapError{msg: capMessage("cli.cap.always_on")} } // checkAlwaysOn refuses settings that would keep an app's sidecars awake // for good (plans 3b; spec §5.2). On its own fleet an app may, but only once // every existing sidecar runs there too: placing an app does not move its // sidecar records (Astra plans-3b finding 4). func (s *Server) checkAlwaysOn(ctx context.Context, acct *Account, app string) error { if err := s.checkAlwaysOnCreate(acct, app); !isPlanCap(err) { if err != nil { return err } if s.cfg.Releases == nil { return nil } l, _ := s.EffectiveLimits(acct) if l == nil { return nil } recs, err := provision.ListSidecarRecordsStrict(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { return err } if len(recs) == 0 { return nil } reg, err := openRegistry(s.cfg.Registry) if err != nil { return err } for _, rec := range recs { own, err := ownsFleet(reg, acct, rec.Region) if err != nil { return err } if !own { return &planCapError{msg: capMessage("cli.cap.always_on")} } } return nil } return &planCapError{msg: capMessage("cli.cap.always_on")} } // checkSchedule refuses a schedule that would run more often than the // plan's minimum interval (plans 3b; spec §5.2). Cron lines are held to // their shortest gap between firings. func (s *Server) checkSchedule(acct *Account, app string, sc schedule.Schedule) error { l, err := s.EffectiveLimits(acct) if err != nil || l == nil || l.MinScheduleSeconds <= 0 { return err } if own, err := s.onOwnBoxes(acct, app); err != nil || own { return err } shortest, err := sc.ShortestInterval() if err != nil { return err } if shortest < time.Duration(l.MinScheduleSeconds)*time.Second { return &planCapError{msg: capMessage("cli.cap.schedule", l.MinScheduleSeconds/60)} } return nil } // onOwnBoxes reports whether app is placed on one of the account's own // fleets. Always-on work there costs the deployment nothing, so the // never-sleep and schedule checks pass for it (Paul, 2026-10-24: always-on // on your own boxes). New sidecars of a placed app inherit its fleet region; // existing ones keep theirs, which checkAlwaysOn reads. A console with no // registry has no fleets, so nothing it serves is on own boxes. func (s *Server) onOwnBoxes(acct *Account, app string) (bool, error) { placement, err := s.store.AppPlacement(acct.ID, app) if err != nil { return false, err } if s.cfg.Registry == "" { return false, nil } reg, err := openRegistry(s.cfg.Registry) if err != nil { return false, err } if placement == "" { if placement, err = s.appPlacement(reg, acct, app); err != nil { return false, err } } return ownsFleet(reg, acct, placement) } // ownsFleet reports whether placement names a fleet acct itself owns. The // "fleet/" shape alone proves nothing: fleet names are one namespace // across accounts, so an unknown or foreign fleet is shared boxes as far as // a plan is concerned (Astra plans-3b finding 5). func ownsFleet(reg consoleRegistry, acct *Account, placement string) (bool, error) { name, ok := registry.FleetPlacement(placement) if !ok { return false, nil } f, err := reg.GetFleet(name) if err != nil { return false, err } return f != nil && f.Account == acct.Sqid, nil }