carlos / platform Public

Merge pull request #345 from carlosframework/cp-instance-doors

Guard the provision-record lifecycle: creating, mutating or removing a record for a control-plane app transfers or destroys reconciler authority over its route. Removal refused outright; creation and mutation refuse a service principal only. Found mid-rollout — the console cannot become instance-backed until this exists.
Paul Campbell pushed by paul@keymail.dev d27e2f01338855e234248083ea5061c6b7595d4d parent 0ac8c98parent 09f8d8e
5 files changed, +595 −6
  • docs/superpowers/specs/2026-08-23-console-as-an-app-design.md +72 −6
  • internal/console/apidomains.go +56 −0
  • internal/console/apiinstances.go +42 −0
  • internal/console/controlplane_test.go +414 −0
  • internal/console/domainattach.go +11 −0
diff --git a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
index 6a9b61c62..24c2e339b 100644
--- a/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
+++ b/docs/superpowers/specs/2026-08-23-console-as-an-app-design.md
@@ -375,7 +375,7 @@ declared target and what was found; a binary whose embedded build ≠ `-version`
is refused naming both; a matching artifact ships; a non-control-plane app is
unaffected; a non-ELF artifact (a static tarball) is unaffected.
-## 6. The control-plane flag gates DESTRUCTION, not promotion
+## 6. The control-plane flag gates DESTRUCTION, not promotion — and narrows a service principal's authority
```sql
ALTER TABLE apps ADD COLUMN control_plane INTEGER NOT NULL DEFAULT 0;
@@ -405,8 +405,62 @@ An app carrying the flag:
- **refuses promotion by a service principal.** A person with ops membership
can roll the console; a CI credential cannot. This closes the hole §10 step
ordering would otherwise leave open, without a step-up flow.
+- **refuses an instance-record DELETE outright** (added mid-rollout, 2026-08-28).
+ This is the eighth door and the shortest fuse of the set. `carlos deploy`
+ resolves hosts through `apps/<sqid>/<app>/instances/<host>.json`, so making
+ the console deployable means giving it a record — and a record is an
+ authority transfer: once it exists and the route is `Provisioned`, the
+ provisioning reconciler's removal phase retires the route the moment the
+ record goes. `DELETE /api/cli/apps/{account}/{app}/instances/{host}` is
+ `svcrole.Allow(svcrole.Instance)`, so one call from a person *or* a CI
+ credential holding that role took the control plane off the air. Same class
+ as trash and purge; same refusal. **The console must not become
+ instance-backed until this guard is in place.**
+- **refuses an instance CREATE or PATCH by a service principal.** A person must
+ be able to write the console's record — that is the runbook step the whole
+ §10 sequence turns on — but a CI credential must not acquire the reconciler's
+ authority over the console's route, and PATCH is a repoint: the reconciler
+ converges a route's Channel and Environment *from* this record on every pass.
+ Same person/service split as promote, for the same reason.
+- **refuses a domain DETACH outright, and a domain ATTACH by a service
+ principal.** `DELETE /api/cli/apps/{account}/{app}/domains/{host}`'s
+ first-route branch calls the same `provision.DeleteRecord` the instance
+ delete does, and `POST .../domains`'s first-route branch *writes* a declared
+ record — both registered `svcrole.Allow(svcrole.Publish, svcrole.Instance)`,
+ so a service credential reaches either. Guarding the instance family and
+ leaving these open would be a guard with a published way round it. Detach's
+ browser twin (`detachDomain`) carries the same outright refusal; attach's
+ needs none, because a browser session's principal is always a person.
+ Neither guard is conditional on the record-touching branch: a conditional
+ would have to re-derive "does this claim mint the app's route" ahead of the
+ code that derives it, and the two would drift.
- gets §5's two ship-time refusals.
+**The closure principle — read this before adding a tenth door.** The set above
+is not a list to extend by taste; it is the **`provision.Record` lifecycle**,
+closed. A record is what grants the provisioning reconciler authority over a
+route, so every door that CREATES, MUTATES or REMOVES one for a flagged app
+carries a guard: create (`POST .../instances`, `POST .../domains`) and mutate
+(`PATCH .../instances/{host}`) refuse a **service principal**, because a person
+performing them is a legitimate operator act; remove
+(`DELETE .../instances/{host}`, `DELETE .../domains/{host}`) refuses
+**outright**, because removal is destruction and belongs with trash and purge.
+A future door that touches that lifecycle needs the same guard by the same
+argument, and one that does not, does not.
+
+Two lifecycle sites carry no `control_plane` call and are still covered, which
+is worth writing down so the next audit does not read them as gaps.
+`POST .../instances/{host}/channel` (`channelmove.go`) MUTATES a record, and is
+registered with the **zero `svcrole.Set{}`** — no service principal reaches it
+at all, admin included, which enforces the service half more strictly than a
+refusal would; a person repointing the control plane's channel is the same
+legitimate operator act as promoting it. And `rollbackAttach`
+(`domainattach.go`) deletes a record, but it is not a door: it is the
+compensating delete inside the attach path, reachable only once that path's own
+guard has passed, and it removes only the record the same call just tried to
+write — guarding it would strand half-written attaches instead of protecting
+anything.
+
Promotion by a **person** is gated by ops membership, which is already the
operator boundary, and by nothing else. That is what keeps
`carlos deploy -app console` working as an ordinary command.
@@ -418,11 +472,23 @@ no-box-commands corollary. It is set at claim time: `carlos apps create` gains
does not).
**Test:** trash/purge/rename/transfer on a flagged app are refused naming the
-flag; a service principal's promote is refused; a person's promote at any rung
-succeeds with ordinary membership; clearing the flag requires fresh auth;
-setting it requires owner; an unflagged app is unaffected in every case; the
-refusal sits behind the existing 404 so it does not leak the flag's existence
-to a non-member.
+flag; the instance-record DELETE is refused for a person AND for a service, and
+the record survives; a domain detach is refused for a person and a service at
+the API door and for the owner at the browser door, and the claim survives; a
+service principal's promote, retention change, instance create, instance patch
+and domain attach are each refused — the attach in BOTH request shapes, alias
+and first-route, since the guard is not conditional on the branch — and each
+for both service roles the domains routes admit; a person's promote at any rung succeeds with ordinary
+membership, and a person can still CREATE and PATCH the flagged app's instance
+record and ATTACH a domain to it — asserted on the stored record and the stored
+claim, not on a status code, because that half is the runbook step the flag must
+not break; on every service-refused and outright-refused door, clearing the flag
+lets the IDENTICAL call through, which is what proves the flag is the only
+variable; clearing the flag requires fresh
+auth; setting it requires owner; an unflagged app is unaffected in every case;
+and every refusal sits behind the existing 404, so a non-member probing any of
+these doors gets the byte-identical not-found and never learns the flag's
+existence.
## 7. The version stamp and the dirty tree
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 071846791..e93fa55b7 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -209,6 +209,38 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // The CREATE half of the provision-record lifecycle, reached through
+ // this door rather than the instance one: the first-route branch below
+ // writes a declared provision.Record (attachDomain, domainattach.go),
+ // and a record is what transfers reconciler authority over the app's
+ // route. `POST .../instances` already refuses a service for exactly
+ // that; leaving this open would let a CI credential acquire the same
+ // authority by taking the other door.
+ //
+ // SERVICE-ONLY, not outright, and the split is the one the flag draws
+ // everywhere it can: a person attaching a domain to the control plane is
+ // an ordinary operator act — the same class as writing its instance
+ // record — while a CI credential doing it is the console's route being
+ // claimed by something with no standing to claim it. Detach is the
+ // asymmetric one (refused outright) because REMOVAL is destruction.
+ //
+ // Placed straight after domainsGate, which puts it after the family's
+ // byte-identical 404 and before EVERY branch's write — the static branch
+ // that delegates to the shared core as well as the alias branch below.
+ // It does not disturb the neutrality ordering the alias branch's own
+ // comment protects: that refusal is about which HOSTS exist, readable by
+ // any caller, whereas this one fires only for a service that is already
+ // a proven binding of this account and app, and tells it only what the
+ // promote door would tell it anyway.
+ //
+ // The browser twin (handleAppDomainAttach, appdomains.go) needs no
+ // counterpart: it authenticates a session, so its principal is always a
+ // person and this refusal could never fire there. The shared core
+ // takes an audit-actor STRING rather than a Principal, which is the same
+ // fact stated in the types.
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "attach a domain to it") {
+ return
+ }
principal := auditActor(p, acct)
var in struct {
Host string `json:"host"`
@@ -508,6 +540,30 @@ func (s *Server) handleAPIDomainDetach(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
+ // The NINTH control-plane door, and the same fuse as the instance
+ // delete: the first-route branch below calls the very
+ // provision.DeleteRecord that retires a route, on the same app, and this
+ // door is reachable by a service credential (svcrole.Publish OR
+ // svcrole.Instance). Guarding the instance family and leaving this open
+ // would be a guard with a published way round it, which reads as
+ // protection and is not — the same reasoning the account-deletion
+ // refusal was built on.
+ //
+ // OUTRIGHT, and NOT conditional on the record-deleting branch. Two
+ // reasons. A conditional guard is a bug surface: it would have to
+ // re-derive "does this claim mint the route" ahead of the code that
+ // derives it, and the two would drift. And detaching a domain from the
+ // control plane is not an act anyone should perform without clearing the
+ // flag first — a hostname the fleet reaches the console by is not
+ // released in passing.
+ //
+ // Straight after domainsGate — after the family's byte-identical 404,
+ // before the hostname is even normalized — so no branch reaches a write
+ // without passing it, and a flagged app's answer here is CONSTANT across
+ // valid and malformed hosts rather than leaking shape-validity.
+ if s.refuseControlPlane(w, acct, app, "detached") {
+ return
+ }
principal := auditActor(p, acct)
// Shape-checked before the key builder, not after: a {host} segment
// arrives percent-decoded, so "..%2f..%2finstances.json" reaches this
diff --git a/internal/console/apiinstances.go b/internal/console/apiinstances.go
index b1420d76d..64650e82b 100644
--- a/internal/console/apiinstances.go
+++ b/internal/console/apiinstances.go
@@ -492,6 +492,23 @@ func (s *Server) handleAPIInstanceCreate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // A PERSON must be able to write the control plane's own instance
+ // record — that is the act that makes `carlos deploy -app console`
+ // resolve a host at all, and refusing it here would close the door this
+ // family exists to open. A SERVICE credential must not: the record it
+ // would write is the thing the reconciler then owns, and ownership of
+ // the console's route is not a CI token's to acquire (controlplane.go).
+ //
+ // Straight after instanceGate, which is to say after the family's
+ // byte-identical 404 and before the manifest read: a non-member must
+ // never learn from this door that an app here carries the flag.
+ // principalFrom's ok is discarded for the reason handleAPIPromote's
+ // comment gives — cliRoute stashed the principal before the handler
+ // started, so it is already present.
+ p, _ := principalFrom(r.Context())
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "create its instance records") {
+ return
+ }
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
m, ok := s.instanceManifest(ctx, w, acct, app)
@@ -697,6 +714,17 @@ func (s *Server) handleAPIInstanceUpdate(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // Same split as create, for a sharper reason: the provisioning
+ // reconciler converges a route's Channel and Environment FROM this
+ // record on every pass (AGENTS.md), so a PATCH here repoints the
+ // control plane. An operator doing that deliberately is legitimate; a CI
+ // credential doing it is the console being moved by something with no
+ // standing to move it. Placed before the host shape check so that no
+ // branch of this handler reaches a write without passing it.
+ p, _ := principalFrom(r.Context())
+ if s.refuseServiceControlPlane(w, p, acct.ID, app, "change its instance records") {
+ return
+ }
host := r.PathValue("host")
// Shape-checked before the key builder, exactly as GET does it and for
// the same reason: a {host} segment arrives percent-decoded, so
@@ -885,6 +913,20 @@ func (s *Server) handleAPIInstanceDelete(w http.ResponseWriter, r *http.Request)
if !ok {
return
}
+ // The eighth destructive door, and the one with the shortest fuse:
+ // deleting a control-plane app's record is how the provisioning
+ // reconciler's removal phase is TOLD to retire the console's route, so a
+ // single call here takes the control plane off the air. That is the same
+ // class of act as trash, purge and store-destroy, and it wears their
+ // refusal — outright, for anyone, service or person alike, until the
+ // flag is cleared (controlplane.go).
+ //
+ // FIRST, ahead of the shape check and the store: the app is already
+ // past instanceGate's byte-identical 404, and nothing below this line
+ // may run for a flagged app.
+ if s.refuseControlPlane(w, acct, app, "deleted") {
+ return
+ }
host := r.PathValue("host")
ctx, cancel := context.WithTimeout(r.Context(), instanceStoreTimeout)
defer cancel()
diff --git a/internal/console/controlplane_test.go b/internal/console/controlplane_test.go
index 163d49993..181587124 100644
--- a/internal/console/controlplane_test.go
+++ b/internal/console/controlplane_test.go
@@ -14,6 +14,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/objstore"
+ "github.com/carlosframework/platform/internal/provision"
"github.com/carlosframework/platform/internal/release"
"github.com/carlosframework/platform/internal/svcrole"
)
@@ -864,3 +865,416 @@ func TestControlPlaneShipRefusalNamesTheMintTrap(t *testing.T) {
t.Fatalf("minted ship of an ordinary app = %d %v, want 201", status, out)
}
}
+
+// --- The instance doors ------------------------------------------------------
+//
+// The eighth door, found mid-rollout. `carlos deploy` resolves hosts through
+// instance records, so the console needs one — but a record is an AUTHORITY
+// TRANSFER (AGENTS.md): once it exists and the route is Provisioned, the
+// provisioning reconciler owns the row, and its removal phase retires the
+// route the moment the record disappears. So a single DELETE on this family
+// takes the control plane off the air, and PATCH repoints it.
+//
+// The split is the one the flag already draws everywhere else: destruction is
+// refused outright; the WRITES a person legitimately performs stay open to a
+// person and closed to a CI credential.
+
+// cpInstanceFixture is controlPlaneFixture with the instance feature enabled
+// on the flagged `console` app and on an ordinary `widget` beside it, so
+// every test below can assert the refusal AND that a tenant's identical call
+// is untouched by it.
+//
+// Custom domains deliberately, not the platform apex: the sibling-apex rail
+// in handleAPIInstanceCreate only fires inside <sqid>.<AppsDomain>, and these
+// tests are about the flag, not about that rail.
+func cpInstanceFixture(t *testing.T, label string) (*consoleFixture, string) {
+ t.Helper()
+ fx, token := controlPlaneFixture(t, label)
+ enableInstances(t, fx, "console", provision.Manifest{Schema: 1, App: "console",
+ Domain: "cp.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
+ if _, err := fx.st.CreateAppIn(OpsAccountID, "widget", "paul@keymail.dev", time.Now()); err != nil {
+ t.Fatal(err)
+ }
+ enableInstances(t, fx, "widget", provision.Manifest{Schema: 1, App: "widget",
+ Domain: "widget.tito.io", Pool: "tito", Channel: "stable", Regions: []string{"ie"}})
+ return fx, token
+}
+
+// TestControlPlaneAppRefusesInstanceDelete is the whole reason this slice
+// exists. Deleting the console's instance record is not an edit to a bucket
+// object — it is the instruction the reconciler's removal phase acts on, and
+// the act it performs is retiring the control plane's route. Refused
+// OUTRIGHT, for a person as much as for a service: the same class as trash,
+// purge and store-destroy, and the same way out (clear the flag first).
+func TestControlPlaneAppRefusesInstanceDelete(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-del")
+ u := instancesURL(fx, "bab", "console")
+
+ // The record exists first — created by the person the next test proves
+ // may create it — so the refusal below is refusing a delete that would
+ // otherwise have succeeded.
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box1.cp.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+
+ status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", token, "")
+ if status != http.StatusForbidden {
+ t.Fatalf("a PERSON deleting the control plane's instance record = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "control plane") {
+ t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
+ }
+ if !strings.Contains(out, "/a/bab/apps/console/settings") {
+ t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
+ }
+ // The record survived. This is the assertion the door exists for: the
+ // reconciler never sees the record go, so the route is never retired.
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
+ t.Fatalf("the control plane's instance record did not survive a refused delete: %v", err)
+ }
+
+ // A service credential holding the role this door grants meets the same
+ // wall — it is refused for what the app IS, not for what the caller is.
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/box1.cp.tito.io", svc, ""); status != http.StatusForbidden {
+ t.Fatalf("a SERVICE deleting the control plane's instance record = %d %s, want 403", status, out)
+ }
+
+ // A traversal host on the flagged app is refused by the flag, not by the
+ // shape check: nothing below the guard runs for a control-plane app.
+ if status, _, out := rawReq(t, http.MethodDelete, u+"/..%2f..%2finstances.json", token, ""); status != http.StatusForbidden {
+ t.Fatalf("traversal delete on a flagged app = %d %s, want the flag's 403", status, out)
+ }
+
+ // And an ORDINARY app deletes exactly as it always did.
+ wu := instancesURL(fx, "bab", "widget")
+ if status, _, out := rawReq(t, http.MethodPost, wu, token,
+ `{"host":"box1.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("ordinary create = %d %s", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodDelete, wu+"/box1.widget.tito.io", token, ""); status != http.StatusOK {
+ t.Fatalf("ordinary delete = %d %s, want 200 — the flag is not a fleet-wide freeze", status, out)
+ }
+}
+
+// TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance is the half
+// that must keep working, and the runbook step this whole slice unblocks:
+// the console has no instance record because it predates provisioning, and
+// `carlos deploy -app console` cannot resolve a host until a PERSON writes
+// one. A create gate here would close the door the flag exists to keep open.
+func TestControlPlaneAppAllowsAPersonToCreateAndPatchItsInstance(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-person")
+ u := instancesURL(fx, "bab", "console")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box1.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("a PERSON creating the control plane's instance record = %d %s, want 201 — this is the runbook step", status, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io"); err != nil {
+ t.Fatalf("the record a person created is not there: %v", err)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box1.cp.tito.io"),
+ token, `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK {
+ t.Fatalf("a PERSON patching the control plane's instance record = %d %s, want 200", status, out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box1.cp.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9002" {
+ t.Fatalf("addrs after a person's patch = %v, want the requested one", rec.Addrs)
+ }
+}
+
+// TestControlPlaneAppRefusesAServiceInstanceCreate: the record a create
+// writes is the thing the reconciler then owns, so minting one for the
+// control plane is acquiring authority over the console's route. A person
+// may; a CI credential may not.
+func TestControlPlaneAppRefusesAServiceInstanceCreate(t *testing.T) {
+ fx, _ := cpInstanceFixture(t, "code-cp-inst-svc-create")
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "console"), svc,
+ `{"host":"box2.cp.tito.io","region":"ie"}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE creating the control plane's instance record = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box2.cp.tito.io"); err == nil {
+ t.Fatal("a record exists after only a refused service create")
+ }
+
+ // The same credential shape, bound to an ordinary app, creates freely:
+ // nothing about service provisioning became worse for a tenant.
+ ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws,
+ `{"host":"box2.widget.tito.io","region":"ie"}`); status != http.StatusCreated {
+ t.Fatalf("a SERVICE creating an ORDINARY app's instance = %d %s, want 201", status, out)
+ }
+}
+
+// TestControlPlaneAppRefusesAServiceInstancePatch: the provisioning
+// reconciler converges a route's Channel and Environment FROM this record on
+// every pass, so a PATCH repoints the control plane. Same split as create.
+func TestControlPlaneAppRefusesAServiceInstancePatch(t *testing.T) {
+ fx, token := cpInstanceFixture(t, "code-cp-inst-svc-patch")
+ u := instancesURL(fx, "bab", "console")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ `{"host":"box3.cp.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("create = %d %s", status, out)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci", "console", svcrole.Instance)
+ status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "console", "box3.cp.tito.io"), svc,
+ `{"addrs":["10.0.0.1:1"]}`)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE patching the control plane's instance record = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ rec, err := provision.GetRecord(context.Background(), fx.rel, "bab", "console", "box3.cp.tito.io")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(rec.Addrs) != 1 || rec.Addrs[0] != "127.0.0.1:9001" {
+ t.Fatalf("addrs after a refused service patch = %v, want the created pair untouched", rec.Addrs)
+ }
+
+ // An ordinary app's record is still a service's to patch.
+ ws := serviceToken(t, fx.st, OpsAccountID, "ci-widget", "widget", svcrole.Instance)
+ if status, _, out := rawReq(t, http.MethodPost, instancesURL(fx, "bab", "widget"), ws,
+ `{"host":"box3.widget.tito.io","region":"ie","kind":"service","addrs":["127.0.0.1:9001"]}`); status != http.StatusCreated {
+ t.Fatalf("ordinary create = %d %s", status, out)
+ }
+ if status, _, out := rawReq(t, http.MethodPatch, updateURL(fx, "bab", "widget", "box3.widget.tito.io"), ws,
+ `{"addrs":["127.0.0.1:9002"]}`); status != http.StatusOK {
+ t.Fatalf("a SERVICE patching an ORDINARY app's instance = %d %s, want 200", status, out)
+ }
+}
+
+// TestControlPlaneInstanceRefusalIsNotAnOracle: the three guards sit AFTER
+// the family's membership gate, so a stranger meets the byte-identical 404
+// and never the refusal. A control-plane app must not be discoverable by
+// asking a door which sentence it answers with.
+func TestControlPlaneInstanceRefusalIsNotAnOracle(t *testing.T) {
+ fx, _ := cpInstanceFixture(t, "code-cp-inst-oracle")
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-inst-eve")
+ want := canonicalNotFound(t)
+ u := instancesURL(fx, "bab", "console")
+ for _, c := range []struct{ what, method, url, body string }{
+ {"non-member create on a flagged app", http.MethodPost, u, `{"host":"box9.cp.tito.io","region":"ie"}`},
+ {"non-member patch on a flagged app", http.MethodPatch, u + "/box9.cp.tito.io", `{"addrs":[]}`},
+ {"non-member delete on a flagged app", http.MethodDelete, u + "/box9.cp.tito.io", ""},
+ } {
+ status, h, out := rawReq(t, c.method, c.url, eve, c.body)
+ assertNotFound(t, c.what, want, status, h, out)
+ }
+}
+
+// --- The detach doors --------------------------------------------------------
+//
+// The NINTH door, and the same fuse as the eighth: handleAPIDomainDetach's
+// first-route branch calls the very provision.DeleteRecord the instance
+// delete does, on the same app, and the route is registered
+// svcrole.Allow(svcrole.Publish, svcrole.Instance) — so a service credential
+// reaches it. Its browser twin is detachDomain, shared by
+// handleAppDomainDetach.
+//
+// Refused OUTRIGHT rather than only on the record-deleting branch. A
+// conditional guard would have to re-derive "does this claim mint the app's
+// route" ahead of the code that derives it, and the two would drift; and
+// releasing a hostname the fleet reaches the control plane by is not
+// something to do in passing.
+
+// cpDetachFixture attaches a hostname to `kass` through the shared core and
+// THEN sets the flag — the order a real control plane arrives in (the console
+// had its hostname long before anyone thought to mark it), and the only order
+// in which the attach half is reachable at all.
+//
+// The control in these tests is the SAME app with the flag cleared rather
+// than a sibling app, deliberately: it isolates the flag as the only variable
+// between a refusal and a 200, which a second app with its own routes and
+// claims could not do as cleanly.
+func cpDetachFixture(t *testing.T, label, host string) (*consoleFixture, string) {
+ t.Helper()
+ fx := newConsoleFixture(t)
+ acct, err := fx.st.AccountBySqid(fx.sqid)
+ if err != nil || acct == nil {
+ t.Fatalf("fixture account %s: %v", fx.sqid, err)
+ }
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "kass", host, "", "", "paul@keymail.dev"); ref != nil {
+ t.Fatalf("attach setup: refused %+v", ref)
+ }
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
+ t.Fatal(err)
+ }
+ return fx, cliToken(t, fx, "paul@keymail.dev", label)
+}
+
+// TestControlPlaneAppRefusesDomainDetach: the API door. Refused for a person
+// and for BOTH service roles the route admits, the claim survives, and
+// clearing the flag is the only way through — which is what proves the
+// refusal is the flag's and nothing else's.
+func TestControlPlaneAppRefusesDomainDetach(t *testing.T) {
+ const host = "docs.example.com"
+ fx, token := cpDetachFixture(t, "code-cp-detach", host)
+ u := fx.ts.URL + "/api/cli/apps/bab/kass/domains/" + host
+
+ status, _, out := rawReq(t, http.MethodDelete, u, token, "")
+ if status != http.StatusForbidden {
+ t.Fatalf("a PERSON detaching a control-plane app's domain = %d %s, want 403", status, out)
+ }
+ if !strings.Contains(out, "control plane") {
+ t.Fatalf("refusal %q does not name the flag the operator has to clear", out)
+ }
+ if !strings.Contains(out, "/a/bab/apps/kass/settings") {
+ t.Fatalf("refusal %q does not point at the settings card that can clear the flag", out)
+ }
+ claimOf(t, fx, host)
+
+ // Both roles the route admits, since either would otherwise walk round
+ // the instance family's guard by taking this door instead.
+ for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} {
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role)
+ if status, _, out := rawReq(t, http.MethodDelete, u, svc, ""); status != http.StatusForbidden {
+ t.Fatalf("a SERVICE (%s) detaching a control-plane app's domain = %d %s, want 403", role, status, out)
+ }
+ }
+ claimOf(t, fx, host)
+
+ // A malformed host answers the SAME 403, not the hostname check's 400:
+ // on a flagged app this door's answer is constant, so it leaks nothing
+ // about which hosts are even well-shaped here.
+ if status, _, out := rawReq(t, http.MethodDelete,
+ fx.ts.URL+"/api/cli/apps/bab/kass/domains/..%2f..%2fclaims", token, ""); status != http.StatusForbidden {
+ t.Fatalf("malformed host on a flagged app = %d %s, want the flag's 403", status, out)
+ }
+
+ // Clear the flag and the identical call goes through. The flag is the
+ // only variable between these two lines.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ status, _, out = rawReq(t, http.MethodDelete, u, token, "")
+ if status != http.StatusOK || !strings.Contains(out, `"removed":true`) {
+ t.Fatalf("detach once the flag is cleared = %d %s, want 200 removed true", status, out)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestControlPlaneAppRefusesBrowserDomainDetach: the browser twin deletes the
+// same record through the same core, so a guard only on the API door would
+// leave the route retirable by a form post. Rendered as a page, like every
+// other browser refusal, and ahead of the claim read.
+func TestControlPlaneAppRefusesBrowserDomainDetach(t *testing.T) {
+ const host = "docs.example.com"
+ fx, _ := cpDetachFixture(t, "code-cp-detach-browser", host)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+
+ status, _, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}})
+ if status != http.StatusForbidden {
+ t.Fatalf("owner detaching a control-plane app's domain in a browser = %d, want 403:\n%s", status, body)
+ }
+ if !strings.Contains(body, "control plane") {
+ t.Fatalf("browser refusal does not name the flag:\n%s", body)
+ }
+ if !strings.Contains(body, "/a/bab/apps/kass/settings") {
+ t.Fatalf("browser refusal does not point at the settings card that can clear the flag:\n%s", body)
+ }
+ claimOf(t, fx, host)
+
+ // Cleared, the same form post lands.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ if status, loc, body := postForm(t, c, fx.u("/apps/kass/domains/detach"),
+ url.Values{"csrf": {csrf}, "host": {host}, "confirm": {host}}); status != http.StatusSeeOther ||
+ loc != fx.path("/apps/kass/settings") {
+ t.Fatalf("browser detach once the flag is cleared = %d -> %q, want 303 -> settings:\n%s", status, loc, body)
+ }
+ claimAbsent(t, fx, host)
+}
+
+// TestControlPlaneDomainDetachRefusalIsNotAnOracle: the guard sits after
+// domainsGate, so a stranger meets the family's byte-identical 404 and never
+// the sentence that would tell them this account holds a control-plane app.
+func TestControlPlaneDomainDetachRefusalIsNotAnOracle(t *testing.T) {
+ const host = "docs.example.com"
+ fx, _ := cpDetachFixture(t, "code-cp-detach-oracle", host)
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-detach-eve")
+ status, h, out := rawReq(t, http.MethodDelete,
+ fx.ts.URL+"/api/cli/apps/bab/kass/domains/"+host, eve, "")
+ assertNotFound(t, "non-member detach on a flagged app", canonicalNotFound(t), status, h, out)
+ claimOf(t, fx, host)
+}
+
+// TestControlPlaneAppRefusesAServiceDomainAttach closes the CREATE half of
+// the provision-record lifecycle at the door that is not the instance one.
+// The first-route branch of `POST .../domains` writes a declared
+// provision.Record, which is the same authority transfer `POST .../instances`
+// refuses a service for — so a CI credential must not be able to acquire it
+// by taking the other door.
+//
+// Service-only, not outright: a PERSON attaching a domain to the control
+// plane is an ordinary operator act. Removal is the asymmetric one.
+//
+// Both service roles the route admits, and both request SHAPES — the alias
+// form and the `kind:"static"` first-route form — because the guard is
+// deliberately not conditional on which branch would have run.
+func TestControlPlaneAppRefusesAServiceDomainAttach(t *testing.T) {
+ fx := newConsoleFixture(t)
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", true); err != nil {
+ t.Fatal(err)
+ }
+ u := fx.ts.URL + "/api/cli/apps/bab/kass/domains"
+ const host = "docs.example.com"
+
+ for _, role := range []svcrole.Role{svcrole.Publish, svcrole.Instance} {
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci-"+string(role), "kass", role)
+ for what, body := range map[string]string{
+ "alias form": attachBody(host, "kass.carlosapp.dev"),
+ "first-route form": `{"host":"` + host + `","kind":"static"}`,
+ } {
+ status, _, out := rawReq(t, http.MethodPost, u, svc, body)
+ if status != http.StatusForbidden {
+ t.Fatalf("a SERVICE (%s) attaching a domain to the control plane, %s = %d %s, want 403",
+ role, what, status, out)
+ }
+ if !strings.Contains(out, "a person must") {
+ t.Fatalf("refusal %q does not say a person is the principal this needs", out)
+ }
+ }
+ claimAbsent(t, fx, host)
+ }
+
+ // The half that must keep working: a person attaches.
+ token := cliToken(t, fx, "paul@keymail.dev", "code-cp-attach")
+ if status, _, out := rawReq(t, http.MethodPost, u, token,
+ attachBody(host, "kass.carlosapp.dev")); status != http.StatusOK {
+ t.Fatalf("a PERSON attaching a domain to the control plane = %d %s, want 200", status, out)
+ }
+ claimOf(t, fx, host)
+
+ // A non-member still meets the family's byte-identical 404, never the
+ // refusal: the guard sits after domainsGate, so it is not an oracle for
+ // which accounts hold a control-plane app.
+ eve := cliToken(t, fx, "eve@keymail.dev", "code-cp-attach-eve")
+ status, h, out := rawReq(t, http.MethodPost, u, eve, attachBody("eve.example.com", "kass.carlosapp.dev"))
+ assertNotFound(t, "non-member attach on a flagged app", canonicalNotFound(t), status, h, out)
+ claimAbsent(t, fx, "eve.example.com")
+
+ // The control: with the flag cleared, the identical service call lands.
+ // The flag is the only variable between these two outcomes.
+ if err := fx.st.SetAppControlPlane(OpsAccountID, "kass", false); err != nil {
+ t.Fatal(err)
+ }
+ svc := serviceToken(t, fx.st, OpsAccountID, "ci-cleared", "kass", svcrole.Publish)
+ if status, _, out := rawReq(t, http.MethodPost, u, svc,
+ attachBody("other.example.com", "kass.carlosapp.dev")); status != http.StatusOK {
+ t.Fatalf("a SERVICE attaching once the flag is cleared = %d %s, want 200", status, out)
+ }
+ claimOf(t, fx, "other.example.com")
+}
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index baff49f74..7e7646e71 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -223,6 +223,17 @@ func (s *Server) detachDomain(ctx context.Context, acct *Account, app, host, pri
if ref := s.bucketRefusal(); ref != nil {
return ref
}
+ // The browser twin of handleAPIDomainDetach's control-plane refusal, and
+ // it has to be here rather than in the door above it: this core is what
+ // deletes the provision record, so a guard the browser path did not
+ // share would leave the same route retirable by the same mechanism
+ // through a form post. Refused outright for a flagged app, for the
+ // reasons the API door's comment gives, and BEFORE the claim is read —
+ // its own caller (handleAppDomainDetach) has already spent the session,
+ // CSRF and owner gates, so a non-member never reaches this sentence.
+ if msg, status := s.controlPlaneRefusal(acct, app, "detached"); msg != "" {
+ return &doorRefusal{status, msg}
+ }
notAttached := &doorRefusal{http.StatusNotFound, "not attached to this app"}
// A host that is not even a hostname cannot be a claim key: refuse it
// here rather than handing it to a store as a key segment.