carlos / platform Public

Merge pull request #350 from carlosframework/feature-flags

App feature flags on the deployment object — `carlos features` (#347)
Paul Campbell pushed by paul@keymail.dev 9a056e54dff3835e6676b32182f62838efd766cc parent cf77212parent ec327e5
15 files changed, +1300 −3
  • apimode.go +44 −0
  • docs/site/cli.md +18 −0
  • features_cli.go +182 −0
  • features_cli_test.go +84 −0
  • internal/console/apidomains.go +2 −2
  • internal/console/apifeatures.go +197 −0
  • internal/console/apifeatures_test.go +302 −0
  • internal/console/appsettings.go +41 −0
  • internal/console/authcli.go +10 −0
  • internal/console/domainstate.go +14 −1
  • internal/console/svcroutes_test.go +9 −0
  • internal/console/templates/app_settings.html +21 −0
  • internal/release/features.go +213 −0
  • internal/release/features_test.go +159 −0
  • main.go +4 −0
diff --git a/apimode.go b/apimode.go
index 5ee7c07b3..118bcc3de 100644
--- a/apimode.go
+++ b/apimode.go
@@ -1179,6 +1179,50 @@ func (s *apiSession) scheduleRun(ctx context.Context, a apiAcct, app, name strin
return res, nil
}
+// apiFeaturesResult mirrors the console's features answer (apifeatures.go):
+// the app's map as the platform now serves it, who last changed it and
+// when, and — on a set — whether this call changed anything.
+type apiFeaturesResult struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Features map[string]string `json:"features"`
+ SetBy string `json:"set_by"`
+ SetAt string `json:"set_at"`
+ Changed bool `json:"changed"`
+}
+
+func (s *apiSession) features(ctx context.Context, a apiAcct, app string) (apiFeaturesResult, error) {
+ status, body, err := s.getJSON(ctx, apiPath(a, app, "/features"))
+ return decodeFeatures(status, body, err)
+}
+
+func (s *apiSession) setFeatures(ctx context.Context, a apiAcct, app string, set map[string]string, unset []string) (apiFeaturesResult, error) {
+ in := struct {
+ Set map[string]string `json:"set,omitempty"`
+ Unset []string `json:"unset,omitempty"`
+ }{set, unset}
+ status, body, err := s.postJSON(ctx, apiPath(a, app, "/features"), in)
+ return decodeFeatures(status, body, err)
+}
+
+func decodeFeatures(status int, body []byte, err error) (apiFeaturesResult, error) {
+ if err != nil {
+ return apiFeaturesResult{}, err
+ }
+ if status != http.StatusOK {
+ if status == http.StatusNotFound {
+ return apiFeaturesResult{}, fmt.Errorf("%w — or this console predates `carlos features` "+
+ "(deploy a console built from current main)", apiErrorFromBody(status, body))
+ }
+ return apiFeaturesResult{}, apiErrorFromBody(status, body)
+ }
+ var res apiFeaturesResult
+ if err := json.Unmarshal(body, &res); err != nil {
+ return apiFeaturesResult{}, fmt.Errorf("features: unreadable response: %w", err)
+ }
+ return res, nil
+}
+
// apiRetentionResult mirrors the console's retention answer: the policy as
// recorded, plus the console's own count of currently-eligible versions —
// computed console-side because this terminal has no bucket. Eligible is a
diff --git a/docs/site/cli.md b/docs/site/cli.md
index 04d332c12..6749533fe 100644
--- a/docs/site/cli.md
+++ b/docs/site/cli.md
@@ -350,6 +350,24 @@ that route at its next domains pass, certificate and all. The `list` verb
joins the claims to the fleet's own readings sweep, so DNS state, certificate
expiry and delegation are readable without leaving the terminal.
+### carlos features
+
+Sets or lists an app's own feature flags — switches the app defines, the
+platform stores and serves, and nobody but the app interprets.
+
+```sh
+carlos features set --app titogo sandbox=on
+carlos features list --app titogo
+```
+
+Keys and values are the app's vocabulary (`sandbox=on`, `beta-ui=v2`); the
+platform never reads them. `key=` with nothing after the equals clears a key.
+The app finds the map on the `deployment` block its instance token already
+fetches beside `managed_domains`, so a change lands on its next poll — no
+restart, no environment edit, no root-owned file on an edge box. Setting is
+an admin-role write and every change is audited under the app's Activity;
+the app's own instance credential can read the flags but never set them.
+
### carlos logs
The app's own stdout and stderr, merged with the platform's events about it:
diff --git a/features_cli.go b/features_cli.go
new file mode 100644
index 000000000..52f6f6b07
--- /dev/null
+++ b/features_cli.go
@@ -0,0 +1,182 @@
+// The `carlos features` verb family (issue #347): the app-defined switches
+// the platform stores and serves but never interprets — `sandbox=on` for
+// titogo's router — set here, audited by the console, and read by the app
+// off the deployment block it already fetches. API mode only, like
+// retention and for its reason: a switch is a console write with an audit
+// row, and there is no store-mode equivalent.
+package main
+
+import (
+ "context"
+ "flag"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+const featuresUsage = `usage: carlos features <set|list> [flags]
+
+ set -app <a> [flags] key=value [key=value ...]
+ set the app's feature flags — keys and values are the
+ app's own vocabulary; an empty value (key=) clears the key
+ list -app <a> [flags]
+ the app's feature flags as the platform serves them
+
+ -account <a> account (sqid or name; default "ops")
+ -account-id <id> account sqid, matched as a sqid ONLY
+ -console <origin> console to act through (default: the only logged-in one)
+
+Signed-in only (carlos auth login): a switch is a console-authorized,
+audited write. The app reads it off the deployment block its instance token
+already fetches — no restart, no env edit; the app picks it up on its next
+poll.
+`
+
+const featuresSetUsage = `usage: carlos features set -app <a> [-account <a>] [-account-id <id>] [-console <origin>] key=value [key=value ...]
+`
+
+const featuresListUsage = `usage: carlos features list -app <a> [-account <a>] [-account-id <id>] [-console <origin>]
+`
+
+// runFeatures dispatches the sub-verbs. set/list are positional words, not
+// flags, and must never reach fs.Parse — runDomains' shape.
+func runFeatures(args []string) {
+ if len(args) > 0 {
+ switch args[0] {
+ case "set":
+ runFeaturesSet(args[1:])
+ return
+ case "list":
+ runFeaturesList(args[1:])
+ return
+ }
+ }
+ log.Fatal(featuresUsage)
+}
+
+// parseFeatureArgs turns the positional key=value words into the door's
+// set map and unset list. `key=` (empty value) is an unset — the one way
+// to clear a switch, since the record never stores an empty string. Keys
+// are shape-checked here so a typo costs nothing, with the same rule the
+// console applies; a key named twice is refused rather than last-wins,
+// because "sandbox=on sandbox=off" is never what someone meant.
+func parseFeatureArgs(words []string) (map[string]string, []string, error) {
+ set := map[string]string{}
+ var unset []string
+ seen := map[string]bool{}
+ for _, w := range words {
+ k, v, ok := strings.Cut(w, "=")
+ if !ok {
+ return nil, nil, fmt.Errorf("%q is not key=value", w)
+ }
+ if !release.ValidFeatureKey(k) {
+ return nil, nil, fmt.Errorf("invalid key %q — keys are 1-64 of a-z, 0-9, '_' and '-'", k)
+ }
+ if seen[k] {
+ return nil, nil, fmt.Errorf("key %q named twice", k)
+ }
+ seen[k] = true
+ if v == "" {
+ unset = append(unset, k)
+ continue
+ }
+ if !release.ValidFeatureValue(v) {
+ return nil, nil, fmt.Errorf("invalid value for %s — values are 1-256 printable characters", k)
+ }
+ set[k] = v
+ }
+ return set, unset, nil
+}
+
+func runFeaturesSet(args []string) {
+ fs := flag.NewFlagSet("features set", flag.ExitOnError)
+ app := fs.String("app", "", "app whose feature flags to set")
+ consoleFlag := fs.String("console", "", "console to set through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ if fs.NArg() == 0 {
+ log.Fatal("features set: name at least one key=value\n" + featuresSetUsage)
+ }
+ set, unset, err := parseFeatureArgs(fs.Args())
+ if err != nil {
+ log.Fatalf("features set: %v", err)
+ }
+ sess := requireFeaturesAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, featuresSetUsage)
+ res, err := sess.setFeatures(context.Background(), acct, app2, set, unset)
+ if err != nil {
+ log.Fatalf("features set: %v", err)
+ }
+ if res.Changed {
+ fmt.Fprintf(stdout, "%s for %s/%s\n", colorFor(stdout).ok("features updated"), res.Account, res.App)
+ } else {
+ fmt.Fprintf(stdout, "%s for %s/%s — nothing changed\n", colorFor(stdout).ok("features already set"), res.Account, res.App)
+ }
+ printFeatures(res)
+}
+
+func runFeaturesList(args []string) {
+ fs := flag.NewFlagSet("features list", flag.ExitOnError)
+ app := fs.String("app", "", "app whose feature flags to list")
+ consoleFlag := fs.String("console", "", "console to list through (default: the only logged-in one)")
+ acctRaw := fs.String("account", "", "account (sqid or name) the app belongs to (default: your only account, or the configured/chosen one)")
+ acctIDRaw := fs.String("account-id", "", "account sqid, matched as a sqid ONLY — supersedes -account")
+ fs.Parse(args)
+ sess := requireFeaturesAPISession(*consoleFlag)
+ printUsing(stdout, sess)
+ acct := resolvedAccount(sess, acctRaw, acctIDRaw)
+ app2 := resolvedApp(sess, acct, *app, featuresListUsage)
+ res, err := sess.features(context.Background(), acct, app2)
+ if err != nil {
+ log.Fatalf("features list: %v", err)
+ }
+ printFeatures(res)
+}
+
+// printFeatures is the one rendering both verbs share, so set's echo and
+// list's answer can never disagree about what the platform now serves.
+func printFeatures(res apiFeaturesResult) {
+ if len(res.Features) == 0 {
+ fmt.Fprintln(stdout, "no features set")
+ return
+ }
+ keys := make([]string, 0, len(res.Features))
+ for k := range res.Features {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ w := newTable(stdout)
+ fmt.Fprintln(w, "KEY\tVALUE")
+ for _, k := range keys {
+ fmt.Fprintf(w, "%s\t%s\n", k, res.Features[k])
+ }
+ w.Flush()
+ if res.SetAt != "" {
+ fmt.Fprintf(stdout, "last changed %s", res.SetAt)
+ if res.SetBy != "" {
+ fmt.Fprintf(stdout, " by %s", res.SetBy)
+ }
+ fmt.Fprintln(stdout)
+ }
+}
+
+// requireFeaturesAPISession refuses direct-bucket mode with features' OWN
+// message, requireRetentionAPISession's pattern and reason.
+func requireFeaturesAPISession(consoleFlag string) *apiSession {
+ sess, err := resolveMode(consoleFlag)
+ if err != nil {
+ log.Fatal(err)
+ }
+ if sess == nil {
+ log.Fatal("carlos features works through a console, and this shell is in direct bucket mode\n" +
+ "(CARLOS_DEPLOYMENT_BUCKET/CARLOS_DEPLOYMENT_DIR is set). A feature flag is a console write with an\n" +
+ "audit row. Unset the store env and run: carlos auth login -console <url>.")
+ }
+ return sess
+}
diff --git a/features_cli_test.go b/features_cli_test.go
new file mode 100644
index 000000000..75e4a9a83
--- /dev/null
+++ b/features_cli_test.go
@@ -0,0 +1,84 @@
+package main
+
+// The CLI-side gates for `carlos features` (issue #347): argument parsing
+// and the refusals that fail before any network call.
+
+import (
+ "os/exec"
+ "reflect"
+ "strings"
+ "testing"
+)
+
+func TestParseFeatureArgs(t *testing.T) {
+ set, unset, err := parseFeatureArgs([]string{"sandbox=on", "beta=", "limit=10 per day"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if want := map[string]string{"sandbox": "on", "limit": "10 per day"}; !reflect.DeepEqual(set, want) {
+ t.Fatalf("set = %v, want %v", set, want)
+ }
+ if want := []string{"beta"}; !reflect.DeepEqual(unset, want) {
+ t.Fatalf("unset = %v, want %v", unset, want)
+ }
+}
+
+func TestParseFeatureArgsRefusals(t *testing.T) {
+ for _, c := range []struct {
+ args []string
+ want string
+ }{
+ {[]string{"sandbox"}, "key=value"},
+ {[]string{"=on"}, "key"},
+ {[]string{"Sandbox=on"}, "key"},
+ {[]string{"sandbox=on", "sandbox=off"}, "twice"},
+ {[]string{"sandbox=on", "sandbox="}, "twice"},
+ } {
+ _, _, err := parseFeatureArgs(c.args)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Errorf("%v: err = %v, want one mentioning %q", c.args, err, c.want)
+ }
+ }
+}
+
+func runFeaturesCLI(t *testing.T, args ...string) (string, error) {
+ t.Helper()
+ bin := buildCarlos(t)
+ cmd := exec.Command(bin, args...)
+ cmd.Env = carlosTestEnv(t)
+ cmd.Dir = t.TempDir()
+ out, err := cmd.CombinedOutput()
+ return string(out), err
+}
+
+func TestFeaturesNeedsASubVerb(t *testing.T) {
+ out, err := runFeaturesCLI(t, "features")
+ if err == nil {
+ t.Fatalf("bare `features` exited 0: %s", out)
+ }
+ if !strings.Contains(out, "set") || !strings.Contains(out, "list") {
+ t.Fatalf("usage should name the sub-verbs, got: %s", out)
+ }
+}
+
+func TestFeaturesSetRefusesNoPairsBeforeTheNetwork(t *testing.T) {
+ // No pairs is a usage error, refused before the session is resolved —
+ // so it must NOT be the no-target refusal.
+ out, err := runFeaturesCLI(t, "features", "set", "-app", "titogo")
+ if err == nil {
+ t.Fatalf("set with no pairs exited 0: %s", out)
+ }
+ if !strings.Contains(out, "key=value") || strings.Contains(out, "no deployment target") {
+ t.Fatalf("want a key=value usage refusal, got: %s", out)
+ }
+}
+
+func TestFeaturesListNeedsASession(t *testing.T) {
+ out, err := runFeaturesCLI(t, "features", "list", "-app", "titogo")
+ if err == nil {
+ t.Fatalf("list with no target exited 0: %s", out)
+ }
+ if !strings.Contains(out, "no deployment target configured") {
+ t.Fatalf("want the no-target refusal, got: %s", out)
+ }
+}
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index e93fa55b7..d9595ecc9 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -722,7 +722,7 @@ func (s *Server) handleAPIDomainList(w http.ResponseWriter, r *http.Request) {
// The self-describing block (#299 §2c). An app reads managed_domains
// to know the platform owns verification and certificates here, and
// stops implementing them itself.
- "deployment": s.domainCapability(),
+ "deployment": s.domainCapability(ctx, acct.Sqid, app),
})
}
@@ -820,7 +820,7 @@ func (s *Server) handleAPIDomainGet(w http.ResponseWriter, r *http.Request) {
now := time.Now()
writeJSON(w, http.StatusOK, map[string]any{
"domain": s.domainItem(ctx, *c, aliasReadings(ctx, s.cfg.Releases, now), s.dnsTargets(), now, c.Wildcard && s.delegationZone != ""),
- "deployment": s.domainCapability(),
+ "deployment": s.domainCapability(ctx, acct.Sqid, app),
})
}
diff --git a/internal/console/apifeatures.go b/internal/console/apifeatures.go
new file mode 100644
index 000000000..6073a5dc7
--- /dev/null
+++ b/internal/console/apifeatures.go
@@ -0,0 +1,197 @@
+// 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"`
+ }
+ if !readJSON(w, r, &in) {
+ 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.audit(acct, app, principal, "features", 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
+}
diff --git a/internal/console/apifeatures_test.go b/internal/console/apifeatures_test.go
new file mode 100644
index 000000000..bea0555bc
--- /dev/null
+++ b/internal/console/apifeatures_test.go
@@ -0,0 +1,302 @@
+package console
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/svcrole"
+)
+
+func featuresURL(fx *consoleFixture, acct, app string) string {
+ return fx.ts.URL + "/api/cli/apps/" + acct + "/" + app + "/features"
+}
+
+// featuresAnswer is the wire shape both doors answer with.
+type featuresAnswer struct {
+ Account string `json:"account"`
+ App string `json:"app"`
+ Features map[string]string `json:"features"`
+ SetBy string `json:"set_by"`
+ SetAt string `json:"set_at"`
+ Changed *bool `json:"changed"`
+}
+
+func decodeFeatures(t *testing.T, body []byte) featuresAnswer {
+ t.Helper()
+ var out featuresAnswer
+ if err := json.Unmarshal(body, &out); err != nil {
+ t.Fatalf("features body %s: %v", body, err)
+ }
+ return out
+}
+
+func TestAPIFeaturesSetMergesAuditsAndDoesNotAnnounce(t *testing.T) {
+ fx, token := instanceFixture(t, "code-feat-set")
+ status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`)
+ if status != http.StatusOK {
+ t.Fatalf("set = %d %s, want 200", status, out)
+ }
+ res := decodeFeatures(t, out)
+ if res.Account != "bab" || res.App != "titogo" || res.Features["sandbox"] != "on" || res.Changed == nil || !*res.Changed {
+ t.Fatalf("set body = %+v", res)
+ }
+ if res.SetBy != "alice@keymail.dev" || res.SetAt == "" {
+ t.Fatalf("set body = %+v, want set_by alice and a set_at", res)
+ }
+ rec, err := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo")
+ if err != nil || rec == nil || rec.Features["sandbox"] != "on" {
+ t.Fatalf("record after set = %+v, %v", rec, err)
+ }
+
+ // A second set MERGES and an unset in the same call lands too.
+ status, out = postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"beta": "1"}, "unset": ["sandbox"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("merge = %d %s, want 200", status, out)
+ }
+ res = decodeFeatures(t, out)
+ if _, ok := res.Features["sandbox"]; ok || res.Features["beta"] != "1" {
+ t.Fatalf("after merge = %+v, want beta only", res.Features)
+ }
+
+ events, err := fx.st.Events(OpsAccountID, "titogo", 10)
+ if err != nil {
+ t.Fatal(err)
+ }
+ var rows []string
+ for _, e := range events {
+ if e.Action == "features" {
+ rows = append(rows, e.Detail)
+ }
+ }
+ if len(rows) != 2 {
+ t.Fatalf("features audit rows = %q, want 2", rows)
+ }
+ // Newest first: the merge names both what it set and what it cleared.
+ if !strings.Contains(rows[0], "beta=1") || !strings.Contains(rows[0], "unset sandbox") {
+ t.Fatalf("merge audit detail = %q, want it to name beta=1 and the unset", rows[0])
+ }
+ if !strings.Contains(rows[1], "sandbox=on") {
+ t.Fatalf("first audit detail = %q, want sandbox=on", rows[1])
+ }
+ // No box consumes features.json — the app reads it off the console —
+ // so a set must not send every box looking for a change it cannot see.
+ if got := serial(t, fx); got != 0 {
+ t.Fatalf("serial = %d, want 0 — features are not box-consumed", got)
+ }
+}
+
+func TestAPIFeaturesNoOpSetWritesNoAuditRow(t *testing.T) {
+ fx, token := instanceFixture(t, "code-feat-noop")
+ postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}}`)
+ status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, `{"set": {"sandbox": "on"}, "unset": ["absent"]}`)
+ if status != http.StatusOK {
+ t.Fatalf("no-op = %d %s, want 200", status, out)
+ }
+ if res := decodeFeatures(t, out); res.Changed == nil || *res.Changed {
+ t.Fatalf("no-op body = %+v, want changed false", res)
+ }
+ events, _ := fx.st.Events(OpsAccountID, "titogo", 10)
+ n := 0
+ for _, e := range events {
+ if e.Action == "features" {
+ n++
+ }
+ }
+ if n != 1 {
+ t.Fatalf("features audit rows = %d, want 1 — a no-op must not audit", n)
+ }
+}
+
+func TestAPIFeaturesGetAbsentIsEmptyNotError(t *testing.T) {
+ fx, token := instanceFixture(t, "code-feat-get")
+ status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), token)
+ if status != http.StatusOK {
+ t.Fatalf("get = %d %s, want 200", status, out)
+ }
+ res := decodeFeatures(t, out)
+ if res.Features == nil || len(res.Features) != 0 || res.SetAt != "" || res.Changed != nil {
+ t.Fatalf("absent get = %s, want an empty (non-null) map and no set_at", out)
+ }
+ if !strings.Contains(string(out), `"features":{}`) {
+ t.Fatalf("absent get = %s, want features spelled {} (an app ranges over it)", out)
+ }
+}
+
+func TestAPIFeaturesBadShapesAre400AndWriteNothing(t *testing.T) {
+ fx, token := instanceFixture(t, "code-feat-bad")
+ for _, body := range []string{
+ `{"set": {"Sandbox": "on"}}`,
+ `{"set": {"sandbox": ""}}`,
+ `{"unset": ["bad key"]}`,
+ `{}`,
+ `{"set": {}, "unset": []}`,
+ } {
+ status, out := postBody(t, featuresURL(fx, "bab", "titogo"), token, body)
+ if status != http.StatusBadRequest {
+ t.Errorf("%s = %d %s, want 400", body, status, out)
+ }
+ }
+ if rec, _ := release.GetFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo"); rec != nil {
+ t.Fatalf("a refused set wrote %+v", rec)
+ }
+}
+
+// The point of the feature (#347): the app reads its flags off the
+// deployment block it already fetches with its CARLOS_INSTANCE_TOKEN, beside
+// managed_domains — no new call, no new credential, no restart.
+func TestAPIFeaturesRideTheDomainsDeploymentBlock(t *testing.T) {
+ fx, member := instanceFixture(t, "code-feat-block")
+ app := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance)
+
+ type block struct {
+ Deployment struct {
+ ManagedDomains bool `json:"managed_domains"`
+ Features map[string]string `json:"features"`
+ FeaturesSetAt string `json:"features_set_at"`
+ Raw map[string]any `json:"-"`
+ } `json:"deployment"`
+ }
+ read := func() (block, string) {
+ code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), app, "")
+ if code != http.StatusOK {
+ t.Fatalf("domains list as the app = %d %s", code, out)
+ }
+ var b block
+ if err := json.Unmarshal([]byte(out), &b); err != nil {
+ t.Fatal(err)
+ }
+ return b, out
+ }
+
+ // Before anything is set: present and EMPTY, never null — an app tells
+ // "no flags" from "could not read" by that.
+ b, raw := read()
+ if !b.Deployment.ManagedDomains {
+ t.Fatal("managed_domains lost from the block")
+ }
+ if b.Deployment.Features == nil || !strings.Contains(raw, `"features":{}`) {
+ t.Fatalf("block before any set = %s, want \"features\":{}", raw)
+ }
+
+ postBody(t, featuresURL(fx, "bab", "titogo"), member, `{"set": {"sandbox": "on"}}`)
+ b, _ = read()
+ if b.Deployment.Features["sandbox"] != "on" || b.Deployment.FeaturesSetAt == "" {
+ t.Fatalf("block after set = %+v, want sandbox=on with a features_set_at", b.Deployment)
+ }
+ if _, err := time.Parse(time.RFC3339, b.Deployment.FeaturesSetAt); err != nil {
+ t.Fatalf("features_set_at %q is not RFC3339: %v", b.Deployment.FeaturesSetAt, err)
+ }
+
+ // The per-host door carries the same block.
+ attachDomain(t, fx, member, "flag.example.com", "go.tito.io")
+ code, _, out := rawReq(t, http.MethodGet, domainGetURL(fx, "bab", "titogo", "flag.example.com"), app, "")
+ if code != http.StatusOK {
+ t.Fatalf("per-host get = %d %s", code, out)
+ }
+ var one block
+ if err := json.Unmarshal([]byte(out), &one); err != nil {
+ t.Fatal(err)
+ }
+ if one.Deployment.Features["sandbox"] != "on" {
+ t.Fatalf("per-host block = %+v, want sandbox=on", one.Deployment)
+ }
+}
+
+// A record the console cannot parse must not take the domains answer down
+// with it — the block reads features as null ("unknown"), which is neither
+// the empty map nor a stale value.
+func TestAPIFeaturesUnreadableRecordDegradesTheBlockToNull(t *testing.T) {
+ fx, member := instanceFixture(t, "code-feat-degrade")
+ if _, err := fx.rel.Put(context.Background(), release.FeaturesKey(release.OpsAccount, "titogo"), []byte(`{"schema":9}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodGet, domainsURL(fx, "bab", "titogo"), member, "")
+ if code != http.StatusOK {
+ t.Fatalf("domains list with a bad features record = %d %s, want 200", code, out)
+ }
+ if !strings.Contains(out, `"features":null`) {
+ t.Fatalf("block = %s, want \"features\":null for an unreadable record", out)
+ }
+ // And the dedicated read says so out loud rather than lying with {}.
+ status, body := getRetention(t, featuresURL(fx, "bab", "titogo"), member)
+ if status != http.StatusBadGateway {
+ t.Fatalf("features get with a bad record = %d %s, want 502", status, body)
+ }
+}
+
+// Roles: the app's own instance credential READS (it is the consumer) and
+// may never WRITE — a switch the app could flip on itself is a root env
+// flag by another name, the exact thing #347 exists to retire. Publish reads
+// too; only admin sets.
+func TestAPIFeaturesRoles(t *testing.T) {
+ fx, _ := instanceFixture(t, "code-feat-roles")
+ instance := serviceToken(t, fx.st, OpsAccountID, "router", "titogo", svcrole.Instance)
+ publish := serviceToken(t, fx.st, OpsAccountID, "ci", "titogo", svcrole.Publish)
+ operate := serviceToken(t, fx.st, OpsAccountID, "ops", "titogo", svcrole.Operate)
+ admin := serviceToken(t, fx.st, OpsAccountID, "root", "titogo", svcrole.Admin)
+
+ for name, tok := range map[string]string{"instance": instance, "publish": publish} {
+ if status, out := getRetention(t, featuresURL(fx, "bab", "titogo"), tok); status != http.StatusOK {
+ t.Errorf("%s GET = %d %s, want 200", name, status, out)
+ }
+ }
+ if status, _ := getRetention(t, featuresURL(fx, "bab", "titogo"), operate); status != http.StatusForbidden {
+ t.Errorf("operate GET = %d, want 403", status)
+ }
+ for name, tok := range map[string]string{"instance": instance, "publish": publish, "operate": operate} {
+ if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), tok, `{"set": {"sandbox": "on"}}`); status != http.StatusForbidden {
+ t.Errorf("%s POST = %d %s, want 403", name, status, out)
+ }
+ }
+ if status, out := postBody(t, featuresURL(fx, "bab", "titogo"), admin, `{"set": {"sandbox": "on"}}`); status != http.StatusOK {
+ t.Errorf("admin POST = %d %s, want 200", status, out)
+ }
+}
+
+// The console surface (#347): the app Settings page shows the flags the
+// platform serves, read-only, with the last change beside them — so a
+// member can see what an operator set without a terminal. Writes stay on
+// the CLI door, where they are role-gated and audited.
+func TestSettingsPageShowsFeatureFlags(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ freshApp(t, fx, "titogo")
+ when := time.Date(2026, 8, 28, 9, 30, 0, 0, time.UTC)
+ if _, _, err := release.UpdateFeatures(context.Background(), fx.rel, release.OpsAccount, "titogo",
+ map[string]string{"sandbox": "on", "beta-ui": "v2"}, nil, "alice@keymail.dev", when); err != nil {
+ t.Fatal(err)
+ }
+
+ status, _, body := get(t, c, fx.u("/apps/titogo/settings"))
+ if status != http.StatusOK {
+ t.Fatalf("settings page = %d:\n%s", status, body)
+ }
+ for _, want := range []string{
+ "<h2>Feature flags</h2>",
+ "<code>sandbox</code>", "<code>on</code>",
+ "<code>beta-ui</code>", "<code>v2</code>",
+ "alice@keymail.dev",
+ "carlos features set --app titogo",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("the feature flags card is missing %q:\n%s", want, body)
+ }
+ }
+ // Sorted by key: beta-ui before sandbox, whatever order the map ranges.
+ if strings.Index(body, "<code>beta-ui</code>") > strings.Index(body, "<code>sandbox</code>") {
+ t.Fatalf("flags are not key-sorted:\n%s", body)
+ }
+
+ // An app with none set says so, and still tells the member how one
+ // gets set.
+ freshApp(t, fx, "sprout")
+ _, _, body = get(t, c, fx.u("/apps/sprout/settings"))
+ if !strings.Contains(body, "No feature flags") || !strings.Contains(body, "carlos features set --app sprout") {
+ t.Fatalf("an app with no flags is not told so:\n%s", body)
+ }
+}
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 81527deed..a8f2ae177 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -34,6 +34,7 @@ import (
"log"
"net/http"
"net/url"
+ "sort"
"strings"
"time"
@@ -80,6 +81,13 @@ type SettingsView struct {
// on a read failure, a zero card when there is genuinely no record.
Mail *MailCard
+ // Features is the app's feature flags as the platform serves them
+ // (#347, apifeatures.go) — read-only here; the write is the audited,
+ // admin-gated CLI door. Same error discipline as Store: nil on a read
+ // failure (the page says nothing rather than "none set" about a record
+ // it could not parse), a zero card when there genuinely are none.
+ Features *FeaturesCard
+
// Domains is this page's OWN Domains & TLS rows (settingsDomains): a
// copy of AppView.Domains with a placeholder row appended for any of
// this app's claims that have no registry row yet. Deliberately not
@@ -247,6 +255,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
}
sv.Store = s.storeCard(ctx, acct, p.AppName)
sv.Mail = s.mailCard(ctx, acct, p.AppName)
+ sv.Features = s.featuresCard(ctx, acct, p.AppName)
if ra := p.Detail.RouteAccount; ra != "" && p.Detail.AllMismatched {
sv.MismatchTarget = ra
if target, err := s.store.AccountBySqid(ra); err == nil && target != nil {
@@ -259,6 +268,38 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
return sv
}
+// FeaturesCard is the Settings page's read of the app's feature flags.
+type FeaturesCard struct {
+ Flags []FeatureFlag // key-sorted
+ SetBy string
+ SetAt string // "" when no record
+}
+
+type FeatureFlag struct{ Key, Value string }
+
+// featuresCard folds the features record into its Settings card, storeCard's
+// discipline: nil on a read failure, a zero card when there is no record.
+func (s *Server) featuresCard(ctx context.Context, acct *Account, app string) *FeaturesCard {
+ rec, err := release.GetFeatures(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: settings features read %s/%s: %v", acct.Sqid, app, err)
+ return nil
+ }
+ card := &FeaturesCard{}
+ if rec == nil {
+ return card
+ }
+ for k, v := range rec.Features {
+ card.Flags = append(card.Flags, FeatureFlag{k, v})
+ }
+ sort.Slice(card.Flags, func(i, j int) bool { return card.Flags[i].Key < card.Flags[j].Key })
+ card.SetBy = rec.SetBy
+ if !rec.SetAt.IsZero() {
+ card.SetAt = rec.SetAt.UTC().Format("2006-01-02 15:04 UTC")
+ }
+ return card
+}
+
// storeCard folds the store record into the Settings card. Best-effort like
// everything else this page assembles: a read failure returns nil and the
// section is simply absent, because "could not read the record" and "there is
diff --git a/internal/console/authcli.go b/internal/console/authcli.go
index 4c66718d2..14eab60b2 100644
--- a/internal/console/authcli.go
+++ b/internal/console/authcli.go
@@ -610,6 +610,16 @@ func (s *Server) registerCLIRoutes() {
s.cliRoute("GET /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainGet)
s.cliRoute("POST /api/cli/apps/{account}/{app}/domains", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainAttach)
s.cliRoute("DELETE /api/cli/apps/{account}/{app}/domains/{host}", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIDomainDetach)
+ // App feature flags (apifeatures.go, #347). The read carries Instance
+ // because the app IS the consumer — its router reads its own switches
+ // with the CARLOS_INSTANCE_TOKEN, the same credential and the same
+ // answer (the domains deployment block) it already uses. The write is
+ // Admin only: these are production switches, and the register is
+ // `pipeline` PUT's and `apps place`'s, not promote's. An app's own
+ // credential must never reach the write — a switch an app could flip
+ // on itself is a root env flag by another name.
+ s.cliRoute("GET /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Publish, svcrole.Instance), s.handleAPIFeaturesGet)
+ s.cliRoute("POST /api/cli/apps/{account}/{app}/features", svcrole.Allow(svcrole.Admin), s.handleAPIFeaturesSet)
// The config API (apiconfig.go) — `carlos env` and `carlos secrets`
// through the same gate as ship/promote, so deploy access no longer
// means bucket credentials (issue #43). Secrets arrive already sealed;
diff --git a/internal/console/domainstate.go b/internal/console/domainstate.go
index 94ede8906..861452c89 100644
--- a/internal/console/domainstate.go
+++ b/internal/console/domainstate.go
@@ -1,6 +1,7 @@
package console
import (
+ "context"
"strings"
"time"
@@ -190,10 +191,20 @@ type deploymentCapability struct {
RecordTTL int `json:"record_ttl"`
// States is the vocabulary THIS deployment can return (see domainStates).
States []string `json:"states"`
+ // Features is the app's own switches (#347, apifeatures.go) — the one
+ // per-APP entry in an otherwise per-deployment block, placed here
+ // because this block is the fetch the app already makes. Three
+ // readings, deliberately distinct on the wire: {} is "none set", null
+ // is "the console could not read the record", and a missing key is an
+ // older console. FeaturesSetAt is when the map last changed, the
+ // checked_at-style freshness stamp an app's own poll ages against.
+ Features map[string]string `json:"features"`
+ FeaturesSetAt string `json:"features_set_at,omitempty"`
}
-func (s *Server) domainCapability() deploymentCapability {
+func (s *Server) domainCapability(ctx context.Context, acct, app string) deploymentCapability {
t := s.dnsTargets()
+ features, setAt := s.appFeatures(ctx, acct, app)
return deploymentCapability{
ManagedDomains: true,
AppsDomain: strings.TrimSpace(s.cfg.AppsDomain),
@@ -202,5 +213,7 @@ func (s *Server) domainCapability() deploymentCapability {
DelegationZone: s.delegationZone,
RecordTTL: domainRecordTTL,
States: domainStates,
+ Features: features,
+ FeaturesSetAt: setAt,
}
}
diff --git a/internal/console/svcroutes_test.go b/internal/console/svcroutes_test.go
index c7ab9d85a..c7fd77ac8 100644
--- a/internal/console/svcroutes_test.go
+++ b/internal/console/svcroutes_test.go
@@ -214,9 +214,18 @@ func TestNoRoleReachesAMintFleetOrPolicyDoor(t *testing.T) {
// its CARLOS_INSTANCE_TOKEN, which is the entire reason the door was added.
// It discloses strictly less than the list door beside it — one hostname the
// caller already owns — so it cannot need a role that list does not have.
+//
+// GET .../features is the third (#347), on the same footing: it did not
+// exist before service principals, and the instance role reaches it
+// DELIBERATELY because the app IS the consumer — its router reads its own
+// switches with its CARLOS_INSTANCE_TOKEN. The same map already rides on
+// the domains list answer that role has always reached, so this discloses
+// nothing the list does not. The WRITE beside it is admin-only, and
+// TestAPIFeaturesRoles pins that an instance credential never reaches it.
var exemptFromLegacyPin = map[string]bool{
"GET /api/cli/apps": true,
"GET /api/cli/apps/{account}/{app}/domains/{host}": true,
+ "GET /api/cli/apps/{account}/{app}/features": true,
}
// TestInstanceRoleMatchesTodaysAppTokenReach is the compatibility pin of spec
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index 5748af769..75f72e3e8 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -163,6 +163,27 @@ credentials — an operator of this deployment has to tear it down.</p>
and delivers its credentials into this app's config.</p>
{{end}}
{{end}}{{end}}
+{{if .Settings}}{{with .Settings.Features}}
+<h2>Feature flags</h2>
+{{if .Flags}}
+<div class="tablewrap">
+<table>
+ <tr><th>Key</th><th>Value</th></tr>
+ {{range .Flags}}<tr><td><code>{{.Key}}</code></td><td><code>{{.Value}}</code></td></tr>
+ {{end}}
+</table>
+</div>
+<p class="small">Last changed {{.SetAt}}{{if .SetBy}} by {{.SetBy}}{{end}}. The platform
+stores and serves these; what each one means is this app's own business. It
+reads them off the deployment block its instance token fetches, so a change
+lands on its next poll — no restart.</p>
+{{else}}
+<p class="small">No feature flags set.</p>
+{{end}}
+<p class="small">Set or clear one with
+<code>carlos features set --app {{$.Detail.App}} key=value</code>
+(<code>key=</code> clears); every change is audited under Activity.</p>
+{{end}}{{end}}
{{if .Settings}}{{with .Settings.Mail}}
<h2>Email</h2>
{{if or .Declared .Granted .Provisioned .Standalone}}
diff --git a/internal/release/features.go b/internal/release/features.go
new file mode 100644
index 000000000..0f517de36
--- /dev/null
+++ b/internal/release/features.go
@@ -0,0 +1,213 @@
+// 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/<acct>/<app>/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/<acct>/<app>/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 UpdateFeatures(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)
+}
diff --git a/internal/release/features_test.go b/internal/release/features_test.go
new file mode 100644
index 000000000..c3636388b
--- /dev/null
+++ b/internal/release/features_test.go
@@ -0,0 +1,159 @@
+package release
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestFeaturesRoundTrip(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Date(2026, 8, 28, 12, 0, 0, 0, time.UTC)
+
+ // Absent is legal forever: no features, not an error.
+ got, err := GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got != nil {
+ t.Fatalf("absent features = %v, %v; want nil, nil", got, err)
+ }
+
+ rec, changed, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"sandbox": "on"}, nil, "alice@keymail.dev", now)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !changed || rec.Features["sandbox"] != "on" || rec.SetBy != "alice@keymail.dev" || !rec.SetAt.Equal(now) {
+ t.Fatalf("first set = %+v changed=%v, want sandbox=on by alice, changed", rec, changed)
+ }
+ got, err = GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got == nil || got.Features["sandbox"] != "on" || got.Schema != 1 || got.App != "titogo" {
+ t.Fatalf("features = %+v, want schema 1 titogo sandbox=on", got)
+ }
+
+ // A second set MERGES: the existing key survives, the new one lands.
+ rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "bob@keymail.dev", now.Add(time.Hour))
+ if err != nil || !changed {
+ t.Fatalf("merge set: changed=%v err=%v", changed, err)
+ }
+ if rec.Features["sandbox"] != "on" || rec.Features["beta"] != "1" || rec.SetBy != "bob@keymail.dev" {
+ t.Fatalf("after merge = %+v, want both keys, set by bob", rec)
+ }
+
+ // Setting the same value again changes nothing — and says so, so the
+ // door can skip the audit row.
+ _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"beta": "1"}, nil, "carol", now.Add(2*time.Hour))
+ if err != nil || changed {
+ t.Fatalf("no-op set: changed=%v err=%v, want false, nil", changed, err)
+ }
+ got, _ = GetFeatures(ctx, s, "bab", "titogo")
+ if got.SetBy != "bob@keymail.dev" {
+ t.Fatalf("a no-op set rewrote the record: set_by %q", got.SetBy)
+ }
+
+ // Unset removes a key; unsetting an absent key is a no-op.
+ rec, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"beta", "never"}, "dave", now.Add(3*time.Hour))
+ if err != nil || !changed {
+ t.Fatalf("unset: changed=%v err=%v", changed, err)
+ }
+ if _, ok := rec.Features["beta"]; ok || rec.Features["sandbox"] != "on" {
+ t.Fatalf("after unset = %+v, want only sandbox", rec.Features)
+ }
+ _, changed, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"never"}, "dave", now.Add(4*time.Hour))
+ if err != nil || changed {
+ t.Fatalf("unset absent: changed=%v err=%v, want false, nil", changed, err)
+ }
+
+ // Unsetting the last key leaves an EMPTY record, not an absent one: the
+ // record's set_at still tells a reader when the last change happened.
+ rec, _, err = UpdateFeatures(ctx, s, "bab", "titogo", nil, []string{"sandbox"}, "erin", now.Add(5*time.Hour))
+ if err != nil || len(rec.Features) != 0 {
+ t.Fatalf("after last unset = %+v err=%v", rec, err)
+ }
+ got, err = GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || len(got.Features) != 0 || got.SetBy != "erin" {
+ t.Fatalf("empty record = %+v, %v; want present, empty, by erin", got, err)
+ }
+}
+
+// Keys and values are the app's own vocabulary — but they are also bucket
+// bytes every consumer parses, so the shape is bounded: short lowercase
+// keys, short printable values, and a cap on how many.
+func TestUpdateFeaturesRefusesBadShapes(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ now := time.Now()
+ cases := []struct {
+ name string
+ set map[string]string
+ want string
+ }{
+ {"uppercase key", map[string]string{"Sandbox": "on"}, "key"},
+ {"empty key", map[string]string{"": "on"}, "key"},
+ {"key with space", map[string]string{"sand box": "on"}, "key"},
+ {"key too long", map[string]string{strings.Repeat("k", 65): "on"}, "key"},
+ {"empty value", map[string]string{"sandbox": ""}, "value"},
+ {"value too long", map[string]string{"sandbox": strings.Repeat("v", 257)}, "value"},
+ {"control char in value", map[string]string{"sandbox": "on\n"}, "value"},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", c.set, nil, "x", now)
+ if err == nil || !strings.Contains(err.Error(), c.want) {
+ t.Fatalf("err = %v, want one naming the %s", err, c.want)
+ }
+ })
+ }
+ if got, err := GetFeatures(ctx, s, "bab", "titogo"); err != nil || got != nil {
+ t.Fatalf("a refused set wrote something: %+v, %v", got, err)
+ }
+
+ // The count cap: FeaturesMax keys is fine, one more is refused —
+ // whether they arrive at once or on top of an existing record.
+ set := map[string]string{}
+ for i := 0; i < FeaturesMax; i++ {
+ set["k"+strings.Repeat("x", i%3)+string(rune('a'+i%26))+string(rune('a'+i/26))] = "v"
+ }
+ if len(set) != FeaturesMax {
+ t.Fatalf("test bug: %d keys", len(set))
+ }
+ if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", set, nil, "x", now); err != nil {
+ t.Fatalf("%d keys refused: %v", FeaturesMax, err)
+ }
+ _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"one-more": "v"}, nil, "x", now)
+ if err == nil || !strings.Contains(err.Error(), "at most") {
+ t.Fatalf("key %d = %v, want a cap error", FeaturesMax+1, err)
+ }
+ // Replacing an existing key at the cap is not an addition.
+ if _, _, err := UpdateFeatures(ctx, s, "bab", "titogo", map[string]string{"kaa": "changed"}, nil, "x", now); err != nil {
+ t.Fatalf("replacing at the cap refused: %v", err)
+ }
+}
+
+func TestGetFeaturesRefusesLiesAndUnknownSchema(t *testing.T) {
+ ctx := context.Background()
+ s := NewDirStore(t.TempDir())
+ put := func(body string) {
+ s.Delete(ctx, FeaturesKey("bab", "titogo"))
+ if _, err := s.Put(ctx, FeaturesKey("bab", "titogo"), []byte(body), ""); err != nil {
+ t.Fatal(err)
+ }
+ }
+ put(`{"schema":1,"app":"other","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`)
+ if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil {
+ t.Fatal("a body naming a different app was accepted")
+ }
+ put(`{"schema":2,"app":"titogo","features":{"a":"b"},"set_at":"2026-08-28T12:00:00Z"}`)
+ if _, err := GetFeatures(ctx, s, "bab", "titogo"); err == nil {
+ t.Fatal("an unknown schema was accepted")
+ }
+ // A record with no features map at all reads as empty, never nil —
+ // consumers range over it without a nil check.
+ put(`{"schema":1,"app":"titogo","set_at":"2026-08-28T12:00:00Z"}`)
+ got, err := GetFeatures(ctx, s, "bab", "titogo")
+ if err != nil || got == nil || got.Features == nil {
+ t.Fatalf("missing map = %+v, %v; want a non-nil empty map", got, err)
+ }
+}
diff --git a/main.go b/main.go
index e1427a9d5..0d8b67ae3 100644
--- a/main.go
+++ b/main.go
@@ -57,6 +57,8 @@ func main() {
runRoutes(args)
case "domains":
runDomains(args)
+ case "features":
+ runFeatures(args)
case "ship":
runShip(args)
case "promote":
@@ -182,6 +184,8 @@ Your apps — ship and run software on a CARLOS deployment
channel, config environment, status
domains a customer's own hostname attached to an app's route:
attach | detach | list
+ features an app's own feature flags, stored and served by the
+ platform, never interpreted: set | list
logs an app's merged log timeline: its own output plus platform
events (wakes, restarts, failures); -f follows live
ledger open ledgers: append | head | list | publish | blob | verify