domains: derive route_paths from claims, gated on same-account targets
Extends the pass that already materializes route_aliases and wildcard_domains, keeping the same wholly-pass-owned property: a prefix dropped from a republished map disappears on the next tick, so rollback needs no delete verb. Adds one authorization rule — a prefix's target must belong to the claim's account — so a prefix can never point another tenant's traffic at your backend. A target that is not a local route yields no row, so the prefix falls through to the hostname's own route: a missing destination serves the origin, never a 404.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2 files changed,
+282
−14
internal/domains/aliases.go+107 −14internal/domains/aliases_paths_test.go+175 −0
diff --git a/internal/domains/aliases.go b/internal/domains/aliases.go| index 21530eb4c..6a6776d6b 100644 |
| --- a/internal/domains/aliases.go |
| +++ b/internal/domains/aliases.go |
| @@ -1,17 +1,19 @@ |
| // Alias reconcile pass — derives the registry's route_aliases table AND its |
| -// wildcard_domains table from the global claims recorded in the bucket |
| -// (#151). A claim contributes an ordinary alias row when it names a Route |
| -// (see the loop below); a Wildcard claim additionally contributes a |
| -// wildcard_domains row (a delegated-ACME wildcard-cert parent), and a |
| +// wildcard_domains and route_paths tables from the global claims recorded in |
| +// the bucket (#151). A claim contributes an ordinary alias row when it names |
| +// a Route (see the loop below); a Wildcard claim additionally contributes a |
| +// wildcard_domains row (a delegated-ACME wildcard-cert parent), a |
| // Wildcard+Catchall claim also contributes a "*.<host>" route_aliases row |
| -// pointing at the same primary. See claims.go for the claim shape and |
| -// registry.go's RouteAlias/WildcardDomain for the storage shapes; this file |
| -// is the seam that turns one into the other. |
| +// pointing at the same primary, and a claim's Paths map contributes one |
| +// route_paths row per prefix (path-based routing). See claims.go for the |
| +// claim shape and registry.go's RouteAlias/WildcardDomain/RoutePath for the |
| +// storage shapes; this file is the seam that turns one into the other. |
| package domains |
| import ( |
| "context" |
| "log" |
| + "sort" |
| "time" |
| "github.com/carlosframework/platform/internal/registry" |
| @@ -24,13 +26,14 @@ import ( |
| // order); drops are logged, never silent. |
| const maxWildcardDomains = 32 |
| -// ReconcileAliases derives route_aliases and wildcard_domains from the |
| -// global claim records. Both tables are wholly pass-owned: every row traces |
| -// to a claim, so removal needs no ownership flag. The authoritative tenancy |
| -// gate is HERE — the door's route check is best-effort (it cannot see the |
| -// registry); a claim only materializes when its primary route exists |
| -// locally AND its account/app match the claim. Routes always win a host |
| -// collision: an alias (plain or catch-all) must never shadow a real route. |
| +// ReconcileAliases derives route_aliases, wildcard_domains and route_paths |
| +// from the global claim records. All three tables are wholly pass-owned: |
| +// every row traces to a claim, so removal needs no ownership flag. The |
| +// authoritative tenancy gate is HERE — the door's route check is |
| +// best-effort (it cannot see the registry); a claim only materializes when |
| +// its primary route exists locally AND its account/app match the claim. |
| +// Routes always win a host collision: an alias (plain or catch-all) must |
| +// never shadow a real route. |
| // |
| // deploymentRoutes is every hostname a live box in this deployment publishes |
| // as a REAL route (edgesfile.DeploymentRoutes). It extends "routes always |
| @@ -64,6 +67,7 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| desired := map[string]bool{} |
| desiredWild := map[string]registry.WildcardDomain{} |
| + desiredPaths := map[string]registry.RoutePath{} // key: host + "\x00" + prefix |
| wildCount := 0 |
| for _, c := range claims { |
| // Plain alias derivation (#164): a routeless (first-route) claim is |
| @@ -110,6 +114,73 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| } |
| + // Path derivation (prefix routing). A claim's Paths map splits its own |
| + // hostname's traffic by leading URL segment; each entry names the |
| + // hostname of the route that serves that segment. |
| + // |
| + // Gated on the claim's host being a LOCAL route whose tenancy matches |
| + // the claim — the same per-box rule the alias half above follows, and |
| + // for the same reason: the prefix map belongs to that route, so a box |
| + // that does not serve the route derives nothing. |
| + // |
| + // Ordered BEFORE the wildcard section on purpose: Paths is a third |
| + // claim capability independent of Wildcard (claims.go's comment on |
| + // the field), and the wildcard section below `continue`s for every |
| + // non-Wildcard claim — placed after that, a Paths-only claim would |
| + // never reach this block. |
| + if len(c.Paths) > 0 { |
| + hostRt, ok := byHost[c.Host] |
| + if !ok { |
| + // The hostname's own route is not on this box; another box |
| + // derives these rows. |
| + } else if hostRt.App != c.App || release.RouteAccount(hostRt) != c.Account { |
| + log.Printf("domains: paths on %s refused: route belongs to %s/%s, claim says %s/%s", |
| + c.Host, release.RouteAccount(hostRt), hostRt.App, c.Account, c.App) |
| + } else { |
| + // Sorted so the cap below truncates DETERMINISTICALLY: every |
| + // box must derive the identical subset, or the same URL would |
| + // resolve differently depending on which edge answered. |
| + prefixes := make([]string, 0, len(c.Paths)) |
| + for p := range c.Paths { |
| + prefixes = append(prefixes, p) |
| + } |
| + sort.Strings(prefixes) |
| + if len(prefixes) > MaxPathsPerClaim { |
| + log.Printf("domains: paths on %s: %d prefixes exceeds the cap of %d, dropping %d in sorted order", |
| + c.Host, len(prefixes), MaxPathsPerClaim, len(prefixes)-MaxPathsPerClaim) |
| + prefixes = prefixes[:MaxPathsPerClaim] |
| + } |
| + for _, prefix := range prefixes { |
| + target := c.Paths[prefix] |
| + if !validPathPrefix(prefix) { |
| + log.Printf("domains: path %s/%s refused: not a bare path segment", c.Host, prefix) |
| + continue |
| + } |
| + tgtRt, ok := byHost[target] |
| + if !ok { |
| + // Target is not a route on this box. No row, so the |
| + // prefix falls through to the host's own route — the |
| + // right failure direction for a migration: a missing |
| + // destination serves the ORIGIN, never a 404. |
| + continue |
| + } |
| + // The authorization rule this feature adds: a prefix must |
| + // never point another tenant's traffic at your backend. |
| + // Account only — an account routing between its own apps |
| + // (classic and Go ARE two apps) is the whole use case. |
| + if release.RouteAccount(tgtRt) != c.Account { |
| + log.Printf("domains: path %s/%s refused: target %s belongs to %s, claim says %s", |
| + c.Host, prefix, target, release.RouteAccount(tgtRt), c.Account) |
| + continue |
| + } |
| + desiredPaths[c.Host+"\x00"+prefix] = registry.RoutePath{ |
| + Host: c.Host, Prefix: prefix, Target: target, |
| + Account: c.Account, App: c.App, |
| + } |
| + } |
| + } |
| + } |
| + |
| // Wildcard/catch-all derivation. A claim with Catchall but not |
| // Wildcard cannot come from the write path (SetClaimCapabilities |
| // refuses it), but a hand-written or pre-migration claim could carry |
| @@ -220,5 +291,27 @@ func ReconcileAliases(ctx context.Context, s release.Store, reg *registry.Regist |
| } |
| } |
| } |
| + |
| + // route_paths is wholly pass-owned, like route_aliases and |
| + // wildcard_domains: every row traces to a claim's Paths entry, so a |
| + // prefix removed from a republished map disappears here with no delete |
| + // verb anywhere. That is exactly how a per-account rollback reaches the |
| + // edge. |
| + existingPaths, err := reg.ListPaths() |
| + if err != nil { |
| + return err |
| + } |
| + for _, p := range desiredPaths { |
| + if err := reg.UpsertPath(p); err != nil { |
| + log.Printf("domains: upsert path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + for _, p := range existingPaths { |
| + if _, ok := desiredPaths[p.Host+"\x00"+p.Prefix]; !ok { |
| + if err := reg.DeletePath(p.Host, p.Prefix); err != nil { |
| + log.Printf("domains: remove path %s/%s: %v", p.Host, p.Prefix, err) |
| + } |
| + } |
| + } |
| return nil |
| } |
diff --git a/internal/domains/aliases_paths_test.go b/internal/domains/aliases_paths_test.go| new file mode 100644 |
| index 000000000..802cee431 |
| --- /dev/null |
| +++ b/internal/domains/aliases_paths_test.go |
| @@ -0,0 +1,175 @@ |
| +package domains |
| + |
| +import ( |
| + "context" |
| + "strconv" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| + "github.com/carlosframework/platform/internal/release" |
| +) |
| + |
| +// pathsByPrefix collapses ListPaths into a prefix -> target map for one host. |
| +func pathsByPrefix(t *testing.T, reg *registry.Registry, host string) map[string]string { |
| + t.Helper() |
| + rows, err := reg.ListPaths() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out := map[string]string{} |
| + for _, p := range rows { |
| + if p.Host == host { |
| + out[p.Prefix] = p.Target |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestReconcileDerivesPathRows is the feature: a claim's Paths map becomes |
| +// route_paths rows. |
| +func TestReconcileDerivesPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca -> go.tito.io", got) |
| + } |
| +} |
| + |
| +// TestReconcileRemovesUndesiredPathRows is the rollback path and the |
| +// pass-ownership property: a prefix dropped from the published map disappears |
| +// from the table on the next pass, with no delete verb anywhere. |
| +func TestReconcileRemovesUndesiredPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io", "bigcorp": "go.tito.io"}, |
| + }) |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); 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 := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + got := pathsByPrefix(t, reg, "ti.to") |
| + if _, still := got["bigcorp"]; still { |
| + t.Fatalf("route_paths = %v, want bigcorp gone after it left the published map", got) |
| + } |
| + if got["paulca"] != "go.tito.io" { |
| + t.Fatalf("route_paths = %v, want paulca still routed", got) |
| + } |
| +} |
| + |
| +// TestReconcileRefusesCrossTenantTarget is the one genuinely new authorization |
| +// rule in this feature: a prefix must never point another tenant's traffic at |
| +// your backend. |
| +func TestReconcileRefusesCrossTenantTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "victim.example", "someone-else", "their-app") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "victim.example"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row — the target belongs to another account", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsUnknownTarget: a target that is not a local route yields |
| +// no row, so the prefix falls through to the host's own route. That is the |
| +// correct failure direction for a migration — a missing Go route serves |
| +// classic rather than 404ing. |
| +func TestReconcileSkipsUnknownTarget(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "not-deployed-here.example"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want no row for a target that is not a local route", got) |
| + } |
| +} |
| + |
| +// TestReconcileSkipsWhenClaimHostIsNotALocalRoute: the prefix map belongs to |
| +// the hostname's own route. If that route lives on another box, this box |
| +// derives nothing — the same per-box rule the alias half already follows. |
| +func TestReconcileSkipsWhenClaimHostIsNotALocalRoute(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: map[string]string{"paulca": "go.tito.io"}, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != 0 { |
| + t.Fatalf("route_paths = %v, want nothing — ti.to is not a route on this box", got) |
| + } |
| +} |
| + |
| +// TestReconcileCapsPathRows: a hand-written claim above the cap is truncated |
| +// deterministically (sorted key order), never silently and never at random. |
| +func TestReconcileCapsPathRows(t *testing.T) { |
| + ctx := context.Background() |
| + s := release.NewDirStore(t.TempDir()) |
| + reg := aliasRegWith(t) |
| + addRoute(t, reg, "ti.to", "tito", "classic") |
| + addRoute(t, reg, "go.tito.io", "tito", "classic") |
| + big := make(map[string]string, MaxPathsPerClaim+10) |
| + for i := 0; i < MaxPathsPerClaim+10; i++ { |
| + big["acct"+strconv.Itoa(i)] = "go.tito.io" |
| + } |
| + putClaim(t, s, Claim{ |
| + Host: "ti.to", Account: "tito", App: "classic", Route: "ti.to", |
| + CreatedAt: "2026-09-03T00:00:00Z", |
| + Paths: big, |
| + }) |
| + |
| + if err := ReconcileAliases(ctx, s, reg, aliasNow(), nil, nil); err != nil { |
| + t.Fatal(err) |
| + } |
| + if got := pathsByPrefix(t, reg, "ti.to"); len(got) != MaxPathsPerClaim { |
| + t.Fatalf("derived %d rows, want the cap of %d", len(got), MaxPathsPerClaim) |
| + } |
| +} |