Compare commits

...

28 Commits

Author SHA1 Message Date
Jakub Zych
f3e8a984b0 docs(quick-261015-w7f): docs since v0.1.14 and the Upgrading to 0.2 notes
- new setup/upgrading.md: version rule, the two 0.2 boot refusals with
  their fixes, behaviour changes, and what 0.2 adds since 0.1
- architecture module table lists all 28 modules
- WinterCMS map: cache and reorder are provided; rows for field types,
  sanitizing, singletons, API tokens, CORS, Turnstile, menu item types
- configuration keys for CORS profiles, api_docs, auth.cookie, turnstile
- index, setup intro and frontend page link the new topics
2026-10-15 23:24:56 +02:00
Jakub Zych
76d788821a docs: decide to release the 19.3 gap closure as v0.2.0 2026-10-15 23:14:08 +02:00
Jakub Zych
694a565941 test(19.3): persist human verification items as UAT 2026-10-15 23:11:05 +02:00
Jakub Zych
56fe2ae372 docs(19.3): re-verification after gap closure (human checks pending) 2026-10-15 23:10:59 +02:00
Jakub Zych
c5d9a7b6b1 docs(19.3-06): state and roadmap after the gap closure 2026-10-15 23:01:46 +02:00
Jakub Zych
350d0355b2 docs(19.3-06): gap closure summary, release handed off to the phase session 2026-10-15 23:01:09 +02:00
Jakub Zych
0c8109c670 docs(19.3-06): review disposition and validation map after the gap closure 2026-10-15 22:51:13 +02:00
Jakub Zych
b2f4044111 test(19.3-06): gate requires the review-fix tests
- go, docker and layers stages require the plan-06 tests by name
  (CR-01, WR-01..WR-09)
2026-10-15 22:47:45 +02:00
Jakub Zych
8d80580083 test(19.3-06): unit tests for the review fixes
- TestCORSCredentialedOriginRules, TestCORSAnchoredPatternNoSuffixMatch (D-04)
- TestAPIDocsCSPNoHostSource (D-17)
- TestLoadCookieConfigProductionInferredSecure, TestZeroCookieConfigDefaults (D-03)
- TestSiteCachePurgeWaitAndClose, TestSiteCacheBulkCommitCoalesced (D-10)
2026-10-15 22:45:16 +02:00
Jakub Zych
4d4e37932c docs(19.3-06): pair a remote purge URL with the site's allowProxied (WR-03)
- caching.md: an https purge_url on another host goes through the site's
  proxy; set NUXT_SITE_CACHE_ALLOW_PROXIED=true there, the token is then the
  only gate
- cooler README: the purge_url row points at it
2026-10-15 22:42:36 +02:00
Jakub Zych
3bb6d61843 docs(19.3-06): progress record after Task 4 2026-10-15 22:40:30 +02:00
Jakub Zych
db6a7f5c3f fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)
- PurgeSiteCacheAfterCommit queues the purge on a per-purger worker after
  commit: one request in flight, saves committed meanwhile collapse into
  one follow-up, so the admin request never waits for the site
- new cooler.SiteCachePurger.Wait drains the queue; Handle.Close waits for
  it at shutdown (timeout plus one second)
- Purge stays the synchronous send; exported names and signatures unchanged
2026-10-15 22:36:24 +02:00
Jakub Zych
50a584c757 fix(19.3-06): site-cache purge never follows redirects (WR-02)
- the purge client's CheckRedirect returns http.ErrUseLastResponse: a 3xx
  is logged as a failed purge with its status and the bearer token never
  reaches the Location
2026-10-15 22:32:32 +02:00
Jakub Zych
d0a580c60c fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)
- BuildRouter logs one warning with the route count and the production
  flag when routes name turnstile:open or turnstile:closed and the
  verifier has no secret; the secret is never logged
2026-10-15 22:30:57 +02:00
Jakub Zych
caa5b5d8ff fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)
- attach.Store returns attach.ErrProtectedNeedsPrivateBucket for an
  is_public false upload while storage.uploads.public_url is set, before
  any blob is written
- OpenBucket's public_url line becomes a warning naming the refusal
2026-10-15 22:30:00 +02:00
Jakub Zych
f705343904 fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)
- the two pinned jsDelivr scripts carry the per-request nonce next to
  their SRI hash; script-src holds only the nonce, no host source
2026-10-15 22:28:34 +02:00
Jakub Zych
85d8b6a85a fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)
- withDefaults gives the zero config Secure and every unset SameSite Lax;
  a config with any field set keeps its Secure value
2026-10-15 22:27:31 +02:00
Jakub Zych
4dae8331c5 fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)
- LoadCookieConfig refuses a Secure that resolves to false in production,
  also when inferred from an http or unset app.url, naming
  auth.cookie.secure and app.url
2026-10-15 22:26:43 +02:00
Jakub Zych
e32940d90f fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)
- with supports_credentials, a * origin must be the whole leftmost host
  label, patterns must be anchored with ^ and $, and no origin rule may
  match the probe origins outside any configured domain
- globally and per profile; non-credentialed configs load as before
2026-10-15 22:25:43 +02:00
Jakub Zych
072353663e fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)
- Router.wrap applies the route's profile outside recovery, body limit and
  locale, whatever its position in the list or the group nesting
- bufferedResponse.commit merges Vary instead of replacing it
- TestCORSProfileHeadersOnRefusals, TestCORSProfileVaryKeptWithHandlerVary
  required by name in the gate
2026-10-15 22:24:08 +02:00
Jakub Zych
e9ad640999 docs(19.3): state after gap closure planning 2026-10-15 21:21:00 +02:00
Jakub Zych
94d9344cdd docs(19.3): revise gap closure plan 06 after checker feedback
Task 1 tests every refusal status routing.md names (403 and the
Turnstile 422 and 503 added). Task 4 ends with a committed progress
record and the plan gains a resume rule, so a context reset after
Task 4 continues at Task 5 without a partial SUMMARY.
2026-10-15 21:19:55 +02:00
Jakub Zych
629e75f152 docs(19.3): create gap closure plan 06 (CR-01, WR-01..WR-09) 2026-10-15 21:12:58 +02:00
Jakub Zych
ebdd896d82 docs(19.3): add phase verification report (gaps found) 2026-10-15 20:28:18 +02:00
Jakub Zych
6cd29eae35 docs(19.3): record code review disposition 2026-10-15 20:18:44 +02:00
Jakub Zych
1ec6aa4364 docs(19.3): add code review report 2026-10-15 20:18:33 +02:00
Jakub Zych
f80e2e8636 docs(19.3-05): state and roadmap after the v0.1.27 release 2026-10-15 20:10:23 +02:00
Jakub Zych
03a9d1f7f1 docs(19.3-05): complete unit tests, phase gate and release plan
- Task 7 reply: release; v0.1.27 tagged and pushed
- plugins bumped to v0.1.27 and pushed; layer and app commits pushed
2026-10-15 20:09:46 +02:00
43 changed files with 3253 additions and 190 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:** 4/5 plans executed
**Plans:** 6/6 plans executed
Plans:
**Wave 1**
@@ -1267,7 +1267,10 @@ Plans:
- [x] 19.3-04-PLAN.md — Site-cache purge in cooler, `pact.ReorderAfterSave`, Journal/menu/FAQ adoption, shared Nuxt purge route in three layers, push checkpoint, app pointer bumps (wave 2)
**Wave 3** *(blocked on Wave 2 completion)*
- [ ] 19.3-05-PLAN.md — Unit tests for all new code, `scripts/check-phase19.3.sh`, miniredis legitimacy check, release checkpoint (wave 3)
- [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)*
- [x] 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)
@@ -1386,7 +1389,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 19. Chat and ChatChannels | 0/TBD | Not started | - |
| 19.1. Chat satellites | 0/TBD | Not started | - |
| 19.2. Chat polish against WavePath clients | 0/TBD | Not started | - |
| 19.3. Framework transport and infrastructure for WavePath | 4/5 | In Progress| |
| 19.3. Framework transport and infrastructure for WavePath | 6/6 | In Progress| |
| 19.4. OIDC provider on wristband | 0/TBD | Not started | - |
| 19.5. Social login in sm-user-plugin | 0/TBD | Not started | - |
| 19.6. Two-factor auth and device flow in sm-user-plugin | 0/TBD | Not started | - |

View File

@@ -3,17 +3,17 @@ msd_state_version: "1.0"
milestone: v1.0
current_phase: "19.3"
current_phase_name: Framework transport and infrastructure for WavePath (INSERTED)
status: executing
stopped_at: 19.3-05 Task 7 release checkpoint (tasks 1-6 done, gate green; awaiting release / tag-only / hold)
last_updated: "2026-10-15T18:01:50.208Z"
status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
stopped_at: Completed 19.3-06-PLAN.md (gap closure; release handed off to the phase 19.3 main session)
last_updated: "2026-10-15T21:01:36.545Z"
last_activity: 2026-10-15
last_activity_desc: Phase 19.3 execution started
state_head: 8f5325300c94fe7fb18e60b47102bc2443a0a17c
last_activity_desc: 19.3-06 gap closure complete, release handed off
state_head: 350d0355b27bdf38e106415cf56b14d0e903c6ae
progress:
total_phases: 40
completed_phases: 31
total_plans: 190
completed_plans: 187
total_plans: 191
completed_plans: 188
gsd_state_version: "1.0"
milestone_name: milestone
---
@@ -30,9 +30,9 @@ See: .planning/PROJECT.md (updated 2026-10-12)
## Current Position
Phase: 19.3 (Framework transport and infrastructure for WavePath (INSERTED)) — EXECUTING
Plan: 5 of 5
Status: Ready to execute
Last activity: 2026-10-15 — Phase 19.3 execution started
Plan: 6 of 6
Status: Phase 19.3 plans complete; release and whole-phase docs pending in the phase 19.3 main session
Last activity: 2026-10-15 — 19.3-06 gap closure complete, release handed off
Progress: [████░░░░░░] 44%
@@ -243,6 +243,8 @@ Progress: [████░░░░░░] 44%
| Phase 19.3 P02 | 15min | 4 tasks | 11 files |
| Phase 19.3 P03 | 10 min | 3 tasks | 20 files |
| Phase 19.3 P04 | 60min | 5 tasks | 14 files |
| Phase 19.3 P05 | 49min | 7 tasks | 38 files |
| Phase 19.3 P06 | 40min | 6 tasks | 24 files |
## Accumulated Context
@@ -825,6 +827,9 @@ Recent decisions affecting current work:
- [Phase 19.3]: 19.3-04 Task 4 reply push-all: plugins, layers, summercms master pushed; layer tags vue-faq/menu v0.2.0, vue-journal v0.4.0; no framework tag
- [Phase 19.3]: 19.3-05: phase gate scripts/check-phase19.3.sh requires every new test by name (go test -json / TAP detectors); --all green incl. Docker
- [Phase 19.3]: 19.3-05: miniredis v2.40.0 test-only in modules/cooler (Task 4 reply: approved)
- [Phase 19.3]: Phase 19.3 released as summercms v0.1.27 (Task 7 reply: release); sm-faq-plugin, sm-menu-plugin and sm-journal-plugin require v0.1.27 on origin; app plugin submodule pointers not bumped yet
- [Phase 19.3]: Phase 19.3 gate scripts/check-phase19.3.sh is fail-closed: a skipped, missing or red required test fails the stage; miniredis is test-only
- [Phase 19.3]: 19.3-06: release of v0.1.28 (tag, plugin require bumps, layer tags v0.3.0/v0.3.0/v0.5.0, pushes) handed off to the phase 19.3 main session with the whole-phase docs update; gap closure pushed and tagged nothing
### Pending Todos
@@ -903,6 +908,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-10-15T18:01:46.843Z
Stopped at: 19.3-05 Task 7 release checkpoint (tasks 1-6 done, gate green; awaiting release / tag-only / hold)
Resume file: .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md
Last session: 2026-10-15T21:01:19.601Z
Stopped at: Completed 19.3-06-PLAN.md (gap closure; release handed off to the phase 19.3 main session)
Resume file: None

View File

@@ -0,0 +1,21 @@
# Release the 19.3 gap closure as v0.2.0, not v0.1.28
**Date:** 2026-10-15
**Decision:** The next framework tag after v0.1.27 is **v0.2.0**.
## Why
The 19.3-06 review fixes include changes that can make an app that boots on v0.1.27 refuse to boot after the upgrade:
- WR-05: in production, a token cookie whose `Secure` is inferred as false (http or empty `app.url`) is refused at boot.
- WR-04: credentialed CORS refuses wildcard-host and unanchored origin patterns at boot.
Smaller behaviour changes ride along: site-cache purges run queued after commit (WR-01), protected uploads into a `public_url` bucket are refused (WR-07), Journal's old purge keys are deprecated aliases.
In a v0.x Go module the minor version is the signal for "check your config before upgrading". Consumers pin exact versions, so the bump costs nothing technically.
## Consequences
- Plugins and apps move with `go get git.golem15.com/golem15/summercms@v0.2.0`.
- The docs and the release notes carry an "Upgrading to 0.2" section naming the two boot refusals and the config that fixes each.
- From here, the minor version is bumped when an upgrade can refuse a previously valid config or changes a documented behaviour; patch versions stay safe to take without reading.

View File

@@ -3,7 +3,7 @@ phase: 19.3-framework-transport-and-infrastructure-for-wavepath
plan: 05
subsystem: testing
tags: [gate, bouncer, turnstile, cors, api-docs, cooler, redis, miniredis, rate-limit, s3, build-tags, site-cache, nuxt-layers, release]
status: in-progress
status: complete
requires:
- phase: 19.3-01
@@ -19,14 +19,15 @@ provides:
- "SC-4 unit tests for every piece of new code of the phase, in summercms.go, three plugins and three layers"
- "github.com/alicebob/miniredis/v2 v2.40.0 as a test-only dependency (human-approved)"
- "19.3-VALIDATION.md per-task map filled, wave_0_complete true"
affects: [19.3 release (Task 7), /msd-verify-work 19.3, /msd-validate-phase 19.3]
- "summercms v0.1.27 tagged and pushed; sm-faq-plugin, sm-menu-plugin and sm-journal-plugin require v0.1.27 on origin"
affects: [/msd-verify-work 19.3, /msd-validate-phase 19.3, sm-grzybyfunkcjonalne-app plugin submodule pointers]
actuals:
tokens: 53300
tasks: 6
commits: 8
tokens: 56050
tasks: 7
commits: 9
plan_head_before: 6035ccc194a74a9124ece17ce3c8301689cbc8ac
plan_head_after: 8f5325300c94fe7fb18e60b47102bc2443a0a17c
plan_head_after: 9921768bbf0814671a89eafd45f949afcc113a11
tech-stack:
added:
@@ -64,6 +65,12 @@ key-files:
- go.mod
- go.sum
- ../sm-journal-plugin/site_cache_admin_test.go
- ../sm-journal-plugin/go.mod
- ../sm-journal-plugin/go.sum
- ../sm-faq-plugin/go.mod
- ../sm-faq-plugin/go.sum
- ../sm-menu-plugin/go.mod
- ../sm-menu-plugin/go.sum
- ../vue-faq-layer/tests/sitePurge.test.ts
- ../vue-menu-layer/tests/sitePurge.test.ts
- ../vue-journal-layer/tests/sitePurge.test.ts
@@ -73,44 +80,121 @@ key-decisions:
- "Phase gate reuses check-phase18.1.sh's go test -json detector: a skip, a missing name, no tests, zero tests or a build failure fails the stage"
- "miniredis is test-only: only _test.go files in modules/cooler import it (Task 4 reply: approved)"
- "Journal's plan-04 Docker test renamed to TestCategoriesAdminFrameworkKeysWin so the unit test owns TestSiteCacheFrameworkKeysWin"
- "Release proposal: framework tag v0.1.27 (highest v0.1.26 plus one, free on origin); nothing pushed or tagged before the Task 7 reply"
- "Phase 19.3 released as summercms v0.1.27 (Task 7 reply: release); the three plugins require v0.1.27, the app's plugin submodule pointers are not bumped yet"
requirements-completed: []
requirements-completed: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-15, D-16, D-17, D-18]
duration: 41min (plan wall time so far, both executor runs)
completed: pending (Task 7 release decision)
coverage:
- deliverable: "Fail-closed phase gate scripts/check-phase19.3.sh"
human_judgment: false
verification:
- kind: command
ref: "bash scripts/check-phase19.3.sh --all"
status: pass
- deliverable: "SC-4 unit tests across framework, plugins and layers"
human_judgment: false
verification:
- kind: command
ref: "bash scripts/check-phase19.3.sh --all (go, docker, plugins, layers stages)"
status: pass
- kind: command
ref: "go test -count=1 ./... (41 packages, Docker suites included)"
status: pass
- deliverable: "Release v0.1.27 with plugins bumped and all commits pushed"
human_judgment: false
verification:
- kind: command
ref: "git ls-remote --tags origin refs/tags/v0.1.27 (non-empty); master == origin/master in every repo"
status: pass
- kind: command
ref: "GOWORK=off go test -count=1 ./... in sm-faq-plugin, sm-menu-plugin, sm-journal-plugin after the bump"
status: pass
duration: 49min (wall time 17:19Z to 18:08Z, including the Task 4 and Task 7 checkpoint waits)
completed: 2026-10-15
---
# Phase 19.3 Plan 05: unit tests, phase gate and release decision Summary
# Phase 19.3 Plan 05: unit tests, phase gate and release Summary
**Fail-closed gate `scripts/check-phase19.3.sh` requiring every new 19.3 test by name across the framework, three plugins and three Nuxt layers. Green on all six stages, Docker included. Release v0.1.27 waits on the human's decision.**
**Fail-closed gate `scripts/check-phase19.3.sh` requires every new 19.3 test by name across the framework, three plugins and three Nuxt layers. It passed on all six stages, Docker included. The phase is released as summercms v0.1.27, and the FAQ, menu and Journal plugins now require that tag on origin.**
## Checkpoint replies
### Task 4 (miniredis legitimacy check)
Reply, verbatim, recorded (commit b139651) before any go.mod change of Task 5:
The reply below was recorded verbatim (commit b139651) before any go.mod change in Task 5:
> approved
Orchestrator instruction with it: install github.com/alicebob/miniredis/v2@v2.40.0 as test-only (only `_test.go` files in modules/cooler may import it). Verified after Task 5: `grep -n 'github.com/alicebob/miniredis/v2 v2.40.0' go.mod` prints line 10; `grep -rl 'alicebob/miniredis' --include='*.go' modules | grep -vc '_test.go$'` prints 0.
The orchestrator sent this instruction with it: install github.com/alicebob/miniredis/v2@v2.40.0 as a test-only dependency, so only `_test.go` files in modules/cooler may import it. Checked after Task 5: `grep -n 'github.com/alicebob/miniredis/v2 v2.40.0' go.mod` prints line 10, and `grep -rl 'alicebob/miniredis' --include='*.go' modules | grep -vc '_test.go$'` prints 0.
### Task 7 (release decision)
Pending. Proposed tag: **v0.1.27**. The reply, the tag and every pushed hash go here.
The reply below is recorded verbatim:
> release
The orchestrator scoped it to exactly the "release" option:
1. Tag v0.1.27 and push master and the tag.
2. Bump the three plugins, test them and push them.
3. Push the layer test commits, with no new layer tags.
4. Push the plan 04 app commits, vue app first.
It also set these rules: never force-push, run fetch and a divergence check before each push, deploy nothing, and leave the app's plugin submodule pointer bump out of scope.
## Release record
- **Tag:** `v0.1.27` is an annotated tag (object d0e39437) on summercms.go master 9921768. `git ls-remote --tags origin refs/tags/v0.1.27` prints `d0e39437b5b56ba77b82267693de189bf5d1329d` and peels to `9921768bbf0814671a89eafd45f949afcc113a11`.
- **Preflight before each push:**
- `git fetch origin --tags` ran in every repository, and nothing was behind origin.
- v0.1.27 was free on origin before tagging, and `go vet ./... && go build ./...` passed on 9921768.
- The only change since the green `--all` gate run is planning docs.
- **summercms.go:** pushed master `af9dad6..9921768`. This covers plan 04's 6f9f2e2 and 6035ccc and this plan's 619916b through 9921768. Then the tag was pushed. `git rev-parse master` equals `git rev-parse origin/master`. The final SUMMARY and state commit lands after the tag and is pushed plainly to master.
- **Plugins:**
- Each one ran `GOWORK=off go get git.golem15.com/golem15/summercms@v0.1.27` and `GOWORK=off go mod tidy`.
- The tidy added go-redis v9.23.0, otter v2.3.0, google/wire and the AWS SDK v2 modules as indirect requires.
- `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...` passed in all three, Docker suites included. `go test -short ./...` also passed through the workspace.
- Pushed main:
- sm-faq-plugin `c60ae6e..fa90e83` (e117bb4, then fa90e83 "chore(19.3-05): require summercms v0.1.27").
- sm-menu-plugin `559d96b..fe27b9a` (6c08f1a, fe27b9a).
- sm-journal-plugin `d7943fb..a99e9a1` (ebfb710, a99e9a1).
- `grep -n "git.golem15.com/golem15/summercms v0.1.27"` prints one line per go.mod: faq line 7, menu line 6, journal line 8.
- **Layers (test commits only, no new tags):**
- vue-faq-layer `e26f22e..96b6bc0`
- vue-menu-layer `3bc7729..dd0ee35`
- vue-journal-layer `3e0b377..5936f66`
- **App, vue app first:**
- vue-grzybyfunkcjonalne-app `bc143fa..0ee97d0`.
- Then sm-grzybyfunkcjonalne-app `4bab6f1..129c0df` (f9cafe2, d604509, 85939c3, 129c0df).
- **No force-push, no deploy.**
## Performance
- **Started:** 2026-10-15T17:19:19Z (first executor run; resumed after the Task 4 checkpoint)
- **Tasks:** 6 of 7 complete (Task 7 is the blocking release checkpoint)
- **Framework commits since the plan ledger:** 8 (6035ccc..8f53253)
- **Started:** 2026-10-15T17:19:19Z, across three executor runs with checkpoints at Task 4 and Task 7.
- **Completed:** 2026-10-15T18:08Z
- **Tasks:** 7 of 7
- **Framework commits since the plan ledger:** 9 (6035ccc..9921768), measured when this SUMMARY was written. The completion commit follows.
- **Commits in other repositories:** 9 in all. Six are test commits (Task 6) and three are plugin go.mod bumps (Task 7).
## Accomplishments
- The gate has stages `--self-test`, `go`, `docker`, `plugins`, `layers` and `docs`, plus `--all`. A stage fails when a required test is missing, skipped or red. `--all` reports NOT RUN docker when no Docker daemon answers, and fails instead when PHASE193_REQUIRE_DOCKER=1.
- The cooler suites cover the memory and Redis caches and the rate-limit parity table, run through surf.MemoryStore, miniredis and real Redis. They also cover concurrency, the fallback on a Redis error, the key prefixes, Install, FromApp, and the site-cache purge on Postgres (rollback, commit, nested transactions, a 500 answer, a slow receiver and an unreachable one).
- In the plugins: the FAQ and menu `purgeSiteCache` helper tests, and the Journal's deprecated-key tests (framework keys win; alias keys deprecated or invalid; no keys).
- In the layers: the shared purge spec grows from 6 to 11 tests, byte-identical in all three layers. New ones include route-handler specs for 404, 401 and 200, the purge count and the log line, timing-safe comparison across bearer lengths, and parse edge cases.
- The gate has stages `--self-test`, `go`, `docker`, `plugins`, `layers` and `docs`, plus `--all`.
- A stage fails when a required test is missing, skipped or red.
- When no Docker daemon answers, `--all` reports docker as NOT RUN. It fails instead when PHASE193_REQUIRE_DOCKER=1.
- The cooler suites cover:
- the memory and Redis caches;
- the rate-limit parity table, run through surf.MemoryStore, miniredis and real Redis;
- concurrency, the fallback on a Redis error and the key prefixes;
- Install and FromApp;
- the site-cache purge on Postgres: rollback, commit, nested transactions, a 500 answer, a slow receiver and an unreachable one.
- In the plugins: tests for the FAQ and menu `purgeSiteCache` helpers, and for the Journal's deprecated keys (framework keys win; alias keys are deprecated or invalid; no keys).
- In the layers: the shared purge spec grows from 6 to 11 tests and is byte-identical in all three layers. The new tests cover:
- route-handler specs for 404, 401 and 200;
- the purge count and the log line;
- timing-safe comparison across bearer lengths;
- parse edge cases.
- Release: v0.1.27 is published. The three plugins on origin build against it without a sibling checkout.
## Task Commits
@@ -130,6 +214,13 @@ Pending. Proposed tag: **v0.1.27**. The reply, the tag and every pushed hash go
| 6 | vue-journal-layer | 5936f66 | test(19.3-05): site purge route handler specs and edge cases |
| 6 | summercms.go | f57fc12 | test(19.3-05): gate plugins, layers and docs stages |
| 6 | summercms.go | 8f53253 | docs(19.3-05): validation map filled, wave 0 complete |
| (docs) | summercms.go | 9921768 | docs(19.3-05): summary through Task 6 and the release sheet; state at the Task 7 checkpoint |
| 7 | summercms.go | tag v0.1.27 (d0e3943 on 9921768) | v0.1.27: cooler, bouncer cookies and Turnstile, CORS hardening and profiles, api-docs, build tags and opt-in S3, public_url, pact.ReorderAfterSave (Phase 19.3) |
| 7 | sm-faq-plugin | fa90e83 | chore(19.3-05): require summercms v0.1.27 |
| 7 | sm-menu-plugin | fe27b9a | chore(19.3-05): require summercms v0.1.27 |
| 7 | sm-journal-plugin | a99e9a1 | chore(19.3-05): require summercms v0.1.27 |
The Task 7 pushes also carried plan 04's app commits: vue-grzybyfunkcjonalne-app 0ee97d0, then sm-grzybyfunkcjonalne-app f9cafe2, d604509, 85939c3 and 129c0df.
## Test names per repository (all required by the gate)
@@ -137,7 +228,7 @@ Pending. Proposed tag: **v0.1.27**. The reply, the tag and every pushed hash go
- bouncer: 17 names (TestLoadCookieConfigDefaults … TestTurnstileNeverLogsSecret).
- surf: 23 names (TestCORSCredentialsWildcardRefused … TestBuildRouterNeverDials, including TestCORSOptionsMatchesLaravel).
- internal/build: 4 names.
- attach: 4 names, run twice (untagged and `-tags summercms_s3`).
- attach: 4 names, run twice (untagged and with `-tags summercms_s3`).
- cooler: TestMemoryCache, TestMemoryCacheTTL, TestMemoryCacheBounded, TestMemoryIncrementConcurrent, TestRememberDegrades, TestRedisCache, TestRedisFlushOnlyPrefix, TestRateLimitParity, TestRateLimitConcurrentNeverExceedsMax, TestRateLimitFallbackOnError, TestRateLimitKeyPrefix, TestInstallConfigErrors, TestInstallPingWarning, TestInstallReusesFromApp, TestFromAppLazy, TestInstallSiteCacheValidation, TestSiteCachePurgeConfig, TestSiteCachePurgeRequest, TestSiteCacheFromApp.
- **summercms.go, docker stage:**
- cabana: TestReorderHookRunsInTx, TestReorderHookErrorRollsBack, TestReorderWithoutHookUnchanged.
@@ -145,27 +236,30 @@ Pending. Proposed tag: **v0.1.27**. The reply, the tag and every pushed hash go
- cooler: TestRateLimitParityDocker, TestRedisCacheDocker, TestSiteCacheAfterCommitOnly, TestSiteCacheFailureNeverFails.
- **Plugins, plugins stage:**
- FAQ and menu `./controllers`: TestPurgeSiteCacheWithoutTx, TestPurgeSiteCacheWithoutPurger.
- Journal: TestSiteCacheFrameworkKeysWin, TestSiteCacheAliasKeysDeprecated, TestSiteCacheNoKeys; `./classes` TestSiteCachePurgeConfig, TestSiteCachePurgeRequest.
- Journal: TestSiteCacheFrameworkKeysWin, TestSiteCacheAliasKeysDeprecated, TestSiteCacheNoKeys; `./classes`: TestSiteCachePurgeConfig, TestSiteCachePurgeRequest.
- **Plugins, docker stage:** TestFaqAdminPurgesSiteCache, TestMenuAdminPurgesSiteCache, TestCategoriesAdminPurgesSiteCache, TestPostsAdminPurgesSiteCache, TestCategoriesAdminFrameworkKeysWin.
- **Layers, layers stage:** 11 `sitePurge:` names per layer, plus `cmp` of server/lib/siteCachePurge.ts, server/api/__site__/cache/purge.post.ts and tests/sitePurge.test.ts across the three layers.
- **Layers, layers stage:** 11 `sitePurge:` names per layer, plus a `cmp` of server/lib/siteCachePurge.ts, server/api/__site__/cache/purge.post.ts and tests/sitePurge.test.ts across the three layers.
## Gate runs
Run on 2026-10-15 on a Docker host:
All runs were on 2026-10-15 on a Docker host:
- `bash scripts/check-phase19.3.sh --all`: PASS self-test, go, plugins, layers, docs, docker. "Phase 19.3 gate passed". Docker ran; it was not NOT RUN.
- `go test -count=1 -run '^TestDocsTree$' ./cmd/summer`: ok. `(cd examples/hello && go test ./...)`: ok. Result: release-gate-ok.
- Full `go test -count=1 ./...` (all Docker suites): exit 0, 41 packages ok, no FAIL lines.
- Fail-closed checks:
- Task 1: renaming TestForgetTokenCookie made the go stage exit 1 (reverted, not committed), and `nosuchstage` exits 2 with usage.
- Task 6: a planted consuming-application name in modules/cooler/redis.go made `docs` exit 1, and a one-line drift in vue-menu-layer's spec made `layers` exit 1. Both were reverted and never committed.
- `bash scripts/check-phase19.3.sh --all` passed self-test, go, plugins, layers, docs and docker, ending with "Phase 19.3 gate passed". The docker stage actually ran; it was not NOT RUN.
- `go test -count=1 -run '^TestDocsTree$' ./cmd/summer` and `(cd examples/hello && go test ./...)` both passed, giving release-gate-ok.
- A full `go test -count=1 ./...` with all Docker suites exited 0: 41 packages ok and no FAIL lines.
- Before tagging, `go vet ./... && go build ./...` passed on 9921768.
- After the bump, each plugin passed `GOWORK=off go vet ./...` and `GOWORK=off go test -count=1 ./...`, Docker included.
- Fail-closed checks (every planted break was reverted and never committed):
- Task 1: renaming TestForgetTokenCookie made the go stage exit 1, and `nosuchstage` exits 2 with a usage line.
- Task 6: a planted consuming-application name in modules/cooler/redis.go made `docs` exit 1, and a one-line drift in vue-menu-layer's spec made `layers` exit 1.
## Decisions Made
- The gate reuses check-phase18.1.sh's `go test -json` detector. A skip, a missing name, no tests, zero tests or a build failure all fail the stage.
- The cooler Docker suites start one container per test (Postgres for the purge, redis:7-alpine for the rest) instead of a TestMain. Nothing starts before flag parsing, and `-short` skips them inside the test.
- The docs stage checks the lines the phase added (`git diff <first 19.3 commit>^`, `.planning` excluded) for consuming-application names. The names are split in the script so it does not match itself; PHASE193_BASE overrides the base.
- Test expectation for TestStoreDiskExtension: the "js under octet-stream" row expects content_type text/javascript (from the extension when the sniff is generic, the plan 03 rule) and the disk name `.bin` (D-15).
- Each cooler Docker test starts its own container instead of sharing one through a TestMain: Postgres for the purge tests, redis:7-alpine for the rest. Nothing starts before flag parsing, and each test skips itself under `-short`.
- The docs stage scans the lines the phase added (`git diff <first 19.3 commit>^`, with `.planning` excluded) for consuming-application names. The names are split in the script so the scan does not match itself, and PHASE193_BASE overrides the base.
- TestStoreDiskExtension, row "js under octet-stream": the test expects content_type text/javascript and the disk name `.bin`. The content type comes from the extension because the sniff is generic (the plan 03 rule); the disk name follows D-15.
- Release (Task 7, reply "release"): v0.1.27 is an annotated tag on master. The plugin bump commits use the subject `chore(19.3-05): require summercms v0.1.27`. The app's plugin submodule pointers stay where they are (out of scope; see Follow-ups).
## Deviations from Plan
@@ -173,46 +267,47 @@ Run on 2026-10-15 on a Docker host:
**1. [Rule 3 - Blocking] Task 3 verify grep could never match**
- **Found during:** Task 3
- **Issue:** `grep -q -- '^--- PASS: TestOpenBucketS3$'` cannot match because `go test -v` appends the duration.
- **Issue:** `grep -q -- '^--- PASS: TestOpenBucketS3$'` cannot match, because `go test -v` appends the duration to the line.
- **Fix:** checked with `grep -qE '^--- PASS: <name>( |$)'` instead (T3-VERIFY-OK). The gate itself uses the JSON detector.
- **Commit:** 38f6d65
**2. [Rule 3 - Blocking] Duplicate test name in the Journal test binary**
- **Found during:** Task 6
- **Issue:** plan 04 had added a Docker test `TestSiteCacheFrameworkKeysWin` in package journal_test, and the plan gives the same name to the new unit test in package journal. `-run '^TestSiteCacheFrameworkKeysWin$'` matched both, so under `-short` one skipped and the gate's skip detector failed the stage.
- **Issue:** plan 04 had added a Docker test named `TestSiteCacheFrameworkKeysWin` in package journal_test, and this plan gives the same name to the new unit test in package journal. `-run '^TestSiteCacheFrameworkKeysWin$'` matched both. Under `-short` the Docker one skipped, and the gate's skip detector failed the stage.
- **Fix:** renamed the Docker test to `TestCategoriesAdminFrameworkKeysWin` (it saves a category with both key sets) and required it in the docker stage. The unit test keeps the plan's name.
- **Files modified:** ../sm-journal-plugin/site_cache_admin_test.go
- **Commit:** ebfb710
**3. [Discretion] Branch the plugin helper unit tests cannot reach**
- `TestPurgeSiteCacheWithoutPurger` covers a nil app, a nil app result and an app without keys. A unit test cannot reach the branch "inside a transaction, no purger": cabana has no exported constructor for a context carrying a transaction, and this plan allows no production changes. The plugins' Docker admin suites reach it. Logged in deferred-items.md.
- `TestPurgeSiteCacheWithoutPurger` covers a nil app, a nil app result and an app without keys.
- No unit test can reach the branch "inside a transaction, no purger". cabana has no exported constructor for a context carrying a transaction, and this plan allows no production changes.
- The plugins' Docker admin suites reach that branch. The gap is logged in deferred-items.md.
No production code changed in this plan.
## Issues Encountered
- go-redis logs `failed to dial after 5 attempts` to stderr on each failing call against a stopped Redis. On a dead Redis, `Cache.Remember` waits for those retries (about 1.7 s) unless the caller's context has a deadline. Logged in deferred-items.md as a follow-up. The earlier follow-up, a committed test for the Redis-outage fallback of the rate-limit store, is now TestRateLimitFallbackOnError: each attempt answers from the fallback within about AttemptTimeout, with one warning naming the endpoint.
- Earlier finding (df7db6c), unchanged: S3 object metadata carries the sniffed content type, so an HTML body uploaded as note.txt is stored with Content-Type text/html.
- On each failing call against a stopped Redis, go-redis logs `failed to dial after 5 attempts` to stderr. With Redis down, `Cache.Remember` waits about 1.7 s for those retries unless the caller's context has a deadline. Logged in deferred-items.md as a follow-up.
- The earlier follow-up, a committed test for the rate-limit store's fallback during a Redis outage, is now TestRateLimitFallbackOnError. Each attempt answers from the fallback within about AttemptTimeout and logs one warning naming the endpoint.
- Earlier finding (df7db6c), unchanged: S3 object metadata carries the sniffed content type. An HTML body uploaded as note.txt is stored with Content-Type text/html.
## Follow-ups
- **Not done, outside the release reply's scope:** sm-grzybyfunkcjonalne-app still points its plugin submodules at the plan 04 commits: plugins/golem15/faq c60ae6e, journal d7943fb and menu 559d96b. Bumping them to fa90e83, a99e9a1 and fe27b9a (the v0.1.27 go.mod commits) is a separate app commit. It needs its own go-ahead, and then a push.
- Apps that build against ../summercms.go need `go mod tidy` once for go-redis, otter and the AWS SDK modules. sm-grzybyfunkcjonalne-app already has it (f9cafe2).
- Nothing was deployed. Manual UAT for /msd-verify-work 19.3 is still open: subdomain cookies, a real S3 bucket and a live purge.
- Left untouched in the working trees, as instructed: sm-journal-plugin/go.work.sum, sm-grzybyfunkcjonalne-app/.gsd/, and an untracked `sm-grzybyfunkcjonalne-app/sm-grzybyfunkcjonalne-app` entry inside the app checkout. It is not from this plan.
## Known Stubs
None.
## Release sheet (Task 7, awaiting the human's reply)
## Next Phase Readiness
- **Proposed tag:** v0.1.27. The current latest framework tag is v0.1.26 (5c2b0e1, 2026-10-14). `git ls-remote --tags origin refs/tags/v0.1.27` prints nothing, so the tag is free.
- **Preflight:** framework paths clean. `git fetch origin --tags` done. `HEAD..origin/master` is empty. origin/master is af9dad6.
- **Public surfaces the tag publishes:**
- cooler and its keys `redis.url`, `cache.*` and `http.rate_limit.store`, plus `site_cache.purge_url` and `site_cache.purge_token`.
- `auth.cookie.*` and the bouncer cookie and Turnstile API.
- `http.cors` hardening, `http.cors_profiles` and `cors:<profile>`.
- `turnstile.*` and `http.api_docs.*`.
- summer.yaml `build.tags` and the `summercms_s3` tag.
- `storage.uploads.public_url` and `pact.ReorderAfterSave`.
- **Framework commits to push:** origin/master..HEAD, which is plan 04's 6f9f2e2 and 6035ccc plus this plan's commits from 619916b on (with the final SUMMARY commit). v0.1.26..HEAD holds the whole phase.
- **Plugin and layer commits not pushed yet** (all test-only, from this plan): sm-faq-plugin e117bb4, sm-menu-plugin 6c08f1a, sm-journal-plugin ebfb710, vue-faq-layer 96b6bc0, vue-menu-layer dd0ee35, vue-journal-layer 5936f66. The layer tags v0.2.0 and v0.4.0 from plan 04 are already on origin.
- **App commits from plan 04 Task 5 not pushed yet:** vue-grzybyfunkcjonalne-app 0ee97d0 (push first), then sm-grzybyfunkcjonalne-app f9cafe2, d604509, 85939c3 and 129c0df.
- **Plugin go.mod:** the three plugins require summercms v0.1.23 (with a local replace). After "release" they move to v0.1.27 through `GOWORK=off go get` and `go mod tidy`.
- Apps building against ../summercms.go need `go mod tidy` once for go-redis, otter and the AWS SDK modules (sm-grzybyfunkcjonalne-app already has f9cafe2).
Plan 05 is the last plan of phase 19.3, so the phase is complete. The next steps are /msd-verify-work 19.3 for manual UAT and the app's plugin pointer bump listed above.
<!-- Self-check runs at plan completion, after Task 7. -->
## Self-Check: PASSED
- All 17 created test and gate files exist.
- All 20 listed commits are reachable from origin in their repositories: summercms.go, three plugins, three layers, the vue app and the app.
- `refs/tags/v0.1.27` on origin is d0e3943, which peels to 9921768.

