docs(19.3): create gap closure plan 06 (CR-01, WR-01..WR-09)

This commit is contained in:
Jakub Zych
2026-10-15 21:12:58 +02:00
parent ebdd896d82
commit 629e75f152
2 changed files with 644 additions and 1 deletions

View File

@@ -1255,7 +1255,7 @@ Plans:
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
**Plans:** 5/5 plans executed
**Plans:** 5/6 plans executed
Plans:
**Wave 1**
@@ -1269,6 +1269,9 @@ Plans:
**Wave 3** *(blocked on Wave 2 completion)*
- [x] 19.3-05-PLAN.md — Unit tests for all new code, `scripts/check-phase19.3.sh`, miniredis legitimacy check, release checkpoint (wave 3)
**Wave 4** *(gap closure)*
- [ ] 19.3-06-PLAN.md — Gap closure: `cors:<profile>` applied outermost (CR-01), review warnings WR-01..WR-09 (queued purges, no redirects, layer `allowProxied`, credentialed CORS origin rules, production cookie Secure, api-docs CSP, protected uploads vs `public_url`, Turnstile boot warning, zero `CookieConfig`), unit tests, release checkpoint v0.1.28 (wave 4)
### Phase 19.4: OIDC provider on wristband (INSERTED)
**Goal:** `wristband` is a full OpenID Connect provider that existing relying parties can switch to without noticing: discovery, JWKS with asymmetric signing and key rotation, `id_token`, userinfo, resumable authorization with account switching, a consent store and pairwise subjects. The existing issuer, signing keys and client secrets carry over.

View File

