// The app feature-flags record (issue #347): one small unsigned object per // app holding the switches the APP defines and the platform merely stores // and serves — `{"sandbox": "on"}` — read back by the app off the deployment // block it already fetches. The platform never interprets a key or a value; // what it owns is the write path (a console door, audited) so that enabling // an app capability is never an app editing root-owned env on an edge box. // // Bucket layout addition: // // apps///features.json CAS-guarded, schema-1 record // // ABSENT IS LEGAL FOREVER: no record means no features set — the // retention.json convention. Nothing on a box reads this key; the only // consumer is the console, so the shape is purely additive. // // Not secrets, not config: values are short strings. Anything an app wants // delivered into a process environment is `carlos env`'s job, and anything // it wants sealed is `carlos secrets`'. package release import ( "context" "encoding/json" "errors" "fmt" "os" "sort" "time" "unicode" ) // FeaturesMax caps how many keys one app's record may hold. The record is // read on every domains-door answer, so it stays a handful of switches // rather than growing into a config store by another name. const FeaturesMax = 64 const ( featureKeyMax = 64 featureValueMax = 256 ) // Features is the wire shape at apps///features.json. type Features struct { Schema int `json:"schema"` // 1 App string `json:"app"` // must agree with the key path Features map[string]string `json:"features"` SetBy string `json:"set_by,omitempty"` SetAt time.Time `json:"set_at"` } // FeaturesKey is the bucket key of app's feature-flags record, derived from // AppPrefix like every other per-app key. func FeaturesKey(acct, app string) string { return AppPrefix(acct, app) + "features.json" } // ValidFeatureKey reports whether k is a well-formed feature key: 1-64 // bytes of lowercase ASCII letters, digits, '_' and '-'. Lowercase only so // that "Sandbox" and "sandbox" can never be two switches. func ValidFeatureKey(k string) bool { if k == "" || len(k) > featureKeyMax { return false } for _, r := range k { switch { case r >= 'a' && r <= 'z', r >= '0' && r <= '9', r == '_', r == '-': default: return false } } return true } // ValidFeatureValue reports whether v is a well-formed feature value: 1-256 // bytes of printable characters. Empty is not a value — the CLI spells // "clear this key" as `key=`, and that must arrive as an unset, never as a // stored empty string an app then has to treat as a third state. func ValidFeatureValue(v string) bool { if v == "" || len(v) > featureValueMax { return false } for _, r := range v { if !unicode.IsPrint(r) { return false } } return true } // GetFeatures reads the app's record. (nil, nil) when absent — no features // is a legal permanent state. A record that lies about identity or carries // an unknown schema is refused, never repaired. A present record always // carries a non-nil map, so consumers range over it without a nil check. func GetFeatures(ctx context.Context, s Store, acct, app string) (*Features, error) { rec, _, err := getFeatures(ctx, s, acct, app) return rec, err } func getFeatures(ctx context.Context, s Store, acct, app string) (*Features, string, error) { key := FeaturesKey(acct, app) raw, etag, err := s.Get(ctx, key) if errors.Is(err, os.ErrNotExist) { return nil, "", nil } if err != nil { return nil, "", err } var rec Features if err := json.Unmarshal(raw, &rec); err != nil { return nil, "", fmt.Errorf("features at %s: %w", key, err) } if rec.Schema != 1 { return nil, "", fmt.Errorf("features at %s: unknown schema %d", key, rec.Schema) } if rec.App != app { return nil, "", fmt.Errorf("features at %s names app %q", key, rec.App) } if rec.Features == nil { rec.Features = map[string]string{} } return &rec, etag, nil } // UpdateFeatures applies set (key -> value) and unset (keys) onto the app's // current map and writes the result. It returns the record as it now // stands and whether this call changed anything: a set to the value already // held, or an unset of an absent key, is a no-op that rewrites nothing — // so set_by/set_at keep naming the last REAL change and a door can skip the // audit row. // // Every key and value is validated before anything is read or written, so a // call with one bad entry lands nothing. CAS with a short retry, the // retention pattern: two writers racing is last-writer-wins on the MERGED // map, which is the right answer for a handful of switches — but a // conflict must re-read and re-merge, never blind-write. func claimRawUpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (*Features, bool, error) { if !ValidAccount(acct) { return nil, false, fmt.Errorf("features: invalid account %q", acct) } if !ValidApp(app) { return nil, false, fmt.Errorf("features: invalid app %q", app) } for k, v := range set { if !ValidFeatureKey(k) { return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) } if !ValidFeatureValue(v) { return nil, false, fmt.Errorf("features: invalid value for %s — 1-%d printable characters", k, featureValueMax) } } for _, k := range unset { if !ValidFeatureKey(k) { return nil, false, fmt.Errorf("features: invalid key %q — 1-%d of a-z, 0-9, '_' and '-'", k, featureKeyMax) } } key := FeaturesKey(acct, app) var lastErr error for attempt := 0; attempt < 3; attempt++ { cur, etag, err := getFeatures(ctx, s, acct, app) if err != nil { return nil, false, err } next := &Features{Schema: 1, App: app, Features: map[string]string{}} if cur != nil { next.SetBy, next.SetAt = cur.SetBy, cur.SetAt for k, v := range cur.Features { next.Features[k] = v } } changed := false for _, k := range unset { if _, ok := next.Features[k]; ok { delete(next.Features, k) changed = true } } // Sorted so a cap refusal is deterministic across calls with the // same input, whatever order the map ranges in. keys := make([]string, 0, len(set)) for k := range set { keys = append(keys, k) } sort.Strings(keys) for _, k := range keys { if next.Features[k] == set[k] { continue } if _, exists := next.Features[k]; !exists && len(next.Features) >= FeaturesMax { return nil, false, fmt.Errorf("features: at most %d keys per app", FeaturesMax) } next.Features[k] = set[k] changed = true } if !changed { if cur != nil { return cur, false, nil } return next, false, nil } next.SetBy, next.SetAt = setBy, now.UTC() raw, err := json.Marshal(next) if err != nil { return nil, false, err } if _, err := s.Put(ctx, key, raw, etag); err != nil { if errors.Is(err, ErrCASConflict) { lastErr = err continue } return nil, false, err } return next, true, nil } return nil, false, fmt.Errorf("features: could not write %s: %w", key, lastErr) } func UpdateFeatures(ctx context.Context, s Store, acct, app string, set map[string]string, unset []string, setBy string, now time.Time) (claimResult0 *Features, claimResult1 bool, claimResult2 error) { claimResult2 = WithAppMutation(ctx, s, acct, app, "release.UpdateFeatures", func(claimContext context.Context) error { claimResult0, claimResult1, claimResult2 = claimRawUpdateFeatures(claimContext, s, acct, app, set, unset, setBy, now) return claimResult2 }) return }