carlos / platform Public

Merge branch 'first-route-wildcard' into main

Merge first-route-wildcard: a first-route claim stays first-route on a route-less re-attach (#238)
claude agent, operated by james@keymail.dev e8d0328dea6fa5474ed432b34b577fd57f2b8c8e parent 58be381parent eca9307
2 files changed, +124 −0
  • internal/console/apidomains.go +27 −0
  • internal/console/apidomains_firstroute_test.go +97 −0

CI passed — run details

diff --git a/internal/console/apidomains.go b/internal/console/apidomains.go
index 376751ba6..2155a225c 100644
--- a/internal/console/apidomains.go
+++ b/internal/console/apidomains.go
@@ -411,6 +411,33 @@ func (s *Server) handleAPIDomainAttach(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": route, "created": res.Created})
return
}
+ // A first-route claim this app already holds stays first-route. Without
+ // this, a re-attach naming no route (how a member flips --wildcard or
+ // --catchall on it) reached resolveAliasRoute, which defaults to the
+ // app's single instance record — for a first-route app, the record the
+ // claim itself declared, at this very host — and AttachClaim then
+ // converged the claim into an alias of its own host. That self-alias
+ // still served, but every box pass logged it as shadowed by a real
+ // route, and detach no longer removed the record (its first-route
+ // branch keys on an empty Route), so the route outlived the claim.
+ // Naming the host itself as --route means the same thing and is treated
+ // the same way: a hostname cannot alias itself.
+ //
+ // Only the caller's OWN claim takes this branch, so it reveals nothing
+ // the list door does not already show them; every other case falls
+ // through to the ordinary path and its neutral refusals unchanged.
+ if want := strings.ToLower(strings.TrimSpace(in.Route)); want == "" || want == host {
+ if cur, err := domains.GetClaim(ctx, s.cfg.Releases, host); err == nil &&
+ cur.Account == acct.Sqid && cur.App == app && cur.Route == "" {
+ if in.Wildcard != nil || in.Catchall != nil {
+ if !s.setDomainCapabilities(w, r, ctx, acct, app, p, host, in.Wildcard, in.Catchall) {
+ return
+ }
+ }
+ writeJSON(w, http.StatusOK, map[string]any{"host": host, "route": host, "created": false})
+ return
+ }
+ }
route, ok := s.resolveAliasRoute(ctx, w, acct, app, in.Route)
if !ok {
return
diff --git a/internal/console/apidomains_firstroute_test.go b/internal/console/apidomains_firstroute_test.go
new file mode 100644
index 000000000..61b5ef7fe
--- /dev/null
+++ b/internal/console/apidomains_firstroute_test.go
@@ -0,0 +1,97 @@
+package console
+
+import (
+ "context"
+ "errors"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/domains"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+// firstRouteFixture gives titogo a first-route claim on woodstar.app — the
+// claim `carlos deploy --kind static --host` makes, with the static record
+// it declared at the same host. That record is titogo's only instance
+// record, which is exactly what used to make a route-less re-attach default
+// to the host itself.
+func firstRouteFixture(t *testing.T, code string) (*consoleFixture, string) {
+ t.Helper()
+ fx, member := instanceFixture(t, code)
+ ctx := context.Background()
+ if _, _, err := domains.AttachClaim(ctx, fx.rel, domains.Claim{
+ Host: "woodstar.app", Account: "bab", App: "titogo", CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ if err := provision.CreateRecord(ctx, fx.rel, provision.Record{
+ Schema: 1, Host: "woodstar.app", App: "titogo", Account: "bab", Channel: "edge", Kind: "static",
+ CreatedAt: time.Now().UTC(),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ return fx, member
+}
+
+func TestAttachKeepsAFirstRouteClaimFirstRoute(t *testing.T) {
+ const zone = "acme.oncarlos.com"
+ for name, body := range map[string]string{
+ "no route": `{"host":"woodstar.app"}`,
+ "route is the host": `{"host":"woodstar.app","route":"WoodStar.app"}`,
+ "wildcard flip": `{"host":"woodstar.app","wildcard":true}`,
+ "wildcard and catchall": `{"host":"woodstar.app","wildcard":true,"catchall":true}`,
+ } {
+ t.Run(name, func(t *testing.T) {
+ fx, member := firstRouteFixture(t, "code-dom-fr-"+strings.ReplaceAll(name, " ", "-"))
+ fx.srv.delegationZone = zone
+ fx.srv.lookupCNAME = func(_ context.Context, n string) (string, error) {
+ if n == "_acme-challenge.woodstar.app" {
+ return "_acme-challenge.woodstar.app." + zone + ".", nil
+ }
+ return "", errors.New("not found")
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, body)
+ if code != http.StatusOK || !strings.Contains(out, `"route":"woodstar.app"`) || !strings.Contains(out, `"created":false`) {
+ t.Fatalf("re-attach = %d %s", code, out)
+ }
+ c := claimOf(t, fx, "woodstar.app")
+ if c.Route != "" {
+ t.Fatalf("claim route = %q, want the first-route claim's empty route", c.Route)
+ }
+ if strings.Contains(body, "wildcard") && !c.Wildcard {
+ t.Fatalf("claim = %+v, want Wildcard set", *c)
+ }
+ if strings.Contains(body, "catchall") && !c.Catchall {
+ t.Fatalf("claim = %+v, want Catchall set", *c)
+ }
+ // Detach still takes the first-route branch: the record goes too.
+ code, _, out = rawReq(t, http.MethodDelete, domainsURL(fx, "bab", "titogo")+"/woodstar.app", member, "")
+ if code != http.StatusOK || !strings.Contains(out, `"removed":true`) {
+ t.Fatalf("detach = %d %s", code, out)
+ }
+ if _, err := provision.GetRecord(context.Background(), fx.rel, "bab", "titogo", "woodstar.app"); err == nil {
+ t.Fatal("the static record outlived its first-route claim")
+ }
+ })
+ }
+}
+
+// Another holder's claim does not take the branch: the ordinary path answers
+// with its neutral refusal, exactly as before.
+func TestAttachFirstRouteBranchIsOwnClaimsOnly(t *testing.T) {
+ fx, member := instanceFixture(t, "code-dom-fr-other")
+ if _, _, err := domains.AttachClaim(context.Background(), fx.rel, domains.Claim{
+ Host: "woodstar.app", Account: "cdef", App: "widgets", CreatedAt: time.Now().UTC().Format(time.RFC3339),
+ }); err != nil {
+ t.Fatal(err)
+ }
+ code, _, out := rawReq(t, http.MethodPost, domainsURL(fx, "bab", "titogo"), member, `{"host":"woodstar.app","route":"go.tito.io"}`)
+ if code != http.StatusConflict || strings.TrimSpace(out) != `{"error":"hostname unavailable"}` {
+ t.Fatalf("attach over another holder's first-route claim = %d %s, want the neutral 409", code, out)
+ }
+ if c := claimOf(t, fx, "woodstar.app"); c.Account != "cdef" || c.Route != "" {
+ t.Fatalf("holder's claim changed: %+v", *c)
+ }
+}