domains: claims carry a prefix map, written only by SetClaimPaths
domains/<host>.json is already one CAS-written object per hostname carrying account and app — exactly the granularity and write semantics a per-host prefix map needs, so path routing is a third claim capability rather than a new control-plane object type. Writes are wholesale: the tenant's platform regenerates the map from its own database and publishes the result, so a republish is always a full repair and a lost update can never desync the two systems. Adding a map field makes Claim non-comparable with ==, so the two existing tests that compared *Claim with != now use reflect.DeepEqual instead. SetClaimPaths also validates input (cap, prefix shape, target hostname) only after confirming the claim exists and is owned by the caller, so a malformed map against a claim the caller doesn't hold still surfaces the neutral ErrHostUnavailable rather than a validation error.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
4 files changed,
+360
−3
internal/domains/claimcache_test.go+2 −1internal/domains/claims.go+143 −0internal/domains/claims_test.go+3 −2internal/domains/paths_test.go+212 −0
diff --git a/internal/domains/claimcache_test.go b/internal/domains/claimcache_test.go| index 985dffad1..6b74485cb 100644 |
| --- a/internal/domains/claimcache_test.go |
| +++ b/internal/domains/claimcache_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "os" |
| + "reflect" |
| "testing" |
| "time" |
| @@ -117,7 +118,7 @@ func TestClaimCacheAgreesWithTheUncachedFold(t *testing.T) { |
| t.Errorf("cached fold invented %s", got.Host) |
| continue |
| } |
| - if got != want { |
| + if !reflect.DeepEqual(got, want) { |
| t.Errorf("%s: cached %+v, uncached %+v", got.Host, got, want) |
| } |
| } |
diff --git a/internal/domains/claims.go b/internal/domains/claims.go| index ec8c8533e..402eb99c4 100644 |
| --- a/internal/domains/claims.go |
| +++ b/internal/domains/claims.go |
| @@ -55,6 +55,22 @@ type Claim struct { |
| // the same change that first writes it. |
| Wildcard bool `json:"wildcard,omitempty"` |
| Catchall bool `json:"catchall,omitempty"` |
| + // Paths splits this hostname's traffic by leading URL segment: a bare |
| + // first segment ("paulca", never "/paulca") mapped to the HOSTNAME of the |
| + // route that serves it. Absent or empty means this host routes by |
| + // hostname alone — the behaviour of every host today, and the reason this |
| + // field is additive in the strongest sense: no row, no map, no change. |
| + // |
| + // This is a THIRD claim capability alongside Wildcard/Catchall, and it is |
| + // declared here for the reason stated above them — converge preserves only |
| + // DECLARED fields, so an undeclared field would be stripped by any binary |
| + // that rewrote the claim. |
| + // |
| + // The writer is the tenant's own platform, publishing the whole map |
| + // regenerated from its own database. Deltas were rejected deliberately: a |
| + // lost delta desyncs the two systems silently, whereas a wholesale |
| + // republish is always a full repair. |
| + Paths map[string]string `json:"paths,omitempty"` |
| } |
| // ErrHostUnavailable is returned by AttachClaim when host is already claimed |
| @@ -316,3 +332,130 @@ func SetClaimCapabilities(ctx context.Context, s release.Store, host, account, a |
| } |
| } |
| } |
| + |
| +// MaxPathsPerClaim bounds a claim's prefix map. Unlike maxWildcardDomains |
| +// this defends no external budget — there is no ACME equivalent for a path |
| +// row — it bounds the claim object's size and the per-box fold that reads it |
| +// every fast-converge tick. At roughly 40 bytes an entry this is a ~400KB |
| +// object refolded every couple of seconds, which is comfortable; sharding a |
| +// host's map across objects is deliberately not built until a real host |
| +// approaches this number. |
| +const MaxPathsPerClaim = 10000 |
| + |
| +// ErrTooManyPaths is returned by SetClaimPaths for a map above |
| +// MaxPathsPerClaim. Refused whole: a silently truncated map would route a |
| +// subset of accounts and look successful. |
| +var ErrTooManyPaths = errors.New("too many path prefixes") |
| + |
| +// ErrBadPathPrefix is returned by SetClaimPaths for a key that is not a bare |
| +// single path segment. |
| +var ErrBadPathPrefix = errors.New("path prefix must be a single bare segment") |
| + |
| +// validPathPrefix reports whether p is a bare first path segment: non-empty, |
| +// no slashes, and nothing that would not survive a URL path intact. The edge |
| +// matches the request's first segment by EQUALITY, so anything that cannot be |
| +// a first segment could never match and must not be stored. |
| +func validPathPrefix(p string) bool { |
| + if p == "" || len(p) > 255 { |
| + return false |
| + } |
| + for i := 0; i < len(p); i++ { |
| + c := p[i] |
| + switch { |
| + case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9': |
| + case c == '-' || c == '_' || c == '.' || c == '~': |
| + default: |
| + return false |
| + } |
| + } |
| + // "." and ".." would be normalised away by any path cleaner between the |
| + // browser and here, so a row keyed on either is unreachable by |
| + // construction. |
| + return p != "." && p != ".." |
| +} |
| + |
| +// SetClaimPaths is the ONLY writer of a claim's Paths field, the same |
| +// single-writer discipline SetClaimCapabilities holds over Wildcard/Catchall |
| +// and for the same reason: AttachClaim's converge repoints Route and nothing |
| +// else, so it can preserve this map but must never invent or strip it. |
| +// |
| +// The write is WHOLESALE — paths replaces the stored map rather than merging |
| +// into it. That is the contract the caller's platform relies on: it |
| +// regenerates the map from its own database and publishes the result, so an |
| +// entry it no longer lists must disappear. Merging would make rollback |
| +// impossible to express. |
| +// |
| +// Owner-gated exactly as SetClaimCapabilities is: a caller who does not hold |
| +// the claim gets the neutral ErrHostUnavailable, and a missing claim answers |
| +// identically, so this door is not an existence oracle. |
| +func SetClaimPaths(ctx context.Context, s release.Store, host, account, app string, paths map[string]string) (changed bool, err error) { |
| + for attempt := 0; ; attempt++ { |
| + cur, etag, err := getClaimETag(ctx, s, host) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return false, ErrHostUnavailable |
| + } else if err != nil { |
| + return false, err |
| + } |
| + if cur.Account != account || cur.App != app { |
| + return false, ErrHostUnavailable |
| + } |
| + // Input validation happens only after existence and ownership are |
| + // confirmed: a malformed map submitted against a host the caller |
| + // doesn't hold must still surface the neutral ErrHostUnavailable, |
| + // never a validation error that would tell a non-owner their input |
| + // was even looked at. |
| + if len(paths) > MaxPathsPerClaim { |
| + return false, ErrTooManyPaths |
| + } |
| + for prefix, target := range paths { |
| + if !validPathPrefix(prefix) { |
| + return false, ErrBadPathPrefix |
| + } |
| + // The target must be a plausible hostname. Whether it is a route |
| + // this account owns is decided by the reconcile pass, which — |
| + // unlike this door — can see the registry. |
| + if n, ok := NormalizeAliasHost(target); !ok || n != target { |
| + return false, fmt.Errorf("domains: path prefix %q: target %q is not a normalised hostname", prefix, target) |
| + } |
| + } |
| + if samePaths(cur.Paths, paths) { |
| + return false, nil |
| + } |
| + next := *cur |
| + if len(paths) == 0 { |
| + next.Paths = nil // omitempty: an empty map and no map are one state |
| + } else { |
| + next.Paths = make(map[string]string, len(paths)) |
| + for k, v := range paths { |
| + next.Paths[k] = v |
| + } |
| + } |
| + body, err := json.Marshal(next) |
| + if err != nil { |
| + return false, err |
| + } |
| + if _, err := s.Put(ctx, ClaimKey(host), body, etag); err == nil { |
| + return true, nil |
| + } else if errors.Is(err, release.ErrCASConflict) && attempt < 3 { |
| + continue |
| + } else { |
| + return false, err |
| + } |
| + } |
| +} |
| + |
| +// samePaths reports whether two prefix maps are equal, treating nil and empty |
| +// as the same state. Republishing an unchanged map is the COMMON case — the |
| +// caller republishes wholesale as a repair — so this is what keeps a repair |
| +// from costing a bucket write and a fleet-wide edge reload. |
| +func samePaths(a, b map[string]string) bool { |
| + if len(a) != len(b) { |
| + return false |
| + } |
| + for k, v := range a { |
| + if b[k] != v { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
diff --git a/internal/domains/claims_test.go b/internal/domains/claims_test.go| index 72513ef18..0a3fd8063 100644 |
| --- a/internal/domains/claims_test.go |
| +++ b/internal/domains/claims_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "errors" |
| "os" |
| + "reflect" |
| "strings" |
| "testing" |
| @@ -53,7 +54,7 @@ func TestAttachClaimIdempotent(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if *before != *after { |
| + if !reflect.DeepEqual(before, after) { |
| t.Fatalf("object changed on idempotent re-attach: before=%+v after=%+v", before, after) |
| } |
| } |
| @@ -117,7 +118,7 @@ func TestAttachClaimOtherOwnerRefused(t *testing.T) { |
| if err != nil { |
| t.Fatal(err) |
| } |
| - if *before != *after { |
| + if !reflect.DeepEqual(before, after) { |
| t.Fatalf("object changed on refused attach: before=%+v after=%+v", before, after) |
| } |
| } |
diff --git a/internal/domains/paths_test.go b/internal/domains/paths_test.go| new file mode 100644 |
| index 000000000..54e46ce9b |
| --- /dev/null |
| +++ b/internal/domains/paths_test.go |
| @@ -0,0 +1,212 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "strconv" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// TestSetClaimPathsRoundTrip: the map written is the map read back. |
| +func TestSetClaimPathsRoundTrip(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !changed { |
| + t.Fatal("changed = false on the first write of a paths map") |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want paulca -> go.tito.io", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsWholesale: publishing is a REPLACE, not a merge. Tito |
| +// regenerates the whole map from its own database, so an account it no longer |
| +// lists must disappear — that is how a rollback reaches the edge. |
| +func TestSetClaimPathsIsWholesale(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, still := c.Paths["bigcorp"]; still { |
| + t.Fatal("bigcorp survived a republish that omitted it — publishing must replace, not merge") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsEmptyClearsTheMap: the final cutover. When every account |
| +// is across, Tito repoints the host's own route and publishes an empty map. |
| +func TestSetClaimPathsEmptyClearsTheMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if len(c.Paths) != 0 { |
| + t.Fatalf("paths = %v, want empty after publishing an empty map", c.Paths) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsIsOwnerGated: a caller who does not hold the claim gets |
| +// the same neutral ErrHostUnavailable the attach door uses — no existence |
| +// oracle, byte-identical to the answer for a claim that does not exist. |
| +func TestSetClaimPathsIsOwnerGated(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "someone-else", "classic", |
| + map[string]string{"paulca": "evil.example"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want ErrHostUnavailable for a non-owner", err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "never-claimed.example", "tito", "classic", |
| + map[string]string{"a": "b"}); !errors.Is(err, ErrHostUnavailable) { |
| + t.Fatalf("err = %v, want the identical ErrHostUnavailable for an absent claim", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsUnchangedIsNoWrite: republishing an identical map must not |
| +// burn a bucket write. Tito republishes wholesale as a repair, so the no-op |
| +// case is the common one. |
| +func TestSetClaimPathsUnchangedIsNoWrite(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + m := map[string]string{"paulca": "go.tito.io"} |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m); err != nil { |
| + t.Fatal(err) |
| + } |
| + changed, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", m) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if changed { |
| + t.Fatal("changed = true for an identical republish — a no-op must not write") |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRefusesOversizeMap: the cap is enforced at the door, so an |
| +// oversize map is refused whole rather than silently truncated into the |
| +// bucket. (The reconcile pass caps again, defensively, for hand-written |
| +// claims — see Task 3.) |
| +func TestSetClaimPathsRefusesOversizeMap(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + big := make(map[string]string, MaxPathsPerClaim+1) |
| + for i := 0; i <= MaxPathsPerClaim; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", big); !errors.Is(err, ErrTooManyPaths) { |
| + t.Fatalf("err = %v, want ErrTooManyPaths", err) |
| + } |
| +} |
| + |
| +// TestSetClaimPathsRejectsMalformedPrefixes: a prefix is a bare segment. A |
| +// leading slash, an embedded slash, or an empty key would each produce a row |
| +// the edge could never match, so they are refused at the door rather than |
| +// stored as dead weight. |
| +func TestSetClaimPathsRejectsMalformedPrefixes(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, bad := range []string{"/paulca", "paulca/awesomeconf", "", "with space"} { |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{bad: "go.tito.io"}); !errors.Is(err, ErrBadPathPrefix) { |
| + t.Fatalf("prefix %q: err = %v, want ErrBadPathPrefix", bad, err) |
| + } |
| + } |
| +} |
| + |
| +// TestAttachConvergePreservesPaths: a repoint must not strip the prefix map. |
| +func TestAttachConvergePreservesPaths(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti.to", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, err := SetClaimPaths(ctx, s, "ti.to", "tito", "classic", |
| + map[string]string{"paulca": "go.tito.io"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + if _, _, err := AttachClaim(ctx, s, Claim{ |
| + Schema: 1, Host: "ti.to", Account: "tito", App: "classic", |
| + Route: "ti-to-v2.tito.io", CreatedAt: "2026-09-03T00:00:00Z", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + c, err := GetClaim(ctx, s, "ti.to") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if c.Paths["paulca"] != "go.tito.io" { |
| + t.Fatalf("paths = %v, want the map preserved across a converge repoint", c.Paths) |
| + } |
| +} |