// The `carlos features` doors (issue #347): the app-defined switches the // platform stores and serves but never interprets — `sandbox=on` for // titogo's router — written here, audited, and read back by the app off the // deployment block the domains doors already answer with (domainCapability, // domainstate.go). This is managed_domains generalized: the platform owns // the write path so that enabling an app capability is never an app editing // root-owned env on an edge box. // // Gate order is handleAPIRetentionSet's exactly — token, one byte-identical // 404 for every identity failure, the shared per-principal write budget, 503 // when the console has no bucket. What differs is what happens AFTER a // successful write: no serial bump. Nothing on a box reads features.json; // the app polls the console for it, so announcing a write would send every // box looking for a change none of them can see. // // Roles (authcli.go): the app's own instance credential READS — it is the // consumer — and never writes. A switch an app could flip on itself would // be a root env flag by another name, the exact thing this door retires. package console import ( "context" "log" "net/http" "sort" "strconv" "strings" "time" "github.com/carlosframework/platform/internal/release" ) // apiFeaturesResponse is the one wire shape both doors answer with. // Features is never omitted and never null on a successful answer: an app // ranges over it, and "no flags set" is spelled {}. type apiFeaturesResponse struct { Account string `json:"account"` App string `json:"app"` Features map[string]string `json:"features"` SetBy string `json:"set_by,omitempty"` SetAt string `json:"set_at,omitempty"` // Changed is the set door's answer only: whether this call wrote // anything. Absent on a read. Changed *bool `json:"changed,omitempty"` } func featuresResponse(acct, app string, rec *release.Features) apiFeaturesResponse { resp := apiFeaturesResponse{Account: acct, App: app, Features: map[string]string{}} if rec != nil { resp.Features = rec.Features resp.SetBy = rec.SetBy if !rec.SetAt.IsZero() { resp.SetAt = rec.SetAt.UTC().Format(time.RFC3339) } } return resp } // handleAPIFeaturesGet answers `carlos features list -app X`. No write // budget and no audit: it is a read. An unreadable record is a 502, not an // empty map — the dedicated read must never tell an operator "nothing set" // about a record it could not parse. func (s *Server) handleAPIFeaturesGet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot read features") return } ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app) if err != nil { log.Printf("console: api features get %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not read the app's features — try again") return } writeJSON(w, http.StatusOK, featuresResponse(acct.Sqid, app, rec)) } // handleAPIFeaturesSet answers `carlos features set -app X k=v [k=]`: set // merges onto the current map, unset removes keys, in one CAS-guarded // write. Only an actual CHANGE audits — a set to the value already held is // a no-op, and a row per no-op would bury the app's log under a caller // re-asserting the same switch on every deploy. func (s *Server) handleAPIFeaturesSet(w http.ResponseWriter, r *http.Request) { _, acct, app, ok := s.apiAccountApp(w, r) if !ok { return } p, _ := principalFrom(r.Context()) if !s.allowWrite(p, time.Now()) { jsonError(w, http.StatusTooManyRequests, "too many requests — wait a minute and try again") return } if s.cfg.Releases == nil { jsonError(w, http.StatusServiceUnavailable, "no deployment bucket on this host — this console cannot write features") return } var in struct { Set map[string]string `json:"set"` Unset []string `json:"unset"` Message string `json:"message"` } if !readJSON(w, r, &in) { return } message, ok := checkedActivityMessage(w, in.Message) if !ok { return } if len(in.Set) == 0 && len(in.Unset) == 0 { jsonError(w, http.StatusBadRequest, "nothing to do — name at least one key to set or unset") return } // Shape-checked HERE, before the store is touched, so a bad key is a // 400 the caller can fix rather than a 502 they cannot — and so the // message names the rule, not just the refusal. for k, v := range in.Set { if !release.ValidFeatureKey(k) { jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") return } if !release.ValidFeatureValue(v) { jsonError(w, http.StatusBadRequest, "invalid value for "+k+" — values are 1-256 printable characters (to clear a key, unset it)") return } } for _, k := range in.Unset { if !release.ValidFeatureKey(k) { jsonError(w, http.StatusBadRequest, "invalid key "+k+" — keys are 1-64 of a-z, 0-9, '_' and '-'") return } } principal := auditActor(p, acct) ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second) defer cancel() rec, changed, err := release.UpdateFeatures(ctx, s.cfg.Releases, acct.Sqid, app, in.Set, in.Unset, principal, time.Now()) if err != nil { if strings.Contains(err.Error(), "at most") { jsonError(w, http.StatusBadRequest, "an app may hold at most "+strconv.Itoa(release.FeaturesMax)+" features") return } log.Printf("console: api features set %s/%s: %v", acct.Sqid, app, err) jsonError(w, http.StatusBadGateway, "could not write the app's features — nothing was changed; try again") return } if changed { s.auditActivity(acct, app, principal, Activity{ Action: "features", TargetType: "app", Target: app, Message: message, Detail: featuresAuditDetail(in.Set, in.Unset), }) } resp := featuresResponse(acct.Sqid, app, rec) resp.Changed = &changed writeJSON(w, http.StatusOK, resp) } // featuresAuditDetail spells one call out the way the operator typed it, // keys sorted so two audits of the same call read the same. func featuresAuditDetail(set map[string]string, unset []string) string { keys := make([]string, 0, len(set)) for k := range set { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)+1) for _, k := range keys { parts = append(parts, k+"="+set[k]) } if len(unset) > 0 { u := append([]string(nil), unset...) sort.Strings(u) parts = append(parts, "unset "+strings.Join(u, ", ")) } return "set features: " + strings.Join(parts, ", ") + " (cli)" } // appFeatures is the deployment-block read (domainCapability): the app's // map, plus when it last changed. Best-effort by design — this rides on the // domains answer, and a features record the console cannot read must not // take a domains listing down with it. The nil map (JSON null) is the // "unknown" reading; an app tells it from {} ("none set") by that. func (s *Server) appFeatures(ctx context.Context, acct, app string) (map[string]string, string) { if s.cfg.Releases == nil { return nil, "" } rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct, app) if err != nil { log.Printf("console: api features block %s/%s: %v", acct, app, err) return nil, "" } if rec == nil { return map[string]string{}, "" } setAt := "" if !rec.SetAt.IsZero() { setAt = rec.SetAt.UTC().Format(time.RFC3339) } return rec.Features, setAt }