carlos / platform Public

edge: Carlos-Purge-Host — same-app+account cross-host purge override (#24)

* edge: a purge may name a sibling host, if the app and account both match

Tito runs one app across two route groups: admin UIs and Stripe webhooks
ride the root domain (go.tito.io), the cached public pages live on
per-tenant routes ({slug}.go.tito.io). Carlos-Purge is confined to r.Host
by design, so every admin-originated purge landed on a host where nothing
relevant was cached and the edited page served its pre-write body for the
full TTL ceiling — breaking the "admin changes show up immediately"
promise the cache was allowed to exist under.

Carlos-Purge-Host names the host a response's targets apply to instead.
It is resolved against the edge's own CURRENT route table (e.current(),
per response — a sibling added by the reload that rebuilt this proxy must
still validate) and refused unless that route's App AND Account both
equal the serving route's, with App non-empty so two App-less routes on a
box can't purge each other. Host confinement is relaxed to app+account
and nothing wider; the invariant stays structural rather than becoming
permission bookkeeping.

Any failure — unknown host, different app, different account, malformed
value, more than one value — discards the ENTIRE declaration, with no
fallback to the serving host: a half-honoured purge is worse than a
refused one, because it clears something and so looks like it worked.

Both headers are stripped unconditionally (nil cache included) and both
are scrubbed off inbound requests in BOTH Rewrite funcs — reflection
matters here for the same reason it does for Carlos-Purge: a cross-host
purge inside one app is still a purge an outsider must not trigger.

Purely additive: a response without the new header behaves byte-
identically to today, and every existing test is unmodified.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* edge: the account halves resolve, the capture re-checks, the shape table bites

Review wave on the Carlos-Purge-Host override. Two behaviour changes, three
pins, and a fixture correction that turned out to matter more than any of them.

Account was compared as raw column bytes, so a route stamped "" and a route
stamped "bab" read as different tenants — even though release.RouteAccount
resolves both to the built-in ops account, which is what keys the app's bucket
objects everywhere else on the box. A raw comparison never let a purge cross a
real account boundary; it split one account in two and refused a purge between
two routes that provably share one key space. Both halves now go through
RouteAccount.

The serving route is a value captured when the proxy was built, and a reload
can land between that capture and the response. The edge now re-reads the
serving host from e.current() before honouring an override and refuses
(override-stale) when it has left the table or its App/Account no longer match.
Naming your own host is untouched and stays equivalent to omitting the header.

The malformed-shape table was vacuous: none of its twelve shapes is a route key
in its fixture, so the route-table lookup refused them all and the table passed
with validPurgeOverrideHost replaced by `return true`. The two shapes a route
table can genuinely contain — nothing validates a host column — now go INTO the
table as real routes with a matching app and account, so only the shape gate
can refuse them.

The fixture accounts were "acct-1"/"acct-2", neither a valid sqid, so both
resolved to ops. The cross-account test was asserting a boundary it never
crossed. They are now real sqids, and the test carries a note saying why they
have to be.

Also pinned: the Connection footgun, which is the nastiest thing here and is
undetectable edge-side. httputil deletes hop-by-hop headers before
ModifyResponse, so `Connection: Carlos-Purge-Host` deletes the redirect while
the Carlos-Purge line survives — the declaration is silently retargeted at the
serving host. That is a half-honoured purge the all-or-nothing rule exists to
prevent, and the edge cannot refuse it because the evidence is gone before this
package runs. Asserted as today's behaviour so a change screams; documented as
an app obligation in the contract and spec §6.4.

And: a non-ready sibling is not nameable (pins Reload's ready-filter as
load-bearing for the gate), and the lowercase asymmetry is stated as such —
a route key with an uppercase byte is unreachable by override, fail-closed
and accepted.

