carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev 7648d7199272b4f7b6d81c48fff7a774ff93fb3a parent 145041b
8 files changed, +596 −3
  • 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
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}}