carlos / platform Public

console: an owner can mark an app as the control plane, without a passkey

Ruling 6. Clearing the flag was one-way: it could only be SET at claim time,
so an owner who cleared it on a live console had no way back except a sqlite
write on the box — which is the product gap CLAUDE.md refuses to accept, not a
workaround.

The settings card now carries both directions, owner-only, with spec §6's
asymmetry: clearing is the dangerous act and keeps requireFreshAuth LAST;
setting only ADDS refusals, so it deliberately demands no step-up. Making the
safe direction expensive would discourage it and teach nobody anything. Both
directions are audited, and the card explains what marking an app costs and
what it does not — a person with ops membership still deploys it exactly as
before.

The card's one-way warning is gone, because it is no longer true.

Ruling 7, recorded not coded: nothing constrains an account to a single
flagged app, and nothing should. controlPlaneAppsIn already returns a list and
the account refusal names every app it finds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e00b64e6a3dfd275d5ea54b7baa29b06d482b814 parent fad2132
4 files changed, +137 −8
  • internal/console/controlplane.go +49 −0
  • internal/console/controlplane_test.go +64 −0
  • internal/console/server.go +5 −2
  • internal/console/templates/app_settings.html +19 −6
diff --git a/internal/console/controlplane.go b/internal/console/controlplane.go
index 216fefb71..a9c545768 100644
--- a/internal/console/controlplane.go
+++ b/internal/console/controlplane.go
@@ -227,3 +227,52 @@ func (s *Server) handleAppControlPlaneClear(w http.ResponseWriter, r *http.Reque
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.")
}
+
+// handleAppControlPlaneSet is the other direction, and the reason the flag is
+// not a trap: an owner who clears it must be able to put it back without a
+// database write on a box — the platform's own rule is that a job with no
+// API/CLI door is a product gap, not a license to open sqlite.
+//
+// Deliberately NO fresh auth (spec §6). The asymmetry is the whole design:
+// setting the flag only ADDS refusals, so the dangerous direction is clearing,
+// and that is the one that costs a passkey. Making setting expensive would
+// discourage the safe act and teach nobody anything.
+//
+// Owner-only all the same: declaring an app to BE the platform decides what
+// the account's other owners can no longer delete, which is not a member's
+// call.
+func (s *Server) handleAppControlPlaneSet(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)
+ }
+ cp, _, err := s.store.AppFlags(acct.ID, app)
+ if err != nil {
+ log.Printf("console: control-plane set 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 already marked as the control plane.")
+ return
+ }
+ if err := s.store.SetAppControlPlane(acct.ID, app, true); err != nil {
+ log.Printf("console: control-plane set %s/%s: %v", acct.Sqid, app, err)
+ back("err", "Could not mark this app as the control plane — nothing was changed. Try again.")
+ return
+ }
+ s.audit(acct, app, addr, "control-plane", "marked "+app+" as the control plane")
+ back("ok", "Marked "+app+" as the control plane — trashing, deleting, renaming and moving it are refused until the flag is cleared.")
+}
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index 780c2393f..f6d5ca05b 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -381,3 +381,67 @@ func TestMemberCannotClearTheControlPlaneFlag(t *testing.T) {
t.Fatalf("AppFlags after a member's attempt = %v %v, want true (still flagged)", cp, err)
}
}
+
+// TestOwnerSetsTheControlPlaneFlagWithoutAPasskey is the other half of the
+// card, and it is what stops the flag being a one-way trap: an owner who
+// clears it on a live console must be able to put it back without a database
+// write on the box.
+//
+// postForm, not postGated: setting the flag is the SAFE direction (spec §6),
+// so it deliberately demands no step-up — a redirect into Keymail here would
+// be the asymmetry inverted.
+func TestOwnerSetsTheControlPlaneFlagWithoutAPasskey(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cp-set")
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ if _, _, page := get(t, c, fx.u("/apps/titogo/settings")); !strings.Contains(page, "/apps/titogo/control-plane/set") {
+ t.Fatalf("the settings page offers no way to set the flag:\n%s", page)
+ }
+ status, loc, body := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
+ url.Values{"csrf": {csrf}})
+ if status != http.StatusSeeOther {
+ t.Fatalf("set the flag = %d -> %q:\n%s", status, loc, body)
+ }
+ if loc != fx.path("/apps/titogo/settings") {
+ t.Fatalf("set redirected to %q, want the settings tab — a Keymail redirect means setting grew a passkey it must not have", loc)
+ }
+ if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || !cp {
+ t.Fatalf("AppFlags after a set = %v %v, want true", cp, err)
+ }
+ // The refusals came back with it — the set is not decoration.
+ status2, out := postJSON(t, fx.ts.URL+"/api/cli/apps/bab/titogo/delete", map[string]string{}, token)
+ if status2 != http.StatusForbidden {
+ t.Fatalf("trash after setting the flag = %d %v, want 403", status2, out)
+ }
+}
+
+// TestMemberCannotSetTheControlPlaneFlag: declaring an app to be the platform
+// is an owner's act, and a member meets the same byte-identical 404 the clear
+// door gives them — with no step-up ever offered ahead of it.
+func TestMemberCannotSetTheControlPlaneFlag(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "titogo", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ 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-set-kim")
+ csrf := sessionToken(t, fx, c)
+
+ status, loc, _ := postForm(t, c, fx.u("/apps/titogo/control-plane/set"),
+ url.Values{"csrf": {csrf}})
+ if status != http.StatusNotFound || loc != "" {
+ t.Fatalf("a member setting the flag = %d -> %q, want the byte-identical 404", status, loc)
+ }
+ if cp, _, err := fx.st.AppFlags(OpsAccountID, "titogo"); err != nil || cp {
+ t.Fatalf("AppFlags after a member's attempt = %v %v, want false", cp, err)
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index e9b5af180..ff4d49cd2 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -749,9 +749,12 @@ 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).
+ // The control-plane flag's two directions, both browser-only and both
+ // owner-only. Clearing is passkey-gated, setting is not — the asymmetry
+ // is spec §6's, and there is deliberately no API sibling for either
+ // (controlplane.go).
s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/clear", s.handleAppControlPlaneClear)
+ s.mux.HandleFunc("POST /a/{sqid}/apps/{app}/control-plane/set", s.handleAppControlPlaneSet)
// 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 39e343f78..469278b8d 100644
--- a/internal/console/templates/app_settings.html
+++ b/internal/console/templates/app_settings.html
@@ -161,23 +161,36 @@ 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}}
+{{if and .Settings .IsOwner}}
<h2 class="danger-head">Control plane</h2>
+{{if .Settings.ControlPlane}}
<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}}
+<p class="small">Confirms with your passkey — clearing is the dangerous
+direction, so it is the one that asks. Marking an app again does not.</p>
+{{else}}
+<p class="small">{{.Detail.App}} is an ordinary app. Marking it as the control
+plane says it IS this deployment&rsquo;s platform: trashing, permanent
+deletion, renaming and the account move are then refused, the account holding
+it cannot be retired, and a service credential can neither promote it nor
+shorten its release history. A person with ops membership still deploys it
+exactly as before.</p>
+<form method="post" action="{{.Base}}/apps/{{.Detail.App}}/control-plane/set" class="settings-row">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button type="submit" class="btn-sm">Mark as control plane</button>
+</form>
+<p class="small">No passkey: marking only adds refusals, and it is undone from
+this same card.</p>
+{{end}}
+{{end}}
{{if .IsOwner}}
<h2 class="danger-head">Delete this app</h2>