carlos / platform Public

console: an owner can clear the control-plane flag, and CI can preview retention

Ruling 4 — the clear door, which the refusals were pointing at:

Every refusal says "clear the control-plane flag first", and until now no door
could. That is user-facing text instructing an operator to do something
impossible, on the exact path where they are already blocked. The door is
browser-only and passkey-gated (spec §6): owner, CSRF, a cheap
nothing-to-clear refusal, then requireFreshAuth LAST — handleAppRename's order
unchanged. The settings page gains a card that says what clearing gives up,
and a flagged app is shown that sentence instead of a rename form the handler
would refuse.

There is deliberately NO API clear door, and the handler says why: the
console's fresh-auth 403 is a dead end for an API caller — the defect that
makes a promote gate unworkable — so a CLI clear would be ungated or unusable.
Setting is easy (CLI, at claim time); clearing is hard.

Ruling 5 — retention's dry run is allowed for a service; the branches that
write are still refused. A preview mutates nothing, and CI that cannot see
what a policy would do cannot bring a person a reviewed proposal. The guard
reads the same decoded struct the switch reads: one parse, no field that can
flip the branch afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev fad21323e26694f635e620f3d1710ab7915916a2 parent dd4878c
6 files changed, +238 −14
  • internal/console/apiretention.go +16 −13
  • internal/console/appsettings.go +13 −0
  • internal/console/controlplane.go +72 −0
  • internal/console/controlplane_test.go +112 −0
  • internal/console/server.go +3 −0
  • internal/console/templates/app_settings.html +22 −1
