carlos / platform Public

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>
Paul Campbell pushed by paul@keymail.dev e56564ba8162c6e3d5e2aba2182698d3362f9f5b parent cc77e06
2 files changed, +282 −14
  • internal/domains/aliases.go +107 −14
  • internal/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)
+ }
+}