domains attach: a first-route claim stays first-route on a route-less re-attach
A member flips --wildcard or --catchall by re-attaching the hostname. For a first-route claim (the one `carlos deploy --kind static --host` makes), the door's route default resolved to the app's single instance record, which is the static record the claim itself declared at the same host. AttachClaim then converged the claim into an alias of its own host. It still served, but every box pass logged the alias as shadowed by a real route, and detach stopped deleting the record (its first-route branch keys on an empty Route), so the route outlived the claim. The attach door now recognises the caller's own first-route claim when the request names no route, or names the host itself. It leaves the route alone, applies any capability flip, and answers like the static path does. Other holders' claims fall through to the ordinary path and its neutral refusals, unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 files changed,
+124
−0
internal/console/apidomains.go+27 −0internal/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) |
| + } |
| +} |