console: features doors, and the map rides the domains deployment block (#347)
GET/POST /api/cli/apps/{acct}/{app}/features. The read carries Instance
because the app IS the consumer — its router reads its own switches with
the CARLOS_INSTANCE_TOKEN it already holds. The write is Admin only: these
are production switches, the register of pipeline PUT and apps place, and
an app's own credential must never reach it — a switch an app could flip on
itself is a root env flag by another name, the thing #347 exists to retire.
The same map is folded into deploymentCapability beside managed_domains,
on both the list and per-host domains doors, so titogo's router gets its
flags on the fetch it already makes: {} is none set, null is the console
could not read the record, a missing key is an older console.
features_set_at is the checked_at-style freshness stamp.
Only a real change audits, and nothing bumps the serial: no box consumes
features.json, the app polls the console for it.
The app Settings page shows the flags read-only with the last change and
the CLI to set one; writes stay on the audited, role-gated door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+596
−3
internal/console/apidomains.go+2 −2internal/console/apifeatures.go+197 −0internal/console/apifeatures_test.go+302 −0internal/console/appsettings.go+41 −0internal/console/authcli.go+10 −0internal/console/domainstate.go+14 −1internal/console/svcroutes_test.go+9 −0internal/console/templates/app_settings.html+21 −0
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}} |