Spec §6.4 gains the Connection sentence, the RouteAccount rule, the
stale-capture rule, and a note that the 1xx/trailer carve-out can disclose a
sibling hostname (own-tenant, accepted, §6.1's rationale). §8's list records
the additions with their red proofs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 5f3b8929834f148eb51a3883dd69b41a4a959a82 parent dc80c65
4 files changed, +1306 −34
  • docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md +212 −8
  • internal/edge/edge.go +15 −8
  • internal/edge/purge_header.go +274 −18
  • internal/edge/purge_header_test.go +805 −0
diff --git a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
index 887f7c2bb..df7a07293 100644
--- a/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
+++ b/docs/superpowers/specs/2026-08-03-unit-tenant-purge-channel.md
@@ -421,9 +421,12 @@ conflict with it.
entry stored under `/café`, and `?q=caf%C3%A9` matches a request whose
query was literally `q=caf%C3%A9` — raw against raw. A path that will not
decode is refused rather than guessed at.
-- **No host field exists.** The edge purges only `r.Host` for the request
- it is serving. A target containing a scheme or an authority is
- **rejected** and a warning logged, never interpreted.
+- **A target carries no host.** The edge purges `r.Host` for the request it
+ is serving. A target containing a scheme or an authority is **rejected**
+ and a warning logged, never interpreted. The one way to name a different
+ host is the separate `Carlos-Purge-Host` field (§6.4), which is validated
+ against the route table and confined to the serving route's own app *and*
+ account; a target is never the place it can be smuggled.
- **Rejection is per-target, not per-line.** One malformed target does
**not** discard the valid targets beside it — the edge ignores the bad one
and honours the rest. Under-purging the whole declaration because one
@@ -519,6 +522,156 @@ Unchanged. No manifest field, no route field, no pointer field. Nothing
written by an older `carlos` needs to parse differently, because nothing
new is written.
+### 6.4 `Carlos-Purge-Host` — redirecting a declaration inside one app
+
+*Added 2026-08-03, after §6.1 shipped (PR #22). Purely additive: a response
+without this header behaves byte-identically to §6.1, and an app binary that
+never emits it is unaffected.*
+
+#### Why
+
+Tito's production topology splits **one app across two route groups**. Admin
+UIs and Stripe webhooks ride the root-domain route (`go.tito.io`); the cached
+public pages live on per-tenant routes (`{slug}.go.tito.io`). Every write
+therefore arrives on a host where nothing relevant is cached, so a purge
+confined to `r.Host` — which is exactly what §6.1 specifies — lands on an
+empty shelf. The edited page then serves its pre-write body for the full TTL
+ceiling, which breaks the product requirement ("admin changes show up
+immediately") the cache was allowed to exist under in the first place.
+
+The two route groups belong to the **same app and the same account** on the
+same box. That is a boundary this platform already treats as one tenant, and
+it is the only boundary relaxed here.
+
+#### Contract
+
+- **Name:** `Carlos-Purge-Host`. Response only.
+- **Value:** exactly **one** bare hostname — `acme.go.tito.io`. Not a URL,
+ not an authority: no scheme, no port, no path, no userinfo, no query or
+ fragment, no whitespace, and lowercase (the route table is matched
+ byte-exactly, so a folded spelling would be honoured under a key the table
+ never used).
+- **At most one host, in every spelling.** Several lines and a comma-joined
+ list mean the same thing (RFC 9110 §5.3), so *both* are read and *both* are
+ refused when they name more than one distinct host. Repeating the same host
+ is tolerated; naming two is a malformed declaration, never "purge both".
+ A header present with an empty value is malformed, not absent.
+- **Effect.** It redirects **all** of this response's `Carlos-Purge` targets
+ — including `*`, and including the §6.1 cap degrades — at the named host
+ instead of the serving route's host. It decides *which host*; §6.1 still
+ decides *which entries of it*. The serving host is **not** also purged: the
+ header redirects, it does not fan out.
+- **Naming your own host** is a no-op equivalent to omitting the header, so
+ an app can emit it unconditionally from shared code.
+- **Never name either purge header in `Connection`.** `Connection` marks a
+ header hop-by-hop, and `net/http/httputil` deletes hop-by-hop headers
+ *before* the edge's response hook runs, so the edge never sees them. Naming
+ `Carlos-Purge` that way silently loses the declaration and nothing is
+ purged. Naming `Carlos-Purge-Host` is **worse than losing it**: the
+ `Carlos-Purge` line survives while its redirect is deleted, so the
+ declaration is silently **retargeted at the serving host** — a half-honoured
+ purge that clears the wrong host and looks like it worked, which is exactly
+ the outcome the all-or-nothing rule below exists to prevent. The edge cannot
+ refuse it: the evidence is destroyed before this code is reached, making the
+ mistake undetectable edge-side. This is therefore an obligation on the app,
+ pinned edge-side only as an assertion of today's stdlib behaviour
+ (`TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost`), so that
+ a change in it is noticed rather than discovered in production.
+
+#### Validation — the rule, exactly
+
+Edge-side, against the **current** route table (`e.current()`, re-read at
+response time, never a snapshot captured when the proxy was built — a
+sibling route added by the very reload that rebuilt this proxy must still
+validate):
+
+> the named host must resolve to a route whose **App** *and* **Account** both
+> equal the serving route's App and Account, and that App must be non-empty.
+
+The App/Account of the **serving** side are taken from the route the edge
+resolved this request to, captured when its proxy was built — the identity
+the request was actually served under, not whatever the table says by the
+time the response comes back. The App/Account of the **named** side are read
+live from the current table.
+
+`App != ""` is part of the rule, not an implementation detail: a route with
+no App has no releases and no provable tenancy, so `"" == ""` is not an app
+match and two unrelated App-less routes on a box cannot purge each other.
+`Account == ""` *is* a legitimate value — it is the built-in `ops` account —
+so the two account halves are compared through **`release.RouteAccount`**, the
+same resolver that decides which account's bucket keys a route's app lives
+under, and not as raw column bytes. An unset account, or a pre-multi-account
+name-shaped one (`"ops"`, an email address), resolves to `ops`; a route
+stamped `""` and a route stamped `"bab"` are therefore the same tenant here
+exactly as they already are everywhere else on the box. Comparing raw would
+never let a purge cross a *real* account boundary, but it would split one
+account in two and refuse a purge between two routes that provably share one
+key space — a live possibility on any box whose registry predates accounts.
+
+The captured serving identity is itself **re-checked against the live table**
+before any override is honoured. A reload can land between the capture and the
+response — the request is in flight, its proxy already resolved, while `carlos
+add` or an adopt re-stamps the row — so the edge re-reads the serving host from
+`e.current()` and refuses when it has left the table or its App/Account no
+longer match what was captured. Honouring an override on the strength of an
+identity the box no longer serves would purge a sibling for a tenancy that has
+expired; this is the all-or-nothing rule applied to the capture itself. Naming
+your own host is unaffected, and stays exactly equivalent to omitting the
+header.
+
+**Any failure discards the entire declaration** — unknown host, different
+app, different account, malformed value, more than one value, a serving route
+that changed underneath the request. Targets and
+all, with **no fallback to the serving host**. A half-honoured purge is worse
+than a refused one: it clears something, so it looks like it worked, and the
+thing it cleared is not the thing that was stale. Every refusal is logged
+through the same rate-limited channel as §6.1's complaints (first, then every
+300th, keyed per class per serving host — the key is a route-table host, so a
+remote party cannot grow the counter map).
+
+#### Stripping and the inbound scrub
+
+- `Carlos-Purge-Host` is stripped from the response **unconditionally**, in
+ the same place and under the same rules as `Carlos-Purge` (§6.1) —
+ including when the edge has no cache at all, and including when there is no
+ `Carlos-Purge` beside it. The 1xx and trailer carve-outs in §6.1 apply
+ identically and for the same reason.
+
+ One consequence of those carve-outs is **new to this header**. A
+ `Carlos-Purge-Host` declared on a 103 Early Hints block, or in a trailer, is
+ neither honoured nor stripped, so it can reach the client — and what it
+ discloses is a **sibling hostname**, where §6.1's header could only ever
+ disclose paths. The hostname is necessarily one of the app's own: the
+ validation rule above means an app has no way to name a host outside its own
+ app and account, so the leak is bounded to the tenant's own topology, which a
+ client can already observe in links and redirects. This is **accepted, not
+ fixed**, on exactly the rationale §6.1 gives for the carve-out: the fix is a
+ ResponseWriter wrapper on a path that deliberately hands the raw writer to the
+ stdlib, and re-opening that class costs more than the disclosure does.
+- It is **scrubbed off every proxied request** in *both* `Rewrite` funcs
+ (unix and TCP), exactly like `Carlos-Purge`, and it is Critical for the
+ same reason: an app that reflects request headers would launder a
+ client-supplied one into an app-*declared* one, and a cross-host purge
+ inside an app is still a purge an outsider must not be able to trigger.
+
+#### Non-goal, permanently
+
+**There is no cross-app and no cross-account purge, ever.** Not by syntax,
+not by a privileged tenant, not by an admin override. This header widens a
+purge's reach *within* one tenant; it is not an addressing scheme, and no
+mechanism is planned that would make it one. It is also **never read from a
+request** — see the scrub above.
+
+#### Old-edge behaviour
+
+An edge predating this change does not know the header, so it neither honours
+**nor strips** it: an app that emits `Carlos-Purge-Host` while a box is still
+on the old binary will have the header forwarded to clients. The disclosure
+is one of the app's own hostnames, which the client can already see in links
+and redirects, so it is harmless — and it stops the moment the box's edge is
+upgraded. Note this is the mirror of `Carlos-Purge` itself, which an old edge
+also forwarded; the fix in both cases is upgrading the edge, not the app.
+
## 7. Migration and rollout
1. **Platform binary.** The change lives entirely in `internal/edge`.
@@ -627,6 +780,52 @@ privileges.**
9. **A no-inert-feature assertion** — `CARLOS_PURGE` is set only when
`Edge.cache != nil`, mirroring `backend_exec.go`'s existing "only set
when there's actually a purge listener to talk to" discipline.
+10. **`Carlos-Purge-Host` (§6.4)** — the honour case (a response on route A
+ naming sibling route B of the same app and account purges B's entries
+ and leaves A's alone), and one refusal case per failure mode, each
+ asserting that **neither** host was touched: different app, different
+ account, unknown host, App-less routes, two conflicting values (on two
+ lines *and* comma-joined on one), and each malformed value shape
+ (port, scheme, protocol-relative, trailing backslash, uppercase, path,
+ space, userinfo, query, fragment, empty). Plus: `*` and the cap degrade
+ both follow the override rather than the serving host; both headers are
+ stripped from the client's view including with a nil cache; both are
+ scrubbed inbound on the unix *and* TCP proxies; the refusal log is
+ rate-limited; and an override resolves against a sibling added by a
+ `Reload` that happened after the serving proxy was built. Every one of
+ these was red-proven before the implementation landed.
+
+ Added in review:
+
+ - **The malformed-shape table is proven non-vacuous.** The twelve shapes
+ above are all absent from their fixture's route table, so the
+ `e.current()` lookup refuses every one of them even with the shape gate
+ deleted — the table passes with `validPurgeOverrideHost` replaced by
+ `return true`. `…MalformedShapesRefusedEvenAsRealRouteKeys` puts the two
+ shapes a route table can genuinely contain (`B.example.com`,
+ `b.example.com:8443` — nothing validates a host column) *into* the table
+ as real routes with a matching app and account, so the lookup succeeds
+ and the tenancy check passes and only the gate can refuse them. It goes
+ red under that same mutation, and carries a guard that fails loudly if
+ the fixture ever stops being non-vacuous.
+ - **Account comparison** (`…AccountIsResolvedNotComparedRaw`) — a serving
+ route with `Account: ""` and a sibling with `Account: "bab"`, same app,
+ is **honoured**: both resolve to `ops` through `release.RouteAccount`.
+ Red against the raw comparison. Note the fixture accounts elsewhere in
+ this suite must be valid sqids (`^[a-z]{3,16}$`), or a "different
+ account" test resolves both sides to `ops` and asserts nothing.
+ - **Stale capture** (`…RefusedWhenTheServingRouteChangedSinceCapture`) —
+ the backend re-stamps the serving route into another account and reloads
+ the edge *while the request is in flight*; the override is refused and
+ neither host is purged.
+ - **Non-ready sibling** (`…NonReadyRouteIsRefused`) — a route in the
+ registry with `Status: "creating"`, same app and account, is not
+ nameable, pinning `Reload`'s ready-filter as load-bearing for the gate.
+ - **The `Connection` footgun** (`…ListedInConnectionRetargetsAtTheServingHost`)
+ — asserts *today's* behaviour rather than fixing it: with
+ `Connection: Carlos-Purge-Host` the serving host **is** purged and the
+ override host is **not**. Not red-provable (it pins existing stdlib
+ behaviour); it exists so that a change in it screams.
## 9. Deliberately not built
@@ -634,11 +833,16 @@ privileges.**
HTTP response the edge proxied cannot purge. Scheduled transitions,
background jobs, and app-CLI writes stay TTL-bounded. This is the
design's headline cost, accepted here and escalated in §10.
-- **Cross-host purge, in any form.** No syntax for it, no privileged
- tenant, no admin override. If a legitimate need appears it is a
- different feature with a different authorisation story.
-- **`Carlos-Purge` on requests.** Read only from upstream responses, and
- **deleted from every proxied request before the app sees it** (§6.1). A
+- **Cross-APP and cross-ACCOUNT purge, in any form.** No syntax for it, no
+ privileged tenant, no admin override. (Cross-*host* purge was on this
+ list until Tito's split root-domain/per-tenant topology made the
+ legitimate need concrete; it became its own feature with its own
+ authorisation story — `Carlos-Purge-Host`, §6.4 — which relaxes host
+ confinement to **app + account** and nothing wider. The tenancy boundary
+ itself stays on this list permanently.)
+- **`Carlos-Purge` (and `Carlos-Purge-Host`) on requests.** Read only from
+ upstream responses, and **deleted from every proxied request before the
+ app sees it** (§6.1, §6.4). A
client-supplied purge header is exactly the externally-reachable purge
endpoint the parent spec §7 rejects as a wake-storm vector; ignoring it at
the edge is necessary but not sufficient, because an app that echoes
diff --git a/internal/edge/edge.go b/internal/edge/edge.go
index 9a723b0a2..916d8ce09 100644
--- a/internal/edge/edge.go
+++ b/internal/edge/edge.go
@@ -214,23 +214,30 @@ func (e *Edge) Reload() error {
var proxy http.Handler
switch {
// Both reverse proxies get the Carlos-Purge response hook
- // (purge_header.go): the app declares its own host's cache
- // invalidations on the response it is already returning, and the edge
- // applies them and strips the header before any client sees it. The
- // hook closes over e rather than e.cache, because EnableCache runs
- // AFTER New's startup Reload — capturing e.cache here would capture
- // nil and the channel would be permanently inert.
+ // (purge_header.go): the app declares its own cache invalidations on
+ // the response it is already returning, and the edge applies them and
+ // strips the header before any client sees it. The hook closes over e
+ // rather than e.cache, because EnableCache runs AFTER New's startup
+ // Reload — capturing e.cache here would capture nil and the channel
+ // would be permanently inert.
+ //
+ // The whole ROUTE goes in, not just its host: Carlos-Purge-Host lets a
+ // response redirect its declaration at a sibling route, and the edge
+ // validates that against this route's App and Account. Those two are
+ // captured (the tenancy the edge resolved this request to); the sibling
+ // lookup is not — it re-reads e.current() per response, so a route
+ // added after this proxy was built still validates.
//
// Static and status routes get no hook: their responses are the
// edge's own, built from bucket bytes by static.go/status.go, with no
// upstream in a position to declare anything.
case t.Socket != "":
p := unixProxy(t.Socket)
- p.ModifyResponse = e.purgeModifier(t.Host)
+ p.ModifyResponse = e.purgeModifier(t)
proxy = p
case t.Addr != "":
p := tcpProxy(t.Addr)
- p.ModifyResponse = e.purgeModifier(t.Host)
+ p.ModifyResponse = e.purgeModifier(t)
proxy = p
case t.Kind == "static":
proxy = e.staticHandlerFor(t)
diff --git a/internal/edge/purge_header.go b/internal/edge/purge_header.go
index a25f73d6a..0112f6697 100644
--- a/internal/edge/purge_header.go
+++ b/internal/edge/purge_header.go
@@ -6,7 +6,11 @@ import (
"net/http"
"net/http/httputil"
"net/url"
+ "slices"
"strings"
+
+ "github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
// PurgeHeader is the response header an app sets to invalidate its OWN
@@ -22,11 +26,13 @@ import (
// Carlos-Purge: /events/42, /events/42?lang=fr
//
// Why a header rather than a socket, a port, or a token: the tenant-isolation
-// invariant ("a tenant may purge only its own host's entries") holds here by
+// invariant ("a tenant may purge only its OWN entries") holds here by
// CONSTRUCTION, not by permission bookkeeping. The host is the one the edge
-// already resolved this request to out of its own routing table — the header
-// carries no host field and the edge never accepts one, so there is no field
-// in which one tenant could address another. There is also nothing to reach:
+// already resolved this request to out of its own routing table, and the only
+// way to name a different one — Carlos-Purge-Host, below — is resolved against
+// that same table and refused unless it lands on the same app AND the same
+// account, so there remains no field in which one TENANT could address
+// another. There is also nothing to reach:
// no socket, no port, no path, no secret, so "which tenants can reach this
// channel" has no answer beyond "each tenant's own responses." That closes
// the reachability half of the invariant, which the exec-backed control
@@ -56,6 +62,74 @@ import (
// request before the app sees it (scrubInboundPurgeHeader).
const PurgeHeader = "Carlos-Purge"
+// PurgeHostHeader lets one response redirect its whole Carlos-Purge
+// declaration at a SIBLING route of the same app and account — spec §6.4.
+//
+// HTTP/1.1 302 Found
+// Carlos-Purge-Host: acme.go.tito.io
+// Carlos-Purge: /events/42
+//
+// # Why the host-confinement above had to be relaxed, and along which seam
+//
+// Tito's production topology splits one app across two route groups: admin
+// UIs and Stripe webhooks ride the root domain (go.tito.io) while the cached
+// public pages live on per-tenant routes ({slug}.go.tito.io). Every write
+// therefore arrives on a host where nothing relevant is cached, so a purge
+// confined to r.Host lands on an empty shelf and the edited page stays stale
+// for its full TTL — which is precisely the product promise ("admin changes
+// show up immediately") this cache was allowed to exist under.
+//
+// The seam relaxed is APP + ACCOUNT, both, and it is the boundary the rest of
+// the platform already treats as one tenant: the same app, deployed by the
+// same account, on the same box. Crucially the header still names no host the
+// EDGE does not already serve — the value is resolved against the edge's own
+// routing table and refused unless the route it names carries the same App and
+// the same Account as the route being served. So the invariant does not become
+// permission bookkeeping; it stays structural, just with "its own host" widened
+// to "its own app's hosts on this box."
+//
+// # The contract, precisely
+//
+// - AT MOST ONE host. The value is a bare hostname. Several lines, or a
+// comma-joined list, naming more than one distinct host is a MALFORMED
+// declaration, not a request to purge several.
+// - ALL-OR-NOTHING. Any failure — unknown host, different app, different
+// account, malformed value — discards the ENTIRE purge declaration for
+// this response, targets and all. There is deliberately no fallback to the
+// serving host: a half-honoured purge is worse than a refused one, because
+// it clears something and so looks like it worked.
+// - It redirects EVERY target in the same response, "*" and the cap-degrade
+// path included. The override decides WHICH host; §6.1 still decides which
+// entries of it.
+// - Resolved against the CURRENT route table at response time (e.current()),
+// never a snapshot taken when the proxy was built — a sibling added by the
+// very reload that rebuilt this proxy must still validate.
+// - Stripped from the response UNCONDITIONALLY, in the same place
+// Carlos-Purge is, including when the edge has no cache at all.
+// - Never read from a request, and scrubbed off every proxied request
+// inbound (scrubInboundPurgeHeader). Reflection matters here for exactly
+// the reason it matters for Carlos-Purge: a cross-host purge within one
+// app is still a purge an outsider must not be able to trigger.
+// - An app must NEVER name EITHER purge header in the response's Connection
+// header, and naming this one is the worse of the two. Connection marks a
+// header hop-by-hop, and net/http/httputil deletes hop-by-hop headers
+// BEFORE ModifyResponse runs, so the edge never sees it. For Carlos-Purge
+// that silently loses the declaration and nothing is purged. For
+// Carlos-Purge-Host the Carlos-Purge line SURVIVES while its redirect is
+// deleted, so the declaration is silently RETARGETED at the serving host —
+// a half-honoured purge of exactly the kind the all-or-nothing rule above
+// exists to prevent, except that the edge cannot refuse it, because the
+// evidence was destroyed before this package was called. It clears the
+// wrong host and looks like it worked. Pinned by
+// TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost.
+//
+// # Non-goal, permanently
+//
+// There is no cross-APP and no cross-ACCOUNT purge, and no mechanism is
+// planned that would add one. This header widens a purge's reach within one
+// tenant; it is not an addressing scheme.
+const PurgeHostHeader = "Carlos-Purge-Host"
+
// Bounds on one response's purge declaration. Beyond either, the edge
// degrades to a whole-host purge rather than partially applying — the
// failure direction is deliberately OVER-purge, never under-purge, because
@@ -100,8 +174,15 @@ const (
// unreachable, and it would do it through a bug in the tenant's code that the
// edge cannot audit. Deleting the header on the way in makes the laundering
// impossible regardless of how any app behaves.
+//
+// Carlos-Purge-Host is scrubbed on the same line and for the same reason. It
+// is the Critical class too: on its own it purges nothing, but reflected
+// ALONGSIDE a reflected (or app-declared) Carlos-Purge it redirects that purge
+// at a sibling host — and a cross-host purge inside one app is still a purge
+// an outsider must not be able to trigger.
func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) {
pr.Out.Header.Del(PurgeHeader)
+ pr.Out.Header.Del(PurgeHostHeader)
}
// purgeModifier builds the httputil.ReverseProxy ModifyResponse hook for one
@@ -164,25 +245,41 @@ func scrubInboundPurgeHeader(pr *httputil.ProxyRequest) {
// signals on its 302. This is deliberately decoupled from cacheability, which
// stays 2xx-only.
//
-// host is the route's own host — the key Edge.ServeHTTP resolved r.Host to in
-// the upstream map, and therefore byte-identical to the Host component of
-// every cache key this request could touch. Taking it from the routing table
-// rather than re-reading it off the proxied response means no header, on
-// either side, can influence which host gets purged.
-func (e *Edge) purgeModifier(host string) func(*http.Response) error {
+// rt is the route this proxy was built for, captured by VALUE at reload time.
+// Three of its fields matter here:
+//
+// - Host — the key Edge.ServeHTTP resolved r.Host to in the upstream map,
+// and therefore byte-identical to the Host component of every cache key
+// this request could touch. Taking it from the routing table rather than
+// re-reading it off the proxied response means no header, on either side,
+// can invent the default purge host.
+// - App and Account — the tenancy this response is allowed to purge within.
+// A captured copy is exactly right: the identity being asserted is the one
+// the edge resolved this request to, not whatever the table says by the
+// time the response comes back.
+//
+// The OVERRIDE side is deliberately the other way round: the named host is
+// looked up in e.current() at response time (see resolvePurgeHost), because
+// the route table is live and a sibling added since this proxy was built must
+// still validate. e.current() is an atomic pointer load, so reading it from
+// this hook needs no lock and cannot tear against a concurrent Reload.
+func (e *Edge) purgeModifier(rt registry.Route) func(*http.Response) error {
return func(resp *http.Response) error {
- e.applyPurgeHeader(host, resp.Header)
+ e.applyPurgeHeader(rt, resp.Header)
return nil
}
}
-// applyPurgeHeader honours and then removes h's Carlos-Purge lines.
-//
-// The strip is unconditional — before the cache nil-check, before parsing,
-// and even when the value is unusable. Carlos-Purge is an edge-internal
-// signal; a box with caching disabled must not forward it to clients any more
-// than a box with a cache does.
-func (e *Edge) applyPurgeHeader(host string, h http.Header) {
+// applyPurgeHeader honours and then removes h's Carlos-Purge and
+// Carlos-Purge-Host lines.
+//
+// Both strips are unconditional — before the cache nil-check, before parsing,
+// and even when the values are unusable. These are edge-internal signals; a
+// box with caching disabled must not forward them to clients any more than a
+// box with a cache does. Carlos-Purge-Host is stripped even when there is no
+// Carlos-Purge beside it: a response that names a sibling host and asks for
+// nothing is a no-op purge, not a header a client may see.
+func (e *Edge) applyPurgeHeader(rt registry.Route, h http.Header) {
// joinHeader, never h.Get: Carlos-Purge is legitimately list-valued and
// may arrive on several lines (RFC 9110 §5.3 — one comma-joined line and
// several lines mean the same thing, and http.Header.Add produces the
@@ -191,15 +288,174 @@ func (e *Edge) applyPurgeHeader(host string, h http.Header) {
// would silently drop every target after the first line — an under-purge,
// i.e. a stale page nobody notices until a customer does.
joined := joinHeader(h, PurgeHeader)
+ // Every line, for the opposite reason: Carlos-Purge-Host is SINGLE-valued,
+ // so reading only the first line would let a second line pass unnoticed —
+ // and "the app named two hosts" must be detected, not silently resolved to
+ // whichever came first.
+ declared := h.Values(PurgeHostHeader)
h.Del(PurgeHeader)
+ h.Del(PurgeHostHeader)
if joined == "" || e.cache == nil {
return
}
+ host, ok := e.resolvePurgeHost(rt, declared)
+ if !ok {
+ return
+ }
e.applyPurgeTargets(host, joined)
}
+// resolvePurgeHost decides which host this response's purge targets apply to:
+// the serving route's own host, or the sibling a valid Carlos-Purge-Host names.
+// ok=false means the whole declaration is refused (see PurgeHostHeader's
+// all-or-nothing note) — the caller must purge nothing at all, not fall back.
+//
+// declared is every Carlos-Purge-Host LINE, already removed from the response.
+func (e *Edge) resolvePurgeHost(rt registry.Route, declared []string) (string, bool) {
+ if len(declared) == 0 {
+ return rt.Host, true
+ }
+ // Distinct values across every line, comma-split (a single-valued field
+ // arriving comma-joined is the same declaration as two lines, RFC 9110
+ // §5.3 — so both spellings of "two hosts" have to be refused, and both
+ // spellings of "the same host twice" have to be tolerated).
+ var vals []string
+ for _, line := range declared {
+ for _, v := range strings.Split(line, ",") {
+ if v = strings.TrimSpace(v); v != "" && !slices.Contains(vals, v) {
+ vals = append(vals, v)
+ }
+ }
+ }
+ if len(vals) != 1 {
+ // Includes the header-present-but-empty case (len 0): a declaration
+ // that names no host is malformed, not absent.
+ e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
+ "%s declared %d Carlos-Purge-Host value(s) (exactly one host is allowed) — the whole purge declaration is ignored",
+ rt.Host, len(vals)))
+ return "", false
+ }
+ host := vals[0]
+ if !validPurgeOverrideHost(host) {
+ e.logPurgeIssue("override-shape", rt.Host, fmt.Sprintf(
+ "%s declared a malformed Carlos-Purge-Host %q — the value must be a bare lowercase hostname, with no scheme, port or path; the whole purge declaration is ignored",
+ rt.Host, truncateForLog(host)))
+ return "", false
+ }
+ if host == rt.Host {
+ // Naming your own host is trivially in-tenancy and needs no lookup, so
+ // an app can emit the header unconditionally from shared code even on
+ // the route whose pages it is already allowed to purge.
+ return rt.Host, true
+ }
+ // e.current(), not a captured map: the route table is live, and a sibling
+ // added after this proxy was built must still validate.
+ cur := e.current()
+ // The SERVING side is a value captured when this proxy was built, and a
+ // reload can land between that capture and this response — the request is
+ // in flight, its proxy already resolved, while `carlos add` or an adopt
+ // re-stamps the row. Re-read it: if the serving host has left the table, or
+ // its App/Account no longer match what was captured, the tenancy this
+ // declaration would be validated against is one the edge no longer serves,
+ // and honouring it would purge a sibling on the strength of an identity
+ // that has expired. Refuse instead — the all-or-nothing rule applied to the
+ // capture itself.
+ //
+ // Only genuine cross-host overrides reach here: naming your own host
+ // returned above, and stays exactly equivalent to omitting the header.
+ if live, ok := cur[rt.Host]; !ok || live.route.App != rt.App ||
+ release.RouteAccount(live.route) != release.RouteAccount(rt) {
+ e.logPurgeIssue("override-stale", rt.Host, fmt.Sprintf(
+ "%s declared Carlos-Purge-Host %q, but the serving route changed or left the route table while the request was in flight — the whole purge declaration is ignored",
+ rt.Host, truncateForLog(host)))
+ return "", false
+ }
+ up, ok := cur[host]
+ if !ok {
+ e.logPurgeIssue("override-unknown", rt.Host, fmt.Sprintf(
+ "%s declared Carlos-Purge-Host %q, which is not a route this edge serves — the whole purge declaration is ignored",
+ rt.Host, truncateForLog(host)))
+ return "", false
+ }
+ // 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.
+ //
+ // Account is compared through release.RouteAccount, not raw, because that
+ // is how the rest of the platform resolves a route's account — it is the
+ // sqid that keys the app's bucket objects. An unset account column, or a
+ // pre-multi-account name-shaped one, resolves to the built-in ops account,
+ // so a route stamped "" and a route stamped "bab" are the SAME tenant
+ // 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.
+ 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))
+ return "", false
+ }
+ return host, true
+}
+
+// validPurgeOverrideHost reports whether h is shaped like a host key in the
+// edge's routing table: a bare hostname, nothing else.
+//
+// It is a REFUSAL gate, not a normaliser, for the same reason validPurgeTarget
+// is. "b.example.com:443", "https://b.example.com" and "b.example.com/x" are
+// each a different string from the host key "b.example.com", and stripping
+// them down to it is how a purge lands somewhere its author did not name.
+// Uppercase is refused rather than folded because the map lookup below is
+// byte-exact — folding would honour a spelling the route table never used, and
+// refusing keeps the honoured set identical to the set of LOWERCASE literal
+// route keys. A route key that does carry an uppercase byte is therefore
+// unreachable by override at all: it can never be named here, only served.
+// That is fail-closed and accepted — the failure is a purge that does not
+// happen, not one that lands somewhere unnamed.
+//
+// It is a blocklist rather than a hostname allowlist deliberately: an unusual
+// but genuine route key (a punycode label, a trailing-dot-free IDN form) must
+// stay purgeable, and anything the gate lets through still has to survive the
+// route-table lookup, which is the real check. Bytes at or below SP and DEL go
+// for the same reason they do in validPurgeTarget — a real hostname has none,
+// and refusing them keeps anything log-injection-shaped out.
+//
+// One class is excluded outright and knowingly: an IP-LITERAL host. ":" is
+// refused (it is how a port arrives), which already rules every IPv6 literal
+// out, and "["/"]" are refused with it for consistency. Route keys are the
+// hosts ServeHTTP matches r.Host against — DNS names, in every deployment this
+// platform has — so the exclusion costs nothing real.
+func validPurgeOverrideHost(h string) bool {
+ if h == "" {
+ return false
+ }
+ for i := 0; i < len(h); i++ {
+ c := h[i]
+ if c <= ' ' || c == 0x7f {
+ return false
+ }
+ if c >= 'A' && c <= 'Z' {
+ return false
+ }
+ switch c {
+ case ':', '/', '\\', '@', '?', '#', '%', '[', ']':
+ return false
+ }
+ }
+ return true
+}
+
// applyPurgeTargets parses one response's joined Carlos-Purge value and
// applies it to host's entries.
+//
+// host has already been decided by resolvePurgeHost — the serving route's own
+// host, or the sibling a valid Carlos-Purge-Host named. Everything below,
+// including "*" and both cap degrades, therefore follows the override by
+// construction: degrading to the SERVING host instead would be the worst of
+// both, wiping a host nobody asked about while leaving the stale pages the app
+// was trying to invalidate.
func (e *Edge) applyPurgeTargets(host, joined string) {
if len(joined) > maxPurgeHeaderBytes {
e.logPurgeIssue("oversize", host, fmt.Sprintf(
diff --git a/internal/edge/purge_header_test.go b/internal/edge/purge_header_test.go
index 1b8879f02..07cdc7de2 100644
--- a/internal/edge/purge_header_test.go
+++ b/internal/edge/purge_header_test.go
@@ -20,6 +20,7 @@ import (
"time"
"github.com/carlosframework/platform/internal/registry"
+ "github.com/carlosframework/platform/internal/release"
)
// purgeEdge builds an edge with a cache in front of one addr-backed route for
@@ -1150,3 +1151,807 @@ func TestPurgeHeaderComplaintLogIsRateLimited(t *testing.T) {
t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String())
}
}
+
+// ---------------------------------------------------------------------------
+// Carlos-Purge-Host: redirecting one response's declaration to a SIBLING route
+// of the same app and account (spec §6.4).
+// ---------------------------------------------------------------------------
+
+// purgeOverrideEdge builds a cached edge over two ready routes sharing one
+// backend handler — the Tito topology in miniature. `a` is the route the
+// request is served on (the root domain, where admin edits and Stripe webhooks
+// land); `b` is the sibling whose public pages are the cached ones
+// ({slug}.go.tito.io). Callers set Host/App/Account on each; everything else is
+// filled in here.
+func purgeOverrideEdge(t *testing.T, a, b registry.Route, h http.HandlerFunc) (*Edge, *registry.Registry) {
+ t.Helper()
+ backend := httptest.NewServer(h)
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ for _, rt := range []registry.Route{a, b} {
+ 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)
+ }
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ return e, reg
+}
+
+// route is shorthand for the three fields these tests actually vary.
+func route(host, app, account string) registry.Route {
+ return registry.Route{Host: host, App: app, Account: account}
+}
+
+// TestPurgeHostOverridePurgesTheSiblingRoute is the headline of this feature:
+// the admin/webhook write arrives on the root-domain route while the stale
+// pages live on the per-tenant route. Same app, same account, so the
+// declaration is redirected there — and the serving host's own entries are
+// left alone, because the app named exactly one host and it was not this one.
+func TestPurgeHostOverridePurgesTheSiblingRoute(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("go.tito.example", "tito", "tenanta"),
+ route("acme.go.tito.example", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "acme.go.tito.example")
+ w.Header().Set("Carlos-Purge", "/events/42, /events/42?lang=fr")
+ w.WriteHeader(http.StatusFound)
+ })
+ seed(t, e.cache, "acme.go.tito.example", "/events/42", "", "old")
+ seed(t, e.cache, "acme.go.tito.example", "/events/42", "lang=fr", "vieux")
+ seed(t, e.cache, "acme.go.tito.example", "/events/43", "", "untouched")
+ seed(t, e.cache, "go.tito.example", "/events/42", "", "serving host")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://go.tito.example/admin/events/42", nil))
+
+ if cached(e.cache, "acme.go.tito.example", "/events/42", "") {
+ t.Fatal("the override host's /events/42 survived")
+ }
+ if cached(e.cache, "acme.go.tito.example", "/events/42", "lang=fr") {
+ t.Fatal("the override host's /events/42?lang=fr survived")
+ }
+ if !cached(e.cache, "acme.go.tito.example", "/events/43", "") {
+ t.Fatal("/events/43 was purged but nothing named it")
+ }
+ if !cached(e.cache, "go.tito.example", "/events/42", "") {
+ t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out")
+ }
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostOverrideRefusedAcrossApps — the boundary this feature must
+// never cross. A different app on the same box is a different tenant, and the
+// whole declaration is dropped rather than falling back to the serving host: a
+// half-honoured purge is worse than none, because it looks like it worked.
+func TestPurgeHostOverrideRefusedAcrossApps(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "app-x", "tenanta"),
+ route("b.example.com", "app-y", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("a route of a DIFFERENT APP was purged across the tenancy boundary")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host instead of being dropped")
+ }
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostOverrideRefusedAcrossAccounts — same app name, different
+// account. App equality alone is not the boundary: two accounts can run the
+// same app, and one tenant's admin must not reach the other's cache.
+//
+// Both fixture accounts must be VALID sqids (release.ValidAccount —
+// ^[a-z]{3,16}$). The comparison runs through release.RouteAccount, which maps
+// anything unset or name-shaped onto the built-in ops account, so two
+// placeholder spellings like "acct-1"/"acct-2" would both resolve to ops and
+// this test would assert a boundary it was not actually crossing.
+func TestPurgeHostOverrideRefusedAcrossAccounts(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("one.example.com", "tito", "tenanta"),
+ route("two.example.com", "tito", "tenantb"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "two.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "one.example.com", "/x", "", "one")
+ seed(t, e.cache, "two.example.com", "/x", "", "two")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://one.example.com/write", nil))
+
+ if !cached(e.cache, "two.example.com", "/x", "") {
+ t.Fatal("a route of a DIFFERENT ACCOUNT was purged")
+ }
+ if !cached(e.cache, "one.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host")
+ }
+}
+
+// TestPurgeHostOverrideUnknownHostIsRefused — a host the route table does not
+// know cannot be validated against anything, so the declaration is dropped.
+// Note the seeded entry: the cache is keyed by host with no reference to the
+// route table, so an unvalidated override would happily purge a host that has
+// no route at all.
+func TestPurgeHostOverrideUnknownHostIsRefused(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("known.example.com", "tito", "tenanta"),
+ route("sibling.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "ghost.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "ghost.example.com", "/x", "", "ghost")
+ seed(t, e.cache, "known.example.com", "/x", "", "known")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://known.example.com/write", nil))
+
+ if !cached(e.cache, "ghost.example.com", "/x", "") {
+ t.Fatal("an unrouted host's entries were purged")
+ }
+ if !cached(e.cache, "known.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host")
+ }
+}
+
+// TestPurgeHostOverrideConflictingValuesRefused — "at most ONE host". Two
+// names is not "purge both", it is a malformed declaration, and neither host
+// is touched. The multi-value shapes are equivalent whether they arrive on
+// separate lines or comma-joined on one (RFC 9110 §5.3).
+func TestPurgeHostOverrideConflictingValuesRefused(t *testing.T) {
+ for _, tc := range []struct {
+ name string
+ set func(http.Header)
+ }{
+ {"two lines", func(h http.Header) {
+ h.Add("Carlos-Purge-Host", "b.example.com")
+ h.Add("Carlos-Purge-Host", "c.example.com")
+ }},
+ {"comma joined", func(h http.Header) {
+ h.Set("Carlos-Purge-Host", "b.example.com, c.example.com")
+ }},
+ } {
+ t.Run(tc.name, func(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ tc.set(w.Header())
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+ seed(t, e.cache, "c.example.com", "/x", "", "C")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ for _, h := range []string{"a.example.com", "b.example.com", "c.example.com"} {
+ if !cached(e.cache, h, "/x", "") {
+ t.Fatalf("%s was purged by a multi-valued Carlos-Purge-Host", h)
+ }
+ }
+ })
+ }
+}
+
+// TestPurgeHostOverrideMalformedValuesRefused — the value is a bare hostname
+// and nothing else. Every shape below is refused outright rather than
+// normalized down to a hostname: an authority with a port is a different
+// string from a host key, and "strip it and carry on" is how a purge lands
+// somewhere its author did not name.
+func TestPurgeHostOverrideMalformedValuesRefused(t *testing.T) {
+ for _, bad := range []string{
+ "b.example.com:443",
+ "https://b.example.com",
+ "//b.example.com",
+ `b.example.com\`,
+ "B.example.com",
+ "b.example.com/",
+ "b.example.com/x",
+ "b example.com",
+ "user@b.example.com",
+ "b.example.com?x=1",
+ "b.example.com#f",
+ "",
+ } {
+ t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", bad)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatalf("Carlos-Purge-Host %q was normalized into a real host and honoured", bad)
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad)
+ }
+ })
+ }
+}
+
+// TestPurgeHostOverrideStarPurgesTheOverrideHost — "*" is a target like any
+// other, so the override redirects it too: the whole SIBLING host is purged
+// and the serving host is untouched.
+func TestPurgeHostOverrideStarPurgesTheOverrideHost(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "*")
+ w.WriteHeader(http.StatusNoContent)
+ })
+ seed(t, e.cache, "b.example.com", "/one", "", "1")
+ seed(t, e.cache, "b.example.com", "/two", "x=1", "2")
+ seed(t, e.cache, "a.example.com", "/one", "", "serving")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/deploy", nil))
+
+ if cached(e.cache, "b.example.com", "/one", "") || cached(e.cache, "b.example.com", "/two", "x=1") {
+ t.Fatal("* did not purge the whole OVERRIDE host")
+ }
+ if !cached(e.cache, "a.example.com", "/one", "") {
+ t.Fatal("* purged the serving host as well as the override host")
+ }
+}
+
+// TestPurgeHostOverrideCapDegradeHitsTheOverrideHost — the degrade path
+// (§6.1's bounds) must follow the override too. Degrading to the SERVING
+// host's PurgeHost would be the worst of both: it wipes a host nobody asked
+// about and leaves the stale pages the app was trying to invalidate.
+func TestPurgeHostOverrideCapDegradeHitsTheOverrideHost(t *testing.T) {
+ targets := make([]string, maxPurgeTargets+1)
+ for i := range targets {
+ targets[i] = fmt.Sprintf("/p/%d", i)
+ }
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", strings.Join(targets, ", "))
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "b.example.com", "/unnamed", "", "B")
+ seed(t, e.cache, "a.example.com", "/unnamed", "", "A")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if cached(e.cache, "b.example.com", "/unnamed", "") {
+ t.Fatal("the cap degrade did not purge the OVERRIDE host")
+ }
+ if !cached(e.cache, "a.example.com", "/unnamed", "") {
+ t.Fatal("the cap degrade purged the SERVING host instead of the override host")
+ }
+}
+
+// TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable — the override is
+// validated against e.current() at RESPONSE time, not against a table snapshot
+// taken when the proxy was built. A modifier that captured the map at build
+// time would hold the table as it was BEFORE its own reload, so a route added
+// by the very reload that rebuilt this proxy would be invisible to it.
+func TestPurgeHostOverrideResolvesAgainstTheCurrentRouteTable(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "late.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ addr := backend.Listener.Addr().String()
+ if err := reg.Upsert(registry.Route{Host: "early.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "late.example.com", "/x", "", "late")
+
+ // The sibling arrives after the edge was built, exactly as `carlos add`
+ // does in production; the watcher's Reload picks it up.
+ if err := reg.Upsert(registry.Route{Host: "late.example.com", App: "tito", Account: "tenanta", Kind: "instance", Addr: addr, Status: "ready"}); err != nil {
+ t.Fatalf("upsert late: %v", err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Fatalf("reload: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://early.example.com/write", nil))
+
+ if cached(e.cache, "late.example.com", "/x", "") {
+ t.Fatal("a route added by the reload that rebuilt this proxy was invisible to the override check")
+ }
+}
+
+// TestPurgeHostHeaderStrippedWithNoTargets — the strip is unconditional. A
+// response carrying only Carlos-Purge-Host declares no purge at all, and the
+// header still must not reach a client.
+func TestPurgeHostHeaderStrippedWithNoTargets(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ io.WriteString(w, "ok")
+ })
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("GET", "http://a.example.com/page", nil))
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostHeaderWithoutCacheIsStillStripped — same as its Carlos-Purge
+// sibling: with caching disabled there is nothing to redirect, but the header
+// is an edge-internal signal and must never be forwarded.
+func TestPurgeHostHeaderWithoutCacheIsStillStripped(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "other.example.com")
+ w.Header().Set("Carlos-Purge", "/a")
+ io.WriteString(w, "ok")
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "nocache2.example.com", Kind: "instance", Addr: backend.Listener.Addr().String(), Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://nocache2.example.com/write", nil))
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client with caching disabled: %q", got)
+ }
+}
+
+// TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream — reflection is still
+// the attack. A cross-host purge inside one app is a purge an outsider must
+// not be able to trigger, so the inbound scrub covers this header exactly as
+// it covers Carlos-Purge.
+func TestPurgeHostHeaderScrubbedFromRequestBeforeUpstream(t *testing.T) {
+ seen := make(chan string, 1)
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|")
+ io.WriteString(w, "ok")
+ })
+
+ req := httptest.NewRequest("POST", "http://a.example.com/write", nil)
+ req.Header.Add(PurgeHostHeader, "b.example.com")
+ req.Header.Add(PurgeHostHeader, "c.example.com")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ if got := <-seen; got != "" {
+ t.Fatalf("upstream saw Carlos-Purge-Host %q — an echoing app would launder it into a cross-host purge", got)
+ }
+}
+
+// The same scrub on the unix-socket proxy: unixProxy and tcpProxy have
+// separate Rewrite funcs, so a fix applied to one is not applied to the other.
+func TestPurgeHostHeaderScrubbedFromRequestOverUnixSocket(t *testing.T) {
+ sock := filepath.Join(t.TempDir(), "u.sock")
+ ln, err := net.Listen("unix", sock)
+ if err != nil {
+ if errors.Is(err, syscall.EPERM) || strings.Contains(err.Error(), "operation not permitted") {
+ t.Skipf("unix-socket listeners not permitted in this environment: %v", err)
+ }
+ t.Fatalf("listen unix: %v", err)
+ }
+ seen := make(chan string, 1)
+ backend := &httptest.Server{
+ Listener: ln,
+ Config: &http.Server{Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ seen <- strings.Join(r.Header.Values(PurgeHostHeader), "|")
+ io.WriteString(w, "ok")
+ })},
+ }
+ backend.Start()
+ t.Cleanup(backend.Close)
+
+ reg := openReg(t)
+ if err := reg.Upsert(registry.Route{Host: "sockscrub2.example.com", Kind: "instance", Socket: sock, Status: "ready"}); err != nil {
+ t.Fatalf("upsert: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+
+ req := httptest.NewRequest("POST", "http://sockscrub2.example.com/write", nil)
+ req.Header.Set(PurgeHostHeader, "other.example.com")
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, req)
+
+ if got := <-seen; got != "" {
+ t.Fatalf("unix-socket upstream saw Carlos-Purge-Host %q", got)
+ }
+}
+
+// TestPurgeHostOverrideRefusalIsLoggedRateLimited — refusals are driven by a
+// remote party's response on the request hot path, so they share the
+// first-then-every-300th shape every other purge complaint uses.
+func TestPurgeHostOverrideRefusalIsLoggedRateLimited(t *testing.T) {
+ var buf bytes.Buffer
+ log.SetOutput(&buf)
+ t.Cleanup(func() { log.SetOutput(os.Stderr) })
+
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "app-x", "tenanta"),
+ route("b.example.com", "app-y", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ const requests = 12
+ for i := 0; i < requests; i++ {
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+ }
+
+ got := strings.Count(buf.String(), "Carlos-Purge-Host")
+ if got != 1 {
+ t.Fatalf("%d requests produced %d complaint lines, want exactly 1 (the first); log:\n%s",
+ requests, got, buf.String())
+ }
+ if !strings.Contains(buf.String(), "occurrence #1") {
+ t.Fatalf("the first occurrence was not logged with its count; log:\n%s", buf.String())
+ }
+}
+
+// TestPurgeHostOverrideNamingTheServingHostIsANoOp — naming your own host is
+// trivially same-app/same-account and must behave exactly as no override at
+// all, so an app can emit the header unconditionally from shared code.
+func TestPurgeHostOverrideNamingTheServingHostIsANoOp(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "a.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("naming the serving host did not purge it")
+ }
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("naming the serving host purged the sibling")
+ }
+}
+
+// TestPurgeHostOverrideRefusedWithoutAnApp — a route with no App has no
+// releases and no provable tenancy, so "" == "" is not an app match. Without
+// this, two unrelated App-less routes on one box could purge each other.
+func TestPurgeHostOverrideRefusedWithoutAnApp(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "", ""),
+ route("b.example.com", "", ""),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("two App-less routes purged each other")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host")
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Carlos-Purge-Host: the review wave's additions (findings 1-4, 7).
+// ---------------------------------------------------------------------------
+
+// TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost pins the
+// worst footgun this header has, and it is deliberately an assertion of
+// TODAY'S behaviour rather than a fix.
+//
+// httputil.ReverseProxy calls removeHopByHopHeaders(res.Header) BEFORE
+// ModifyResponse. An app that names Carlos-Purge-Host in its Connection header
+// therefore has the REDIRECT deleted while the Carlos-Purge line beside it
+// SURVIVES — so the declaration is silently retargeted at the serving host.
+// That is a half-honoured purge, the exact outcome the all-or-nothing rule
+// exists to prevent, and the edge cannot refuse it: the evidence was destroyed
+// before this package was ever called, so there is nothing left to detect. It
+// is a contract obligation on the app (see PurgeHostHeader's doc comment and
+// spec §6.4), not something the edge can enforce.
+//
+// Compare TestPurgeHeaderListedInConnectionIsEatenBeforeTheHook: naming
+// Carlos-Purge that way merely LOSES the purge, which is the benign direction.
+// Naming this one clears the wrong host and looks like it worked.
+func TestPurgeHostHeaderListedInConnectionRetargetsAtTheServingHost(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route("b.example.com", "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Connection", "Carlos-Purge-Host")
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the SERVING host was NOT purged — the stdlib appears to have stopped " +
+ "deleting Connection-listed headers before ModifyResponse, which would make " +
+ "the Connection warning in PurgeHostHeader's contract and spec §6.4 wrong")
+ }
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("the OVERRIDE host was purged — the hop-by-hop strip no longer eats " +
+ "Carlos-Purge-Host, so the half-honoured-purge footgun is gone and the " +
+ "contract note about it should be revisited")
+ }
+ if got := rec.Header().Get("Carlos-Purge"); got != "" {
+ t.Fatalf("Carlos-Purge reached the client: %q", got)
+ }
+ if got := rec.Header().Get("Carlos-Purge-Host"); got != "" {
+ t.Fatalf("Carlos-Purge-Host reached the client: %q", got)
+ }
+}
+
+// TestPurgeHostOverrideAccountIsResolvedNotComparedRaw — the account halves
+// are compared through release.RouteAccount, the same function that decides
+// which account's bucket keys a route's app lives under, not as raw column
+// bytes.
+//
+// An unset account column resolves to the built-in ops account
+// (release.OpsAccount), so a route stamped "" and a route stamped "bab" are
+// the SAME tenant everywhere else on the box — same key space, same releases.
+// A raw comparison splits that one account in two and refuses a purge between
+// two routes that provably share everything. This is the pre-multi-account
+// registry state a real box can still be carrying.
+func TestPurgeHostOverrideAccountIsResolvedNotComparedRaw(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", ""),
+ route("b.example.com", "tito", release.OpsAccount),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatalf("the override was refused between account %q and account %q, which "+
+ "release.RouteAccount resolves to the same account (%q)",
+ "", release.OpsAccount, release.RouteAccount(route("b.example.com", "tito", "")))
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the SERVING host was purged too — the override redirects, it does not fan out")
+ }
+}
+
+// TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys makes the
+// shape gate's table NON-VACUOUS.
+//
+// TestPurgeHostOverrideMalformedValuesRefused above proves the 12 shapes are
+// refused, but it cannot prove WHO refuses them: none of those strings is a
+// route key in its fixture, so the e.current() lookup would reject every one
+// of them even with validPurgeOverrideHost deleted entirely. Replace the
+// gate's body with `return true` and that test stays green.
+//
+// These two shapes are the ones a route table can genuinely contain — nothing
+// validates a host column, so an uppercase key or a host:port key can be
+// upserted — so here they ARE real routes, with a matching app and account.
+// The lookup now SUCCEEDS and the tenancy check PASSES; the shape gate is the
+// only thing left that can refuse them. The same mutation turns this test red.
+func TestPurgeHostOverrideMalformedShapesRefusedEvenAsRealRouteKeys(t *testing.T) {
+ for _, bad := range []string{"B.example.com", "b.example.com:8443"} {
+ t.Run(fmt.Sprintf("%q", bad), func(t *testing.T) {
+ e, _ := purgeOverrideEdge(t,
+ route("a.example.com", "tito", "tenanta"),
+ route(bad, "tito", "tenanta"),
+ func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", bad)
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ })
+ // The guard that keeps this test honest: if the fixture ever stops
+ // putting the malformed spelling into the table as a real route, the
+ // lookup starts doing the refusing again and the assertions below go
+ // back to proving nothing.
+ if _, ok := e.current()[bad]; !ok {
+ t.Fatalf("fixture is vacuous: %q is not a route key, so the route-table "+
+ "lookup would refuse it whatever the shape gate does", bad)
+ }
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, bad, "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, bad, "/x", "") {
+ t.Fatalf("Carlos-Purge-Host %q was honoured — it resolves to a real route "+
+ "with a matching app and account, so only validPurgeOverrideHost "+
+ "stands between this declaration and a purge", bad)
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatalf("Carlos-Purge-Host %q fell back to the serving host", bad)
+ }
+ })
+ }
+}
+
+// TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture — the
+// serving route is a value CAPTURED when the proxy was built, and a reload can
+// land between that capture and this response.
+//
+// The backend below re-stamps the serving route into a different account and
+// reloads the edge WHILE the request is in flight — the proxy running
+// ModifyResponse for this response still carries the pre-swap identity. That
+// stale identity must not be enough to authorise a cross-host purge: the edge
+// re-reads the serving host from e.current() and refuses when it has changed
+// or gone. Without that check the captured (tito, tenanta) still matches the
+// sibling, and the sibling is purged on the strength of an identity the box no
+// longer serves.
+func TestPurgeHostOverrideRefusedWhenTheServingRouteChangedSinceCapture(t *testing.T) {
+ var (
+ e *Edge
+ reg *registry.Registry
+ addr string
+ )
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The route table moves under the in-flight request.
+ if err := reg.Upsert(registry.Route{
+ Host: "a.example.com", App: "tito", Account: "tenantb",
+ Kind: "instance", Addr: addr, Status: "ready",
+ }); err != nil {
+ t.Errorf("upsert during flight: %v", err)
+ }
+ if err := e.Reload(); err != nil {
+ t.Errorf("reload during flight: %v", err)
+ }
+ w.Header().Set("Carlos-Purge-Host", "b.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+ addr = backend.Listener.Addr().String()
+ reg = openReg(t)
+ for _, h := range []string{"a.example.com", "b.example.com"} {
+ if err := reg.Upsert(registry.Route{
+ Host: h, App: "tito", Account: "tenanta",
+ Kind: "instance", Addr: addr, Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert %s: %v", h, err)
+ }
+ }
+ var err error
+ if e, err = New(reg, nil); err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "b.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, "b.example.com", "/x", "") {
+ t.Fatal("the sibling was purged on the strength of a STALE captured identity — " +
+ "the serving route had already moved to another account")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host")
+ }
+}
+
+// TestPurgeHostOverrideNonReadyRouteIsRefused — the override resolves against
+// e.current(), which Reload builds from READY routes only. A route that exists
+// in the registry but is still 'creating' (or has 'failed') is therefore not
+// nameable, and this pins that the ready-filter is load-bearing for the gate
+// rather than incidental: an unready route has no working backend, so purging
+// its cache would be acting on a tenancy the edge is not currently serving.
+func TestPurgeHostOverrideNonReadyRouteIsRefused(t *testing.T) {
+ backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Carlos-Purge-Host", "creating.example.com")
+ w.Header().Set("Carlos-Purge", "/x")
+ w.WriteHeader(http.StatusOK)
+ }))
+ t.Cleanup(backend.Close)
+ reg := openReg(t)
+ addr := backend.Listener.Addr().String()
+ if err := reg.Upsert(registry.Route{
+ Host: "a.example.com", App: "tito", Account: "tenanta",
+ Kind: "instance", Addr: addr, Status: "ready",
+ }); err != nil {
+ t.Fatalf("upsert serving: %v", err)
+ }
+ // Same app, same account: the ONLY thing wrong with it is that it is not
+ // ready, so it would sail through the tenancy check if Reload had let it in.
+ if err := reg.Upsert(registry.Route{
+ Host: "creating.example.com", App: "tito", Account: "tenanta",
+ Kind: "instance", Addr: addr, Status: "creating",
+ }); err != nil {
+ t.Fatalf("upsert sibling: %v", err)
+ }
+ e, err := New(reg, nil)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ e.EnableCache(NewCache(DefaultCacheMaxEntries, DefaultCacheMaxBodyBytes))
+ if _, ok := e.current()["creating.example.com"]; ok {
+ t.Fatal("fixture: the unready route reached the served map, so this proves nothing")
+ }
+ seed(t, e.cache, "a.example.com", "/x", "", "A")
+ seed(t, e.cache, "creating.example.com", "/x", "", "B")
+
+ rec := httptest.NewRecorder()
+ e.ServeHTTP(rec, httptest.NewRequest("POST", "http://a.example.com/write", nil))
+
+ if !cached(e.cache, "creating.example.com", "/x", "") {
+ t.Fatal("a registry route that is NOT ready was purged — Reload's ready-filter " +
+ "is what confines the override to hosts this edge actually serves")
+ }
+ if !cached(e.cache, "a.example.com", "/x", "") {
+ t.Fatal("the refused declaration fell back to the serving host")
+ }
+}