carlos / platform Public

Allow trusted accounts to attach short platform app aliases

Paul Campbell pushed by paul@keymail.dev e36cdb6aa2a377212b0b1068d27957dc187e5c3b parent db79997
7 files changed, +341 −6
  • AGENTS.md +14 −0
  • cmd/console/main.go +1 −0
  • internal/console/apidomains.go +20 −4
  • internal/console/domainattach.go +5 −1
  • internal/console/domainpolicy.go +29 −0
  • internal/console/domainpolicy_test.go +270 −0
  • internal/console/server.go +2 −1

CI passed — run details

diff --git a/AGENTS.md b/AGENTS.md
index 4b7a58ef6..f0e66f53e 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -366,3 +366,17 @@ anything.
request id they acted on (`scopes` in `restart-marks.json`, additive in
both directions); a mark that has rolled off the log re-baselines and
cycles nothing.
+
+## Short platform aliases (Paul's request, 2026-09-15)
+
+Tito may delegate direct app-domain aliases to named accounts with
+CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS (comma-separated sqids; empty by default).
+A listed account can claim an unoccupied single-label hostname below
+PLATFORM_APPS_DOMAIN through the existing domain attach API. This is explicit
+operator delegation of namespace authority, not DNS ownership evidence.
+The exception supersedes blanket ancestor deny entries only for these names;
+exact denied hosts, Origin, RedirectHosts, SteeringHost, account-sqid labels,
+the domain apex and nested account namespaces remain protected. Alias targets
+still require an existing app route. First-route creation and wildcard or
+catchall capabilities are refused. Global claims and deployment-route conflict
+checks remain authoritative. Tito's initial approved account is bap (Pullup).
diff --git a/cmd/console/main.go b/cmd/console/main.go
index 6edc35e4d..b8af53cf5 100644
--- a/cmd/console/main.go
+++ b/cmd/console/main.go
@@ -278,6 +278,7 @@ func serve() {
EdgeIPs: edgeIPs(),
SecretsPub: secretsPub(),
AppsDomain: appsDomain(),
+ AppsDomainAliasAccounts: strings.FieldsFunc(os.Getenv("CONSOLE_APPS_DOMAIN_ALIAS_ACCOUNTS"), func(r rune) bool { return r == ',' || r == ' ' }),
SteeringHost: steeringHost(),
RedirectHosts: redirectHosts(),
DenyDomains: denyDomains(),
diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 36a809882..6c445375a 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -85,15 +85,27 @@ import (
// embedder (or a test) that sets any of these after NewServer. The redirect
// entries go through the same normalizeHost ServeHTTP's retired-host check
// uses, so the two readings of CONSOLE_REDIRECT_HOSTS cannot disagree.
-func (s *Server) deniedDomains() []string {
+func (s *Server) deniedDomains(exemptAppsDomain bool) []string {
deny := make([]string, 0, len(s.cfg.DenyDomains)+len(s.cfg.RedirectHosts)+2)
- deny = append(deny, s.cfg.DenyDomains...)
+ domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
+ for _, entry := range s.cfg.DenyDomains {
+ entry = strings.ToLower(strings.TrimSpace(entry))
+ if exemptAppsDomain && (entry == domain || (entry != "" && strings.HasSuffix(domain, "."+entry))) {
+ continue
+ }
+ deny = append(deny, entry)
+ }
+ steeringHost := normalizeHost(s.cfg.SteeringHost)
+ if steeringHost == "" && domain != "" {
+ steeringHost = "edge." + domain
+ }
+ deny = append(deny, steeringHost)
if u, err := url.Parse(s.cfg.Origin); err == nil {
if h := strings.ToLower(u.Hostname()); h != "" {
deny = append(deny, h)
}
}
- if d := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain)); d != "" {
+ if d := domain; d != "" && !exemptAppsDomain {
deny = append(deny, d)
}
for _, h := range s.cfg.RedirectHosts {
@@ -276,7 +288,7 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
"invalid hostname — attach a full hostname like tickets.example.com")
return
}
- if domains.HostDenied(host, s.deniedDomains()) {
+ if s.domainHostDenied(host, acct.Sqid) || (s.directAppsDomainHost(host) && ((in.Wildcard != nil && *in.Wildcard) || (in.Catchall != nil && *in.Catchall))) {
jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
return
}
@@ -487,6 +499,10 @@ func (s *Server) setDomainCapabilities(w http.ResponseWriter, r *http.Request, c
return false
}
}
+ if s.directAppsDomainHost(host) && (wildcard || catchall) {
+ jsonError(w, http.StatusBadRequest, "that hostname is platform-managed")
+ return false
+ }
capChanged, err := domains.SetClaimCapabilities(ctx, s.cfg.Releases, host, acct.Sqid, app, wildcard, catchall)
if errors.Is(err, domains.ErrCatchallNeedsWildcard) {
jsonError(w, http.StatusUnprocessableEntity, err.Error())
diff --git a/internal/console/domainattach.go b/internal/console/domainattach.go
index a20b98937..a44b414d6 100644
--- a/internal/console/domainattach.go
+++ b/internal/console/domainattach.go
@@ -111,7 +111,7 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"not a valid hostname — attach a full hostname like tickets.example.com"}
}
- if domains.HostDenied(host, s.deniedDomains()) {
+ if s.domainHostDenied(host, acct.Sqid) {
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
"that hostname is platform-managed"}
}
@@ -155,6 +155,10 @@ func (s *Server) attachDomain(ctx context.Context, acct *Account, app, host, kin
return attachResult{Claim: claim, Created: created}, nil
}
+ if s.directAppsDomainHost(host) {
+ return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity, "an app with no running route can only attach a static domain — deploy first, then attach"}
+ }
+
// --- first-route attach: this host becomes the app's primary --------
if kindFlag != "" && kindFlag != "static" {
return attachResult{}, &doorRefusal{http.StatusUnprocessableEntity,
diff --git a/internal/console/domainpolicy.go b/internal/console/domainpolicy.go
new file mode 100644
index 000000000..6d2d2b295
--- /dev/null
+++ b/internal/console/domainpolicy.go
@@ -0,0 +1,29 @@
+package console
+
+import (
+ "slices"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/domains"
+)
+
+func (s *Server) directAppsDomainHost(host string) bool {
+ domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
+ if domain == "" || !strings.HasSuffix(host, "."+domain) {
+ return false
+ }
+ label := strings.TrimSuffix(host, "."+domain)
+ return label != "" && !strings.Contains(label, ".")
+}
+
+func (s *Server) domainHostDenied(host, account string) bool {
+ allowed := domains.ValidSqid(account) && slices.Contains(s.cfg.AppsDomainAliasAccounts, account) && s.directAppsDomainHost(host)
+ if allowed {
+ domain := strings.ToLower(strings.TrimSpace(s.cfg.AppsDomain))
+ label := strings.TrimSuffix(host, "."+domain)
+ if domains.ValidSqid(label) {
+ return true
+ }
+ }
+ return domains.HostDenied(host, s.deniedDomains(allowed))
+}
diff --git a/internal/console/domainpolicy_test.go b/internal/console/domainpolicy_test.go
new file mode 100644
index 000000000..576bb6c5d
--- /dev/null
+++ b/internal/console/domainpolicy_test.go
@@ -0,0 +1,270 @@
+package console
+
+import (
+ "context"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/edgesfile"
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/status"
+)
+
+func TestDirectAppsDomainHost(t *testing.T) {
+ cases := []struct {
+ name string
+ appsDomain string
+ host string
+ want bool
+ }{
+ {"no apps domain configured", "", "widget.apps.test", false},
+ {"the apex itself is not a one-label alias", "apps.test", "apps.test", false},
+ {"one label under the apps domain", "apps.test", "widget.apps.test", true},
+ {"a nested name is refused", "apps.test", "a.b.apps.test", false},
+ {"a host unrelated to the apps domain", "apps.test", "widget.other.test", false},
+ {"a suffix match without the separating dot", "apps.test", "notapps.test", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ s := &Server{cfg: Config{AppsDomain: c.appsDomain}}
+ if got := s.directAppsDomainHost(c.host); got != c.want {
+ t.Errorf("directAppsDomainHost(%q) with AppsDomain=%q = %v, want %v", c.host, c.appsDomain, got, c.want)
+ }
+ })
+ }
+}
+
+func TestDomainHostDeniedPolicyMatrix(t *testing.T) {
+ const appsDomain = "apps.test"
+ const alias = "widget." + appsDomain
+ base := func() Config {
+ return Config{
+ AppsDomain: appsDomain,
+ Origin: "https://origin." + appsDomain,
+ DenyDomains: []string{"blocked." + appsDomain},
+ RedirectHosts: []string{"retired." + appsDomain},
+ }
+ }
+ cases := []struct {
+ name string
+ mutate func(*Config)
+ host string
+ want bool
+ }{
+ {"flag off denies a one-label alias, unchanged from before the flag existed",
+ func(c *Config) {}, alias, true},
+ {"flag on allows an ordinary one-label alias",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, alias, false},
+ {"flag on still denies the apex",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, appsDomain, true},
+ {"flag on still denies a nested name",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, "a.b." + appsDomain, true},
+ {"flag on still denies an explicit DenyDomains entry",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, "blocked." + appsDomain, true},
+ {"flag on still denies the console's own origin host",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, "origin." + appsDomain, true},
+ {"flag on still denies a retired redirect host",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"} }, "retired." + appsDomain, true},
+ {"flag on with no apps domain configured denies nothing new",
+ func(c *Config) { c.AppsDomainAliasAccounts = []string{"bab"}; c.AppsDomain = "" }, "widget.example.com", false},
+ }
+ for _, c := range cases {
+ t.Run(c.name, func(t *testing.T) {
+ cfg := base()
+ c.mutate(&cfg)
+ s := &Server{cfg: cfg}
+ if got := s.domainHostDenied(c.host, "bab"); got != c.want {
+ t.Errorf("domainHostDenied(%q) = %v, want %v (cfg=%+v)", c.host, got, c.want, cfg)
+ }
+ })
+ }
+}
+
+func TestAPIDomainAttachDeniesAppsDomainAliasByDefault(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-alias-default")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody("widget.oncarlos.test", "go.tito.io"))
+ if code != http.StatusBadRequest {
+ t.Fatalf("attach of an apps-domain alias with the flag unset = %d %s, want 400", code, out)
+ }
+ claimAbsent(t, fx, "widget.oncarlos.test")
+}
+
+func TestAPIDomainAttachAllowsAppsDomainAlias(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-alias-allow")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+
+ if got := attachDomain(t, fx, member, "widget.oncarlos.test", "go.tito.io"); !got.Created {
+ t.Fatalf("attach of a one-label apps-domain alias = %+v, want created true", got)
+ }
+
+ for _, host := range []string{
+ "a.widget.oncarlos.test", // nested
+ "oncarlos.test", // the apex itself
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody(host, "go.tito.io"))
+ if code != http.StatusBadRequest {
+ t.Errorf("attach %q with aliases allowed = %d %s, want 400", host, code, out)
+ }
+ claimAbsent(t, fx, host)
+ }
+}
+
+func TestAPIDomainAttachAppsDomainAliasRejectsWildcardAndCatchall(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-alias-wild")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ const host = "widget.oncarlos.test"
+
+ for name, body := range map[string]string{
+ "wildcard": `{"host":"` + host + `","route":"go.tito.io","wildcard":true}`,
+ "catchall": `{"host":"` + host + `","route":"go.tito.io","catchall":true}`,
+ } {
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusBadRequest {
+ t.Errorf("%s flag on an apps-domain alias = %d %s, want 400", name, code, out)
+ }
+ claimAbsent(t, fx, host)
+ }
+ if got := attachDomain(t, fx, member, host, "go.tito.io"); !got.Created {
+ t.Fatalf("plain attach of the same host with neither flag set = %+v, want created true", got)
+ }
+}
+
+func TestAPIDomainAttachAppsDomainAliasStillRefusesForeignClaim(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-alias-conflict")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ const host = "widget.oncarlos.test"
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: host, Account: "cdef", App: "widgets",
+ Route: "widgets.example.net", CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody(host, "go.tito.io"))
+ if code != http.StatusConflict {
+ t.Fatalf("attach of an apps-domain alias another account holds = %d %s, want 409", code, out)
+ }
+ if got := strings.TrimSpace(out); got != `{"error":"hostname unavailable"}` {
+ t.Fatalf("409 body = %s, want the neutral refusal", got)
+ }
+}
+
+func TestAPIDomainAttachAppsDomainAliasStillRefusesDeploymentRoute(t *testing.T) {
+ ctx := context.Background()
+ fx, member := instanceFixture(t, "code-dom-alias-route")
+ fx.srv.cfg.AppsDomain = "oncarlos.test"
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ const host = "widget.oncarlos.test"
+ if _, err := fx.rel.Put(ctx, edgesfile.Key("annie"),
+ []byte(`{"v":1,"box":"annie","eip":"198.51.100.9","hosts":{"`+host+`":{"role":"home"}}}`), ""); err != nil {
+ t.Fatal(err)
+ }
+ if err := status.WriteBox(ctx, fx.rel, status.Box{Label: "annie", GeneratedAt: time.Now()}); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member,
+ attachBody(host, "two.go.tito.io"))
+ if code != http.StatusConflict {
+ t.Fatalf("attach of an apps-domain alias a live box already serves = %d %s, want 409", code, out)
+ }
+ claimAbsent(t, fx, host)
+}
+
+func TestAttachDomainCoreAppsDomainAlias(t *testing.T) {
+ fx, acct := attachFixture(t)
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ const alias = "widget.oncarlos.com"
+
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", alias, "", "", "paul@keymail.dev"); ref == nil || ref.Status != http.StatusUnprocessableEntity {
+ t.Fatalf("attach of an apps-domain alias with the flag unset = %+v, want 422", ref)
+ }
+ claimAbsent(t, fx, alias)
+
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ res, ref := fx.srv.attachDomain(context.Background(), acct, "story", alias, "", "", "paul@keymail.dev")
+ if ref != nil {
+ t.Fatalf("attach of an apps-domain alias with the flag set = refused %+v", ref)
+ }
+ want := "story." + fx.sqid + ".oncarlos.com"
+ if res.Claim.Route != want {
+ t.Fatalf("claim route = %q, want the ready route %q it aliases", res.Claim.Route, want)
+ }
+
+ if _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "a.b.oncarlos.com", "", "", "paul@keymail.dev"); ref == nil || ref.Status != http.StatusUnprocessableEntity {
+ t.Fatalf("attach of a nested apps-domain name with the flag set = %+v, want still 422", ref)
+ }
+ claimAbsent(t, fx, "a.b.oncarlos.com")
+}
+
+func TestAttachDomainCoreAppsDomainAliasStillRefusesHeldHost(t *testing.T) {
+ fx, acct := attachFixture(t)
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ fx.srv.cfg.Registry = attachRegistry(t,
+ registry.Route{Host: "story." + fx.sqid + ".oncarlos.com", Kind: "static", App: "story",
+ Account: fx.sqid, Channel: "canary/live", Status: "ready"},
+ )
+ const alias = "widget.oncarlos.com"
+ seedClaim(t, fx, domains.Claim{Schema: 1, Host: alias, Account: "zzz", App: "rival"})
+
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", alias, "", "", "paul@keymail.dev")
+ if ref == nil || ref.Status != http.StatusConflict || ref.Msg != "hostname unavailable" {
+ t.Fatalf("refusal = %+v, want exactly 409 hostname unavailable even though aliases are allowed", ref)
+ }
+}
+
+func TestAppsDomainAliasAccountAuthority(t *testing.T) {
+ s := &Server{cfg: Config{
+ AppsDomain: "platform.example.test", AppsDomainAliasAccounts: []string{"bap"},
+ Origin: "https://console.platform.example.test",
+ DenyDomains: []string{"example.test", "platform.example.test", "blocked.platform.example.test"},
+ }}
+ for _, tc := range []struct {
+ host, account string
+ denied bool
+ }{
+ {"pullup.platform.example.test", "bap", false},
+ {"pullup.platform.example.test", "bab", true},
+ {"pullup.platform.example.test", "", true},
+ {"bap.platform.example.test", "bap", true},
+ {"bab.platform.example.test", "bap", true},
+ {"edge.platform.example.test", "bap", true},
+ {"console.platform.example.test", "bap", true},
+ {"blocked.platform.example.test", "bap", true},
+ {"x.bap.platform.example.test", "bap", true},
+ {"platform.example.test", "bap", true},
+ {"outside.example.test", "bap", true},
+ } {
+ if got := s.domainHostDenied(tc.host, tc.account); got != tc.denied {
+ t.Errorf("%s/%s: denied=%v, want %v", tc.account, tc.host, got, tc.denied)
+ }
+ }
+ s.cfg.SteeringHost = "steering.platform.example.test"
+ if !s.domainHostDenied(s.cfg.SteeringHost, "bap") {
+ t.Fatal("steering host allowed")
+ }
+}
+
+func TestAppsDomainAliasCannotMintFirstRoute(t *testing.T) {
+ fx, acct := attachFixture(t)
+ fx.srv.cfg.AppsDomainAliasAccounts = []string{fx.sqid}
+ fx.srv.cfg.Registry = attachRegistry(t)
+ for _, kind := range []string{"", "static"} {
+ _, ref := fx.srv.attachDomain(context.Background(), acct, "story", "pullup.oncarlos.com", kind, "", "actor")
+ if ref == nil || ref.Status != http.StatusUnprocessableEntity {
+ t.Fatalf("kind %q: refusal=%v", kind, ref)
+ }
+ claimAbsent(t, fx, "pullup.oncarlos.com")
+ }
+}
diff --git a/internal/console/server.go b/internal/console/server.go
index 8a7666c78..a922475b6 100644
--- a/internal/console/server.go
+++ b/internal/console/server.go
@@ -513,7 +513,8 @@ type Config struct {
// follows the litestream convention: the member-facing enable door
// does not exist, and the handler refuses with a pointer at the
// operator's store-mode `carlos instances enable`.
- AppsDomain string
+ AppsDomain string
+ AppsDomainAliasAccounts []string
// RedirectHosts are request hosts this console answers with a permanent
// redirect to Origin (CONSOLE_REDIRECT_HOSTS, comma-separated) — retired
// hostnames after a canonical-origin move. Opt-in per host: hostnames