Merge pull request #389 from carlosframework/purge-path-routed-hosts
Let a path-routed app purge its own cache
4 files changed,
+259
−34
docs/superpowers/specs/2026-09-02-path-based-routing-design.md+29 −16internal/edge/edge.go+19 −16internal/edge/purge_header.go+55 −2internal/edge/purge_path_test.go+156 −0
diff --git a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md| index ea44193e0..c20c1375c 100644 |
| --- a/docs/superpowers/specs/2026-09-02-path-based-routing-design.md |
| +++ b/docs/superpowers/specs/2026-09-02-path-based-routing-design.md |
| @@ -579,19 +579,32 @@ any of it. |
| target route are homed on the same box, and decide whether making it work |
| across boxes is wanted (a design change, not a fix). |
| -**Known gap: a path-routed app cannot purge its own cache.** The response |
| -cache keys on the REQUESTED hostname (`ti.to`), while `purgeModifier` is built |
| -from the resolved route — the TARGET's (`go.tito.io`). So a `Carlos-Purge` |
| -emitted by the Go app purges under `go.tito.io` and drops nothing; the entry |
| -lives under `ti.to`. The documented `Carlos-Purge-Host: ti.to` escape hatch |
| -does not help either: `resolvePurgeHost` requires the override to name a host |
| -in the same app, and path routing exists precisely to cross an app boundary. |
| -Aliases never hit this, because an alias maps to the primary's own route and |
| -the app comparison passes trivially. |
| - |
| -The bound: a path-routed response can go stale for at most the cache's max |
| -TTL, and only for responses that opted into caching with a positive |
| -`max-age`. Uncached responses are unaffected. Deliberately NOT fixed here — |
| -changing the purge tenancy model is unreviewed territory this feature did not |
| -design. Documented on `lookupUpstreamPath` and recorded here so a consumer |
| -knows before it relies on purge. |
| +**Resolved: a path-routed app purging its own cache.** Shipped as a known gap |
| +and closed straight after, once it became clear the platform might carry more |
| +than a two-app migration. |
| + |
| +The problem was real. The response cache keys on the REQUESTED hostname |
| +(`ti.to`), while `purgeModifier` is built from the resolved route — the |
| +TARGET's (`go.tito.io`). So a bare `Carlos-Purge` from the Go app purged under |
| +`go.tito.io` and dropped nothing, because the entry lives under `ti.to`. The |
| +documented `Carlos-Purge-Host: ti.to` escape hatch did not help either: |
| +`resolvePurgeHost` required the override to name a host in the same app, and |
| +path routing exists precisely to cross an app boundary. Aliases never hit |
| +this, because an alias maps to the primary's own route and the app comparison |
| +passes trivially. |
| + |
| +`resolvePurgeHost` now accepts a cross-app override when a prefix on the named |
| +host selects the serving route. That grants nothing new: the edge is already |
| +routing that host's traffic here, so this route serves pages under that |
| +hostname. **Account is still required**, and is checked at the edge rather than |
| +inherited from the reconcile pass — a path row written by an older binary or by |
| +hand must not reach another tenant's cache. The exception is scoped to the |
| +route the prefix map actually names, so "the front host path-routes somewhere" |
| +is not enough. |
| + |
| +**What an app must do:** emit `Carlos-Purge-Host` set to the `Host` header it |
| +received, unconditionally. The inbound Host is preserved end to end, so on an |
| +ordinary request that equals the serving route's own host and takes the |
| +existing own-host branch; on a path-routed one it names the front hostname and |
| +takes the new branch. One line of shared code, and nothing in the app needs to |
| +know whether it is path-routed. |
diff --git a/internal/edge/edge.go b/internal/edge/edge.go| index 5d3ee5b39..4d86d5886 100644 |
| --- a/internal/edge/edge.go |
| +++ b/internal/edge/edge.go |
| @@ -952,28 +952,31 @@ func (e *Edge) lookupUpstream(host string) (upstream, bool) { |
| // 2. The X-Carlos-Version response header reports the TARGET's release. |
| // Correct: it names the code that produced the response. |
| // 3. Purge tenancy is the TARGET's app and account (purgeModifier is built |
| -// from up.route). Correct as tenancy — but it is also a real GAP, see |
| -// below. |
| +// from up.route), while the response CACHE keys on the REQUESTED host. |
| +// Those two disagree, and closing that is what the path-routing branch |
| +// in resolvePurgeHost exists for — see below. |
| // 4. Activator keying and hibernation are the TARGET's (up.hibernating and |
| // up.route come from the same entry), so a request for a flipped prefix |
| // wakes the target instance, not the requested hostname's. Correct: the |
| // target is the process that has to be awake to answer. |
| // |
| -// The gap in (3): the response CACHE keys on the REQUESTED host |
| -// (CacheKey{Host: host} in ServeHTTP), while purgeModifier resolves the purge |
| -// host from the target's route. So a path-routed app emitting Carlos-Purge |
| -// purges under go.tito.io while its entry is stored under ti.to, and nothing |
| -// is dropped; the documented Carlos-Purge-Host: ti.to escape hatch does not |
| -// help either, because resolvePurgeHost requires the override to name a host |
| -// in the SAME app — and path routing exists precisely to cross an app |
| -// boundary. Aliases never hit this: an alias key maps to the primary's own |
| -// route, so the app comparison passes trivially. |
| +// How (3) is closed, and what an app must do. The cache keys on the requested |
| +// host (CacheKey{Host: host} in ServeHTTP) while purgeModifier is built from |
| +// the target's route, so a path-routed app emitting a bare Carlos-Purge still |
| +// purges under go.tito.io while its entry is stored under ti.to, and drops |
| +// nothing. The fix is the documented escape hatch: name the hostname the |
| +// request arrived on, Carlos-Purge-Host: ti.to. resolvePurgeHost used to |
| +// refuse that — it required the override to name a host in the SAME app, and |
| +// path routing exists precisely to cross an app boundary — and now allows it |
| +// when a prefix on the named host selects this route. Account is still |
| +// required. Aliases never hit any of this: an alias key maps to the primary's |
| +// own route, so the app comparison passes trivially. |
| // |
| -// The bound on that gap: a path-routed response can go stale for at most the |
| -// cache's max TTL, and only for responses that opted into caching with a |
| -// positive max-age. Uncached responses are unaffected. Widening the purge |
| -// tenancy model to follow a prefix map is a design change this feature did |
| -// not make; it is recorded as a known gap in design spec §11. |
| +// So an app can emit Carlos-Purge-Host set to the Host header it received, |
| +// unconditionally, from shared code. The inbound Host is preserved end to end, |
| +// so on an ordinary request that equals rt.Host and takes resolvePurgeHost's |
| +// own-host branch; on a path-routed one it names the front hostname and takes |
| +// the path-routing branch. Nothing in the app has to know which it is. |
| func (e *Edge) lookupUpstreamPath(host, urlPath string) (upstream, bool) { |
| m := e.current() |
| up, ok := lookupIn(m, host) |
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go| index bdb75c250..1d8830132 100644 |
| --- a/internal/edge/purge_header.go |
| +++ b/internal/edge/purge_header.go |
| @@ -389,16 +389,69 @@ func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, b |
| // everywhere else on this box. A raw comparison never lets a purge cross a |
| // real account boundary, but it does split one account in two and refuse a |
| // purge between two routes that demonstrably share one key space. |
| + // The path-routing exception, and the ONLY place an override crosses an |
| + // app boundary. |
| + // |
| + // When host splits its traffic by path (route_paths) and one of its |
| + // prefixes selects THIS route, the edge is already sending some of host's |
| + // requests here — and caching the responses under host, because the cache |
| + // key is the hostname the browser asked for while this proxy was built |
| + // from the target's route. Without this branch the serving app cannot |
| + // purge pages it is itself producing: its own Carlos-Purge lands under |
| + // rt.Host and hits nothing, and naming host explicitly is refused by the |
| + // App check below, because path routing exists precisely to cross an app |
| + // boundary (Tito classic and Tito Go are two apps). |
| + // |
| + // Nothing is granted that is not already true: the edge demonstrably |
| + // routes host's traffic to this route, so this route serves pages under |
| + // that hostname. |
| + // |
| + // ACCOUNT is still required, and is checked HERE rather than inherited |
| + // from the reconcile pass that writes path rows. That pass does enforce |
| + // same-account on a target, but a row written by an older binary, or by |
| + // hand, must not be able to reach another tenant's cache — the same |
| + // defence-in-depth reasoning the staleness re-read above applies to the |
| + // serving side. |
| + // |
| + // Scoped to the route the prefix map actually names: "host path-routes |
| + // somewhere" is not enough, or any app in the account could purge host by |
| + // naming it. |
| + if rt.App != "" && pathRoutesTo(up, rt.Host) && |
| + release.RouteAccount(up.route) == release.RouteAccount(rt) { |
| + return host, true |
| + } |
| + // App AND Account, both, and App must be a real one: a route with no App |
| + // has no releases and no provable tenancy, so ""=="" is not a match — two |
| + // unrelated App-less routes on one box must not be able to purge each |
| + // other. |
| if rt.App == "" || up.route.App != rt.App || |
| release.RouteAccount(up.route) != release.RouteAccount(rt) { |
| e.logPurgeIssue("override-tenancy", rt.Host, fmt.Sprintf( |
| - "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge never crosses an app or account boundary; the whole declaration is ignored", |
| - rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account)) |
| + "%s (app %q, account %q) declared Carlos-Purge-Host %q, which belongs to app %q, account %q — a purge crosses an app boundary only when %q path-routes to the serving route, and never crosses an account boundary; the whole declaration is ignored", |
| + rt.Host, rt.App, rt.Account, truncateForLog(host), up.route.App, up.route.Account, truncateForLog(host))) |
| return "", false |
| } |
| return host, true |
| } |
| +// pathRoutesTo reports whether any prefix on up selects the route at target. |
| +// |
| +// A linear scan of the prefix map, deliberately, rather than a reverse index |
| +// folded at Reload. It runs only for a response that declares a cross-host |
| +// Carlos-Purge-Host — rare — and the map is capped at |
| +// domains.MaxPathsPerClaim, so the worst case is a few thousand string |
| +// compares on a path that already did a cache write. An index would cost a |
| +// field on every upstream and fold work on every reload to save that; revisit |
| +// if purge-declaring responses ever become the common case. |
| +func pathRoutesTo(up upstream, target string) bool { |
| + for _, t := range up.paths { |
| + if t == target { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
| + |
| // validPurgeOverrideHost reports whether h is shaped like a host key in the |
| // edge's routing table: a bare hostname, nothing else. |
| // |
diff --git a/internal/edge/purge_path_test.go b/internal/edge/purge_path_test.go| new file mode 100644 |
| index 000000000..29d4d6b4d |
| --- /dev/null |
| +++ b/internal/edge/purge_path_test.go |
| @@ -0,0 +1,156 @@ |
| +package edge |
| + |
| +import ( |
| + "net/http" |
| + "net/http/httptest" |
| + "testing" |
| + |
| + "github.com/carlosframework/platform/internal/registry" |
| +) |
| + |
| +// purgePathEdge stands up the shape path routing exists to create: a front |
| +// hostname whose prefix routes to a second route belonging to a DIFFERENT app |
| +// in the same account, plus a cache. The path row is written before New so |
| +// Reload folds it into the upstream map. |
| +func purgePathEdge(t *testing.T, front, target registry.Route, prefix string, h http.HandlerFunc) *Edge { |
| + t.Helper() |
| + backend := httptest.NewServer(h) |
| + t.Cleanup(backend.Close) |
| + reg := openReg(t) |
| + for _, rt := range []registry.Route{front, target} { |
| + rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready" |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + if err := reg.UpsertPath(registry.RoutePath{ |
| + Host: front.Host, Prefix: prefix, Target: target.Host, |
| + Account: front.Account, App: front.App, |
| + }); err != nil { |
| + t.Fatalf("upsert path %s/%s: %v", front.Host, prefix, err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatalf("New: %v", err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + return e |
| +} |
| + |
| +// TestPurgeHostOverrideAllowedFromAPathRoutedHost is the hole this closes. |
| +// |
| +// A path-routed request is cached under the hostname the browser asked for |
| +// (ti.to), but it is served by the target's route (go.tito.example), and |
| +// purgeModifier is built from that route. So the app's own Carlos-Purge lands |
| +// under go.tito.example and hits nothing, and the documented escape hatch — |
| +// naming ti.to explicitly — used to be refused too, because the override |
| +// required the same app and path routing crosses apps by design. |
| +// |
| +// The request really is path-routed here rather than simulated: it arrives on |
| +// ti.to/paulca and reaches the target through the prefix map, so this pins the |
| +// composition and not just resolvePurgeHost in isolation. |
| +func TestPurgeHostOverrideAllowedFromAPathRoutedHost(t *testing.T) { |
| + e := purgePathEdge(t, |
| + route("ti.to", "classic", "tenanta"), |
| + route("go.tito.example", "gonew", "tenanta"), |
| + "paulca", |
| + func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "ti.to") |
| + w.Header().Set("Carlos-Purge", "/paulca/events/42") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale") |
| + seed(t, e.cache, "ti.to", "/paulca/events/43", "", "untouched") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil)) |
| + |
| + if cached(e.cache, "ti.to", "/paulca/events/42", "") { |
| + t.Fatal("a path-routed app could not purge its own stale page under the hostname it is served on") |
| + } |
| + if !cached(e.cache, "ti.to", "/paulca/events/43", "") { |
| + t.Fatal("/paulca/events/43 was purged but nothing named it") |
| + } |
| + if got := rec.Header().Get("Carlos-Purge-Host"); got != "" { |
| + t.Fatalf("Carlos-Purge-Host reached the client: %q", got) |
| + } |
| +} |
| + |
| +// TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount — the new branch |
| +// drops the same-APP requirement and nothing else. Account remains the |
| +// boundary, and the edge checks it here rather than trusting that the |
| +// reconcile pass already refused a cross-account path row: a row written by an |
| +// older binary, or by hand, must not be able to reach another tenant's cache. |
| +// |
| +// Both accounts must be valid sqids (^[a-z]{3,16}$) or release.RouteAccount |
| +// folds them both onto the built-in ops account and the test asserts a |
| +// boundary it never crossed. |
| +func TestPurgeHostOverrideFromPathRoutedHostStillNeedsTheAccount(t *testing.T) { |
| + e := purgePathEdge(t, |
| + route("ti.to", "classic", "tenanta"), |
| + route("go.tito.example", "gonew", "tenantb"), |
| + "paulca", |
| + func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "ti.to") |
| + w.Header().Set("Carlos-Purge", "/paulca/events/42") |
| + w.WriteHeader(http.StatusOK) |
| + }) |
| + seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale") |
| + |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://ti.to/paulca/admin", nil)) |
| + |
| + if !cached(e.cache, "ti.to", "/paulca/events/42", "") { |
| + t.Fatal("a path row pointing across ACCOUNTS let the target purge the front host's cache") |
| + } |
| +} |
| + |
| +// TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute — the exception is |
| +// scoped to the route the prefix map actually names. A second route in the |
| +// same account, serving nothing of ti.to's traffic, gets the ordinary |
| +// cross-app refusal. |
| +// |
| +// Without this, "the front host path-routes somewhere" would be enough, and |
| +// any app in the account could purge ti.to by naming it. |
| +func TestPurgeHostOverrideRefusedWhenNoPrefixPointsAtTheRoute(t *testing.T) { |
| + backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Carlos-Purge-Host", "ti.to") |
| + w.Header().Set("Carlos-Purge", "/paulca/events/42") |
| + w.WriteHeader(http.StatusOK) |
| + })) |
| + t.Cleanup(backend.Close) |
| + |
| + reg := openReg(t) |
| + for _, rt := range []registry.Route{ |
| + route("ti.to", "classic", "tenanta"), |
| + route("go.tito.example", "gonew", "tenanta"), |
| + route("bystander.example", "other", "tenanta"), |
| + } { |
| + rt.Kind, rt.Addr, rt.Status = "instance", backend.Listener.Addr().String(), "ready" |
| + if err := reg.Upsert(rt); err != nil { |
| + t.Fatalf("upsert %s: %v", rt.Host, err) |
| + } |
| + } |
| + // ti.to routes /paulca to go.tito.example — and to nothing else. |
| + if err := reg.UpsertPath(registry.RoutePath{ |
| + Host: "ti.to", Prefix: "paulca", Target: "go.tito.example", |
| + Account: "tenanta", App: "classic", |
| + }); err != nil { |
| + t.Fatal(err) |
| + } |
| + e, err := New(reg, nil) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes)) |
| + seed(t, e.cache, "ti.to", "/paulca/events/42", "", "stale") |
| + |
| + // The write arrives on the bystander's OWN host, so it is served by the |
| + // bystander's route — which no prefix of ti.to names. |
| + rec := httptest.NewRecorder() |
| + e.ServeHTTP(rec, httptest.NewRequest("POST", "http://bystander.example/write", nil)) |
| + |
| + if !cached(e.cache, "ti.to", "/paulca/events/42", "") { |
| + t.Fatal("a route no prefix points at purged ti.to — the exception is not scoped to the path target") |
| + } |
| +} |