carlos / platform Public

Merge pull request #389 from carlosframework/purge-path-routed-hosts

Let a path-routed app purge its own cache
Paul Campbell pushed by paul@keymail.dev eb72128243c1757d4e54f4140c6b4a73c191427d parent 505addbparent 236f2f8
4 files changed, +259 −34
  • docs/superpowers/specs/2026-09-02-path-based-routing-design.md +29 −16
  • internal/edge/edge.go +19 −16
  • internal/edge/purge_header.go +55 −2
  • internal/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")
+ }
+}