View File

@@ -0,0 +1,665 @@
---
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
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.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 or 403 from a guard listed before it or set on the group, a 422 or 503 from turnstile:closed, 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 a group-level guard, a 403, a 429 and the Turnstile 422 and 503) |
| 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>
## Context budget and resume point
The plan stays one plan (user decision), so its estimate exceeds one executor context. It runs in at most two contexts, split after Task 4. Tasks 1-4 hold the six framework fixes and the three plugin repositories; Tasks 5-7 hold the layers, the unit-test tables, the full gate and the release.
- **Record.** The last step of Task 4 writes `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` and commits it as a planning-docs commit. The record is deliberately not a SUMMARY. execute-phase treats a plan that has a `-SUMMARY.md` as finished, so a partial SUMMARY would skip Tasks 5-7 and the release checkpoint.
- **Resume rule.** Before Task 1, an executor of this plan checks for the record. When the record exists and `19.3-06-SUMMARY.md` does not, it does three things. It confirms every commit the record lists (`git -C <repo> cat-file -e <hash>`). It does not redo the tasks the record marks done. It starts at the task named on the record's `next:` line. A later task is skipped only when its commits exist (subject with `(19.3-06)` and the finding ID) and its acceptance criteria hold. A partly done task resumes at its first missing commit.
- **After a reset.** If the executor's context ends after Task 4, execute-phase's resume gate finds plan-06 commits without a SUMMARY and asks how to recover. The fitting answer is a continuation executor: its completed tasks are the record's table and its resume point is the `next:` task. Re-executing from scratch would duplicate the plugin commits in the sibling repositories.
- **Close-out.** The SUMMARY carries over the record's commits, verify results and notes. The record stays as written.
## 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. Before Task 1, apply the resume rule in "Context budget and resume point" (19.3-06-PROGRESS.md).
<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 or 403 from a guard listed before it or set on the group, a Turnstile 422 or 503, 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, turnstileRoutes, TestTurnstileMiddlewareSmoke: the published verifier and the production app), modules/bouncer/turnstile.go (Verify: an empty token is refused before any siteverify call), 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 three named middlewares: deny, which answers 401 without calling next, forbid, which answers 403 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;
- subtest guard_403, route [forbid, cors:spa]: 403;
- 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;
- subtest turnstile_422: an app with a published bouncer.NewTurnstile("test-secret", false), as TestTurnstileMiddlewareSmoke publishes one, and a POST route [turnstile:closed, cors:spa]; a request without a token answers 422. The empty token is refused before any siteverify call, so no fake server is needed;
- subtest turnstile_503: transportApp in the production environment with no turnstile.secret, POST route [turnstile:closed, cors:spa]: 503.
The two Turnstile subtests assemble their own app, because BuildRouter builds one verifier per router. Every status the docs sentence below names (401, 403, 422, 503, 429, 413, 500) has a subtest here. 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; go test -count=1 -short -run '^TestCORSProfileHeadersOnRefusals$/^(guard_403|turnstile_422|turnstile_503)$' -v ./modules/surf 2&gt;&amp;1 | grep -cE '^\s+--- PASS: TestCORSProfileHeadersOnRefusals/(guard_403|turnstile_422|turnstile_503) \(' | grep -qx 3 &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), fewer than three passing guard_403, turnstile_422 and turnstile_503 subtests (a 403 or Turnstile refusal the docs name is untested or lacks the headers), 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 -cE '"(guard_403|turnstile_422|turnstile_503)"' modules/surf/cors_profile_test.go` prints at least 3, and the verify step shows each of the three subtests passing (every status the routing.md sentence names has a subtest).
- `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, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md</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.
Progress record, the last step of Task 4, after the three plugin commits and this task's verify (see "Context budget and resume point"). Write .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md with:
- a first line saying it is the mid-plan record of 19.3-06, superseded by 19.3-06-SUMMARY.md once that file exists;
- the line `next: Task 5` on its own;
- a Completed Tasks table for Tasks 1-4 in the executor's checkpoint shape: task, name, commit (repository, short hash and subject), key files, one row per commit;
- the HEAD of summercms.go and of the six sibling repositories (the layers still at their planning-time heads);
- each task's verify result, with the docker stage marked as run or NOT RUN;
- the notes the SUMMARY needs from Tasks 1-4: deviations, decisions taken while executing, and what the admin shows for a refused protected upload (Task 3).
Commit it alone as `docs(19.3-06): progress record after Task 4`, staged by its path. Do not write 19.3-06-SUMMARY.md here.</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).
- `grep -cx 'next: Task 5' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` prints 1, and `for id in CR-01 WR-04 WR-05 WR-09 WR-06 WR-07 WR-08 WR-02 WR-01; do grep -q "($id)" .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md || echo "missing $id"; done` prints nothing.
- `git log --oneline -1 -- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PROGRESS.md` prints one line, and `test -e .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md` exits non-zero (no SUMMARY before the plan's end).
</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. The committed progress record lets a fresh executor start at Task 5.</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), 19.3-06-PROGRESS.md (Tasks 1-4), `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, 19.3-06-PROGRESS.md (Tasks 1-4) and this plan's Task 5-6 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 guard_403, turnstile_422 and turnstile_503 subtests (every status routing.md names is tested), 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; the committed progress record naming Task 5 as next.
- 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, never before Task 7; the Tasks 1-4 part comes from 19.3-06-PROGRESS.md, which stays as written. 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>

View File

@@ -0,0 +1,69 @@
# 19.3-06 progress record after Task 4
Mid-plan record of plan 19.3-06 (gap closure CR-01, WR-01..WR-09), superseded by 19.3-06-SUMMARY.md once that file exists. It is not a SUMMARY: Tasks 5-7 are still to run.
next: Task 5
Recorded: 2026-10-15. Plan start: 2026-10-15T20:20:38Z. Ledger base (summercms.go HEAD before the plan): e9ad640.
## Completed Tasks
| Task | Name | Commit (repository, hash, subject) | Key files |
|------|------|------------------------------------|-----------|
| 1 | Tracer: cors:&lt;profile&gt; headers on every refusal (CR-01) | summercms.go 0723536 `fix(19.3-06): cors:<profile> headers on every response of the route (CR-01)` | modules/surf/router.go, modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md, docs/architecture/request-lifecycle.md, scripts/check-phase19.3.sh |
| 2 | Credentialed CORS origin rules (WR-04) | summercms.go e32940d `fix(19.3-06): credentialed CORS refuses wildcard-host and unanchored origins (WR-04)` | modules/surf/cors.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/routing.md |
| 2 | Production cookie Secure rule (WR-05) | summercms.go 4dae833 `fix(19.3-06): production refuses an inferred insecure token cookie (WR-05)` | modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md |
| 2 | Zero CookieConfig defaults (WR-09) | summercms.go 85d8b6a `fix(19.3-06): zero CookieConfig writes SameSite=Lax and Secure (WR-09)` | modules/bouncer/cookie.go, modules/bouncer/cookie_test.go, modules/bouncer/README.md, docs/services/authentication.md |
| 3 | Nonce-only api-docs CSP (WR-06) | summercms.go f705343 `fix(19.3-06): api-docs CSP allows only nonce-carrying scripts (WR-06)` | modules/surf/apidocs.go, modules/surf/apidocs_test.go, modules/surf/README.md, docs/services/routing.md |
| 3 | Protected uploads refused with public_url (WR-07) | summercms.go caa5b5d `fix(19.3-06): refuse protected uploads into a public_url bucket (WR-07)` | modules/lagoon/attach/store.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/url_test.go, modules/lagoon/README.md, docs/services/storage.md |
| 3 | Turnstile boot warning (WR-08) | summercms.go d0a580c `fix(19.3-06): warn at boot when turnstile routes have no secret (WR-08)` | modules/surf/router.go, modules/surf/cors_profile_test.go, modules/surf/README.md, docs/services/authentication.md |
| 4 | Purge without redirects (WR-02) | summercms.go 50a584c `fix(19.3-06): site-cache purge never follows redirects (WR-02)` | modules/cooler/sitecache.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md |
| 4 | Queued, coalesced purges and Wait (WR-01) | summercms.go db6a7f5 `fix(19.3-06): queue site-cache purges after commit and coalesce them (WR-01)` | modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/example_test.go, modules/cooler/README.md, docs/services/caching.md |
| 4 | FAQ plugin test waits (WR-01) | sm-faq-plugin e52b286 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go |
| 4 | Menu plugin test waits (WR-01) | sm-menu-plugin 4b42978 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go |
| 4 | Journal plugin test waits (WR-01) | sm-journal-plugin 4f633f0 `test(19.3-06): wait for the queued site-cache purge before asserting (WR-01)` | site_cache_admin_test.go, classes/site_cache_test.go |
## Heads after Task 4
| Repository | HEAD |
|------------|------|
| summercms.go | db6a7f5 (this record's commit follows it) |
| sm-faq-plugin | e52b286 (planning head fa90e83) |
| sm-menu-plugin | 4b42978 (planning head fe27b9a) |
| sm-journal-plugin | 4f633f0 (planning head a99e9a1) |
| vue-faq-layer | 96b6bc0 (planning-time head, v0.2.0) |
| vue-menu-layer | dd0ee35 (planning-time head, v0.2.0) |
| vue-journal-layer | 5936f66 (planning-time head, v0.4.0) |
Nothing pushed or tagged in any repository.
## Verify results
- Task 1: PASS. `go vet ./...`, surf short suite, the five named CORS tests (5 top-level PASS), guard_403, turnstile_422 and turnstile_503 (3 subtest PASS), `check-phase19.3.sh go` passed, TestDocsTree ok. Both new tests failed against the old router.go (checked by restoring it locally, then the new file back). Tracer gate: automated verify re-run green, expansion continued.
- Task 2: PASS. Surf and bouncer short suites, 7 top-level PASS, TestDocsTree ok.
- Task 3: PASS. Vet in both tag modes, surf and lagoon short suites, 6 top-level PASS, TestDocsTree ok.
- Task 4: PASS. `go test -short -race ./modules/cooler` ok, 5 top-level PASS, `check-phase19.3.sh plugins` passed, `check-phase19.3.sh docker` RUN and passed (Docker answered for user jin; framework cooler, cabana, attach suites and the FAQ, menu and Journal admin purge suites).
## Notes for the SUMMARY
Deviations:
- [Rule 2 - docs] Task 1 also updated docs/architecture/request-lifecycle.md ("The wrapping order" list gains the CORS profile step), since the list would otherwise be wrong after the change. Same commit as CR-01.
- [Rule 1 - test] Task 2 (WR-05): TestLoadCookieConfigDefaults had production rows with an http and an empty app.url expecting Secure false without error; they now run in development (the production case is an error by design and is covered in TestLoadCookieConfigErrors).
- [Rule 1 - test] Task 4 (WR-01): ExampleSiteCacheFromApp printed the purge from the receiver synchronously; it now calls `cooler.SiteCacheFromApp(app).Wait` after the save, and the `purge` doc comment of the example controller says the purge is queued. docs/services/caching.md's src= copy was refreshed with `summer docs:sync`.
- [Rule 1 - test] Task 4: "a slow receiver is abandoned and the save stays committed" now asserts the save returns within 500 ms (it no longer waits for the 2 s purge) and waits for the queue before reading the log.
Decisions taken while executing:
- CR-01: `Router.wrap` resolves the profile through `routeCORSProfile` (two profiles stay a boot error), skips the `cors:<profile>` entry in its loop after the unknown-profile check, and wraps the recovered handler in `profileMiddleware` just inside `recordPattern`. `bufferedResponse.commit` merges each comma-separated `Vary` value through `addVary`.
- WR-04: anchoring is checked on the parsed expression (`regexp/syntax`): every alternative must begin with `^` and end with `$`, so `^a|b$` is refused as unanchored. Wildcard entries must be `<literal scheme>://*.<rest>` with one `*`. Probe origins: https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com, null.
- WR-05: the explicit-false message stays; the inferred case says "auth.cookie.secure resolves to false in the production environment" and names app.url.
- WR-09: the zero-config Secure is decided before Name and Path are filled; any unset SameSite becomes Lax.
- WR-07: the check sits in `attach.Store` right after the argument checks, before the extension check and before the body is read.
- WR-08: the warning goes through `accessLogger(app)` (the published `*slog.Logger`, else `slog.Default`) with attributes `routes` and `production`.
- WR-01: `installSiteCache` returns the purger in effect, also one a plugin published before Install (the Journal alias keys), so `Handle.Close` drains whichever purger the app uses. Queued sends use a background context bounded by `SiteCachePurgeTimeout`. Log lines add `coalesced` only when above zero. `Purge` tolerates a nil client field (falls back to a no-redirect client).
- Plugin tests: `purgeReceiver` gains a `drain` hook set by `drainFrom(t, app)`; `take` (and so `expectOnePurge` and `expectNone`) waits on `cooler.SiteCacheFromApp(app).Wait` with a 5 s context first. In TestCategoriesAdminFrameworkKeysWin both receivers drain the app's purger.
What the admin shows for a refused protected upload (Task 3, WR-07): cabana's `writeFileError` has no branch for `attach.ErrProtectedNeedsPrivateBucket`, so it reaches the default path: `logFileFailure` logs the error (its message names storage.uploads.public_url) and the client gets the generic opaque 500 error body. cabana is unchanged, as planned.
Acceptance notes:
- The `git log --oneline --grep='(WR-0N)$'` checks of Tasks 2 and 3 print 4 and 5 lines rather than 3: commits of earlier phases (02) also end with `(WR-04)` and `(WR-07)`. The three 19.3-06 commits per task are present.
- `grep -n 'cooler.SiteCachePurger.Wait' modules/cooler/README.md` prints two lines (the Features bullet and the API row); Task 5's cooler README edit rewords the bullet so only the API row names it.

View File

