Purge the rendered HTML when a site-wide resource changes (#232) - #235
Merged
Merged
Conversation
A write to a configured resource class now appends the constant surrogate key `cwa-html` to the purge the bundle already sends, so resources that shape every page without entering the front end's resource store - SiteConfigParameter above all - invalidate the rendered HTML rather than leaving it stale until its TTL lapses. The tag is a cross-repo interface contract held as HttpCachePurger::RENDERED_HTML_TAG and mirrored by the module's RENDERED_HTML_SURROGATE_KEY; a mismatch fails silently by matching nothing. It cannot collide with a resource IRI, which is always an ABS_PATH beginning with `/`. The tag is sent once per purge, not once per written resource: a single bool set during collection and appended once in propagate(), cleared by reset(). A multi-parameter save remains N requests, N flushes, N purges - the bundle cannot coalesce across requests without state the worker-mode rule forbids, and purging an already-purged key is a no-op at the edge. SiteConfigParameter::Post carried no security expression at all, leaving creation gated only by the application firewall, so any authenticated user could have flushed the whole HTML cache. It now matches its four sibling operations, via securityPostDenormalize because a plain security expression is evaluated on POST while `object` is still null, which the voter's subject check would reject. ProfilerContext gains a single shared purge-header parser, reading whichever of xkey/surrogate-key is present and splitting on `/[,\s]+/`, so the steps assert correctly under both the Varnish purger the test app runs and the Souin purger production runs.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #235 +/- ##
============================================
+ Coverage 87.70% 87.73% +0.03%
- Complexity 2653 2658 +5
============================================
Files 257 257
Lines 7653 7673 +20
============================================
+ Hits 6712 6732 +20
Misses 941 941
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
This was referenced Sep 21, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #232. Front-end counterpart: components-web-app/cwa-nuxt-module#289
The gap
The module tags each cached page with the resource IRIs the render touched, so a write to any of them already drops the affected pages. That misses resources which shape every page without ever entering the module's resource store — site config above all. Change
siteNameand every cached page kept the old one until its TTL lapsed.The fix
A write to a class listed in the new
http_cache.purge_rendered_html_classesalso purges the constant surrogate keycwa-html, which the module puts on every cacheable page. No new endpoint: the bundle already holds the purge connection, it just needed to know which resource types invalidate all rendered pages rather than only their own IRI.The node mirrors
personalised_resource_classesbeside it, includingis_a(..., true)so subclasses match.Once per purge, by construction
HttpCachePurgercarries a singleboolset incollectResource()and appended inpropagate(). A bool cannot be added twice, so N listed resources written in one flush produce exactly one tag — a structural guarantee rather than a filter.Across requests it is once per write.
propagate()is bound topostFlushandSiteConfigParameterhas no batch operation, so saving four parameters is four requests and four purges. That is harmless — the key is already gone after the first — and coalescing would need cross-request state on a shared service, which is the FrankenPHP worker-mode bug class this codebase has been bitten by before. Deliberately not done.One correction to the original design during implementation:
propagate()'s early return now tests the assembled list rather than$this->tags. With the original ordering, a listed resource whose IRI resolution threw would leavetagsempty, return early, and never reset the flag — so the next unrelated flush would spuriously drop the whole HTML cache. Covered by a regression test.POSTonSiteConfigParameternow requires the admin permissionThis is a behaviour change for existing applications.
#[Post]carried nosecurity:at all — only the application firewall gated it — so any authenticated user could create site config. With the purge tag attached to that write, any authenticated user could also flush the entire HTML cache. It now requirespublishable.permission, matchingPut/Patch/Delete.It is
securityPostDenormalize:, notsecurity:. AP4 evaluates a plainsecurityexpression in the provider stage with no object yet, andSiteConfigParameterVoter::supports()requires$subject instanceof SiteConfigParameter— so a null subject makes every voter abstain, andAffirmativeStrategydenies on all-abstain, locking out admins as well.securityPostDenormalizeruns with the denormalized object available.The existing
features/main/security.featurescenarios (anonymous POST → 401, admin POST → 201) both still pass unedited.The tag is a cross-repo contract
cwa-html, asHttpCachePurger::RENDERED_HTML_TAG. The module asserts the same literal inhttp-cache.spec.ts. A mismatch fails silently by matching nothing, so it gets the same written-down treatment asexplicitAllowOnlyandSouinPurger::SEPARATOR.It is deliberately not namespaced in the
kind:valuestyle #227 proposes for manifest keys — those prefixes are a kind qualifying a value, and this tag has no value part, socwa:htmlwould only look consistent. Collision with a resource IRI is impossible: every collected tag is a path beginning with/.Tests
tests/HttpCache/HttpCachePurgerTest.php, which did not exist beforefeatures/main/purge_rendered_html.featureProfilerContext::collectPurgedTags()is extracted as a single parser and the existing purge step rebuilt on it, so there is one implementation rather than two. It reads whichever ofxkey/surrogate-keyis present and splits on/[,\s]+/— the test harness uses the Varnish xkey purger with space glue while production uses Souin with', ', so the steps are deliberately header- and separator-agnostic.🤖 Generated with Claude Code