diff --git a/internal/console/apiretention.go b/internal/console/apiretention.go
index 4deb47ba6..8411601da 100644
--- a/internal/console/apiretention.go
+++ b/internal/console/apiretention.go
@@ -104,19 +104,6 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) {
"no deployment bucket on this host — this console cannot write retention")
return
}
- // Retention is how the rollback DEPTH behind a control-plane app gets
- // shortened, so it wears the promote pair's service refusal: a CI
- // credential must not quietly shrink the console's own history. A PERSON
- // still may — how much history to keep is an operator's decision, and
- // PruneEligible never touches anything promoted, tagged or recent.
- //
- // The whole door, dry run included: this is a POST whose default branch is
- // the write, and a credential that cannot set retention has no use for a
- // preview of setting it. Reading the current policy stays open — that is
- // the GET (controlplane.go).
- if s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") {
- return
- }
var in struct {
Keep int `json:"keep"`
Off bool `json:"off"`
@@ -136,6 +123,22 @@ func (s *Server) handleAPIRetentionSet(w http.ResponseWriter, r *http.Request) {
release.KeepFloor, release.KeepFloor))
return
}
+ // Retention is how the rollback DEPTH behind a control-plane app gets
+ // shortened, so the branches that WRITE wear the promote pair's service
+ // refusal: a CI credential must not quietly shrink the console's own
+ // history. A PERSON still may — how much history to keep is an operator's
+ // decision, and PruneEligible never touches anything promoted, tagged or
+ // recent (controlplane.go).
+ //
+ // The dry run is exempt because it writes nothing, and a CI that cannot
+ // see what a policy WOULD do has no way to bring a person a reviewed
+ // proposal. The condition reads `in`, which is decoded once above and
+ // never written again, and the switch below re-reads that same struct —
+ // there is no second parse and no request field that could flip the
+ // branch after this check.
+ if !in.DryRun && s.refuseServiceControlPlane(w, p, acct.ID, app, "change its release retention") {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
switch {
diff --git a/internal/console/appsettings.go b/internal/console/appsettings.go
index b2916fa47..bb27f9054 100644
--- a/internal/console/appsettings.go
+++ b/internal/console/appsettings.go
@@ -49,6 +49,14 @@ type SettingsView struct {
CanRename bool
RenameBlocked string
+ // ControlPlane: this app IS the platform, so its destructive verbs are
+ // refused until the flag is cleared (controlplane.go). Read straight
+ // from the row rather than derived, and false when the row cannot be
+ // read — the PAGE showing no card is the right failure, since the
+ // handler re-decides everything anyway and the refusals do not depend
+ // on this being rendered.
+ ControlPlane bool
+
// The account-move repair: MismatchTarget is the account the app's
// routes say it belongs to ("" when console and routes agree),
// TargetKnown whether this console has that account at all, and
@@ -129,6 +137,11 @@ func (s *Server) assembleSettings(ctx context.Context, p *page, acct *Account) *
// with no bucket configured (s.cfg.Releases == nil), which used to
// return before reaching them and show "No domains yet" under a live
// route.
+ if cp, _, err := s.store.AppFlags(acct.ID, p.AppName); err != nil {
+ log.Printf("console: settings control-plane flag %s/%s: %v", acct.Sqid, p.AppName, err)
+ } else {
+ sv.ControlPlane = cp
+ }
sv.Domains = s.settingsDomains(ctx, acct, p.AppName, p.Detail.Domains)
sv.OnlyPlatformDomain = len(sv.Domains) > 0
for _, d := range sv.Domains {
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
index 8f5696a10..216fefb71 100644
--- a/internal/console/controlplane.go
+++ b/internal/console/controlplane.go
@@ -18,11 +18,19 @@
//
// The one promote-side restriction is about WHO, not whether: a person with
// ops membership may roll the control plane, a service credential may not.
+//
+// The CLEAR door lives here too (handleAppControlPlaneClear), rather than in
+// appsettings.go with the settings tab's other writes, because the refusals it
+// unlocks are in this file and the two must be read together: every refusal
+// above ends "clear the control-plane flag first", and this is the only place
+// that sentence can be acted on.
package console
import (
+ "crypto/subtle"
"log"
"net/http"
+ "net/url"
"strings"
)
@@ -155,3 +163,67 @@ func isare(n int) string {
}
return "are"
}
+
+// handleAppControlPlaneClear takes the flag off, which is the only way past
+// every refusal in this file.
+//
+// BROWSER ONLY, and that is the design rather than a gap (spec §6). Setting
+// the flag is easy — the CLI, at claim time — and clearing it is hard: the
+// owner, in a browser, with a passkey. There is deliberately no API door: the
+// console's fresh-auth 403 is a dead end for an API caller (the same defect
+// that makes a promote gate unworkable), so a CLI clear would have to be
+// either ungated, which defeats the flag, or unusable, which is worse than
+// absent.
+//
+// The gate order is handleAppRename's, unchanged:
+//
+// accountApp — session, then the app's byte-identical 404.
+// CSRF — constant-time, before any pending action can be parked.
+// owner — a member probing this gets a stranger's answer.
+// nothing to clear — a cheap refusal, so an already-clear app never costs a
+// passkey touch.
+// requireFreshAuth — LAST, so no redirect into Keymail ever happens for
+// someone who should have got a 404.
+//
+// No serial bump: the flag is this console's own row and no box parses it.
+func (s *Server) handleAppControlPlaneClear(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 role != RoleOwner {
+ http.NotFound(w, r)
+ return
+ }
+ back := func(kind, msg string) {
+ s.setFlash(w, kind, msg)
+ http.Redirect(w, r, "/a/"+url.PathEscape(acct.Sqid)+"/apps/"+url.PathEscape(app)+"/settings", http.StatusSeeOther)
+ }
+ // Fail CLOSED on an unreadable row, like every other reader of this flag:
+ // clearing what may already be clear is harmless, but a lookup error here
+ // would otherwise be reported to the operator as a successful clear.
+ cp, _, err := s.store.AppFlags(acct.ID, app)
+ if err != nil {
+ log.Printf("console: control-plane clear read %s/%s: %v", acct.Sqid, app, err)
+ back("err", "Could not read this app's control-plane setting — nothing was changed. Try again.")
+ return
+ }
+ if !cp {
+ back("err", app+" is not marked as the control plane, so there is nothing to clear.")
+ return
+ }
+ if !s.requireFreshAuth(w, r, addr, raw) {
+ return
+ }
+ if err := s.store.SetAppControlPlane(acct.ID, app, false); err != nil {
+ log.Printf("console: control-plane clear %s/%s: %v", acct.Sqid, app, err)
+ back("err", "Could not clear the control-plane flag — nothing was changed. Try again.")
+ return
+ }
+ s.audit(acct, app, addr, "control-plane", "cleared the control-plane flag")
+ back("ok", "Cleared the control-plane flag on "+app+" — it can now be renamed, moved and deleted like any other app.")
+}
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index 64a18172f..780c2393f 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -269,3 +269,115 @@ func TestControlPlaneRetentionRefusesAServiceButNotAPerson(t *testing.T) {
t.Fatalf("a PERSON setting retention on the control plane = %d %v, want 200 — only the service is barred", status, out)
}
}
+
+// TestServiceMayPreviewRetentionButNotApplyIt: a preview writes nothing, and
+// refusing it would leave CI no way to see what a policy WOULD do before a
+// person applies it. The refusal is scoped to the branch that mutates.
+func TestServiceMayPreviewRetentionButNotApplyIt(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-dryrun")
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Publish)
+
+ status, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
+ map[string]any{"keep": release.KeepFloor, "dry_run": true}, svc)
+ if status != http.StatusOK {
+ t.Fatalf("a SERVICE previewing retention on the control plane = %d %v, want 200", status, out)
+ }
+ if dry, _ := out["dry_run"].(bool); !dry {
+ t.Fatalf("preview answer is not a dry run: %v", out)
+ }
+ // The same credential, the same door, the branch that writes: refused.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
+ map[string]any{"keep": release.KeepFloor}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE applying retention = %d %v, want 403", status, out)
+ }
+ // And `off`, which is a write of its own shape.
+ status, out = postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/retention",
+ map[string]any{"off": true}, svc)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE clearing retention = %d %v, want 403", status, out)
+ }
+}
+
+// TestOwnerClearsTheControlPlaneFlag is the door the refusals point at. Every
+// refusal in this file says "clear the control-plane flag first"; without a
+// way to do that, the sentence instructs an operator to do something
+// impossible on the exact path where they are already blocked.
+//
+// Browser-only and passkey-gated by design (spec §6): setting the flag is
+// easy (the CLI, at claim time), clearing it is hard. There is deliberately
+// no API clear door — the console's fresh-auth 403 is a dead end for an API
+// caller, so a CLI clear would be either ungated or unusable.
+func TestOwnerClearsTheControlPlaneFlag(t *testing.T) {
+ fx, token := controlPlaneFixture(t, "code-cp-clear")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ // The page has to OFFER it, or the door might as well not exist.
+ if _, _, page := get(t, c, fx.u("/apps/console/settings")); !strings.Contains(page, "/apps/console/control-plane/clear") {
+ t.Fatalf("the settings page offers no way to clear the flag:\n%s", page)
+ }
+ status, loc, body := postGated(t, fx, c, fx.u("/apps/console/control-plane/clear"),
+ url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("clear the flag = %d -> %q:\n%s", status, loc, body)
+ }
+ cp, _, err := fx.st.AppFlags(OpsAccountID, "console")
+ if err != nil || cp {
+ t.Fatalf("AppFlags after a confirmed clear = %v %v, want false", cp, err)
+ }
+ // The card is state-driven: with the flag gone, so is the button.
+ if _, _, page := get(t, c, fx.u("/apps/console/settings")); strings.Contains(page, "/apps/console/control-plane/clear") {
+ t.Fatalf("the clear form is still offered on an app that is no longer flagged:\n%s", page)
+ }
+ // And the verb that was refused now works — the whole point of the door.
+ status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/console/delete", map[string]string{}, token)
+ if status2 != http.StatusOK {
+ t.Fatalf("trash after clearing the flag = %d %v, want 200", status2, out)
+ }
+}
+
+// TestClearingTheControlPlaneFlagDemandsAPasskey: without the step-up the
+// clear does not happen. postForm, not postGated, so the passkey round trip
+// is visible rather than walked.
+func TestClearingTheControlPlaneFlagDemandsAPasskey(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-clear-nopasskey")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, body := postForm(t, c, fx.u("/apps/console/control-plane/clear"),
+ url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
+ t.Fatalf("clear without a passkey = %d -> %q, want the step-up redirect:\n%s", status, loc, body)
+ }
+ if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp {
+ t.Fatalf("AppFlags after an unconfirmed clear = %v %v, want true (still flagged)", cp, err)
+ }
+}
+
+// TestMemberCannotClearTheControlPlaneFlag proves the gate ORDER as much as
+// the permission: a member meets the byte-identical 404, and never a redirect
+// into Keymail — requireFreshAuth is LAST, so no step-up is ever offered to
+// someone who should have got a 404.
+func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) {
+ fx, _ := controlPlaneFixture(t, "code-cp-clear-member")
+ if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ fx.setUser("kim@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-cp-clear-kim")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/console/control-plane/clear"),
+ url.Values{"csrf": {csrf}})
+ if status != http.StatusNotFound {
+ t.Fatalf("a member clearing the flag = %d -> %q, want the byte-identical 404", status, loc)
+ }
+ if loc != "" {
+ t.Fatalf("a member was redirected to %q — step-up must never be offered ahead of the 404", loc)
+ }
+ if cp, _, err := fx.st.AppFlags(OpsAccountID, "console"); err != nil || !cp {
+ t.Fatalf("AppFlags after a member's attempt = %v %v, want true (still flagged)", cp, err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 981579f19..e9b5af180 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -749,6 +749,9 @@ func NewServer(cfg Config, st *Store) (*Server, error) {
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/rename", s.handleAppRename)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/delete", s.handleAppDelete)
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/move", s.handleAppMoveAccount)
+ // Clearing the control-plane flag is browser-only and passkey-gated by
+ // design; there is deliberately no API sibling (controlplane.go).
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear)
// Custom domain attach/detach (appdomains.go, #82): owner-only, no
// passkey step-up (spec §6) — detach's guard is the typed-host confirm.
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/domains/attach", s.handleAppDomainAttach)
diff --git a/internal/console/templates/app_settings.html b/internal/console/templates/app_settings.html
index adff2cacf..39e343f78 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -11,7 +11,11 @@
</table>
</div>
{{if .IsOwner}}
-{{if .Settings.CanRename}}
+{{if .Settings.ControlPlane}}
+<p class="small">This app is marked as the <strong>control plane</strong> — it
+is this deployment&rsquo;s own platform. Renaming, moving and deleting it are
+refused while the flag is set, and a service credential cannot promote it.</p>
+{{else if .Settings.CanRename}}
<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/rename" class="settings-row">
<input type="hidden" name="csrf" value="{{.CSRF}}">
<label class="visually-hidden" for="rename-to">New name</label>
@@ -157,6 +161,23 @@ 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 and .Settings .Settings.ControlPlane}}{{if .IsOwner}}
+
+<h2 class="danger-head">Control plane</h2>
+<p class="small">{{.Detail.App}} is marked as the control plane: it is what
+serves this deployment, so its release history is what the fleet is running.
+Clearing the flag removes that protection — trash, permanent deletion, rename
+and the account move all become available, and a service credential can promote
+it again.</p>
+<p class="small">Clearing is one-way from here: the flag is stamped when an app
+is claimed (<code>carlos apps create -control-plane</code>), and this page
+cannot put it back.</p>
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/clear" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" class="btn-sm btn-danger">Clear the control-plane flag</button>
+</form>
+<p class="small">Confirms with your passkey.</p>
+{{end}}{{end}}
{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>