carlos / platform Public

console: fleets and custom domains follow the plan

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 51654d70810eaf4af2c88478ae7a581425b5bfd6 parent 949aa92
6 files changed, +121 −0
  • internal/console/apidomains.go +6 −0
  • internal/console/domainattach.go +4 −0
  • internal/console/domaincatchall.go +4 −0
  • internal/console/fleets.go +10 −0
  • internal/console/plancaps.go +40 −0
  • internal/console/plancaps_doors_test.go +57 −0
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index c97618b7d..a44711821 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -522,6 +522,12 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
// can refuse first, the two 409s are indistinguishable in status, body
// AND reachability. Anything added to this branch later belongs above
// this line, not below it.
+ // Plans 3a: custom domains need a plan that includes them (above
+ // heldElsewhere, per the rule).
+ if ref := s.customDomainRefusal(acct, host); ref != nil {
+ writeJSON(w, ref.Status, capOrError(ref))
+ return
+ }
if ref := s.heldElsewhere(ctx, host, acct.Sqid, app); ref != nil {
jsonError(w, ref.Status, ref.Msg)
return
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index 888dcb090..34da425d3 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -137,6 +137,10 @@ func (s *Server) attachDomainAs(ctx context.Context, acct *Account, app, host, k
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"that hostname is platform-managed"}
}
+ // Plans 3a: custom domains need a plan that includes them.
+ if ref := s.customDomainRefusal(acct, host); ref != nil {
+ return attachResult{}, ref
+ }
// The reserved sidecar suffix, refused at the same policy stage and with
// the same status as the two checks above: this door must never mint a
// claim (let alone a route record) under a name provision.SidecarSuffix
diff --git a/internal/console/domaincatchall.go b/internal/console/domaincatchall.go
index 578a96ced..e6a97a7c9 100644
--- a/internal/console/domaincatchall.go
+++ b/internal/console/domaincatchall.go
@@ -92,6 +92,10 @@ func (s *Server) attachInstanceCatchall(ctx context.Context, acct *Account, app,
if s.catchallParentRefused(parent, acct.Sqid) {
return false, &doorRefusal{http.StatusBadRequest, "that hostname is platform-managed"}
}
+ // Plans 3a: a catch-all is a custom domain too.
+ if ref := s.customDomainRefusal(acct, parent); ref != nil {
+ return false, ref
+ }
created, err := domains.AttachInstanceCatchall(ctx, s.cfg.Releases, parent, acct.Sqid, app,
time.Now().UTC().Format(time.RFC3339), principal)
switch {
diff --git a/internal/console/fleets.go b/internal/console/fleets.go
index dd445ddbc..39310ba62 100644
--- a/internal/console/fleets.go
+++ b/internal/console/fleets.go
@@ -172,6 +172,16 @@ func (s *Server) handleAPIFleetNew(w http.ResponseWriter, r *http.Request) {
"fleet creation limit reached for today — try again tomorrow")
return
}
+ // Plans 3a: fleets need a plan that includes them.
+ if err := s.checkFleets(acct); err != nil {
+ if isPlanCap(err) {
+ writeJSON(w, http.StatusConflict, map[string]any{"error": err.Error(), "code": "cap"})
+ return
+ }
+ log.Printf("console: fleet create %s: plan: %v", acct.Sqid, err)
+ jsonError(w, http.StatusBadGateway, "could not check this account's plan. Try again")
+ return
+ }
var req struct {
Name string `json:"name"`
Bucket string `json:"bucket"`
diff --git a/internal/console/plancaps.go b/internal/console/plancaps.go
index bbdf00202..2b002e036 100644
--- a/internal/console/plancaps.go
+++ b/internal/console/plancaps.go
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
+ "net/http"
"strings"
"github.com/carlosframework/platform/internal/provision"
@@ -201,3 +202,42 @@ func (s *Server) planLock(acct *Account) func() {
}
return s.lockAccount(acct.Sqid)
}
+
+// customDomain reports whether host is a customer's own domain, the kind a
+// free plan may not attach: an open host outside the deployment's apps
+// domain. The account's own <sqid>.<apps-domain> addresses and the
+// operator's platform zones are not custom domains.
+func (s *Server) customDomain(host, account string) bool {
+ if s.domainHostPolicy(host, account) != hostOpen {
+ return false
+ }
+ if domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); domain != "" && strings.HasSuffix(host, "."+domain) {
+ return false
+ }
+ return true
+}
+
+// customDomainRefusal is checkCustomDomains as a door refusal, for the
+// shared attach paths; nil when the attach may go ahead.
+func (s *Server) customDomainRefusal(acct *Account, host string) *doorRefusal {
+ if !s.customDomain(host, acct.Sqid) {
+ return nil
+ }
+ if err := s.checkCustomDomains(acct); err != nil {
+ if isPlanCap(err) {
+ return &doorRefusal{http.StatusConflict, err.Error()}
+ }
+ return &doorRefusal{http.StatusBadGateway, "could not check this account's plan. Try again"}
+ }
+ return nil
+}
+
+// capOrError is a door refusal as a JSON body, with "code":"cap" when it is
+// a plan cap (the only 409 customDomainRefusal returns).
+func capOrError(ref *doorRefusal) map[string]any {
+ body := map[string]any{"error": ref.Msg}
+ if ref.Status == http.StatusConflict {
+ body["code"] = "cap"
+ }
+ return body
+}
diff --git a/internal/console/plancaps_doors_test.go b/internal/console/plancaps_doors_test.go
index 15e887bf7..fd7f68bf4 100644
--- a/internal/console/plancaps_doors_test.go
+++ b/internal/console/plancaps_doors_test.go
@@ -284,3 +284,60 @@ func TestNewAccountWithoutACatalogUnchanged(t *testing.T) {
}
}
}
+
+func TestFleetCreateRefusedOnAPlanWithoutFleets(t *testing.T) {
+ fx, token := fleetFixture(t, "code-cap-fleet")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ status, out := postJSON(t, fleetsURL(fx, fx.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token)
+ if status != http.StatusConflict || out["code"] != "cap" {
+ t.Fatalf("fleet create on free = %d %v, want 409 cap", status, out)
+ }
+}
+
+func TestCustomDomainRefusedOnAFreePlan(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-domain")
+ shipVersion(t, fx, "titogo", "v1")
+ seedChannel(t, fx, "titogo", "canary/live", "v1")
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ for name, body := range map[string]string{
+ "alias": `{"host":"tickets.example.com","route":"go.tito.io"}`,
+ "first route": `{"host":"fresh.example.com","kind":"static"}`,
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusConflict {
+ t.Errorf("%s on free = %d %s, want 409", name, code, out)
+ }
+ }
+ claimAbsent(t, fx, "tickets.example.com")
+ claimAbsent(t, fx, "fresh.example.com")
+}
+
+func TestFleetsAndDomainsWithoutACatalogUnchanged(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-domain-nocat")
+ if code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"tickets.example.com","route":"go.tito.io"}`); code != http.StatusOK {
+ t.Fatalf("alias attach with no catalog = %d %s", code, out)
+ }
+ fx2, token := fleetFixture(t, "code-cap-fleet-nocat")
+ if status, out := postJSON(t, fleetsURL(fx2, fx2.sqid)+"/new", map[string]string{"name": "acme", "bucket": "bkt-1"}, token); status != http.StatusOK {
+ t.Fatalf("fleet create with no catalog = %d %v", status, out)
+ }
+}
+
+// Review Focus 5: an address under the deployment's apps domain is the
+// account's own platform address, not a custom domain, so free plans keep it.
+func TestPlatformHostnamesStayFreeOnFreePlans(t *testing.T) {
+ fx, member := instanceFixture(t, "code-cap-platform")
+ fx.srv.cfg.AppsDomain = "apps.example.com"
+ fx.srv.cfg.PlanCatalog = examplePlanCatalog(t)
+ acct, _ := fx.st.AccountBySqid("bab")
+ if fx.srv.customDomain("shop.bab.apps.example.com", acct.Sqid) {
+ t.Fatal("an address under the apps domain was treated as a custom domain")
+ }
+ if !fx.srv.customDomain("shop.example.com", acct.Sqid) {
+ t.Fatal("a customer domain was not treated as a custom domain")
+ }
+ if ref := fx.srv.customDomainRefusal(acct, "shop.bab.apps.example.com"); ref != nil {
+ t.Fatalf("free plan refused its own platform address: %+v", ref)
+ }
+ _ = member
+}