package console import ( "errors" "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 // Strict: a misspelt sponsored limit must be refused, not dropped. if !readJSONStrictLimit(w, r, &req, 8<<10) { 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 { if errors.Is(err, ErrAccountGone) { http.NotFound(w, r) return } 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 }