@@ -0,0 +1,640 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
plan: 06
type: execute
wave: 4
depends_on: ["19.3-05"]
gap_closure: true
files_modified:
- modules/surf/router.go
- modules/surf/cors.go
- modules/surf/apidocs.go
- modules/surf/cors_profile_test.go
- modules/surf/apidocs_test.go
- modules/surf/README.md
- docs/services/routing.md
- modules/bouncer/cookie.go
- modules/bouncer/cookie_test.go
- modules/bouncer/README.md
- docs/services/authentication.md
- modules/lagoon/attach/store.go
- modules/lagoon/attach/bucket.go
- modules/lagoon/attach/url_test.go
- modules/lagoon/README.md
- docs/services/storage.md
- modules/cooler/sitecache.go
- modules/cooler/cooler.go
- modules/cooler/sitecache_test.go
- modules/cooler/README.md
- docs/services/caching.md
- scripts/check-phase19.3.sh
- ../sm-faq-plugin/site_cache_admin_test.go
- ../sm-menu-plugin/site_cache_admin_test.go
- ../sm-journal-plugin/site_cache_admin_test.go
- ../sm-journal-plugin/classes/site_cache_test.go
- ../sm-faq-plugin/go.mod
- ../sm-faq-plugin/go.sum
- ../sm-menu-plugin/go.mod
- ../sm-menu-plugin/go.sum
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/go.sum
- ../vue-faq-layer/server/lib/siteCachePurge.ts
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
- ../vue-faq-layer/tests/sitePurge.test.ts
- ../vue-faq-layer/nuxt.config.ts
- ../vue-faq-layer/README.md
- ../vue-faq-layer/package.json
- ../vue-menu-layer/server/lib/siteCachePurge.ts
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
- ../vue-menu-layer/tests/sitePurge.test.ts
- ../vue-menu-layer/nuxt.config.ts
- ../vue-menu-layer/README.md
- ../vue-menu-layer/package.json
- ../vue-journal-layer/server/lib/siteCachePurge.ts
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
- ../vue-journal-layer/tests/sitePurge.test.ts
- ../vue-journal-layer/nuxt.config.ts
- ../vue-journal-layer/README.md
- ../vue-journal-layer/package.json
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
autonomous: false
requirements: [SC-1, SC-3, SC-4, D-03, D-04, D-05, D-10, D-11, D-12, D-17, D-18]
estimate:
tokens: 240000
raw_tokens: 240000
tasks: 7
confidence: low
must_haves:
truths:
- "SC-1 / D-05 (CR-01, the verification gap): every response of a route that names cors:<profile>, on the route or on an enclosing group, carries the profile's Access-Control-Allow-Origin (the exact allowed origin), Access-Control-Allow-Credentials and Vary: Origin whatever the entry's position in the list: a 401 from a guard listed before it or set on the group, a 429 from throttle, a 413 from a middleware reading an over-limit body, and a recovered panic 500 in JSON and raw groups; a disallowed origin gets Vary: Origin and no Allow-Origin; a handler's own Vary is merged, not replaced."
- "CR-01 gate and docs: TestCORSProfileHeadersOnRefusals and TestCORSProfileVaryKeptWithHandlerVary are required by name in SURF_TESTS of scripts/check-phase19.3.sh; docs/services/routing.md and modules/surf/README.md state that the profile applies to every response of the route whatever its position in the list or the group nesting."
- "D-04 (WR-04): with supports_credentials true, an allowed_origins entry whose wildcard is not a whole leftmost host label (https://*, *://*, https://*example.com, a * in the scheme), an allowed_origins_patterns entry without ^ and $, and any origin pattern that matches a probe origin outside the configured domains (https://*.com, ^https?://.*$) fail boot naming the key and index, globally and per profile; credentialed https://*.example.com and anchored patterns keep loading; non-credentialed configs load exactly as before and TestCORSOptionsMatchesLaravel stays green."
- "D-03 (WR-05, WR-09): in the production environment LoadCookieConfig refuses a token cookie whose Secure resolves to false whether auth.cookie.secure was set or inferred from an http or unset app.url, with an error naming auth.cookie.secure and app.url; the zero CookieConfig writes token, Path=/, HttpOnly, SameSite=Lax and Secure, and any config with SameSite unset writes SameSite=Lax."
- "D-17, D-12, D-18 (WR-06, WR-07, WR-08): the /api-docs page sends script-src with the per-request nonce as its only source and every script tag carries that nonce (SRI unchanged); attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an is_public false upload while storage.uploads.public_url is set, before any blob is written, and OpenBucket warns at boot; BuildRouter logs one warning naming the route count when routes name turnstile:open or turnstile:closed and the secret is empty."
- "D-10, D-11 (WR-02, WR-01): the purge client never follows a redirect (a 3xx is logged as a failed purge and the token never reaches the Location); PurgeSiteCacheAfterCommit queues the purge after commit on a per-purger worker, so the admin request does not wait for the site, at most one purge request is in flight per purger and saves committed meanwhile collapse into one follow-up request (a bulk delete of N rows sends at most two); cooler.SiteCachePurger.Wait drains the queue and cooler's Handle.Close waits for it at shutdown; every exported cooler purge name and signature, the Journal aliases and the FAQ, menu and Journal controllers are unchanged."
- "D-11 (WR-03): the shared purge route in vue-faq-layer, vue-menu-layer and vue-journal-layer accepts a proxied request when runtimeConfig siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED) is true, the bearer token then being the only gate, and refuses it as before when the flag is unset; the three shared files stay byte-identical with updated hash pins; docs/services/caching.md pairs an https purge_url on another host with that flag."
- "Release: scripts/check-phase19.3.sh --all passes with Docker and requires every new test by name; 19.3-REVIEW-DISPOSITION.md records CR-01 and WR-01..WR-09 as fixed; nothing is tagged or pushed in any repository before the human's reply at the Task 7 checkpoint."
artifacts:
- path: "modules/surf/router.go"
provides: "Router.wrap applying the route's CORS profile outside recovery, body limit and locale; the boot warning for turnstile routes without a secret"
contains: "profileMiddleware"
- path: "modules/surf/cors.go"
provides: "credentialed origin rules with probe origins"
contains: "cors-probe.invalid"
- path: "modules/surf/apidocs.go"
provides: "nonce on every script tag of the api-docs page"
contains: 'nonce="{{.Nonce}}"'
- path: "modules/bouncer/cookie.go"
provides: "production refusal of an inferred insecure cookie; zero-config defaults"
contains: "resolves to false"
- path: "modules/lagoon/attach/store.go"
provides: "refusal of protected uploads into a public_url bucket"
contains: "ErrProtectedNeedsPrivateBucket"
- path: "modules/cooler/sitecache.go"
provides: "redirect-free purge client, queued and coalesced after-commit purges, Wait"
contains: "ErrUseLastResponse"
- path: "../vue-faq-layer/server/lib/siteCachePurge.ts"
provides: "allowProxied opt-in in the shared authorization rules"
contains: "allowProxied"
- path: "scripts/check-phase19.3.sh"
provides: "gate requiring the gap-closure tests by name"
contains: "TestCORSProfileHeadersOnRefusals"
key_links:
- from: "modules/surf/router.go"
to: "modules/surf/cors.go"
via: "wrap skips the cors:<profile> entry in its middleware loop and wraps the recovered handler in r.corsProfiles[profile].profileMiddleware just inside recordPattern"
pattern: "corsProfiles\\[profile\\]"
- from: "scripts/check-phase19.3.sh"
to: "modules/surf/cors_profile_test.go"
via: "SURF_TESTS requires TestCORSProfileHeadersOnRefusals by name in the go stage"
pattern: "TestCORSProfileHeadersOnRefusals"
- from: "modules/cooler/sitecache.go"
to: "modules/lagoon/transaction.go"
via: "PurgeSiteCacheAfterCommit registers an AfterCommit callback that only queues the purge"
pattern: "enqueue"
- from: "modules/cooler/cooler.go"
to: "modules/cooler/sitecache.go"
via: "Handle.Close waits for the purger Install published before closing Redis"
pattern: "Wait\\("
- from: "../vue-faq-layer/server/api/__site__/cache/purge.post.ts"
to: "../vue-faq-layer/server/lib/siteCachePurge.ts"
via: "the route passes runtimeConfig siteCache.allowProxied to authorizeSitePurge"
pattern: "allowProxied"
prohibitions:
- statement: "No breaking change to the Journal, menu and FAQ plugins: cooler's exported purge names and signatures, the Journal aliases and every plugin controller stay as they are; only plugin test files change before the release bump."
status: unresolved
verification: judgment
- statement: "Existing non-credentialed * CORS answers stay byte-identical (TestCORSOptionsMatchesLaravel green)."
status: unresolved
verification: judgment
- statement: "Framework code and docs never name a consuming application."
status: unresolved
verification: judgment
- statement: "Nothing is pushed or tagged in any repository before the human's reply at the Task 7 checkpoint."
status: unresolved
verification: judgment
- statement: "Info findings IN-01..IN-11 are not addressed in this plan."
status: unresolved
verification: judgment
---
## Phase Goal
ROADMAP Goal (not a user story; standard goal-backward planning, as in plans 01 to 05): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
This plan's slice (plan 6, gap closure, wave 4): close the one verification gap (CR-01: `cors:<profile>` headers missing from refusals, 413 and 500, which hides the 401 the SPA needs for its cookie refresh) and fix the nine review warnings WR-01..WR-09 of 19.3-REVIEW.md, then release v0.1.28 on the human's reply.
## Gap being closed (19.3-VERIFICATION.md `gaps[0].missing`)
| Missing item | Task |
|--------------|------|
| `Router.wrap` skips the `cors:<profile>` entry and applies `r.corsProfiles[profile].profileMiddleware` after recovery, just inside `recordPattern` | Task 1 |
| Test: guard listed before `cors:spa` → 401 with ACAO, ACAC, `Vary: Origin`; same for a body-limit 413 and a recovered 500; in SURF_TESTS | Task 1 (plus group-level guard and 429) |
| docs/services/routing.md and modules/surf/README.md: the profile applies to every response regardless of position | Task 1 |
Review warnings: WR-04, WR-05, WR-09 in Task 2; WR-06, WR-07, WR-08 in Task 3; WR-02, WR-01 in Task 4; WR-03 in Task 5. Unit-test breadth and gate wiring in Task 6; release in Task 7. IN-01..IN-11 are out of scope (user decision).
## Decisions taken in this plan (user decisions of this gap run, then Claude's discretion)
- One plan, not split (user). Each finding lands as its own commit with its README and docs lines, carrying one focused regression test; the unit-test tables and the gate wiring for Tasks 2 to 5 come in Task 6, the plan's last code task (project rule: unit tests last). Task 1 wires its own test into the gate because the verification gap names that step.
- CR-01: besides moving the profile outermost, `bufferedResponse.commit` merges `Vary` instead of replacing it. Found while planning: once the profile sets headers on the outer writer, a handler that sets its own `Vary` would otherwise erase `Vary: Origin` on commit (the global `http.cors` wrapper has the same latent problem and benefits).
- WR-04: three rules apply only with credentials: a wildcard is a whole leftmost host label, a pattern is anchored with `^` and `$`, and no origin pattern may match the probe origins `https://cors-probe.invalid`, `http://cors-probe.invalid`, `https://cors-probe.com` and `null`. Refusing (not auto-anchoring) keeps the matching semantics of existing non-credentialed configs. No app in the sibling repos uses credentialed CORS today.
- WR-05: the review's primary fix (a boot error), not only a warning. WR-09: the zero `CookieConfig` gets Secure and SameSite=Lax; a partially filled config keeps its Secure value and gets Lax when SameSite is unset.
- WR-06: the nonce goes on all three script tags and `script-src` keeps only the nonce. `default-src`/`connect-src`/`style-src` are not added, so Swagger UI "Try it out" against other servers keeps working; human UAT item 4 (render under CSP) remains.
- WR-07: refuse (`attach.ErrProtectedNeedsPrivateBucket`) rather than only warn; `public_url` is new in v0.1.27 and no deployment sets it. cabana's upload handler answers the new error through its existing default branch (logged, opaque error); cabana is not changed.
- WR-08: a warning at boot, not a boot error (turnstile:closed already answers 503 in production without a secret).
- WR-01: in-flight coalescing on the purger (one request in flight, later saves collapse into one follow-up), no timer debounce. This supersedes plan 04's "one request per committed save, no debounce" by the user's decision in this gap run. `Purge` stays the synchronous send; `PurgeSiteCacheAfterCommit` queues. New exported `(*SiteCachePurger).Wait(ctx)` for tests, commands and shutdown. The plugin admin tests wait on it before asserting, which is why the plugins require the new framework tag at release.
- WR-03: the review's second option (the route accepts proxied requests on an explicit opt-in, `siteCache.allowProxied`). The first option, restricting `site_cache.purge_url` to loopback, would refuse at boot an https purge URL that a Journal deployment may already carry through the alias keys, which breaks a core plugin. The default stays "refuse proxied". Layer versions are additive minors: vue-faq-layer 0.3.0, vue-menu-layer 0.3.0, vue-journal-layer 0.5.0, tagged only at Task 7.
- Release: highest `v0.1.*` plus one (v0.1.28 at planning time). After the tag, the three plugins require it, as in plan 05. The app's layer pins and plugin pointers are a follow-up outside this plan.
<assumption_delta_decision>
Detector signal: `chosen` on "configurable" (SC-1 cookie attributes). Primary noun: the resolved token-cookie attributes (`bouncer.CookieConfig`). Decision: no-change. The attributes have been configurable since plan 02; this plan only tightens the derived Secure value in production (WR-05) and fills the zero-config defaults (WR-09).
</assumption_delta_decision>
## Artifacts this phase produces (this plan)
- surf: `Router.wrap` applies `cors:<profile>` outermost (inside `recordPattern`); `bufferedResponse.commit` merges `Vary`; credentialed origin rules in `validateCORSConfig` (probe origins); api-docs page with a nonce on every script and a nonce-only `script-src`; boot warning `surf: turnstile.secret is empty` with a route count.
- bouncer: `LoadCookieConfig` refuses an inferred insecure cookie in production; `CookieConfig` zero-value defaults (SameSite=Lax, Secure).
- lagoon/attach: `attach.ErrProtectedNeedsPrivateBucket`; `attach.Store` refuses protected uploads while `storage.uploads.public_url` is set; the OpenBucket boot line becomes a warning.
- cooler: purge client without redirects; queued, coalesced after-commit purges; `cooler.SiteCachePurger.Wait`; `Handle.Close` drains the purger.
- Layers: `siteCache.allowProxied` (`NUXT_SITE_CACHE_ALLOW_PROXIED`) in the shared purge lib, route and nuxt.config, byte-identical in vue-faq-layer 0.3.0, vue-menu-layer 0.3.0 and vue-journal-layer 0.5.0, with two new `sitePurge:` specs and updated hash pins.
- Plugin tests: FAQ, menu and Journal admin purge tests wait for the queued purge.
- Tests: TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary, TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch, TestAPIDocsCSPNoHostSource, TestTurnstileEmptySecretWarnsAtBoot, TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults, TestStoreProtectedNeedsPrivateBucket, TestSiteCachePurgeNoRedirect, TestSiteCachePurgeAsyncCoalesced, TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (Docker); regression rows in TestCORSCredentialsWildcardRefused, TestLoadCookieConfigErrors, TestTokenCookieAttributes, TestAPIDocsServesPinnedPage.
- Gate: every new name required in scripts/check-phase19.3.sh (go, docker and layers stages).
- Docs: routing.md (CORS profiles, credentials rules, api-docs CSP), authentication.md (cookie Secure rule, zero config, Turnstile warning), storage.md (public_url refuses protected uploads), caching.md (queued purges, redirects, remote purge URL with allowProxied); surf, bouncer, lagoon and cooler READMEs; layer READMEs.
- Planning: 19.3-REVIEW-DISPOSITION.md (CR-01, WR-01..WR-09 fixed), 19.3-VALIDATION.md rows for plan 06.
- Release (on the human's reply): tag v0.1.28, plugin go.mod bumps, layer tags v0.3.0, v0.3.0 and v0.5.0.
<objective>
Close the CR-01 verification gap so a credentialed SPA can read every answer of a `cors:<profile>` route, fix review warnings WR-01..WR-09 with docs and tests, and release v0.1.28 on the human's reply.
Purpose: SC-1/D-05 (the failed truth), the hardening behind D-03, D-04, D-10, D-11, D-12, D-17 and D-18, and SC-4 (docs and unit tests for the changed code).
Output: commits in summercms.go, the three plugins (tests only) and the three layers; the updated gate; the disposition record; the release record.
</objective>
<execution_context>
@~/.claude/msd-core/workflows/execute-plan.md
@~/.claude/msd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW.md
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md
Rules: one commit per finding where practical, README and docs in the same commit as the code they describe, planning docs in separate commits; stage by explicit path, never `git add -A`; no co-author tags. Before each task run `git status --porcelain` on the paths it touches and stop on foreign uncommitted edits (other sessions work in this checkout; `.planning/state.json` and `.msd/` are theirs, leave them alone; sm-journal-plugin's untracked go.work.sum stays untouched). Framework commits land before the plugin and layer commits that depend on them. The plugins build against this checkout through their checked-in go.work. Docker suites skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN when no daemon answers. Framework code and docs never name a consuming application (the gate's docs stage scans added lines). Nothing is pushed or tagged before the Task 7 reply. State updates go through `msd_run query state.*` handlers.
<interfaces>
Seams to change (read them; do not re-derive):
- surf (`modules/surf/router.go`): `route` (`middleware`, `raw`, `preflight`), `Router.wrap` (:404-473: the loop composing `rt.middleware` with index 0 outermost, then `bodyLimit`, `r.locale`, `recoverJSON`/`recoverBare`, then `recordPattern`), `BuildRouter` (`cors` factory :521-527, `turnstile` factory :528-540, the final `for _, rt := range r.routes { r.wrap(rt) }` loop, `addCORSPreflights`), `buildTurnstile` (:648), `recoverJSON`/`recoverBare`/`bufferedResponse.commit` (:735-805); `modules/surf/accesslog.go` `recordPattern`, `accessLogger(app)`; `modules/surf/cors.go` `validateCORSConfig`, `wildcardOriginPattern`, `newCORSPolicy`, `corsPolicy.apply`, `addVary`, `profileMiddleware`, `routeCORSProfile`, `LoadCORSProfiles`; `modules/surf/apidocs.go` asset constants, `apiDocsPage`, `apiDocsPageHandler`.
- surf tests (`modules/surf/cors_profile_test.go`, internal package): `routesPluginFunc`, `transportApp`, `transportBodyLimits`, `spaProfileYAML` (profiles `spa`, credentialed, origin https://www.example.com, and `open`), `writeHTTPConfig`, `corsRequest`, `serveCORS`, `varyHas`, `okHandler`.
- bouncer (`modules/bouncer/cookie.go`): `CookieConfig`, `LoadCookieConfig`, `cookieSecure`, `withDefaults`, `TokenCookie`, `ForgetTokenCookie`, `CookieRefreshHandler`, `CookieLogoutHandler`; `Turnstile` fields `Secret`, `Production`.
- attach: `Store` (store.go :111), error vars (store.go :25-40), `PublicURLBase`, `setPublicURLBase` (internal), `OpenBucket` (bucket.go :113-155); `TestStoreSmokeRefusals` uses `&gorm.DB{}` and memblob for refusals that happen before any write.
- cooler (`modules/cooler/sitecache.go`): `SiteCachePurger` (`url`, `endpoint`, `token`, `client`), `NewSiteCachePurgerFor`, `Purge`, `purgeTransportError`, `PurgeSiteCacheAfterCommit`, `SiteCacheFromApp`, `installSiteCache`; `cooler.go` `Handle`, `Handle.Close`, `Install`; `modules/surf/serve.go` defers `coolerHandle.Close()`.
- Plugins: the receivers in `../sm-faq-plugin/site_cache_admin_test.go`, `../sm-menu-plugin/site_cache_admin_test.go` (`take`, `expectOnePurge`, `expectNone`) and `../sm-journal-plugin/site_cache_admin_test.go` (`newPostsEnv`, `env`, `take`, `expectOnePurge`); `../sm-journal-plugin/classes/site_cache_test.go` :209-215. Heads at planning time: sm-faq-plugin fa90e83, sm-menu-plugin fe27b9a, sm-journal-plugin a99e9a1.
- Layers: `server/lib/siteCachePurge.ts` (`SitePurgeAuthInput`, `authorizeSitePurge`), `server/api/__site__/cache/purge.post.ts` (`PROXY_HEADERS`), `tests/sitePurge.test.ts` (`SHARED_LIB_SHA256`, `SHARED_ROUTE_SHA256`, 11 `sitePurge:` specs), `nuxt.config.ts` runtimeConfig `siteCache`. Heads: vue-faq-layer 96b6bc0 (v0.2.0), vue-menu-layer dd0ee35 (v0.2.0), vue-journal-layer 5936f66 (v0.4.0).
- Gate (`scripts/check-phase19.3.sh`): lists `BOUNCER_TESTS`, `SURF_TESTS`, `ATTACH_TESTS` (run untagged and with `-tags summercms_s3`), `COOLER_TESTS`, `DOCKER_COOLER_TESTS`, `LAYER_REQUIRE`, `LAYER_SHARED`; `go_named` requires a `pass` event per name and fails on skip.
</interfaces>
</context>
<tasks>
<task type="tracer">
<name>Task 1: Tracer: a cross-origin SPA reads every refusal of a cors:&lt;profile&gt; route; Router.wrap applies the profile outermost, so a 401 from a guard listed before it or set on the group, a 429, a 413 and a recovered 500 all carry Allow-Origin, Allow-Credentials and Vary: Origin (CR-01)</name>
<reversibility rating="reversible">Internal composition order in wrap; the cors:&lt;profile&gt; name and the http.cors_profiles keys are unchanged.</reversibility>
<precondition>`git status --porcelain -- modules/surf docs/services/routing.md scripts/check-phase19.3.sh` in summercms.go prints nothing.</precondition>
<files>modules/surf/router.go, modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, scripts/check-phase19.3.sh</files>
<read_first>modules/surf/router.go (wrap :404-473, BuildRouter cors factory :521-527, recoverJSON, recoverBare and bufferedResponse.commit :735-805), modules/surf/cors.go (corsPolicy.apply, addVary, profileMiddleware, routeCORSProfile, addCORSPreflights), modules/surf/accesslog.go (recordPattern), modules/surf/bodylimit.go, modules/surf/cors_profile_test.go (the helpers in the interfaces block, TestCORSProfilePreflightRoute), modules/surf/transport_smoke_test.go (TestCORSProfileSmoke), modules/surf/cors_coverage_test.go (TestCORSOptionsMatchesLaravel), docs/services/routing.md (middleware table :101, CORS profiles :252-270), modules/surf/README.md (:30-31), scripts/check-phase19.3.sh (SURF_TESTS), 19.3-REVIEW.md (CR-01), 19.3-VERIFICATION.md (gap missing list)</read_first>
<action>Per D-05 (CR-01, the verification gap). router.go wrap: before the middleware loop, resolve the route's profile with routeCORSProfile(rt) (return its error as is) and keep today's unknown-profile boot error with its message. Inside the loop, an entry whose base is cors (with a param) is validated and then skipped, so it is never composed at its list position. Group entries arrive through rt.middleware, so group nesting needs nothing extra. After the recovery wrapper (recoverJSON or recoverBare) and before recordPattern, wrap h in r.corsProfiles[profile].profileMiddleware when the route names a profile. Preflight routes keep their current path. Keep the cors factory registration in BuildRouter (the name must keep resolving and stay reserved for surf) and change its comment to say wrap applies the profile itself. bufferedResponse.commit: copy every buffered header key as today except Vary. Merge each comma-separated Vary value into the destination through addVary, so a Vary: Origin the profile set on the outer writer survives a handler that sets its own Vary. Preflights never pass through commit, so the bytes TestCORSOptionsMatchesLaravel pins must not change. Change the profileMiddleware doc comment: it runs outermost, outside the body limit, locale and recovery.
Test in cors_profile_test.go, reusing the helpers: TestCORSProfileHeadersOnRefusals with spaProfileYAML, no http.cors, and a test plugin that registers two named middlewares: deny, which answers 401 without calling next, and readall, which reads the whole body and answers 413 on *http.MaxBytesError. Subtests, each sending Origin https://www.example.com:
- route [deny, cors:spa]: 401;
- a group Use(deny) whose route names cors:spa: 401;
- [throttle:1,1, cors:spa]: the second request gets 429;
- [body.limit:16, readall, cors:spa] with a 64-byte body: 413;
- a handler that panics behind cors:spa: 500 (JSON);
- a GroupRaw with cors:spa and a panicking handler: 500.
Each asserts Access-Control-Allow-Origin equal to the origin, Access-Control-Allow-Credentials true and Vary containing Origin. One more case: [deny, cors:spa] with Origin https://evil.example.org gives 401 with no Allow-Origin and with Vary containing Origin. TestCORSProfileVaryKeptWithHandlerVary: a handler behind cors:spa sets Vary: Accept-Language, and the response's Vary names both Origin and Accept-Language. Add both names to SURF_TESTS in scripts/check-phase19.3.sh.
Docs, in the same commit:
- docs/services/routing.md "CORS profiles": replace the sentence that tells readers to list the profile beside the guard. The new text says the route (or its group) lists cors:spa among its middleware names, and surf applies the profile outside every other layer of the route, whatever its position in the list or the group nesting. Every response of the route therefore carries the profile's headers, including refusals from guards (401, 403), throttle (429) and Turnstile (422, 503), the body limit (413) and recovered panics (500); otherwise the browser would hide them from the page. Shorten the same statement into the cors:&lt;profile&gt; row of the middleware table.
- modules/surf/README.md: the Features bullet on named CORS profiles gets the same statement, including the words "whatever its position".
Commit: `fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/surf &amp;&amp; go test -count=1 -short -run '^(TestCORSProfileHeadersOnRefusals|TestCORSProfileVaryKeptWithHandlerVary|TestCORSOptionsMatchesLaravel|TestCORSProfilePreflightRoute|TestCORSProfileSmoke)$' -v ./modules/surf 2&gt;&amp;1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 5 &amp;&amp; bash scripts/check-phase19.3.sh go &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
<fails_when>Non-zero exit: vet or the surf short suite is red, fewer than five top-level "--- PASS" lines (a refusal still lacks the profile headers, a handler's Vary erased Origin, the Laravel preflight bytes changed or the profile smoke broke), the gate's go stage refuses (a SURF_TESTS name missing, skipped or red), or the docs checker reports a problem.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'TestCORSProfileHeadersOnRefusals\|TestCORSProfileVaryKeptWithHandlerVary' scripts/check-phase19.3.sh` prints 2.
- `grep -c 'profileMiddleware' modules/surf/router.go` prints at least 2 (the factory and the wrap call).
- `grep -n 'whatever its position' docs/services/routing.md modules/surf/README.md` prints at least one line per file.
- `grep -c 'next to its guard' docs/services/routing.md` prints 0.
- `git log --oneline -1 --grep='(CR-01)' -- modules/surf/router.go` prints one line (the fix commit touches router.go).
</acceptance_criteria>
<done>A browser on the SPA origin can read every answer of a profiled route, so the 401 that triggers the cookie refresh reaches the page, and the gate requires the proof by name.</done>
</task>
<task type="auto">
<name>Task 2: Credentialed sessions cannot be opened by another spelling: credentialed CORS refuses wildcard-host and unanchored origins (WR-04), production refuses an inferred insecure token cookie (WR-05), and the zero CookieConfig writes SameSite=Lax and Secure (WR-09)</name>
<reversibility rating="reversible">Boot validation and builder defaults; no key, name or signature changes.</reversibility>
<files>modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md</files>
<read_first>modules/surf/cors.go (validateCORSConfig, phpDelimited, wildcardOriginPattern, newCORSPolicy), modules/surf/cors_profile_test.go (TestCORSCredentialsWildcardRefused :36-71, TestCORSPatternErrors, TestCORSWildcardOriginEntry, TestCORSSubdomainCredentialed), modules/surf/cors_coverage_test.go (pattern literals), docs/services/routing.md (CORS credentials rules), modules/surf/README.md (http.cors rows), modules/bouncer/cookie.go (LoadCookieConfig :47-110, cookieSecure, withDefaults :262-272, TokenCookie, ForgetTokenCookie, both handlers), modules/bouncer/cookie_test.go (TestLoadCookieConfigErrors :95, TestTokenCookieAttributes :126-153), modules/bouncer/README.md (:34, :159-187), docs/services/authentication.md (Cookie transport :60-90), 19.3-REVIEW.md (WR-04, WR-05, WR-09)</read_first>
<action>Three commits, one per finding, each with its README and docs lines.
WR-04 (per D-04), surf/cors.go validateCORSConfig. These rules apply only when SupportsCredentials is true; non-credentialed configs keep loading exactly as today.
- (a) An allowed_origins entry containing * (the bare * is already refused) is accepted only when its scheme is literal and every * is a whole leftmost host label followed by a dot, as in https://*.example.com.
- (b) Every allowed_origins_patterns entry must start with ^ and end with $.
- (c) No origin pattern may match any of the probe origins https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com and null (an unexported list). This covers the compiled wildcard entries too, and it catches https://*, *://*, https://*.com and ^https?://.*$.
Each error names the key with its index (for example http.cors.allowed_origins[1] or http.cors_profiles.spa.allowed_origins_patterns[0]) and says what is accepted. Profiles get the same checks through LoadCORSProfiles. Regression: append rows to TestCORSCredentialsWildcardRefused. https://* and the unanchored https://.*\.example\.com are refused with credentials, and both still load without credentials. Docs:
- routing.md credentials rules: list the three rules, including that patterns must be anchored with ^ and $;
- surf README: the http.cors allowed_origins and allowed_origins_patterns rows mention them.
Commit: `fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)`.
WR-05 (per D-03), bouncer/cookie.go LoadCookieConfig: in the production environment, a Secure that resolves to false is an error, whether auth.cookie.secure was set explicitly or inferred from an http or unset app.url. Keep the explicit-false message. For the inferred case, return an error prefixed bouncer: that contains "auth.cookie.secure resolves to false in the production environment", names app.url, and tells the reader to set app.url to an https URL or auth.cookie.secure: true. Non-production environments keep inferring false for an http app.url. Update the LoadCookieConfig doc comment. Regression: append rows to TestLoadCookieConfigErrors for production with app.url http and production with app.url unset. Docs: the auth.cookie.secure row in authentication.md and the error sentence below that table; the bouncer README Configuration row. Commit: `fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)`.
WR-09 (per D-03), withDefaults:
- a CookieConfig equal to the zero value also gets Secure true; decide this before Name and Path are filled;
- any config whose SameSite is the zero value gets http.SameSiteLaxMode;
- a config with any field set keeps its Secure value.
Confirm that TokenCookie, ForgetTokenCookie and both handlers go through withDefaults. Update the CookieConfig field docs and the withDefaults comment: SameSite is Lax when unset; Secure has no default except for the zero config, which is Secure; LoadCookieConfig derives Secure from app.url. Regression:
- extend the zero-config assertions in TestTokenCookieAttributes to SameSite Lax and Secure;
- a partial config ({Name: sid}) gets SameSite Lax and stays not Secure.
Docs:
- bouncer README: the CookieConfig row and the Cookie transport bullet;
- authentication.md: one sentence after the key table on a hand-built config.
Commit: `fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short ./modules/surf ./modules/bouncer &amp;&amp; go test -count=1 -short -run '^(TestCORSCredentialsWildcardRefused|TestCORSWildcardOriginEntry|TestCORSSubdomainCredentialed|TestCORSOptionsMatchesLaravel|TestLoadCookieConfigErrors|TestLoadCookieConfigDefaults|TestTokenCookieAttributes)$' -v ./modules/surf ./modules/bouncer 2&gt;&amp;1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 7 &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
<fails_when>Non-zero exit, or fewer than seven top-level "--- PASS" lines: a credentialed wildcard-host or unanchored entry still loads, an accepted credentialed config (https://*.example.com, the api and www case) is refused, the Laravel preflight bytes changed, production accepts an inferred insecure cookie, the zero config lacks SameSite=Lax or Secure, or the docs checker fails.</fails_when>
</verify>
<acceptance_criteria>
- `grep -cE 'cors-probe\.(invalid|com)' modules/surf/cors.go` prints at least 2.
- `grep -ni 'anchored' docs/services/routing.md` prints at least one line.
- `grep -c 'resolves to false' modules/bouncer/cookie.go` prints at least 1.
- `git log --oneline --grep='(WR-04)$' --grep='(WR-05)$' --grep='(WR-09)$'` prints 3 lines (one commit per finding).
</acceptance_criteria>
<done>No credentialed CORS config can trust arbitrary origins, and no production token cookie is written without Secure, whatever the config spelling.</done>
</task>
<task type="auto">
<name>Task 3: Exposure and silent misconfiguration closed: the api-docs page runs only nonce-carrying scripts (WR-06), protected uploads are refused while public_url serves the bucket publicly (WR-07), and turnstile routes without a secret are named in a boot warning (WR-08)</name>
<reversibility rating="reversible">An added error value, a CSP header value and a log line; all can be relaxed later without a migration.</reversibility>
<files>modules/surf/apidocs.go, modules/surf/apidocs_test.go, modules/surf/router.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, docs/services/authentication.md, modules/lagoon/attach/store.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/url_test.go, modules/lagoon/README.md, docs/services/storage.md</files>
<read_first>modules/surf/apidocs.go (asset constants :32-45, apiDocsPage :53-80, apiDocsPageHandler :188-210), modules/surf/apidocs_test.go (TestAPIDocsServesPinnedPage), docs/services/routing.md (API documentation page :272-300), modules/surf/README.md (:39 and the turnstile.secret row :165), modules/surf/router.go (BuildRouter final wrap loop, buildTurnstile), modules/surf/accesslog.go (accessLogger), modules/bouncer/turnstile.go (Turnstile fields), modules/surf/cors_profile_test.go (TestTurnstileFactoryParams), docs/services/authentication.md (Turnstile keys :180-190), modules/lagoon/attach/store.go (error vars :25-40, Store :111-185), modules/lagoon/attach/bucket.go (OpenBucket :113-155), modules/lagoon/attach/url_test.go (TestPublicURLBase and its cleanup), modules/lagoon/attach/store_test.go (TestStoreSmokeRefusals), modules/cabana/field_file.go (:810-845, how an unknown Store error is answered), modules/lagoon/README.md (:41, :194-195, :235), docs/services/storage.md (:84-96), 19.3-REVIEW.md (WR-06, WR-07, WR-08)</read_first>
<action>Three commits, one per finding, each with its docs.
WR-06 (per D-17), apidocs.go:
- The two external script tags carry nonce="{{.Nonce}}" next to their integrity and crossorigin attributes.
- The Content-Security-Policy keeps the per-request nonce as the only script-src source: script-src 'nonce-n'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'. There is no host source.
- The CSS link and its SRI stay. Remove the CDN host constant if nothing else uses it. The version and the integrity values do not change.
Regression: TestAPIDocsServesPinnedPage asserts that all three script tags carry the header's nonce and that the script-src directive has no https: source. Docs: the API documentation page paragraph in routing.md says scripts run only with the page's per-request nonce; the two jsDelivr scripts carry it as well as their SRI hash, so no other jsDelivr script can run. Update the matching surf README Features bullet. Commit: `fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)`.
WR-07 (per D-12), attach:
- A new exported error, ErrProtectedNeedsPrivateBucket. Its message names storage.uploads.public_url and says that a protected (is_public false) upload needs a private bucket: unset public_url or make the relation public.
- Store returns it when Upload.Public is false and PublicURLBase() is not empty, before it reads the body or writes a blob. Public uploads, and any upload without public_url, behave as today.
- OpenBucket's line about a set public_url moves from Info to Warn and says protected uploads are refused.
- cabana answers an unknown Store error through its default branch (logged, opaque error). Leave cabana unchanged and record in the SUMMARY what the admin sees.
Regression in url_test.go (package attach, so it can set the base): TestStoreProtectedNeedsPrivateBucket, using memblob and &gorm.DB{} as TestStoreSmokeRefusals does. With the base set, a Public false upload returns the error and leaves the bucket empty. Reset the base in t.Cleanup and do not mark the test parallel. Docs:
- storage.md: the public_url paragraph says protected uploads are refused while public_url is set;
- lagoon README: the attach bullet, an API reference row for attach.ErrProtectedNeedsPrivateBucket, and the storage.uploads.public_url configuration row.
Commit: `fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)`.
WR-08 (per D-18), router.go BuildRouter:
- After the loop that wraps every route, count the routes whose middleware list names turnstile:open or turnstile:closed.
- When the count is positive and the verifier from buildTurnstile has an empty Secret, log one warning through accessLogger(app). The message starts "surf: turnstile.secret is empty" and says that turnstile:open passes every request, while turnstile:closed passes outside production and answers 503 in production. The attributes are routes (the count) and production (the verifier's Production flag).
- Never log the secret.
- No warning when no route names turnstile, or when the secret is set.
Regression in cors_profile_test.go: TestTurnstileEmptySecretWarnsAtBoot. Publish a *slog.Logger with a capturing handler on the app. Two turnstile routes and no secret give one warning with routes 2. A published verifier with a secret gives none, and so does an app without turnstile routes. Docs: the turnstile.secret rows in the surf README and in authentication.md mention the start-up warning. Commit: `fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)`.</action>
<verify>
<automated>go vet ./... &amp;&amp; go vet -tags summercms_s3 ./modules/lagoon/... &amp;&amp; go test -count=1 -short ./modules/surf ./modules/lagoon/... &amp;&amp; go test -count=1 -short -run '^(TestAPIDocsServesPinnedPage|TestAPIDocsSmoke|TestTurnstileEmptySecretWarnsAtBoot|TestTurnstileFactoryParams|TestStoreProtectedNeedsPrivateBucket|TestPublicURLBase)$' -v ./modules/surf ./modules/lagoon/attach 2&gt;&amp;1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 6 &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
<fails_when>Non-zero exit (vet in either tag mode, a short suite, the docs checker), or fewer than six top-level "--- PASS" lines: a script tag lacks the nonce or script-src still names a host, a protected upload into a public_url bucket is stored or leaves a blob, or the turnstile warning is missing, repeated or logged with a secret set.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'nonce="{{.Nonce}}"' modules/surf/apidocs.go` prints 3.
- `grep -n 'ErrProtectedNeedsPrivateBucket' modules/lagoon/attach/store.go modules/lagoon/README.md docs/services/storage.md` prints at least one line per file.
- `grep -c 'turnstile.secret is empty' modules/surf/router.go` prints 1.
- `git log --oneline --grep='(WR-06)$' --grep='(WR-07)$' --grep='(WR-08)$'` prints 3 lines (one commit per finding).
</acceptance_criteria>
<done>Only nonce-carrying scripts run on /api-docs, protected files can no longer land in a publicly served bucket, and an operator learns at start-up when bot protection is off.</done>
</task>
<task type="auto">
<name>Task 4: An admin save no longer waits for the site, and a bulk delete sends at most two purges: the purge client never follows redirects (WR-02), and PurgeSiteCacheAfterCommit queues onto a coalescing per-purger worker that Wait and shutdown drain (WR-01)</name>
<reversibility rating="costly">cooler.SiteCachePurger.Wait joins cooler's published API and the plugin tests call it; reshaping it later needs a coordinated plugin release.</reversibility>
<precondition>`git status --porcelain -- modules/cooler docs/services/caching.md` in summercms.go prints nothing, and `git -C ../sm-faq-plugin status --porcelain`, `git -C ../sm-menu-plugin status --porcelain` and `git -C ../sm-journal-plugin status --porcelain -uno` print nothing.</precondition>
<files>modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md, ../sm-faq-plugin/site_cache_admin_test.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-journal-plugin/site_cache_admin_test.go, ../sm-journal-plugin/classes/site_cache_test.go</files>
<read_first>modules/cooler/sitecache.go (whole), modules/cooler/cooler.go (Handle, Handle.Close :110-117, Install :163-215), modules/surf/serve.go (:40-120, coolerHandle.Close at shutdown), modules/lagoon/transaction.go (AfterCommit, runAfterCommit), modules/cooler/sitecache_test.go (purgeReceiver, newTestPurger, captureLogs, the outside-a-transaction subtest :374-381, TestSiteCacheAfterCommitOnly :445, TestSiteCacheFailureNeverFails :519), modules/cooler/README.md (:39, :175-180), docs/services/caching.md (Site cache purge :100-140), ../sm-faq-plugin/site_cache_admin_test.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-journal-plugin/site_cache_admin_test.go, ../sm-journal-plugin/classes/site_cache_test.go (:205-216), ../sm-faq-plugin/controllers/site_cache.go and ../sm-journal-plugin/classes/site_cache.go (call sites that stay unchanged), 19.3-REVIEW.md (WR-01, WR-02)</read_first>
<action>Framework first, two commits.
WR-02 (per D-10): NewSiteCachePurgerFor builds the client with a CheckRedirect that returns http.ErrUseLastResponse. A 3xx answer is never followed, so the bearer token never goes to the Location and the POST never turns into a GET. Purge logs the 3xx through the existing "cooler: site cache purge failed" warning with its status. Regression in sitecache_test.go: TestSiteCachePurgeNoRedirect. Receiver A answers 307 with a Location pointing at receiver B; B receives nothing; one failed warning carries status 307; no log line contains the token. Docs: "Timing and failures" in caching.md says redirects are not followed and are logged as failures with their status. Commit: `fix(19.3-06): site-cache purge never follows redirects (WR-02)`.
WR-01 (per D-10 and D-11). The user's decision in this gap run supersedes plan 04's "one request per committed save, no debounce".
Keep every exported name and signature: SiteCachePurger, NewSiteCachePurger, NewSiteCachePurgerFor, Purge, Endpoint, PurgeSiteCacheAfterCommit, SiteCacheFromApp and the constants. Purge stays the synchronous single send.
Queue:
- SiteCachePurger gets unexported queue state: a mutex, a sending flag, one pending kind and slug, a coalesced count, and an idle channel that is closed when a busy period ends.
- An unexported enqueue(kind, slug). When nothing is in flight, it marks sending and starts one goroutine that sends. While a send is in flight, it replaces the pending body with the latest one and counts it.
- The goroutine loops. It sends through the same code as Purge (a detached background context bounded by SiteCachePurgeTimeout). Then it takes the pending body, if any, and sends again. Otherwise it clears sending and closes idle.
- The success and failure log lines keep their messages and attributes. They add coalesced only when it is above zero, so the existing cooler and Journal log assertions keep passing.
After commit and shutdown:
- PurgeSiteCacheAfterCommit registers an after-commit callback that calls enqueue. The admin request returns as soon as the commit's callbacks have queued. A nil purger still does nothing; outside a transaction the purge queues at once.
- New exported method Wait(ctx context.Context) error. It returns nil at once for a nil or idle purger. Otherwise it blocks until the purger is idle, looping when a new busy period starts, or until ctx ends, then returning ctx.Err().
- cooler.go: installSiteCache returns the purger it published, and Install keeps it on the Handle in an unexported field. Handle.Close first waits for it with a context bounded by SiteCachePurgeTimeout plus one second and ignores the deadline error. Then it closes the Redis client as today. Close no longer returns before the drain when there is no Redis client.
- Update the doc comments:
- PurgeSiteCacheAfterCommit: queued after commit, fire and forget, at most one request in flight per purger, later saves collapse into one follow-up;
- Purge: the blocking send the queue uses;
- Wait.
Existing tests:
- The tests that count requests after PurgeSiteCacheAfterCommit call Wait before counting: the outside-a-transaction subtest, TestSiteCacheAfterCommitOnly and TestSiteCacheFailureNeverFails.
- Every test that queues a purge waits before it returns, because captureLogs restores slog.Default afterwards.
Regression: TestSiteCachePurgeAsyncCoalesced (Docker-free). A receiver blocks until released. Twenty PurgeSiteCacheAfterCommit calls with a nil db each return within 50 ms. After the release and Wait, the receiver has exactly two requests, the second carrying the last slug, and the purged log line of the second carries coalesced 18.
Docs, in the same commit:
- caching.md, rewrite "What a purge sends" and "Timing and failures":
- the purge is queued after commit and never blocks the save;
- at most one request is in flight per process;
- saves committed meanwhile collapse into one follow-up request, because each purge clears the whole route cache;
- Wait serves tests and commands;
- serve waits for a queued purge on shutdown, up to the timeout plus a second.
- cooler README: the Features bullet; the API rows for PurgeSiteCacheAfterCommit and Purge; a new cooler.SiteCachePurger.Wait row.
Commit: `fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)`.
Plugins, test-only, one commit per repository after the framework commit; plugin controllers and the Journal aliases stay untouched:
- In site_cache_admin_test.go of sm-faq-plugin, sm-menu-plugin and sm-journal-plugin, every read of the purge receiver first drains the purger used by the app under test. That covers take, expectOnePurge, expectNone and the direct take calls. Drain through cooler.SiteCacheFromApp(the harness's app).Wait with a 5-second context, so each save's purge is asserted after it was sent. The probe and Seen assertions stay.
- In sm-journal-plugin classes/site_cache_test.go, the outside-a-transaction subtest waits before reading hits, and its name says the purge is queued at once.
Each commit: `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)`, staged by explicit path.</action>
<verify>
<automated>go vet ./... &amp;&amp; go test -count=1 -short -race ./modules/cooler &amp;&amp; go test -count=1 -short -run '^(TestSiteCachePurgeNoRedirect|TestSiteCachePurgeAsyncCoalesced|TestSiteCachePurgeRequest|TestSiteCachePurgeConfig|TestSiteCacheFromApp)$' -v ./modules/cooler 2&gt;&amp;1 | grep -cE '^--- PASS: Test[A-Za-z]+ \(' | grep -qx 5 &amp;&amp; bash scripts/check-phase19.3.sh plugins &amp;&amp; bash scripts/check-phase19.3.sh docker</automated>
<fails_when>Non-zero exit: vet, a data race report, a cooler unit test, fewer than five top-level "--- PASS" lines (a redirect was followed, a call blocked or more than two requests went out), the plugins stage, or the docker stage. The docker stage fails when a framework or plugin purge test is red or skipped, for example a plugin test that read the receiver before the queued purge was sent, or when no Docker daemon answers.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'ErrUseLastResponse' modules/cooler/sitecache.go` prints 1.
- `grep -n 'func (p \*SiteCachePurger) Wait(ctx context.Context) error' modules/cooler/sitecache.go` prints one line and `grep -n 'cooler.SiteCachePurger.Wait' modules/cooler/README.md` prints one line.
- `git -C ../sm-faq-plugin diff fa90e83 -- controllers`, `git -C ../sm-menu-plugin diff fe27b9a -- controllers` and `git -C ../sm-journal-plugin diff a99e9a1 -- controllers classes/site_cache.go plugin.go` each print nothing (call sites and aliases unchanged).
- `grep -c 'Wait(' ../sm-faq-plugin/site_cache_admin_test.go ../sm-menu-plugin/site_cache_admin_test.go ../sm-journal-plugin/site_cache_admin_test.go` prints at least 1 for each file.
- `git -C ../sm-faq-plugin log --format=%ct -1` is not earlier than `git log --format=%ct -1 --grep='(WR-01)' -- modules/cooler/sitecache.go` (framework commit first).
</acceptance_criteria>
<done>An editor's save, or a 50-row bulk delete, answers without waiting for the site, the site still gets its purge after the last commit, and the bearer token never follows a redirect.</done>
</task>
<task type="auto">
<name>Task 5: A site behind its TLS proxy accepts purges when the operator opts in: the shared route honours runtimeConfig siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED), byte-identical in the three layers, and the caching guide pairs it with an https purge_url (WR-03)</name>
<reversibility rating="costly">siteCache.allowProxied and NUXT_SITE_CACHE_ALLOW_PROXIED become deployment configuration of every site on the new layer versions.</reversibility>
<precondition>`git -C ../vue-faq-layer status --porcelain`, `git -C ../vue-menu-layer status --porcelain` and `git -C ../vue-journal-layer status --porcelain` print nothing; Task 4's caching.md commit is in place.</precondition>
<files>../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/README.md, ../vue-faq-layer/package.json, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/README.md, ../vue-menu-layer/package.json, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json, docs/services/caching.md, modules/cooler/README.md</files>
<read_first>../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/tests/sitePurge.test.ts (SHARED_LIB_SHA256, SHARED_ROUTE_SHA256, the route handler specs), ../vue-faq-layer/nuxt.config.ts (runtimeConfig siteCache :26-32), ../vue-faq-layer/README.md (Site cache purge), ../vue-menu-layer/nuxt.config.ts, ../vue-journal-layer/nuxt.config.ts (the journal alias entries stay), the three package.json, docs/services/caching.md (Site cache purge after Task 4), modules/cooler/sitecache.go (the https rule in NewSiteCachePurgerFor), 19.3-REVIEW.md (WR-03), 19.3-04-SUMMARY.md (shared files and hash pins)</read_first>
<action>Per D-10 and D-11. The model is the review's second option, an explicit opt-in. Restricting site_cache.purge_url to loopback would refuse at boot an https URL that Journal deployments may carry through the alias keys, which would break a core plugin. The default stays "refuse proxied".
vue-faq-layer first, one commit:
- server/lib/siteCachePurge.ts: SitePurgeAuthInput gains an optional allowProxied boolean. When it is true, authorizeSitePurge skips the proxied verdict, so the bearer token is the only gate (at least 32 characters, compared in constant time). The doc comments say so.
- server/api/__site__/cache/purge.post.ts passes allowProxied from runtimeConfig.siteCache.allowProxied, accepting true or the string "true". Its header comment documents the flag.
- nuxt.config.ts: runtimeConfig siteCache gains allowProxied: false. Its comment names NUXT_SITE_CACHE_ALLOW_PROXIED and says when to set it: the backend reaches the site through a TLS-terminating proxy, which adds X-Forwarded-For, X-Real-IP or Forwarded.
- tests/sitePurge.test.ts, two new specs; the existing specs stay unchanged:
- "sitePurge: allowProxied lets a proxied request reach the token check": with allowProxied true, a proxied request is ok with the right bearer and unauthorized with a wrong one, and is still disabled without a token;
- "sitePurge: route accepts a proxied request with allowProxied and the right bearer": in the file's route-handler spec style, it answers 200 with the count, and the same request answers 404 with the flag false.
- Recompute SHARED_LIB_SHA256 and SHARED_ROUTE_SHA256 once the lib and route are final.
- README: the Site cache purge section documents the flag and the environment variable.
- package.json: version 0.3.0.
Then copy the three shared files byte for byte into vue-menu-layer and vue-journal-layer. Add the same nuxt.config.ts entry and README paragraph to each. Versions: 0.3.0 for menu, 0.5.0 for journal. One commit per repository. vue-journal-layer keeps the deprecated /api/__journal__/cache/purge alias and app/utils/siteCache.ts unchanged. No tags here; Task 7 creates them.
Framework, one docs commit:
- caching.md "Site cache purge" gains a paragraph that pairs an https purge_url on another host with NUXT_SITE_CACHE_ALLOW_PROXIED=true on the site. It explains that the proxy adds forwarding headers, which the route refuses by default. It also says that the token is then the only gate, so it must stay long and secret.
- cooler README: the site_cache.purge_url configuration row points at that paragraph.
Commits:
- each layer: `feat(19.3-06): purge route accepts proxied requests on siteCache.allowProxied (WR-03)`;
- summercms.go: `docs(19.3-06): pair a remote purge URL with the site's allowProxied (WR-03)`.</action>
<verify>
<automated>bash scripts/check-phase19.3.sh layers &amp;&amp; ( for l in ../vue-faq-layer ../vue-menu-layer ../vue-journal-layer; do pnpm -C "$l" test 2&gt;&amp;1 | grep -cE '^\s*ok [0-9]+ - sitePurge: (allowProxied lets a proxied request reach the token check|route accepts a proxied request with allowProxied and the right bearer)$' | grep -qx 2 || exit 1; done ) &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; go run ./cmd/summer docs:build --check</automated>
<fails_when>Non-zero exit. Possible causes: the shared files differ between the layers; a layer spec fails, for example a stale hash pin; one of the two new sitePurge specs does not print an "ok" line in a layer; or the docs checker or docs:build --check reports a problem.</fails_when>
</verify>
<acceptance_criteria>
- `grep -n 'allowProxied' ../vue-faq-layer/nuxt.config.ts ../vue-menu-layer/nuxt.config.ts ../vue-journal-layer/nuxt.config.ts` prints at least one line per file.
- `grep -n '"version": "0.3.0"' ../vue-faq-layer/package.json ../vue-menu-layer/package.json` prints one line per file and `grep -n '"version": "0.5.0"' ../vue-journal-layer/package.json` prints one line.
- `grep -n 'NUXT_SITE_CACHE_ALLOW_PROXIED' docs/services/caching.md` prints at least one line.
- `test -f ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` exits 0 (alias kept).
- `git -C ../vue-faq-layer tag --points-at HEAD`, `git -C ../vue-menu-layer tag --points-at HEAD` and `git -C ../vue-journal-layer tag --points-at HEAD` print nothing (no tag before Task 7).
</acceptance_criteria>
<done>The two halves of the purge feature agree: a remote https purge URL works once the site opts in, and a site that does not opt in refuses proxied requests as before.</done>
</task>
<task type="auto">
<name>Task 6: Unit tests for every fix, required by name in scripts/check-phase19.3.sh, the whole gate green with Docker, and the review disposition recorded</name>
<files>modules/surf/cors_profile_test.go, modules/surf/apidocs_test.go, modules/bouncer/cookie_test.go, modules/cooler/sitecache_test.go, scripts/check-phase19.3.sh, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</files>
<read_first>scripts/check-phase19.3.sh (the lists, go_named, LAYER_REQUIRE), `git log --oneline --grep='(19.3-06)'` (the Task 1-5 commits), modules/surf/cors.go, modules/surf/apidocs.go, modules/bouncer/cookie.go, modules/cooler/sitecache.go, modules/cooler/cooler.go (Handle.Close), modules/cooler/install_test.go (Install with a test config), modules/cooler/sitecache_test.go (the Postgres harness of TestSiteCacheAfterCommitOnly), 19.3-REVIEW-DISPOSITION.md, 19.3-VALIDATION.md (Per-Task Verification Map)</read_first>
<action>Unit tests, Docker-free unless noted, committed as `test(19.3-06): unit tests for the review fixes`:
- surf cors_profile_test.go:
- TestCORSCredentialedOriginRules: a table over http.cors and http.cors_profiles.spa with credentials (per D-04).
- Accepted: https://www.example.com, https://*.example.com, https://*.api.example.com:8443, ^https://[a-z]+\.example\.org$.
- Refused: https://*, *://*, https://*example.com, https://*.com, http*://www.example.com, the unanchored https://.*\.example\.com, ^https://.*\.example\.com without $, ^https?://.*$ and ^.*$.
- Each refusal names the key with its index, and every refused entry still loads without credentials.
- TestCORSAnchoredPatternNoSuffixMatch: a credentialed profile with ^https://[a-z]+\.example\.com$ answers https://www.example.com with the exact origin, and https://www.example.com.attacker.net without Allow-Origin but with Vary: Origin.
- surf apidocs_test.go, TestAPIDocsCSPNoHostSource (per D-17): parse the Content-Security-Policy into directives.
- script-src holds exactly one source, 'nonce-n', and object-src 'none', base-uri 'none' and frame-ancestors 'none' are present.
- Every script tag in the body carries nonce n, and two requests get different nonces.
- The three integrity values are unchanged.
- bouncer cookie_test.go (per D-03):
- TestLoadCookieConfigProductionInferredSecure, a table: production with app.url http, unset or malformed gives an error naming auth.cookie.secure and app.url; production with https gives Secure; production with an http app.url and auth.cookie.secure true gives Secure; development with http gives Secure false and no error.
- TestZeroCookieConfigDefaults: the zero config's TokenCookie and ForgetTokenCookie carry token, /, HttpOnly, SameSite Lax and Secure; {Name: sid} gets SameSite Lax and stays not Secure; a CookieRefreshHandler and a CookieLogoutHandler built from the zero config write Set-Cookie lines containing SameSite=Lax and Secure.
- cooler sitecache_test.go (per D-10):
- TestSiteCachePurgeWaitAndClose:
- Wait on a nil purger and on an idle one returns nil at once.
- With a blocking receiver, Wait under an expired context returns context.DeadlineExceeded, and the queued purge is still sent after the release.
- Install with the site_cache keys (memory drivers, no Redis), then one queued purge, then Handle.Close: Close returns after the receiver got the request, and returns within SiteCachePurgeTimeout plus a second when the receiver hangs.
- TestSiteCacheBulkCommitCoalesced (Postgres harness, skips under -short), with a blocking receiver, in two scenarios:
- 25 purges registered inside one lagoon.Transaction;
- 10 separate transactions in a loop.
Each Transaction returns before the receiver is released, in under 500 ms. After the release and Wait, the receiver got exactly two requests per scenario, the second carrying the last slug.
Gate, one commit `test(19.3-06): gate requires the review-fix tests`:
- append to the lists:
- BOUNCER_TESTS: TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults;
- SURF_TESTS: TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch, TestAPIDocsCSPNoHostSource, TestTurnstileEmptySecretWarnsAtBoot;
- ATTACH_TESTS: TestStoreProtectedNeedsPrivateBucket (it then runs in both tag modes);
- COOLER_TESTS: TestSiteCachePurgeNoRedirect, TestSiteCachePurgeAsyncCoalesced, TestSiteCachePurgeWaitAndClose;
- DOCKER_COOLER_TESTS: TestSiteCacheBulkCommitCoalesced;
- LAYER_REQUIRE: the two sitePurge names from Task 5.
- Add one line to the header comment naming the plan-06 fixes (CR-01, WR-01..WR-09).
- Run `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` on a Docker host.
- Prove fail-closed: rename one new required test locally, check that the go stage exits non-zero, revert, and record the result in the SUMMARY.
Planning docs, a separate commit `docs(19.3-06): review disposition and validation map after the gap closure`:
- 19.3-REVIEW-DISPOSITION.md: set CR-01 and WR-01..WR-09 to fixed, both in the frontmatter findings and in the table. The Source cell names the fix commit(s), and for WR-01 and WR-03 also the plugin and layer commits. The open count becomes 11; IN-01..IN-11 stay open.
- 19.3-VALIDATION.md: the Per-Task Verification Map gains rows for plan 06 (CR-01 and each WR, with test names and gate stage).</action>
<verify>
<automated>PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; (cd examples/hello &amp;&amp; go test ./...)</automated>
<fails_when>Non-zero exit, or "Phase 19.3 gate passed" absent. Possible causes: a stage refuses (a required name missing, skipped or red; the layer copies differ; the docs checker or the consuming-application scan fails); no Docker daemon answers (PHASE193_REQUIRE_DOCKER=1 turns NOT RUN into a failure); or the examples/hello workspace tests are red.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'TestCORSCredentialedOriginRules\|TestLoadCookieConfigProductionInferredSecure\|TestZeroCookieConfigDefaults\|TestStoreProtectedNeedsPrivateBucket\|TestSiteCachePurgeAsyncCoalesced\|TestSiteCacheBulkCommitCoalesced\|TestAPIDocsCSPNoHostSource\|TestTurnstileEmptySecretWarnsAtBoot' scripts/check-phase19.3.sh` prints at least 8.
- `grep -c 'allowProxied' scripts/check-phase19.3.sh` prints at least 2.
- `grep -cE '^\| (CR-01|WR-0[1-9]) \| [a-z]+ \| fixed \|' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md` prints 10 and `grep -c '^open: 11$' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-REVIEW-DISPOSITION.md` prints 1.
- The SUMMARY records that a locally renamed required test made `bash scripts/check-phase19.3.sh go` exit non-zero (reverted, never committed).
</acceptance_criteria>
<done>Every fix is pinned by a named unit test the gate cannot pass without, the whole phase gate is green with Docker, and the review record shows CR-01 and WR-01..WR-09 fixed.</done>
</task>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 7: Release gate: with the whole gate green, a human decides on tagging v0.1.28, bumping the plugin requires, tagging the layers and the pushes</name>
<read_first>.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-PLAN.md (Task 7) and 19.3-05-SUMMARY.md (release record), .planning/notes/core-plugins-own-repos.md, .planning/notes/2026-10-07-embedded-admin-release-gate.md, this plan's Task 1-6 SUMMARY notes</read_first>
<action>Do everything that can be automated, print a release sheet, then stop.
Checks before the sheet:
- In summercms.go, `git status --porcelain -- modules internal cmd scripts docs go.mod go.sum` prints nothing.
- `git fetch origin --tags`, then `git log --oneline HEAD..origin/master` prints nothing. Otherwise stop and report.
- Propose the highest `git tag -l 'v0.1.*' --sort=-v:refname` plus one (v0.1.28 at planning time) and confirm with `git ls-remote --tags origin` that it is free.
- Run the same fetch and divergence checks in sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer and vue-journal-layer, and confirm that v0.3.0 (faq and menu layers) and v0.5.0 (journal layer) are free on origin.
- Run the full gate (verify).
The sheet lists:
- the proposed tag;
- what it publishes:
- cors:&lt;profile&gt; applied outermost and the Vary merge;
- the credentialed CORS origin rules;
- the production cookie Secure rule and the zero-config defaults;
- the nonce-only api-docs CSP;
- attach.ErrProtectedNeedsPrivateBucket;
- the Turnstile boot warning;
- purges without redirects, queued and coalesced, and cooler.SiteCachePurger.Wait;
- the framework commits to push, including the planning commits since v0.1.27;
- the plugin test commits and the layer commits;
- that the plugin tests now call SiteCachePurger.Wait, so each plugin's go.mod must require the new tag before it builds on origin without go.work;
- that sites pick up siteCache.allowProxied only after their layer pins move (a follow-up, not part of this plan);
- whether the Docker stage ran.
No push and no tag before the reply. Never force-push, deploy nothing, and leave the app pointer and layer-pin bumps out of scope.
After "release":
- annotated tag on master; push master, then the tag;
- in each plugin: `GOWORK=off go get git.golem15.com/golem15/summercms@<tag>`, `GOWORK=off go mod tidy`, `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...`; commit go.mod and go.sum as `chore(19.3-06): require summercms <tag>`; push main;
- push the three layer mains, then create and push annotated tags v0.3.0 (vue-faq-layer, vue-menu-layer) and v0.5.0 (vue-journal-layer).
After "tag-only": tag and push summercms only. After "hold": nothing.
Record the reply verbatim, with every pushed hash and tag.</action>
<decision>Tag and push the framework release v0.1.28, bump and push the plugins, push and tag the layers now?</decision>
<context>A pushed tag is read by the plugins' go.mod files, the Go module proxy and every site that pins a layer by tag. It cannot be moved, only superseded. Holding costs nothing locally, because apps and plugins build through replace and go.work, but the pushed plugins' tests then cannot build without a sibling checkout until the bump.</context>
<options>
<option id="release">
<name>Tag and push everything</name>
<pros>The CR-01 fix reaches every consumer; plugins and layers on origin build and test on their own.</pros>
<cons>A defect found in UAT needs another tag.</cons>
</option>
<option id="tag-only">
<name>Tag and push the framework only</name>
<pros>The fixed framework is published; plugin and layer publication waits for UAT.</pros>
<cons>The plugin test commits and the layer flag stay local; the plugins on origin keep requiring v0.1.27.</cons>
</option>
<option id="hold">
<name>Hold until UAT (/msd-verify-work 19.3)</name>
<pros>One publication after the manual checks (subdomain cookies, real S3, live purge, Swagger UI under its CSP).</pros>
<cons>Origin keeps the CR-01 defect in v0.1.27.</cons>
</option>
</options>
<resume-signal>Reply "release", "tag-only" or "hold".</resume-signal>
<verify>
<automated>bash scripts/check-phase19.3.sh --all &amp;&amp; go test -count=1 -run '^TestDocsTree$' ./cmd/summer &amp;&amp; (cd examples/hello &amp;&amp; go test ./...) &amp;&amp; echo release-gate-ok</automated>
<fails_when>Non-zero exit or "release-gate-ok" absent: a gate stage, the docs checker or the examples/hello workspace tests are red. The executor fixes the defect, records it as a deviation and never presents a red gate.</fails_when>
</verify>
<acceptance_criteria>
- The SUMMARY records the proposed tag and the reply verbatim. After "release" or "tag-only", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` is non-empty and `git rev-parse master` equals `git rev-parse origin/master`.
- After "release", `grep -n "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-faq-plugin/go.mod ../sm-menu-plugin/go.mod ../sm-journal-plugin/go.mod` prints one line per file. `git -C ../vue-faq-layer ls-remote --tags origin refs/tags/v0.3.0`, `git -C ../vue-menu-layer ls-remote --tags origin refs/tags/v0.3.0` and `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.5.0` each print one line.
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.
</acceptance_criteria>
<done>The gap closure is either published with the plugins and layers pinned to it, or held, with a record of exactly what waits.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| browser on the SPA origin to a profiled API route | credentialed cross-origin requests whose answers the page must be able to read |
| config to boot | CORS origins, cookie Secure, public_url and the Turnstile secret, validated or reported before serving |
| Go app to the site's purge route | bearer-token POST, loopback or https through a proxy |
| CDN scripts to the api-docs page | third-party code executed in the app's origin |
| local checkouts to origin | tags and pushes that every consumer reads |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-19.3-33 | Tampering | profiled-route refusals without `Vary: Origin` or CORS headers (a shared cache serving one origin's answer to another; the SPA blind to its 401) | medium | mitigate | the profile is applied outermost in `Router.wrap`; `bufferedResponse.commit` merges `Vary`; TestCORSProfileHeadersOnRefusals and TestCORSProfileVaryKeptWithHandlerVary in the gate |
| T-19.3-34 | Information disclosure | credentialed CORS trusting arbitrary origins through `https://*`, `*://*` or unanchored patterns | high | mitigate | `validateCORSConfig` wildcard-label rule, anchoring rule and probe origins, globally and per profile; TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch |
| T-19.3-35 | Information disclosure | purge bearer token forwarded on a redirect (https to http on the same host) | high | mitigate | `CheckRedirect` returns `http.ErrUseLastResponse`; TestSiteCachePurgeNoRedirect asserts the Location receives nothing |
| T-19.3-36 | Denial of service | synchronous per-row purges holding admin requests past server write timeouts | medium | mitigate | after-commit queue with one request in flight and coalescing; `Wait` and `Handle.Close` drain within the timeout plus a second; TestSiteCachePurgeAsyncCoalesced, TestSiteCacheBulkCommitCoalesced |
| T-19.3-37 | Spoofing | purge route with `allowProxied` reachable from the network | medium | mitigate | opt-in flag, default false; the token stays at least 32 characters and is compared in constant time over sha256 digests; caching.md documents that the token is then the only gate; sitePurge specs cover both flag states |
| T-19.3-38 | Information disclosure | auth token cookie without Secure in production through an http or unset `app.url` | high | mitigate | `LoadCookieConfig` boot error naming `auth.cookie.secure` and `app.url`; TestLoadCookieConfigProductionInferredSecure |
| T-19.3-39 | Information disclosure | hand-built zero `CookieConfig` writing a cookie without SameSite or Secure | medium | mitigate | `withDefaults` gives the zero config Secure and every unset SameSite Lax; TestZeroCookieConfigDefaults |
| T-19.3-40 | Elevation of privilege | api-docs CSP allowing any jsDelivr script (DOM XSS loading attacker code) | medium | mitigate | `script-src` holds only the per-request nonce; every script tag carries it; SRI kept; TestAPIDocsCSPNoHostSource |
| T-19.3-41 | Information disclosure | protected (`is_public` false) files in a bucket served publicly through `public_url` | high | mitigate | `attach.Store` returns `ErrProtectedNeedsPrivateBucket` before writing; OpenBucket warns at boot; TestStoreProtectedNeedsPrivateBucket |
| T-19.3-42 | Spoofing | `turnstile:*` routes unprotected by a missing secret, unnoticed | medium | mitigate | one boot warning with the route count and the production flag, never the secret; TestTurnstileEmptySecretWarnsAtBoot |
| T-19.3-43 | Tampering | the three layer copies of the purge route drifting apart | low | mitigate | byte-identical files checked with `cmp` in the layers stage, sha256 pins updated in each spec |
| T-19.3-44 | Tampering | publishing an unverified release, or a moved tag | medium | mitigate | Task 7 blocking checkpoint after the full gate; tags checked free on origin; no force-push |
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | this plan installs nothing: no new Go module, no npm package; the plugin `go get` at release only moves the framework require to the tag this plan creates |
</threat_model>
<verification>
- Task 1: the surf short suite, the five CORS tests, the gate's go stage with the new SURF_TESTS names, TestDocsTree.
- Task 2: the surf and bouncer short suites and seven regression and parity tests, TestDocsTree.
- Task 3: vet in both tag modes, the surf and lagoon short suites, six named tests, TestDocsTree.
- Task 4: the cooler suite under -race, five named purge tests, the gate's plugins and docker stages.
- Task 5: the gate's layers stage (cmp, then the existing specs), both new specs in every layer, TestDocsTree, docs:build --check.
- Task 6: `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all`, TestDocsTree, the examples/hello workspace tests.
- Task 7: the full gate again before the human decision.
- Manual (19.3-VERIFICATION.md human items, unchanged by this plan): cookie refresh across real subdomains (now readable through a `cors:<profile>` route as well), a real S3 bucket, a live Nuxt purge (now queued), and Swagger UI under its now stricter CSP.
</verification>
<success_criteria>
- SC-1 / D-05 gap closed: every response of a `cors:<profile>` route carries the profile's headers, proven by a test the gate requires, with docs updated.
- WR-01..WR-09 fixed, each in its own commit with docs and a regression test, and recorded as fixed in 19.3-REVIEW-DISPOSITION.md.
- No breaking change to the core plugins; the non-credentialed `*` CORS answers are unchanged.
- The whole phase gate is green with Docker; the release is published only on the human's reply.
</success_criteria>
<output>
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md` when done. Include:
- the commits per repository and per finding;
- the identifiers added (`attach.ErrProtectedNeedsPrivateBucket`, `cooler.SiteCachePurger.Wait`, runtimeConfig `siteCache.allowProxied`);
- what the admin shows for a refused protected upload;
- the new test names per gate stage, and the gate stages run or NOT RUN;
- the fail-closed check of Task 6;
- the Task 7 reply verbatim, with every pushed hash and tag;
- deviations;
- follow-ups: the app's plugin pointers and layer pins to the new versions; moving remote purge deployments to `NUXT_SITE_CACHE_ALLOW_PROXIED`; IN-01..IN-11 still open.
After the SUMMARY, update STATE through `msd_run query state.*` handlers and post a short g15office comment that phase 19.3's gap closure advanced (`.planning/G15OFFICE.md`).
</output>