From 56fe2ae3724f3395133fa3456149ef5525a90e27 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 15 Oct 2026 23:10:59 +0200 Subject: [PATCH] docs(19.3): re-verification after gap closure (human checks pending) --- .../19.3-VERIFICATION.md | 299 +++++++++--------- 1 file changed, 154 insertions(+), 145 deletions(-) diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md index acb8465..e1439a6 100644 --- a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VERIFICATION.md @@ -1,8 +1,8 @@ --- phase: 19.3-framework-transport-and-infrastructure-for-wavepath -verified: 2026-10-15T20:45:00Z -status: gaps_found -score: 3/4 roadmap success criteria verified (SC-1 partial); plan must-haves 35/36 verified (D-05 failed); 0 present-behavior-unverified +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 @@ -14,6 +14,8 @@ covered_files: - .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 @@ -39,46 +41,56 @@ covered_files: - modules/surf/router.go - modules/surf/serve.go - scripts/check-phase19.3.sh -covered_digest: "v2:sha256:f9d58003dac25f701e22b19bc467b0e06c88a94264b5dce19b90c2a29bacc6b5" +covered_digest: "v2:sha256:469d289a206fd43b90c3401d9971e57193333d0c2efefa1c88a2356520b1a092" behavior_unverified: 0 overrides_applied: 0 -gaps: - - truth: "SC-1 / D-05: credentialed CORS for a configured SPA origin is available as middleware; a named middleware cors: applies the profile's policy to the routes that carry it" - status: partial - reason: >- - Review CR-01, reproduced in this verification. Router.wrap (modules/surf/router.go:416-466) composes - route middleware in declaration order and puts bodyLimit, locale and recoverJSON/recoverBare outside - all of them. profileMiddleware (modules/surf/cors.go:255-262) only sets the CORS headers when the - request reaches it. A cross-origin GET from the allowed origin got these answers on HEAD: - ["deny","cors:spa"] -> 401 with no Access-Control-Allow-Origin; group Use("deny") with route - cors:spa -> 401 with no ACAO; a panicking cors:spa route -> 500 with no ACAO. Only ["cors:spa","deny"] - and a group-level Use("cors:spa") carried ACAO and ACAC. A body-limit 413 and a recovered 500 never - carry them, whatever the order. docs/services/routing.md tells readers to list cors:spa "next to its - guard", which does not prevent the failing order. The browser then reports an opaque network error, so the SPA cannot read the - 401 that should trigger its cookie refresh (the phase's headline flow), the 429 from a throttle, or the 422 - that should re-show the Turnstile widget. The global path-scoped http.cors wrapper is NOT affected (it - wraps the mux: 401 and 500 both carried ACAO and ACAC in the same probe), so credentialed CORS works - for apps that configure it through http.cors.paths. The per-route-group profile that D-05 delivered is the part that fails. - artifacts: - - path: "modules/surf/router.go" - issue: "wrap applies the cors: middleware at its list position, inside bodyLimit and recovery; refusals from outer layers and from guards/throttle/turnstile listed before it carry no CORS headers" - - path: "modules/surf/cors.go" - issue: "profileMiddleware relies on being outermost; nothing enforces it" - - path: "docs/services/routing.md" - issue: "line ~265 suggests placing cors:spa 'next to its guard' without stating that order matters" - missing: - - "In Router.wrap, skip the cors: entry in the middleware loop and apply r.corsProfiles[profile].profileMiddleware after recoverJSON/recoverBare (just inside recordPattern), so every response on a profiled route, including 401/403/413/422/429/500, carries the profile's headers" - - "Test: a cross-origin request to a cors:spa route behind a refusing guard listed BEFORE cors:spa answers 401 with Access-Control-Allow-Origin, Access-Control-Allow-Credentials and Vary: Origin; same for a body-limit 413 and a recovered panic 500; add it to SURF_TESTS in 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 regardless of its position in the list" +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: 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: (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 `user-story.validate` returns `valid: false` for the goal because it is not written as "As a ..., I want to ..., so that ...". As in the 18 and 18.1 reports, this report uses standard goal-backward verification against the goal text and its four success criteria, with a User Flow Coverage table derived from the goal. To make the mode consistent, run `/msd mvp-phase 19.3` to restate the goal, or drop `mode: mvp`.) -**Verified:** 2026-10-15T20:45:00Z -**Status:** gaps_found -**Re-verification:** No, initial 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:` 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:` 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) @@ -86,13 +98,13 @@ Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as sev | Step | Expected | Evidence | Status | |------|----------|----------|--------| -| Log in, get a cookie | httpOnly `token` cookie, Domain/SameSite/Secure from `auth.cookie.*`, Max-Age = refresh window | `bouncer.LoadCookieConfig`, `CookieConfig.TokenCookie` (`modules/bouncer/cookie.go`); `TestTokenCookieMaxAgeIsRefreshWindow`, `TestLoadCookieConfigDefaults/Errors` passed | VERIFIED | -| Access token expires, API answers 401 | The SPA can read the 401 cross-origin and calls refresh | Global `http.cors` path: 401 and 500 carry ACAO+ACAC (my probe). `cors:` path: 401 carries no ACAO when the guard is listed before the profile or comes from a group; a 500 or 413 never carries them (my probe, CR-01) | PARTIAL (gap) | -| Refresh through the cookie | New cookie with the same attributes, old jti blacklisted, replay refused | `CookieRefreshHandler`; `TestCookieRefreshHandlerRotatesAndBlacklists` uses the real `bouncer.Refresh` and asserts the old jti is blacklisted and the replay answers 401; `TestCookieRefreshAcrossSubdomainsSmoke` passed in my run | VERIFIED | -| Requests land on different instances | Throttle counts one window across instances | `TestRateLimitAcrossInstancesSmoke` (two `Assemble`d apps, one real Redis container, `throttle:2,1`: 200, 200, then 429 from the other instance) passed in my run | VERIFIED | -| Upload a file to S3 | `bucket_url: s3://` works in a tagged binary, the app imports nothing | `s3_on.go` is the only `s3blob` importer (`//go:build summercms_s3`); `summer build` passes `-tags` from `summer.yaml` `build.tags`; `TestOpenBucketS3` passed in both tag modes | VERIFIED (real bucket: human item 2) | -| Read the API docs | `/api-docs` and `/api-docs.json` | `mountAPIDocs`; SRI hashes in `apidocs.go` match the live jsDelivr files (checked with curl and openssl) | VERIFIED (rendering under CSP: human item 4) | -| Register behind Turnstile | `turnstile:open|closed` middleware | `bouncer.Turnstile`, surf factory; 7 Turnstile tests + `TestTurnstileFactoryParams` passed | VERIFIED | +| 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:`: 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 @@ -100,160 +112,157 @@ Flow: a SPA on `www.example.com` talks to an API on `api.example.com` run as sev | # | 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 | ✗ FAILED (partial) | Cookie half: VERIFIED. `cookie.go` builders, loader and handlers; old jti blacklist proven by `TestCookieRefreshHandlerRotatesAndBlacklists`. TTL keys `golem15.user.jwt.ttl` (60) / `refresh_ttl` (20160) exist in the user plugin (`api_controller.go:825-836`) and `admin.jwt.*` in `cabana/auth.go:454-463`. CORS half: global `http.cors` with `supports_credentials` works on every response, including 401 and 500. The named `cors:` middleware (D-05) drops its headers on refusals depending on order, and always on 413 and 500. See gap | -| SC-2 | Cache module with memory and Redis drivers; `surf` gets a Redis-backed `Store` so rate limits hold across instances; Redis client decision note before code | ✓ VERIFIED | `modules/cooler` (otter v2 memory driver, go-redis v9 Redis driver, Get/Set/Has/Forget/Remember/Increment/Flush). `RateLimitStore.Attempt` is one atomic Lua script (PTTL/INCR). `serve.go` `installCooler` publishes `surf.Store` before `BuildRouter`, and `BuildRouter` looks it up (`router.go:497`). Note commit `77dd11c` is an ancestor of `fa4d89d`, the first commit that adds go-redis to go.mod (`git merge-base --is-ancestor`). Cross-instance 429 proven against real Redis | -| SC-3 | S3 blob driver selectable through framework config without the app importing it; `/api-docs` + `/api-docs.json` servable; Turnstile middleware available | ✓ VERIFIED | `s3_on.go`/`s3_off.go` build tag, `checkS3Driver` error names `summercms_s3`, `goBuildArgs` adds `-tags=`. `mountAPIDocs` gated by `http.api_docs.enabled`, exactly-one-provider check, pinned 5.33.1 with SRI that matches the CDN. `turnstile:open|closed` factory registered in `BuildRouter` (`router.go:528-538`) | -| SC-4 | Touched modules' READMEs and `docs/` updated; new code has unit tests delivered in the last plan | ✓ VERIFIED | READMEs of bouncer, cabana, cooler (new, standard structure, root table row), lagoon, pact, surf changed; 13 docs pages changed; each new config key appears in docs (grep). `TestDocsTree` and `docs:build --check` passed in the gate. Plan 05 commits touch only `_test.go`, the gate script, go.mod/go.sum (miniredis) and the cooler README | +| 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:` 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:** 3/4 roadmap truths verified (SC-1 partial). Plan must-haves: 35/36 verified; the D-05 truth failed for the same reason as SC-1. 0 present-but-behavior-unverified. +**Score:** 4/4 roadmap truths verified. Plan must-haves 44/44 verified (0 present-but-behavior-unverified). -### Plan must-haves (36 truths across 5 plans) +### Plan must-haves | Plan | Truths | Status | Notes | |------|--------|--------|-------| -| 01 (cooler, Redis store) | 8 | 8 ✓ | Decision note ordering, module + README + root row, cross-instance window (`TestRateLimitAcrossInstancesSmoke`), MemoryStore parity (`TestRateLimitParity` miniredis + `TestRateLimitParityDocker`), fallback + once-per-minute warning (`TestRateLimitFallbackOnError`), `BuildRouter` never dials (`TestBuildRouterNeverDials`), Cache API + prefix-only Flush (`TestRedisFlushOnlyPrefix`), `FromApp` lazy (`TestFromAppLazy`) | -| 02 (cookies, CORS, Turnstile, api-docs) | 7 | 6 ✓, 1 ✗ | D-01, D-02, D-03, D-04, D-16/17 and D-18 verified by named tests. **D-05 failed (partial):** the profile config, validation and OPTIONS routes work (`TestCORSProfilePreflightRoute`, `...Unknown`, `...WithPathsRefused`, `...OverlapGlobal`), but the middleware does not reach error responses (CR-01) | -| 03 (S3 tag, DiskExtension, public_url) | 6 | 6 ✓ | `TestManifestBuildTags`, `TestSaveManifestKeepsBuildTags`, `TestGoBuildArgs`, `TestOpenBucketS3` (both tag modes), `TestDiskExtensionTable`, `TestStoreDiskExtension` (Postgres), `TestPublicURLBase/Validation`; deploy nosniff note at `docs/services/storage.md:122`; `store.go:154` calls `DiskExtension` | -| 04 (site-cache purge, plugins, layers) | 7 | 7 ✓ | `cooler/sitecache.go`; `pact.ReorderAfterSave` called inside the reorder transaction (`cabana/reorder.go:354`); menu/FAQ/Journal adoption with Docker admin tests; Journal alias keys and deprecated names kept (`sm-journal-plugin/classes/site_cache.go`); three byte-identical layer routes (gate `cmp`); plugin and layer repos clean and in sync with origin | -| 05 (tests, gate) | 8 | 8 ✓ | The gate ran green with `PHASE193_REQUIRE_DOCKER=1` (self-test, go, plugins, layers, docs, docker); detectors fail on a skipped or missing test name | +| 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 | -### Required Artifacts +**Plan 06 truths:** + +| # | Truth (abridged) | Status | Evidence | +|---|------------------|--------|----------| +| 1 | CR-01: every response of a `cors:` 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 | |----------|----------|--------|---------| -| `.planning/notes/2026-10-15-cooler-go-redis-otter.md` | decision note | ✓ VERIFIED | committed in `77dd11c`, before go-redis lands | -| `modules/cooler/{cooler,memory,redis,rediscache,ratelimit,sitecache}.go` | cache, store, purge | ✓ VERIFIED | 1,142 lines, imports only backpack/compass/lagoon | -| `modules/surf/serve.go` | `installCooler` | ✓ VERIFIED | called before `BuildRouter` | -| `modules/bouncer/cookie.go` | cookie config, builders, handlers | ✓ VERIFIED | bouncer imports no SummerCMS module (`go list`) | -| `modules/bouncer/turnstile.go` | verifier + middleware | ✓ VERIFIED | siteverify URL, 5 s timeout, 2048 limit | -| `modules/surf/cors.go` | validated CORS, profiles, preflights | ⚠️ PARTIAL | global wrapper correct; profile middleware placement defect (gap) | -| `modules/surf/apidocs.go` | provider, pinned page, spec route | ✓ VERIFIED | SRI matches CDN | -| `modules/lagoon/attach/{s3_on,s3_off,diskext}.go` | S3 tag, DiskExtension | ✓ VERIFIED | | -| `internal/build/manifest.go` | `Manifest.Tags` | ✓ VERIFIED | | -| `modules/pact/capabilities.go` | `ReorderAfterSave` | ✓ VERIFIED | | -| `../vue-*-layer/server/api/__site__/cache/purge.post.ts` | shared purge route | ✓ VERIFIED | identical in three layers | -| `scripts/check-phase19.3.sh` | phase gate | ✓ VERIFIED | ran green | +| `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 | | -### Key Link Verification +Artifacts from plans 01-05: unchanged status (✓ VERIFIED; `cors.go` upgraded from ⚠️ PARTIAL). + +### Key Link Verification (plan 06) | From | To | Via | Status | |------|----|-----|--------| -| `surf/serve.go` | `cooler.Install` | `installCooler` → `app.Publish[Store]` when `http.rate_limit.store: redis` | WIRED | -| `surf/router.go` BuildRouter | published `Store` | `app.Lookup[Store]()` else `NewMemoryStore` | WIRED | -| `cooler/ratelimit.go` | `surf.Store` | structural `Attempt(key, max, decay)` | WIRED | -| `surf/router.go` | `cors.go` | `"cors"` factory + `addCORSPreflights` | WIRED (placement defect, see gap) | -| `surf/router.go` | `bouncer/turnstile.go` | `"turnstile"` factory, `buildTurnstile`, `ClientIP` with trusted proxies | WIRED | -| `bouncer/cookie.go` | `bouncer/refresh.go` | app-supplied Refresh wraps `Refresh`/`RefreshAudienceFor` | WIRED (test uses real `Refresh`) | -| `surf/router.go` | `apidocs.go` | `mountAPIDocs` | WIRED | -| `internal/build/build.go` | `manifest.go` | `goBuildArgs(binRel, m.Tags)` | WIRED | -| `attach/bucket.go` | `s3_off.go` | `checkS3Driver` / `s3Enabled` | WIRED | -| `attach/store.go` | `diskext.go` | `newDiskName(DiskExtension(sniffed, ext))` | WIRED | -| `attach/thumb.go` | `bucket.go` | `PublicURLBase()` | WIRED | -| `cabana/reorder.go` | `pact.ReorderAfterSave` | type assertion inside the Transaction closure with `txCtx` | WIRED | -| FAQ/menu/Journal controllers | `cooler.PurgeSiteCacheAfterCommit` | `cabana.TxFromContext` + `SiteCacheFromApp` | WIRED | +| `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 equivalent runtime flows (cookie → Refresh → blacklist; request → throttle → shared Redis counter; save → after-commit → purge POST) are each exercised by a passing test listed above. +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 incl. Docker | `PHASE193_REQUIRE_DOCKER=1 bash scripts/check-phase19.3.sh --all` | exit 0; PASS self-test, go, plugins, layers, docs, docker | ✓ PASS | -| Cross-instance rate limit + cookie refresh smoke | `go test -count=1 -run '^(TestRateLimitAcrossInstancesSmoke\|TestCookieRefreshAcrossSubdomainsSmoke)$' ./modules/surf` | both PASS (real Redis container) | ✓ PASS | -| CR-01 reproduction (`cors:`) | scratch module (outside the repo) with `surf.Assemble` and a refusing `deny` middleware | `[deny,cors:spa]` 401 ACAO="" ; `[cors:spa,deny]` 401 ACAO set ; group `Use(cors:spa)` 401 ACAO set ; group `Use(deny)` + route `cors:spa` 401 ACAO="" ; panic 500 ACAO="" | ✗ FAIL | -| Global `http.cors` on error responses | same scratch module, credentialed `http.cors.paths` | 401 and 500 both carry ACAO + ACAC | ✓ PASS | -| Swagger UI SRI values | `curl` jsDelivr 5.33.1 assets, `openssl dgst -sha384` | all three equal the constants in `apidocs.go` | ✓ PASS | +| 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:`) | 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 (`scripts/check-phase19.3.sh`) was run by the verifier in its own process (see above). +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: +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 | ✗ PARTIAL | see gap (D-05 profile middleware) | +| 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 | ✓ SATISFIED | S3 tag, api-docs, Turnstile | -| SC-4 | 01-05 | ✓ SATISFIED | READMEs, docs, tests in plan 05 | -| D-01, D-02, D-03 | 02, 05 | ✓ SATISFIED | cookie tests | -| D-04 | 02, 05 | ✓ SATISFIED | literal contract met; WR-04 hardening gap is a warning | -| D-05 | 02, 05 | ✗ BLOCKED (partial) | CR-01 | +| 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 | ✓ SATISFIED | live purge is human item 3 | -| D-12, D-13, D-14, D-15 | 03, 05 | ✓ SATISFIED | real bucket is human item 2 | -| D-16, D-17 | 02, 05 | ✓ SATISFIED | rendering under CSP is human item 4 | -| D-18 | 02, 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 | |------|-------------|---------|----------| -| 01 | cooler imports neither surf nor cabana | holds | `go list` imports: backpack, compass, lagoon | -| 01/04 | Framework code and docs never name a consuming application | holds | gate docs stage: "no consuming application named in framework lines since 2d352ff^" (older test files are logged in deferred-items.md) | -| 01 | Cache Flush never empties the whole Redis database | holds | SCAN MATCH `cache:*` + UNLINK; `TestRedisFlushOnlyPrefix` | -| 02 | bouncer imports no SummerCMS module | holds | `go list` shows none | -| 02 | Non-credentialed `*` CORS answers byte-identical | holds | `TestCORSOptionsMatchesLaravel`, `TestCORSStarUnchanged` | -| 02 | Turnstile secret/token never logged | holds | `TestTurnstileNeverLogsSecret` | -| 03 | No new `s3.*` keys | holds | no `"s3.` config reads in modules/internal | -| 03 | Uploads passing today's allow-list still pass | holds | allow-list check unchanged; only the disk name derives from the sniff | -| 03 | App call sites outside the framework untouched | holds | no fonoteka.go commits today | -| 04 | No breaking change to Journal/menu/FAQ | holds | alias keys and deprecated names kept; `TestSiteCacheAliasKeysDeprecated`, admin Docker suites green | -| 04/05 | Nothing pushed/tagged without the human's reply | not code-verifiable | SUMMARY records the checkpoint replies; v0.1.27 points at `9921768`, and no code commits follow it | -| 05 | Tests reach no network beyond localhost/httptest/miniredis/testcontainers | holds (sampled) | Turnstile tests set `Endpoint` to httptest servers | -| 05 | No production code changes in plan 05 except recorded deviations | holds | plan 05 commits touch only tests, the gate, go.mod/go.sum and the cooler README | +| 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 any file the phase changed (the gate script's `TODO` match is its TAP directive parser). The open code-review findings are the real debt: +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/router.go`, `cors.go` | CR-01: profile CORS headers missing from refusals/413/500 | 🛑 Blocker | SPA cannot read the 401 that triggers cookie refresh (gap above) | -| `modules/cooler/sitecache.go` | WR-01: purge is synchronous after commit; bulk delete sends N purges | ⚠️ Warning | slow admin saves when the site is slow; accepted "no debounce" (user answer in plan 04), but the 2 s × N latency was not weighed | -| `modules/cooler/sitecache.go` | WR-02: purge client follows redirects with the bearer token | ⚠️ Warning | token leak on an https→http redirect | -| `vue-*-layer/.../purge.post.ts` | WR-03: route refuses proxied requests; Go side accepts https remote targets | ⚠️ Warning | remote mode cannot work; D-10 only promised loopback | -| `modules/surf/cors.go` | WR-04: `https://*` entries and unanchored patterns still trust arbitrary origins with credentials | ⚠️ Warning | same exposure D-04 meant to close, by another spelling | -| `modules/bouncer/cookie.go` | WR-05: inferred Secure=false in production when `app.url` is http | ⚠️ Warning | matches D-03's literal rule but produces an insecure cookie silently | -| `modules/surf/apidocs.go` | WR-06: CSP allowlists all of jsDelivr for scripts | ⚠️ Warning | weakens the nonce | -| `modules/lagoon/attach/bucket.go` | WR-07: protected files land in a public bucket when `public_url` is set | ⚠️ Warning | Info-level log only | -| `modules/surf/router.go` | WR-08: `turnstile:*` with no secret logs nothing at boot | ⚠️ Warning | silent bot-protection off | -| `modules/bouncer/cookie.go` | WR-09: zero `CookieConfig` has no SameSite and no Secure | ⚠️ Warning | contradicts its own field doc | +| `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 | -Info items IN-01..IN-11 and deferred-items.md (S3 object content type, Redis outage latency in `Remember`) remain as recorded in 19.3-REVIEW.md. 19.3-VALIDATION.md is still `status: draft`, `nyquist_compliant: false`. +### Advisory (New Scope, Unevidenced) -### Human Verification Required (after the gap closes) +| # | 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 | -These remain open from 19.3-VALIDATION.md and are not satisfiable by tests. Status is `gaps_found`, so they are listed here and not in the frontmatter. +### Human Verification Required -1. **Cookie refresh across real subdomains.** Deploy to staging with `auth.cookie.domain` set to the parent domain and `cors:` or `http.cors` credentialed for the www origin. Log in on www and wait past the access TTL. Expected: the 401 is readable in the SPA, the refresh call sends the cookie and 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`. Expected: both serve the file. Note deferred-items' stored Content-Type caveat. Why human: needs bucket credentials. -3. **Live Nuxt purge.** Save a menu entry and a FAQ in admin. Expected: the page updates without waiting for the 300 s SWR TTL. Why human: needs a running Nuxt site and the app. -4. **Swagger UI under its 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; the SRI values were checked automatically. -5. **Triage the open review warnings WR-01..WR-09.** Set each disposition in 19.3-REVIEW-DISPOSITION.md (fix in the gap-closure plan, defer with a reason, or skip). WR-04 and WR-02 are security-relevant and cheap to fix next to CR-01. +1. **Cookie refresh across real subdomains.** Deploy to staging with `auth.cookie.domain` set to the parent domain and `cors:` (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 -The phase delivers almost all of its goal, and the evidence is solid: the gate runs green including Docker, the cross-instance rate limit is proven against a real Redis, the cookie refresh path uses the real `Refresh` and its blacklist, the S3 tag guard works in both tag modes, and the api-docs SRI values match the CDN. - -One gap remains, and it hits the phase's headline flow. `cors:`, the named credentialed-CORS middleware that D-05 added for SPA route groups, is applied at its list position inside body-limit and panic recovery. A guard, throttle or Turnstile refusal listed before it, a guard from an enclosing group, a 413 and a recovered 500 all leave without `Access-Control-Allow-Origin`, so the browser hides the 401 the SPA needs in order to refresh its cookie. I reproduced this on HEAD. Apps that use the global `http.cors` paths are not affected, which is why SC-1 is partial rather than absent. The fix is local: apply the profile outermost in `Router.wrap`, add a refusing-guard test to the gate, and adjust the routing docs. That needs a small gap-closure plan and a patch release (v0.1.28). No later phase (19.4 OIDC, 19.5 social login, 19.6 2FA) covers it, so it is not deferred. - -**If you decide to accept the current behaviour instead** (for example, by documenting that `cors:` must come first and only the global `http.cors` covers 413/500), record it as an override: - -```yaml -overrides: - - must_have: "D-05: a named middleware cors: applies the profile's CORS policy to routes that carry it" - reason: "Documented that cors: must be listed first (or set on the group); 413/500 are not covered; apps that need every response covered use http.cors paths" - accepted_by: "{name}" - accepted_at: "{ISO timestamp}" -``` +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-15T20:45:00Z_ +_Verified: 2026-10-15T21:08:52Z_ _Verifier: Claude (msd-verifier)_