@@ -0,0 +1,256 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
plan: 06
subsystem: infra
tags: [gap-closure, cors, cors-profiles, vary, bouncer, cookies, csp, api-docs, attach, public_url, turnstile, cooler, site-cache, purge, nuxt-layers, gate]
status: complete
requires:
- phase: 19.3-02
provides: "bouncer token cookies and Turnstile, surf CORS hardening, CORS profiles, api-docs"
- phase: 19.3-03
provides: "attach public_url and the S3 build tag"
- phase: 19.3-04
provides: "cooler site-cache purger, plugin purge helpers, the shared layer purge route"
- phase: 19.3-05
provides: "scripts/check-phase19.3.sh fail-closed gate, v0.1.27"
provides:
- "cors:<profile> applied outermost by Router.wrap: every response of a profiled route (guard 401/403, Turnstile 422/503, 429, 413, recovered 500) carries the profile's CORS headers; Vary merged (CR-01)"
- "credentialed CORS refuses wildcard-host and unanchored origins at boot, globally and per profile (WR-04)"
- "production refuses an inferred insecure token cookie; the zero CookieConfig writes SameSite=Lax and Secure (WR-05, WR-09)"
- "nonce-only script-src on /api-docs (WR-06)"
- "attach.ErrProtectedNeedsPrivateBucket: protected uploads refused while storage.uploads.public_url is set, plus a boot warning (WR-07)"
- "boot warning for turnstile routes without a secret (WR-08)"
- "site-cache purge client never follows redirects (WR-02); PurgeSiteCacheAfterCommit queues onto a coalescing per-purger worker; cooler.SiteCachePurger.Wait; Handle.Close drains (WR-01)"
- "siteCache.allowProxied (NUXT_SITE_CACHE_ALLOW_PROXIED) in vue-faq-layer 0.3.0, vue-menu-layer 0.3.0, vue-journal-layer 0.5.0 (local commits, untagged) (WR-03)"
- "11 new unit tests and 2 layer specs required by name in scripts/check-phase19.3.sh"
- "19.3-REVIEW-DISPOSITION.md: CR-01 and WR-01..WR-09 fixed, open 11 (IN-01..IN-11)"
affects: [phase 19.3 main session (docs update, v0.1.28 release), /msd-verify-work 19.3, sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer, vue-journal-layer, app layer pins]
actuals:
tokens: 49100
tasks: 6
commits: 14
plan_head_before: e9ad640999fee13dc5be8cd0cd96205e2f2d7cad
plan_head_after: 0c8109c6702b75beb62ba627b19d66223120c029
tech-stack:
added: []
patterns:
- "Route-level middleware that must decorate every response (CORS profile) is wrapped outside recovery, body limit and locale, not placed by its position in the middleware list"
- "Credentialed origin rules checked on the parsed regexp (regexp/syntax) plus probe origins outside the configured domains"
- "After-commit side effects only enqueue; a per-purger worker keeps one request in flight and coalesces the rest; Wait and Close drain"
key-files:
created: []
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/bouncer/cookie.go
- modules/bouncer/cookie_test.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/bucket.go
- modules/lagoon/attach/url_test.go
- modules/cooler/sitecache.go
- modules/cooler/cooler.go
- modules/cooler/sitecache_test.go
- modules/cooler/example_test.go
- scripts/check-phase19.3.sh
- modules/surf/README.md
- modules/bouncer/README.md
- modules/lagoon/README.md
- modules/cooler/README.md
- docs/services/routing.md
- docs/services/authentication.md
- docs/services/storage.md
- docs/services/caching.md
- docs/architecture/request-lifecycle.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
- ../vue-{faq,menu,journal}-layer/server/lib/siteCachePurge.ts
- ../vue-{faq,menu,journal}-layer/server/api/__site__/cache/purge.post.ts
- ../vue-{faq,menu,journal}-layer/tests/sitePurge.test.ts
- ../vue-{faq,menu,journal}-layer/nuxt.config.ts
- ../vue-{faq,menu,journal}-layer/README.md
- ../vue-{faq,menu,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
key-decisions:
- "Release of v0.1.28, the plugin require bumps and the layer tags handed off to the phase 19.3 main session (human reply at Task 7); this plan pushed and tagged nothing"
- "CR-01: Router.wrap resolves the profile via routeCORSProfile, skips the cors:<profile> entry in its loop and wraps the recovered handler in profileMiddleware just inside recordPattern; bufferedResponse.commit merges Vary through addVary"
- "WR-03: explicit opt-in (siteCache.allowProxied) instead of restricting purge_url to loopback, which would refuse https alias URLs Journal deployments may carry"
- "WR-01: installSiteCache returns the purger in effect (including one a plugin published before Install), so Handle.Close drains whichever purger the app uses"
patterns-established:
- "Outermost route decoration: a CORS profile is applied around recovery, so refusals and panics carry its headers"
- "Queued after-commit purges with coalescing and an explicit Wait for tests and shutdown"
requirements-completed: [SC-1, SC-3, SC-4, D-03, D-04, D-05, D-10, D-11, D-12, D-17, D-18]
duration: 40min
completed: 2026-10-15
---
# Phase 19.3 Plan 06: Review gap closure (CR-01, WR-01..WR-09) Summary
**cors:&lt;profile&gt; now decorates every response of a profiled route including guard, Turnstile, throttle, body-limit and panic refusals; credentialed CORS, cookie Secure, api-docs CSP, protected uploads and Turnstile secrets are checked at boot; site-cache purges are redirect-free, queued and coalesced, and the layers accept proxied purges on an explicit allowProxied opt-in. Release handed off to the phase 19.3 main session.**
## Performance
- **Duration:** about 40 min of execution (two executor contexts, split after Task 4)
- **Started:** 2026-10-15T20:20:38Z
- **Last task commit:** 2026-10-15T20:51:13Z
- **Completed:** 2026-10-15T21:00Z (SUMMARY)
- **Tasks:** 6 of 7 executed; Task 7 (release checkpoint) handed off
- **Files modified:** 24 in summercms.go (excluding .planning), 4 test files in three plugins, 6 files in each of three layers
## Accomplishments
- **CR-01 (verification gap closed):** a cross-origin SPA reads every refusal of a `cors:<profile>` route: 401/403 from a guard listed before the profile or set on the group, Turnstile 422/503, 429, 413 and a recovered 500 in JSON and raw groups all carry Allow-Origin, Allow-Credentials and `Vary: Origin`; a disallowed origin gets `Vary: Origin` only; a handler's own `Vary` is merged.
- **WR-04:** with `supports_credentials`, wildcard entries other than a whole leftmost host label, unanchored patterns and patterns matching probe origins outside the configured domains fail boot naming key and index, globally and per profile. Non-credentialed configs unchanged (`TestCORSOptionsMatchesLaravel` green).
- **WR-05 / WR-09:** production refuses a token cookie whose Secure resolves to false, set or inferred from an http/unset `app.url`; zero `CookieConfig` writes `token`, `Path=/`, HttpOnly, SameSite=Lax, Secure.
- **WR-06:** `/api-docs` sends `script-src 'nonce-…'` as the only source; every script tag carries the nonce; SRI unchanged.
- **WR-07:** `attach.Store` returns `attach.ErrProtectedNeedsPrivateBucket` before any blob is written when `is_public` is false and `storage.uploads.public_url` is set; `OpenBucket` warns at boot.
- **WR-08:** `BuildRouter` logs one warning naming the route count when turnstile routes exist and the secret is empty.
- **WR-02 / WR-01:** the purge client never follows a redirect; `PurgeSiteCacheAfterCommit` only enqueues after commit; one request in flight per purger, saves committed meanwhile collapse into one follow-up (bulk delete of N rows sends at most two); `cooler.SiteCachePurger.Wait`; `Handle.Close` drains. Exported cooler purge names, Journal aliases and the FAQ, menu and Journal controllers unchanged.
- **WR-03:** the shared layer purge route honours `siteCache.allowProxied` / `NUXT_SITE_CACHE_ALLOW_PROXIED`; the three shared files are byte-identical (sha256 `0ea9f0f9…193b260e`) with updated hash pins; `docs/services/caching.md` pairs an https `purge_url` on another host with the flag.
- **Gate:** 11 Go tests and 2 layer specs added to the required lists; the whole gate passes with Docker.
## Task Commits
| Task | Name | Commit (repository, hash) |
|------|------|---------------------------|
| 1 | Tracer: cors:&lt;profile&gt; headers on every response of the route (CR-01) | summercms.go `0723536` (fix) |
| 2 | Credentialed CORS refuses wildcard-host and unanchored origins (WR-04) | summercms.go `e32940d` (fix) |
| 2 | Production refuses an inferred insecure token cookie (WR-05) | summercms.go `4dae833` (fix) |
| 2 | Zero CookieConfig writes SameSite=Lax and Secure (WR-09) | summercms.go `85d8b6a` (fix) |
| 3 | api-docs CSP allows only nonce-carrying scripts (WR-06) | summercms.go `f705343` (fix) |
| 3 | Refuse protected uploads into a public_url bucket (WR-07) | summercms.go `caa5b5d` (fix) |
| 3 | Warn at boot when turnstile routes have no secret (WR-08) | summercms.go `d0a580c` (fix) |
| 4 | Site-cache purge never follows redirects (WR-02) | summercms.go `50a584c` (fix) |
| 4 | Queue site-cache purges after commit and coalesce them (WR-01) | summercms.go `db6a7f5` (fix) |
| 4 | Plugin tests wait for the queued purge (WR-01) | sm-faq-plugin `e52b286`, sm-menu-plugin `4b42978`, sm-journal-plugin `4f633f0` (test) |
| 4 | Progress record after Task 4 | summercms.go `3bb6d61` (docs) |
| 5 | Purge route accepts proxied requests on siteCache.allowProxied (WR-03) | vue-faq-layer `a229f6a` (0.3.0), vue-menu-layer `c4639c2` (0.3.0), vue-journal-layer `f5d7aac` (0.5.0) (feat) |
| 5 | Pair a remote purge URL with the site's allowProxied (WR-03) | summercms.go `4d4e379` (docs) |
| 6 | Unit tests for the review fixes | summercms.go `8d80580` (test) |
| 6 | Gate requires the review-fix tests | summercms.go `b2f4044` (test) |
| 6 | Review disposition and validation map after the gap closure | summercms.go `0c8109c` (docs) |
| 7 | Release gate | handed off, no commit (see below) |
summercms.go commits measured from the ledger: `git rev-list --count e9ad640..0c8109c` = 14 (12 code/docs plus 2 planning-docs commits).
## Task 7: handed off
The release checkpoint was reached with every pre-check green (see Verification). The human's reply, verbatim:
> "ok, differently. there is main agent that was doing 19.3 phase and he was waiting for gap closure to update /docs and make a release then. in other words, you can finalize your gap closure, write SUMMARY.md and leave him the rest - he will update docs for whole phase 19.3, create a tag and do a release."
Outcome: none of the three options (release / tag-only / hold) was executed by this plan. Proposed tag at the checkpoint: **v0.1.28**. Nothing was pushed or tagged in any repository; no plugin `go.mod` was changed.
## Handoff to the phase 19.3 main session
State at handoff:
- summercms.go `master` at `0c8109c` plus this plan's SUMMARY and tracking commits, 20+ commits ahead of `origin/master` (includes planning commits since v0.1.27). Highest tag v0.1.27; v0.1.28 free on origin.
- sm-faq-plugin, sm-menu-plugin, sm-journal-plugin: each 1 commit ahead of `origin/main` (the WR-01 test commits above).
- vue-faq-layer, vue-menu-layer, vue-journal-layer: each 1 commit ahead of `origin/main`; v0.3.0 / v0.3.0 / v0.5.0 free on origin.
What remains, in order:
1. Whole-phase docs update for 19.3 (`docs/`); this plan's docs changes are already committed (routing, authentication, storage, caching, request-lifecycle, the four module READMEs).
2. Annotated tag `v0.1.28` on summercms.go `master`; push `master`, then the tag.
3. In each plugin (sm-faq-plugin, sm-menu-plugin, sm-journal-plugin): `GOWORK=off go get git.golem15.com/golem15/summercms@v0.1.28`, `GOWORK=off go mod tidy`, `GOWORK=off go vet ./...`, `GOWORK=off go test -count=1 ./...`; commit go.mod and go.sum as `chore: require summercms v0.1.28` (plan wording: `chore(19.3-06): require summercms v0.1.28`); push main. Required: the plugin tests now call `cooler.SiteCachePurger.Wait`, so the plugins do not build on origin without go.work until this bump.
4. Push the three layer mains, then create and push annotated tags v0.3.0 (vue-faq-layer), v0.3.0 (vue-menu-layer), v0.5.0 (vue-journal-layer).
5. Follow-up, out of scope here: app layer pins (sites get `siteCache.allowProxied` only after their pins move) and plugin submodule pointer bumps in apps.
6. g15office phase comment (skipped here; to be posted with the release).
## Files Created/Modified
- `modules/surf/router.go`, `modules/surf/cors.go`: profile applied outermost, Vary merge, credentialed origin rules with probe origins, Turnstile boot warning.
- `modules/surf/apidocs.go`: nonce on every script tag, nonce-only script-src.
- `modules/bouncer/cookie.go`: production Secure rule, zero-config defaults.
- `modules/lagoon/attach/store.go`, `bucket.go`: `ErrProtectedNeedsPrivateBucket`, boot warning.
- `modules/cooler/sitecache.go`, `cooler.go`: no-redirect client, queue and coalescing worker, `Wait`, drain on `Handle.Close`.
- `scripts/check-phase19.3.sh`: CR-01 tests in SURF_TESTS, plan-06 tests in BOUNCER/SURF/ATTACH/COOLER/DOCKER_COOLER lists and LAYER_REQUIRE.
- Module READMEs and `docs/services/{routing,authentication,storage,caching}.md`, `docs/architecture/request-lifecycle.md`.
- Plugin `site_cache_admin_test.go` (three plugins) and `sm-journal-plugin/classes/site_cache_test.go`: wait for the queued purge.
- Layers: shared `siteCachePurge.ts` and `purge.post.ts`, specs with new hash pins, `nuxt.config.ts`, README, version bump.
## Decisions Made
- CR-01: `Router.wrap` resolves the profile through `routeCORSProfile` (two profiles stay a boot error), skips the `cors:<profile>` entry after the unknown-profile check and wraps the recovered handler in `profileMiddleware` just inside `recordPattern`; `bufferedResponse.commit` merges each comma-separated `Vary` value through `addVary`.
- WR-04: anchoring checked on the parsed expression (`regexp/syntax`): every alternative must begin with `^` and end with `$`, so `^a|b$` is refused. Wildcards must be `<literal scheme>://*.<rest>` with one `*`. Probe origins: https://cors-probe.invalid, http://cors-probe.invalid, https://cors-probe.com, null.
- WR-05: the explicit-false message stays; the inferred case says "auth.cookie.secure resolves to false in the production environment" and names app.url.
- WR-09: zero-config Secure is decided before Name and Path are filled; any unset SameSite becomes Lax.
- WR-07: the check sits in `attach.Store` right after the argument checks, before the extension check and before the body is read. In the admin, cabana's `writeFileError` has no branch for the new error, so it reaches the default path: `logFileFailure` logs the error (its message names `storage.uploads.public_url`) and the client gets the generic opaque 500. cabana unchanged, as planned.
- WR-08: the warning goes through `accessLogger(app)` with attributes `routes` and `production`.
- WR-01: `installSiteCache` returns the purger in effect, also one a plugin published before Install (Journal alias keys). Queued sends use a background context bounded by `SiteCachePurgeTimeout`. Log lines add `coalesced` only when above zero. `Purge` tolerates a nil client field.
- WR-03: explicit opt-in, default stays "refuse proxied"; accepts `true` or the string `"true"` from runtimeConfig.
- Plugin tests: `purgeReceiver` gains a `drain` hook set by `drainFrom(t, app)`; `take` (so `expectOnePurge` and `expectNone`) waits on `cooler.SiteCacheFromApp(app).Wait` with a 5 s context first. In TestCategoriesAdminFrameworkKeysWin both receivers drain the app's purger.
- Task 7: release handed off to the phase 19.3 main session.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 2 - Docs] request-lifecycle wrapping order**
- **Found during:** Task 1
- **Issue:** `docs/architecture/request-lifecycle.md` "The wrapping order" list would be wrong after CR-01.
- **Fix:** the list gains the CORS profile step.
- **Commit:** `0723536`
**2. [Rule 1 - Test] Production cookie rows moved to development**
- **Found during:** Task 2 (WR-05)
- **Issue:** `TestLoadCookieConfigDefaults` had production rows with http and empty `app.url` expecting Secure false without error, now an error by design.
- **Fix:** those rows run in development; the production case is covered by `TestLoadCookieConfigErrors` and `TestLoadCookieConfigProductionInferredSecure`.
- **Commit:** `4dae833`
**3. [Rule 1 - Test] ExampleSiteCacheFromApp waits for the queued purge**
- **Found during:** Task 4 (WR-01)
- **Issue:** the example printed the purge synchronously from the receiver.
- **Fix:** it calls `cooler.SiteCacheFromApp(app).Wait` after the save; the example controller's `purge` comment says the purge is queued; `docs/services/caching.md` src= copy refreshed with `summer docs:sync`.
- **Commit:** `db6a7f5`
**4. [Rule 1 - Test] Slow-receiver subtest asserts the save is not blocked**
- **Found during:** Task 4
- **Issue:** "a slow receiver is abandoned and the save stays committed" waited for the 2 s purge.
- **Fix:** asserts the save returns within 500 ms and waits for the queue before reading the log.
- **Commit:** `db6a7f5`
**5. Task 7 not executed as specified**
- The checkpoint answer was none of release / tag-only / hold; the release moved to the phase 19.3 main session (see Handoff).
**Total deviations:** 4 auto-fixed (1 docs, 3 test), 1 scope handoff. No CLAUDE.md-driven adjustments.
## Verification
- Tasks 1-4: per-task verify PASS (details in 19.3-06-PROGRESS.md): vet in both tag modes, surf/bouncer/lagoon/cooler short suites, `go test -short -race ./modules/cooler`, `check-phase19.3.sh go`, `plugins` and `docker` (RUN). Both CR-01 tests failed against the old router.go. Tracer gate: automated verify re-run green before expansion.
- Task 5: `check-phase19.3.sh layers` passed, both new sitePurge specs `ok` in each layer, shared files byte-identical, TestDocsTree and `summer docs:build --check` ok.
- Task 6: `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` passed; TestDocsTree and `examples/hello` tests passed.
- **Fail-closed proof (Task 6):** renaming `TestZeroCookieConfigDefaults` locally made `bash scripts/check-phase19.3.sh go` exit 1 ("refuse: required tests did not pass: TestZeroCookieConfigDefaults"); reverted, never committed.
- Task 7 pre-checks: `bash scripts/check-phase19.3.sh --all` passed all stages including docker (RUN); TestDocsTree and examples/hello passed; v0.1.28 free on origin; v0.3.0 / v0.3.0 / v0.5.0 free on the layer origins.
Acceptance quirks:
- `git log --oneline --grep='(WR-04)$'` and `(WR-07)$` print more than three lines: phase-02 commits end with the same suffixes. The three 19.3-06 commits per task are present.
- The cooler README Features bullet was reworded in `4d4e379` so `cooler.SiteCachePurger.Wait` appears only in the API row.
## Issues Encountered
None blocking. Info findings IN-01..IN-11 left open, as planned.
## User Setup Required
Sites whose backend reaches the purge route through a TLS-terminating proxy set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` after moving their layer pin (follow-up, see Handoff).
## Next Phase Readiness
Gap closure complete in code, docs and tests. The phase 19.3 main session finishes the whole-phase docs update and the release (Handoff, steps 1-6), then `/msd-verify-work 19.3`.
## Self-Check: PASSED
All 14 summercms.go commits, the 3 plugin commits and the 3 layer commits verified with `git cat-file -e`; plan_head_before/after measured from the ledger base e9ad640.

View File

@@ -0,0 +1,123 @@
---
phase: 19.3
review: 19.3-REVIEW.md
titles: json
findings:
- id: CR-01
severity: critical
disposition: fixed
title: "`cors:<profile>` headers are missing from every response produced outside the profile middleware (401, 403, 413, 422, 429, 500)"
- id: WR-01
severity: warning
disposition: fixed
title: "The site-cache purge runs synchronously in the admin request, and bulk delete multiplies it"
- id: WR-02
severity: warning
disposition: fixed
title: "The purge client follows redirects and forwards the bearer token, including https-to-http on the same host"
- id: WR-03
severity: warning
disposition: fixed
title: "The shipped purge route refuses every proxied request, so the https/remote mode the Go side advertises cannot work"
- id: WR-04
severity: warning
disposition: fixed
title: "Credentialed CORS validation only refuses a bare `*`; a wildcard entry or an unanchored pattern still trusts arbitrary origins with credentials"
- id: WR-05
severity: warning
disposition: fixed
title: "In production, the cookie's Secure flag is inferred false without an error when `app.url` is http or unset"
- id: WR-06
severity: warning
disposition: fixed
title: "The api-docs CSP allowlists all of jsDelivr for scripts, which cancels the nonce"
- id: WR-07
severity: warning
disposition: fixed
title: "`storage.uploads.public_url` puts protected (`is_public=false`) files in a publicly served bucket"
- id: WR-08
severity: warning
disposition: fixed
title: "`turnstile:open` without a secret switches off bot protection in production, with nothing logged"
- id: WR-09
severity: warning
disposition: fixed
title: "The zero `CookieConfig` does not give the documented default attributes (no SameSite, no Secure)"
- id: IN-01
severity: info
disposition: open
title: "The cache prefix has no enforced separator, so one application's Flush can reach another's keys"
- id: IN-02
severity: info
disposition: open
title: "The purge token is not trimmed although the URL is"
- id: IN-03
severity: info
disposition: open
title: "The purge and the Journal alias wiring log through `slog.Default()` instead of the app logger"
- id: IN-04
severity: info
disposition: open
title: "`SiteCacheFromApp` logs an Error on every call when the configuration is invalid"
- id: IN-05
severity: info
disposition: open
title: "The Turnstile middleware buffers the whole body even for content types it never parses"
- id: IN-06
severity: info
disposition: open
title: "Each preflight route copies the constraints of the first route on its path only"
- id: IN-07
severity: info
disposition: open
title: "The `DiskExtension` script-capable denylist is incomplete"
- id: IN-08
severity: info
disposition: open
title: "The cookie refresh and logout handlers do not check `Origin`"
- id: IN-09
severity: info
disposition: open
title: "Timing-sensitive cooler unit tests may flake on loaded CI"
- id: IN-10
severity: info
disposition: open
title: "The `purgeSiteCache` helper is copied across the FAQ and menu plugins, with a third variant in the Journal"
- id: IN-11
severity: info
disposition: open
title: "A published `*bouncer.Turnstile` silently overrides `turnstile.secret`"
open: 11
total: 21
recorded: 2026-10-15T18:18:43.865Z
---
# Phase 19.3: Code Review Disposition
| Finding | Severity | Disposition | Source |
|---------|----------|-------------|--------|
| CR-01 | critical | fixed | summercms.go 0723536 (19.3-06) |
| WR-01 | warning | fixed | summercms.go db6a7f5; sm-faq-plugin e52b286, sm-menu-plugin 4b42978, sm-journal-plugin 4f633f0 (19.3-06) |
| WR-02 | warning | fixed | summercms.go 50a584c (19.3-06) |
| WR-03 | warning | fixed | vue-faq-layer a229f6a, vue-menu-layer c4639c2, vue-journal-layer f5d7aac; summercms.go 4d4e379 (19.3-06) |
| WR-04 | warning | fixed | summercms.go e32940d (19.3-06) |
| WR-05 | warning | fixed | summercms.go 4dae833 (19.3-06) |
| WR-06 | warning | fixed | summercms.go f705343 (19.3-06) |
| WR-07 | warning | fixed | summercms.go caa5b5d (19.3-06) |
| WR-08 | warning | fixed | summercms.go d0a580c (19.3-06) |
| WR-09 | warning | fixed | summercms.go 85d8b6a (19.3-06) |
| IN-01 | info | open | - |
| IN-02 | info | open | - |
| IN-03 | info | open | - |
| IN-04 | info | open | - |
| IN-05 | info | open | - |
| IN-06 | info | open | - |
| IN-07 | info | open | - |
| IN-08 | info | open | - |
| IN-09 | info | open | - |
| IN-10 | info | open | - |
| IN-11 | info | open | - |
Dispositions: `open` (recorded, not yet triaged), `fixed`, `skipped`, `deferred`.
Set `deferred` by hand and put the reason in the Source cell; both are preserved. A `|` in the reason is kept as prose and escaped on the next run.
Re-running the gate keeps every row it can. A row the current review no longer reports is kept and its Source cell flagged, so a finding does not leave this record silently. ONE exception: when a finding id is REUSED by a different finding, the earlier decision cannot keep a row — the id is taken — and it is dropped. A RECORDED decision (anything but `open`) is named on the console when that happens; a row still at `open` is replaced silently, because `open` records no decision to lose.

View File

@@ -0,0 +1,254 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
reviewed: 2026-10-15T18:16:47Z
depth: standard
files_reviewed: 35
files_reviewed_list:
- go.mod
- internal/build/build.go
- internal/build/manifest.go
- modules/bouncer/cookie.go
- modules/bouncer/turnstile.go
- modules/cabana/reorder.go
- modules/cooler/cooler.go
- modules/cooler/memory.go
- modules/cooler/ratelimit.go
- modules/cooler/redis.go
- modules/cooler/rediscache.go
- modules/cooler/sitecache.go
- modules/lagoon/attach/bucket.go
- modules/lagoon/attach/diskext.go
- modules/lagoon/attach/s3_off.go
- modules/lagoon/attach/s3_on.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/thumb.go
- modules/pact/capabilities.go
- modules/surf/apidocs.go
- modules/surf/cors.go
- modules/surf/router.go
- modules/surf/serve.go
- scripts/check-phase19.3.sh
- modules/cooler/memory_test.go
- modules/cooler/install_test.go
- modules/cooler/ratelimit_test.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/controllers/site_cache.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/controllers/faqs.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/site_cache.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/menus.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/menuentries.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/admin_registry.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/classes/site_cache.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/controllers/site_cache.go
- /media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/plugin.go
- /media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/server/lib/siteCachePurge.ts
- /media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/server/api/__site__/cache/purge.post.ts
findings:
critical: 1
warning: 9
info: 11
total: 21
status: issues_found
---
# Phase 19.3: Code Review Report
**Reviewed:** 2026-10-15T18:16:47Z
**Depth:** standard
**Files Reviewed:** 35 (framework sources, the plugins' purge wiring, the shared Nuxt purge route; tests got a lighter pass)
**Status:** issues_found
## Narrative Findings (AI reviewer)
## Summary
The phase is careful in most of the places the brief asked about. The Redis Lua script is atomic and matches `surf.MemoryStore`: a missing key or one without a TTL opens the window, a denied attempt does not increment, and INCR keeps the TTL. Keys are namespaced `<prefix>rl:` / `<prefix>cache:`, and Flush escapes SCAN glob characters. The S3 driver is linked only under `summercms_s3`, and an untagged binary refuses `s3://` at boot. Build tags are validated by regex and passed as a single exec argument. The api-docs template escapes the title and spec URL, and the assets carry SRI. The purge token is compared in constant time over sha256 digests. Every cabana write path that reaches the plugins' hooks (`crud.go:134`, `reorder.go:319`) now runs in `lagoon.Transaction`, so after-commit semantics hold.
The defects are at the edges between modules:
- The `cors:<profile>` middleware runs in declaration order. Responses from the guard, throttle, Turnstile, body limit or panic recovery therefore carry no CORS headers, and the browser cannot read them (CR-01).
- The site-cache purge runs synchronously inside the admin request after commit, and bulk deletes repeat it per row.
- The purge client follows redirects with the bearer token attached.
- The shipped Nuxt route returns 404 to every proxied request, so the https mode that the Go side advertises cannot be used.
- Several hardening gaps remain in credentialed CORS validation, cookie Secure inference, the api-docs CSP and Turnstile misconfiguration visibility.
Already logged in `deferred-items.md` and not counted again: S3 object metadata keeps the sniffed `text/html` / `text/javascript` content type; Redis outage latency in `Cache.Remember` plus go-redis stderr logging; the unreachable "in transaction but no purger" plugin branch; the gofmt and consumer-name items.
## Critical Issues
### CR-01: `cors:<profile>` headers are missing from every response produced outside the profile middleware (401, 403, 413, 422, 429, 500)
**File:** `modules/surf/router.go:416-466`, `modules/surf/cors.go:255-262`
**Issue:** `wrap` composes route middleware so that `rt.middleware[0]` is outermost (`for i := len-1; i >= 0; i-- { h = mw(h) }`). It then puts `bodyLimit`, `r.locale` and `recoverJSON`/`recoverBare` outside all of them. `profileMiddleware` only sets `Access-Control-Allow-Origin` when the request reaches it. With the ordering the docs suggest (`docs/services/routing.md:265`: "lists `cors:spa` among its middleware names, next to its guard"), a route such as `[]string{"auth", "throttle:60,1", "turnstile:open", "cors:spa"}` answers:
- a guard refusal (401, expired token),
- a throttle denial (429),
- a Turnstile refusal (422/503),
- a body-limit trip (413, always outside), and
- a recovered panic (500, always outside)
all without ACAO, ACAC or `Vary: Origin`. The browser turns each of these into an opaque network error. A credentialed SPA, which is the use case profiles exist for, can no longer see the 401 that should trigger its cookie refresh, or the 422 that should re-show the Turnstile widget. The global `pathScopedCORS` wrapper does not have this problem because it runs before the mux.
**Fix:** Apply the profile outside every other route layer, whatever position it has in the list. In `wrap`, take the `cors:` entry out of the loop and wrap it last, after `recoverJSON` and before `recordPattern`:
```go
profile, _ := routeCORSProfile(rt) // already validated in addCORSPreflights
// ... loop skips names with base "cors" ...
if rt.raw { h = recoverBare(h) } else { h = recoverJSON(h) }
if profile != "" {
h = r.corsProfiles[profile].profileMiddleware(h)
}
return recordPattern(rt.method+" "+rt.path, h), nil
```
Add a test that sends a cross-origin request to a `cors:spa` route behind a refusing guard and asserts that the 401 carries `Access-Control-Allow-Origin`.
## Warnings
### WR-01: The site-cache purge runs synchronously in the admin request, and bulk delete multiplies it
**File:** `modules/cooler/sitecache.go:123-195`, `modules/lagoon/transaction.go:86,194-205`, `modules/cabana/crud.go:1025-1040`
**Issue:** `lagoon.Transaction` runs after-commit callbacks inline (`runAfterCommit` after `gdb.Transaction` returns). `PurgeSiteCacheAfterCommit` registers `p.Purge`, which is a blocking HTTP POST with a 2 s bound. Every admin save therefore waits for the Nuxt site. When the site is down or slow, each save takes up to 2 s longer. `BulkDelete` calls `deleteRecord` per row, and the FAQ, menu and menu-entry hooks each register a purge, so deleting N rows sends N sequential full-cache purges after commit. With N=50 and a hung site that is about 100 s, which is past typical server write timeouts. The client then sees a failed request for a delete that has committed. Each purge also wipes the whole `nitro:routes` cache (`siteCachePurge.ts:62-71`), so after the first purge the remaining N-1 do nothing.
**Fix:** Coalesce per transaction and send asynchronously. One option is a purger method that marks "purge pending" in the after-commit buffer once per transaction (dedupe on the `*SiteCachePurger`). Another is to make `Purge` enqueue onto a single background worker with a small debounce, for example:
```go
func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
if p == nil { return }
select { case p.queue <- siteCachePurgeBody{kind, slug}: default: /* one already pending */ }
}
```
Document that the purge is fire-and-forget.
### WR-02: The purge client follows redirects and forwards the bearer token, including https-to-http on the same host
**File:** `modules/cooler/sitecache.go:93,148`
**Issue:** `&http.Client{}` uses the default redirect policy. Go forwards `Authorization` on a redirect when the destination hostname equals the original or is a subdomain of it, and it ignores the scheme and port. A purge URL `https://site.example.com/api/__site__/cache/purge` that answers 301/302/307/308 to `http://site.example.com/...` (a common proxy misconfiguration, or a trailing-slash redirect behind a TLS terminator) therefore sends the token in clear. `NewSiteCachePurgerFor` (lines 87-89) exists to prevent exactly that. A 301/302 also turns the POST into a GET, so the purge silently does nothing.
**Fix:**
```go
client: &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }},
```
A 3xx then logs as `site cache purge failed status=30x`.
### WR-03: The shipped purge route refuses every proxied request, so the https/remote mode the Go side advertises cannot work
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/vue-faq-layer/server/api/__site__/cache/purge.post.ts:109-121`, `modules/cooler/sitecache.go:60-63,87-89`
**Issue:** On the Go side, `site_cache.purge_url` accepts any https host ("use https for any other host"). The route that all three layers ship returns 404 whenever `X-Forwarded-For`, `X-Real-IP` or `Forwarded` is present. A remote https endpoint is reached through a TLS-terminating proxy almost by definition, and nginx, Caddy and Traefik add those headers by default. The configuration therefore passes boot validation and fails every save with `status=404`. Operators get a warning per save and no explanation. The two halves of one feature disagree about the threat model.
**Fix:** Pick one model. Either restrict `purge_url` to loopback hosts in `NewSiteCachePurgerFor` (drop the https/remote allowance and its docs), or let the route accept proxied requests when an explicit runtime flag is set (for example `siteCache.allowProxied`), relying on the bearer token alone, and document the pairing in `docs/services/caching.md`.
### WR-04: Credentialed CORS validation only refuses a bare `*`; a wildcard entry or an unanchored pattern still trusts arbitrary origins with credentials
**File:** `modules/surf/cors.go:45-58,79-88,120-137`
**Issue:** `validateCORSConfig` rejects `supports_credentials: true` only when `allowed_origins` contains exactly `"*"`. These still pass:
- `allowed_origins: ["https://*"]` or `["*://*"]`, which `wildcardOriginPattern` turns into `^https://.*$` and which trusts every https origin with credentials;
- `allowed_origins_patterns: ["https://.*\\.example\\.com"]` (delimiters stripped, as the error message tells PHP users to do, but without `^`/`$`), which `MatchString` treats as a substring match, so `https://x.example.com.attacker.net` is reflected with `Access-Control-Allow-Credentials: true`.
The phase made "credentials with `*` is a boot error" a deliberate hardening step (02-SUMMARY), and these are the same exposure under a different spelling.
**Fix:** When `SupportsCredentials` is true:
- require every `allowed_origins_patterns` entry to start with `^` and end with `$` (or anchor them yourself with `^(?:` + p + `)$` in `newCORSPolicy`);
- refuse wildcard origins whose host part is only `*` (for example, require at least one literal label after the `*`, as in `https://*.example.com`).
Add these cases to `TestCORSCredentialsWildcardRefused`.
### WR-05: In production, the cookie's Secure flag is inferred false without an error when `app.url` is http or unset
**File:** `modules/bouncer/cookie.go:100-107,116-121`
**Issue:** The production guard only refuses an *explicit* `auth.cookie.secure: false`. When the key is unset, `cookieSecure` derives Secure from `app.url`. If `app.url` is missing, mistyped or `http://` (common when TLS terminates at a proxy and `APP_URL` was copied from a dev `.env`), production writes the httpOnly auth token cookie without `Secure`, and nothing errors or warns. `SameSite=None` is caught; Lax/Strict are not.
**Fix:** In the production environment, treat an inferred `secure=false` like an explicit one:
```go
if !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
return cfg, errors.New("bouncer: auth.cookie.secure resolves to false in production (app.url is not https); set app.url to https or auth.cookie.secure: true")
}
```
At the least, log a warning naming `app.url`.
### WR-06: The api-docs CSP allowlists all of jsDelivr for scripts, which cancels the nonce
**File:** `modules/surf/apidocs.go:207`
**Issue:** `script-src 'nonce-…' https://cdn.jsdelivr.net` lets any script from jsDelivr execute, and that includes every npm package and every GitHub repo (`/gh/<user>/<repo>`). Any HTML injection on the page (Swagger UI renders `description` markdown from the spec, and Swagger UI has a history of DOM XSS) can load attacker code from jsDelivr without a nonce. The SRI attributes protect only the three tags that carry them. The policy also has no `default-src`, `style-src` or `connect-src`.
**Fix:** Add the nonce to the three external `<script>` tags as well (`<script nonce="{{.Nonce}}" src=… integrity=…>`) and drop the host source: `script-src 'nonce-X'; style-src https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/ 'unsafe-inline'; default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'`. Alternatively, pin the host source to the exact path `https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/`.
### WR-07: `storage.uploads.public_url` puts protected (`is_public=false`) files in a publicly served bucket
**File:** `modules/lagoon/attach/bucket.go:131-155`, `modules/lagoon/attach/store.go:155-172`
**Issue:** With `public_url` set, the bucket is served directly by the CDN/S3 host, and that host "serves any key without the is_public check" (the info log at line 151). `Store` still writes every upload to that one bucket, including those from protected relations (`Relation.Public == false`, which cabana serves through `serveProtectedFile`). The protection then rests only on the 88-bit random key staying secret. Common public-bucket setups that allow `s3:ListBucket`, CDN access logs, and any leak of the disk name expose protected files. The only safeguard is an Info-level log line.
**Fix:** When `public_url` is set, either refuse to store `Public: false` uploads (`ErrProtectedNeedsPrivateBucket`), or write protected files under a key prefix that the operator is told to deny publicly (for example `protected/…`) and check it at boot. At minimum, raise the log line to Warn and state in `docs/services/storage.md` that protected relations must not be used with `public_url`.
### WR-08: `turnstile:open` without a secret switches off bot protection in production, with nothing logged
**File:** `modules/surf/router.go:648-661`, `modules/bouncer/turnstile.go:105-111`
**Issue:** Open mode passes every request when `turnstile.secret` is empty, in every environment. Closed mode passes outside an environment named exactly `production` (so also `staging` and `prod`). This matches PHP, but surf builds the verifier once at boot and never reports that routes declaring `turnstile:*` are unprotected. A missing `SUMMER_TURNSTILE__SECRET` on deploy silently opens registration to bots.
**Fix:** After `wrap` has validated the routes, if any route names `turnstile:open|closed` and `buildTurnstile` returned an empty secret, log one Warn at boot ("turnstile.secret is empty: N routes accept requests without a Turnstile check"). Consider making it a boot error in production for `turnstile:closed`.
### WR-09: The zero `CookieConfig` does not give the documented default attributes (no SameSite, no Secure)
**File:** `modules/bouncer/cookie.go:24-37,262-272`
**Issue:** The field docs say SameSite "is http.SameSiteLaxMode unless auth.cookie.same_site says otherwise", and `withDefaults` says "a zero CookieConfig writes the default token cookie on '/'". `withDefaults` fills only Name and Path. A handler built with `CookieRefreshOptions{Cookie: bouncer.CookieConfig{}}` (an easy mistake in an app that skips `LoadCookieConfig`) writes the auth token cookie with no `SameSite` attribute, which Safari treats as None, and without `Secure`.
**Fix:** In `withDefaults`, set `if c.SameSite == 0 { c.SameSite = http.SameSiteLaxMode }`, and either make `CookieRefreshHandler`/`CookieLogoutHandler` reject a config not produced by `LoadCookieConfig` or document that a zero config is insecure.
## Info
### IN-01: The cache prefix has no enforced separator, so one application's Flush can reach another's keys
**File:** `modules/cooler/cooler.go:335-347`, `modules/cooler/rediscache.go:199-220`
**Issue:** `cache.prefix` is used verbatim. Prefix `acme` gives `acmecache:*`, and that SCAN MATCH also matches an application whose prefix is `acmecache:` (`acmecache:cache:x`). Similarly, `acme:` flushes keys of an application prefixed `acme:cache:`. The default prefix is the slug of `app.name`, so staging and production of one app that share a Redis database also share cache and rate-limit counters, and one Flush clears both.
**Fix:** Append `:` when the configured prefix does not end in one, and mention `cache.prefix` per environment in the caching guide.
### IN-02: The purge token is not trimmed although the URL is
**File:** `modules/cooler/sitecache.go:67-93`
**Issue:** A token read from an env file with a trailing newline or space passes the length check. Go then refuses to send the header (`invalid header field value`), or the Nuxt comparison fails. The result is a warning on every save with no hint at the cause.
**Fix:** `token = strings.TrimSpace(token)` (and the same in the Journal's alias check at `plugin.go:79`).
### IN-03: The purge and the Journal alias wiring log through `slog.Default()` instead of the app logger
**File:** `modules/cooler/sitecache.go:132`, `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/plugin.go:75`
**Issue:** Install and the cache drivers use `loggerFromApp`. `Purge` and `wireSiteCache` do not, so their lines bypass any app-published logger (format, level, destination).
**Fix:** Keep the logger on `SiteCachePurger` (set it in `installSiteCache`/`SiteCacheFromApp`) and use it in `Purge`.
### IN-04: `SiteCacheFromApp` logs an Error on every call when the configuration is invalid
**File:** `modules/cooler/sitecache.go:211-214`
**Issue:** Outside serve (job workers, console commands), each admin-hook call rebuilds, fails and logs again.
**Fix:** Cache the failure, for example by publishing a sentinel, or log through a `warner`.
### IN-05: The Turnstile middleware buffers the whole body even for content types it never parses
**File:** `modules/bouncer/turnstile.go:252-286`
**Issue:** The body is read into memory before the media type is checked. On a route with an upload-sized `body.limit`, a multipart request with no token is buffered in full before it is refused, which is pre-auth memory pressure. Multipart forms are also never searched for the token, so an HTML form with a file input always fails.
**Fix:** Parse `Content-Type` first, read the body only for JSON or urlencoded types, and either support `multipart/form-data` through `r.ParseMultipartForm` or document that it is unsupported.
### IN-06: Each preflight route copies the constraints of the first route on its path only
**File:** `modules/surf/cors.go:320-345`
**Issue:** When several routes share a path pattern but declare different constraints, the OPTIONS route uses the constraints of the first one in `r.routes`, so a valid preflight for another route can get 404. The overlap check against `http.cors.paths` matches the route *pattern* text (`/api/{slug}`) against globs, so a global glob such as `api/posts` that matches real requests to a profiled `/api/{slug}` route is not detected.
**Fix:** Merge constraints (accept when any route's constraints match), and test the global globs against a sample path built from each pattern.
### IN-07: The `DiskExtension` script-capable denylist is incomplete
**File:** `modules/lagoon/attach/diskext.go:39-42`
**Issue:** The list omits extensions that common server setups execute or render: `php3`-`php8`, `phps`, `asp`, `aspx`, `jsp`, `cgi`, `pl`, `py`, `mht`, `mhtml`. The extension allowlist runs first (`store.go:128`), so this matters only when an application widens `Limits.Extensions`, but the denylist is meant to be the backstop for that case.
**Fix:** Add those extensions, or match `^php\d*$|^pht` style families.
### IN-08: The cookie refresh and logout handlers do not check `Origin`
**File:** `modules/bouncer/cookie.go:213-260`
**Issue:** With `same_site: none` (allowed when Secure), a cross-site form POST can rotate or revoke the user's token (logout CSRF). This is low impact but has an easy fix.
**Fix:** Optionally verify `Origin`/`Sec-Fetch-Site` against the CORS profile's allowed origins when `SameSite` is None.
### IN-09: Timing-sensitive cooler unit tests may flake on loaded CI
**File:** `modules/cooler/memory_test.go:142-170`, `modules/cooler/install_test.go:224`
**Issue:** The tests assert that a 40 ms entry is present right after Set, a 60 ms TTL around `Increment`, and that `FromApp` finishes in under 100 ms. These can fail under CPU contention, and the phase gate fails on any test failure.
**Fix:** Use TTLs of at least 1 s with an injected clock, or widen the margins (for example, assert presence with a 1 s TTL and absence after the TTL plus a poll loop).
### IN-10: The `purgeSiteCache` helper is copied across the FAQ and menu plugins, with a third variant in the Journal
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/sm-faq-plugin/controllers/site_cache.go:15-32`, `/media/nvme/dev/golem15/summercms.io/summercms/sm-menu-plugin/controllers/site_cache.go:15-32`, `/media/nvme/dev/golem15/summercms.io/summercms/sm-journal-plugin/controllers/site_cache.go:19-36`
**Issue:** These are the same logic (app nil check, `cabana.TxFromContext`, `SiteCacheFromApp`, `PurgeSiteCacheAfterCommit`) in three places. A fix for WR-01 would need three plugin releases.
**Fix:** Add a framework helper such as `cabana.PurgeSiteCacheOnSave(ctx, app, kind, slug)` (cabana already imports lagoon, and cooler does not import cabana), and have the plugins call it.
### IN-11: A published `*bouncer.Turnstile` silently overrides `turnstile.secret`
**File:** `modules/surf/router.go:655-657`
**Issue:** This seam was added for tests (02-SUMMARY deviation 1). Any plugin can publish a verifier with an empty secret and `Production: false` and switch off Turnstile application-wide, and nothing logs it.
**Fix:** Log at boot when a published verifier replaces a configured secret.
---
_Reviewed: 2026-10-15T18:16:47Z_
_Reviewer: Claude (msd-code-reviewer)_
_Depth: standard_

View File

@@ -0,0 +1,44 @@
---
status: testing
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
source: [19.3-VERIFICATION.md]
started: 2026-10-15T21:11:00.207Z
updated: 2026-10-15T21:11:00.207Z
---
## Current Test
number: 1
name: Cookie refresh across real api/www subdomains in a browser. Deploy to staging with auth.cookie.domain set to the parent domain and cors:<profile> (or http.cors) credentialed for the www origin. Log in on www, wait past the access TTL.
expected: |
The SPA reads the 401, the refresh call sends the cookie, the rotated cookie replaces the old one, the replayed old token answers 401.
awaiting: user response
## Tests
### 1. Cookie refresh across real api/www subdomains in a browser. Deploy to staging with auth.cookie.domain set to the parent domain and cors:<profile> (or http.cors) credentialed for the www origin. Log in on www, wait past the access TTL.
expected: The SPA reads the 401, the refresh call sends the cookie, the rotated cookie replaces the old one, the replayed old token answers 401.
result: [pending]
### 2. S3-compatible upload. Build with build.tags [summercms_s3], set storage bucket_url s3://...?endpoint=..., upload a public file, fetch it via storage.uploads.public_url and via /storage/uploads; try a protected (is_public false) upload with public_url set.
expected: Both URLs serve the public file; the protected upload is refused with attach.ErrProtectedNeedsPrivateBucket (admin answers the opaque 500 and logs the reason).
result: [pending]
### 3. Live Nuxt purge. Save a menu entry and a FAQ entry in admin against a running Nuxt site using the 0.3.0/0.5.0 layers; for a proxied deployment set NUXT_SITE_CACHE_ALLOW_PROXIED=true.
expected: The page updates without waiting for the 300 s SWR TTL; the app log shows 'cooler: site cache purged' after the save returned.
result: [pending]
### 4. Swagger UI under its new nonce-only CSP. Enable http.api_docs, open /api-docs in a browser.
expected: The UI renders the spec with no CSP violations in the console (script-src is now only 'nonce-...').
result: [pending]
## Summary
total: 4
passed: 0
issues: 0
pending: 4
skipped: 0
blocked: 0
## Gaps

View File

@@ -39,7 +39,7 @@ created: "2026-10-15"
## Per-Task Verification Map
Filled by plan 05. Every row is a stage of `scripts/check-phase19.3.sh`, which runs the named tests and fails when one is missing, skipped or red (go test -json and TAP detectors). Statuses from the run of 2026-10-15 on a Docker host.
Filled by plan 05; the plan 06 rows (gap closure, run of 2026-10-15 with `PHASE193_REQUIRE_DOCKER=1 --all` on a Docker host) follow the SC-4 row. Every row is a stage of `scripts/check-phase19.3.sh`, which runs the named tests and fails when one is missing, skipped or red (go test -json and TAP detectors). Statuses from the run of 2026-10-15 on a Docker host.
| Req | Behavior | Test Type | Automated Command | File Exists | Status |
|-----|----------|-----------|-------------------|-------------|--------|
@@ -61,6 +61,16 @@ Filled by plan 05. Every row is a stage of `scripts/check-phase19.3.sh`, which r
| D-10, D-11 | Plugin purge helpers; Journal framework keys win, alias keys deprecated | unit + docker | `bash scripts/check-phase19.3.sh plugins` (`TestPurgeSiteCacheWithoutTx`, `TestPurgeSiteCacheWithoutPurger`, `TestSiteCacheFrameworkKeysWin`, `TestSiteCacheAliasKeysDeprecated`, `TestSiteCacheNoKeys`); `... docker` (`TestFaqAdminPurgesSiteCache`, `TestMenuAdminPurgesSiteCache`, `TestCategoriesAdminPurgesSiteCache`, `TestPostsAdminPurgesSiteCache`, `TestCategoriesAdminFrameworkKeysWin`) | ✅ three plugins | ✅ green |
| D-11 | Nuxt shared purge route (lib and route handler), shared files byte-identical | unit (node) | `bash scripts/check-phase19.3.sh layers` (11 `sitePurge:` names per layer, `cmp` of the three shared files) | ✅ `tests/sitePurge.test.ts` in three layers | ✅ green |
| SC-4 | Docs identifiers and links; no consuming application named in framework lines of the phase | docs | `bash scripts/check-phase19.3.sh docs` | ✅ | ✅ green |
| SC-1, D-05 (CR-01, plan 06) | `cors:<profile>` headers on every response of the route: guard 401 and 403 (route and group), throttle 429, Turnstile 422 and 503, body limit 413, recovered 500 (JSON and raw); disallowed origin gets `Vary: Origin` only; handler `Vary` merged | unit | `bash scripts/check-phase19.3.sh go` (SURF_TESTS: `TestCORSProfileHeadersOnRefusals`, `TestCORSProfileVaryKeptWithHandlerVary`) | ✅ `modules/surf/cors_profile_test.go` | ✅ green |
| D-04 (WR-04, plan 06) | Credentialed CORS: wildcard must be the leftmost host label, patterns anchored, no rule matches the probe origins; global and per profile; non-credentialed configs unchanged | unit | `bash scripts/check-phase19.3.sh go` (SURF_TESTS: `TestCORSCredentialedOriginRules`, `TestCORSAnchoredPatternNoSuffixMatch`, rows in `TestCORSCredentialsWildcardRefused`) | ✅ `modules/surf/cors_profile_test.go` | ✅ green |
| D-03 (WR-05, plan 06) | Production refuses a Secure that resolves to false, set or inferred from app.url | unit | `bash scripts/check-phase19.3.sh go` (BOUNCER_TESTS: `TestLoadCookieConfigProductionInferredSecure`, rows in `TestLoadCookieConfigErrors`) | ✅ `modules/bouncer/cookie_test.go` | ✅ green |
| D-03 (WR-09, plan 06) | Zero `CookieConfig` writes SameSite=Lax and Secure; unset SameSite is Lax | unit | `bash scripts/check-phase19.3.sh go` (BOUNCER_TESTS: `TestZeroCookieConfigDefaults`, `TestTokenCookieAttributes`) | ✅ `modules/bouncer/cookie_test.go` | ✅ green |
| D-17 (WR-06, plan 06) | api-docs `script-src` holds only the per-request nonce; every script tag carries it; SRI unchanged | unit | `bash scripts/check-phase19.3.sh go` (SURF_TESTS: `TestAPIDocsCSPNoHostSource`, `TestAPIDocsServesPinnedPage`) | ✅ `modules/surf/apidocs_test.go` | ✅ green |
| D-12 (WR-07, plan 06) | `attach.Store` refuses a protected upload while `public_url` is set, before any blob is written | unit (both tag modes) | `bash scripts/check-phase19.3.sh go` (ATTACH_TESTS: `TestStoreProtectedNeedsPrivateBucket`) | ✅ `modules/lagoon/attach/url_test.go` | ✅ green |
| D-18 (WR-08, plan 06) | One boot warning with the route count when turnstile routes have no secret; none with a secret or without such routes | unit | `bash scripts/check-phase19.3.sh go` (SURF_TESTS: `TestTurnstileEmptySecretWarnsAtBoot`) | ✅ `modules/surf/cors_profile_test.go` | ✅ green |
| D-10 (WR-02, plan 06) | The purge client never follows a redirect; a 3xx is a failed purge, the token never reaches the Location | unit | `bash scripts/check-phase19.3.sh go` (COOLER_TESTS: `TestSiteCachePurgeNoRedirect`) | ✅ `modules/cooler/sitecache_test.go` | ✅ green |
| D-10, D-11 (WR-01, plan 06) | After-commit purges queued, one in flight per purger, later saves coalesced; Wait and Handle.Close drain; a bulk commit sends at most two; plugin admin tests wait for the queue | unit + docker | `bash scripts/check-phase19.3.sh go` (COOLER_TESTS: `TestSiteCachePurgeAsyncCoalesced`, `TestSiteCachePurgeWaitAndClose`); `... docker` (`TestSiteCacheBulkCommitCoalesced`, the FAQ, menu and Journal admin purge suites) | ✅ `modules/cooler/sitecache_test.go`, three plugins | ✅ green |
| D-11 (WR-03, plan 06) | Shared purge route accepts proxied requests on `siteCache.allowProxied`, refuses them without it; files byte-identical | unit (node) | `bash scripts/check-phase19.3.sh layers` (LAYER_REQUIRE: `sitePurge: allowProxied lets a proxied request reach the token check`, `sitePurge: route accepts a proxied request with allowProxied and the right bearer`) | ✅ `tests/sitePurge.test.ts` in three layers | ✅ green |
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*

View File

@@ -0,0 +1,268 @@
---
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
verified: 2026-10-15T21:08:52Z
status: human_needed
score: 4/4 roadmap success criteria verified; plan must-haves 44/44 verified (36 from plans 01-05, 8 from gap-closure plan 06); 0 present-behavior-unverified
covered_files:
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-PLAN.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-03-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-PLAN.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-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-PLAN.md
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-06-SUMMARY.md
- go.mod
- go.sum
- internal/build/build.go
- internal/build/manifest.go
- modules/bouncer/cookie.go
- modules/bouncer/turnstile.go
- modules/cabana/reorder.go
- modules/cooler/cooler.go
- modules/cooler/memory.go
- modules/cooler/ratelimit.go
- modules/cooler/redis.go
- modules/cooler/rediscache.go
- modules/cooler/sitecache.go
- modules/lagoon/attach/bucket.go
- modules/lagoon/attach/diskext.go
- modules/lagoon/attach/s3_off.go
- modules/lagoon/attach/s3_on.go
- modules/lagoon/attach/store.go
- modules/lagoon/attach/thumb.go
- modules/pact/capabilities.go
- modules/surf/apidocs.go
- modules/surf/cors.go
- modules/surf/router.go
- modules/surf/serve.go
- scripts/check-phase19.3.sh
covered_digest: "v2:sha256:469d289a206fd43b90c3401d9971e57193333d0c2efefa1c88a2356520b1a092"
behavior_unverified: 0
overrides_applied: 0
re_verification:
previous_status: gaps_found
previous_verified: 2026-10-15T20:45:00Z
previous_score: 3/4 roadmap success criteria (SC-1 partial); plan must-haves 35/36 (D-05 failed)
gaps_closed:
- "SC-1 / D-05 (CR-01): cors:<profile> headers now reach every response of a profiled route. Router.wrap skips the cors entry in its middleware loop and wraps the recovered handler in r.corsProfiles[profile].profileMiddleware just inside recordPattern (modules/surf/router.go:483-485, fix commit 0723536). Verifier probe on HEAD c5d9a7b: [deny, cors:spa] 401, group Use(deny) + route cors:spa 401, nested group 401, handler panic 500 (JSON), middleware panic 500, raw-group panic 500, oversize body 413, throttle 429 all carry ACAO=https://www.example.com, ACAC=true, Vary: Origin; disallowed origin gets Vary: Origin only. The same probe against the pre-fix tree (0723536^) fails 7 of 11 cases."
gaps_remaining: []
regressions: []
advisory:
- finding: "WR-04 residual: credentialed allowed_origins https://*.co.uk loads (probe origins catch https://*.com and https://*, not multi-label public suffixes such as co.uk or com.pl)"
category: security
reason: "Raised in this re-verification by a scratch probe of surf.LoadCORSConfig (https://*.co.uk -> no error; https://*.com, https://*, https://*example.com -> error). Plan 06's must-have names probe origins and the https://*.com example, and that contract holds; refusing public-suffix wildcards would need a public-suffix list (a new dependency) or an extra probe set. Operator must write such an entry deliberately. Resolve by adding probes such as https://cors-probe.co.uk / .com.pl, or accept and note in docs/services/routing.md."
evidence_status: "scratch-probe output only; new scope relative to the carried-forward gap, not blocking"
human_verification:
- test: "Cookie refresh across real api/www subdomains in a browser. Deploy to staging with auth.cookie.domain set to the parent domain and cors:<profile> (or http.cors) credentialed for the www origin. Log in on www, wait past the access TTL."
expected: "The SPA reads the 401, the refresh call sends the cookie, the rotated cookie replaces the old one, the replayed old token answers 401."
why_human: "Needs real DNS, TLS and a browser's cookie and CORS enforcement; httptest cannot reproduce SameSite/Domain handling."
- test: "S3-compatible upload. Build with build.tags [summercms_s3], set storage bucket_url s3://...?endpoint=..., upload a public file, fetch it via storage.uploads.public_url and via /storage/uploads; try a protected (is_public false) upload with public_url set."
expected: "Both URLs serve the public file; the protected upload is refused with attach.ErrProtectedNeedsPrivateBucket (admin answers the opaque 500 and logs the reason)."
why_human: "Needs bucket credentials. Note deferred-items.md: the blob's stored Content-Type is the sniffed type."
- test: "Live Nuxt purge. Save a menu entry and a FAQ entry in admin against a running Nuxt site using the 0.3.0/0.5.0 layers; for a proxied deployment set NUXT_SITE_CACHE_ALLOW_PROXIED=true."
expected: "The page updates without waiting for the 300 s SWR TTL; the app log shows 'cooler: site cache purged' after the save returned."
why_human: "Needs a running Nuxt site and the app; purges are now asynchronous."
- test: "Swagger UI under its new nonce-only CSP. Enable http.api_docs, open /api-docs in a browser."
expected: "The UI renders the spec with no CSP violations in the console (script-src is now only 'nonce-...')."
why_human: "Browser rendering; plan 06 (WR-06) tightened script-src, and whether the pinned swagger-ui-bundle 5.33.1 needs anything beyond nonce-tagged scripts is only observable in a browser. SRI values were checked automatically in the initial verification."
---
# Phase 19.3: Framework transport and infrastructure for WavePath Verification Report
**Phase Goal:** 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.
**Mode:** mvp (ROADMAP marks the phase `mode: mvp`, but the goal is not written as a user story; as in the initial report, this report uses standard goal-backward verification with a derived User Flow Coverage table. Run `/msd mvp-phase 19.3` to restate the goal, or drop `mode: mvp`, to make the mode consistent.)
**Verified:** 2026-10-15T21:08:52Z (HEAD `c5d9a7b`, local master, not yet tagged or pushed: expected, the release follows verification)
**Status:** human_needed
**Re-verification:** Yes, after gap-closure plan 19.3-06
## Re-verification Summary
| Item | Previous (2026-10-15T20:45Z) | Now | Evidence |
|------|------------------------------|-----|----------|
| SC-1 / D-05, CR-01: `cors:<profile>` headers on refusals, 413, 500 | ✗ FAILED (partial) | ✓ VERIFIED | Verifier probe (below) on HEAD: 11/11 PASS. Same probe on the pre-fix tree `0723536^`: 7/11 FAIL. `TestCORSProfileHeadersOnRefusals` and `TestCORSProfileVaryKeptWithHandlerVary` are required by name in `SURF_TESTS` and passed in the gate |
| WR-01..WR-09 (warnings) | open | fixed, verified in code and by named tests | see Plan 06 must-haves |
| Previously verified must-haves (35) | ✓ | ✓ no regressions | full gate with Docker green; cross-instance smokes and `go test -short ./...` green |
### Resolved gap (history)
The initial verification recorded one gap. `Router.wrap` composed `cors:<profile>` at its list position, inside the body limit and panic recovery. A guard listed before the profile, a guard on an enclosing group and a recovered 500 therefore answered without `Access-Control-Allow-Origin`, and the browser hid the 401 that starts the SPA's cookie refresh. Plan 06 (commit `0723536`) resolves the profile through `routeCORSProfile`, skips the `cors:` entry in the middleware loop after the unknown-profile check, and wraps the recovered handler in `r.corsProfiles[profile].profileMiddleware` just inside `recordPattern` (`modules/surf/router.go:417-485`). `bufferedResponse.commit` now merges `Vary` through `addVary` instead of replacing it (`router.go:837-852`), so a handler's own `Vary` does not drop `Vary: Origin`. The docs say the profile applies whatever its position: `docs/services/routing.md:101,271`, `modules/surf/README.md:31` and `docs/architecture/request-lifecycle.md:40`.
## User Flow Coverage (derived; the goal is not a user story)
Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as several instances.
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| Log in, get a cookie | httpOnly `token` cookie, Domain/SameSite/Secure from `auth.cookie.*`, Max-Age = refresh window; production refuses an insecure cookie | `bouncer.LoadCookieConfig`, `CookieConfig.TokenCookie`; `TestTokenCookieMaxAgeIsRefreshWindow`, `TestLoadCookieConfigProductionInferredSecure`, `TestZeroCookieConfigDefaults` passed | VERIFIED |
| Access token expires, API answers 401 | The SPA can read the 401 cross-origin | Global `http.cors`: verified in the initial run. `cors:<profile>`: 401 carries ACAO+ACAC+Vary whatever the order or group nesting (probe, this run) | VERIFIED (real browser: human item 1) |
| Refresh through the cookie | New cookie, old jti blacklisted, replay refused | `TestCookieRefreshHandlerRotatesAndBlacklists` (real `bouncer.Refresh`), `TestCookieRefreshAcrossSubdomainsSmoke` passed in this run | VERIFIED |
| Requests land on different instances | Throttle counts one window across instances | `TestRateLimitAcrossInstancesSmoke` (two apps, one real Redis container) passed in this run; the 429 also carries the profile's CORS headers (probe) | VERIFIED |
| Upload a file to S3 | `bucket_url: s3://` in a tagged binary, no app import | `s3_on.go`/`s3_off.go`; `TestOpenBucketS3` in both tag modes; protected uploads refused when `public_url` is set (`TestStoreProtectedNeedsPrivateBucket`) | VERIFIED (real bucket: human item 2) |
| Read the API docs | `/api-docs` and `/api-docs.json` | `mountAPIDocs`; nonce on all three script tags (`apidocs.go:65-67`), `script-src 'nonce-…'` only (`apidocs.go:208`); `TestAPIDocsCSPNoHostSource` passed | VERIFIED (rendering under CSP: human item 4) |
| Register behind Turnstile | `turnstile:open|closed` middleware; refusals readable cross-origin | `bouncer.Turnstile`, surf factory; Turnstile 422/503 carry the profile's headers (`TestCORSProfileHeadersOnRefusals/turnstile_*`); boot warning without a secret (`TestTurnstileEmptySecretWarnsAtBoot`) | VERIFIED |
## Goal Achievement
### Observable Truths (roadmap success criteria)
| # | Truth | Status | Evidence |
|---|-------|--------|----------|
| SC-1 | `bouncer` refresh issues a new httpOnly `token` cookie with configurable `Domain`/`SameSite`/`Secure`, blacklists the old JTI; 60-min TTL and 14-day refresh window are config keys; credentialed CORS for a configured SPA origin is available as middleware | ✓ VERIFIED | Cookie half unchanged from the initial run and still green. CORS half: global `http.cors` (initial run) and now `cors:<profile>` decorate every response of the route; verifier probe 11/11 on HEAD, 7/11 FAIL on the pre-fix tree; credentialed wildcard/unanchored origins refused at boot (`TestCORSCredentialedOriginRules`, `TestCORSAnchoredPatternNoSuffixMatch`) |
| SC-2 | Cache module with memory and Redis drivers; Redis-backed `Store` so rate limits hold across instances; Redis client decision note before code | ✓ VERIFIED | Unchanged since the initial run; cooler suites and `TestRateLimitParityDocker`, `TestRedisCacheDocker` passed in the gate; `TestRateLimitAcrossInstancesSmoke` passed separately |
| SC-3 | S3 blob driver selectable through framework config without the app importing it; `/api-docs` + `/api-docs.json` servable; Turnstile middleware available | ✓ VERIFIED | Unchanged artifacts plus plan 06 hardening (WR-06, WR-07, WR-08), each pinned by a required test that passed in the gate (attach tests ran untagged and with `-tags summercms_s3`) |
| SC-4 | Touched modules' READMEs and `docs/` updated; new code has unit tests in the last plan | ✓ VERIFIED | Plan 06 updated the surf, bouncer, lagoon and cooler READMEs and `docs/services/{routing,authentication,storage,caching}.md`, `docs/architecture/request-lifecycle.md`; `TestDocsTree` and `docs:build --check` passed in the gate's docs stage. Plan 06 commits its tests in `8d80580` (Task 6) after the fix commits, so the "unit tests last" rule holds for the gap-closure plan |
**Score:** 4/4 roadmap truths verified. Plan must-haves 44/44 verified (0 present-but-behavior-unverified).
### Plan must-haves
| Plan | Truths | Status | Notes |
|------|--------|--------|-------|
| 01 (cooler, Redis store) | 8 | 8 ✓ | Regression check: cooler required tests and Docker parity passed in this gate run |
| 02 (cookies, CORS, Turnstile, api-docs) | 7 | 7 ✓ | D-05 previously failed, now VERIFIED (CR-01 closed) |
| 03 (S3 tag, DiskExtension, public_url) | 6 | 6 ✓ | Regression check: build and attach tests (both tag modes) and `TestStoreDiskExtension` (Postgres) passed |
| 04 (site-cache purge, plugins, layers) | 7 | 7 ✓ | Regression check: plugin Docker admin suites passed with the queued purge; three layers' shared files still byte-identical (gate `cmp`) |
| 05 (tests, gate) | 8 | 8 ✓ | Gate still fail-closed on required names (lists include the plan 06 tests) |
| 06 (gap closure) | 8 | 8 ✓ | See below |
**Plan 06 truths:**
| # | Truth (abridged) | Status | Evidence |
|---|------------------|--------|----------|
| 1 | CR-01: every response of a `cors:<profile>` route carries ACAO (exact origin), ACAC, `Vary: Origin` for 401/403 (route-before or group), 422/503 Turnstile, 429, 413, panic 500 (JSON and raw); disallowed origin gets `Vary: Origin` only; handler `Vary` merged | ✓ VERIFIED | Verifier probe 11/11 (real HTTP server via `httptest.NewServer`); `TestCORSProfileHeadersOnRefusals` (11 subtests incl. Turnstile 422/503, 403) and `TestCORSProfileVaryKeptWithHandlerVary` passed in the gate |
| 2 | CR-01 gate and docs | ✓ VERIFIED | `scripts/check-phase19.3.sh:80` lists both tests in `SURF_TESTS`; docs lines cited above |
| 3 | D-04 / WR-04: credentialed wildcard-host, unanchored and probe-matching origins fail boot, globally and per profile; non-credentialed configs unchanged | ✓ VERIFIED | `cors.go` `wildcardHostLabel`, `anchoredOriginPattern` (parsed with `regexp/syntax`), `corsProbeOrigins`; `TestCORSCredentialedOriginRules`, `TestCORSAnchoredPatternNoSuffixMatch`, `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` passed. Verifier probe: `https://*.com`, `https://*`, `https://*example.com` refused, `https://*.example.com` loads. Residual: see Advisory |
| 4 | D-03 / WR-05, WR-09: production refuses Secure resolving to false (set or inferred); zero `CookieConfig` writes Lax + Secure | ✓ VERIFIED | `cookie.go:111` error names `auth.cookie.secure` and `app.url`; `TestLoadCookieConfigProductionInferredSecure`, `TestZeroCookieConfigDefaults`, `TestLoadCookieConfigErrors` passed |
| 5 | D-17, D-12, D-18 / WR-06, WR-07, WR-08 | ✓ VERIFIED | `apidocs.go:65-67,208`; `attach/store.go:42,136` returns `ErrProtectedNeedsPrivateBucket` after the argument checks, before the body is read; boot warning in `bucket.go`; Turnstile secret warning in `BuildRouter`; `TestAPIDocsCSPNoHostSource`, `TestStoreProtectedNeedsPrivateBucket` (both tag modes), `TestTurnstileEmptySecretWarnsAtBoot` passed |
| 6 | D-10, D-11 / WR-02, WR-01: no redirects; queued, coalesced after-commit purges; `Wait`; `Handle.Close` drains; exported names unchanged | ✓ VERIFIED | `sitecache.go:109,115` `CheckRedirect: noRedirect` → `http.ErrUseLastResponse`; `enqueue`/`work`/`Wait` (`sitecache.go:212-275`) keep one request in flight and one pending follow-up; `cooler.go:119` waits in `Close`. `TestSiteCachePurgeNoRedirect` (redirect target receives 0 requests, 307 logged, token not logged), `TestSiteCachePurgeAsyncCoalesced`, `TestSiteCachePurgeWaitAndClose`, Docker `TestSiteCacheBulkCommitCoalesced`, `TestSiteCacheAfterCommitOnly` passed. Plugin commits change only test files (`git show --stat`) |
| 7 | D-11 / WR-03: layer purge route honours `siteCache.allowProxied`; shared files byte-identical with updated pins; caching docs pair remote https `purge_url` with the flag | ✓ VERIFIED | `purge.post.ts` passes `allowProxied` (`=== true` or `'true'`) to `authorizeSitePurge`; sha256 of the shared files is identical in vue-faq-layer, vue-menu-layer, vue-journal-layer; layer TAP 24/37/170 pass, including "allowProxied lets a proxied request reach the token check" |
| 8 | Release precondition: gate `--all` green with Docker and names every new test; disposition records CR-01, WR-01..WR-09 fixed; nothing tagged or pushed before the human reply | ✓ VERIFIED | Gate exit 0 (below). 19.3-REVIEW-DISPOSITION.md: 10 fixed, 11 info open. `git tag --contains 0723536` empty; summercms.go 22 commits ahead of `origin/master`, no `v0.1.28` on origin; plugins and layers each 1 commit ahead of origin with no tag on HEAD |
### Required Artifacts (plan 06)
| Artifact | Expected | Status | Details |
|----------|----------|--------|---------|
| `modules/surf/router.go` | profile outermost; Turnstile boot warning | ✓ VERIFIED | `profileMiddleware` applied at `router.go:483-485` |
| `modules/surf/cors.go` | credentialed origin rules with probe origins | ✓ VERIFIED | `cors-probe.invalid` at `cors.go:92-93` |
| `modules/surf/apidocs.go` | nonce on every script tag | ✓ VERIFIED | 3 tags |
| `modules/bouncer/cookie.go` | production refusal, zero-config defaults | ✓ VERIFIED | "resolves to false" at `cookie.go:111` |
| `modules/lagoon/attach/store.go` | protected-upload refusal | ✓ VERIFIED | |
| `modules/cooler/sitecache.go` | no-redirect client, queue, Wait | ✓ VERIFIED | |
| `../vue-faq-layer/server/lib/siteCachePurge.ts` (and two siblings) | allowProxied opt-in | ✓ VERIFIED | byte-identical |
| `scripts/check-phase19.3.sh` | requires gap-closure tests | ✓ VERIFIED | |
Artifacts from plans 01-05: unchanged status (✓ VERIFIED; `cors.go` upgraded from ⚠️ PARTIAL).
### Key Link Verification (plan 06)
| From | To | Via | Status |
|------|----|-----|--------|
| `surf/router.go` wrap | `surf/cors.go` | `routeCORSProfile(rt)` then `r.corsProfiles[profile].profileMiddleware(h)` after recovery, inside `recordPattern` | WIRED (probe confirms runtime effect) |
| `scripts/check-phase19.3.sh` | `surf/cors_profile_test.go` | `SURF_TESTS` names `TestCORSProfileHeadersOnRefusals` | WIRED (ran in the go stage: 29 surf tests passed) |
| `cooler/sitecache.go` | `lagoon.AfterCommit` | `PurgeSiteCacheAfterCommit` callback only calls `p.enqueue` | WIRED |
| `cooler/cooler.go` | `cooler/sitecache.go` | `Handle.Close` → `h.purger.Wait(ctx)` | WIRED |
| layer `purge.post.ts` | layer `siteCachePurge.ts` | `authorizeSitePurge({..., allowProxied})` | WIRED |
Key links from plans 01-05: unchanged, WIRED.
### Data-Flow Trace (Level 4)
Not applicable: no rendered dynamic data. The runtime flows (cookie → Refresh → blacklist; request → throttle → shared Redis counter; save → after-commit → queued purge POST; refusal → profile CORS headers) are each exercised by a passing test or the verifier probe.
### Behavioral Spot-Checks
| Behavior | Command | Result | Status |
|----------|---------|--------|--------|
| Phase gate, all stages, Docker required | `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` | exit 0; PASS self-test, go, plugins, layers, docs, docker ("Phase 19.3 gate passed") | ✓ PASS |
| CR-01 on HEAD (`cors:<profile>`) | scratch module outside the repo (`replace` to the repo), `surf.Assemble` + `httptest.NewServer`, profile `spa` credentialed for `https://www.example.com` | `[deny,cors:spa]` 401, `[cors:spa,deny]` 401, group `Use(deny)` + route `cors:spa` 401, group `Use(cors:spa)` > inner group `Use(deny)` 401, handler panic 500, `[boom,cors:spa]` middleware panic 500, raw-group panic 500, `body.limit:16` 64-byte POST 413, throttle 200 then 429: all ACAO=`https://www.example.com`, ACAC=`true`, Vary=`Origin`. Disallowed origin 401: no ACAO/ACAC, Vary=`Origin`. ALL PASS | ✓ PASS |
| Same probe on the pre-fix tree (`git archive 0723536^`) | identical probe, `replace` to the extracted tree | 7/11 FAIL (route-before 401, group 401, all three 500s, 429, disallowed-origin Vary) | discriminating, as expected |
| Credentialed wildcard validation | scratch module calling `surf.LoadCORSConfig` | `https://*.example.com` ok; `https://*.com`, `https://*`, `https://*example.com` refused; `https://*.co.uk` ok (Advisory) | ✓ PASS (contract) |
| Cross-instance rate limit and cookie refresh smokes | `go test -count=1 -run '^(TestRateLimitAcrossInstancesSmoke\|TestCookieRefreshAcrossSubdomainsSmoke\|TestCookieRefreshHandlerRotatesAndBlacklists)$' ./modules/surf ./modules/bouncer` | all PASS (real Redis container) | ✓ PASS |
| Regression, whole module (once) | `go test -short -count=1 ./...` | all packages ok | ✓ PASS |
| examples/hello workspace module | `cd examples/hello && go test -count=1 ./...` | ok | ✓ PASS |
### Probe Execution
No `scripts/*/tests/probe-*.sh` exist and no plan declares one. The phase gate was run by the verifier in its own process (above).
### Requirements Coverage
REQUIREMENTS.md maps no IDs to phase 19.3 (no orphans). Phase-local IDs from PLAN frontmatter (plans 01-06):
| ID | Source Plans | Status | Evidence |
|----|--------------|--------|----------|
| SC-1 | 02, 05, 06 | ✓ SATISFIED | cookie tests; CR-01 closed |
| SC-2 | 01, 05 | ✓ SATISFIED | cooler, Redis store, note ordering |
| SC-3 | 02, 03, 05, 06 | ✓ SATISFIED | S3 tag, api-docs (nonce-only CSP), Turnstile |
| SC-4 | 01-06 | ✓ SATISFIED | READMEs, docs, tests last in each plan |
| D-01, D-02 | 02, 05 | ✓ SATISFIED | cookie tests |
| D-03 | 02, 05, 06 | ✓ SATISFIED | plus WR-05/WR-09 hardening |
| D-04 | 02, 05, 06 | ✓ SATISFIED | WR-04 fixed; public-suffix residual is Advisory |
| D-05 | 02, 05, 06 | ✓ SATISFIED | CR-01 closed |
| D-06, D-07, D-08, D-09 | 01, 05 | ✓ SATISFIED | |
| D-10, D-11 | 04, 05, 06 | ✓ SATISFIED | WR-01/02/03 fixed; live purge is human item 3 |
| D-12 | 03, 05, 06 | ✓ SATISFIED | WR-07 fixed |
| D-13, D-14, D-15 | 03, 05 | ✓ SATISFIED | real bucket is human item 2 |
| D-16, D-17 | 02, 05, 06 | ✓ SATISFIED | WR-06 fixed; rendering under CSP is human item 4 |
| D-18 | 02, 05, 06 | ✓ SATISFIED | WR-08 fixed |
| D-19 | 01-04 | ✓ SATISFIED | placement matches (cooler / surf / bouncer / lagoon/attach) |
### Prohibitions (all judgment-tier; non-authoritative verifier verdicts)
Plans 01-05: verdicts from the initial run still hold (cooler imports, bouncer imports, Flush scope, byte-identical `*` CORS, Turnstile secret never logged, no new `s3.*` keys, Journal/menu/FAQ compatibility, test network scope).
| Plan | Prohibition | Verdict | Evidence |
|------|-------------|---------|----------|
| 06 | No breaking change to Journal, menu, FAQ: exported cooler purge names/signatures, Journal aliases, plugin controllers unchanged; only plugin test files change | holds | plugin commits `e52b286`, `4b42978`, `4f633f0` touch only `*_test.go`; `PurgeSiteCacheAfterCommit` and `SiteCachePurger.Purge` keep their signatures (`Wait` is additive); Journal alias tests passed. Note: the plugin tests now call `SiteCachePurger.Wait`, so the plugins need the v0.1.28 require bump before they build on origin without go.work (recorded in the SUMMARY handoff) |
| 06 | Non-credentialed `*` CORS byte-identical | holds | `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` passed |
| 06 | Framework code and docs never name a consuming application | holds | gate docs stage: "no consuming application named in framework lines since 2d352ff^" |
| 06 | Nothing pushed or tagged before the human's reply | holds | no tag contains `0723536`; no `v0.1.28` on origin; plugins and layers 1 commit ahead with untagged HEADs |
| 06 | IN-01..IN-11 not addressed | holds | disposition shows all 11 `open` |
### Anti-Patterns Found
No `TBD`/`FIXME`/`XXX`/`TODO`/`HACK` markers in files changed by plan 06 (the only match is the gate's TAP `SKIP|TODO` directive parser at `scripts/check-phase19.3.sh:261`). No stubs: every plan 06 change is exercised by a named test that ran.
| File | Finding | Severity | Impact |
|------|---------|----------|--------|
| `modules/surf/cors.go` | Credentialed `https://*.co.uk`-style wildcards (multi-label public suffix) still load | 📋 Advisory | Needs a deliberate misconfiguration; outside plan 06's stated contract. See frontmatter `advisory` |
| 19.3-REVIEW.md IN-01..IN-11 | Info findings, open by design | ℹ️ Info | recorded in 19.3-REVIEW-DISPOSITION.md |
| deferred-items.md | S3 object Content-Type from sniff; Redis `Remember` outage latency; pre-existing gofmt/naming in older files | ℹ️ Info | follow-ups, unchanged |
| 19.3-VALIDATION.md | still `status: draft`, `nyquist_compliant: false` | ℹ️ Info | `/msd-validate-phase 19.3` can settle it |
### Advisory (New Scope, Unevidenced)
| # | Finding | Category | Why Advisory |
|---|---------|----------|--------------|
| 1 | Credentialed wildcard on a multi-label public suffix (`https://*.co.uk`) passes boot validation | security | new scope relative to the carried-forward gap; only scratch-probe evidence; plan 06's contract (probe origins, `https://*.com`) holds |
### Human Verification Required
1. **Cookie refresh across real subdomains.** Deploy to staging with `auth.cookie.domain` set to the parent domain and `cors:<profile>` (or `http.cors`) credentialed for the www origin. Log in on www and wait past the access TTL.
- Expected: the SPA reads the 401, the refresh call sends the cookie, the rotated cookie replaces it.
- Why human: needs real DNS and TLS, and a browser's cookie and CORS enforcement.
2. **Real S3/R2/MinIO upload.** Build with `build.tags: [summercms_s3]`, set `bucket_url: s3://...?endpoint=...`, upload, fetch via `public_url` and via `/storage/uploads`; try a protected upload with `public_url` set.
- Expected: both URLs serve the public file; the protected upload is refused.
- Why human: needs bucket credentials.
3. **Live Nuxt purge.** Save a menu entry and a FAQ entry in admin against a site on the new layer versions (set `NUXT_SITE_CACHE_ALLOW_PROXIED=true` behind a proxy).
- Expected: the page updates without waiting for the 300 s SWR TTL; the purge is logged after the save returned.
- Why human: needs a running Nuxt site and the app.
4. **Swagger UI under the nonce-only CSP.** Enable `http.api_docs`, open `/api-docs`.
- Expected: the UI renders the spec with no CSP violations in the console.
- Why human: browser rendering; WR-06 tightened `script-src` since the initial verification.
The initial report's item 5 (triage WR-01..WR-09) is settled: all ten CR/WR findings are `fixed` in 19.3-REVIEW-DISPOSITION.md.
### Gaps Summary
No gaps. The one gap from the initial verification (SC-1 / D-05, CR-01) is closed in code, docs and gate. The verifier reproduced the earlier failure scenarios in a scratch module outside the repo: every case now carries the profile's headers on HEAD, and the same probe still fails on the pre-fix tree, so it actually tests the fix. The full gate passes with Docker required, and the earlier must-haves show no regression. The status is `human_needed` because four checks need real infrastructure or a browser. The release steps in the 19.3-06 SUMMARY handoff remain for the phase session: whole-phase docs, the v0.1.28 tag, the plugin require bumps and the layer tags. Until the plugins move to v0.1.28, their tests do not build on origin without go.work.
---
_Verified: 2026-10-15T21:08:52Z_
_Verifier: Claude (msd-verifier)_

View File

@@ -24,12 +24,12 @@ Each framework module is one Go package under `modules/`, documented by its READ
| Concern | Modules |
|---------|---------|
| Plugins and the container | [party](../../modules/party/README.md), [pact](../../modules/pact/README.md), [backpack](../../modules/backpack/README.md), [festival](../../modules/festival/README.md), [compass](../../modules/compass/README.md) |
| Plugins and the container | [party](../../modules/party/README.md), [pact](../../modules/pact/README.md), [backpack](../../modules/backpack/README.md), [festival](../../modules/festival/README.md), [compass](../../modules/compass/README.md), [signpost](../../modules/signpost/README.md) |
| HTTP | [surf](../../modules/surf/README.md), [towel](../../modules/towel/README.md), [wire](../../modules/wire/README.md), [bouncer](../../modules/bouncer/README.md), [wristband](../../modules/wristband/README.md), [fetchguard](../../modules/fetchguard/README.md) |
| Data | [lagoon](../../modules/lagoon/README.md), [beachcomber](../../modules/beachcomber/README.md) |
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md) |
| Services | [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md) |
| Admin | [cabana](../../modules/cabana/README.md), [boardwalk](../../modules/boardwalk/README.md), [seaglass](../../modules/seaglass/README.md) |
| Services | [cooler](../../modules/cooler/README.md), [phrasebook](../../modules/phrasebook/README.md), [postcard](../../modules/postcard/README.md), [conga](../../modules/conga/README.md), [lighthouse](../../modules/lighthouse/README.md), [flare](../../modules/flare/README.md), [sunscreen](../../modules/sunscreen/README.md) |
| Console and tooling | [bonfire](../../modules/bonfire/README.md), [tide](../../modules/tide/README.md), [sunrise](../../modules/sunrise/README.md) |
| Utilities | [sieve](../../modules/sieve/README.md) |
## What runs where

View File

@@ -37,12 +37,13 @@ surf wraps every non-raw route in the same layers. From the outside in:
1. Optional locale-prefix rewrite, when a `surf.LocaleResolver` is published: an enabled locale as the first path segment is stripped so later layers and routing see the unprefixed path.
2. CORS, only for the paths configured under `http.cors.paths`, including preflight requests.
3. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
4. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
5. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
6. The route's middleware, in the order you listed them: group middleware first, then the route's own.
7. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
8. Your handler.
3. The route's CORS profile, when it or its group names `cors:<profile>`. It sits here whatever its position in the middleware list, so refusals and recovered panics carry its headers too.
4. Panic recovery. A panic becomes an opaque JSON 500 from [wire](../../modules/wire/README.md), and because the response is buffered until the handler returns, the client never receives half a body.
5. The request locale, taken from the published `surf.LocaleResolver` when one exists, or from the `Accept-Language` header otherwise, and stored in the context.
6. The body limit: `http.body_limits.default_bytes`, or the route's own `body.limit:<bytes>`.
7. The route's middleware, in the order you listed them: group middleware first, then the route's own (`cors:<profile>` excepted, see step 3).
8. The path constraints. A request whose parameter fails `pact.Router.Where` or `pact.Router.WhereIn` gets a 404 before your handler runs.
9. Your handler.
Raw groups, declared with `pact.Router.GroupRaw`, are for webhooks and file streams. They skip the default body limit, refuse house middleware, and a panic in them returns a bare 500.

View File

@@ -12,10 +12,10 @@ Start with [Installation](setup/installation.md) to set up the toolchain and the
## Where to start
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration, the move from WinterCMS and a [plugin porting walkthrough](setup/porting-a-plugin.md).
- [Setup](setup/introduction.md): what SummerCMS is, installation, configuration, the move from WinterCMS, a [plugin porting walkthrough](setup/porting-a-plugin.md) and [Upgrading](setup/upgrading.md) to a newer release, with what to check before 0.2.
- [Architecture](architecture/introduction.md): the single binary, Go modules, the application lifecycle, the request lifecycle and [performance and scaling](architecture/performance-and-scaling.md) compared with PHP-FPM.
- [Plugins](plugins/registration.md): registering a plugin, scheduling, extending other plugins and testing.
- [Backend](backend/admin-controllers.md): admin controllers, forms, lists, relations, users, settings and the admin SPA.
- [Plugins](plugins/registration.md): registering a plugin, scheduling, extending other plugins, [menu item types](plugins/menu-item-types.md) and testing.
- [Backend](backend/admin-controllers.md): admin controllers, forms, lists, relations, [plugin field types](backend/field-types.md), users and [API tokens](backend/users-and-permissions.md#api-tokens), settings and the admin SPA.
- [Database](database/models.md): models, migrations, queries, relations, casts, attachments and transactions.
- [Services](services/configuration.md): configuration, events, routing, authentication, mail, jobs, realtime, search and the other services, plus what SummerCMS does not provide for the frontend.
- [Services](services/configuration.md): configuration, events, routing, authentication, [caching](services/caching.md), mail, jobs, realtime, search and the other services, plus what SummerCMS does not provide for the frontend.
- [Console](console/introduction.md): the `summer` tool, the application binary's commands and writing your own.

View File

@@ -75,10 +75,12 @@ This fragment belongs in `config/auth.yaml`. With the block unset the cookie is
| `auth.cookie.name` | `token` | Cookie name. |
| `auth.cookie.domain` | empty (host-only) | A parent domain shares the cookie with its subdomains. |
| `auth.cookie.path` | `/` | Cookie path. |
| `auth.cookie.secure` | true when `app.url` is https | `false` is refused in the production environment. |
| `auth.cookie.secure` | true when `app.url` is https | In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
An invalid domain, `same_site: none` without Secure or `secure: false` in production makes `bouncer.LoadCookieConfig` return an error naming the key; return it from your plugin's boot so the application does not start with a cookie browsers would drop.
A `bouncer.CookieConfig` built by hand instead of through `bouncer.LoadCookieConfig` gets `SameSite=Lax` when `SameSite` is unset, and the zero config writes `token` on `/` with `HttpOnly`, `SameSite=Lax` and `Secure`; a config with any field set keeps its own `Secure` value.
An invalid domain, `same_site: none` without Secure, or a cookie that is not Secure in production makes `bouncer.LoadCookieConfig` return an error naming the key. In production that covers `secure: false` and an unset `auth.cookie.secure` with an http or missing `app.url`; that error names `auth.cookie.secure` and `app.url`, and you fix it by setting `app.url` to the public https URL or `auth.cookie.secure: true`. Return the error from your plugin's boot so the application does not start with a cookie browsers would drop or send in clear.
`bouncer.CookieConfig.TokenCookie` builds the cookie for a token and a lifetime. Pass the guard's refresh window (`refresh_ttl`, 20160 minutes by default), not its access `ttl`: the access token inside expires after `ttl`, the frontend's 401 handling then calls the refresh endpoint, and that endpoint needs the cookie to still be there. A cookie that died with the token would force a new sign-in every hour. `bouncer.CookieConfig.ForgetTokenCookie` builds the matching cookie that deletes it; it carries the same name, path and domain, or the browser would keep the original.
@@ -181,7 +183,7 @@ The middleware reads the token from the `turnstile_token` field (`turnstile.fiel
| Key | Default | Controls |
|-----|---------|----------|
| `turnstile.secret` | empty | The site's secret key. Set it through `SUMMER_TURNSTILE__SECRET`; a PHP application's `TURNSTILE_SECRET` value moves to that variable. |
| `turnstile.secret` | empty | The site's secret key. Set it through `SUMMER_TURNSTILE__SECRET`; a PHP application's `TURNSTILE_SECRET` value moves to that variable. When it is empty and routes name `turnstile:open` or `turnstile:closed`, the start-up logs one warning, `surf: turnstile.secret is empty`, with the number of such routes, since they are then unprotected. |
| `turnstile.field` | `turnstile_token` | The request field carrying the token. |
Refusals are written with neutral bodies, `{"error":"turnstile_unavailable"}` (503) and `{"error":"turnstile_failed"}` (422). To keep an existing response envelope, publish a `bouncer.TurnstileWriter` on the app in your plugin's `Register`; it receives the status and the `bouncer.TurnstileVerdict`. A published `*bouncer.Turnstile` replaces the verifier built from config, for example to give it its own `http.Client`.

View File

@@ -97,7 +97,7 @@ The `serve` command installs cooler at start-up (`cooler.Install`) and publishes
## Site cache purge
A server-rendered site in front of the API usually caches its pages for a few minutes (a Nuxt `routeRules` `swr` window, for example). Without help, an edit in the admin shows on the site only when that window ends. cooler closes the gap: after an admin save commits, it sends the site one purge request, and the site drops its route cache, so the next page view renders the change.
A server-rendered site in front of the API usually caches its pages for a few minutes (a Nuxt `routeRules` `swr` window, for example). Without help, an edit in the admin shows on the site only when that window ends. cooler closes the gap: after an admin save commits, it queues a purge request to the site, and the site drops its route cache, so the next page view renders the change.
Configure the target once for the application:
@@ -110,7 +110,7 @@ Set both or neither; with neither, nothing is purged. The URL must be http or ht
### What a purge sends
Each committed save sends one request, with no debounce:
Each committed save queues a purge; the request looks like this:
```http
POST /api/__site__/cache/purge
@@ -122,20 +122,24 @@ Content-Type: application/json
The kind names what was saved (a plugin picks it, for example `faq`, `menu`, `menu_entry`, `post` or `category`) and the slug which record; the site may log them. The site answers `{"purged": <n>}`, and cooler logs `cooler: site cache purged` with the kind, slug, status and count.
At most one request is in flight per process (per purger). Saves committed while it is sent collapse into one follow-up request carrying the last kind and slug, because each purge clears the site's whole route cache: a bulk delete of fifty rows sends at most two requests. The log line of such a follow-up adds `coalesced` with the number of saves it stands in for besides its own.
### Timing and failures
`cooler.PurgeSiteCacheAfterCommit` registers the request on the save's transaction through `lagoon.AfterCommit`. It is sent only after the commit, so a page view between the purge and the commit cannot re-cache the old content, and a save that fails or is rolled back sends nothing. Outside a transaction it is sent at once.
`cooler.PurgeSiteCacheAfterCommit` registers the purge on the save's transaction through `lagoon.AfterCommit`. It is queued only after the commit, so a page view between the purge and the commit cannot re-cache the old content, and a save that fails or is rolled back sends nothing. Outside a transaction it is queued at once. Queuing never blocks: the admin request answers without waiting for the site, and a background worker sends the request.
A purge never fails a save. The request is bounded by `cooler.SiteCachePurgeTimeout` (2 s) and detached from the admin request's cancellation; an unreachable site, an error status or a timeout is logged as one `cooler: site cache purge failed` warning and the save answers as usual.
A purge never fails a save. Each request is bounded by `cooler.SiteCachePurgeTimeout` (2 s) and detached from the admin request's cancellation; an unreachable site, an error status or a timeout is logged as one `cooler: site cache purge failed` warning and the save answers as usual. Redirects are not followed: a 3xx answer is logged as a failed purge with its status, so the bearer token never goes to another URL and the POST never turns into a GET. Point `site_cache.purge_url` at the final URL.
Because the purge is fire and forget, a test or a console command that wants to see its effect calls `cooler.SiteCachePurger.Wait`, which returns once nothing is queued or in flight (or when its context ends). `serve` waits for a queued purge on shutdown, up to the timeout plus one second, through `cooler.Handle.Close`.
### Purging from a plugin
The `serve` command publishes the purger at start-up (`cooler.Install`), and `cooler.SiteCacheFromApp` returns it, or builds it from the keys in commands and tests that do not run `serve`. An admin controller calls it from its after-save hooks, which run inside the save transaction, and takes that transaction from `cabana.TxFromContext`:
```go src=modules/cooler/example_test.go#articlesController.purge
// purge registers one site-cache purge for the saved article, sent after the
// save transaction commits (at once outside one). Without a purge configured
// the purger is nil and nothing is sent.
// purge queues one site-cache purge for the saved article once the save
// transaction commits (at once outside one); the save never waits for the
// site. Without a purge configured the purger is nil and nothing is sent.
func (c articlesController) purge(ctx context.Context, model any) error {
article, ok := model.(*Article)
if !ok {
@@ -164,7 +168,11 @@ The Nuxt layers of the core plugins (the Journal, menu and FAQ layers) each ship
- `POST /api/__site__/cache/purge` removes every entry of Nitro's route cache (pages, their `_payload.json` and feeds) and answers `{"purged": <n>}`.
- Its token is `runtimeConfig.siteCache.purgeToken`, set through `NUXT_SITE_CACHE_PURGE_TOKEN` to the same value as `site_cache.purge_token`. With a token shorter than 32 characters the route answers 404.
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`) and 401 to a missing or wrong bearer token, compared in constant time.
- It answers 404 to any request that came through a reverse proxy (`X-Forwarded-For`, `X-Real-IP` or `Forwarded`), unless the site opts in with `NUXT_SITE_CACHE_ALLOW_PROXIED` (see [A purge URL on another host](#a-purge-url-on-another-host)), and 401 to a missing or wrong bearer token, compared in constant time.
- Each layer also ships the route rule `'/api/__site__/**': { cache: false }`. Without it, a site rule such as `'/**': { swr: 300 }` would wrap the purge route in Nitro's cached handler, which answers from the cache without running the route.
The three copies are byte-identical, so it does not matter which layer's copy Nitro picks.
### A purge URL on another host
When the site does not run on the backend's machine, `site_cache.purge_url` is an https URL on the site's host, for example `https://www.example.com/api/__site__/cache/purge`. The request then reaches Nuxt through the site's TLS-terminating proxy (nginx, Caddy, Traefik), which adds `X-Forwarded-For`, `X-Real-IP` or `Forwarded`, and the route refuses such requests by default with 404. Pair the https purge URL with `NUXT_SITE_CACHE_ALLOW_PROXIED=true` on the site (`runtimeConfig.siteCache.allowProxied`): the route then accepts proxied requests, and the bearer token is the only gate. Keep the token long and secret (the same value in `site_cache.purge_token` and `NUXT_SITE_CACHE_PURGE_TOKEN`), and keep the default, loopback and no flag, whenever the site runs next to the backend.

View File

@@ -27,9 +27,12 @@ A WinterCMS plugin that shipped components and AJAX handlers is ported as routes
Build the frontend with any framework that can call a JSON API, as its own project with its own build and deployment:
- **Data:** call the plugins' JSON routes. [Routing](routing.md) shows how routes, auth groups and JSON responses are declared, and [Queries and pagination](../database/queries-and-pagination.md) the list envelope.
- **Signing in:** the user plugin issues JWTs; send them as a bearer token or in the cookie the guard reads. See [Authentication](authentication.md).
- **Signing in:** the user plugin issues JWTs; send them as a bearer token or in the cookie the guard reads. A browser frontend can keep the token in an httpOnly cookie that the API sets, with `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` as its refresh and logout endpoints. See [Authentication](authentication.md) and [Cookie transport](authentication.md#cookie-transport).
- **Live updates:** instead of polling an AJAX handler, subscribe to realtime channels. The frontend connects to Centrifugo with a token from the token route and receives model broadcasts and explicit events. See [Realtime](realtime.md).
- **Cross-origin calls:** when the frontend runs on another origin, allow it in `http.cors`, as [Routing](routing.md) describes.
- **Cross-origin calls:** when the frontend runs on another origin, allow it in `http.cors`, as [Routing](routing.md#cors) describes. A credentialed single-page app that needs its own policy on some routes names a profile with the `cors:<profile>` middleware; see [CORS profiles](routing.md#cors-profiles).
- **Bot protection:** guard public forms such as sign-up or guest entry with Cloudflare Turnstile: the frontend widget's token goes with the request, and the `turnstile:open` or `turnstile:closed` middleware checks it. See [Turnstile](authentication.md#turnstile).
- **Page cache:** a server-rendered frontend that caches its pages can have them purged after every admin save, so an edit shows on the next page view. See [Site cache purge](caching.md#site-cache-purge).
- **API documentation:** publish a browsable Swagger UI page of the public API for frontend developers with `http.api_docs.enabled`. See [API documentation page](routing.md#api-documentation-page).
- **Push notifications:** see [Web Push](push.md).
The admin is the one frontend SummerCMS ships. It is a single-page app built the same way, against the admin API; see [Admin SPA](../backend/admin-spa.md).

View File

@@ -98,7 +98,7 @@ The framework registers these names:
|------|------|
| `throttle:<bucket>` or `throttle:<max>,<minutes>` | Rate limiting; see [Rate limiting](rate-limiting.md). |
| `body.limit:<bytes>` | Replaces the default request body limit for the route. |
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>`; see [CORS profiles](#cors-profiles). |
| `cors:<profile>` | CORS headers from `http.cors_profiles.<profile>` on every response of the route, refusals and recovered panics included, whatever its position in the list; see [CORS profiles](#cors-profiles). |
| `turnstile:open`, `turnstile:closed` | Cloudflare Turnstile verification; see [Turnstile](authentication.md#turnstile). |
| `locale.from-principal` | Switches the request locale to the signed-in user's preferred locale. |
| `backend` | The admin guard, when the admin is enabled. |
@@ -229,6 +229,12 @@ This fragment belongs in `config/http.yaml`. List the frontend's exact origin; `
The rules surf applies to the origin answer:
- `supports_credentials: true` together with `*` in `allowed_origins` fails the start-up. A credentialed answer that trusted any origin would let every site read responses made with the user's cookies; Laravel's php-cors reflects the origin in that case, surf refuses it.
- With `supports_credentials: true`, three more rules close other spellings of the same exposure, and each refusal names the key with its index, such as `http.cors.allowed_origins[1]`:
- an `allowed_origins` entry with a `*` is accepted only when the scheme is literal and the `*` is the whole leftmost host label, as in `https://*.example.com`; `https://*`, `*://*`, `https://*example.com` and `http*://www.example.com` fail the start-up;
- every `allowed_origins_patterns` entry must be anchored with `^` and `$`, as in `^https://[a-z]+\.example\.com$`; an unanchored pattern matches inside any origin, so `https://.*\.example\.com` would trust `https://x.example.com.attacker.net`;
- no origin rule may match an origin outside the configured domains: surf tries each rule against probe origins such as `https://cors-probe.invalid` and `null`, which refuses `https://*.com` and `^https?://.*$`.
Without credentials these entries load as before.
- An allowed origin is echoed exactly in `Access-Control-Allow-Origin`; `*` is sent only for `allowed_origins: ["*"]` without credentials, and that answer is unchanged from earlier releases.
- When the answer depends on the request's `Origin` (with `supports_credentials`, with any pattern, or with more than one listed origin), every response on a CORS path carries `Vary: Origin`, the preflight and a disallowed origin's response included, so a shared cache never serves one origin's answer to another.
- An `allowed_origins` entry with a `*` inside, such as `https://*.example.com`, matches like Laravel's `Str::is`: the `*` stands for any characters and the rest is literal.
@@ -262,7 +268,7 @@ cors_profiles:
supports_credentials: true
```
This fragment belongs in `config/http.yaml`. A route group then lists `cors:spa` among its middleware names, next to its guard, exactly as it lists `throttle:60,1`.
This fragment belongs in `config/http.yaml`. The route, or its group, then lists `cors:spa` among its middleware names, as it lists `throttle:60,1`. 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, and a single-page app could not see the 401 that starts its cookie refresh.
A profile is checked like `http.cors`: credentials with a `*` origin, a pattern that does not compile and a PHP-delimited pattern fail the start-up, with errors naming `http.cors_profiles.<name>.<key>`. Profile names use lower-case letters, digits, underscore and dash, and a profile that sets `paths` fails too.
@@ -293,4 +299,4 @@ surf converts the document once at start-up: a document starting with `{` must b
| `http.api_docs.path` | `/api-docs` | The page path; the JSON route is the same path plus `.json`. It must be an absolute URL path, and neither route may replace a plugin route. |
| `http.api_docs.title` | `API documentation` | The page title. |
The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` that allows scripts only from jsDelivr and its own inline script (through a per-request nonce), plus `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.
The page loads `swagger-ui-dist` from jsDelivr at the exact version `surf.SwaggerUIVersion`, and every script and stylesheet carries a Subresource Integrity hash with `crossorigin="anonymous"`, so the browser refuses a file that changed on the CDN. A floating version such as `@5` cannot carry a hash, which is why the version is pinned and moves only with a framework release. The page sends a `Content-Security-Policy` whose only script source is a per-request nonce: scripts run only with the page's nonce. The two jsDelivr scripts carry it as well as their SRI hash and the inline start-up script carries it, so no other jsDelivr script can run, even if HTML were injected into the page. The page also sends `X-Content-Type-Options: nosniff` and `Referrer-Policy: no-referrer`.

View File

@@ -91,7 +91,7 @@ uploads:
`attach.PublicURL`, `attach.File.URL` and thumbnail URLs then are that base, one slash and the blob key, which keeps the partition layout: `https://cdn.example.com/uploads/5f1/d0c/2e9/5f1d0c2e9a7b4c3d8e6f.jpg`. `attach.PublicURLBase` returns the base. The value must be an absolute `http` or `https` URL with a host and without user info, query or fragment; anything else stops the start-up with an error naming the key. Thumbnails are written to the bucket when they are first requested through `attach.File.Thumb`, so their URLs work behind the base as well.
The base applies to every URL the framework builds, private files included. The `/storage/uploads` route checks `is_public` before it serves a file; a public bucket or a CDN serves any key it holds. Use `public_url` only for a bucket that holds public files alone, or knowingly rely on the 22 random hexadecimal characters of each disk name. `serve` logs one line at start-up naming the host and this rule.
The `/storage/uploads` route checks `is_public` before it serves a file; a public bucket or a CDN serves any key it holds. A bucket behind `public_url` therefore holds public files only: while `public_url` is set, `attach.Store` refuses a protected (`is_public` false) upload with `attach.ErrProtectedNeedsPrivateBucket` before it writes anything. Keep protected relations on an application whose bucket is private, with `public_url` unset, or make the relation public. `serve` logs one warning at start-up naming the host and this rule.
Leave `public_url` unset to keep the URLs under `public_path_prefix`. With `storage.uploads.serve` on, those URLs go through the [Serving files](#serving-files) route, which works for an S3 bucket too, and private files stay protected.

View File

@@ -26,6 +26,9 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
| Laravel collections: `map`, `filter`, `reduce`, `keyBy`, `groupBy`, `unique`, `partition`, `first` and `pluck` | Generic functions on plain slices and maps: `sieve.Map`, `sieve.TryMap`, `sieve.Filter`, `sieve.Reduce`, `sieve.KeyBy`, `sieve.GroupBy`, `sieve.Uniq`, `sieve.Partition` and `sieve.Find`. Pluck is `sieve.Map` with a field accessor, unique by key is `sieve.UniqBy`, `chunk` is the standard `slices.Chunk`, and sets are built with `sieve.SetOf` | [sieve](../../modules/sieve/README.md) |
| `fields.yaml` and `columns.yaml` | The same YAML, embedded in the plugin through `pact.AdminAssets` and compiled at boot into a `cabana.CompiledController` | [Forms](../backend/forms.md), [Lists and filters](../backend/lists-and-filters.md), [cabana](../../modules/cabana/README.md) |
| Backend controllers with the Form, List and Relation behaviours | A `pact.AdminController` returned from `pact.HasAdminControllers`; the generic admin API replaces the behaviours, and hooks such as `pact.FormBeforeCreate` and `pact.ListExtendQuery` replace behaviour overrides | [Admin controllers](../backend/admin-controllers.md), [cabana](../../modules/cabana/README.md) |
| The Reorder behaviour and `config_reorder.yaml` | A `config_reorder.yaml` next to `config_list.yaml` and the `reorder` toolbar button: a drag-and-drop tree, or a flat list for records without a parent column; `pact.ReorderAfterSave` runs after a reorder save | [Lists and filters](../backend/lists-and-filters.md#tree-reorder), [cabana](../../modules/cabana/README.md) |
| Form widgets and `registerFormWidgets` | A plugin field type: `pact.HasFormFieldTypes` returning `pact.FormFieldType` values, usable as `type:` in any controller's `fields.yaml`; a one-off element with a server action is a `type: widget` field | [Plugin field types](../backend/field-types.md), [Partials and widgets](../backend/partials-and-widgets.md#form-widgets) |
| HTML cleaning of rich editor content | Every `richeditor` value is sanitized on save by `seaglass.Sanitize`, an allowlist policy; plugin code calls `seaglass.SanitizeReport` to learn what was removed | [Rich text](../backend/forms.md#rich-text), [seaglass](../../modules/seaglass/README.md) |
| `routes.php` | `pact.HasRoutes`, declaring routes on a `pact.Router` with groups, middleware names and `pact.Router.Where` constraints | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
| Route middleware | Named middleware of type `pact.Middleware`, registered through `pact.HasMiddleware` | [Routing](../services/routing.md), [surf](../../modules/surf/README.md) |
| `config/*.php`, `.env` and `Config::get` | `compass.Config` with per-environment directories and `SUMMER_` overrides; plugin defaults through `pact.HasConfig` | [Configuration](../services/configuration.md), [compass](../../modules/compass/README.md) |
@@ -37,6 +40,12 @@ Each row names the WinterCMS concept, the SummerCMS identifiers that replace it,
| `registerSchedule` and the scheduler | `pact.HasSchedule` returning `pact.ScheduledCommand` entries with a `pact.Cadence` | [Task scheduling](../plugins/scheduling.md), [pact](../../modules/pact/README.md) |
| Mail templates in `views/mail` | `pact.HasMailTemplates`, sent through `postcard.Mailer` | [Mail](../services/mail.md), [postcard](../../modules/postcard/README.md) |
| Settings models and `registerSettings` | `pact.HasSettings` returning `pact.SettingsItem` entries; an entry with `'url' => Backend::url(...)` sets the `Controller` field to the admin controller ID | [Settings](../backend/settings.md), [cabana](../../modules/cabana/README.md) |
| A backend controller that only ever edits one record (its index redirects to the update screen) | A singleton controller: `pact.AdminSingleton` returns the one record, and a settings entry's `Controller` links to it | [Admin controllers](../backend/admin-controllers.md#singleton-controllers), [Settings](../backend/settings.md) |
| Personal access tokens for API clients | Administrator API tokens with scopes and expiry, sent as `Authorization: Bearer g15_…` and managed in My account or with `admin:token:create` and the other `admin:token:*` commands | [Users and permissions](../backend/users-and-permissions.md#api-tokens), [cabana](../../modules/cabana/README.md) |
| The Laravel `Cache` facade and `Cache::remember` | `cooler.Cache` with `cooler.Cache.Get`, `cooler.Cache.Set`, `cooler.Cache.Remember` and `cooler.Cache.Forget`, kept in memory or in Redis (`cache.driver`) | [Caching](../services/caching.md), [cooler](../../modules/cooler/README.md) |
| The Laravel CORS config (`config/cors.php`) | The same keys under `http.cors`, plus named profiles under `http.cors_profiles` applied with the `cors:<profile>` middleware | [Routing](../services/routing.md#cors), [surf](../../modules/surf/README.md) |
| A captcha on public forms | Cloudflare Turnstile as the `turnstile:open` and `turnstile:closed` middleware, with `turnstile.secret` | [Authentication](../services/authentication.md#turnstile), [bouncer](../../modules/bouncer/README.md) |
| The Static Pages menu item events `pages.menuitem.listTypes`, `pages.menuitem.getTypeInfo` and `pages.menuitem.resolveItem` | Typed menu item types: `pact.HasMenuItemTypes` returning `pact.MenuItemType` values, collected in the `signpost.Registry` that menu and sitemap plugins read | [Menu item types](../plugins/menu-item-types.md), [signpost](../../modules/signpost/README.md) |
| Laravel broadcasting | `lighthouse.Publisher` drivers and models that implement `lighthouse.Broadcastable` | [Realtime](../services/realtime.md), [lighthouse](../../modules/lighthouse/README.md) |
| Laravel Scout search | Models that implement `beachcomber.Searchable`, synced after commit | [Search](../services/search.md), [beachcomber](../../modules/beachcomber/README.md) |
| The Laravel HTTP client | `fetchguard.Fetch` with a `fetchguard.Policy` that blocks private addresses and limits size and time | [Outbound HTTP](../services/outbound-http.md), [fetchguard](../../modules/fetchguard/README.md) |
@@ -53,10 +62,8 @@ SummerCMS does not port the WinterCMS frontend or the PHP helpers that Go alread
| The AJAX framework and Snowboard | Not provided. The frontend calls the JSON API and subscribes to realtime channels; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
| The media manager | Not provided. Store uploads as model attachments; see [Attachments](../database/attachments.md). |
| Import and export in backend lists | Not provided. |
| Record sorting (the Reorder behaviour) | Not provided. |
| The fluent collection object (the Collection class and method chaining) | Not provided. Call the [sieve](../../modules/sieve/README.md) functions and the standard `slices` and `maps` packages on plain slices and maps. |
| Behaviours and dynamic class extension | Not provided. Use Go interfaces and composition. |
| Cache | Not provided. Use the Go standard library or a service another plugin publishes. |
| Session | Not provided. The API is stateless and authenticates with tokens; see [Frontend and AJAX](../services/frontend-and-ajax.md). |
## Plugin.php in Go

View File

@@ -52,7 +52,9 @@ These are the keys most applications set. Each module's reference page lists all
| `app.timezone` | Timezone of scheduled commands. Defaults to UTC. | [conga](../../modules/conga/README.md) |
| `app.locale`, `app.fallback_locale` | Default and fallback translation locales. | [phrasebook](../../modules/phrasebook/README.md) |
| `http.body_limits.default_bytes`, `http.body_limits.upload_bytes` | Request body limits. Required by `serve` and `route:list`. | [surf](../../modules/surf/README.md) |
| `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. | [surf](../../modules/surf/README.md) |
| `http.cors.*`, `http.trusted_proxies` | CORS and the proxies whose forwarded client IP is trusted. With `supports_credentials: true`, a wildcard host other than the whole leftmost label and an origin pattern not anchored with `^` and `$` fail the start-up. | [surf](../../modules/surf/README.md) |
| `http.cors_profiles.*` | Named CORS profiles with the `http.cors` keys except `paths`, applied to a route or group with the `cors:<profile>` middleware; checked like `http.cors`. | [surf](../../modules/surf/README.md) |
| `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title` | The opt-in Swagger UI page for the public API (off by default; path `/api-docs`, title `API documentation`). | [surf](../../modules/surf/README.md) |
| `http.rate_limit.store` | Where `throttle` counters live: `memory` (per instance, the default) or `redis` (shared by every instance). | [cooler](../../modules/cooler/README.md) |
| `cache.driver`, `cache.prefix`, `cache.memory.max_entries` | The cache driver (`memory`, the default, or `redis`), the key prefix (default from `app.name`) and the memory driver's size bound (default 10000). | [cooler](../../modules/cooler/README.md) |
| `site_cache.purge_url`, `site_cache.purge_token` | The host site's page-cache purge route and its bearer token (at least 32 characters). Set both or neither; http only to a loopback host. Keep the token in `SUMMER_SITE_CACHE__PURGE_TOKEN`. | [cooler](../../modules/cooler/README.md) |
@@ -61,6 +63,8 @@ These are the keys most applications set. Each module's reference page lists all
| `storage.uploads.bucket_url` | Uploads bucket, `file://`, `mem://` or `s3://` (the last only in a binary built with the `summercms_s3` tag from `build.tags` in `summer.yaml`; credentials from the AWS environment). Required by `serve`. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.public_url` | Absolute `http` or `https` base of a public bucket or CDN; file and thumbnail URLs become that base plus the partition path. Unset keeps `storage.uploads.public_path_prefix`. Only for a bucket holding public files alone. | [lagoon](../../modules/lagoon/README.md) |
| `storage.uploads.serve` | Mounts `attach.StaticHandlerPublic` under `storage.uploads.public_path_prefix` with `X-Content-Type-Options: nosniff`, a sandboxing CSP and `Content-Disposition: attachment` for anything but a raster image. Defaults to `false`. | [surf](../../modules/surf/README.md) |
| `auth.cookie.name`, `auth.cookie.domain`, `auth.cookie.path`, `auth.cookie.secure`, `auth.cookie.same_site` | The httpOnly token cookie for a browser frontend (defaults `token`, host-only, `/`, Secure when `app.url` is https, `lax`). In production a cookie that is not Secure fails the start-up. | [bouncer](../../modules/bouncer/README.md) |
| `turnstile.secret`, `turnstile.field` | The Cloudflare Turnstile secret behind the `turnstile:open` and `turnstile:closed` middleware (keep it in `SUMMER_TURNSTILE__SECRET`) and the request field carrying the token (default `turnstile_token`). | [surf](../../modules/surf/README.md) |
| `admin.jwt.secret` | Secret for admin tokens. Required as soon as a plugin registers an admin controller. | [cabana](../../modules/cabana/README.md) |
| `backend.uri` | Path the admin is served under. Defaults to `/backend`. | [cabana](../../modules/cabana/README.md) |
| `mail.driver`, `mail.from`, `mail.smtp.*` | Mail delivery: `memory`, `log` or `smtp`. | [postcard](../../modules/postcard/README.md) |
@@ -69,6 +73,8 @@ These are the keys most applications set. Each module's reference page lists all
| `search.driver`, `search.typesense.*` | The search engine and its Typesense settings. | [beachcomber](../../modules/beachcomber/README.md) |
| `push.enabled`, `push.public_key`, `push.private_key` | Web Push with VAPID keys. | [flare](../../modules/flare/README.md) |
Some keys are checked more strictly from 0.2 on, so a value that started an older release can stop the start-up; [Upgrading](upgrading.md#upgrading-to-02) lists them and their fixes.
> [!NOTE]
> The documentation checks every identifier, link and command name it shows against the code, but not configuration keys. The keys on this page are reviewed by hand; the module reference pages are the authority.

View File

@@ -30,9 +30,12 @@ The data layer supports PostgreSQL only, and SummerCMS targets Go 1.27.
## How these docs are organised
- **Setup** covers installation, configuration and the move from WinterCMS.
- **Setup** covers installation, configuration, the move from WinterCMS and [Upgrading](upgrading.md) to a newer framework release.
- **Architecture** explains the single binary, Go modules, the application lifecycle and how a request reaches your code.
- **Plugins** covers registering a plugin, scheduling, extending other plugins and testing.
- **Plugins** covers registering a plugin, scheduling, extending other plugins, menu item types and testing.
- **Backend** covers the admin: controllers, forms and field types, lists and filters, relations, users and API tokens, settings and the admin SPA.
- **Database** covers models, migrations, queries and pagination, relations, casts and validation, attachments and transactions.
- **Services** covers configuration, events, routing, authentication, caching, rate limiting, storage, logging, mail, jobs, realtime, search, push and the other runtime services.
- **Console** lists the `summer` tool's commands and the commands of every application binary, and shows how to write your own.
- **API reference** has one page per framework module.

73
docs/setup/upgrading.md Normal file
View File

@@ -0,0 +1,73 @@
---
title: Upgrading
description: Move an application to a newer framework release, what the version numbers promise, and the config to check before you take 0.2.
section: setup
order: 60
---
# Upgrading
An application created by `summer new` builds against a framework checkout next to it; the update script moves that checkout to the newest release tag, reinstalls the `summer` tool and rebuilds the application (see [Updating an application](installation.md#updating-an-application)). An application or plugin that requires a tagged framework version in its `go.mod` moves with the go command instead:
```sh
go get git.golem15.com/golem15/summercms@v0.2.0
go mod tidy
```
Then rebuild, run `migrate` with the new binary and restart it.
## Version numbers
SummerCMS is in the 0.x series, and the version tells you whether to read before you upgrade:
- The minor version (0.1 to 0.2) goes up when an upgrade can refuse a configuration that the previous release accepted, or changes a documented behaviour. Read the section for that version below before you deploy it.
- A patch version (0.2.0 to 0.2.1) is safe to take without reading: it fixes bugs and adds features, and an application that started on the previous patch starts on it too.
Applications pin exact versions, so a new release never reaches an application until it moves its `go.mod` or its checkout.
## Upgrading to 0.2
0.2 tightens two configuration checks so that settings which leaked credentials stop the start-up instead. Most applications are not affected: the first concerns applications that load an `auth.cookie` block, the second applications that send credentialed CORS answers. Start the new binary once in each environment before you switch traffic to it; a refused configuration fails `serve` at start-up with an error that names the key.
### Boot refusals
**A token cookie that is not Secure in production.** In the production environment, `bouncer.LoadCookieConfig` refuses a token cookie whose `Secure` attribute resolves to false. That covers `auth.cookie.secure: false` and, since 0.2, an unset `auth.cookie.secure` with an http or empty `app.url`, from which the cookie inherited `Secure=false` before. The error names `auth.cookie.secure` and `app.url`.
It affects applications whose plugins load an `auth.cookie` block for a browser frontend (the cookie transport). To fix it, set `app.url` to the application's public https URL, or set `auth.cookie.secure: true` explicitly. See [Cookie transport](../services/authentication.md#cookie-transport).
**Credentialed CORS with a wildcard host or an unanchored pattern.** With `supports_credentials: true`, globally under `http.cors` or in any profile under `http.cors_profiles.<name>`, these entries now fail the start-up, and the error names the key with its index, such as `http.cors.allowed_origins[1]` or `http.cors_profiles.spa.allowed_origins_patterns[0]`:
- an `allowed_origins` entry with a `*` that is not the whole leftmost host label after a literal scheme, such as `https://*`, `*://*`, `https://*example.com` or `http*://www.example.com` (`https://*.example.com` stays valid);
- an `allowed_origins_patterns` entry not anchored with `^` and `$`, in every alternative of the expression;
- any origin rule that matches an origin outside the configured domains, such as `https://*.com` or `^https?://.*$`.
It affects applications that send credentialed CORS answers, to a browser frontend on another origin that calls the API with cookies. To fix it, list the frontend's exact origins, or write anchored patterns such as `^https://[a-z]+\.example\.com$`. Without credentials these entries load as before. See [CORS](../services/routing.md#cors) and [CORS profiles](../services/routing.md#cors-profiles).
### Behaviour changes
These need no configuration change, but a test or an operator may notice them:
- **Site-cache purges are queued.** After an admin save commits, the purge request to the site is queued and sent by a background worker, with at most one request in flight per purger; saves committed meanwhile collapse into one follow-up request. The save no longer waits for the site. A test that asserts a purge calls `cooler.SiteCachePurger.Wait` first, and `serve` drains the queue on shutdown. See [Site cache purge](../services/caching.md#site-cache-purge).
- **Protected uploads need a private bucket.** While `storage.uploads.public_url` is set, `attach.Store` refuses a protected (`is_public` false) upload with `attach.ErrProtectedNeedsPrivateBucket` before it writes anything, and `serve` logs one warning at start-up that the bucket must hold public files only. Keep protected relations on a bucket without `public_url`, or make the relation public. See [Public URL base](../services/storage.md#public-url-base).
- **The Journal plugin's purge keys are deprecated.** `golem15.journal.site_cache.purge_url` and `golem15.journal.site_cache.purge_token` still work as aliases of the framework's `site_cache.purge_url` and `site_cache.purge_token`, which win when both are set; the plugin logs a deprecation warning. Move the values to the `site_cache` keys.
- **Cookie defaults.** A `bouncer.CookieConfig` built by hand with every field zero writes `SameSite=Lax` and `Secure`; a config with any field set keeps its own `Secure` value.
- **The API documentation page** at `/api-docs` runs only scripts that carry the page's per-request nonce. See [API documentation page](../services/routing.md#api-documentation-page).
- **Turnstile without a secret.** When `turnstile.secret` is empty and routes name `turnstile:open` or `turnstile:closed`, the start-up logs one warning with the number of such routes. See [Turnstile](../services/authentication.md#turnstile).
- **CORS profile headers on every answer.** A route that names `cors:<profile>` carries the profile's headers on every response, including refusals from guards, throttle, Turnstile and the body limit, and recovered panics.
## What 0.2 adds since 0.1
The 0.1 series grew through many patch releases. These are the larger features that arrived after its first public releases, with the page that documents each:
- Caching with memory and Redis drivers, and a Redis store that shares `throttle` counters between instances: [Caching](../services/caching.md), [Rate limiting](../services/rate-limiting.md).
- The site-cache purge after admin saves, including reorder saves through `pact.ReorderAfterSave`: [Site cache purge](../services/caching.md#site-cache-purge).
- Token cookies with ready-made refresh and logout handlers, and Turnstile bot protection: [Cookie transport](../services/authentication.md#cookie-transport), [Turnstile](../services/authentication.md#turnstile).
- Credentialed CORS, per-route CORS profiles (`cors:<profile>`) and the opt-in API documentation page: [CORS](../services/routing.md#cors), [CORS profiles](../services/routing.md#cors-profiles), [API documentation page](../services/routing.md#api-documentation-page).
- Go build tags in `summer.yaml` (`build.tags`), S3 uploads buckets behind the `summercms_s3` tag and a public URL base for a bucket or CDN: [The summer.yaml manifest](../architecture/go-modules-and-workspaces.md#the-summeryaml-manifest), [S3 and S3-compatible buckets](../services/storage.md#s3-and-s3-compatible-buckets), [Public URL base](../services/storage.md#public-url-base).
- Serving uploads from the application and a per-request access log: [Serving files](../services/storage.md#serving-files), [Request log](../services/logging.md#request-log).
- Plugin-provided admin field types: [Plugin field types](../backend/field-types.md).
- Rich text, repeater, money, password and taglist fields, and dependent dropdowns: [Rich text](../backend/forms.md#rich-text), [Repeater](../backend/forms.md#repeater), [Money](../backend/forms.md#money), [Form-only fields](../backend/forms.md#form-only-fields), [Taglist](../backend/forms.md#taglist), [Dependent dropdowns](../backend/forms.md#dependent-dropdowns).
- Record reordering as a tree or a flat list: [Tree reorder](../backend/lists-and-filters.md#tree-reorder), [Flat reorder](../backend/lists-and-filters.md#flat-reorder).
- Singleton controllers, record actions and write-only settings secrets: [Singleton controllers](../backend/admin-controllers.md#singleton-controllers), [Record actions](../backend/admin-controllers.md#record-actions), [Write-only secrets](../backend/settings.md#write-only-secrets).
- Personal API tokens for administrators and the `admin:token:*` commands: [API tokens](../backend/users-and-permissions.md#api-tokens), [Admin accounts](../console/setup-and-maintenance.md#admin-accounts).
- Menu item types that content plugins register for menus and sitemaps: [Menu item types](../plugins/menu-item-types.md).
- New framework modules: [cooler](../../modules/cooler/README.md) (caches, shared counters and the site-cache purge), [seaglass](../../modules/seaglass/README.md) (the rich text sanitizer), [sieve](../../modules/sieve/README.md) (collection helpers) and [signpost](../../modules/signpost/README.md) (the menu item type registry).

View File

@@ -31,7 +31,7 @@ Use cases:
- Context helpers: `bouncer.WithUser` and `bouncer.User` for the principal, `bouncer.WithCredential` and `bouncer.Credential` for the credential behind it (for example an API token record).
- Scope limits for personal API tokens: `bouncer.ScopeLimit` on `bouncer.Principal.Scope` (nil means unrestricted, as for a JWT session). A token guard sets it after filtering the token's scopes by the request method; `Full` means the token acts as the owner on every method (`*`) or on GET/HEAD (`admin:read`).
- Blacklist stores behind `bouncer.BlacklistStore`: `bouncer.MemoryBlacklist` for tests and `bouncer.PostgresBlacklist` for production, which works on a caller-supplied table with `jti`, `expires_at` and `valid_until` columns and rejects unsafe table names.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and `secure: false` in the production environment; `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Cookie transport: `bouncer.LoadCookieConfig` reads the `auth.cookie.*` block into a `bouncer.CookieConfig` and refuses an invalid domain, `same_site: none` without Secure and, in the production environment, a Secure that resolves to false (set to `false`, or inferred from an http or unset `app.url`); `bouncer.CookieConfig.TokenCookie` builds the httpOnly token cookie whose Max-Age is the refresh window, and `bouncer.CookieConfig.ForgetTokenCookie` the matching cookie that deletes it. A hand-built config with `SameSite` unset writes `SameSite=Lax`, and the zero `bouncer.CookieConfig` writes `token` on `/` with `HttpOnly`, `SameSite=Lax` and `Secure`. `bouncer.CookieRefreshHandler` and `bouncer.CookieLogoutHandler` are ready-made handlers for a cookie-based refresh and logout; the application supplies the refresh or revoke call and a writer for its own response envelope.
- Cloudflare Turnstile: `bouncer.Turnstile.Verify` checks a token against siteverify with a 5 second timeout, in PHP's open mode (an empty secret passes) or closed mode (an empty secret is `bouncer.TurnstileUnavailable` in production); `bouncer.Turnstile.Middleware` reads the token from the query, a JSON body or a form body, restores the body and writes refusals through a `bouncer.TurnstileWriter`. surf registers it as `turnstile:open` and `turnstile:closed`.
- Passwords: `bouncer.HashPassword`, `bouncer.CheckPassword` and `bouncer.NeedsRehash` (bcrypt, cost chosen by the caller).
@@ -156,7 +156,7 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `bouncer.BlacklistStore` | Revoked-jti store with a grace window and sweeping. |
| `bouncer.NewMemoryBlacklist` | In-process blacklist for tests. |
| `bouncer.NewPostgresBlacklist` | Blacklist over a `*sql.DB` and a table name. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. |
| `bouncer.CookieConfig` | Token cookie attributes: `Name`, `Domain`, `Path`, `Secure`, `SameSite`. An unset `SameSite` is Lax; the zero config is also `Secure`, while a config with any field set keeps its `Secure` value. |
| `bouncer.CookieConfigSource` | The config methods `bouncer.LoadCookieConfig` reads (`Lookup`, `String`, `Environment`); `*compass.Config` satisfies it. |
| `bouncer.LoadCookieConfig` | Reads and validates `auth.cookie.*`; every error names the key. |
| `bouncer.DefaultCookieName` | `token`, the cookie name when `auth.cookie.name` is unset. |
@@ -191,7 +191,7 @@ func RefreshRoute(cfg bouncer.CookieConfigSource, users bouncer.UserProvider, se
| `auth.cookie.name` | `token` | Cookie name; must be a valid cookie name. |
| `auth.cookie.domain` | empty (host-only) | Cookie domain. Set a parent domain such as `example.com` to share the cookie between `api.example.com` and `www.example.com`; every subdomain then receives it. An invalid domain fails boot. |
| `auth.cookie.path` | `/` | Cookie path; must start with `/`. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. `false` is refused in the production environment. |
| `auth.cookie.secure` | true when `app.url` is https | Sends the cookie over https only. In the production environment a value that resolves to false is refused, whether `false` is set or inferred from an http or unset `app.url`. |
| `auth.cookie.same_site` | `lax` | `lax`, `strict` or `none`; `none` requires Secure. |
Environment variables follow the compass mapping, for example `SUMMER_AUTH__COOKIE__DOMAIN`.

View File

@@ -29,10 +29,12 @@ type CookieConfig struct {
Domain string
// Path is the cookie path (auth.cookie.path, default "/").
Path string
// Secure sends the cookie over https only.
// Secure sends the cookie over https only. It has no default except for
// the zero CookieConfig, which writes a Secure cookie; LoadCookieConfig
// derives it from app.url unless auth.cookie.secure is set.
Secure bool
// SameSite is http.SameSiteLaxMode unless auth.cookie.same_site says
// otherwise.
// SameSite is http.SameSiteLaxMode when unset (also on a hand-built
// config) and otherwise what auth.cookie.same_site says.
SameSite http.SameSite
}
@@ -52,8 +54,9 @@ type CookieConfigSource interface {
// be a valid cookie domain, so it is never dropped silently when the
// cookie is written.
// - auth.cookie.same_site: lax (default), strict or none, case-insensitive.
// - auth.cookie.secure: unset means true exactly when app.url is https; an
// explicit false is refused in the production environment.
// - auth.cookie.secure: unset means true exactly when app.url is https. In
// the production environment a Secure that resolves to false is refused,
// whether set explicitly or inferred from an http or unset app.url.
//
// same_site none without Secure is refused, as browsers drop such cookies.
// Every error names the key.
@@ -101,8 +104,11 @@ func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error) {
if err != nil {
return cfg, err
}
if explicit && !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment")
if !secure && strings.EqualFold(strings.TrimSpace(src.Environment()), "production") {
if explicit {
return cfg, errors.New("bouncer: auth.cookie.secure: false is not allowed in the production environment")
}
return cfg, errors.New("bouncer: auth.cookie.secure resolves to false in the production environment: it is unset and app.url is not an https URL; set app.url to an https URL or auth.cookie.secure: true")
}
cfg.Secure = secure
if cfg.SameSite == http.SameSiteNoneMode && !cfg.Secure {
@@ -259,9 +265,18 @@ func CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error) {
}), nil
}
// withDefaults fills an empty Name and Path, so a zero CookieConfig writes
// the default token cookie on "/".
// withDefaults fills an empty Name and Path and an unset SameSite (Lax). The
// zero CookieConfig also gets Secure, so it writes the default token cookie
// on "/" with HttpOnly, SameSite=Lax and Secure; a config with any field set
// keeps its Secure value (LoadCookieConfig derives it from app.url).
// TokenCookie, ForgetTokenCookie and both handlers go through it.
func (c CookieConfig) withDefaults() CookieConfig {
if c == (CookieConfig{}) {
c.Secure = true
}
if c.SameSite == 0 {
c.SameSite = http.SameSiteLaxMode
}
if strings.TrimSpace(c.Name) == "" {
c.Name = DefaultCookieName
}

View File

@@ -39,16 +39,19 @@ func TestLoadCookieConfigDefaults(t *testing.T) {
t.Fatalf("nil source = %+v", cfg)
}
// An http or unset app.url infers Secure false outside production only;
// production refuses it (TestLoadCookieConfigErrors).
for _, tc := range []struct {
url string
env string
secure bool
}{
{"https://app.example.com", true},
{"HTTPS://app.example.com", true},
{"http://localhost:8000", false},
{"", false},
{"https://app.example.com", "production", true},
{"HTTPS://app.example.com", "production", true},
{"http://localhost:8000", "development", false},
{"", "development", false},
} {
cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: "production"})
cfg, err := LoadCookieConfig(cookieSource{values: map[string]any{"app.url": tc.url}, env: tc.env})
if err != nil {
t.Fatalf("app.url %q: %v", tc.url, err)
}
@@ -104,6 +107,8 @@ func TestLoadCookieConfigErrors(t *testing.T) {
{"same_site none on http app", map[string]any{"auth.cookie.same_site": "none", "app.url": "http://localhost"}, "", "auth.cookie.same_site"},
{"secure false in production", map[string]any{"auth.cookie.secure": false}, "production", "auth.cookie.secure"},
{"secure string false in production", map[string]any{"auth.cookie.secure": "false"}, "Production", "auth.cookie.secure"},
{"secure inferred from http app.url in production", map[string]any{"app.url": "http://api.example.com"}, "production", "app.url"},
{"secure inferred from unset app.url in production", map[string]any{}, "production", "auth.cookie.secure resolves to false"},
{"secure not a bool", map[string]any{"auth.cookie.secure": "maybe"}, "", "auth.cookie.secure"},
{"secure wrong type", map[string]any{"auth.cookie.secure": 1}, "", "auth.cookie.secure"},
{"unknown same_site", map[string]any{"auth.cookie.same_site": "relaxed"}, "", "auth.cookie.same_site"},
@@ -142,11 +147,20 @@ func TestTokenCookieAttributes(t *testing.T) {
}
}
// A zero config writes the default cookie on "/" and host-only.
// A zero config writes the default cookie on "/", host-only, with
// SameSite=Lax and Secure.
z := CookieConfig{}.TokenCookie("v", time.Minute)
if z.Name != DefaultCookieName || z.Path != "/" || z.Domain != "" || !z.HttpOnly {
t.Fatalf("zero config cookie = %+v", z)
}
if z.SameSite != http.SameSiteLaxMode || !z.Secure {
t.Fatalf("zero config cookie SameSite %v Secure %t, want Lax and Secure", z.SameSite, z.Secure)
}
// A partial config gets SameSite=Lax and keeps its Secure value.
p := CookieConfig{Name: "sid"}.TokenCookie("v", time.Minute)
if p.SameSite != http.SameSiteLaxMode || p.Secure {
t.Fatalf("partial config cookie SameSite %v Secure %t, want Lax and not Secure", p.SameSite, p.Secure)
}
if strings.Contains(z.String(), "Domain=") {
t.Fatalf("host-only cookie carries a domain: %s", z.String())
}
@@ -428,3 +442,89 @@ func TestCookieHandlerOptionsValidated(t *testing.T) {
t.Fatalf("valid logout options: %v", err)
}
}
func TestLoadCookieConfigProductionInferredSecure(t *testing.T) {
for _, tc := range []struct {
name string
env string
values map[string]any
wantErr bool
secure bool
}{
{"production, http app.url", "production", map[string]any{"app.url": "http://api.example.com"}, true, false},
{"production, unset app.url", "production", map[string]any{}, true, false},
{"production, malformed app.url", "production", map[string]any{"app.url": "://not a url"}, true, false},
{"production, https app.url", "production", map[string]any{"app.url": "https://api.example.com"}, false, true},
{"production, http app.url and secure true", "production", map[string]any{"app.url": "http://api.example.com", "auth.cookie.secure": true}, false, true},
{"development, http app.url", "development", map[string]any{"app.url": "http://localhost:8000"}, false, false},
} {
t.Run(tc.name, func(t *testing.T) {
cfg, err := LoadCookieConfig(cookieSource{values: tc.values, env: tc.env})
if tc.wantErr {
if err == nil {
t.Fatalf("config = %+v, want an error", cfg)
}
for _, key := range []string{"auth.cookie.secure", "app.url"} {
if !strings.Contains(err.Error(), key) {
t.Errorf("error %q does not name %s", err, key)
}
}
return
}
if err != nil {
t.Fatal(err)
}
if cfg.Secure != tc.secure {
t.Fatalf("Secure = %t, want %t", cfg.Secure, tc.secure)
}
})
}
}
func TestZeroCookieConfigDefaults(t *testing.T) {
var zero CookieConfig
for name, c := range map[string]*http.Cookie{
"TokenCookie": zero.TokenCookie("tok", time.Hour),
"ForgetTokenCookie": zero.ForgetTokenCookie(),
} {
if c.Name != DefaultCookieName || c.Path != "/" || c.Domain != "" || !c.HttpOnly || c.SameSite != http.SameSiteLaxMode || !c.Secure {
t.Errorf("%s of the zero config = %+v, want token on / with HttpOnly, SameSite=Lax and Secure", name, c)
}
}
partial := CookieConfig{Name: "sid"}.TokenCookie("tok", time.Hour)
if partial.SameSite != http.SameSiteLaxMode || partial.Secure {
t.Errorf("{Name: sid} cookie SameSite %v Secure %t, want Lax and not Secure", partial.SameSite, partial.Secure)
}
write := func(w http.ResponseWriter, _ *http.Request, res CookieResult) { w.WriteHeader(res.Status) }
refresh, err := CookieRefreshHandler(CookieRefreshOptions{
RefreshTTL: time.Hour,
Refresh: func(context.Context, string) (string, error) { return "rotated", nil },
Write: write,
})
if err != nil {
t.Fatal(err)
}
logout, err := CookieLogoutHandler(CookieLogoutOptions{
Revoke: func(context.Context, string) error { return nil },
Write: write,
})
if err != nil {
t.Fatal(err)
}
for name, h := range map[string]http.Handler{"refresh": refresh, "logout": logout} {
req := httptest.NewRequest(http.MethodPost, "/auth/"+name, nil)
req.AddCookie(&http.Cookie{Name: DefaultCookieName, Value: "old"})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
set := rec.Header().Get("Set-Cookie")
if set == "" {
t.Fatalf("%s: no Set-Cookie (status %d)", name, rec.Code)
}
for _, want := range []string{DefaultCookieName + "=", "Path=/", "HttpOnly", "SameSite=Lax", "Secure"} {
if !strings.Contains(set, want) {
t.Errorf("%s: Set-Cookie %q lacks %s", name, set, want)
}
}
}
}

View File

@@ -36,7 +36,7 @@ Use cases:
- Keys carry a per-application prefix: `cache.prefix`, or by default `app.name` lower-cased with every run of other characters than `a-z` and `0-9` replaced by `_`, then `:` (`summer:` when `app.name` is empty). Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`.
- `cooler.OpenRedis` accepts `redis://`, `rediss://` (TLS) and `unix://` URLs, does not dial at construction, and skips the `CLIENT SETINFO` and maintenance-notification handshakes.
- Only `serve` installs cooler: `route:list` and `surf.Assemble` in tests keep the in-process store and never dial Redis.
- Site-cache purge: `cooler.SiteCachePurger` sends `POST {"kind":…,"slug":…}` with `Authorization: Bearer <token>` to `site_cache.purge_url`. `cooler.PurgeSiteCacheAfterCommit` registers that request on the save transaction through `lagoon.AfterCommit`, so it runs only after the save commits and never for a save that rolled back. One request per committed save, no debounce.
- Site-cache purge: `cooler.SiteCachePurger` sends `POST {"kind":…,"slug":…}` with `Authorization: Bearer <token>` to `site_cache.purge_url`. `cooler.PurgeSiteCacheAfterCommit` queues that request on the save transaction through `lagoon.AfterCommit`, so it is queued only after the save commits and never for a save that rolled back. The save never waits for the site: a per-purger worker sends the request, with at most one in flight, and saves committed meanwhile collapse into one follow-up request (each purge clears the site's whole route cache), so a bulk delete sends at most two. The purger's `Wait` method drains the queue for tests and commands (see the API reference), and `cooler.Handle.Close` drains it at shutdown.
- A purge never fails a save: each request is bounded by `cooler.SiteCachePurgeTimeout` (2 s) and detached from the request's cancellation, and a failure, an error status or a timeout is one warning (`cooler: site cache purge failed`) with the kind, slug and status. A success is one info line (`cooler: site cache purged`) with the purged count the site answered. Log lines and errors never carry the token or the full URL.
- The purge URL must be http or https with a host and without user info or a fragment; plain http is accepted only for a loopback host (127.0.0.0/8, `::1`, `localhost`), so the token never crosses a network in clear. The token needs at least `cooler.SiteCachePurgeMinTokenLength` (32) characters, the same minimum the site's route applies. Setting only one of the two keys, or an invalid value, fails `serve` at boot with an error that names the key.
- `cooler.Install` publishes the purger at boot unless a plugin already published one (a plugin that still reads its own deprecated keys), and `cooler.SiteCacheFromApp` gives admin hooks the same purger lazily in commands and tests that do not run `serve`. Plugins take the save transaction from `cabana.TxFromContext` themselves; cooler imports neither surf nor cabana.
@@ -164,7 +164,7 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt
| `cooler.Install` | Reads cooler's config, publishes the Cache, opens and pings Redis when a Redis driver is selected, and returns a `cooler.Handle`. Called by surf's `serve`. |
| `cooler.Handle` | What `cooler.Install` set up. |
| `cooler.Handle.Cache` | The Cache `cooler.Install` published or found. |
| `cooler.Handle.Close` | Closes the Redis client `cooler.Install` opened, if any. |
| `cooler.Handle.Close` | Waits for the site-cache purges queued on the purger `cooler.Install` published (up to `cooler.SiteCachePurgeTimeout` plus one second), then closes the Redis client `cooler.Install` opened, if any. |
| `cooler.Handle.RateLimitRedis` | Reports whether `http.rate_limit.store` selects the Redis store. |
| `cooler.Handle.RateLimitStore` | Returns the Redis rate-limit store with the given fallback, or the fallback itself when Redis is not selected. |
| `cooler.Attempter` | One atomic fixed-window admission; the method set of `surf.Store`. |
@@ -175,9 +175,10 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt
| `cooler.SiteCachePurger` | Sends the site-cache purge request; a nil purger does nothing. |
| `cooler.NewSiteCachePurger` | Builds the purger from the values of `site_cache.purge_url` and `site_cache.purge_token`; both empty returns nil; errors name those keys and never the token. |
| `cooler.NewSiteCachePurgerFor` | The same with the config keys its errors name passed in, for a plugin that reads deprecated keys of its own. |
| `cooler.SiteCachePurger.Purge` | Sends one purge request (2 s timeout, detached from cancellation) and logs the outcome; never fails. |
| `cooler.SiteCachePurger.Purge` | Sends one purge request and blocks until it is done (2 s timeout, detached from cancellation, redirects not followed); the send the after-commit queue uses. Logs the outcome; never fails. |
| `cooler.SiteCachePurger.Wait` | Blocks until no queued purge is pending or in flight, or until the context ends (returning its error); nil at once for a nil or idle purger. |
| `cooler.SiteCachePurger.Endpoint` | The purge URL's host and path, for log lines. |
| `cooler.PurgeSiteCacheAfterCommit` | Registers one purge to run after the given transaction commits (at once outside a transaction). |
| `cooler.PurgeSiteCacheAfterCommit` | Queues one purge after the given transaction commits (at once outside a transaction), fire and forget: one request in flight per purger, later saves collapse into one follow-up. |
| `cooler.SiteCacheFromApp` | Returns the published purger, or builds it from the framework keys and publishes it; nil when unset or invalid. |
| `cooler.SiteCachePurgeURLKey` | `site_cache.purge_url`. |
| `cooler.SiteCachePurgeTokenKey` | `site_cache.purge_token`. |
@@ -203,7 +204,7 @@ func AllowExport(ctx context.Context, app *backpack.App, fallback cooler.Attempt
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared). Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
| `redis.url` | none | Redis URL (`redis://`, `rediss://` or `unix://`). Required when a Redis cache or rate-limit store is selected; keep it in the environment when it carries a password. Environment: `SUMMER_REDIS__URL`. |
| `cache.prefix` | from `app.name` | Prefix of every cooler key. Environment: `SUMMER_CACHE__PREFIX`. |
| `site_cache.purge_url` | none | The host site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. http only to a loopback host, https otherwise. Set together with the token, or neither (no purge). Environment: `SUMMER_SITE_CACHE__PURGE_URL`. |
| `site_cache.purge_url` | none | The host site's purge route, for example `http://127.0.0.1:3000/api/__site__/cache/purge`. http only to a loopback host, https otherwise; an https URL on another host goes through the site's proxy, so the site sets `NUXT_SITE_CACHE_ALLOW_PROXIED=true` (see [A purge URL on another host](../../docs/services/caching.md#a-purge-url-on-another-host)). Set together with the token, or neither (no purge). Environment: `SUMMER_SITE_CACHE__PURGE_URL`. |
| `site_cache.purge_token` | none | Bearer token of the purge request, at least 32 characters, the same value as the site's `NUXT_SITE_CACHE_PURGE_TOKEN`. Keep it in the environment. Environment: `SUMMER_SITE_CACHE__PURGE_TOKEN`. |
## Dependencies

View File

@@ -94,21 +94,32 @@ type Cache interface {
Flush(ctx context.Context) error
}
// Handle is what Install set up. Close releases the Redis client Install
// opened, if any.
// Handle is what Install set up. Close drains the site-cache purger and
// releases the Redis client Install opened, if any.
type Handle struct {
client *redis.Client
prefix string
rateRedis bool
cache Cache
log *slog.Logger
purger *SiteCachePurger
}
// Close closes the Redis client Install opened. It is safe on a nil Handle
// and when no Redis driver was selected. A Redis-backed Cache that Install
// built on that client stops working; serve closes the handle on shutdown.
// Close first waits for the site-cache purges queued on the purger Install
// published, up to SiteCachePurgeTimeout plus one second, then closes the
// Redis client Install opened. It is safe on a nil Handle and when no Redis
// driver was selected. A Redis-backed Cache that Install built on that
// client stops working; serve closes the handle on shutdown.
func (h *Handle) Close() error {
if h == nil || h.client == nil {
if h == nil {
return nil
}
if h.purger != nil {
ctx, cancel := context.WithTimeout(context.Background(), SiteCachePurgeTimeout+time.Second)
_ = h.purger.Wait(ctx) // the deadline only bounds shutdown
cancel()
}
if h.client == nil {
return nil
}
err := h.client.Close()
@@ -169,9 +180,11 @@ func Install(ctx context.Context, app *backpack.App) (*Handle, error) {
}
cfg := app.Config
h := &Handle{prefix: keyPrefix(cfg), log: loggerFromApp(app)}
if err := installSiteCache(app, h.log); err != nil {
purger, err := installSiteCache(app, h.log)
if err != nil {
return nil, err
}
h.purger = purger
store, err := driverName(cfg, RateLimitStoreKey)
if err != nil {
return nil, err

View File

@@ -59,9 +59,9 @@ type articlesController struct {
app func() *backpack.App
}
// purge registers one site-cache purge for the saved article, sent after the
// save transaction commits (at once outside one). Without a purge configured
// the purger is nil and nothing is sent.
// purge queues one site-cache purge for the saved article once the save
// transaction commits (at once outside one); the save never waits for the
// site. Without a purge configured the purger is nil and nothing is sent.
func (c articlesController) purge(ctx context.Context, model any) error {
article, ok := model.(*Article)
if !ok {
@@ -121,5 +121,9 @@ func ExampleSiteCacheFromApp() {
if err := c.FormAfterUpdate(context.Background(), &Article{Slug: "hello-summer"}); err != nil {
fmt.Println(err)
}
// The purge is queued; Wait drains the queue (serve does it at shutdown).
if err := cooler.SiteCacheFromApp(app).Wait(context.Background()); err != nil {
fmt.Println(err)
}
// Output: POST /api/__site__/cache/purge {"kind":"article","slug":"hello-summer"}
}

View File

@@ -12,6 +12,7 @@ import (
"net/http"
"net/url"
"strings"
"sync"
"time"
"git.golem15.com/golem15/summercms/modules/backpack"
@@ -40,11 +41,26 @@ const (
// SiteCachePurger asks the host site to drop its page caches after an admin
// save. A nil *SiteCachePurger is disabled and does nothing.
//
// Purges queued by PurgeSiteCacheAfterCommit go through one worker per
// purger: at most one request is in flight, and the saves committed while
// it is sent collapse into one follow-up request carrying the last kind and
// slug (each purge clears the site's whole route cache).
type SiteCachePurger struct {
url string
endpoint string
token string
client *http.Client
mu sync.Mutex
// sending is true while the worker goroutine runs.
sending bool
// pending is the follow-up body, the latest one queued while sending.
pending *siteCachePurgeBody
// coalesced counts the queued bodies pending replaced.
coalesced int
// idle is closed when the current busy period ends; nil while idle.
idle chan struct{}
}
// NewSiteCachePurger returns the purger for purgeURL and token, read from
@@ -90,9 +106,14 @@ func NewSiteCachePurgerFor(urlKey, tokenKey, purgeURL, token string) (*SiteCache
if len(token) < SiteCachePurgeMinTokenLength {
return nil, fmt.Errorf("%s must hold at least %d characters", tokenKey, SiteCachePurgeMinTokenLength)
}
return &SiteCachePurger{url: u.String(), endpoint: u.Host + u.Path, token: token, client: &http.Client{}}, nil
return &SiteCachePurger{url: u.String(), endpoint: u.Host + u.Path, token: token, client: &http.Client{CheckRedirect: noRedirect}}, nil
}
// noRedirect keeps the purge client on the configured URL: a 3xx answer is
// returned as is (and logged as a failed purge with its status), so the
// bearer token never follows a Location and the POST never becomes a GET.
func noRedirect(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
// Endpoint returns the purge URL's host and path, without the scheme, user
// info or query, for log lines.
func (p *SiteCachePurger) Endpoint() string {
@@ -117,43 +138,62 @@ type siteCachePurgeBody struct {
// Purge sends the purge request for one saved entity: a POST of
// {"kind":kind,"slug":slug} with the bearer token, bounded by
// SiteCachePurgeTimeout and detached from ctx's cancellation. It logs the
// outcome and returns nothing: a purge never fails a save. The token is
// never logged.
// SiteCachePurgeTimeout and detached from ctx's cancellation. It blocks
// until the request is done; it is the send the PurgeSiteCacheAfterCommit
// queue uses. It logs the outcome and returns nothing: a purge never fails
// a save. A redirect is never followed; a 3xx answer is logged as a failed
// purge with its status. The token is never logged.
func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
if p == nil {
return
}
p.send(ctx, kind, slug, 0)
}
// send is Purge; coalesced, when above zero, is logged with the outcome:
// the number of queued purges this request stands in for besides its own.
func (p *SiteCachePurger) send(ctx context.Context, kind, slug string, coalesced int) {
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), SiteCachePurgeTimeout)
defer cancel()
log := slog.Default()
failed := func(attrs ...any) {
attrs = append([]any{"kind", kind, "slug", slug}, attrs...)
if coalesced > 0 {
attrs = append(attrs, "coalesced", coalesced)
}
log.Warn("cooler: site cache purge failed", attrs...)
}
raw, err := json.Marshal(siteCachePurgeBody{Kind: kind, Slug: slug})
if err != nil {
log.Warn("cooler: site cache purge failed", "kind", kind, "slug", slug, "error", err.Error())
failed("error", err.Error())
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, p.url, bytes.NewReader(raw))
if err != nil {
log.Warn("cooler: site cache purge failed", "kind", kind, "slug", slug, "error", "invalid request")
failed("error", "invalid request")
return
}
req.Header.Set("Authorization", "Bearer "+p.token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
res, err := p.client.Do(req)
client := p.client
if client == nil {
client = &http.Client{CheckRedirect: noRedirect}
}
res, err := client.Do(req)
if err != nil {
log.Warn("cooler: site cache purge failed", "kind", kind, "slug", slug, "error", purgeTransportError(err))
failed("error", purgeTransportError(err))
return
}
defer res.Body.Close()
body, _ := io.ReadAll(io.LimitReader(res.Body, siteCacheAnswerLimit))
if res.StatusCode < 200 || res.StatusCode > 299 {
log.Warn("cooler: site cache purge failed", "kind", kind, "slug", slug, "status", res.StatusCode)
failed("status", res.StatusCode)
return
}
attrs := []any{"kind", kind, "slug", slug, "status", res.StatusCode}
@@ -163,9 +203,79 @@ func (p *SiteCachePurger) Purge(ctx context.Context, kind, slug string) {
if json.Unmarshal(body, &answer) == nil && answer.Purged != nil {
attrs = append(attrs, "purged", *answer.Purged)
}
if coalesced > 0 {
attrs = append(attrs, "coalesced", coalesced)
}
log.Info("cooler: site cache purged", attrs...)
}
// enqueue queues one purge. With nothing in flight it starts the worker,
// which sends at once; while a request is in flight the body becomes the
// pending follow-up, replacing (and counting) an earlier pending one.
func (p *SiteCachePurger) enqueue(kind, slug string) {
body := siteCachePurgeBody{Kind: kind, Slug: slug}
p.mu.Lock()
if p.sending {
if p.pending != nil {
p.coalesced++
}
p.pending = &body
p.mu.Unlock()
return
}
p.sending = true
p.idle = make(chan struct{})
p.mu.Unlock()
go p.work(body)
}
// work sends body, then each pending follow-up, until none is left; then it
// ends the busy period.
func (p *SiteCachePurger) work(body siteCachePurgeBody) {
coalesced := 0
for {
p.send(context.Background(), body.Kind, body.Slug, coalesced)
p.mu.Lock()
if p.pending == nil {
p.sending = false
close(p.idle)
p.idle = nil
p.mu.Unlock()
return
}
body, coalesced = *p.pending, p.coalesced
p.pending, p.coalesced = nil, 0
p.mu.Unlock()
}
}
// Wait blocks until no queued purge is pending or in flight, or until ctx
// ends, then returning ctx.Err(). It returns nil at once for a nil or idle
// purger. Tests and commands call it before they read what a purge did;
// cooler's Handle.Close calls it at shutdown.
func (p *SiteCachePurger) Wait(ctx context.Context) error {
if p == nil {
return nil
}
if ctx == nil {
ctx = context.Background()
}
for {
p.mu.Lock()
idle := p.idle
p.mu.Unlock()
if idle == nil {
return nil
}
select {
case <-idle:
// A new busy period may have started; look again.
case <-ctx.Done():
return ctx.Err()
}
}
}
// purgeTransportError describes a failed request without the request URL
// or headers.
func purgeTransportError(err error) string {
@@ -179,18 +289,22 @@ func purgeTransportError(err error) string {
return err.Error()
}
// PurgeSiteCacheAfterCommit registers p.Purge(kind, slug) to run after the
// PurgeSiteCacheAfterCommit queues a purge of kind and slug after the
// transaction db belongs to commits, through lagoon.AfterCommit: a failed
// save never purges, and a concurrent page view cannot re-cache the old
// content between the purge and the commit. Each call sends one request
// after its commit; there is no debounce. A nil p does nothing; outside a
// transaction (db nil or not in one) the purge runs at once.
// content between the purge and the commit. The after-commit callback only
// queues, so the save never waits for the site (fire and forget). At most
// one request is in flight per purger, and saves committed meanwhile
// collapse into one follow-up request with the last kind and slug, since
// each purge clears the site's whole route cache. A nil p does nothing;
// outside a transaction (db nil or not in one) the purge is queued at once.
// SiteCachePurger.Wait waits for the queue to drain.
func PurgeSiteCacheAfterCommit(ctx context.Context, db *gorm.DB, p *SiteCachePurger, kind, slug string) {
if p == nil {
return
}
lagoon.AfterCommit(ctx, db, func(ctx context.Context, _ *gorm.DB) {
p.Purge(ctx, kind, slug)
lagoon.AfterCommit(ctx, db, func(context.Context, *gorm.DB) {
p.enqueue(kind, slug)
})
}
@@ -238,23 +352,24 @@ func siteCacheFromConfig(app *backpack.App) (*SiteCachePurger, error) {
}
// installSiteCache publishes the site-cache purger unless one is already
// published (a plugin's alias configuration): an invalid configuration is a
// boot error naming the key.
func installSiteCache(app *backpack.App, log *slog.Logger) error {
// published (a plugin's alias configuration) and returns the purger in
// effect, nil when none is configured: an invalid configuration is a boot
// error naming the key.
func installSiteCache(app *backpack.App, log *slog.Logger) (*SiteCachePurger, error) {
if p, ok := app.Lookup[*SiteCachePurger](); ok && p != nil {
log.Info("cooler: site cache purge enabled", "endpoint", p.Endpoint())
return nil
return p, nil
}
p, err := siteCacheFromConfig(app)
if err != nil {
return err
return nil, err
}
if p == nil {
return nil
return nil, nil
}
if err := app.Publish(p); err != nil {
return fmt.Errorf("cooler: %w", err)
return nil, fmt.Errorf("cooler: %w", err)
}
log.Info("cooler: site cache purge enabled", "endpoint", p.Endpoint())
return nil
return p, nil
}

View File

@@ -244,6 +244,16 @@ func newTestPurger(t *testing.T, srv *httptest.Server) *SiteCachePurger {
return p
}
// waitPurges drains the purges queued on p, failing the test after 5 s.
func waitPurges(t *testing.T, p *SiteCachePurger) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := p.Wait(ctx); err != nil {
t.Fatalf("Wait: %v", err)
}
}
func assertNoToken(t *testing.T, logs *syncBuffer) {
t.Helper()
for _, line := range logs.lines() {
@@ -371,10 +381,12 @@ func TestSiteCachePurgeRequest(t *testing.T) {
}
})
t.Run("outside a transaction the after-commit purge runs at once", func(t *testing.T) {
t.Run("outside a transaction the after-commit purge is queued at once", func(t *testing.T) {
captureLogs(t)
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":2}`)) })
PurgeSiteCacheAfterCommit(context.Background(), nil, newTestPurger(t, srv), "category", "now")
p := newTestPurger(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "category", "now")
waitPurges(t, p)
if got := hits(); len(got) != 1 || got[0].body["kind"] != "category" || got[0].body["slug"] != "now" {
t.Fatalf("requests = %#v, want one", got)
}
@@ -464,6 +476,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if n := countPosts(t, gdb, "rolled-back"); n != 0 {
t.Fatalf("%d rows after rollback", n)
}
waitPurges(t, p)
if got := hits(); len(got) != 0 {
t.Fatalf("a rolled-back save sent %d purges", len(got))
}
@@ -485,6 +498,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if during != 0 {
t.Fatalf("%d purges were sent before the commit", during)
}
waitPurges(t, p)
got := hits()
if len(got) != 1 || got[0].body["kind"] != "post" || got[0].body["slug"] != "committed" {
t.Fatalf("purges = %#v, want one for post committed", got)
@@ -510,6 +524,7 @@ func TestSiteCacheAfterCommitOnly(t *testing.T) {
if afterInner != before {
t.Fatal("the purge ran when the inner transaction finished")
}
waitPurges(t, p)
if got := hits(); len(got) != before+1 || got[len(got)-1].body["slug"] != "nested" {
t.Fatalf("purges = %#v, want one more for nested", got)
}
@@ -531,6 +546,7 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil {
t.Fatalf("a failing purge failed the save: %v", err)
}
waitPurges(t, p)
if n := countPosts(t, gdb, "server-error"); n != 1 || len(hits()) != 1 {
t.Fatalf("rows %d, purges %d; want 1 and 1", n, len(hits()))
}
@@ -559,12 +575,13 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil {
t.Fatalf("a slow purge failed the save: %v", err)
}
if elapsed := time.Since(start); elapsed > SiteCachePurgeTimeout+2*time.Second {
t.Fatalf("the save waited %s, want the purge bounded by %s", elapsed, SiteCachePurgeTimeout)
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("the save waited %s for a queued purge", elapsed)
}
if n := countPosts(t, gdb, "slow"); n != 1 {
t.Fatalf("%d rows, want the save committed", n)
}
waitPurges(t, p)
if lines := logs.lines(); len(lines) != 1 || !strings.Contains(lines[0], "error=timeout") {
t.Fatalf("log = %q", lines)
}
@@ -582,6 +599,7 @@ func TestSiteCacheFailureNeverFails(t *testing.T) {
if err != nil || countPosts(t, gdb, "unreachable") != 1 {
t.Fatalf("Transaction = %v, rows %d", err, countPosts(t, gdb, "unreachable"))
}
waitPurges(t, p)
})
}
@@ -639,3 +657,222 @@ func TestSiteCacheFromApp(t *testing.T) {
t.Fatal("SiteCacheFromApp rebuilt a published purger")
}
}
func TestSiteCachePurgeNoRedirect(t *testing.T) {
logs := captureLogs(t)
target, targetHits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) { _, _ = w.Write([]byte(`{"purged":1}`)) })
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Location", target.URL+"/api/__site__/cache/purge")
w.WriteHeader(http.StatusTemporaryRedirect)
})
newTestPurger(t, srv).Purge(context.Background(), "post", "moved")
if len(hits()) != 1 {
t.Fatalf("%d requests to the purge URL, want 1", len(hits()))
}
if got := targetHits(); len(got) != 0 {
t.Fatalf("the redirect target received %d requests: %#v", len(got), got)
}
lines := logs.lines()
if len(lines) != 1 || !strings.Contains(lines[0], "level=WARN") || !strings.Contains(lines[0], `msg="cooler: site cache purge failed"`) || !strings.Contains(lines[0], "status=307") {
t.Fatalf("log = %q, want one failed purge with status=307", lines)
}
assertNoToken(t, logs)
}
func TestSiteCachePurgeAsyncCoalesced(t *testing.T) {
logs := captureLogs(t)
release := make(chan struct{})
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
<-release
_, _ = w.Write([]byte(`{"purged":3}`))
})
p := newTestPurger(t, srv)
released := false
defer func() {
if !released {
close(release)
}
waitPurges(t, p)
}()
for i := range 20 {
start := time.Now()
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "slug-"+strconv.Itoa(i))
if elapsed := time.Since(start); elapsed > 50*time.Millisecond {
t.Fatalf("call %d blocked for %s", i, elapsed)
}
}
close(release)
released = true
waitPurges(t, p)
got := hits()
if len(got) != 2 {
t.Fatalf("%d requests, want 2 (one in flight, one coalesced follow-up)", len(got))
}
if got[0].body["slug"] != "slug-0" || got[1].body["slug"] != "slug-19" {
t.Fatalf("slugs %q, %q; want slug-0 then the last, slug-19", got[0].body["slug"], got[1].body["slug"])
}
lines := logs.lines()
if len(lines) != 2 || strings.Contains(lines[0], "coalesced=") || !strings.Contains(lines[1], "slug=slug-19") || !strings.Contains(lines[1], "coalesced=18") {
t.Fatalf("log = %q, want the second purged line with coalesced=18", lines)
}
assertNoToken(t, logs)
}
// blockingReceiver answers once release is closed (or the request ends).
func blockingReceiver(t *testing.T) (*httptest.Server, func() []purgeHit, func()) {
t.Helper()
release := make(chan struct{})
var once sync.Once
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, r *http.Request) {
select {
case <-release:
_, _ = w.Write([]byte(`{"purged":1}`))
case <-r.Context().Done():
}
})
free := func() { once.Do(func() { close(release) }) }
t.Cleanup(free)
return srv, hits, free
}
func TestSiteCachePurgeWaitAndClose(t *testing.T) {
t.Run("nil and idle purgers return at once", func(t *testing.T) {
var nilPurger *SiteCachePurger
if err := nilPurger.Wait(context.Background()); err != nil {
t.Fatalf("nil Wait = %v", err)
}
srv, _ := purgeReceiver(t, func(http.ResponseWriter, *http.Request) {})
ctx, cancel := context.WithCancel(context.Background())
cancel()
if err := newTestPurger(t, srv).Wait(ctx); err != nil {
t.Fatalf("idle Wait under a cancelled context = %v, want nil", err)
}
})
t.Run("Wait gives up with the context and the purge is still sent", func(t *testing.T) {
captureLogs(t)
srv, hits, free := blockingReceiver(t)
p := newTestPurger(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "pending")
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond)
defer cancel()
if err := p.Wait(ctx); !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Wait = %v, want context.DeadlineExceeded", err)
}
free()
waitPurges(t, p)
if got := hits(); len(got) != 1 || got[0].body["slug"] != "pending" {
t.Fatalf("requests = %#v, want the queued purge", got)
}
})
install := func(t *testing.T, srv *httptest.Server) (*Handle, *SiteCachePurger) {
t.Helper()
cfg := testConfig(t)
if err := cfg.Set(SiteCachePurgeURLKey, srv.URL+"/api/__site__/cache/purge"); err != nil {
t.Fatal(err)
}
if err := cfg.Set(SiteCachePurgeTokenKey, purgeToken40); err != nil {
t.Fatal(err)
}
app := backpack.New(cfg)
h, err := Install(context.Background(), app)
if err != nil {
t.Fatal(err)
}
p := SiteCacheFromApp(app)
if p == nil {
t.Fatal("Install published no purger")
}
return h, p
}
t.Run("Handle.Close waits for a queued purge", func(t *testing.T) {
captureLogs(t)
srv, hits := purgeReceiver(t, func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(200 * time.Millisecond)
_, _ = w.Write([]byte(`{"purged":1}`))
})
h, p := install(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "at-shutdown")
if err := h.Close(); err != nil {
t.Fatal(err)
}
if got := hits(); len(got) != 1 || got[0].body["slug"] != "at-shutdown" {
t.Fatalf("requests after Close = %#v, want the queued purge", got)
}
})
t.Run("Handle.Close is bounded when the site hangs", func(t *testing.T) {
captureLogs(t)
srv, _, _ := blockingReceiver(t)
h, p := install(t, srv)
PurgeSiteCacheAfterCommit(context.Background(), nil, p, "post", "hung")
start := time.Now()
if err := h.Close(); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed > SiteCachePurgeTimeout+time.Second+500*time.Millisecond {
t.Fatalf("Close took %s, want at most %s plus a second", elapsed, SiteCachePurgeTimeout)
}
waitPurges(t, p)
})
}
func TestSiteCacheBulkCommitCoalesced(t *testing.T) {
gdb := purgeDB(t)
captureLogs(t)
ctx := context.Background()
run := func(t *testing.T, save func(p *SiteCachePurger) error, last string) {
t.Helper()
srv, hits, free := blockingReceiver(t)
p := newTestPurger(t, srv)
start := time.Now()
if err := save(p); err != nil {
t.Fatal(err)
}
if elapsed := time.Since(start); elapsed > 500*time.Millisecond {
t.Fatalf("the saves took %s while the site was blocked, want under 500ms", elapsed)
}
free()
waitPurges(t, p)
got := hits()
if len(got) != 2 {
t.Fatalf("%d requests, want 2 (one in flight, one coalesced follow-up)", len(got))
}
if got[1].body["slug"] != last {
t.Fatalf("follow-up slug = %q, want the last, %q", got[1].body["slug"], last)
}
}
t.Run("25 purges in one transaction", func(t *testing.T) {
run(t, func(p *SiteCachePurger) error {
return lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
for i := range 25 {
slug := "bulk-" + strconv.Itoa(i)
if err := tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error; err != nil {
return err
}
PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug)
}
return nil
})
}, "bulk-24")
})
t.Run("10 separate transactions", func(t *testing.T) {
run(t, func(p *SiteCachePurger) error {
for i := range 10 {
slug := "each-" + strconv.Itoa(i)
if err := lagoon.Transaction(ctx, gdb, func(ctx context.Context, tx *gorm.DB) error {
PurgeSiteCacheAfterCommit(ctx, tx, p, "post", slug)
return tx.Exec(`INSERT INTO purge_posts (slug) VALUES (?)`, slug).Error
}); err != nil {
return err
}
}
return nil
}, "each-9")
})
}

View File

@@ -38,7 +38,7 @@ Use cases:
- Lifecycle and relations: hook interfaces matching GORM's native method names (`lagoon.HasBeforeCreate`, `lagoon.HasBeforeSave`, `lagoon.HasBeforeDelete`, `lagoon.HasAfterDelete`) plus `lagoon.HasBeforeValidate`; `lagoon.WithSoftDeleteCascade` runs a cascade inside the parent delete; `lagoon.RegisterJoinTable` wires pivot models with business columns.
- Deferred binding: WinterCMS's `deferred_bindings` table holds the uploads and related-record changes of a form whose record is not saved yet. Every operation takes a `lagoon.DeferredKey` (the form's session key, the owning backend admin's id and the master record's morph type from `lagoon.MorphType`) and never reads or changes another admin's rows, since each row stores `backend_user_id`. `lagoon.DeferredBind` and `lagoon.DeferredUnbind` port WinterCMS's duplicate and cancel rules: a repeated bind writes nothing, and an unbind of a slave with a pending bind deletes that bind and returns it so the caller can remove what it created. `lagoon.DeferredBindings` reads and locks a session's bindings for the save that commits them, `lagoon.DeferredForget` deletes them once applied, and `lagoon.DeferredSlaves` is the subquery a list uses to include pending rows. A child created under deferral carries the `lagoon.DeferredEnvelope` (`{"created":true,"pivot":{...},"upload_id":"..."}`) in `pivot_data`. `lagoon.PurgeDeferred` removes expired bindings: it deletes an unattached `system_files` row a bind points at, and its blobs only after the commit, deletes a child only when its binding carries the created envelope, keeps records that were only linked, and locks each batch with `FOR UPDATE SKIP LOCKED`.
- Imports from Laravel: `lagoon.DecryptLaravelPayload` decrypts Laravel `encrypted` payloads with the old application key, for one-off data imports.
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming and, in image mode, the `attach.IsAllowedImage` content guard), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
- Attachments (`attach`): the `attach.File` model for `system_files` rows, WinterCMS-compatible partitioned storage keys (`attach.BlobKey`, `attach.PartitionDirectory`), an S3 bucket option behind the `attach.S3BuildTag` build tag (an untagged binary refuses an `s3://` bucket URL at boot with an error naming the tag), public URLs (`attach.PublicURL` for any key, under `storage.uploads.public_url` when a public bucket or CDN serves the files (`attach.PublicURLBase`), `attach.File.URL` for an original, matching WinterCMS's `File::getPath()` under the WinterCMS layout), on-demand thumbnails through `attach.File.Thumb` for JPEG, PNG, GIF and WebP originals (a WebP original's thumbnail is JPEG bytes under its `.webp` name, since WebP cannot be encoded; a missing, undecodable or oversized original gets WinterCMS's broken-image picture, `attach.BrokenImagePNG`, as its thumbnail, as `File::makeThumb` does), storing uploads through `attach.Store` (a server-generated disk name whose extension follows the sniffed content through `attach.DiskExtension`, so a PNG named `x.html` is stored as `.png`, an extension allow-list with `attach.DefaultImageExtensions` and `attach.DefaultFileExtensions` as defaults, a MIME filter, a size limit enforced while streaming, in image mode the `attach.IsAllowedImage` content guard, and the refusal of protected uploads while `storage.uploads.public_url` serves the bucket publicly, `attach.ErrProtectedNeedsPrivateBucket`), attachment relation declarations (`attach.Relation`, `attach.HasRelations`), static serving with an optional `is_public` gate (`attach.StaticHandlerPublic`), and a two-phase delete that removes blobs only after the database transaction commits (`attach.DeleteForOwner`, `attach.DeleteKeys`).
## Usage
@@ -211,6 +211,7 @@ func (p *Plugin) Migrations() []*gormigrate.Migration {
| `attach.ErrFileType` | Returned by `attach.Store` for a missing, malformed or disallowed extension. |
| `attach.ErrMIMEType` | Returned by `attach.Store` when the content type matches no `MIMETypes` entry. |
| `attach.ErrNotImage` | Returned by `attach.Store` in image mode for content that is not an allowed image. |
| `attach.ErrProtectedNeedsPrivateBucket` | Returned by `attach.Store` for a protected (`Public` false) upload while `storage.uploads.public_url` is set, before any blob is written: that bucket is served publicly. |
| `attach.DefaultImageExtensions` | Image-mode extensions when none are given: jpg, jpeg, png, gif, webp. |
| `attach.DefaultFileExtensions` | File-mode extensions when none are given: WinterCMS's default list without the script-capable types. |
| `attach.AllowedImageMIMEs` | The sniffed content types the image guard accepts. |
@@ -232,7 +233,7 @@ Keys are read from the compass config; the `SUMMER_` environment overlay maps a
| `app.previous_keys` | empty | List of earlier base64 keys still accepted when decrypting, for key rotation. |
| `storage.uploads.bucket_url` | none (required by `attach.OpenBucket`) | Uploads bucket URL, `file://`, `mem://` or `s3://` (with `attach.S3BuildTag`; query parameters such as `region`, `endpoint` and `use_path_style` per gocloud.dev, credentials from the AWS environment). |
| `storage.uploads.public_path_prefix` | `/storage/uploads` | URL prefix used when building public file and thumbnail URLs. |
| `storage.uploads.public_url` | empty | Absolute `http` or `https` base of a public bucket or CDN; when set, file and thumbnail URLs are this base plus the partition path. A malformed value fails `attach.OpenBucket`; a set value logs that the bucket must hold only public files. |
| `storage.uploads.public_url` | empty | Absolute `http` or `https` base of a public bucket or CDN; when set, file and thumbnail URLs are this base plus the partition path. A malformed value fails `attach.OpenBucket`; a set value logs a warning that the bucket must hold only public files, and `attach.Store` refuses protected uploads (`attach.ErrProtectedNeedsPrivateBucket`). |
| `database.deferred_bindings.purge_days` | `5` | Age in days after which `deferred:purge` removes a deferred binding, WinterCMS's `cleanUp(5)`; the `--days` flag overrides it. |
| `database.deferred_bindings.purge_at` | `03:00` | Daily `HH:MM` (in `app.timezone`) at which the scheduler runs `deferred:purge`; an empty value disables the framework entry. |

View File

@@ -115,7 +115,8 @@ func checkS3Driver(bucketURL string) error {
// binary built with S3BuildTag) and records
// storage.uploads.public_path_prefix and storage.uploads.public_url. An
// empty bucket_url or a malformed public_url fails boot; a set public_url
// logs one info line saying the bucket behind it must hold only public files.
// logs one warning saying the bucket behind it must hold only public files
// and that Store refuses protected uploads (ErrProtectedNeedsPrivateBucket).
func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error) {
if cfg == nil {
return nil, fmt.Errorf("attach: config is nil")
@@ -149,8 +150,9 @@ func OpenBucket(ctx context.Context, cfg *compass.Config) (*blob.Bucket, error)
return bucket, nil
}
setPublicURLBase(publicURL.String())
slog.Default().InfoContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+
"which serves any key without the is_public check, so the bucket behind it must hold only public files",
slog.Default().WarnContext(ctx, "attach: storage.uploads.public_url is set; every file and thumbnail URL points at this host, "+
"which serves any key without the is_public check, so the bucket behind it must hold only public files: "+
"protected (is_public false) uploads are refused",
slog.String("host", publicURL.Host))
return bucket, nil
}

View File

@@ -35,6 +35,11 @@ var (
// ErrNotImage is returned by Store in image mode when the bytes are not
// an image IsAllowedImage accepts.
ErrNotImage = errors.New("attach: file is not an allowed image")
// ErrProtectedNeedsPrivateBucket is returned by Store for a protected
// (Upload.Public false) upload while storage.uploads.public_url is set:
// the host behind public_url serves every key of the bucket without the
// is_public check, so a protected file needs a private bucket.
ErrProtectedNeedsPrivateBucket = errors.New("attach: storage.uploads.public_url is set, so the bucket is served publicly and a protected (is_public false) upload needs a private bucket: unset storage.uploads.public_url or make the relation public")
)
// DefaultImageExtensions is the extension list of an image upload when
@@ -88,6 +93,10 @@ type Limits struct {
// Store saves an upload as an unattached system_files row.
//
// A protected upload (Upload.Public false) while storage.uploads.public_url
// is set returns ErrProtectedNeedsPrivateBucket before the body is read or
// any blob is written.
//
// It accepts the client extension, lower-cased, only when it matches
// [a-z0-9]{1,10} and is allowed by Limits (else ErrFileType). It reads up to
// 1 MiB ahead to sniff the content type from the bytes; in image mode those
@@ -123,6 +132,9 @@ func Store(ctx context.Context, db *gorm.DB, bucket *blob.Bucket, in Upload, lim
if lim.MaxBytes < 0 {
return nil, fmt.Errorf("attach: negative size limit %d", lim.MaxBytes)
}
if !in.Public && PublicURLBase() != "" {
return nil, ErrProtectedNeedsPrivateBucket
}
name := clientBaseName(in.FileName)
ext := strings.ToLower(strings.TrimPrefix(path.Ext(name), "."))
if !extPattern.MatchString(ext) || !slices.Contains(allowedExtensions(lim), ext) {

View File

@@ -3,6 +3,7 @@ package attach
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"image"
@@ -19,6 +20,7 @@ import (
"git.golem15.com/golem15/summercms/modules/compass"
"gocloud.dev/blob"
"gocloud.dev/blob/memblob"
"gorm.io/gorm"
)
// winterLayoutBucket opens a mem:// bucket with the WinterCMS public prefix
@@ -466,3 +468,26 @@ func TestPublicURLValidation(t *testing.T) {
}
}
}
func TestStoreProtectedNeedsPrivateBucket(t *testing.T) {
// Not parallel: the public URL base is package state.
t.Cleanup(func() { setPublicURLBase("") })
bucket := memblob.OpenBucket(nil)
t.Cleanup(func() { _ = bucket.Close() })
db := &gorm.DB{}
setPublicURLBase("https://cdn.example.com/uploads/")
_, err := Store(t.Context(), db, bucket, Upload{FileName: "notes.txt", Body: bytes.NewReader([]byte("secret"))}, Limits{})
if !errors.Is(err, ErrProtectedNeedsPrivateBucket) {
t.Fatalf("protected upload with public_url: error = %v, want ErrProtectedNeedsPrivateBucket", err)
}
for _, want := range []string{"storage.uploads.public_url", "private bucket"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q lacks %q", err, want)
}
}
iter := bucket.List(nil)
if obj, err := iter.Next(t.Context()); err == nil {
t.Fatalf("a blob was written: %s", obj.Key)
}
}

View File

@@ -28,7 +28,7 @@ Use cases:
- Named middleware from plugins (`pact.HasMiddleware`), parameterized middleware used as `name:param` (`pact.HasMiddlewareFactories`) and house middleware for the JSON envelope and error handling (`pact.HasHouseMiddleware`). Duplicate or unknown names fail boot.
- Raw groups (`surf.Router.GroupRaw`) for routes that must not be wrapped in house middleware, such as webhooks or file streams: house middleware is refused there, the default body limit is skipped and a panic returns a bare 500.
- Built-in middleware names: `throttle:<bucket>` or `throttle:<max>,<minutes>`, `body.limit:<bytes>`, `cors:<profile>`, `turnstile:open` and `turnstile:closed` (Cloudflare Turnstile through `bouncer.Turnstile`, built from `turnstile.secret`, with the client IP from `surf.ClientIP` and refusals written by a published `bouncer.TurnstileWriter` when there is one), `locale.from-principal`, plus `backend` (the admin guard) when the admin is enabled.
- Named CORS profiles: `cors:<profile>` applies `http.cors_profiles.<profile>` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route group, and surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot.
- Named CORS profiles: `cors:<profile>` applies `http.cors_profiles.<profile>` (read by `surf.LoadCORSProfiles`, validated like `http.cors`) to a route or route group. The profile wraps every other layer of the route, whatever its position in the list or the group nesting, so every response carries its headers: guard refusals (401, 403), throttle (429), Turnstile (422, 503), the body limit (413) and recovered panics (500) included. A handler's own `Vary` is merged with the profile's `Vary: Origin`, not replacing it. surf registers an `OPTIONS` route for each such path that answers the preflight from the profile, since ServeMux would answer it with 405 before the middleware runs. An unknown profile, two profiles on one path and a profiled path that `http.cors.paths` also matches fail boot.
- Fixed-window rate limiting (`surf.FixedWindowLimiter`): named buckets from plugins that implement `surf.BucketProvider`, or inline limits keyed by the signed-in user, or by client IP for guests. Rejected requests get a 429 with `Retry-After` and `X-RateLimit-*` headers. The in-process `surf.MemoryStore` sits behind the `surf.Store` interface; `surf.BuildRouter` uses a `surf.Store` published on the app instead when there is one, which is how `serve` shares counters across instances through [cooler](../cooler/README.md)'s Redis store (`http.rate_limit.store: redis`).
- Client IP resolution for limiter keys (`surf.ClientIP`) that only trusts `X-Forwarded-For` hops when the direct peer is inside a configured trusted proxy range (`surf.TrustedProxies`).
- Every non-raw route runs inside JSON panic recovery (an opaque 500 via [wire](../wire/README.md)), gets the request locale from a published `surf.LocaleResolver` when one exists or from the `Accept-Language` header otherwise (see [towel](../towel/README.md)), and a request body cap. Responses are buffered until the handler returns, so a panic never leaves a half-written body.
@@ -36,7 +36,7 @@ Use cases:
- Optional `surf.LocaleResolver`: a compiled plugin publishes the interface through backpack; surf looks it up without importing the plugin. When present, it may strip an enabled locale prefix before routing and writes a validated code with `towel.WithLocale`. When absent, hosts keep the raw `Accept-Language` header and `surf.LocaleFromPrincipal` overlay.
- `surf.LocaleFromPrincipal` switches the request locale to the signed-in user's preferred locale.
- Opt-in uploads route: with `storage.uploads.serve` true, `surf.BuildRouter` mounts `GET <storage.uploads.public_path_prefix>/{path...}` (owned by `summercms.surf`) on `attach.StaticHandlerPublic`, so a file is served only when its `system_files` row is public. Every response carries `X-Content-Type-Options: nosniff` and a sandboxing `Content-Security-Policy`, and a file whose type is not a raster image (JPEG, PNG, GIF, WebP, AVIF) is sent with `Content-Disposition: attachment`, so a stored HTML or SVG file never renders on the app origin. The bucket and the database are looked up per request, so `route:list` lists the route without opening either; until `serve` publishes them it answers 404. The route owns the whole prefix: `surf.BuildRouter` fails when a plugin route sits at or under it, and when the prefix is not an absolute URL path (no leading slash, a scheme or host, a query, an empty, `.` or `..` segment, or `{`/`}`).
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a nonce-based `Content-Security-Policy`.
- Opt-in API documentation page: with `http.api_docs.enabled` true, `surf.BuildRouter` mounts `GET /api-docs` (a Swagger UI page) and `GET /api-docs.json` (owned by `summercms.apidocs`, public, no middleware). The spec comes from the one plugin implementing `surf.APIDocsProvider`, as YAML or JSON, converted once at boot to compact JSON in source key order; zero or several providers, or a spec that does not convert, fail boot. The page loads swagger-ui-dist `surf.SwaggerUIVersion` from jsDelivr with Subresource Integrity hashes on all three assets and a `Content-Security-Policy` whose only script source is a per-request nonce; every script tag, the two jsDelivr scripts included, carries it, so no other jsDelivr script can run.
- Opt-in request log: with `http.access_log` true, every request writes one Info record `http request` after it is served, with `method`, `pattern` (the matched route, such as `GET /posts/{id}`), `status` and `duration_ms`. A request whose client went away adds `client_gone=true`; its 5xx is logged as 499 (client closed request) only when the client had already gone when the status was written and the handler did not panic, so a 5xx written before the cancellation and a recovered panic keep their real status. An unmatched request (a 404 or 405 from the mux, or a CORS preflight answered before routing) has an empty `pattern`. The URL path, the query string, headers and the client address are never logged, for an unmatched request too: a token route requested with another method answers 405 before any route handler runs. Records go to the app's published `*slog.Logger`, else `slog.Default`, so the [sunscreen](../sunscreen/README.md) redaction still applies.
- A read-only route table (`surf.Router.Routes`), the admin mount status (`surf.Router.AdminPrefix`) and the `serve` and `route:list` commands.
@@ -154,15 +154,15 @@ The generated application `main` wires surf in with `surf.ServeCommand` and `sur
| `http.body_limits.upload_bytes` | none, required | Upload body cap in bytes. Must be a whole number of at least 1; it is validated at boot. |
| `http.trusted_proxies` | empty | List of CIDRs whose `X-Forwarded-For` header is trusted when resolving the client IP. Malformed entries are skipped. |
| `http.cors.paths` | empty | Path globs (`api/*`) that get CORS headers. With no `http.cors` section no CORS headers are sent. |
| `http.cors.allowed_origins` | empty | Allowed origins, echoed exactly; `*` allows any (without credentials only). An entry with a `*` inside, such as `https://*.example.com`, matches any characters there, as Laravel's `Str::is` does. |
| `http.cors.allowed_origins_patterns` | empty | Go regular expressions matched against the origin. A pattern that does not compile, or one still wrapped in PHP delimiters (`#^...$#`), fails boot. |
| `http.cors.allowed_origins` | empty | Allowed origins, echoed exactly; `*` allows any (without credentials only). An entry with a `*` inside, such as `https://*.example.com`, matches any characters there, as Laravel's `Str::is` does. With credentials, such a `*` must be the whole leftmost host label after a literal scheme (`https://*.example.com`; `https://*` and `*://*` fail boot), and no entry may match an origin outside the configured domains. |
| `http.cors.allowed_origins_patterns` | empty | Go regular expressions matched against the origin. A pattern that does not compile, or one still wrapped in PHP delimiters (`#^...$#`), fails boot. With credentials, a pattern must be anchored with `^` and `$` and must not match an origin outside the configured domains (`^https?://.*$` fails boot). |
| `http.cors.allowed_methods` | empty | Methods sent in `Access-Control-Allow-Methods`; `*` allows any. |
| `http.cors.allowed_headers` | empty | Headers sent in `Access-Control-Allow-Headers`; `*` allows any. |
| `http.cors.exposed_headers` | empty | Headers sent in `Access-Control-Expose-Headers`. |
| `http.cors.max_age` | `0` | Preflight cache time in seconds. |
| `http.cors.supports_credentials` | `false` | Sends `Access-Control-Allow-Credentials: true`. Combined with `*` in `allowed_origins` it fails boot. With credentials, a pattern or several origins, every response on a CORS path carries `Vary: Origin`, preflights and disallowed origins included. |
| `http.cors_profiles.<name>.*` | none | A CORS profile for `cors:<name>`: the `http.cors` keys except `paths` (`allowed_origins`, `allowed_origins_patterns`, `allowed_methods`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`), validated the same way. Names use lower-case letters, digits, underscore and dash. |
| `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere). A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. |
| `turnstile.secret` | empty | Cloudflare Turnstile secret for `turnstile:open` and `turnstile:closed`. Without it `turnstile:open` passes and `turnstile:closed` answers 503 in the production environment (passes elsewhere), and when routes name either, `surf.BuildRouter` logs one start-up warning, `surf: turnstile.secret is empty`, with the route count. A `*bouncer.Turnstile` published on the app replaces the verifier built from this key. Environment: `SUMMER_TURNSTILE__SECRET`. |
| `turnstile.field` | `turnstile_token` | Request field (query, JSON body or form body) carrying the Turnstile token. |
| `http.rate_limit.store` | `memory` | Where `throttle` counters live: `memory` (per instance) or `redis` (shared by every instance through [cooler](../cooler/README.md), which then needs `redis.url`). Only `serve` installs the Redis store; `route:list` keeps the memory store and never dials Redis. Any other value fails at boot. Environment: `SUMMER_HTTP__RATE_LIMIT__STORE`. |
| `http.api_docs.enabled` | `false` | Mounts `GET <http.api_docs.path>` (Swagger UI page) and `GET <http.api_docs.path>.json` (the spec of the one `surf.APIDocsProvider` plugin). Both are public. Environment: `SUMMER_HTTP__API_DOCS__ENABLED`. |

View File

@@ -40,7 +40,6 @@ const (
swaggerUIBundleIntegrity = "sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw"
swaggerUIPreset = "https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/swagger-ui-standalone-preset.js"
swaggerUIPresetIntegrity = "sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF"
swaggerUICDN = "https://cdn.jsdelivr.net"
)
const (
@@ -63,8 +62,8 @@ var apiDocsPage = template.Must(template.New("apidocs").Parse(`<!DOCTYPE html>
</head>
<body>
<div id="swagger-ui"></div>
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous"></script>
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous"></script>
<script src="` + swaggerUIBundle + `" integrity="` + swaggerUIBundleIntegrity + `" crossorigin="anonymous" nonce="{{.Nonce}}"></script>
<script src="` + swaggerUIPreset + `" integrity="` + swaggerUIPresetIntegrity + `" crossorigin="anonymous" nonce="{{.Nonce}}"></script>
<script nonce="{{.Nonce}}">
window.addEventListener('load', function () {
window.ui = SwaggerUIBundle({
@@ -185,8 +184,8 @@ func apiDocsJSON(raw []byte) ([]byte, error) {
return buf.Bytes(), nil
}
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce for
// its one inline script.
// apiDocsPageHandler renders the Swagger UI page with a fresh CSP nonce on
// every script tag, the two pinned assets and the inline start-up script.
func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
return func(w http.ResponseWriter, _ *http.Request) {
nonceBytes := make([]byte, 18)
@@ -204,7 +203,9 @@ func apiDocsPageHandler(title, specURL string) http.HandlerFunc {
h.Set("Content-Type", "text/html; charset=utf-8")
h.Set("X-Content-Type-Options", "nosniff")
h.Set("Referrer-Policy", "no-referrer")
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"' "+swaggerUICDN+"; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
// The nonce is the only script source: the two jsDelivr scripts
// carry it next to their SRI hash, so no other jsDelivr script runs.
h.Set("Content-Security-Policy", "script-src 'nonce-"+nonce+"'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")
_, _ = w.Write(page.Bytes())
}
}

View File

@@ -127,10 +127,13 @@ func TestAPIDocsServesPinnedPage(t *testing.T) {
t.Errorf("headers = %v", hd)
}
csp := hd.Get("Content-Security-Policy")
m := regexp.MustCompile(`script-src 'nonce-([A-Za-z0-9_-]+)' https://cdn\.jsdelivr\.net;`).FindStringSubmatch(csp)
m := regexp.MustCompile(`script-src 'nonce-([A-Za-z0-9_-]+)';`).FindStringSubmatch(csp)
if m == nil {
t.Fatalf("CSP %q", csp)
}
if strings.Contains(csp, "https:") {
t.Errorf("CSP %q names a host source; script-src must hold only the nonce", csp)
}
for _, part := range []string{"object-src 'none'", "base-uri 'none'", "frame-ancestors 'none'"} {
if !strings.Contains(csp, part) {
t.Errorf("CSP %q lacks %s", csp, part)
@@ -140,6 +143,16 @@ func TestAPIDocsServesPinnedPage(t *testing.T) {
if len(scripts) != 1 || scripts[0][1] != m[1] {
t.Fatalf("inline script nonces %v, CSP nonce %q", scripts, m[1])
}
// Every script tag, the two pinned assets included, carries the nonce.
tags := regexp.MustCompile(`<script[^>]*>`).FindAllString(page, -1)
if len(tags) != 3 {
t.Fatalf("script tags = %q, want 3", tags)
}
for _, tag := range tags {
if !strings.Contains(tag, `nonce="`+m[1]+`"`) {
t.Errorf("script tag %q lacks the CSP nonce", tag)
}
}
if !strings.Contains(page, `url: "/api-docs.json"`) || !strings.Contains(page, "validatorUrl: null") {
t.Error("page does not load the local spec with the validator off")
}
@@ -292,3 +305,67 @@ func TestAPIDocsTitleEscaped(t *testing.T) {
t.Fatal("default title missing")
}
}
// cspDirectives splits a Content-Security-Policy value into its directives.
func cspDirectives(csp string) map[string][]string {
out := map[string][]string{}
for _, part := range strings.Split(csp, ";") {
fields := strings.Fields(part)
if len(fields) == 0 {
continue
}
out[fields[0]] = fields[1:]
}
return out
}
func TestAPIDocsCSPNoHostSource(t *testing.T) {
h, err := Assemble(apiDocsApp(t, "api_docs:\n enabled: true\n"), []party.Plugin{specPlugin{id: "acme.zoo", spec: orderedSpecYAML}})
if err != nil {
t.Fatal(err)
}
nonces := map[string]bool{}
for range 2 {
rec := apiGet(h, "/api-docs")
if rec.Code != http.StatusOK {
t.Fatalf("GET /api-docs = %d", rec.Code)
}
dirs := cspDirectives(rec.Header().Get("Content-Security-Policy"))
script := dirs["script-src"]
if len(script) != 1 || !strings.HasPrefix(script[0], "'nonce-") || !strings.HasSuffix(script[0], "'") {
t.Fatalf("script-src = %q, want exactly one nonce source", script)
}
nonce := strings.TrimSuffix(strings.TrimPrefix(script[0], "'nonce-"), "'")
if nonce == "" {
t.Fatal("empty nonce")
}
for _, d := range []string{"object-src", "base-uri", "frame-ancestors"} {
if got := dirs[d]; len(got) != 1 || got[0] != "'none'" {
t.Errorf("%s = %q, want 'none'", d, got)
}
}
page := rec.Body.String()
tags := regexp.MustCompile(`<script[^>]*>`).FindAllString(page, -1)
if len(tags) != 3 {
t.Fatalf("script tags = %q, want 3", tags)
}
for _, tag := range tags {
if !strings.Contains(tag, ` nonce="`+nonce+`"`) {
t.Errorf("script tag %q lacks nonce %q", tag, nonce)
}
}
for _, sri := range []string{
"sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW",
"sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw",
"sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF",
} {
if !strings.Contains(page, `integrity="`+sri+`"`) {
t.Errorf("page lacks integrity %s", sri)
}
}
nonces[nonce] = true
}
if len(nonces) != 2 {
t.Fatal("two requests got the same nonce")
}
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"regexp"
"regexp/syntax"
"strconv"
"strings"
@@ -25,7 +26,10 @@ type CORSConfig struct {
// LoadCORSConfig reads http.cors. Missing section yields a zero config (no
// path matches, so no CORS headers). The config is validated: credentials
// with a "*" origin, an origin pattern that does not compile and a pattern
// still wrapped in PHP delimiters (#^...$#) are errors naming the key.
// still wrapped in PHP delimiters (#^...$#) are errors naming the key. With
// credentials, a wildcard origin must be the whole leftmost host label
// (https://*.example.com), a pattern must be anchored with ^ and $, and no
// origin rule may match an origin outside the configured domains.
func LoadCORSConfig(cfg *compass.Config) (CORSConfig, error) {
var out CORSConfig
if cfg == nil || !cfg.Has("http.cors") {
@@ -50,13 +54,111 @@ func validateCORSConfig(prefix string, cfg CORSConfig) error {
if phpDelimited(p) {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is wrapped in PHP delimiters; drop the leading and trailing %q (and any flags): Go patterns take none", prefix, i, p, p[:1])
}
if _, err := regexp.Compile(p); err != nil {
re, err := regexp.Compile(p)
if err != nil {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q does not compile: %v", prefix, i, p, err)
}
if !cfg.SupportsCredentials {
continue
}
if !anchoredOriginPattern(p) {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q is not anchored: with %s.supports_credentials true a pattern must start with ^ and end with $, as in ^https://[a-z]+\\.example\\.com$, or it matches inside any origin", prefix, i, p, prefix)
}
if probe, ok := matchesCORSProbe(re); ok {
return fmt.Errorf("surf: %s.allowed_origins_patterns[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a pattern must name your own domains", prefix, i, p, probe, prefix)
}
}
if !cfg.SupportsCredentials {
return nil
}
for i, o := range cfg.AllowedOrigins {
if o == "*" || !strings.Contains(o, "*") {
continue
}
if !wildcardHostLabel(o) {
return fmt.Errorf("surf: %s.allowed_origins[%d] %q: with %s.supports_credentials true a wildcard must be the whole leftmost host label after a literal scheme, as in https://*.example.com", prefix, i, o, prefix)
}
if probe, ok := matchesCORSProbe(regexp.MustCompile(wildcardOriginPattern(o))); ok {
return fmt.Errorf("surf: %s.allowed_origins[%d] %q matches %s, an origin outside any configured domain: with %s.supports_credentials true a wildcard must sit in front of your own domain, as in https://*.example.com", prefix, i, o, probe, prefix)
}
}
return nil
}
// corsProbeOrigins are origins no credentialed origin rule may match: a
// rule that matches them trusts origins outside the configured domains
// (https://*, https://*.com, ^https?://.*$).
var corsProbeOrigins = []string{
"https://cors-probe.invalid",
"http://cors-probe.invalid",
"https://cors-probe.com",
"null",
}
// matchesCORSProbe reports the first probe origin re matches.
func matchesCORSProbe(re *regexp.Regexp) (string, bool) {
for _, probe := range corsProbeOrigins {
if re.MatchString(probe) {
return probe, true
}
}
return "", false
}
// wildcardHostLabel reports an allowed_origins entry whose only "*" is the
// whole leftmost host label after a literal scheme: https://*.example.com.
func wildcardHostLabel(origin string) bool {
scheme, host, ok := strings.Cut(origin, "://")
if !ok || scheme == "" || strings.Contains(scheme, "*") {
return false
}
rest, ok := strings.CutPrefix(host, "*.")
return ok && rest != "" && !strings.Contains(rest, "*")
}
// anchoredOriginPattern reports a pattern anchored at both ends: it starts
// with ^ and ends with $, and every alternative of the parsed expression
// stays anchored, so ^a|b$ is refused.
func anchoredOriginPattern(p string) bool {
if !strings.HasPrefix(p, "^") || !strings.HasSuffix(p, "$") {
return false
}
re, err := syntax.Parse(p, syntax.Perl)
if err != nil {
return false
}
return anchoredAt(re, true) && anchoredAt(re, false)
}
// anchoredAt reports whether every match of re begins (start true) or ends
// at the text boundary.
func anchoredAt(re *syntax.Regexp, start bool) bool {
switch re.Op {
case syntax.OpBeginText:
return start
case syntax.OpEndText:
return !start
case syntax.OpCapture:
return anchoredAt(re.Sub[0], start)
case syntax.OpConcat:
if len(re.Sub) == 0 {
return false
}
if start {
return anchoredAt(re.Sub[0], start)
}
return anchoredAt(re.Sub[len(re.Sub)-1], start)
case syntax.OpAlternate:
for _, sub := range re.Sub {
if !anchoredAt(sub, start) {
return false
}
}
return len(re.Sub) > 0
}
return false
}
// phpDelimited reports a preg pattern such as #^https://x$# or /^x$/i: the
// first byte is # or /, the same byte closes the pattern and only flag
// letters follow it. An origin pattern never starts with either byte.
@@ -251,7 +353,9 @@ func LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error) {
}
// profileMiddleware sets the profile's CORS headers on a non-OPTIONS request;
// preflights reach the OPTIONS route compile registers instead.
// preflights reach the OPTIONS route compile registers instead. wrap runs it
// outermost, outside the body limit, locale and recovery, so every answer of
// the route carries the headers, refusals and recovered panics included.
func (p *corsPolicy) profileMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodOptions {

View File

@@ -1,11 +1,17 @@
package surf
import (
"bytes"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.golem15.com/golem15/summercms/modules/bouncer"
"git.golem15.com/golem15/summercms/modules/pact"
"git.golem15.com/golem15/summercms/modules/party"
)
@@ -68,6 +74,25 @@ cors:
`)); err != nil {
t.Fatalf("credentials with exact origins: %v", err)
}
// A wildcard host and an unanchored pattern are the same exposure under
// another spelling (WR-04): refused with credentials, loaded without.
for _, tc := range []struct{ key, value, want string }{
{"allowed_origins", "https://*", "http.cors.allowed_origins[1]"},
{"allowed_origins_patterns", `https://.*\.example\.com`, "http.cors.allowed_origins_patterns[1]"},
} {
list := `["https://www.example.com", '` + tc.value + `']`
if tc.key == "allowed_origins_patterns" {
list = `['^https://www\.example\.com$', '` + tc.value + `']`
}
yaml := "cors:\n paths: [\"api/*\"]\n " + tc.key + ": " + list + "\n"
if _, err := LoadCORSConfig(writeHTTPConfig(t, yaml+" supports_credentials: true\n")); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("%s with credentials: error = %v, want one naming %s", tc.value, err, tc.want)
}
if _, err := LoadCORSConfig(writeHTTPConfig(t, yaml)); err != nil {
t.Errorf("%s without credentials: %v", tc.value, err)
}
}
}
func TestCORSPatternErrors(t *testing.T) {
@@ -457,3 +482,339 @@ func TestTurnstileFactoryParams(t *testing.T) {
t.Fatalf("bare turnstile: error = %v", err)
}
}
// guardRoutesPlugin is a routesPluginFunc that also registers named
// middleware.
type guardRoutesPlugin struct {
routesPluginFunc
mw map[string]pact.Middleware
}
func (p guardRoutesPlugin) Middlewares() map[string]pact.Middleware { return p.mw }
// refusalMiddlewares answers 401 (deny) and 403 (forbid) without calling
// next, and 413 from readall when the body exceeds the route's limit.
func refusalMiddlewares() map[string]pact.Middleware {
refuse := func(status int) pact.Middleware {
return func(http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":"refused"}`))
})
}
}
return map[string]pact.Middleware{
"deny": refuse(http.StatusUnauthorized),
"forbid": refuse(http.StatusForbidden),
"readall": func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if _, err := io.ReadAll(r.Body); err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
w.WriteHeader(http.StatusRequestEntityTooLarge)
return
}
w.WriteHeader(http.StatusBadRequest)
return
}
next.ServeHTTP(w, r)
})
},
}
}
// assertSPAProfileHeaders requires the spa profile's answer for an allowed
// origin.
func assertSPAProfileHeaders(t *testing.T, label string, rec *httptest.ResponseRecorder, status int) {
t.Helper()
const www = "https://www.example.com"
if rec.Code != status {
t.Fatalf("%s: status %d, want %d (body %q)", label, rec.Code, status, rec.Body.String())
}
hd := rec.Header()
if got := hd.Get("Access-Control-Allow-Origin"); got != www {
t.Errorf("%s: Allow-Origin = %q, want %q", label, got, www)
}
if got := hd.Get("Access-Control-Allow-Credentials"); got != "true" {
t.Errorf("%s: Allow-Credentials = %q, want true", label, got)
}
if !varyHas(hd, "Origin") {
t.Errorf("%s: Vary = %q, want Origin", label, hd.Values("Vary"))
}
}
func TestCORSProfileHeadersOnRefusals(t *testing.T) {
const www = "https://www.example.com"
panicky := func(http.ResponseWriter, *http.Request) { panic("boom") }
plugin := guardRoutesPlugin{
routesPluginFunc: routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
r.Get("/deny", okHandler, "deny", "cors:spa")
r.Get("/forbid", okHandler, "forbid", "cors:spa")
r.Group("/guarded", Use("deny"), func(g pact.Router) {
g.Get("/me", okHandler, "cors:spa")
})
r.Get("/throttled", okHandler, "throttle:1,1", "cors:spa")
r.Post("/upload", okHandler, "body.limit:16", "readall", "cors:spa")
r.Get("/panic", panicky, "cors:spa")
r.GroupRaw("/raw", Use("cors:spa"), func(g pact.Router) {
g.Get("/panic", panicky)
})
}},
mw: refusalMiddlewares(),
}
h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
t.Run("guard_401", func(t *testing.T) {
assertSPAProfileHeaders(t, "deny", serveCORS(h, corsRequest(http.MethodGet, "/deny", www, false)), http.StatusUnauthorized)
})
t.Run("guard_403", func(t *testing.T) {
assertSPAProfileHeaders(t, "forbid", serveCORS(h, corsRequest(http.MethodGet, "/forbid", www, false)), http.StatusForbidden)
})
t.Run("group_guard_401", func(t *testing.T) {
assertSPAProfileHeaders(t, "group deny", serveCORS(h, corsRequest(http.MethodGet, "/guarded/me", www, false)), http.StatusUnauthorized)
})
t.Run("throttle_429", func(t *testing.T) {
assertSPAProfileHeaders(t, "first", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusOK)
assertSPAProfileHeaders(t, "second", serveCORS(h, corsRequest(http.MethodGet, "/throttled", www, false)), http.StatusTooManyRequests)
})
t.Run("body_limit_413", func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/upload", strings.NewReader(strings.Repeat("x", 64)))
req.Header.Set("Origin", www)
assertSPAProfileHeaders(t, "upload", serveCORS(h, req), http.StatusRequestEntityTooLarge)
})
t.Run("panic_500_json", func(t *testing.T) {
rec := serveCORS(h, corsRequest(http.MethodGet, "/panic", www, false))
assertSPAProfileHeaders(t, "panic", rec, http.StatusInternalServerError)
if !strings.Contains(rec.Header().Get("Content-Type"), "json") {
t.Errorf("panic Content-Type = %q, want JSON", rec.Header().Get("Content-Type"))
}
})
t.Run("panic_500_raw", func(t *testing.T) {
assertSPAProfileHeaders(t, "raw panic", serveCORS(h, corsRequest(http.MethodGet, "/raw/panic", www, false)), http.StatusInternalServerError)
})
t.Run("disallowed_origin", func(t *testing.T) {
rec := serveCORS(h, corsRequest(http.MethodGet, "/deny", "https://evil.example.org", false))
if rec.Code != http.StatusUnauthorized {
t.Fatalf("status %d, want 401", rec.Code)
}
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want none", got)
}
if got := rec.Header().Get("Access-Control-Allow-Credentials"); got != "" {
t.Errorf("Allow-Credentials = %q, want none", got)
}
if !varyHas(rec.Header(), "Origin") {
t.Errorf("Vary = %q, want Origin", rec.Header().Values("Vary"))
}
})
turnstilePlugin := routesPluginFunc{id: "acme.guest", routes: func(r pact.Router) {
r.Post("/guest", okHandler, "turnstile:closed", "cors:spa")
}}
post := func(h http.Handler) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, "/guest", strings.NewReader(`{"name":"x"}`))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", www)
return serveCORS(h, req)
}
t.Run("turnstile_422", func(t *testing.T) {
// BuildRouter builds one verifier per router, so each Turnstile
// case assembles its own app. An empty token is refused before any
// siteverify call.
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML})
if err := app.Publish[*bouncer.Turnstile](bouncer.NewTurnstile("test-secret", false)); err != nil {
t.Fatal(err)
}
h, err := Assemble(app, []party.Plugin{turnstilePlugin})
if err != nil {
t.Fatal(err)
}
assertSPAProfileHeaders(t, "turnstile without a token", post(h), http.StatusUnprocessableEntity)
})
t.Run("turnstile_503", func(t *testing.T) {
h, err := Assemble(transportApp(t, "production", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{turnstilePlugin})
if err != nil {
t.Fatal(err)
}
assertSPAProfileHeaders(t, "turnstile without a secret in production", post(h), http.StatusServiceUnavailable)
})
}
func TestCORSProfileVaryKeptWithHandlerVary(t *testing.T) {
const www = "https://www.example.com"
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
r.Get("/x/me", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Vary", "Accept-Language")
w.WriteHeader(http.StatusOK)
}, "cors:spa")
}}
h, err := Assemble(transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + spaProfileYAML}), []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", www, false))
assertSPAProfileHeaders(t, "handler Vary", rec, http.StatusOK)
if !varyHas(rec.Header(), "Accept-Language") {
t.Errorf("Vary = %q, want Accept-Language too", rec.Header().Values("Vary"))
}
}
func TestTurnstileEmptySecretWarnsAtBoot(t *testing.T) {
// boot builds a router on an app whose published logger writes JSON
// lines to a buffer and returns the warnings naming turnstile.secret.
boot := func(t *testing.T, env string, verifier *bouncer.Turnstile, plugins ...party.Plugin) []map[string]any {
t.Helper()
app := transportApp(t, env, map[string]string{"http.yaml": transportBodyLimits})
var buf bytes.Buffer
if err := app.Publish(slog.New(slog.NewJSONHandler(&buf, nil))); err != nil {
t.Fatal(err)
}
if verifier != nil {
if err := app.Publish[*bouncer.Turnstile](verifier); err != nil {
t.Fatal(err)
}
}
if _, err := BuildRouter(app, plugins); err != nil {
t.Fatal(err)
}
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
if line == "" {
continue
}
var rec map[string]any
if err := json.Unmarshal([]byte(line), &rec); err != nil {
t.Fatalf("log line %q: %v", line, err)
}
if msg, _ := rec["msg"].(string); strings.HasPrefix(msg, "surf: turnstile.secret is empty") {
out = append(out, rec)
}
}
return out
}
got := boot(t, "production", nil, turnstileRoutes("turnstile:closed", "turnstile:open"))
if len(got) != 1 {
t.Fatalf("warnings = %v, want one", got)
}
if got[0]["level"] != "WARN" || got[0]["routes"] != float64(2) || got[0]["production"] != true {
t.Fatalf("warning = %v, want WARN with routes 2 and production true", got[0])
}
if msg := got[0]["msg"].(string); !strings.Contains(msg, "turnstile:open passes every request") || !strings.Contains(msg, "503 in production") {
t.Errorf("message = %q", msg)
}
// A published verifier with a secret: no warning.
if got := boot(t, "production", bouncer.NewTurnstile("test-secret", true), turnstileRoutes("turnstile:closed", "turnstile:open")); len(got) != 0 {
t.Fatalf("warnings with a secret = %v", got)
}
// No turnstile routes: no warning.
plain := routesPluginFunc{id: "acme.plain", routes: func(r pact.Router) { r.Get("/x", okHandler) }}
if got := boot(t, "development", nil, plain); len(got) != 0 {
t.Fatalf("warnings without turnstile routes = %v", got)
}
}
func TestCORSCredentialedOriginRules(t *testing.T) {
// Each block lists a good entry first, so a refusal names index [1].
blocks := []struct{ name, prefix, yamlHead, indent string }{
{"global", "http.cors", "cors:\n paths: [\"api/*\"]\n", " "},
{"profile", "http.cors_profiles.spa", "cors_profiles:\n spa:\n", " "},
}
load := func(t *testing.T, block int, body string) error {
t.Helper()
b := blocks[block]
cfg := writeHTTPConfig(t, b.yamlHead+body)
if b.name == "global" {
_, err := LoadCORSConfig(cfg)
return err
}
_, err := LoadCORSProfiles(cfg)
return err
}
entry := func(indent, key, good, value string, credentials bool) string {
out := indent + key + ": ['" + good + "', '" + value + "']\n"
if credentials {
out += indent + "supports_credentials: true\n"
}
return out
}
const goodOrigin, goodPattern = "https://www.example.com", `^https://www\.example\.com$`
accepted := []struct{ key, value string }{
{"allowed_origins", "https://www.example.com"},
{"allowed_origins", "https://*.example.com"},
{"allowed_origins", "https://*.api.example.com:8443"},
{"allowed_origins_patterns", `^https://[a-z]+\.example\.org$`},
}
refused := []struct{ key, value string }{
{"allowed_origins", "https://*"},
{"allowed_origins", "*://*"},
{"allowed_origins", "https://*example.com"},
{"allowed_origins", "https://*.com"},
{"allowed_origins", "http*://www.example.com"},
{"allowed_origins_patterns", `https://.*\.example\.com`},
{"allowed_origins_patterns", `^https://.*\.example\.com`},
{"allowed_origins_patterns", `^https?://.*$`},
{"allowed_origins_patterns", `^.*$`},
}
for i, b := range blocks {
for _, tc := range accepted {
good := goodOrigin
if tc.key == "allowed_origins_patterns" {
good = goodPattern
}
if err := load(t, i, entry(b.indent, tc.key, good, tc.value, true)); err != nil {
t.Errorf("%s %s %q with credentials: %v", b.name, tc.key, tc.value, err)
}
}
for _, tc := range refused {
good := goodOrigin
if tc.key == "allowed_origins_patterns" {
good = goodPattern
}
want := b.prefix + "." + tc.key + "[1]"
err := load(t, i, entry(b.indent, tc.key, good, tc.value, true))
if err == nil || !strings.Contains(err.Error(), want) {
t.Errorf("%s %q with credentials: error = %v, want one naming %s", b.name, tc.value, err, want)
}
if err := load(t, i, entry(b.indent, tc.key, good, tc.value, false)); err != nil {
t.Errorf("%s %q without credentials: %v", b.name, tc.value, err)
}
}
}
}
func TestCORSAnchoredPatternNoSuffixMatch(t *testing.T) {
plugin := routesPluginFunc{id: "acme.spa", routes: func(r pact.Router) {
r.Get("/x/me", okHandler, "cors:spa")
}}
app := transportApp(t, "development", map[string]string{"http.yaml": transportBodyLimits + `cors_profiles:
spa:
allowed_origins_patterns: ['^https://[a-z]+\.example\.com$']
allowed_methods: ["GET"]
supports_credentials: true
`})
h, err := Assemble(app, []party.Plugin{plugin})
if err != nil {
t.Fatal(err)
}
rec := serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com", false))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://www.example.com" || rec.Header().Get("Access-Control-Allow-Credentials") != "true" {
t.Fatalf("allowed origin: Allow-Origin %q Allow-Credentials %q", got, rec.Header().Get("Access-Control-Allow-Credentials"))
}
rec = serveCORS(h, corsRequest(http.MethodGet, "/x/me", "https://www.example.com.attacker.net", false))
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("suffixed origin: Allow-Origin = %q, want none", got)
}
if rec.Header().Get("Access-Control-Allow-Credentials") != "" {
t.Fatal("suffixed origin: Allow-Credentials set")
}
if !varyHas(rec.Header(), "Origin") {
t.Fatalf("suffixed origin: Vary = %q, want Origin", rec.Header().Values("Vary"))
}
}

View File

@@ -3,6 +3,7 @@ package surf
import (
"bytes"
"fmt"
"log/slog"
"math"
"net/http"
"strings"
@@ -413,6 +414,13 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
return nil, fmt.Errorf("surf: plugin %q: %w", rt.pluginID, err)
}
h = orgSlot(h)
// The route's cors:<profile> (on the route or an enclosing group) is
// applied below, outside recovery, the body limit and locale, so every
// answer of the route carries it, a guard's refusal included.
profile, err := routeCORSProfile(rt)
if err != nil {
return nil, err
}
for i := len(rt.middleware) - 1; i >= 0; i-- {
name := rt.middleware[i]
if named, ok := r.named[name]; ok {
@@ -437,6 +445,8 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
if _, ok := r.corsProfiles[param]; !ok {
return nil, fmt.Errorf("surf: plugin %q route %s %s references unknown CORS profile %q (define http.cors_profiles.%s)", rt.pluginID, rt.method, rt.path, param, param)
}
// Never composed at its list position: see below.
continue
}
if base == "turnstile" && param != "open" && param != "closed" {
return nil, fmt.Errorf("surf: plugin %q route %s %s: turnstile:%s must be turnstile:open or turnstile:closed", rt.pluginID, rt.method, rt.path, param)
@@ -467,6 +477,12 @@ func (r *Router) wrap(rt route) (http.Handler, error) {
} else {
h = recoverJSON(h)
}
// The CORS profile wraps recovery, so a refusal from a guard listed
// before it, a 413 from the body limit and a recovered 500 all carry
// the profile's headers; otherwise the browser hides them from the page.
if profile != "" {
h = r.corsProfiles[profile].profileMiddleware(h)
}
// Outermost: name the matched route for the access log (accesslog.go).
h = recordPattern(rt.method+" "+rt.path, h)
return h, nil
@@ -520,7 +536,8 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
}
r.corsProfiles = map[string]*corsPolicy{}
if err := r.RegisterMiddlewareFactory("surf", "cors", func(param string) pact.Middleware {
// wrap refuses an unknown profile before the factory runs.
// The name stays reserved for surf, but wrap applies the profile
// itself, outermost, and never calls this factory for it.
return r.corsProfiles[param].profileMiddleware
}); err != nil {
return nil, err
@@ -635,6 +652,7 @@ func BuildRouter(app *backpack.App, plugins []party.Plugin) (*Router, error) {
return nil, err
}
}
warnTurnstileWithoutSecret(app, turnstile, r.routes)
if err := r.addCORSPreflights(); err != nil {
return nil, err
}
@@ -660,6 +678,31 @@ func buildTurnstile(app *backpack.App) (*bouncer.Turnstile, string) {
return bouncer.NewTurnstile(strings.TrimSpace(app.Config.String("turnstile.secret")), production), field
}
// warnTurnstileWithoutSecret logs one warning at boot when routes name
// turnstile:open or turnstile:closed and the verifier has no secret: such
// routes are not protected. The secret itself is never logged.
func warnTurnstileWithoutSecret(app *backpack.App, verifier *bouncer.Turnstile, routes []route) {
if verifier != nil && strings.TrimSpace(verifier.Secret) != "" {
return
}
n := 0
for _, rt := range routes {
for _, name := range rt.middleware {
if name == "turnstile:open" || name == "turnstile:closed" {
n++
break
}
}
}
if n == 0 {
return
}
production := verifier != nil && verifier.Production
accessLogger(app).Warn("surf: turnstile.secret is empty; turnstile:open passes every request, "+
"and turnstile:closed passes outside production and answers 503 in production",
slog.Int("routes", n), slog.Bool("production", production))
}
// checkAdminPrefix fails boot when a plugin other than cabana owns a route at
// or under the admin prefix: the admin SPA and API own that whole subtree.
func (r *Router) checkAdminPrefix(prefix string) error {
@@ -793,6 +836,18 @@ func (*bufferedResponse) Flush() {}
func (w *bufferedResponse) commit(dst http.ResponseWriter) {
for key, values := range w.header {
if key == "Vary" {
// Merged, not replaced: a Vary: Origin a CORS wrapper set on
// dst survives a handler that sets its own Vary.
for _, line := range values {
for _, v := range strings.Split(line, ",") {
if v = strings.TrimSpace(v); v != "" {
addVary(dst.Header(), v)
}
}
}
continue
}
dst.Header()[key] = append([]string(nil), values...)
}
status := w.status

View File

@@ -7,6 +7,10 @@
# module: caches, the Redis rate-limit store, Install and the site-cache
# purge), the purge wiring in the FAQ, menu and Journal plugins, and the
# shared purge route of the FAQ, menu and Journal Nuxt layers.
# Plan 06 (gap closure) adds the tests of its fixes: CR-01 (cors:<profile> on
# every response) and WR-01..WR-09 (queued purges, no redirects, allowProxied,
# credentialed origin rules, cookie Secure and defaults, nonce-only api-docs
# CSP, protected uploads with public_url, the Turnstile boot warning).
#
# Stages:
# go go vet ./... and go vet -tags summercms_s3 ./modules/lagoon/...;
@@ -63,6 +67,8 @@ BOUNCER_TESTS=(
TestTurnstileVerifyMatrix TestTurnstileVerifyTimeout TestTurnstileFormFields
TestTurnstileMiddlewareTokenSources TestTurnstileMiddlewareRestoresBody
TestTurnstileMiddlewareStatuses TestTurnstileNeverLogsSecret
TestLoadCookieConfigProductionInferredSecure
TestZeroCookieConfigDefaults
)
SURF_TESTS=(
@@ -71,6 +77,11 @@ SURF_TESTS=(
TestCORSOptionsMatchesLaravel
TestCORSProfilePreflightRoute TestCORSProfileUnknown TestCORSProfileWithPathsRefused
TestCORSProfileOverlapGlobal TestCORSProfileConflictingProfilesSamePath
TestCORSProfileHeadersOnRefusals TestCORSProfileVaryKeptWithHandlerVary
TestCORSCredentialedOriginRules
TestCORSAnchoredPatternNoSuffixMatch
TestAPIDocsCSPNoHostSource
TestTurnstileEmptySecretWarnsAtBoot
TestTurnstileFactoryParams
TestAPIDocsOffByDefault TestAPIDocsServesPinnedPage TestAPIDocsSpecJSONOrder
TestAPIDocsJSONSpecInput TestAPIDocsProviderCount TestAPIDocsPathValidation
@@ -89,6 +100,7 @@ ATTACH_TESTS=(
TestDiskExtensionTable
TestPublicURLBase
TestPublicURLValidation
TestStoreProtectedNeedsPrivateBucket
)
# cooler: caches, rate-limit parity with surf.MemoryStore (miniredis),
@@ -103,6 +115,9 @@ COOLER_TESTS=(
TestInstallConfigErrors TestInstallPingWarning TestInstallReusesFromApp
TestFromAppLazy TestInstallSiteCacheValidation
TestSiteCachePurgeConfig TestSiteCachePurgeRequest TestSiteCacheFromApp
TestSiteCachePurgeNoRedirect
TestSiteCachePurgeAsyncCoalesced
TestSiteCachePurgeWaitAndClose
)
# docker stage: testcontainers suites.
@@ -116,6 +131,7 @@ DOCKER_COOLER_TESTS=(
TestRedisCacheDocker
TestSiteCacheAfterCommitOnly
TestSiteCacheFailureNeverFails
TestSiteCacheBulkCommitCoalesced
)
DOCKER_FAQ_TESTS=(TestFaqAdminPurgesSiteCache)
DOCKER_MENU_TESTS=(TestMenuAdminPurgesSiteCache)
@@ -151,6 +167,8 @@ LAYER_REQUIRE=(
"sitePurge: purge event edge cases"
"sitePurge: route answers 404 when disabled or proxied and 401 for a wrong bearer"
"sitePurge: route purges, answers the count and logs kind and slug only"
"sitePurge: allowProxied lets a proxied request reach the token check"
"sitePurge: route accepts a proxied request with allowProxied and the right bearer"
)
# Files every layer ships byte for byte.
LAYER_SHARED=(