console: bake controls — on for members, off and fast-track behind owner + passkey
Three doors for the bake window (2026-08-04 console bake controls spec §7), with the gate order the repo has paid for: session → CSRF → owner as a byte-identical 404 → requireFreshAuth LAST. - POST .../production flips the per-app flag. ON is any member's, instantly: the safe direction stays cheap. OFF takes an account owner behind the passkey, because it removes a supply-chain guard from every subscriber — and a member probing it meets the 404 before any step-up redirect could confirm the account exists. - POST .../fasttrack waives the window for a channel's current head. Owner + passkey always; the pointer wears the badge and the actor forever. - enable_bake=1 on promote turns the flag on inside the same passkey-confirmed stable promotion, and only when it is off. The promote flash's bake note now follows the flag as well as the hold: with production defaulting off, "adopts after the bake window" was a promise a non-production app's boxes do not keep. An unreadable flag still shows the note — the adopt path fails toward baking, so that is the truth.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VioXzogWf1Xi4Rswz2B6EK
3 files changed,
+331
−4
internal/console/bakecontrols_test.go+192 −0internal/console/server.go+132 −1internal/console/server_test.go+7 −3
diff --git a/internal/console/bakecontrols_test.go b/internal/console/bakecontrols_test.go| new file mode 100644 |
| index 000000000..c252ef2af |
| --- /dev/null |
| +++ b/internal/console/bakecontrols_test.go |
| @@ -0,0 +1,192 @@ |
| +package console |
| + |
| +// The bake controls (2026-08-04 console bake controls spec §7). Properties |
| +// in the order they'd hurt if lost: OFF cannot happen without owner + |
| +// passkey (and a member probing it gets the byte-identical 404); ON is any |
| +// member's, instantly; fast-track is owner + passkey and lands on the |
| +// pointer; the stable-promote checkbox flips the flag in the same confirmed |
| +// action. |
| + |
| +import ( |
| + "context" |
| + "net/http" |
| + "net/url" |
| + "strings" |
| + "testing" |
| + "time" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// productionFlag reads the app's flag straight from the bucket, through the |
| +// same verification the adopt path uses. The fixture carries the release |
| +// public key, so there is nothing to derive. |
| +func productionFlag(t *testing.T, fx *consoleFixture, app string) *release.ProductionFlag { |
| + t.Helper() |
| + f, err := release.GetProduction(context.Background(), fx.rel, fx.pub, release.OpsAccount, app) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return f |
| +} |
| + |
| +func TestProductionOnIsAnyMembers(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-bake") |
| + csrf := sessionToken(t, fx, c) |
| + |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"on"}}) |
| + if status != http.StatusSeeOther { |
| + t.Fatalf("member turning bake ON = %d, want a plain redirect (no step-up)", status) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatalf("flag = %+v, want production on", f) |
| + } |
| +} |
| + |
| +func TestProductionOffIsOwnerPasskeyGated(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + // Seed on, as any member could. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + |
| + // Without the passkey the flag does not move. |
| + status, loc, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"off"}}) |
| + if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) { |
| + t.Fatalf("un-stepped-up off = %d -> %q, want a redirect into Keymail", status, loc) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatal("the flag flipped off with no passkey confirmation") |
| + } |
| + // With it, the held action resumes. |
| + if status, _, body := completeStepUp(t, fx, c, loc); status != http.StatusSeeOther { |
| + t.Fatalf("resumed off = %d:\n%s", status, body) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || f.Production { |
| + t.Fatal("the flag did not flip off after confirmation") |
| + } |
| +} |
| + |
| +// A MEMBER probing the off direction meets the byte-identical 404 — before |
| +// any step-up redirect could confirm the account exists. |
| +func TestProductionOffMemberGets404(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-bake2") |
| + csrf := sessionToken(t, fx, c) |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/production"), |
| + url.Values{"csrf": {csrf}, "production": {"off"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member turning bake OFF = %d, want 404", status) |
| + } |
| +} |
| + |
| +func TestFastTrackIsOwnerPasskeyGatedAndLands(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + // The fixture's stable already points at v1; waive it as the owner with |
| + // the full passkey round trip. |
| + postGated(t, fx, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + p := fx.channelVersion(t, "kass", "stable") |
| + if !p.FastTrack { |
| + t.Fatalf("pointer = %+v, want fast_track set", p) |
| + } |
| + if len(p.Audit) == 0 || !p.Audit[0].FastTrack || p.Audit[0].Actor != "paul@keymail.dev" { |
| + t.Fatalf("audit[0] = %+v, want the waiver recorded with its actor", p.Audit) |
| + } |
| +} |
| + |
| +// A MEMBER probing fast-track meets the byte-identical 404 — the loud |
| +// instant path past the window is the owner's alone. |
| +func TestFastTrackMemberGets404(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + if err := fx.st.AddMember(OpsAccountID, "sam@keymail.dev", RoleMember, time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + fx.setUser("sam@keymail.dev") |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-sam-bake3") |
| + csrf := sessionToken(t, fx, c) |
| + status, _, _ := postForm(t, c, fx.u("/apps/kass/fasttrack"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}}) |
| + if status != http.StatusNotFound { |
| + t.Fatalf("member fast-tracking = %d, want 404", status) |
| + } |
| + if p := fx.channelVersion(t, "kass", "stable"); p.FastTrack { |
| + t.Fatal("a member's post waived the bake window") |
| + } |
| +} |
| + |
| +// The stable-promote prompt (spec §7): enable_bake rides the same |
| +// passkey-confirmed promote, and flips the flag on only when it is off. |
| +func TestPromoteEnableBakeFlipsTheFlag(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + seedChannel(t, fx, "kass", "beta", "v3") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "channel": {"stable"}, "version": {"v3"}, "enable_bake": {"1"}}) |
| + if p := fx.channelVersion(t, "kass", "stable"); p.Version != "v3" { |
| + t.Fatalf("stable = %s, want v3", p.Version) |
| + } |
| + if f := productionFlag(t, fx, "kass"); f == nil || !f.Production { |
| + t.Fatalf("flag = %+v after enable_bake promote, want production on", f) |
| + } |
| + // The flash tells the truth about both halves of what just happened: the |
| + // flag went on in this action, so the bake note is now accurate. |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Supply-chain bake enabled.") || |
| + !strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("enable_bake flash missing its two halves:\n%s", body) |
| + } |
| +} |
| + |
| +// The bake note is a claim about this app, not about the channel: a |
| +// non-production app adopts a held channel immediately (spec §2), so |
| +// promising a window would be a lie. |
| +func TestPromoteBakeNoteFollowsTheFlag(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + shipVersion(t, fx, "kass", "v3") |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + // No flag at all: beta holds, but this app does not bake. (edge first — |
| + // the ladder promotes a build one rung at a time.) |
| + seedChannel(t, fx, "kass", "edge", "v3") |
| + postForm(t, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"beta"}}) |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("a flagless app was promised a bake window:\n%s", body) |
| + } |
| + |
| + // Flag on: the same rung now says so. |
| + if err := release.SetProduction(context.Background(), fx.rel, fx.priv, |
| + release.OpsAccount, "kass", true, "test"); err != nil { |
| + t.Fatal(err) |
| + } |
| + shipVersion(t, fx, "kass", "v4") |
| + seedChannel(t, fx, "kass", "edge", "v4") |
| + postForm(t, c, fx.u("/apps/kass/promote"), |
| + url.Values{"csrf": {csrf}, "version": {"v4"}, "channel": {"beta"}}) |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Adopts after the bake window") { |
| + t.Fatalf("a production app's held promotion did not mention the window:\n%s", body) |
| + } |
| +} |
diff --git a/internal/console/server.go b/internal/console/server.go| index 5e44aa521..eae1c5167 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -230,6 +230,8 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("GET /a/{sqid}/apps/{app}", s.handleApp) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/promote", s.handlePromote) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rollback", s.handleRollback) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/production", s.handleProduction) |
| + s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/fasttrack", s.handleFastTrack) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary", s.handleNewCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/canary/retire", s.handleRetireCanary) |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| @@ -1365,7 +1367,32 @@ func (s *Server) handlePromote(w http.ResponseWriter, r *http.Request) { |
| } |
| s.audit(acct, app, addr, "promote", "promoted "+version+" to "+channel) |
| msg := "Promoted " + version + " to " + channel + "." |
| - if release.HoldFor(release.DefaultHolds, channel) > 0 { |
| + // Whether this app bakes at all is the production flag's business (bake |
| + // controls spec §2): a non-production app's boxes adopt a held channel |
| + // immediately, so the window note below would be a lie for them. Read the |
| + // flag before the checkbox, which may still turn it on in this same |
| + // request. |
| + flag, ferr := release.GetProduction(ctx, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, app) |
| + if ferr != nil { |
| + log.Printf("console: production flag %s: %v", app, ferr) |
| + } |
| + // An unreadable flag fails TOWARD baking, exactly as the adopt path does |
| + // (spec §4) — so the note is still the truth about what the boxes will do. |
| + baking := ferr != nil || (flag != nil && flag.Production) |
| + // The spec §7 prompt: promote-to-stable asked "is this app in production |
| + // now?" and the human said yes — in the same passkey-confirmed action. |
| + // Turning ON is any member's anyway, so no extra gate; flips only when |
| + // the flag is actually off, so a stray field on a repeat is a no-op. |
| + if r.FormValue("enable_bake") == "1" && ferr == nil && !baking { |
| + if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, true, addr); err != nil { |
| + log.Printf("console: enable_bake %s: %v", app, err) |
| + } else { |
| + s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on, at stable promote)") |
| + msg += " Supply-chain bake enabled." |
| + baking = true |
| + } |
| + } |
| + if baking && release.HoldFor(release.DefaultHolds, channel) > 0 { |
| msg += " Adopts after the bake window on each host — rollback never bakes." |
| } |
| back("ok", msg) |
| @@ -1438,6 +1465,110 @@ func (s *Server) handleRollback(w http.ResponseWriter, r *http.Request) { |
| back("ok", "Rolled back "+channel+" to "+target+".") |
| } |
| +// handleProduction flips the app's production flag (2026-08-04 console bake |
| +// controls spec §7). ON is any member's to do, instantly — the safe |
| +// direction stays cheap. OFF removes a supply-chain guard from every |
| +// subscriber of the app, so it is the owner's, behind the passkey, and the |
| +// fleet ratchets it in on each box's own clock. Gate order is the mandated |
| +// one: session → CSRF → owner (byte-identical 404) → requireFreshAuth LAST. |
| +func (s *Server) handleProduction(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + var on bool |
| + switch r.FormValue("production") { |
| + case "on": |
| + on = true |
| + case "off": |
| + on = false |
| + default: |
| + back("err", "Invalid production value.") |
| + return |
| + } |
| + if !on { |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + if err := release.SetProduction(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, on, addr); err != nil { |
| + log.Printf("console: production %s -> %v: %v", app, on, err) |
| + back("err", "Could not update the production flag: "+err.Error()) |
| + return |
| + } |
| + if on { |
| + s.audit(acct, app, addr, "production", "enabled the supply-chain bake (production: on)") |
| + back("ok", "Supply-chain bake enabled — beta and stable now hold before hosts adopt.") |
| + } else { |
| + s.audit(acct, app, addr, "production", "disabled the supply-chain bake (production: off)") |
| + back("ok", "Supply-chain bake turning off — each host honors it after the stable window (~72h) on its own clock.") |
| + } |
| +} |
| + |
| +// handleFastTrack waives the bake window for one channel's CURRENT head — |
| +// owner + passkey, always: this is the loud instant path past the window |
| +// (spec §5, §9), and the pointer wears the badge forever. |
| +func (s *Server) handleFastTrack(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, acct, role, app, ok := s.accountApp(w, r) |
| + if !ok { |
| + return |
| + } |
| + if subtle.ConstantTimeCompare([]byte(r.FormValue("csrf")), []byte(raw)) != 1 { |
| + s.renderMessage(w, r, http.StatusForbidden, "Refused", "Cross-site request blocked.") |
| + return |
| + } |
| + if s.cfg.Key == nil { |
| + s.renderMessage(w, r, http.StatusServiceUnavailable, "Promotions disabled", |
| + "No signing key on this host — the console is running read-only.") |
| + return |
| + } |
| + back := func(kind, msg string) { |
| + s.setFlash(w, kind, msg) |
| + http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app), http.StatusSeeOther) |
| + } |
| + channel := strings.TrimSpace(r.FormValue("channel")) |
| + if !release.ValidChannel(channel) { |
| + back("err", "Invalid channel name.") |
| + return |
| + } |
| + if role != RoleOwner { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + if !s.requireFreshAuth(w, r, addr, raw) { |
| + return |
| + } |
| + ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) |
| + defer cancel() |
| + ver, err := release.FastTrack(ctx, s.cfg.Releases, s.cfg.Key, acct.Sqid, app, channel, addr) |
| + if err != nil { |
| + log.Printf("console: fast-track %s %s: %v", app, channel, err) |
| + back("err", "Fast-track failed: "+err.Error()) |
| + return |
| + } |
| + s.audit(acct, app, addr, "fast-track", "waived the bake window for "+ver+" on "+channel) |
| + back("ok", "Fast-tracked "+ver+" on "+channel+" — hosts adopt on their next pass.") |
| +} |
| + |
| // ---- canaries --------------------------------------------------------------- |
| // canaryNameRe pins canary names to the same boring slugs as app names — the |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 3f078f8ae..c959921af 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -497,7 +497,7 @@ func TestPromoteFlow(t *testing.T) { |
| _, _, body = get(t, c, fx.u("/apps/kass")) |
| // edge holds zero — pipelines spec: merged to main is picked up as it lands. |
| // The flash for an edge promotion contains only "Promoted v3 to edge." without |
| - // the "bake window" text that appears for channels with a hold (beta, stable). |
| + // the "bake window" text that a held channel gets on a PRODUCTION app. |
| if !strings.Contains(body, `<p class="banner">Promoted v3 to edge.</p>`) { |
| t.Fatalf("edge promotion flash incorrect:\n%s", body) |
| } |
| @@ -509,8 +509,12 @@ func TestPromoteFlow(t *testing.T) { |
| if p := fx.channelVersion(t, "kass", "beta"); p.Version != "v3" { |
| t.Fatalf("beta pointer = %+v, want v3", p) |
| } |
| - if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, "Promoted v3 to beta.") { |
| - t.Fatal("no beta promote flash") |
| + // beta HOLDS, but this app has no production flag — the default is not |
| + // production (bake controls spec §2), so its boxes adopt beta immediately |
| + // and the flash must not promise a window. The exact banner is asserted, |
| + // not a substring: "no bake note here" is the property under test. |
| + if _, _, body := get(t, c, fx.u("/apps/kass")); !strings.Contains(body, `<p class="banner">Promoted v3 to beta.</p>`) { |
| + t.Fatalf("beta promotion flash incorrect (a flagless app must not be promised a bake window):\n%s", body) |
| } |
| postGated(t, fx, c, fx.u("/apps/kass/promote"), |
| url.Values{"csrf": {csrf}, "version": {"v3"}, "channel": {"stable"}}) |