docs(19.3): re-verification after gap closure (human checks pending)
This commit is contained in:
@@ -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:<profile> 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:<profile> 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:<profile> 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:<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 `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:<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)
|
||||
|
||||
@@ -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:<profile>` 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:<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
|
||||
|
||||
@@ -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:<profile>` 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:<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:** 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:<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 |
|
||||
|----------|----------|--------|---------|
|
||||
| `.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:<profile>`) | 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:<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 (`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 `<prefix>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:<profile>` 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:<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
|
||||
|
||||
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:<profile>`, 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:<profile>` 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:<profile> applies the profile's CORS policy to routes that carry it"
|
||||
reason: "Documented that cors:<profile> 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)_
|
||||
|
||||
Reference in New Issue
Block a user