carlos / platform Public

console: an Environments section, and a guard against an old console

The config bundles had no console surface at all. The ladder showed which
environment a route was wired to; nothing showed what the environments WERE,
what they set, or what else belonged to them. Settings gains a section: one
card per named bundle, the variable NAMES it sets (sealed ones marked, never
values — this page has never shown a config value and does not start here),
the instances bound to it, and whether it has an object store of its own.

The pairing that section exists for is "a store, and no instances". Teardown
is explicit by ruling, so a bundle nothing reads can still own a bucket that
still bills; that row says so and links to the card that destroys it.

The guard is the more urgent half. A console older than per-environment
stores does not read ?environment= at all — it answers about the app's OWN
store, with a 200 and an ordinary body. So `carlos store create --app x
--environment beta` against an un-upgraded console would declare, grant or
rotate the WRONG store and report success, and none of those is reliably
reversible: the env prefix is fixed once granted, and a rotation mints a
credential. Every console that knows about environment stores echoes the
environment on every reply, so a reply that does not name what was asked
about is the tell, and the CLI refuses rather than narrating it.

Found while working out how to deploy this to a live console still running
the old binary — which is exactly the window the guard is for.

Spec: docs/superpowers/specs/2026-09-19-per-environment-store-design.md
Discussion: amadan carlos/platform #159
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
James Adam pushed by james@keymail.dev 8116e4cd38f2dcbdcdef0f4ed77bc0afc32aa3c2 parent 8dd2cee
6 files changed, +328 −8
  • internal/console/appsettings.go +109 −8
  • internal/console/appsettings_env_test.go +88 −0
  • internal/console/server.go +1 −0
  • internal/console/templates/app_settings.html +39 −0
  • store.go +40 −0
  • store_env_guard_test.go +51 −0
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index 3cf6bf774..4d6798069 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -39,6 +39,7 @@ import (
"strings"
"time"
+ "github.com/carlosframework/platform/internal/appconfig"
"github.com/carlosframework/platform/internal/gate"
"github.com/carlosframework/platform/internal/mail"
"github.com/carlosframework/platform/internal/objstore"
@@ -50,14 +51,15 @@ import (
// this server-side means a typo gets the same 404 as any other unknown app
// page, rather than rendering an empty settings shell.
var appSettingsSections = map[string]bool{
- "general": true,
- "domains": true,
- "pipeline": true,
- "storage": true,
- "email": true,
- "features": true,
- "advanced": true,
- "delete": true,
+ "general": true,
+ "domains": true,
+ "pipeline": true,
+ "environments": true,
+ "storage": true,
+ "email": true,
+ "features": true,
+ "advanced": true,
+ "delete": true,
}
func appSettingsURL(acct, app, section string) string {
@@ -106,6 +108,17 @@ type SettingsView struct {
// record, so the section remains present and does not claim there is no
// store during a transient failure.
StoreUnavailable bool
+ // Envs is one card per named config environment — the bundles themselves,
+ // which until now had no console surface at all: the ladder shows which
+ // environment a route is wired to, and nothing showed what the
+ // environments ARE. Read-only, and variable NAMES only: this page has
+ // never shown a config value and does not start here.
+ Envs []*EnvCard
+ // EnvsUnavailable distinguishes a failed listing from an app that
+ // genuinely has none, so the section does not claim "no environments"
+ // during a transient bucket failure — StoreUnavailable's rule.
+ EnvsUnavailable bool
+
// StoreEnvs is one card per ENVIRONMENT store (2026-09-19), oldest name
// first, empty for the apps that have none — which is all of them until
// someone asks for one. Each carries its own bucket, its own credential
@@ -200,6 +213,39 @@ type StoreCard struct {
CanDestroy bool
}
+// EnvCard is one named config environment as the Settings page sees it.
+//
+// Variable NAMES only, never values. This page has never shown a config
+// value — `carlos env list` is where values live, behind a bearer token —
+// and an environment bundle is exactly where a per-tenant origin or a
+// delivered credential name sits, so starting here would be the wrong place
+// to start.
+type EnvCard struct {
+ Name string
+ // Vars are the names this bundle SETS, sorted. A named bundle overlays
+ // the app's default per key, so this is what the environment overrides
+ // or adds — not the whole set an instance bound to it ends up reading.
+ Vars []EnvVarName
+ // Hosts are the instances wired to this bundle. Empty is the case worth
+ // looking at: a bundle nothing reads, which is where an abandoned store
+ // hides.
+ Hosts []string
+ // HasStore and StoreBucket: whether this environment has an object store
+ // of its own, and what it is called. An environment store bills whether
+ // or not any instance reads the bundle, and teardown is explicit, so the
+ // pairing of "no hosts" with "has a store" is the thing this section
+ // exists to make visible.
+ HasStore bool
+ StoreBucket string
+}
+
+// EnvVarName is a variable's name and whether it is sealed — enough to see
+// the shape of a bundle without seeing into it.
+type EnvVarName struct {
+ Name string
+ Secret bool
+}
+
// MailCard is the Settings tab's view of the app sender, apps/<acct>/<app>/
// email/senders/app.json with an email.json read fallback (issue #240,
// mail.Record): tenant email's Settings card, mirroring StoreCard verb
@@ -315,6 +361,7 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
default:
sv.CanRename = true
}
+ sv.Envs, sv.EnvsUnavailable = s.envCards(ctx, acct, p.AppName, p.Detail.Routes)
sv.Store = s.storeCard(ctx, acct, p.AppName)
sv.StoreEnvs = s.storeEnvCards(ctx, acct, p.AppName)
sv.Mail = s.mailCard(ctx, acct, p.AppName)
@@ -403,6 +450,60 @@ func (s *Server) storeCard(ctx context.Context, acct *Account, app string) *Stor
return s.storeCardFor(rec, "")
}
+// envCards is one card per named config environment, sorted by name, with
+// the second return saying the listing itself failed — the difference
+// between "this app has no environments" and "I could not find out", which
+// the section must not collapse.
+//
+// A single unreadable bundle is skipped and logged rather than blanking the
+// list: the other environments are real, their instances are really bound to
+// them, and their stores are really billing. Same trade storeEnvCards makes,
+// for the same reason.
+func (s *Server) envCards(ctx context.Context, acct *Account, app string, routes []RouteView) ([]*EnvCard, bool) {
+ names, err := appconfig.ListEnvironments(ctx, s.cfg.Releases, acct.Sqid, app)
+ if err != nil {
+ log.Printf("console: settings environments %s/%s: %v", acct.Sqid, app, err)
+ return nil, true
+ }
+ cards := make([]*EnvCard, 0, len(names))
+ for _, name := range names {
+ m, merr := appconfig.LoadEnvironment(ctx, s.cfg.Releases, acct.Sqid, app, name)
+ if merr != nil {
+ log.Printf("console: settings environment %s/%s/%s: %v", acct.Sqid, app, name, merr)
+ continue
+ }
+ card := &EnvCard{Name: name}
+ varNames := make([]string, 0, len(m.Vars))
+ for k := range m.Vars {
+ varNames = append(varNames, k)
+ }
+ sort.Strings(varNames)
+ for _, k := range varNames {
+ card.Vars = append(card.Vars, EnvVarName{Name: k, Secret: m.Vars[k].Kind == "secret"})
+ }
+ for _, rt := range routes {
+ if rt.Environment == name {
+ card.Hosts = append(card.Hosts, rt.Host)
+ }
+ }
+ // The store is read through the same door the storage section uses,
+ // so the two sections cannot disagree about whether one exists.
+ if rec, _, rerr := objstore.GetIn(ctx, s.cfg.Releases, acct.Sqid, app, name); rerr != nil {
+ log.Printf("console: settings environment store %s/%s/%s: %v", acct.Sqid, app, name, rerr)
+ } else if rec != nil {
+ card.HasStore = true
+ switch {
+ case rec.Provisioned != nil && rec.Provisioned.Bucket != "":
+ card.StoreBucket = rec.Provisioned.Bucket
+ case rec.Grant != nil:
+ card.StoreBucket = rec.Grant.Bucket
+ }
+ }
+ cards = append(cards, card)
+ }
+ return cards, false
+}
+
// storeEnvCards is one card per environment store, in the order
// StoreEnvironments returns them (sorted). nil on a read failure, matching
// storeCard's own discipline: the page says nothing rather than claiming an
diff --git a/internal/console/appsettings_env_test.go b/internal/console/appsettings_env_test.go
new file mode 100644
index 000000000..db234834f
--- /dev/null
+++ b/internal/console/appsettings_env_test.go
@@ -0,0 +1,88 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/appconfig"
+ "github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// The Environments section is the first console surface for the config
+// bundles themselves. Until it existed the ladder showed which environment a
+// route was wired to, and nothing showed what the environments WERE.
+
+func TestSettingsPageShowsEnvironments(t *testing.T) {
+ ctx := context.Background()
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "sprout")
+
+ // A bundle with one plain var and one sealed one. The envelope is opaque
+ // to appconfig at set time, so it needs no real key here.
+ if _, err := appconfig.ApplyEnvironment(ctx, fx.rel, release.OpsAccount, "sprout", "acme",
+ map[string]appconfig.Var{
+ "APP_ORIGIN": {Kind: "env", Value: "https://acme.example.com"},
+ "ACME_API_KEY": {Kind: "secret", Envelope: "sealed-envelope-bytes"},
+ }, nil); err != nil {
+ t.Fatal(err)
+ }
+ // ...and a store of its own, provisioned, which nothing reads.
+ rec := &objstore.Record{
+ Declared: &objstore.Declared{EnvPrefix: "CARLOS_STORE", At: time.Now(), By: "member@example.com"},
+ Grant: &objstore.GrantBlock{Backend: "mem", Bucket: "carlos-bab-sprout-acme", Source: objstore.GrantSourceOperator, At: time.Now(), By: "op"},
+ Provisioned: &objstore.Provisioned{Bucket: "carlos-bab-sprout-acme", Region: "eu-west-1", IAMUser: "u", KeyID: "K", At: time.Now()},
+ }
+ if _, err := objstore.PutIn(ctx, fx.rel, release.OpsAccount, "sprout", "acme", rec, ""); err != nil {
+ t.Fatal(err)
+ }
+
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/sprout/settings/environments"))
+ if status != http.StatusOK {
+ t.Fatalf("environments page = %d:\n%s", status, body)
+ }
+ for _, want := range []string{
+ "acme", // the bundle
+ "APP_ORIGIN", // a name it sets
+ "ACME_API_KEY", // including the sealed one, by name
+ "carlos-bab-sprout-acme", // the store that belongs to it
+ "no instances", // nothing is bound to it
+ "no instance reads it", // ...so the bucket is billing for nobody
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("the environments section is missing %q:\n%s", want, body)
+ }
+ }
+ // NAMES, never values. This page has never shown a config value, and an
+ // environment bundle is exactly where a per-tenant origin or credential
+ // sits — so this is the assertion that keeps it that way.
+ if strings.Contains(body, "https://acme.example.com") {
+ t.Fatalf("the environments section printed a config VALUE:\n%s", body)
+ }
+ if strings.Contains(body, "sealed-envelope-bytes") {
+ t.Fatalf("the environments section printed a sealed envelope:\n%s", body)
+ }
+}
+
+// An app with no named environments says so, and says how to make one —
+// rather than rendering an empty shell that reads like a failure.
+func TestSettingsPageEnvironmentsEmptyState(t *testing.T) {
+ fx := newConsoleFixture(t)
+ freshApp(t, fx, "bare")
+ c := signInPaul(t, fx)
+
+ status, _, body := get(t, c, fx.u("/apps/bare/settings/environments"))
+ if status != http.StatusOK {
+ t.Fatalf("environments page = %d:\n%s", status, body)
+ }
+ if !strings.Contains(body, "No named environments") {
+ t.Fatalf("no empty-state line:\n%s", body)
+ }
+ if !strings.Contains(body, "carlos env set --app bare") {
+ t.Fatalf("the empty state does not say how to make one:\n%s", body)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index f0fd18ef3..7c1aedd22 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -3228,6 +3228,7 @@ func setAppSettingsSection(p *page, section string) {
{"general", "General"},
{"domains", "Domains"},
{"pipeline", "Release pipeline"},
+ {"environments", "Environments"},
{"storage", "Storage"},
{"email", "Email"},
{"features", "Feature flags"},
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index ea31a759f..1e7e857c3 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -161,6 +161,45 @@ different account moves its bucket objects and re-stamps its routes —
offers the final console-record step.</p>
{{end}}{{end}}
{{end}}
+{{if eq .SettingsSection "environments"}}
+{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot read config</p>{{end}}
+{{if .Settings.EnvsUnavailable}}<p class="banner error">Could not read this app's environments. Please try again.</p>{{end}}
+{{if .Settings}}
+<h2>Environments</h2>
+<p class="small">A named environment is a config bundle that overlays this
+app's default one, per key. An instance bound to it reads the app's config
+with these values on top — which is how two instances of one app serve
+different origins, and how one of them gets a bucket of its own.</p>
+{{if .Settings.Envs}}
+{{range .Settings.Envs}}
+<h3><code>{{.Name}}</code></h3>
+<div class="tablewrap">
+<table>
+ <tr><th>Sets</th><th>Read by</th><th>Object store</th></tr>
+ <tr>
+ <td>{{if .Vars}}{{range $i, $v := .Vars}}{{if $i}}, {{end}}<code>{{$v.Name}}</code>{{if $v.Secret}} <span class="small">sealed</span>{{end}}{{end}}{{else}}<span title="the bundle exists but sets nothing">&mdash;</span>{{end}}</td>
+ <td>{{if .Hosts}}{{range $i, $h := .Hosts}}{{if $i}}, {{end}}<a href="{{$.Nav}}/apps/{{$.Detail.App}}/instances/{{$h}}">{{$h}}</a>{{end}}{{else}}<span title="no instance is bound to this bundle">no instances</span>{{end}}</td>
+ <td>{{if .HasStore}}{{if .StoreBucket}}<code>{{.StoreBucket}}</code>{{else}}declared{{end}}{{else}}<span title="this environment has no store of its own">&mdash;</span>{{end}}</td>
+ </tr>
+</table>
+</div>
+{{if and .HasStore (not .Hosts)}}<p class="small">This environment has an object
+store but no instance reads it. The bucket still exists and still costs —
+destroying a store is always deliberate, so nothing has removed it. Its card is
+under <a href="{{$.Nav}}/apps/{{$.Detail.App}}/settings/storage">Storage</a>.</p>{{end}}
+{{end}}
+<p class="small">Values are not shown here. Read them with <code>carlos env list
+--app {{.Detail.App}} --environment &lt;name&gt;</code>; secrets list by name only,
+wherever you ask.</p>
+{{else}}{{if not .Settings.EnvsUnavailable}}
+<p class="small">No named environments. Every instance of this app reads its
+default config bundle. Add one with <code>carlos env set --app {{.Detail.App}}
+--environment &lt;name&gt; KEY=value</code>, then bind an instance to it with
+<code>carlos instances set-environment --app {{.Detail.App}} --host &lt;h&gt;
+--environment &lt;name&gt;</code>.</p>
+{{end}}{{end}}
+{{end}}
+{{end}}
{{if eq .SettingsSection "storage"}}
{{if .Settings.NoBucket}}<p class="banner error">no deployment bucket on this host — this console cannot serve object storage</p>{{end}}
{{if .Settings.StoreUnavailable}}<p class="banner error">Could not read platform state. Please try again.</p>{{end}}
diff --git a/store.go b/store.go
index 684676aaa..f5b865b39 100644
--- a/store.go
+++ b/store.go
@@ -206,11 +206,39 @@ func storeEnvironmentFlag(fs *flag.FlagSet, usage string) *string {
return fs.String("environment", "", usage)
}
+// storeEnvMismatch refuses a reply that does not name the environment we
+// asked about.
+//
+// A console older than per-environment stores does not read ?environment= at
+// all: it answers about the app's OWN store, with a 200 and a perfectly
+// ordinary body. So `carlos store create --app x --environment beta` against
+// an un-upgraded console would declare — or grant, or rotate — the WRONG
+// store and report success. The env prefix is fixed once granted, and a
+// rotation mints a credential, so that mistake is not always reversible.
+//
+// Every console that knows about environment stores echoes the environment
+// on every store reply, including "" for the app's own. An older one omits
+// the key, which decodes as "" — so a reply that does not say what we asked
+// about is the tell, and it is a refusal rather than a warning because the
+// caller cannot inspect what happened without the very verb that just lied.
+func storeEnvMismatch(res apiStoreReply, env string) error {
+ if env == "" || res.Environment == env {
+ return nil
+ }
+ return fmt.Errorf("this console answered about the app's own store, not the %q environment — "+
+ "it is older than per-environment object storage. Upgrade the console, or drop --environment", env)
+}
+
func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct, app, env, envPrefix string, out io.Writer) error {
res, err := sess.storeCreate(ctx, acct, app, env, envPrefix)
if err != nil {
return err
}
+ // Before printing: a reply about the wrong store must not be narrated as
+ // if it were the right one.
+ if err := storeEnvMismatch(res, env); err != nil {
+ return err
+ }
printStoreCreate(out, res)
if res.Backend == "" || !res.Entitlement.Approved() {
return nil
@@ -219,6 +247,9 @@ func storeCreateAndProvision(ctx context.Context, sess *apiSession, acct apiAcct
if err != nil {
return err
}
+ if err := storeEnvMismatch(res, env); err != nil {
+ return err
+ }
printStoreCreate(out, res)
return nil
}
@@ -247,6 +278,9 @@ func runStoreStatus(args []string) {
if err != nil {
log.Fatalf("store status: %v", err)
}
+ if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
+ log.Fatalf("store status: %v", err)
+ }
printStoreStatus(stdout, res)
}
@@ -275,6 +309,9 @@ func runStoreGrant(args []string) {
if err != nil {
log.Fatalf("store grant: %v", err)
}
+ if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
+ log.Fatalf("store grant: %v", err)
+ }
printStoreGrant(stdout, res)
}
@@ -303,6 +340,9 @@ func runStoreRotate(args []string) {
if err != nil {
log.Fatalf("store rotate: %v", err)
}
+ if err := storeEnvMismatch(res, strings.TrimSpace(*environment)); err != nil {
+ log.Fatalf("store rotate: %v", err)
+ }
printStoreRotate(stdout, res, *finish)
}
diff --git a/store_env_guard_test.go b/store_env_guard_test.go
new file mode 100644
index 000000000..b8fcd9363
--- /dev/null
+++ b/store_env_guard_test.go
@@ -0,0 +1,51 @@
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+// A console older than per-environment stores does not read ?environment= at
+// all: it answers about the app's OWN store, with a 200 and an ordinary body.
+// Every console that knows about them echoes the environment on every reply,
+// so a reply that does not name what we asked about is the tell.
+//
+// This matters because the verbs behind it are not all reversible: create
+// fixes the env prefix once granted, grant mints a bucket and an IAM user,
+// and rotate cycles a live credential. Acting on the wrong store and
+// reporting success is the failure mode being closed here.
+func TestStoreEnvMismatch(t *testing.T) {
+ cases := []struct {
+ name string
+ reply apiStoreReply
+ asked string
+ wantErr bool
+ }{
+ {"app store, nothing asked", apiStoreReply{Environment: ""}, "", false},
+ {"environment echoed back", apiStoreReply{Environment: "beta"}, "beta", false},
+ {"old console omits it", apiStoreReply{Environment: ""}, "beta", true},
+ {"answered about another environment", apiStoreReply{Environment: "alpha"}, "beta", true},
+ // An old console answering an app-store request is the ordinary case
+ // and must stay silent: this guard exists for the environment ask.
+ {"old console, app store asked", apiStoreReply{}, "", false},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ err := storeEnvMismatch(tc.reply, tc.asked)
+ if tc.wantErr != (err != nil) {
+ t.Fatalf("storeEnvMismatch(%+v, %q) = %v, wantErr %v", tc.reply, tc.asked, err, tc.wantErr)
+ }
+ if err == nil {
+ return
+ }
+ // The sentence has to name the environment asked for and the fix,
+ // because the caller cannot inspect what happened without the
+ // very verb that just answered about the wrong store.
+ for _, want := range []string{tc.asked, "older than per-environment", "Upgrade the console"} {
+ if !strings.Contains(err.Error(), want) {
+ t.Errorf("refusal %q does not contain %q", err, want)
+ }
+ }
+ })
+ }
+}