carlos / platform Public

feat: global hostname claim records (#151)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 98fed206bea4faf5c160e86990b01458eb79599f parent 3990067
2 files changed, +458 −0
  • internal/domains/claims.go +215 −0
  • internal/domains/claims_test.go +243 −0
diff --git a/internal/domains/claims.go b/internal/domains/claims.go
new file mode 100644
index 000000000..cf0823f80
--- /dev/null
+++ b/internal/domains/claims.go
@@ -0,0 +1,215 @@
+// Global hostname claims — the desired-state object behind attaching an
+// extra hostname to a route (#151). A claim lives at ClaimKey(host), the
+// SAME key regardless of which account or app is asking, so the bucket key
+// itself is the cross-account uniqueness gate: two accounts racing for the
+// same host cannot both win, because there is only ever one object to Put.
+// Later tasks derive registry rows from these claims; nothing here talks to
+// the registry.
+package domains
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "log"
+ "os"
+ "regexp"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+// ClaimsPrefix is where every hostname claim lives.
+const ClaimsPrefix = "domains/"
+
+// ClaimKey is the bucket key for host's claim.
+func ClaimKey(host string) string { return ClaimsPrefix + host + ".json" }
+
+// Claim is the wire shape at ClaimKey(Host) — the global record of who owns
+// an extra hostname attached to a route. Route is omitempty because a claim
+// can exist ahead of the route it will back (reserved but not yet wired);
+// CreatedBy is omitempty because a claim minted by a box-side process, not a
+// human door, has no operator to name.
+type Claim struct {
+ Schema int `json:"schema"` // 1
+ Host string `json:"host"`
+ Account string `json:"account"`
+ App string `json:"app"`
+ Route string `json:"route,omitempty"`
+ CreatedAt string `json:"created_at"`
+ CreatedBy string `json:"created_by,omitempty"`
+}
+
+// ErrHostUnavailable is returned by AttachClaim when host is already claimed
+// by a different account or app. Deliberately neutral: it never names the
+// current holder, so a caller cannot use this door to discover who owns a
+// hostname they do not.
+var ErrHostUnavailable = errors.New("hostname unavailable")
+
+// getClaimETag reads and parses host's claim, carrying the etag it was read
+// under. Not-found stays os.ErrNotExist-wrapped. A schema newer than this
+// binary understands is refused rather than silently truncated by
+// json.Unmarshal — the GetManifestETag precedent (provision.go).
+func getClaimETag(ctx context.Context, s release.Store, host string) (*Claim, string, error) {
+ raw, etag, err := s.Get(ctx, ClaimKey(host))
+ if err != nil {
+ return nil, "", err
+ }
+ var c Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ return nil, "", fmt.Errorf("domains: claim %s: %w", host, err)
+ }
+ if c.Schema > 1 {
+ return nil, "", fmt.Errorf("domains: claim %s: schema %d is newer than this binary understands — upgrade carlos", host, c.Schema)
+ }
+ return &c, etag, nil
+}
+
+// GetClaim reads host's claim. Not-found stays os.ErrNotExist-wrapped.
+func GetClaim(ctx context.Context, s release.Store, host string) (*Claim, error) {
+ c, _, err := getClaimETag(ctx, s, host)
+ return c, err
+}
+
+// AttachClaim writes host's global claim, create-only by key: the bucket
+// key IS the cross-account uniqueness gate. Same owner re-attaching is
+// idempotent (converging Route in place); any other owner gets
+// ErrHostUnavailable — deliberately neutral, never naming the holder.
+func AttachClaim(ctx context.Context, s release.Store, c Claim) (bool, error) {
+ c.Schema = 1
+ body, err := json.Marshal(c)
+ if err != nil {
+ return false, err
+ }
+ for attempt := 0; ; attempt++ {
+ cur, etag, err := getClaimETag(ctx, s, c.Host)
+ if errors.Is(err, os.ErrNotExist) {
+ if _, err := s.Put(ctx, ClaimKey(c.Host), body, ""); err == nil {
+ return true, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // lost the create race — re-read and judge the winner
+ } else {
+ return false, err
+ }
+ } else if err != nil {
+ return false, err
+ }
+ if cur.Account != c.Account || cur.App != c.App {
+ return false, ErrHostUnavailable
+ }
+ if cur.Route == c.Route {
+ return false, nil
+ }
+ if _, err := s.Put(ctx, ClaimKey(c.Host), body, etag); err == nil {
+ return false, nil
+ } else if errors.Is(err, release.ErrCASConflict) && attempt == 0 {
+ continue // converge race — one retry, then surface
+ } else {
+ return false, err
+ }
+ }
+}
+
+// DetachClaim removes host's claim, but only for the owner that holds it:
+// a mismatched account/app is quietly ignored (removed=false, nil error)
+// rather than erroring, the same neutrality AttachClaim's ErrHostUnavailable
+// observes — a caller must not learn who the real owner is by probing
+// detach. An already-absent claim is likewise quiet: detach is idempotent.
+func DetachClaim(ctx context.Context, s release.Store, host, account, app string) (bool, error) {
+ cur, err := GetClaim(ctx, s, host)
+ if errors.Is(err, os.ErrNotExist) {
+ return false, nil
+ }
+ if err != nil {
+ return false, err
+ }
+ if cur.Account != account || cur.App != app {
+ return false, nil
+ }
+ if err := s.Delete(ctx, ClaimKey(host)); err != nil {
+ return false, err
+ }
+ return true, nil
+}
+
+// ListClaims returns every valid claim in the bucket. An unparseable or
+// newer-schema object is logged and skipped — one bad claim must never take
+// the whole listing down.
+func ListClaims(ctx context.Context, s release.Store) ([]Claim, error) {
+ keys, err := s.List(ctx, ClaimsPrefix)
+ if err != nil {
+ return nil, fmt.Errorf("domains: list claims: %w", err)
+ }
+ var claims []Claim
+ for _, key := range keys {
+ raw, _, err := s.Get(ctx, key)
+ if err != nil {
+ log.Printf("domains: claim %s: %v", key, err)
+ continue
+ }
+ var c Claim
+ if err := json.Unmarshal(raw, &c); err != nil {
+ log.Printf("domains: claim %s: unparseable: %v", key, err)
+ continue
+ }
+ if c.Schema > 1 {
+ log.Printf("domains: claim %s: schema %d is newer than this binary understands, skipping", key, c.Schema)
+ continue
+ }
+ claims = append(claims, c)
+ }
+ return claims, nil
+}
+
+// hostLabelRe mirrors provision.labelRe (which itself mirrors
+// domains.accountNameRe — see that var's comment) — pinned HERE rather than
+// imported from internal/provision, because internal/provision/reconcile.go
+// already imports internal/domains for account/route materialization;
+// importing provision.ValidHostShape back would be a cycle. Same DNS label
+// rule: lowercase a-z, 0-9, hyphen, no leading/trailing hyphen, 1-63 chars.
+var hostLabelRe = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
+
+// validHostShape is provision.ValidHostShape's check, duplicated for the
+// import-cycle reason hostLabelRe explains: every label of host must match
+// hostLabelRe, and the whole host must fit in the DNS 253-character ceiling.
+func validHostShape(host string) bool {
+ if host == "" || len(host) > 253 {
+ return false
+ }
+ for _, label := range strings.Split(host, ".") {
+ if !hostLabelRe.MatchString(label) {
+ return false
+ }
+ }
+ return true
+}
+
+// NormalizeAliasHost lowercases raw, strips one trailing dot, and validates
+// the result: validHostShape (the same label rules a route host is held to)
+// AND at least one interior dot — a bare label like "localhost" is never a
+// public hostname, however it parses.
+func NormalizeAliasHost(raw string) (string, bool) {
+ h := strings.ToLower(raw)
+ h = strings.TrimSuffix(h, ".")
+ if !validHostShape(h) || !strings.Contains(h, ".") {
+ return "", false
+ }
+ return h, true
+}
+
+// HostDenied reports whether host equals any entry in deny, or is a
+// subdomain of one (ends in "."+entry). Empty entries are skipped, so a
+// deny list padded with "" (e.g. from a blank config line) can never match
+// every host.
+func HostDenied(host string, deny []string) bool {
+ for _, entry := range deny {
+ if entry == "" {
+ continue
+ }
+ if host == entry || strings.HasSuffix(host, "."+entry) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go
new file mode 100644
index 000000000..697cfc36c
--- /dev/null
+++ b/internal/domains/claims_test.go
@@ -0,0 +1,243 @@
+package domains
+
+import (
+ "context"
+ "errors"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/release"
+)
+
+func TestAttachClaimCreates(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ created, err := AttachClaim(ctx, s, Claim{
+ Host: "tickets.example.com", Account: "bab", App: "kass",
+ Route: "canary/live", CreatedAt: "2026-08-12T00:00:00Z", CreatedBy: "paul@tito.io",
+ })
+ if err != nil || !created {
+ t.Fatalf("AttachClaim: created=%v err=%v, want true nil", created, err)
+ }
+ got, err := GetClaim(ctx, s, "tickets.example.com")
+ if err != nil {
+ t.Fatalf("GetClaim: %v", err)
+ }
+ if got.Schema != 1 || got.Host != "tickets.example.com" || got.Account != "bab" ||
+ got.App != "kass" || got.Route != "canary/live" || got.CreatedAt != "2026-08-12T00:00:00Z" ||
+ got.CreatedBy != "paul@tito.io" {
+ t.Fatalf("GetClaim round-trip mismatch: %+v", got)
+ }
+}
+
+func TestAttachClaimIdempotent(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"}
+ if _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ before, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ created, err := AttachClaim(ctx, s, claim)
+ if err != nil || created {
+ t.Fatalf("AttachClaim re-attach: created=%v err=%v, want false nil", created, err)
+ }
+ after, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *before != *after {
+ t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after)
+ }
+}
+
+func TestAttachClaimConvergesRoute(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"}
+ if _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ claim.Route = "canary/next"
+ created, err := AttachClaim(ctx, s, claim)
+ if err != nil || created {
+ t.Fatalf("AttachClaim route change: created=%v err=%v, want false nil", created, err)
+ }
+ got, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got.Route != "canary/next" {
+ t.Fatalf("Route = %q, want canary/next", got.Route)
+ }
+}
+
+func TestAttachClaimOtherOwnerRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass", Route: "canary/live"}
+ if _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ before, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ other := claim
+ other.Account = "bad"
+ if _, err := AttachClaim(ctx, s, other); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("other account: err = %v, want ErrHostUnavailable", err)
+ }
+
+ otherApp := claim
+ otherApp.App = "console"
+ if _, err := AttachClaim(ctx, s, otherApp); !errors.Is(err, ErrHostUnavailable) {
+ t.Fatalf("other app: err = %v, want ErrHostUnavailable", err)
+ }
+
+ after, err := GetClaim(ctx, s, claim.Host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if *before != *after {
+ t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after)
+ }
+}
+
+func TestAttachClaimNewerSchemaRefused(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ body := []byte(`{"schema":2,"host":"tickets.example.com","account":"bab","app":"kass"}`)
+ if _, err := s.Put(ctx, ClaimKey("tickets.example.com"), body, ""); err != nil {
+ t.Fatal(err)
+ }
+ _, err := AttachClaim(ctx, s, Claim{Host: "tickets.example.com", Account: "bab", App: "kass"})
+ if err == nil || !strings.Contains(err.Error(), "schema") {
+ t.Fatalf("AttachClaim over newer schema: err = %v, want error mentioning schema", err)
+ }
+ got, _, gerr := s.Get(ctx, ClaimKey("tickets.example.com"))
+ if gerr != nil {
+ t.Fatal(gerr)
+ }
+ if string(got) != string(body) {
+ t.Fatalf("object changed on newer-schema refusal: got %s", got)
+ }
+}
+
+func TestDetachClaimRemoves(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}
+ if _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ removed, err := DetachClaim(ctx, s, claim.Host, claim.Account, claim.App)
+ if err != nil || !removed {
+ t.Fatalf("DetachClaim: removed=%v err=%v, want true nil", removed, err)
+ }
+ if _, err := GetClaim(ctx, s, claim.Host); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("GetClaim after detach: err = %v, want os.ErrNotExist", err)
+ }
+}
+
+func TestDetachClaimUnknownQuiet(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ removed, err := DetachClaim(ctx, s, "nope.example.com", "bab", "kass")
+ if err != nil || removed {
+ t.Fatalf("DetachClaim unknown: removed=%v err=%v, want false nil", removed, err)
+ }
+}
+
+func TestDetachClaimOtherOwnerQuiet(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ claim := Claim{Host: "tickets.example.com", Account: "bab", App: "kass"}
+ if _, err := AttachClaim(ctx, s, claim); err != nil {
+ t.Fatal(err)
+ }
+ removed, err := DetachClaim(ctx, s, claim.Host, "bad", "console")
+ if err != nil || removed {
+ t.Fatalf("DetachClaim other owner: removed=%v err=%v, want false nil", removed, err)
+ }
+ if _, err := GetClaim(ctx, s, claim.Host); err != nil {
+ t.Fatalf("GetClaim after other-owner detach: %v, want claim still present", err)
+ }
+}
+
+func TestListClaimsSkipsGarbage(t *testing.T) {
+ s := release.NewDirStore(t.TempDir())
+ ctx := context.Background()
+ if _, err := AttachClaim(ctx, s, Claim{Host: "a.example.com", Account: "bab", App: "kass"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := AttachClaim(ctx, s, Claim{Host: "b.example.com", Account: "bad", App: "console"}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Put(ctx, ClaimKey("garbage.example.com"), []byte("not json"), ""); err != nil {
+ t.Fatal(err)
+ }
+ claims, err := ListClaims(ctx, s)
+ if err != nil {
+ t.Fatalf("ListClaims: %v", err)
+ }
+ if len(claims) != 2 {
+ t.Fatalf("ListClaims returned %d claims, want 2: %+v", len(claims), claims)
+ }
+ hosts := map[string]bool{}
+ for _, c := range claims {
+ hosts[c.Host] = true
+ }
+ if !hosts["a.example.com"] || !hosts["b.example.com"] {
+ t.Fatalf("ListClaims missing expected hosts: %+v", claims)
+ }
+}
+
+func TestNormalizeAliasHost(t *testing.T) {
+ cases := []struct {
+ raw string
+ want string
+ ok bool
+ }{
+ {"Tickets.Example.COM.", "tickets.example.com", true},
+ {"localhost", "", false},
+ {"a..b", "", false},
+ {"", "", false},
+ {strings.Repeat("a", 254), "", false},
+ }
+ for _, c := range cases {
+ got, ok := NormalizeAliasHost(c.raw)
+ if ok != c.ok {
+ t.Errorf("NormalizeAliasHost(%q) ok = %v, want %v", c.raw, ok, c.ok)
+ continue
+ }
+ if ok && got != c.want {
+ t.Errorf("NormalizeAliasHost(%q) = %q, want %q", c.raw, got, c.want)
+ }
+ }
+}
+
+func TestHostDenied(t *testing.T) {
+ deny := []string{"oncarlos.com"}
+ cases := []struct {
+ host string
+ want bool
+ }{
+ {"x.oncarlos.com", true},
+ {"oncarlos.com", true},
+ {"xoncarlos.com", false},
+ }
+ for _, c := range cases {
+ if got := HostDenied(c.host, deny); got != c.want {
+ t.Errorf("HostDenied(%q, %v) = %v, want %v", c.host, deny, got, c.want)
+ }
+ }
+ if HostDenied("anything.example.com", []string{"", "oncarlos.com"}) {
+ t.Fatal("empty deny entry must never match")
+ }
+}