docs(19.3): create phase plan
This commit is contained in:
@@ -1255,10 +1255,19 @@ Plans:
|
||||
3. An S3 blob driver can be selected through framework config, so the application does not import it. `/api-docs` and `/api-docs.json` can be served. Turnstile verification middleware is available.
|
||||
4. The touched modules' READMEs and `docs/` pages are updated. The new code has unit tests, delivered in the phase's last plan.
|
||||
|
||||
**Plans:** 0 plans
|
||||
**Plans:** 5 plans
|
||||
|
||||
Plans:
|
||||
- [ ] TBD (run /msd-plan-phase 19.3 to break down)
|
||||
**Wave 1**
|
||||
- [ ] 19.3-01-PLAN.md — Decision note, then the `cooler` module: Redis-backed rate-limit store wired by serve, memory (otter) and Redis cache drivers (wave 1)
|
||||
|
||||
**Wave 2** *(blocked on Wave 1 completion)*
|
||||
- [ ] 19.3-02-PLAN.md — bouncer cookie transport and Turnstile, surf CORS hardening and `cors:<profile>`, opt-in /api-docs (wave 2)
|
||||
- [ ] 19.3-03-PLAN.md — summer.yaml `build.tags` and the `summercms_s3` S3 driver, disk-name extension fix, `storage.uploads.public_url` (wave 2)
|
||||
- [ ] 19.3-04-PLAN.md — Site-cache purge in cooler, `pact.ReorderAfterSave`, Journal/menu/FAQ adoption, shared Nuxt purge route in three layers, push checkpoint, app pointer bumps (wave 2)
|
||||
|
||||
**Wave 3** *(blocked on Wave 2 completion)*
|
||||
- [ ] 19.3-05-PLAN.md — Unit tests for all new code, `scripts/check-phase19.3.sh`, miniredis legitimacy check, release checkpoint (wave 3)
|
||||
|
||||
### Phase 19.4: OIDC provider on wristband (INSERTED)
|
||||
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
---
|
||||
msd_state_version: "1.0"
|
||||
milestone: v1.0
|
||||
current_phase: "18.1"
|
||||
current_phase_name: payment-gateway-subscriptions
|
||||
current_phase: "19.3"
|
||||
current_phase_name: Framework transport and infrastructure for WavePath
|
||||
status: executing
|
||||
stopped_at: Phase 19.3 context gathered
|
||||
last_updated: "2026-10-15T09:51:35.368Z"
|
||||
last_updated: "2026-10-15T12:45:51.064Z"
|
||||
last_activity: 2026-10-14
|
||||
last_activity_desc: Phase 18.1 execution started
|
||||
state_head: 2d352ff5829fb7a4946aac6ef4e5de540686bbc1
|
||||
state_head: 55dbb182955e40ff86defead4acd60dd276ef87c
|
||||
progress:
|
||||
total_phases: 40
|
||||
completed_phases: 31
|
||||
total_plans: 185
|
||||
total_plans: 190
|
||||
completed_plans: 182
|
||||
gsd_state_version: "1.0"
|
||||
milestone_name: milestone
|
||||
@@ -29,7 +29,7 @@ See: .planning/PROJECT.md (updated 2026-10-12)
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 18.1 (payment-gateway-subscriptions) — READY TO EXECUTE
|
||||
Phase: 19.3 (Framework transport and infrastructure for WavePath) — READY TO EXECUTE
|
||||
Plan: 6 of 6
|
||||
Status: Ready to execute
|
||||
Last activity: 2026-10-14 — Phase 18.1 execution started
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- .planning/notes/2026-10-15-cooler-go-redis-otter.md
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/cooler/README.md
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/redis.go
|
||||
- modules/cooler/ratelimit.go
|
||||
- modules/cooler/memory.go
|
||||
- modules/cooler/rediscache.go
|
||||
- modules/cooler/smoke_test.go
|
||||
- modules/surf/serve.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/store_lookup_test.go
|
||||
- modules/surf/README.md
|
||||
- README.md
|
||||
- docs/services/rate-limiting.md
|
||||
- docs/services/caching.md
|
||||
- docs/setup/configuration.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
autonomous: true
|
||||
requirements: [SC-2, SC-4, D-06, D-07, D-08, D-09, D-19]
|
||||
estimate:
|
||||
tokens: 160000
|
||||
raw_tokens: 160000
|
||||
tasks: 2
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-07: a decision note under .planning/notes/ names go-redis v9 (v9.23.0) as the Redis client, otter v2 (v2.3.0) as the memory cache driver with the reasons a stdlib map was rejected, miniredis v2.40.0 as a test-only dependency installed in plan 05 after a blocking human check, and gocloud.dev/blob/s3blob (AWS SDK v2 pulled in as indirect modules) for plan 03; the note's commit is an ancestor of the first commit that adds go-redis to go.mod."
|
||||
- "D-06, D-19: a new module `cooler` (import git.golem15.com/golem15/summercms/modules/cooler) exists with a README in the standard structure (H1, summary, import line, **Name:** mnemonic, Overview, Role in the framework, Features, Usage, API reference, Configuration, Dependencies, Testing) and a row in the root README module table whose purpose text equals the README summary sentence."
|
||||
- "D-09: with `http.rate_limit.store: redis` and `redis.url` set, `summer serve` publishes cooler's Redis-backed store as surf.Store before BuildRouter, so two application instances sharing one Redis count one fixed window together: the third request to a `throttle:2,1` route is 429 whichever instance answers it."
|
||||
- "D-09: the Redis store reproduces surf.MemoryStore semantics through one atomic Lua script: the first attempt opens the window, a denied attempt does not increment, the window never extends, retryAfter is the remaining window; keys are `<prefix>rl:<key>`."
|
||||
- "D-09 (user answer): when Redis does not answer, Attempt falls back to a per-instance surf.MemoryStore and logs a warning at most once per minute; an unreachable Redis at start-up is a warning, never a boot failure; a missing or malformed redis.url when a Redis driver is selected is a boot error that names the key and never contains the URL or its password."
|
||||
- "D-09: `route:list` and surf.Assemble never dial Redis: BuildRouter looks up a published surf.Store and falls back to NewMemoryStore; only the serve command installs cooler."
|
||||
- "D-08: cooler.Cache offers Get, Set, Has, Forget, Remember, Increment and Flush with per-entry TTL (0 = forever) and a per-app key prefix; `cache.driver: memory` (default) uses a size-bounded otter v2 cache, `cache.driver: redis` uses go-redis with keys `<prefix>cache:<key>`; Flush on Redis removes only keys under `<prefix>cache:` by SCAN and UNLINK; no tags and no locks."
|
||||
- "D-09: the framework wires everything at boot: serve publishes cooler.Cache, and cooler.FromApp gives jobs and other commands the same cache lazily without a dial at construction; apps import nothing."
|
||||
artifacts:
|
||||
- path: ".planning/notes/2026-10-15-cooler-go-redis-otter.md"
|
||||
provides: "decision note naming go-redis v9, otter v2, miniredis (test-only) and s3blob"
|
||||
contains: "go-redis"
|
||||
- path: "modules/cooler/ratelimit.go"
|
||||
provides: "Redis fixed-window store with MemoryStore semantics and a memory fallback"
|
||||
contains: "PTTL"
|
||||
- path: "modules/cooler/cooler.go"
|
||||
provides: "Install, Handle, FromApp, Cache interface, config keys"
|
||||
contains: "func Install"
|
||||
- path: "modules/cooler/memory.go"
|
||||
provides: "otter v2 memory driver"
|
||||
contains: "otter"
|
||||
- path: "modules/surf/serve.go"
|
||||
provides: "serve installs cooler and publishes the Redis store before BuildRouter"
|
||||
contains: "installCooler"
|
||||
- path: "docs/services/caching.md"
|
||||
provides: "the caching guide (drivers, keys, prefix, FromApp)"
|
||||
contains: "cache.driver"
|
||||
key_links:
|
||||
- from: "modules/surf/serve.go"
|
||||
to: "modules/cooler/cooler.go"
|
||||
via: "installCooler calls cooler.Install, then app.Publish[Store](handle.RateLimitStore(NewMemoryStore(2*time.Minute))) when http.rate_limit.store is redis"
|
||||
pattern: "cooler.Install"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "BuildRouter: app.Lookup[Store]() else NewMemoryStore(2*time.Minute)"
|
||||
pattern: "Lookup\\[Store\\]"
|
||||
- from: "modules/cooler/ratelimit.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "RateLimitStore.Attempt has surf.Store's exact signature (structural, cooler never imports surf)"
|
||||
pattern: "func \\(s \\*RateLimitStore\\) Attempt"
|
||||
prohibitions:
|
||||
- statement: "cooler imports neither surf nor cabana (surf imports cooler; cabana would close an import cycle)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code, READMEs and docs never name a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Cache Flush never empties the whole Redis database."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; MVP mode would take a story from `/msd mvp-phase 19.3`, which was not run, so no story is invented here): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 1 of 5, wave 1): SC-2. The dependency decision note lands first, then the new `cooler` module: a Redis-backed `surf.Store` wired by `serve` so rate limits hold across instances (the tracer), then the Laravel-core cache API with memory (otter v2) and Redis drivers. Plans 02, 03 and 04 build on this checkout in wave 2.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
No SPEC.md and no REQUIREMENTS IDs exist for this phase: the orchestrator recorded "spec-less probe fallback skipped: phase has no requirement IDs to probe (visible skip)". No probe predicates were generated. `must_haves` derive from the ROADMAP success criteria SC-1..SC-4 and the CONTEXT decisions D-01..D-19; prohibitions are recalled in-prompt and descriptor-less (flagged unverified).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Memory driver: otter v2 (bounded size, per-entry TTL, atomic Compute for Increment, injectable clock). A stdlib map was rejected because a key-per-client cache grows without bound. The decision note records it (D-07 discretion).
|
||||
- `serve` treats an unreachable Redis at start-up as a warning, not a boot failure, consistent with the user's answer that rate limits fall back to per-instance memory when Redis is down. A missing or malformed `redis.url` with a Redis driver selected stays a boot error (configuration, not availability).
|
||||
- Key prefix: `cache.prefix`, default derived from `app.name` (lower-cased, every run of characters outside `[a-z0-9]` replaced by `_`, then `:`), or `summer:` when `app.name` is empty. Cache keys are `<prefix>cache:<key>`, rate-limit keys `<prefix>rl:<key>`.
|
||||
- `Remember` degrades instead of failing a page: when the read fails it runs the callback and returns its value, skips a failing write, and logs a warning at most once per minute. The other methods return their errors.
|
||||
- `cooler.FromApp` gives commands other than `serve` (the dedicated job worker, console commands) the configured cache lazily; go-redis does not dial at construction.
|
||||
|
||||
<assumption_delta_decision>
|
||||
add-alongside: the rate-limit store pluralizes from memory to memory|redis. surf.MemoryStore stays the default and the fallback; the Redis store is added next to it behind `http.rate_limit.store: redis`. No existing behaviour changes for apps that set nothing.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- Decision note `.planning/notes/2026-10-15-cooler-go-redis-otter.md`.
|
||||
- Module `modules/cooler`: `cooler.Cache` (methods `Get`, `Set`, `Has`, `Forget`, `Remember`, `Increment`, `Flush`), `cooler.Attempter`, `cooler.Handle` (methods `Close`, `Cache`, `RateLimitRedis`, `RateLimitStore`), `cooler.Install`, `cooler.FromApp`, `cooler.OpenRedis`, `cooler.NewMemoryCache`, `cooler.NewRedisCache`, `cooler.RateLimitStore` with `cooler.NewRateLimitStore` and method `Attempt`, constants `cooler.RedisURLKey` (`redis.url`), `cooler.CacheDriverKey` (`cache.driver`), `cooler.CachePrefixKey` (`cache.prefix`), `cooler.CacheMaxEntriesKey` (`cache.memory.max_entries`), `cooler.RateLimitStoreKey` (`http.rate_limit.store`), `cooler.DefaultMaxEntries` (10000), `cooler.PingTimeout` (3 s), `cooler.AttemptTimeout` (500 ms).
|
||||
- Config keys: `redis.url`, `cache.driver` (`memory`|`redis`), `cache.prefix`, `cache.memory.max_entries`, `http.rate_limit.store` (`memory`|`redis`).
|
||||
- `modules/surf`: unexported `installCooler(ctx, app) (*cooler.Handle, error)` in serve.go; `BuildRouter` looks up a published `surf.Store`.
|
||||
- Smoke tests: `TestBuildRouterStoreSmoke`, `TestRateLimitAcrossInstancesSmoke` (Docker) in `modules/surf/store_lookup_test.go`; `TestOpenRedisRedactsSmoke`, `TestMemoryCacheSmoke`, `TestRedisCacheSmoke` (Docker) in `modules/cooler/smoke_test.go`. Full unit coverage is plan 05.
|
||||
- Docs: new `docs/services/caching.md`; `docs/services/rate-limiting.md` section "Sharing limits across instances"; rows in `docs/setup/configuration.md`; the scaling note in `docs/architecture/performance-and-scaling.md`; cooler README; surf README configuration and dependency rows; root README table row.
|
||||
- Dependencies: `github.com/redis/go-redis/v9` v9.23.0, `github.com/maypok86/otter/v2` v2.3.0.
|
||||
|
||||
<objective>
|
||||
Land the dependency decision note, then the `cooler` module: a Redis-backed rate-limit store that `serve` wires before the router (rate limits hold across instances), and the Laravel-core cache API with memory and Redis drivers, documented in the same commits.
|
||||
|
||||
Purpose: SC-2 and D-06..D-09, D-19. Plans 02 and 04 extend surf and cooler on top of this; plan 03 needs the decision note for s3blob.
|
||||
Output: one docs commit (the note), then code commits with README and docs, all green under go vet and go test.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
|
||||
Concurrency: Phase 18.1 gap-closure plans may run in parallel in this checkout (they touch modules/pact, modules/cabana and docs/backend, not this plan's files). Before Task 1 run `git status --porcelain -- go.mod go.sum modules/surf modules/cooler README.md docs/services docs/setup docs/architecture`; if it lists a file this plan touches, stop and report (never stash or overwrite another session's work). Stage files by explicit path; no co-author tags; the decision note (planning doc) and code go in separate commits; a module README and the docs pages change in the same commit as the code they describe. Docker-backed smoke tests skip under `-short`; run them on a Docker host (if the socket is denied although the user is in the docker group, run through `sudo -u golem`) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `modules/surf/limiter_store.go`: `type Store interface { Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) }` (:11-13), `NewMemoryStore(sweep time.Duration) *MemoryStore`, Attempt semantics (:68-93): expired or missing entry resets to count 0 and resetAt now+decay, retryAfter = resetAt-now floored at 0, `count >= max` denies without incrementing, otherwise count++ and allow.
|
||||
- `modules/surf/router.go`: `BuildRouter` (:468) builds `NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted)` at :476; `FixedWindowLimiter.store` (limiter.go:40) is the field an internal test reads.
|
||||
- `modules/surf/serve.go`: `ServeCommand` Run opens the DB, `publishUploads` (:51-55), then `BuildRouter` (:56).
|
||||
- `modules/backpack/services.go`: `Publish[T]` keyed by `reflect.TypeFor[T]()`, a duplicate is an error; `Lookup[T]() (T, bool)`.
|
||||
- `modules/postcard/mailer.go` `Activate` (:85-110): nil-app guard, driver from config with a default, `app.Publish[Mailer]`, errors prefixed by the module name.
|
||||
- `modules/compass/config.go`: `String`, `Int`, `Bool`, `Has`, `Lookup`, `Environment`.
|
||||
- `modules/postcard/mailpit_test.go` (:35-60) and `modules/beachcomber/postgres_test.go` (:29-54): per-test `testcontainers.Run` with a `testing.Short()` skip.
|
||||
- RESEARCH Pattern 1 (boot wiring), Pattern 2 (the Lua script, probed on Redis 7.2.5), Pitfall 7 (url.Parse errors echo the URL), Pitfall 13 (outage), Pitfall 14 (go-redis handshake options), Code Examples (otter driver, Redis client from redis.url).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: decision note first, then two serve instances sharing one Redis count one rate-limit window through cooler's Redis surf.Store</name>
|
||||
<reversibility rating="costly">D-07: every Redis-touching module and app config builds on go-redis v9 and on the redis.url and http.rate_limit.store keys once they ship.</reversibility>
|
||||
<files>.planning/notes/2026-10-15-cooler-go-redis-otter.md, go.mod, go.sum, modules/cooler/cooler.go, modules/cooler/redis.go, modules/cooler/ratelimit.go, modules/cooler/smoke_test.go, modules/cooler/README.md, modules/surf/serve.go, modules/surf/router.go, modules/surf/store_lookup_test.go, modules/surf/README.md, README.md, docs/services/rate-limiting.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md</files>
|
||||
<read_first>.planning/notes/2026-10-13-paymentgateway-hashids-go-hashids.md (decision note structure), .planning/research/STACK.md, .planning/research/go-ecosystem.md (Cache row), .planning/research/ARCHITECTURE.md (the cooler row near line 73), modules/surf/limiter_store.go, modules/surf/limiter.go, modules/surf/router.go (BuildRouter :468-580), modules/surf/serve.go, modules/surf/routelist_command.go, modules/backpack/services.go, modules/postcard/mailer.go, modules/postcard/README.md, modules/postcard/mailpit_test.go, modules/surf/README.md, README.md (module table :94-120), docs/services/rate-limiting.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md, 19.3-RESEARCH.md (Pattern 1, Pattern 2, Pitfalls 7, 13, 14, Code Examples)</read_first>
|
||||
<action>Per D-07 the note lands before any code. Write `.planning/notes/2026-10-15-cooler-go-redis-otter.md` in the structure of the hashids note (Decision, Related dependencies, Consequences): go-redis `github.com/redis/go-redis/v9` v9.23.0 is the Redis client (locked by D-07; official client; ParseURL handles redis, rediss and unix URLs; NewScript and Run give EVALSHA with EVAL fallback); otter `github.com/maypok86/otter/v2` v2.3.0 is the memory driver (bounded MaximumSize, per-entry ExpiryCalculator, atomic Compute, injectable Clock, no runtime dependencies; a stdlib map was rejected as unbounded); miniredis `github.com/alicebob/miniredis/v2` v2.40.0 is test-only, installed in plan 05 only after a blocking human check of the package; `gocloud.dev/blob/s3blob` comes from the already-required gocloud.dev v0.46.0, is imported only by a file under the `summercms_s3` build tag (plan 03) and adds AWS SDK v2 modules to go.mod and go.sum as indirect requirements; the Docker-backed Redis tests use the generic testcontainers Run with redis:7-alpine (no new module). Commit the note alone (`docs(19.3): decide cooler dependencies`) before the first go.mod change.
|
||||
|
||||
Then `go get github.com/redis/go-redis/v9@v9.23.0`.
|
||||
|
||||
cooler (package doc names the module role; errors prefixed `cooler:`; cooler imports neither surf nor cabana):
|
||||
- cooler.go: the key constants listed in this plan's Artifacts; `Attempter` with exactly surf.Store's Attempt signature; `Handle` with `Close() error`, `RateLimitRedis() bool` and `RateLimitStore(fallback Attempter) Attempter`; `Install(ctx context.Context, app *backpack.App) (*Handle, error)`: nil-app guard; `http.rate_limit.store` empty means memory, any value other than memory or redis is a boot error naming the key; when redis is selected, `redis.url` empty is a boot error naming the key (and the SUMMER_REDIS__URL variable), else `OpenRedis` builds the client and a Ping bounded by `PingTimeout` runs; a failed Ping logs one warning (the endpoint host only, never the URL) and boot continues. The prefix helper resolves `cache.prefix` as described in this plan's Decisions.
|
||||
- redis.go: `OpenRedis(ctx context.Context, rawURL string) (*redis.Client, error)` parses with redis.ParseURL and on error returns a fixed message naming `redis.url` and the accepted schemes without wrapping or quoting the raw error (Pitfall 7); sets DisableIdentity true and MaintNotificationsConfig mode disabled (Pitfall 14); does not dial.
|
||||
- ratelimit.go: `RateLimitStore` built by `NewRateLimitStore(client *redis.Client, prefix string, fallback Attempter) *RateLimitStore`; the Lua script of RESEARCH Pattern 2 as a package-level redis.NewScript; `Attempt` runs it with key `<prefix>rl:<key>`, ARGV max and decay in milliseconds clamped to at least 1, under a context bounded by `AttemptTimeout`; it maps the three integers to (allowed, attempts, retryAfter as milliseconds); on any Redis error it returns fallback.Attempt(key, max, decay) and logs one warning per minute at most.
|
||||
|
||||
surf: serve.go gains `installCooler(ctx, app) (*cooler.Handle, error)` called after publishUploads and before BuildRouter (deferred Close); when `RateLimitRedis()` it publishes `app.Publish[Store](h.RateLimitStore(NewMemoryStore(2*time.Minute)))`. router.go BuildRouter: when app is non-nil and `app.Lookup[Store]()` returns a non-nil store, use it; otherwise NewMemoryStore(2*time.Minute) as today. routelist_command.go is not changed: route:list never installs cooler.
|
||||
|
||||
Docs in the same commit as the code: cooler README in the standard structure (summary sentence "Cache drivers and shared counters for SummerCMS: memory and Redis caches with a key prefix and TTLs, and a Redis-backed rate-limit store.", mnemonic about a cool box at the beach keeping things fresh and at hand; at this commit list only identifiers that exist); root README table row (alphabetical, after conga) with that sentence and the mnemonic; surf README Configuration row `http.rate_limit.store` and the serve paragraph (serve installs cooler), Dependencies gains cooler; docs/services/rate-limiting.md: a section "Sharing limits across instances" (keys, the memory fallback with its warning, route:list never dials) and the sentence that each instance counts on its own now says it applies to the memory store; docs/setup/configuration.md rows `redis.url` and `http.rate_limit.store`; docs/architecture/performance-and-scaling.md: the rate-limit note points at the Redis store. No application or client is named.
|
||||
|
||||
Smoke tests (plan 05 owns full coverage): modules/surf/store_lookup_test.go `TestBuildRouterStoreSmoke` (no Docker: an app with http.rate_limit.store redis and redis.url redis://127.0.0.1:1 but no install builds a router whose limiter store is a *MemoryStore and returns within a second; an app with a published counting fake Store routes a throttle:2,1 request through the fake) and `TestRateLimitAcrossInstancesSmoke` (skips under -short; redis:7-alpine through testcontainers.Run; two apps share redis.url, each runs installCooler and surf.Assemble with a test plugin route GET /ping carrying throttle:2,1; requests A, B, A answer 200, 200, 429 with Retry-After); modules/cooler/smoke_test.go `TestOpenRedisRedactsSmoke` (a URL carrying user, password and a control character yields an error whose text contains neither the user nor the password; a rediss URL parses).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cooler/... ./modules/surf && go test -count=1 -run '^(TestBuildRouterStoreSmoke|TestRateLimitAcrossInstancesSmoke)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -run '^TestOpenRedisRedactsSmoke$' -v ./modules/cooler 2>&1 | grep -q -- '^--- PASS: TestOpenRedisRedactsSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than two "--- PASS" lines for the surf smoke tests (the Docker test skipped because no daemon answered, or failed), the redaction smoke did not pass, or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `N=$(git log --format=%H --diff-filter=A -- .planning/notes/2026-10-15-cooler-go-redis-otter.md) && G=$(git log --format=%H -S 'github.com/redis/go-redis/v9 v9.23.0' -- go.mod) && test -n "$N" && test -n "$G" && git merge-base --is-ancestor "$N" "$G"` exits 0 (one note commit, one go.mod commit, the note first).
|
||||
- `grep -n 'github.com/redis/go-redis/v9 v9.23.0' go.mod` prints one line.
|
||||
- `grep -n 'NewMemoryStore(2\*time.Minute), trusted' modules/surf/router.go` prints nothing and `grep -n 'Lookup\[Store\]' modules/surf/router.go` prints one line.
|
||||
- `go list -deps ./modules/cooler | grep -E 'summercms/modules/(surf|cabana)$'` prints nothing.
|
||||
- `grep -n '^| \[cooler\](modules/cooler/README.md)' README.md` prints one line, and the purpose cell equals the second non-empty line of modules/cooler/README.md.
|
||||
- `grep -n 'http.rate_limit.store' docs/services/rate-limiting.md docs/setup/configuration.md modules/surf/README.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>Two application instances behind one Redis enforce one shared rate limit; route:list and tests never dial Redis; a Redis outage degrades to per-instance limits with a warning.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Plugins and jobs cache values through cooler.Cache: otter memory driver, Redis driver under the app prefix, Install publishes it and FromApp serves other commands</name>
|
||||
<reversibility rating="costly">D-08: the cache.driver, cache.prefix and cache.memory.max_entries keys and the Cache method set become what plugins and app configs depend on.</reversibility>
|
||||
<files>go.mod, go.sum, modules/cooler/cooler.go, modules/cooler/memory.go, modules/cooler/rediscache.go, modules/cooler/smoke_test.go, modules/cooler/README.md, docs/services/caching.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/cooler/cooler.go and modules/cooler/redis.go (Task 1), 19.3-RESEARCH.md (Code Examples: memory driver with per-entry TTL, Anti-Patterns: the database-wide flush), docs/services/rate-limiting.md (frontmatter and tone of a services page), docs/services/mail.md (a driver-selection page), modules/cooler/README.md (Task 1)</read_first>
|
||||
<action>Per D-08 and D-09. `go get github.com/maypok86/otter/v2@v2.3.0` (named in the Task 1 note).
|
||||
|
||||
cooler.go: the `Cache` interface with `Get(ctx, key) ([]byte, bool, error)`, `Set(ctx, key string, value []byte, ttl time.Duration) error` (ttl 0 or less is forever), `Has(ctx, key) (bool, error)`, `Forget(ctx, key) error`, `Remember(ctx, key string, ttl time.Duration, fn func(context.Context) ([]byte, error)) ([]byte, error)` (degrades as this plan's Decisions say), `Increment(ctx, key string, by int64) (int64, error)` (a missing key starts at 0 and gets no TTL; an existing key keeps its expiry, as Redis INCRBY does), `Flush(ctx) error`. Install reads `cache.driver` (empty means memory; anything but memory or redis is a boot error naming the key), builds the driver, reuses a Cache that FromApp already published, otherwise publishes `app.Publish[Cache]`; `Handle.Cache()` returns it. When both the cache and the rate limiter use Redis they share one client. `FromApp(app *backpack.App) (Cache, error)` returns the published Cache or builds the configured one without a Ping and publishes it (on a duplicate-publish race it returns the winner).
|
||||
|
||||
memory.go: `NewMemoryCache(prefix string, maxEntries int) (Cache, error)` on otter v2 with MaximumSize from `cache.memory.max_entries` (default DefaultMaxEntries; zero or negative is a boot error naming the key) and an ExpiryCalculator reading an absolute expiresAt stored with the value (forever as an excessively long period, per otter's docs); Increment uses Compute so it is atomic per key; values are copied on Set and Get so callers cannot mutate stored bytes.
|
||||
|
||||
rediscache.go: `NewRedisCache(client *redis.Client, prefix string) Cache`; keys `<prefix>cache:<key>`; Set uses SET with PX (no expiry when ttl is 0 or less); Has uses EXISTS; Forget uses UNLINK; Increment uses INCRBY; Flush walks SCAN MATCH `<prefix>cache:*` in batches of 500 and removes each batch with UNLINK (never the database-wide flush command, which would also drop rate-limit counters and other apps' keys).
|
||||
|
||||
Docs in the same commit: new docs/services/caching.md (frontmatter section services, order 45, title Caching; how WinterCMS's Cache facade maps to cooler.Cache, the driver keys, the prefix and key layout, TTL semantics, Remember's degrade rule, FromApp in jobs and commands, the Flush scope; neutral names such as acme and blog); docs/setup/configuration.md rows `cache.driver`, `cache.prefix`, `cache.memory.max_entries`; cooler README Features, Usage, API reference, Configuration (every key with its default and SUMMER_ variable), Dependencies (go-redis v9, otter v2), Testing.
|
||||
|
||||
Smoke tests in modules/cooler/smoke_test.go: `TestMemoryCacheSmoke` (no Docker: Set, Get, Has, Increment from missing and existing, Remember calling the callback once, Forget, Flush, a 1 ms TTL expiring) and `TestRedisCacheSmoke` (skips under -short; redis:7-alpine; the same round trip, and Flush leaving a `<prefix>rl:` key and a foreign key in place).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -run '^(TestMemoryCacheSmoke|TestRedisCacheSmoke)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or the short suite is red, fewer than two "--- PASS" lines for the cache smoke tests (the Redis test skipped without Docker, or failed), or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'github.com/maypok86/otter/v2 v2.3.0' go.mod` prints one line.
|
||||
- `grep -cE 'FlushDB|FlushAll' modules/cooler/rediscache.go` prints 0.
|
||||
- `head -8 docs/services/caching.md | grep -c -E '^(section: services|order: 45)$'` prints 2.
|
||||
- `grep -n 'cache.driver' docs/setup/configuration.md modules/cooler/README.md docs/services/caching.md` prints at least one line per file.
|
||||
- `grep -rniE 'wavepath|fonoteka|grzyby' modules/cooler docs/services/caching.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin or job reads and writes a prefixed cache that is in-process by default and shared through Redis when configured, with Flush limited to the app's cache keys.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| application to Redis | network hop carrying credentials in redis.url and shared counters other instances trust |
|
||||
| request data to cache and limiter keys | keys can derive from client IPs, user ids or request values |
|
||||
| config and environment to boot | redis.url and driver keys come from YAML or SUMMER_ variables |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-01 | Information disclosure | cooler.OpenRedis, Install errors and logs | high | mitigate | parse errors are replaced by a fixed message naming the key, never the URL or the raw url.Parse text; the Ping warning logs only the host; TestOpenRedisRedactsSmoke asserts user and password are absent |
|
||||
| T-19.3-02 | Denial of service | RateLimitStore.Attempt during a Redis outage | medium | mitigate | per-instance MemoryStore fallback (no site-wide 429, no unlimited traffic), warning at most once per minute, 500 ms bound per attempt |
|
||||
| T-19.3-03 | Tampering | cache and limiter key space on a shared Redis | medium | mitigate | `<prefix>cache:` and `<prefix>rl:` namespaces; Flush is SCAN plus UNLINK under the cache namespace only; go-redis sends keys as arguments (no command injection) |
|
||||
| T-19.3-04 | Denial of service | memory cache growth from request-derived keys | medium | mitigate | otter MaximumSize from cache.memory.max_entries (default 10000), non-positive values refused at boot |
|
||||
| T-19.3-05 | Information disclosure | route:list and tests dialing Redis | low | mitigate | only serve calls installCooler; BuildRouter only looks up; TestBuildRouterStoreSmoke |
|
||||
| T-19.3-SC | Tampering | Go module installs (go-redis v9.23.0, otter v2.3.0) | high | mitigate | both named in the decision note before install, versions pinned, verified on proxy.golang.org in RESEARCH (Package Legitimacy Audit: OK); miniredis ([ASSUMED] name) is not installed here and waits for plan 05's blocking human check |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the note precedes the go.mod change; vet and the short cooler and surf suites; the two surf smoke tests (Docker for the cross-instance one); the redaction smoke; TestDocsTree.
|
||||
- Task 2: the memory and Redis cache smokes; the whole short suite; TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/cooler ./modules/surf`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-2: cooler ships memory and Redis drivers; surf uses a Redis-backed Store published at serve time; the decision note landed first.
|
||||
- route:list never dials Redis; the memory store stays the default.
|
||||
- `go vet ./...` and `go test -short ./...` green at every commit; the docs checker green.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md` when done, with the commits (note first), the identifiers added, the Docker smoke tests run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,287 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- modules/bouncer/cookie.go
|
||||
- modules/bouncer/turnstile.go
|
||||
- modules/bouncer/README.md
|
||||
- modules/surf/cors.go
|
||||
- modules/surf/router.go
|
||||
- modules/surf/apidocs.go
|
||||
- modules/surf/transport_smoke_test.go
|
||||
- modules/surf/README.md
|
||||
- docs/services/authentication.md
|
||||
- docs/services/routing.md
|
||||
autonomous: true
|
||||
requirements: [SC-1, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-16, D-17, D-18, D-19]
|
||||
estimate:
|
||||
tokens: 190000
|
||||
raw_tokens: 190000
|
||||
tasks: 4
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-01: bouncer ships config-driven cookie builders (CookieConfig.TokenCookie, CookieConfig.ForgetTokenCookie) and optional handlers (CookieRefreshHandler, CookieLogoutHandler); the refresh handler reads the token cookie, calls the app-supplied Refresh func (bouncer.Refresh or RefreshAudienceFor, which rotate the token and blacklist the old JTI), sets the rotated cookie and hands the result to the app's writer func; sm-user-plugin is not changed."
|
||||
- "D-02: cookie attributes come from one block auth.cookie.* (name, domain, secure, same_site, path); Max-Age equals the refresh window the app passes (the guard's refresh_ttl, 20160 min by default), never the access ttl; the per-guard ttl and refresh_ttl keys (golem15.user.jwt.*, admin.jwt.*) stay where they are."
|
||||
- "D-03: with auth.cookie unset the cookie is token, Path=/, HttpOnly, SameSite=Lax, host-only (no Domain), Secure exactly when app.url is https; an invalid domain, same_site none without Secure, or secure false in the production environment is an error from LoadCookieConfig that names the key (bouncer imports no framework module: LoadCookieConfig takes a three-method interface that *compass.Config satisfies)."
|
||||
- "D-04: http.cors with supports_credentials true and a * origin (global or any profile) fails boot naming the key; an allowed_origins_patterns entry that does not compile, or a PHP-delimited one such as #^...$#, fails boot; a * inside an allowed_origins entry becomes an anchored pattern; a dynamic origin answer (credentials, patterns or several origins) echoes the exact request origin and sends Vary: Origin on every response of a CORS path, preflight and disallowed origin included; the existing * non-credentialed behaviour stays byte-identical (TestCORSOptionsMatchesLaravel green); a www origin calling an api host with credentials works end to end."
|
||||
- "D-05: a named middleware cors:<profile> reads http.cors_profiles.<profile> (the http.cors keys without paths); every route carrying it gets an OPTIONS route that answers the preflight from the profile; an unknown profile, a profile with paths, or a profiled route whose path also matches http.cors.paths fails boot."
|
||||
- "D-18: bouncer.Turnstile.Verify(ctx, token, ip, failClosed) posts secret, response and remoteip as a form to Cloudflare siteverify with a 5 s timeout; open mode passes on an empty secret; closed mode answers 503 when unconfigured in the production environment and 422 on a missing token, success false, an error or a timeout; a named middleware turnstile:open|closed reads turnstile_token (or turnstile.field) from the query, a JSON body or a form body, restores the body for the handler and writes errors through an app-published bouncer.TurnstileWriter or a neutral default; the secret comes from turnstile.secret (SUMMER_TURNSTILE__SECRET)."
|
||||
- "D-16, D-17: with http.api_docs.enabled true and exactly one plugin implementing surf.APIDocsProvider, GET /api-docs (http.api_docs.path) serves a Swagger UI page loading swagger-ui-dist 5.33.1 from jsDelivr with sha384 SRI and crossorigin on all three assets, and GET /api-docs.json serves the provider's YAML or JSON spec converted to compact JSON once at boot in source key order; both are public; off by default (404); zero or several providers with the flag on fail boot."
|
||||
artifacts:
|
||||
- path: "modules/bouncer/cookie.go"
|
||||
provides: "CookieConfig, LoadCookieConfig, TokenCookie, ForgetTokenCookie, CookieRefreshHandler, CookieLogoutHandler"
|
||||
contains: "func LoadCookieConfig"
|
||||
- path: "modules/bouncer/turnstile.go"
|
||||
provides: "Turnstile verifier, verdicts, middleware, writer type"
|
||||
contains: "challenges.cloudflare.com/turnstile/v0/siteverify"
|
||||
- path: "modules/surf/cors.go"
|
||||
provides: "validated CORS config, shared policy, profile middleware and preflight handler"
|
||||
contains: "cors_profiles"
|
||||
- path: "modules/surf/apidocs.go"
|
||||
provides: "APIDocsProvider, the pinned Swagger UI page and the spec route"
|
||||
contains: "5.33.1"
|
||||
key_links:
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/cors.go"
|
||||
via: "BuildRouter registers the cors factory; compile registers OPTIONS routes for routes whose chain names cors:<profile>"
|
||||
pattern: "\"cors\""
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/bouncer/turnstile.go"
|
||||
via: "BuildRouter registers the turnstile factory with a bouncer.Turnstile built from turnstile.secret and the environment, ClientIP with trusted proxies"
|
||||
pattern: "\"turnstile\""
|
||||
- from: "modules/bouncer/cookie.go"
|
||||
to: "modules/bouncer/refresh.go"
|
||||
via: "the app's Refresh func wraps bouncer.Refresh or RefreshAudienceFor; the handler sets TokenCookie(newToken, refreshTTL)"
|
||||
pattern: "TokenCookie"
|
||||
- from: "modules/surf/router.go"
|
||||
to: "modules/surf/apidocs.go"
|
||||
via: "BuildRouter calls mountAPIDocs after mountUploads"
|
||||
pattern: "mountAPIDocs"
|
||||
prohibitions:
|
||||
- statement: "bouncer still imports no SummerCMS module."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Existing non-credentialed * CORS answers (all four apps today) stay byte-identical."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Neither the Turnstile secret nor a token ever appears in a log line or error."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 2 of 5, wave 2): SC-1 and two thirds of SC-3. Cookie transport in bouncer with hardened credentialed CORS (the tracer: a www page refreshes its session on the api host), named CORS profiles, Turnstile verification, and the opt-in /api-docs page.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Cookie builders are methods on `bouncer.CookieConfig`, not guard methods: one config serves every guard, and the refresh window is passed per call (the guard's `refresh_ttl`).
|
||||
- `bouncer.LoadCookieConfig` takes `bouncer.CookieConfigSource` (`Lookup`, `String`, `Environment`), which `*compass.Config` satisfies structurally, so bouncer keeps importing no framework module (pattern-mapper flag 1). Apps call it in their plugin Boot and return its error, which fails boot on a bad cookie config.
|
||||
- The handlers return `(http.Handler, error)` so missing options are a boot error, not a request-time panic. The refresh handler reads only the cookie (as PHP does); a missing cookie or a refused refresh answers 401 through the writer and leaves the cookie alone. The logout handler revokes through the app's Revoke func when a cookie is present, always sends the forget cookie, and answers 200.
|
||||
- CORS profiles live under `http.cors_profiles.<name>`; the middleware name is `cors:<name>`.
|
||||
- Turnstile open mode with a secret set: a transport error or timeout is a 422 failure (PHP lets the exception surface as a 500); a missing token is a 422 without a siteverify call (PHP sends it and Cloudflare answers false). A token longer than 2048 characters fails without a call. Both are recorded here as deliberate, outcome-preserving divergences.
|
||||
- The default Turnstile error bodies are neutral JSON: 503 `{"error":"turnstile_unavailable"}`, 422 `{"error":"turnstile_failed"}`; an app keeps its PHP envelope by publishing a `bouncer.TurnstileWriter`.
|
||||
- The api-docs spec is converted at boot; a spec that does not parse fails boot naming the plugin (PHP answers 500 per request). The page sets a per-request CSP nonce for its one inline script.
|
||||
- Plan 02's config keys are documented in the surf and bouncer READMEs and the services pages; the curated key table in docs/setup/configuration.md is left to plans 01 and 03 so wave-2 plans never edit the same file.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/bouncer/cookie.go`: `CookieConfig` (fields `Name`, `Domain`, `Path`, `Secure`, `SameSite`), `CookieConfigSource`, `LoadCookieConfig`, `DefaultCookieName` (`token`), methods `TokenCookie(token string, refreshTTL time.Duration) *http.Cookie` and `ForgetTokenCookie() *http.Cookie`, `CookieResult` (`Status`, `Token`, `Err`), `CookieRefreshOptions` (`Cookie`, `RefreshTTL`, `Refresh`, `Write`), `CookieLogoutOptions` (`Cookie`, `Revoke`, `Write`), `CookieRefreshHandler`, `CookieLogoutHandler`, `ErrNoTokenCookie`.
|
||||
- `modules/bouncer/turnstile.go`: `Turnstile` (fields `Secret`, `Production`, `Endpoint`, `Client`), `NewTurnstile`, `TurnstileSiteverifyURL`, `TurnstileTimeout` (5 s), `TurnstileMaxTokenLength` (2048), `DefaultTurnstileField` (`turnstile_token`), `TurnstileVerdict` with `TurnstilePassed`, `TurnstileFailed`, `TurnstileUnavailable`, method `Verify`, method `Middleware`, `TurnstileWriter`.
|
||||
- `modules/surf/cors.go`: `LoadCORSProfiles`, validation inside `LoadCORSConfig`, profile middleware and preflight handler; `modules/surf/router.go`: factories `cors` and `turnstile`, OPTIONS routes for profiled routes; `modules/surf/apidocs.go`: `APIDocsProvider`, `SwaggerUIVersion` (`5.33.1`), unexported `mountAPIDocs`.
|
||||
- Config keys: `auth.cookie.name`, `auth.cookie.domain`, `auth.cookie.path`, `auth.cookie.secure`, `auth.cookie.same_site`; `http.cors_profiles.<name>.*`; `turnstile.secret`, `turnstile.field`; `http.api_docs.enabled`, `http.api_docs.path`, `http.api_docs.title`.
|
||||
- Named middleware `cors:<profile>`, `turnstile:open`, `turnstile:closed`; routes `GET /api-docs` and `GET /api-docs.json` (opt-in).
|
||||
- Smoke tests in `modules/surf/transport_smoke_test.go`: `TestCookieRefreshAcrossSubdomainsSmoke`, `TestCORSCredentialsWildcardRefusedSmoke`, `TestCORSProfileSmoke`, `TestTurnstileMiddlewareSmoke`, `TestAPIDocsSmoke`. Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/authentication.md` sections "Cookie transport" and "Turnstile"; `docs/services/routing.md` CORS hardening, "CORS profiles", "API documentation page"; bouncer and surf READMEs.
|
||||
|
||||
<objective>
|
||||
Give the framework cookie-based token refresh across subdomains, credentialed CORS that refuses unsafe configs (globally and per route group), Cloudflare Turnstile verification as middleware, and an opt-in Swagger UI page with its JSON spec, each with README and docs in the same commit.
|
||||
|
||||
Purpose: SC-1, the api-docs and Turnstile parts of SC-3, and D-01..D-05, D-16..D-18 (placement per D-19).
|
||||
Output: bouncer and surf changes with smoke tests and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/COVERAGE.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
|
||||
Concurrency: plans 19.3-03 and 19.3-04 run in the same wave in this checkout and own other files (lagoon/attach, internal/build, go.mod, cooler, pact, cabana). Before Task 1 run `git status --porcelain -- modules/bouncer modules/surf docs/services/authentication.md docs/services/routing.md`; if it lists a file this plan touches that this plan did not change, stop and report. A transient vet or test failure in a package another wave-2 plan owns is not this plan's: re-run once after a short pause, then report it instead of editing that package. Stage files by explicit path; no co-author tags. PHP reference files live under /media/nvme/dev/jz/wavepath.org; read them for behaviour, never name that application in framework code or docs.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `modules/bouncer/refresh.go`: `Refresh(secret, tokenString string, refreshTTL time.Duration, bl BlacklistStore, grace time.Duration, issuerURL string) (string, error)`, `RefreshAudienceFor(ctx, users, secret, tokenString, audience, refreshTTL, bl, grace, issuerURL)`, `VerifyRefreshableClaimsAudience` (logout of a token past its access lifetime).
|
||||
- `modules/bouncer/mint.go`: `Mint(secret, sub, issuerURL string, ttl time.Duration) (token, jti string, err error)`; `modules/bouncer/blacklist.go`: `NewMemoryBlacklist`, `IsBlacklisted`.
|
||||
- `modules/cabana/auth.go` :503-517: refusal of `cookie_secure: false` in the production environment (`app.Config.Environment()`).
|
||||
- `modules/surf/cors.go`: `CORSConfig` and koanf tags, `LoadCORSConfig`, `pathScopedCORS`, `writeOptions` (Laravel preflight bytes), `corsAllowOrigin`, `compileLaravelGlob`; `modules/surf/cors_coverage_test.go` :99-147 `TestCORSOptionsMatchesLaravel`.
|
||||
- `modules/surf/router.go`: `RegisterMiddlewareFactory`, `wrap` (factory lookup by `base:param`, throttle validation at :421 is the model for validating cors and turnstile params at boot), `compile` (:345, ServeMux registration, overlap families, `handleRoute` conflict to error), `BuildRouter` (:468), `BucketProvider` type assertion (:538) as the model for `APIDocsProvider`; `modules/surf/uploads.go` `mountUploads` and `uploadsPrefix` (opt-in route, prefix validation, conflict check, `GroupRaw`).
|
||||
- `modules/surf/clientip.go`: `ClientIP(r, trusted)`, `TrustedProxies(cfg)`.
|
||||
- PHP: `AuthApiController.php` `tokenCookie`/`forgetTokenCookie` (:342-381), `TurnstileVerifier.php`, `ApiDocsController.php`, php-cors `CorsService.php` :100-131 (wildcard to pattern), :204-220 (origin answer).
|
||||
- RESEARCH Pattern 3 (cookies), Pattern 4 (CORS), Pattern 7 (Turnstile), Pattern 9 (api-docs, SRI hashes for 5.33.1), Pitfalls 1-5, 12.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: a page on www refreshes its session on the api host: credentialed CORS answers the exact origin and the cookie refresh handler rotates the httpOnly token cookie and blacklists the old JTI</name>
|
||||
<reversibility rating="costly">D-02: apps' config files and deploy docs reference the auth.cookie.* keys once they ship.</reversibility>
|
||||
<files>modules/bouncer/cookie.go, modules/bouncer/README.md, modules/surf/cors.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/authentication.md, docs/services/routing.md</files>
|
||||
<read_first>modules/bouncer/refresh.go, modules/bouncer/jwt.go (extractToken and cookie names), modules/bouncer/mint.go, modules/bouncer/blacklist.go, modules/bouncer/README.md, modules/cabana/auth.go (:440-520), modules/compass/config.go (Lookup, String, Environment), modules/surf/cors.go, modules/surf/cors_test.go, modules/surf/cors_coverage_test.go, modules/surf/router.go (BuildRouter), modules/surf/README.md (Configuration), docs/services/authentication.md (Refreshing and revoking), docs/services/routing.md (CORS), /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php (:280-400), /media/nvme/dev/jz/wavepath.org/vendor/fruitcake/php-cors/src/CorsService.php (:100-131, :204-220), 19.3-RESEARCH.md (Pattern 3, Pattern 4, Pitfalls 1-4)</read_first>
|
||||
<action>bouncer/cookie.go per D-01..D-03: `CookieConfigSource` with `Lookup(path string) (any, bool)`, `String(path string) string`, `Environment() string`. `LoadCookieConfig(src CookieConfigSource) (CookieConfig, error)` reads auth.cookie.name (default DefaultCookieName; must be a valid cookie name), auth.cookie.path (default /; must start with /), auth.cookie.domain (default empty, host-only; a non-empty value must pass (*http.Cookie).Valid, which also catches the silent drop of Pitfall 1), auth.cookie.same_site (lax, strict or none, case-insensitive; default lax), auth.cookie.secure (unset: true exactly when app.url has the https scheme; an explicit false in the production environment is an error, as cabana refuses backend.cookie_secure false); same_site none without Secure is an error. Every error names the key and is prefixed `bouncer:`. `TokenCookie(token, refreshTTL)` sets Name, Value, Path, Domain, MaxAge refreshTTL in whole seconds, Expires now plus refreshTTL, HttpOnly true, Secure and SameSite from the config (D-02: Max-Age is the refresh window). `ForgetTokenCookie()` has the same attributes, an empty value, MaxAge -1 and Expires at Unix 0. `CookieRefreshHandler(opts CookieRefreshOptions) (http.Handler, error)` refuses a nil Refresh or Write or a non-positive RefreshTTL; per request it reads the cookie named opts.Cookie.Name (missing or empty: Write with 401 and ErrNoTokenCookie), calls Refresh(r.Context(), token) (error: Write with 401 and the error, cookie untouched), otherwise http.SetCookie with TokenCookie(newToken, RefreshTTL) and Write with 200 and the new token. `CookieLogoutHandler(opts CookieLogoutOptions) (http.Handler, error)` refuses a nil Revoke or Write; when the cookie is present it calls Revoke and passes a revoke error to Write in the result; it always sets ForgetTokenCookie and writes 200. bouncer gains no framework import.
|
||||
|
||||
surf/cors.go per D-04: LoadCORSConfig validates and returns errors naming http.cors keys: supports_credentials true with a * entry in allowed_origins; an allowed_origins_patterns entry that does not compile, or one wrapped in PHP delimiters (# or / at both ends), with a hint to drop the delimiters; it translates an allowed_origins entry containing * (other than the bare *) into an anchored pattern the way php-cors convertWildcardToPattern does (quote the rest, * becomes any characters). Factor pathScopedCORS's per-request logic into an unexported policy type that both the global wrapper and Task 2's profiles reuse. When the origin answer is dynamic (credentials true, any pattern, or more than one listed origin) every response on a CORS path, preflight and disallowed origin included, carries Vary: Origin (combined with the existing preflight Vary values), and an allowed origin is echoed exactly. A non-dynamic config (bare * without credentials, the four apps' setting) keeps today's bytes. BuildRouter already returns LoadCORSConfig's error.
|
||||
|
||||
Docs in the same commits: bouncer README (Features, Usage example of an app mounting the refresh handler with RefreshAudienceFor and its own envelope writer, API reference rows for every new identifier, Configuration for auth.cookie.*); docs/services/authentication.md section "Cookie transport" (defaults, why Max-Age is the refresh window, Domain for api and www subdomains and the trust boundary it creates, CSRF note: refresh and logout accept POST only, prefer a JSON body or custom header, SameSite Lax treats sibling subdomains as same-site); docs/services/routing.md CORS section (credentials rules, exact-origin echo and Vary, wildcard entries, pattern errors, the api and www example); surf README Configuration rows updated for the hardened keys. Neutral host names (example.com).
|
||||
|
||||
Smoke tests in modules/surf/transport_smoke_test.go: `TestCookieRefreshAcrossSubdomainsSmoke` (config: app.url https://api.example.com, auth.cookie.domain example.com, http.cors paths api/*, allowed_origins https://www.example.com, methods and headers *, supports_credentials true; a test plugin route POST /api/auth/refresh on CookieRefreshHandler whose Refresh wraps bouncer.Refresh with a MemoryBlacklist and a 20160-minute window; a token from bouncer.Mint in the cookie; Origin https://www.example.com: 200, Set-Cookie token with a new value, Domain=example.com, Path=/, Max-Age=1209600, HttpOnly, Secure, SameSite=Lax, Access-Control-Allow-Origin equal to the origin, Allow-Credentials true, Vary containing Origin, the old jti blacklisted; the OPTIONS preflight answers 204 with Vary containing Origin) and `TestCORSCredentialsWildcardRefusedSmoke` (credentials plus * makes BuildRouter fail with an error naming http.cors).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bouncer ./modules/surf && go test -count=1 -short -run '^(TestCookieRefreshAcrossSubdomainsSmoke|TestCORSCredentialsWildcardRefusedSmoke|TestCORSOptionsMatchesLaravel)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the bouncer or surf short suite is red, fewer than three "--- PASS" lines (a smoke test or the Laravel preflight parity test failed or did not run), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go list -f '{{join .Imports "\n"}}' ./modules/bouncer | grep -c 'golem15/summercms/modules'` prints 0.
|
||||
- `grep -n 'func LoadCookieConfig(src CookieConfigSource) (CookieConfig, error)' modules/bouncer/cookie.go` prints one line.
|
||||
- `grep -n 'auth.cookie.same_site' modules/bouncer/README.md docs/services/authentication.md` prints at least one line per file.
|
||||
- `grep -n 'supports_credentials' docs/services/routing.md` prints at least two lines.
|
||||
- `grep -rniE 'wavepath' modules/bouncer modules/surf docs/services/authentication.md docs/services/routing.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>A browser on www keeps its session alive through the api host with an httpOnly cookie, and a credentialed CORS config that would reflect any origin cannot boot.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: A route group carries its own credentialed CORS policy through cors:<profile>, with preflights answered by an OPTIONS route the router registers</name>
|
||||
<reversibility rating="costly">D-05: http.cors_profiles.* keys and the cors:<profile> middleware name are what plugin route declarations and app configs reference.</reversibility>
|
||||
<files>modules/surf/cors.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/routing.md</files>
|
||||
<read_first>modules/surf/cors.go (Task 1 policy type), modules/surf/router.go (wrap :399-456, compile :345-387, handleRoute, overlap family registration), modules/surf/overlap.go, modules/surf/limiter.go (ValidateThrottle as the boot-validation model), docs/services/routing.md (Middleware and CORS sections), 19.3-RESEARCH.md (Pattern 4 profiles, Pitfall 5)</read_first>
|
||||
<action>Per D-05. cors.go: `LoadCORSProfiles(cfg *compass.Config) (map[string]CORSConfig, error)` reads every child of http.cors_profiles with the CORSConfig keys; a profile that sets paths is an error; each profile goes through the Task 1 validation with errors naming http.cors_profiles.<name>.<key>; profile names follow the plugin-id character set (lower-case letters, digits, underscore and dash). Add an unexported middleware built from a profile's policy that, for non-OPTIONS requests, sets the same headers the global wrapper sets for an allowed origin (and Vary: Origin when dynamic), and an unexported preflight handler that answers OPTIONS from the profile with writeOptions semantics.
|
||||
|
||||
router.go: BuildRouter loads the profiles and registers factory `cors` (plugin surf); wrap validates the param like throttle: an unknown profile fails boot naming the plugin, the route and the profile. compile registers, for every route whose middleware list contains cors:<p>, one `OPTIONS <path>` route on the preflight handler wrapped only in recover and recordPattern (preflights carry no credentials, so no auth, throttle or body middleware); two routes on the same path with different profiles fail boot; a ServeMux conflict surfaces through handleRoute as an error; a profiled route whose path matches a glob of http.cors.paths fails boot with a message saying the global wrapper would answer its preflight first. route:list shows the OPTIONS routes with plugin surf.
|
||||
|
||||
Docs in the same commit: docs/services/routing.md section "CORS profiles" (config example with a www origin and credentials, the middleware name, the generated OPTIONS route, the overlap rule); surf README Configuration rows http.cors_profiles.<name>.* and the cors middleware in Features and API reference (LoadCORSProfiles).
|
||||
|
||||
Smoke test `TestCORSProfileSmoke` appended to modules/surf/transport_smoke_test.go: profile spa (allowed_origins https://www.example.com, methods GET and POST, headers Content-Type, credentials true) on GET /x/me with cors:spa and no global http.cors; OPTIONS /x/me with that Origin and Access-Control-Request-Method GET answers 204 with the exact origin, Allow-Credentials true and Vary containing Origin; GET with that origin carries the exact origin; GET with https://evil.example.org carries no Allow-Origin and does carry Vary: Origin; a route naming cors:missing makes BuildRouter fail naming missing.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf && go test -count=1 -short -run '^(TestCORSProfileSmoke|TestCookieRefreshAcrossSubdomainsSmoke|TestCORSOptionsMatchesLaravel)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the surf short suite is red, fewer than three "--- PASS" lines (the profile smoke, the tracer smoke or the Laravel preflight parity failed or did not run), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func LoadCORSProfiles(cfg \*compass.Config) (map\[string\]CORSConfig, error)' modules/surf/cors.go` prints one line.
|
||||
- `grep -n 'RegisterMiddlewareFactory("surf", "cors"' modules/surf/router.go` prints one line.
|
||||
- `grep -n 'cors_profiles' modules/surf/README.md docs/services/routing.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>A plugin's route group can opt into its own credentialed CORS policy, and browsers' preflights for it get a 204 instead of a 405.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: A guest or registration route rejects bots through turnstile:closed or turnstile:open, verified against Cloudflare siteverify with PHP's two modes</name>
|
||||
<files>modules/bouncer/turnstile.go, modules/bouncer/README.md, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/authentication.md</files>
|
||||
<read_first>/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/classes/auth/TurnstileVerifier.php, /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/GuestCallController.php (:55-75), /media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php (:60-75, :175-190), ../sm-journal-plugin/classes/site_cache.go (bounded outbound POST, transport error without the URL), modules/surf/bodylimit.go, modules/surf/router.go (wrap order: body cap outside named middleware), modules/surf/clientip.go, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/COVERAGE.md (turnstile rows), 19.3-RESEARCH.md (Pattern 7, Pitfall 12)</read_first>
|
||||
<action>Per D-18 and the COVERAGE.md turnstile rows. bouncer/turnstile.go: `Turnstile` with Secret, Production, Endpoint (default TurnstileSiteverifyURL, overridable for tests) and Client (default an *http.Client with Timeout TurnstileTimeout); `NewTurnstile(secret string, production bool) *Turnstile`. `Verify(ctx, token, ip string, failClosed bool) (TurnstileVerdict, error)`: empty secret gives Passed in open mode, and in closed mode Unavailable in production and Passed elsewhere; an empty token or one longer than TurnstileMaxTokenLength gives Failed without a call; otherwise one POST of the form fields secret, response and remoteip (remoteip omitted when ip is empty) under a context bounded by TurnstileTimeout, reading at most 4 KiB of the answer; Passed only when the JSON field success is exactly true; any transport error, timeout, non-2xx status or undecodable answer gives Failed with the error returned for logging (never containing the secret or the token; a url.Error is unwrapped like site_cache.go's purgeTransportError). `TurnstileWriter` is `func(w http.ResponseWriter, r *http.Request, status int, verdict TurnstileVerdict)`. `Middleware(failClosed bool, field string, clientIP func(*http.Request) string, write TurnstileWriter) func(http.Handler) http.Handler`: reads the token from the query parameter named field, else from the body (a JSON object's top-level string field, or an application/x-www-form-urlencoded field), reading the body fully (the router's body cap already bounds it) and restoring r.Body with the same bytes before calling the next handler; Unavailable writes 503, Failed writes 422, Passed calls next; a transport error is logged once per request at warn with the error text only. A nil write uses the neutral default bodies from this plan's Decisions.
|
||||
|
||||
router.go BuildRouter: build the Turnstile from turnstile.secret, turnstile.field (default DefaultTurnstileField) and Production = the environment is production; writer from app.Lookup[bouncer.TurnstileWriter]() when published; register factory `turnstile` whose param open or closed selects failClosed; wrap validates the param (anything else fails boot naming the plugin and route); the clientIP func is ClientIP with the router's trusted proxies.
|
||||
|
||||
Docs in the same commit: docs/services/authentication.md section "Turnstile" (the two modes and when to use each, statuses, the token field, the writer for app envelopes, turnstile.secret with SUMMER_TURNSTILE__SECRET and the mapping from a PHP TURNSTILE_SECRET variable, test secrets from Cloudflare's testing page, the single-use token note, the order-of-errors caveat versus validating in the handler, and Verify for in-handler use); bouncer README API rows; surf README Configuration rows turnstile.secret and turnstile.field and the middleware.
|
||||
|
||||
Smoke test `TestTurnstileMiddlewareSmoke` appended to modules/surf/transport_smoke_test.go, against an httptest siteverify fake (Endpoint override) that answers success true for token ok and false otherwise: POST /guest with turnstile:closed and a JSON body {"turnstile_token":"ok","name":"x"} reaches the handler with the full body; token bad answers 422; POST /register with turnstile:open and an empty secret passes without calling the fake; turnstile:closed with an empty secret in SUMMER_ENV production answers 503; turnstile:maybe fails BuildRouter.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/bouncer ./modules/surf && go test -count=1 -short -run '^TestTurnstileMiddlewareSmoke$' -v ./modules/surf 2>&1 | grep -q -- '^--- PASS: TestTurnstileMiddlewareSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, the Turnstile smoke did not print "--- PASS", or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'https://challenges.cloudflare.com/turnstile/v0/siteverify' modules/bouncer/turnstile.go` prints one line.
|
||||
- `grep -n 'TurnstileTimeout *= *5 \* time.Second' modules/bouncer/turnstile.go` prints one line.
|
||||
- `grep -n 'RegisterMiddlewareFactory("surf", "turnstile"' modules/surf/router.go` prints one line.
|
||||
- `grep -n 'SUMMER_TURNSTILE__SECRET' docs/services/authentication.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>An app guards its guest and registration endpoints with Turnstile in PHP's fail-closed and fail-open modes, keeping its own error envelope.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 4: A developer opens /api-docs and reads the app's public API in a pinned, SRI-checked Swagger UI fed by /api-docs.json</name>
|
||||
<files>modules/surf/apidocs.go, modules/surf/router.go, modules/surf/transport_smoke_test.go, modules/surf/README.md, docs/services/routing.md</files>
|
||||
<read_first>/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/ApiDocsController.php, modules/surf/uploads.go (mountUploads, uploadsPrefix), modules/surf/router.go (BucketProvider assertion, mountUploads call), modules/surf/README.md, docs/services/routing.md, 19.3-RESEARCH.md (Pattern 9: the three sha384 hashes for swagger-ui-dist 5.33.1, Package Legitimacy Audit row for swagger-ui-dist)</read_first>
|
||||
<action>Per D-16 and D-17. apidocs.go: `APIDocsProvider` interface with `APIDocsSpec() ([]byte, error)`, type-asserted on plugins like BucketProvider (surf-defined, not pact). `SwaggerUIVersion` constant 5.33.1 (5.33.2 is avoided: flagged too new in RESEARCH) and unexported constants for the three jsDelivr asset URLs under https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.33.1/ with their sha384 integrity values copied from RESEARCH Pattern 9. `mountAPIDocs(r *Router, app *backpack.App, plugins []party.Plugin) error`, called from BuildRouter after mountUploads: nothing unless http.api_docs.enabled is true; path from http.api_docs.path (default /api-docs; validated as an absolute URL path with the uploadsPrefix rules); JSON route at path plus .json; title http.api_docs.title (default API documentation); zero providers or more than one fails boot naming the providers; the provider's bytes are converted once: a body whose first non-space byte is { must be valid JSON, anything else goes through goccy/go-yaml YAMLToJSON; then json.Compact; a conversion error fails boot naming the plugin. Routes under plugin id summercms.apidocs through GroupRaw with no middleware (public), refusing to mount over an existing route (boot error like mountUploads). GET path renders an html/template page: the title, the css link and both scripts with integrity and crossorigin anonymous, and one inline script with a per-request random nonce that calls SwaggerUIBundle with url set to the JSON path (template-escaped), dom_id #swagger-ui, the standalone preset and layout StandaloneLayout; headers Content-Type text/html; charset=utf-8, X-Content-Type-Options nosniff, Referrer-Policy no-referrer, and Content-Security-Policy with script-src limited to the nonce and https://cdn.jsdelivr.net, object-src none, base-uri none and frame-ancestors none. GET path.json writes the cached bytes with Content-Type application/json.
|
||||
|
||||
Docs in the same commit: docs/services/routing.md section "API documentation page" (provider interface with an embedded YAML example under a neutral plugin, the three keys, why the version is pinned with SRI, both routes public, off by default); surf README Configuration rows http.api_docs.* and API reference rows APIDocsProvider and SwaggerUIVersion.
|
||||
|
||||
Smoke test `TestAPIDocsSmoke` appended to modules/surf/transport_smoke_test.go: a provider plugin returning YAML whose keys are not in alphabetical order (openapi, info, paths); with the flag off GET /api-docs answers 404; with it on GET /api-docs.json answers compact JSON whose keys keep the source order, and GET /api-docs contains swagger-ui-dist@5.33.1 and three integrity="sha384- attributes and a nonce matching the CSP header; two providers fail BuildRouter.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/surf && go test -count=1 -short -run '^(TestAPIDocsSmoke|TestCORSProfileSmoke|TestTurnstileMiddlewareSmoke|TestCookieRefreshAcrossSubdomainsSmoke|TestCORSCredentialsWildcardRefusedSmoke)$' -v ./modules/surf 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 5 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than five "--- PASS" lines for the plan's smoke tests, or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'sha384-' modules/surf/apidocs.go` prints 3.
|
||||
- `grep -n 'swagger-ui-dist@5.33.1' modules/surf/apidocs.go` prints at least one line and `grep -c 'swagger-ui-dist@5"' modules/surf/apidocs.go` prints 0 (no floating major).
|
||||
- `grep -n 'goccy/go-yaml' modules/surf/apidocs.go` prints one line.
|
||||
- `grep -n 'http.api_docs.enabled' modules/surf/README.md docs/services/routing.md` prints at least one line per file.
|
||||
</acceptance_criteria>
|
||||
<done>An app that ships a spec can turn on a public API docs page whose CDN assets cannot be swapped under it.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| browser on a sibling subdomain to the api host | credentialed cross-origin requests and the token cookie |
|
||||
| request to Turnstile middleware | untrusted token and body; the siteverify call crosses to Cloudflare |
|
||||
| jsDelivr CDN to the api-docs page | third-party scripts executed in the app's origin |
|
||||
| config to boot | CORS, cookie, Turnstile and api-docs keys validated before serving |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-06 | Information disclosure | surf CORS with credentials and a * origin | high | mitigate | LoadCORSConfig and LoadCORSProfiles refuse the combination at boot; TestCORSCredentialsWildcardRefusedSmoke |
|
||||
| T-19.3-07 | Tampering | shared caches storing a CORS answer for another origin | medium | mitigate | Vary: Origin on every dynamic CORS response, preflight and disallowed origin included; exact-origin echo |
|
||||
| T-19.3-08 | Spoofing | token cookie attributes (SameSite none without Secure, secure false in production, invalid Domain dropped silently) | high | mitigate | LoadCookieConfig errors for each, with (*http.Cookie).Valid for the domain; httpOnly always on |
|
||||
| T-19.3-09 | Information disclosure | cookie Domain covering every subdomain | medium | mitigate | host-only default; Domain is opt-in and the docs name the subdomain trust boundary |
|
||||
| T-19.3-10 | Tampering | CSRF on the cookie refresh and logout handlers from a sibling subdomain | low | accept | refresh only rotates a cookie (the answer is unreadable cross-origin unless CORS allows that origin); logout CSRF is a forced logout; POST-only routes and the docs' JSON-or-custom-header advice |
|
||||
| T-19.3-11 | Spoofing | Turnstile bypass by an unconfigured secret | high | mitigate | closed mode answers 503 in production with an empty secret; open mode is documented for registration only; with a secret both modes require success true |
|
||||
| T-19.3-12 | Denial of service | slow siteverify piling up requests | medium | mitigate | TurnstileTimeout 5 s on the client and the context; at most 4 KiB of the answer read |
|
||||
| T-19.3-13 | Tampering | Turnstile middleware consuming the request body | medium | mitigate | the body (already capped) is read once and restored; TestTurnstileMiddlewareSmoke asserts the handler sees it |
|
||||
| T-19.3-14 | Information disclosure | Turnstile secret or token in logs and errors | high | mitigate | the secret travels only in the form body; logged errors carry the transport error text without URL, secret or token |
|
||||
| T-19.3-15 | Tampering | swagger-ui assets on jsDelivr | medium | mitigate | exact version 5.33.1 with sha384 SRI and crossorigin on all three assets; nonce CSP limits scripts to the nonce and the CDN host |
|
||||
| T-19.3-16 | Information disclosure | /api-docs exposing the API surface | low | accept | off by default; the spec is the app's hand-written public surface (D-16); both routes public by design (D-17) |
|
||||
| T-19.3-17 | Tampering | XSS through http.api_docs.title or the spec URL | medium | mitigate | html/template contextual escaping for HTML and JS contexts |
|
||||
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | this plan installs nothing: swagger-ui-dist is loaded from the CDN at a pinned version with SRI (5.33.2 flagged too new is avoided); go.mod and go.sum unchanged |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the bouncer and surf short suites, the two tracer smokes and the Laravel preflight parity test, TestDocsTree.
|
||||
- Task 2: the surf short suite, the profile smoke with the tracer smoke and the parity test, TestDocsTree.
|
||||
- Task 3: the bouncer and surf short suites, the Turnstile smoke, TestDocsTree.
|
||||
- Task 4: the five smoke tests, the whole short suite, TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/bouncer ./modules/surf`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-1: cookie refresh with configurable Domain, SameSite and Secure, JTI blacklisted, Max-Age from refresh_ttl; credentialed CORS as global config and as cors:<profile> middleware.
|
||||
- SC-3: /api-docs and /api-docs.json served opt-in; Turnstile middleware available.
|
||||
- Existing CORS answers for non-credentialed * configs unchanged; go vet and the short suite green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md` when done, with the commits, the identifiers and config keys added, the divergences from PHP recorded in Decisions, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,260 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- go.mod
|
||||
- go.sum
|
||||
- internal/build/manifest.go
|
||||
- internal/build/build.go
|
||||
- internal/build/manifest_tags_smoke_test.go
|
||||
- modules/lagoon/attach/bucket.go
|
||||
- modules/lagoon/attach/s3_on.go
|
||||
- modules/lagoon/attach/s3_off.go
|
||||
- modules/lagoon/attach/diskext.go
|
||||
- modules/lagoon/attach/store.go
|
||||
- modules/lagoon/attach/thumb.go
|
||||
- modules/lagoon/attach/infra_smoke_test.go
|
||||
- modules/lagoon/README.md
|
||||
- docs/services/storage.md
|
||||
- docs/database/attachments.md
|
||||
- docs/architecture/go-modules-and-workspaces.md
|
||||
- docs/architecture/performance-and-scaling.md
|
||||
- docs/console/scaffolding.md
|
||||
- docs/setup/configuration.md
|
||||
autonomous: true
|
||||
requirements: [SC-3, SC-4, D-12, D-13, D-14, D-15, D-19]
|
||||
estimate:
|
||||
tokens: 130000
|
||||
raw_tokens: 130000
|
||||
tasks: 3
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-13: summer.yaml accepts build.tags (a list of tags matching ^[a-z0-9_]+$); summer build passes them as go build -tags=<comma list> and prints them; summer plugin:add (SaveManifest) round-trips build.tags; summer dev inherits them through build.App."
|
||||
- "D-13: a binary built with the summercms_s3 tag opens storage.uploads.bucket_url s3://... through gocloud.dev/blob/s3blob, which only the tagged file s3_on.go imports, so an application never imports the driver itself; a binary built without the tag fails boot on an s3:// bucket_url with an error that names summercms_s3, build.tags in summer.yaml and the -tags flag for raw go build."
|
||||
- "D-14: S3-compatible endpoints are configured only through gocloud's bucket_url query (region, endpoint, use_path_style, request_checksum_calculation, response_checksum_validation) with credentials from the standard AWS environment; no s3.* key set exists."
|
||||
- "D-15: attach.Store names the stored blob with attach.DiskExtension(sniffedType, clientExt): a PNG uploaded as x.html is stored and served as .png while file_name keeps x.html; a docx (sniffed application/zip) keeps .docx; an HTML body uploaded as .txt stays .txt; a script-capable client extension under a generic sniff becomes txt or bin; the allow-list check on the client extension is unchanged."
|
||||
- "D-15: the storage docs carry a deploy note: serve uploads with X-Content-Type-Options nosniff and keep a file-type allow-list in the static server."
|
||||
- "D-12: with storage.uploads.public_url set (an absolute http or https base), attach.PublicURL, File.URL and thumbnail URLs are that base plus the partition path; with it unset URLs keep storage.uploads.public_path_prefix and the /storage/uploads proxy route, which also serves S3 buckets; a malformed public_url fails boot naming the key, and a set public_url logs one start-up line warning that the bucket behind it must hold only public files."
|
||||
artifacts:
|
||||
- path: "modules/lagoon/attach/s3_on.go"
|
||||
provides: "the tagged s3blob import"
|
||||
contains: "//go:build summercms_s3"
|
||||
- path: "modules/lagoon/attach/s3_off.go"
|
||||
provides: "s3Enabled false without the tag"
|
||||
contains: "//go:build !summercms_s3"
|
||||
- path: "modules/lagoon/attach/diskext.go"
|
||||
provides: "DiskExtension"
|
||||
contains: "func DiskExtension"
|
||||
- path: "internal/build/manifest.go"
|
||||
provides: "Manifest.Tags, build.tags load and save"
|
||||
contains: "Tags"
|
||||
key_links:
|
||||
- from: "internal/build/build.go"
|
||||
to: "internal/build/manifest.go"
|
||||
via: "App passes m.Tags to goBuildArgs, which adds -tags=<joined>"
|
||||
pattern: "goBuildArgs"
|
||||
- from: "modules/lagoon/attach/bucket.go"
|
||||
to: "modules/lagoon/attach/s3_off.go"
|
||||
via: "OpenBucket refuses an s3 scheme when s3Enabled is false"
|
||||
pattern: "s3Enabled"
|
||||
- from: "modules/lagoon/attach/store.go"
|
||||
to: "modules/lagoon/attach/diskext.go"
|
||||
via: "newDiskName(DiskExtension(sniffed, ext))"
|
||||
pattern: "DiskExtension\\("
|
||||
- from: "modules/lagoon/attach/thumb.go"
|
||||
to: "modules/lagoon/attach/bucket.go"
|
||||
via: "PublicURL prefers PublicURLBase() over PublicPathPrefix()"
|
||||
pattern: "PublicURLBase"
|
||||
prohibitions:
|
||||
- statement: "No new top-level s3.* configuration keys (D-14)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Uploads that pass today's allow-list still pass; only the stored name of mismatched or script-capable uploads changes."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Application call sites outside the framework are not changed in this phase (deferred follow-up)."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 3 of 5, wave 2): the S3 part of SC-3 and the folded stored-XSS todo. `summer.yaml` gains `build.tags`, the `summercms_s3` tag links the s3blob driver (the tracer: tagged binary opens s3://, untagged binary names the tag), then the disk-name extension fix and `storage.uploads.public_url`.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The build tag is `summercms_s3` (exported as `attach.S3BuildTag` for docs and errors). summer.yaml declares it as a list under `build.tags`; each tag must match `^[a-z0-9_]+$`, duplicates are dropped, and the tags are passed to go build as one `-tags=` argument (exec args, no shell). `summer build` prints a `build tags: …` line, because app build scripts that run a second raw `go build` must repeat the tag (Pitfall 6); the boot error is the safety net.
|
||||
- `DiskExtension` follows RESEARCH Pattern 6: a table of specific sniffed types with their accepted extension sets (the client's extension is kept when it belongs to the set, else the canonical one); every other sniff counts as generic and keeps the client's extension unless it is script-capable, which becomes `txt` for a text/* sniff and `bin` otherwise. It never calls `mime.ExtensionsByType` (host-dependent). Store passes the sniffed type before its extension-based fallback.
|
||||
- `public_url` applies to every URL the package builds, as D-12 states; the start-up warning and the docs carry the "public files only" rule instead of a narrower per-file switch.
|
||||
- The bucket-open error keeps quoting bucket_url (credentials never travel in it per D-14).
|
||||
|
||||
<assumption_delta_decision>
|
||||
add-alongside: blob drivers pluralize from file and mem to file, mem and s3. fileblob and memblob stay linked in every binary; s3blob is added only to binaries built with the summercms_s3 tag, so binaries without the tag keep their size and dependency set.
|
||||
</assumption_delta_decision>
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `internal/build`: `Manifest.Tags`, `ValidateBuildTag`, unexported `goBuildArgs(binRel string, tags []string) []string`; `summer.yaml` key `build.tags`; the `build tags:` output line.
|
||||
- `modules/lagoon/attach`: `S3BuildTag` (`summercms_s3`), files `s3_on.go` (`//go:build summercms_s3`, blank import of `gocloud.dev/blob/s3blob`) and `s3_off.go` (`//go:build !summercms_s3`), unexported `s3Enabled`; `DiskExtension(contentType, clientExt string) string`; `PublicURLBase() string`; config key `storage.uploads.public_url`.
|
||||
- Dependencies: AWS SDK v2 modules as indirect requirements of `gocloud.dev/blob/s3blob` (named in plan 01's decision note).
|
||||
- Smoke tests in `internal/build/manifest_tags_smoke_test.go` (`TestManifestBuildTagsSmoke`) and `modules/lagoon/attach/infra_smoke_test.go` (`TestOpenBucketS3Smoke` in both tag modes, `TestStoreDiskExtensionSmoke` on Postgres, `TestPublicURLSmoke`). Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/storage.md` sections "S3 and S3-compatible buckets", "Public URL base" and a deploy note; `docs/database/attachments.md` disk names; `docs/architecture/go-modules-and-workspaces.md` `build.tags`; `docs/console/scaffolding.md`; rows in `docs/setup/configuration.md`; the shared-uploads note in `docs/architecture/performance-and-scaling.md`; lagoon README.
|
||||
|
||||
<objective>
|
||||
Let an application choose an S3 (or S3-compatible) uploads bucket from config through a summer.yaml build tag, give it a public URL base for a bucket or CDN, and close the stored-XSS hole where a disk name kept the client's extension, each documented in the same commit.
|
||||
|
||||
Purpose: SC-3's S3 driver, D-12..D-15 (placement per D-19), and the folded todo `2026-10-11-uploads-keep-client-extension-stored-xss`.
|
||||
Output: internal/build and lagoon/attach changes with smoke tests and docs.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/notes/2026-10-15-cooler-go-redis-otter.md
|
||||
@.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md
|
||||
|
||||
Concurrency: plans 19.3-02 and 19.3-04 run in the same wave in this checkout and own other files (bouncer, surf, cooler, pact, cabana). This plan is the only wave-2 plan that changes go.mod and go.sum. Before Task 1 run `git status --porcelain -- go.mod go.sum internal/build modules/lagoon docs/services/storage.md docs/database docs/architecture docs/console docs/setup`; if it lists a file this plan touches that this plan did not change, stop and report. A transient vet or test failure in a package another wave-2 plan owns is not this plan's: re-run once after a short pause, then report it. Stage files by explicit path; no co-author tags. Docker-backed smoke tests skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN in the SUMMARY when no daemon answers. No real S3 or MinIO is available: the s3 smoke opens the bucket only (s3blob opens lazily); a real upload stays a manual UAT item (19.3-VALIDATION.md).
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; extend, do not fork):
|
||||
|
||||
- `internal/build/manifest.go`: `Manifest{Module, Binary, Plugins}`, `LoadManifest` (koanf yaml parser into a tree; `asString`, `asSlice`, `asMap`), `SaveManifest` (writes module, binary and plugins only, lines :90-103, so a new key is dropped by `plugin:add` unless written here).
|
||||
- `internal/build/build.go` :49: `exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")`; `internal/dev/watch.go` :46 reuses `build.App`.
|
||||
- `modules/lagoon/attach/bucket.go`: blank imports of fileblob and memblob (:13-14), `normalizeBucketURL` (unknown schemes pass through unchanged), `OpenBucket` (records public_path_prefix through `setPublicPathPrefix` under `prefixMu`), error style `attach: … (set SUMMER_…)`.
|
||||
- `modules/lagoon/attach/store.go` :124-152: client extension allow-list, `contentType := baseMediaType(http.DetectContentType(head))`, the octet-stream fallback to `mime.TypeByExtension`, `newDiskName(ext)`.
|
||||
- `modules/lagoon/attach/thumb.go` :83-99: `PublicURL(key)` and `(*File).URL()`; `Thumb`/`ThumbKey` write thumbnails eagerly, so their URLs work behind a bucket or CDN base too.
|
||||
- `modules/lagoon/attach/store_test.go` `TestStoreSmoke` (:48): the Postgres harness and `testing.Short()` skip to reuse.
|
||||
- gocloud.dev v0.46.0 `blob/s3blob` (Scheme s3, URL parameters per RESEARCH Pattern 5).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: an app lists summercms_s3 under build.tags, summer build passes it to go build, and the binary opens an s3:// bucket; an untagged binary names the tag at boot</name>
|
||||
<reversibility rating="costly">D-13: the summercms_s3 tag name and the build.tags key end up in app summer.yaml files and build scripts.</reversibility>
|
||||
<files>go.mod, go.sum, internal/build/manifest.go, internal/build/build.go, internal/build/manifest_tags_smoke_test.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/s3_on.go, modules/lagoon/attach/s3_off.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/architecture/go-modules-and-workspaces.md, docs/console/scaffolding.md, docs/setup/configuration.md, docs/architecture/performance-and-scaling.md</files>
|
||||
<read_first>internal/build/manifest.go, internal/build/build.go, internal/build/build_test.go (TestLoadHelloManifest, TestMakeAndAddPluginOnHelloCopy), internal/build/scaffold.go (:80-160, plugin:add round trip), internal/dev/watch.go (:40-60), modules/lagoon/attach/bucket.go, modules/lagoon/attach/bucket_test.go, modules/lagoon/README.md (attach bullet and API rows), docs/services/storage.md, docs/architecture/go-modules-and-workspaces.md (The summer.yaml manifest), docs/console/scaffolding.md (:10-60), docs/setup/configuration.md, docs/architecture/performance-and-scaling.md (Scaling and operations), ../sm-grzybyfunkcjonalne-app/scripts/build.sh (the second raw go build, read only), 19.3-RESEARCH.md (Pattern 5, Pitfall 6, Pitfall 11)</read_first>
|
||||
<action>Per D-13 and D-14.
|
||||
|
||||
internal/build: Manifest gains `Tags []string`. LoadManifest reads the optional `build` mapping and its `tags` list (a non-mapping build or a non-list tags is an error naming build.tags); every entry is trimmed and checked by `ValidateBuildTag(tag string) error` (pattern ^[a-z0-9_]+$, error names the tag); duplicates are dropped keeping the first. SaveManifest writes a `build:` block with `tags:` entries after plugins when Tags is non-empty, validating each, so `summer plugin:add` keeps them. `goBuildArgs(binRel string, tags []string) []string` returns build, then -tags=<comma-joined> when tags exist, then -o binRel and the package dot; App uses it and, when tags exist, prints `build tags: <comma list>` to out before building.
|
||||
|
||||
attach: move the s3 driver behind the tag: s3_on.go (constraint summercms_s3) blank-imports gocloud.dev/blob/s3blob and sets the unexported constant s3Enabled to true; s3_off.go (constraint not summercms_s3) sets it to false. bucket.go exports `S3BuildTag` = summercms_s3 and, in OpenBucket after normalizeBucketURL, refuses an s3 scheme (case-insensitive) when s3Enabled is false with an error that starts with `attach: storage.uploads.bucket_url uses s3://` and names S3BuildTag, `build.tags` in summer.yaml and the `-tags summercms_s3` flag (or GOFLAGS) for a raw go build. Run `go mod tidy` (it adds the AWS SDK v2 modules s3blob needs as indirect requirements); nothing outside the tagged file imports s3blob.
|
||||
|
||||
Docs in the same commit: docs/services/storage.md section "S3 and S3-compatible buckets" (the summer.yaml build.tags example, the boot error, raw go build users add -tags summercms_s3, a bucket_url for AWS and one for an S3-compatible endpoint with region, endpoint, use_path_style and the two checksum parameters, credentials from AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY or a profile, never in config, and that the existing /storage/uploads route also serves an S3 bucket); docs/architecture/go-modules-and-workspaces.md manifest example and text for build.tags; docs/console/scaffolding.md summer build and summer.yaml rows mention build tags; docs/setup/configuration.md storage.uploads.bucket_url row lists s3:// with the tag; docs/architecture/performance-and-scaling.md says shared uploads can also be an S3 bucket; lagoon README attach bullet and API row for S3BuildTag. Neutral app names (acme).
|
||||
|
||||
Smoke tests: internal/build/manifest_tags_smoke_test.go `TestManifestBuildTagsSmoke` (a temp summer.yaml with build.tags summercms_s3 and a duplicate loads one tag; SaveManifest then LoadManifest keeps it; goBuildArgs contains -tags=summercms_s3 before -o; a tag with a dash or a space is refused). modules/lagoon/attach/infra_smoke_test.go `TestOpenBucketS3Smoke` (t.Setenv for dummy AWS credentials, AWS_REGION and AWS_EC2_METADATA_DISABLED; bucket_url s3://smoke-bucket?region=us-east-1: with s3Enabled false the error contains summercms_s3 and build.tags; with it true the bucket opens and closes without network).</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go vet -tags summercms_s3 ./modules/lagoon/... && go build -tags summercms_s3 ./... && go test -count=1 -short -run '^TestManifestBuildTagsSmoke$' -v ./internal/build 2>&1 | grep -q -- '^--- PASS: TestManifestBuildTagsSmoke' && go test -count=1 -short -run '^TestOpenBucketS3Smoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3Smoke' && go test -count=1 -short -tags summercms_s3 -run '^TestOpenBucketS3Smoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3Smoke' && go test -count=1 -short ./internal/build ./modules/lagoon/... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet fails in either tag mode, the tagged build does not compile, a smoke test did not print "--- PASS" (in either tag mode), the short suites are red, or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -rln 'gocloud.dev/blob/s3blob' --include='*.go' modules internal cmd` prints exactly `modules/lagoon/attach/s3_on.go`.
|
||||
- `head -1 modules/lagoon/attach/s3_on.go` prints `//go:build summercms_s3` and `head -1 modules/lagoon/attach/s3_off.go` prints `//go:build !summercms_s3`.
|
||||
- `grep -n 'github.com/aws/aws-sdk-go-v2' go.mod` prints at least one line.
|
||||
- `grep -n 'build.tags\|summercms_s3' docs/services/storage.md docs/architecture/go-modules-and-workspaces.md` prints at least one line per file.
|
||||
- `grep -rnE '"s3\.[a-z_]+' --include='*.go' modules/lagoon` prints nothing (no s3.* config keys).
|
||||
</acceptance_criteria>
|
||||
<done>An app turns on S3 uploads with one summer.yaml line and a bucket_url, and a binary that lacks the driver says exactly which tag to add.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: An uploaded PNG named x.html is stored and served as .png: Store derives the disk-name extension from the sniffed type through DiskExtension, with a deploy note on nosniff and allow-lists</name>
|
||||
<files>modules/lagoon/attach/diskext.go, modules/lagoon/attach/store.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/database/attachments.md, docs/services/storage.md</files>
|
||||
<read_first>modules/lagoon/attach/store.go (whole file), modules/lagoon/attach/store_test.go (TestStoreSmoke harness), .planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md, .planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md (WR-01), modules/surf/uploads.go (nosniff, CSP sandbox and attachment disposition already sent by the proxy route), docs/database/attachments.md, docs/services/storage.md (Serving files), 19.3-RESEARCH.md (Pattern 6 and its DetectContentType probe table, Pitfall 8)</read_first>
|
||||
<action>Per D-15. diskext.go: `DiskExtension(contentType, clientExt string) string` (clientExt is the lower-cased, already allow-listed extension without a dot; contentType is a base media type). A table maps specific sniffed types to accepted extension sets with the first entry canonical: image/png png; image/jpeg jpg, jpeg, jpe; image/gif gif; image/webp webp; image/bmp bmp; image/x-icon and image/vnd.microsoft.icon ico; application/pdf pdf; audio/mpeg mp3; audio/wave and audio/wav wav; application/ogg and audio/ogg ogg, oga, opus; video/webm webm, mkv; video/mp4 mp4, m4v; font/woff2 woff2; font/woff woff; font/ttf ttf; font/otf otf; application/vnd.ms-fontobject eot; application/x-rar-compressed rar; application/x-gzip gz, tgz. For a table type the client extension is kept when it is in the set, else the canonical extension is returned. Any other type is generic: the client extension is kept unless it is script-capable (html, htm, xhtml, xht, shtml, svg, svgz, xml, xsl, xslt, js, mjs, css, swf, php, phtml, pht, phar), which yields txt for a text/* type and bin otherwise. The helper never asks package mime for extensions (its answer depends on the host's mime tables). store.go: keep the sniffed base type in its own variable before the octet-stream fallback, and name the blob with newDiskName(DiskExtension(sniffed, ext)); file_name, the allow-list check and the stored content_type rule are unchanged.
|
||||
|
||||
Docs in the same commit: docs/database/attachments.md explains that the disk name's extension follows the content, with the x.html example, and that file_name keeps the client's name; docs/services/storage.md gains a deploy note: when a static server (nginx) serves the uploads directory or bucket directly, send X-Content-Type-Options nosniff and allow only the file types the app accepts, as the /storage/uploads route already does; lagoon README API row for DiskExtension and the Store description.
|
||||
|
||||
Smoke test `TestStoreDiskExtensionSmoke` appended to modules/lagoon/attach/infra_smoke_test.go (skips under -short; reuses the TestStoreSmoke Postgres harness and a mem:// bucket; Limits.Extensions widened to html, png, docx and txt): an 8-byte PNG signature plus IHDR uploaded as x.html gives a DiskName ending .png, a URL ending .png, FileName x.html and ContentType image/png; a zip body uploaded as report.docx keeps .docx; an HTML body uploaded as note.txt keeps .txt.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/lagoon/... && go test -count=1 -run '^TestStoreDiskExtensionSmoke$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestStoreDiskExtensionSmoke' && go test -count=1 -run '^TestDocsTree$' ./cmd/summer</automated>
|
||||
<fails_when>Non-zero exit: vet or the lagoon short suites are red, the Store regression smoke did not print "--- PASS" (skipped without Docker, or failed), or the docs checker reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'DiskExtension(' modules/lagoon/attach/store.go` prints one line.
|
||||
- `grep -c 'ExtensionsByType' modules/lagoon/attach/diskext.go` prints 0.
|
||||
- `grep -n 'nosniff' docs/services/storage.md` prints at least one line and `grep -n 'DiskExtension' modules/lagoon/README.md` prints at least one line.
|
||||
</acceptance_criteria>
|
||||
<done>A disguised HTML upload can no longer be served back as HTML from its own URL, and real documents keep their extensions.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Files on a bucket or CDN get direct URLs through storage.uploads.public_url, while apps without it keep the /storage/uploads proxy URLs</name>
|
||||
<reversibility rating="costly">D-12: storage.uploads.public_url is referenced by app configs and determines the URLs clients store.</reversibility>
|
||||
<files>modules/lagoon/attach/bucket.go, modules/lagoon/attach/thumb.go, modules/lagoon/attach/infra_smoke_test.go, modules/lagoon/README.md, docs/services/storage.md, docs/database/attachments.md, docs/setup/configuration.md</files>
|
||||
<read_first>modules/lagoon/attach/bucket.go (prefixMu, setPublicPathPrefix, OpenBucket), modules/lagoon/attach/thumb.go (:79-99), modules/lagoon/attach/url_test.go, modules/surf/uploads.go (uploadsPrefix validation as the model for URL checks), docs/services/storage.md (Bucket URLs, The WinterCMS layout, Serving files), docs/database/attachments.md (:80-90), docs/setup/configuration.md, 19.3-RESEARCH.md (Pitfall 15)</read_first>
|
||||
<action>Per D-12. bucket.go: a second package value next to publicPathPrefix under the same prefixMu, set by OpenBucket from storage.uploads.public_url (trimmed; empty means unset) and read by `PublicURLBase() string`. A set value must parse as an absolute http or https URL with a host, without user info, query or fragment; otherwise OpenBucket fails with an error naming storage.uploads.public_url and SUMMER_STORAGE__UPLOADS__PUBLIC_URL. When set, OpenBucket logs one info line naming the host and stating that every file URL points there, so the bucket behind it must hold only public files (the proxy route checks is_public, a public bucket or CDN does not). thumb.go PublicURL: when PublicURLBase is non-empty the URL is that base with trailing slashes trimmed, one slash and the key; otherwise the current prefix logic, unchanged. File.URL and Thumb follow through PublicURL.
|
||||
|
||||
Docs in the same commit: docs/services/storage.md section "Public URL base" (bucket or CDN base example under a neutral domain, the partition path layout, the public-files-only rule, that leaving it unset keeps proxy URLs, which work for S3 too); docs/database/attachments.md URL paragraph mentions public_url; docs/setup/configuration.md row storage.uploads.public_url; lagoon README API rows PublicURLBase and the PublicURL description.
|
||||
|
||||
Smoke test `TestPublicURLSmoke` appended to modules/lagoon/attach/infra_smoke_test.go (no Docker): OpenBucket on mem:// with public_url https://cdn.example.com/uploads/ makes PublicURL of a partitioned key https://cdn.example.com/uploads/<key>; without it the URL starts with /storage/uploads; ftp://x and https://u:p@cdn.example.com fail naming storage.uploads.public_url. Because the base is package state, the test restores it with t.Cleanup.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/lagoon/... ./internal/build && go test -count=1 -short -run '^(TestPublicURLSmoke|TestOpenBucketS3Smoke)$' -v ./modules/lagoon/attach 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -short ./... && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go run ./cmd/summer docs:build --check</automated>
|
||||
<fails_when>Non-zero exit: vet or a short suite is red, fewer than two "--- PASS" lines for the URL and S3 smokes, or the docs checker or docs:build --check reports a problem.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'func PublicURLBase() string' modules/lagoon/attach/bucket.go` prints one line.
|
||||
- `grep -n 'storage.uploads.public_url' docs/services/storage.md docs/setup/configuration.md modules/lagoon/README.md` prints at least one line per file.
|
||||
- `grep -rniE 'wavepath|fonoteka|grzyby' modules/lagoon docs/services/storage.md docs/database/attachments.md` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>An app on S3 or behind a CDN serves file URLs straight from that base; apps that set nothing see the same URLs as before.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| client upload to attach.Store | untrusted bytes and an untrusted file name |
|
||||
| bucket or CDN to the browser | stored files served from the app's or a CDN's origin |
|
||||
| summer.yaml to go build | manifest values become command arguments |
|
||||
| AWS environment to s3blob | credentials outside summer config |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-18 | Tampering / Elevation of privilege | attach.Store disk name (stored XSS) | high | mitigate | DiskExtension from the sniffed type, script-capable extensions never kept under a generic sniff; TestStoreDiskExtensionSmoke; deploy note on nosniff and allow-lists |
|
||||
| T-19.3-19 | Information disclosure | storage.uploads.public_url exposing non-public files | medium | mitigate | start-up info line and docs state the public-files-only rule; blob keys stay unguessable (22 random hex characters); unset keeps the is_public-checking proxy |
|
||||
| T-19.3-20 | Tampering | summer.yaml build.tags reaching go build | medium | mitigate | ValidateBuildTag ^[a-z0-9_]+$ at load and save; tags passed as one exec argument, no shell |
|
||||
| T-19.3-21 | Repudiation / misconfiguration | s3:// in a binary built without the tag | medium | mitigate | OpenBucket boot error naming summercms_s3, build.tags and -tags; summer build prints the tags |
|
||||
| T-19.3-22 | Information disclosure | AWS credentials | medium | mitigate | credentials only from the AWS environment or profile (D-14); docs forbid keys in config; gocloud s3 URLs carry no secrets |
|
||||
| T-19.3-SC | Tampering | Go module installs (AWS SDK v2 via gocloud.dev/blob/s3blob) | high | mitigate | s3blob is a sub-package of the already-required gocloud.dev v0.46.0; its AWS modules are named in plan 01's decision note before go mod tidy; linked only with the summercms_s3 tag |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: vet in both tag modes, the tagged build, the manifest and S3 smokes (both modes), the short suites, TestDocsTree.
|
||||
- Task 2: the lagoon short suites, the Postgres Store regression smoke, TestDocsTree.
|
||||
- Task 3: the URL and S3 smokes, the whole short suite, TestDocsTree and docs:build --check.
|
||||
- Feedback sampling (19.3-VALIDATION.md): after each commit `go vet ./... && go test -short ./modules/lagoon/... ./internal/build`; per wave also `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach`.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-3: an S3 blob driver is selected through framework config and summer.yaml; the application imports nothing.
|
||||
- D-15: disk names follow content; the regression smoke passes.
|
||||
- D-12: public_url builds direct URLs; unset keeps proxy URLs.
|
||||
- go vet (both tag modes) and the short suite green at every commit.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md` when done, with the commits, the identifiers and keys added, the go.mod additions, the Docker smoke run or NOT RUN, and any deviation.
|
||||
</output>
|
||||
@@ -0,0 +1,346 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on: ["19.3-01"]
|
||||
files_modified:
|
||||
- modules/cooler/sitecache.go
|
||||
- modules/cooler/cooler.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- modules/cooler/README.md
|
||||
- docs/services/caching.md
|
||||
- README.md
|
||||
- modules/pact/capabilities.go
|
||||
- modules/pact/README.md
|
||||
- modules/cabana/reorder.go
|
||||
- modules/cabana/README.md
|
||||
- docs/backend/admin-controllers.md
|
||||
- docs/backend/lists-and-filters.md
|
||||
- ../sm-faq-plugin/controllers/faqs.go
|
||||
- ../sm-faq-plugin/controllers/site_cache.go
|
||||
- ../sm-faq-plugin/site_cache_admin_test.go
|
||||
- ../sm-faq-plugin/README.md
|
||||
- ../sm-menu-plugin/controllers/admin_registry.go
|
||||
- ../sm-menu-plugin/controllers/menus.go
|
||||
- ../sm-menu-plugin/controllers/menuentries.go
|
||||
- ../sm-menu-plugin/controllers/site_cache.go
|
||||
- ../sm-menu-plugin/site_cache_admin_test.go
|
||||
- ../sm-menu-plugin/README.md
|
||||
- ../sm-journal-plugin/plugin.go
|
||||
- ../sm-journal-plugin/classes/site_cache.go
|
||||
- ../sm-journal-plugin/classes/site_cache_test.go
|
||||
- ../sm-journal-plugin/controllers/site_cache.go
|
||||
- ../sm-journal-plugin/README.md
|
||||
- ../vue-faq-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-faq-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-faq-layer/nuxt.config.ts
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-faq-layer/README.md
|
||||
- ../vue-faq-layer/package.json
|
||||
- ../vue-menu-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-menu-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-menu-layer/nuxt.config.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/README.md
|
||||
- ../vue-menu-layer/package.json
|
||||
- ../vue-journal-layer/server/lib/siteCachePurge.ts
|
||||
- ../vue-journal-layer/server/api/__site__/cache/purge.post.ts
|
||||
- ../vue-journal-layer/nuxt.config.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/README.md
|
||||
- ../vue-journal-layer/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/go.mod
|
||||
- ../sm-grzybyfunkcjonalne-app/go.sum
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu
|
||||
- ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json
|
||||
- ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml
|
||||
autonomous: false
|
||||
requirements: [SC-4, D-10, D-11, D-19]
|
||||
estimate:
|
||||
tokens: 220000
|
||||
raw_tokens: 220000
|
||||
tasks: 5
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "D-10: cooler holds the site-cache purge service: one target from site_cache.purge_url and site_cache.purge_token (http only to a loopback host, token of at least 32 characters, errors name the key and never the token); PurgeSiteCacheAfterCommit registers one bearer-token POST of {kind, slug} through lagoon.AfterCommit, so it runs only after the admin save commits; a failure or timeout (2 s) is logged and never fails the save; one POST per committed save, no debounce (user answer)."
|
||||
- "D-10: serve validates and publishes the purger at boot (a bad site_cache config fails boot); cooler.SiteCacheFromApp gives hooks the same purger lazily in commands and tests that do not run serve; cooler imports neither surf nor cabana (plugins take the save transaction from cabana.TxFromContext themselves)."
|
||||
- "D-11: the FAQ plugin purges after a FAQ create, update or delete; the menu plugin purges after a menu create, update or delete and after a menu-entry create, update, delete or reorder; the existing FAQ locale check and menu placement checks run first and still refuse bad saves."
|
||||
- "D-11: cabana's reorder save calls a new optional pact.ReorderAfterSave(ctx, partition) hook inside its transaction after the new order is written; a hook error rolls the reorder back; controllers without the hook behave exactly as before."
|
||||
- "D-11: the Journal plugin moves onto cooler.SiteCachePurger: framework keys win; when only golem15.journal.site_cache.purge_url and purge_token are set they still work for one release with a deprecation warning; classes.SiteCachePurger, NewSiteCachePurger and PurgeSiteCacheAfterCommit stay as aliases, so no Journal deployment or importer breaks."
|
||||
- "D-11: vue-journal-layer, vue-menu-layer and vue-faq-layer each ship the same shared purge route POST /api/__site__/cache/purge (runtimeConfig siteCache.purgeToken, NUXT_SITE_CACHE_PURGE_TOKEN) with its own '/api/__site__/**': { cache: false } route rule, so a site without Journal still gets purges; the two shared files are byte-identical across the three layers (pinned hash in each layer's test); vue-journal-layer keeps /api/__journal__/cache/purge and journal.cachePurgeToken as aliases for one release."
|
||||
- "D-11: commit order is framework, then plugin and layer repos (pushed and tagged only on the human's reply), then the app pointer bumps as separate commits."
|
||||
artifacts:
|
||||
- path: "modules/cooler/sitecache.go"
|
||||
provides: "SiteCachePurger moved from the Journal plugin with framework keys, SiteCacheFromApp, PurgeSiteCacheAfterCommit"
|
||||
contains: "site_cache.purge_url"
|
||||
- path: "modules/pact/capabilities.go"
|
||||
provides: "the optional ReorderAfterSave hook"
|
||||
contains: "ReorderAfterSave"
|
||||
- path: "../vue-faq-layer/server/api/__site__/cache/purge.post.ts"
|
||||
provides: "the shared Nitro purge route"
|
||||
contains: "siteCache"
|
||||
- path: "../sm-menu-plugin/controllers/menuentries.go"
|
||||
provides: "menu-entry purge hooks including reorder"
|
||||
contains: "ReorderAfterSave"
|
||||
key_links:
|
||||
- from: "../sm-faq-plugin/controllers/faqs.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "FormAfterCreate/Update/Delete call cooler.PurgeSiteCacheAfterCommit with cooler.SiteCacheFromApp and cabana.TxFromContext"
|
||||
pattern: "PurgeSiteCacheAfterCommit"
|
||||
- from: "modules/cabana/reorder.go"
|
||||
to: "modules/pact/capabilities.go"
|
||||
via: "reorderPost type-asserts pact.ReorderAfterSave inside the Transaction closure with txCtx"
|
||||
pattern: "pact.ReorderAfterSave"
|
||||
- from: "modules/cooler/cooler.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "Install builds and publishes the purger from site_cache.* unless a plugin already published one"
|
||||
pattern: "SiteCachePurger"
|
||||
- from: "../sm-journal-plugin/plugin.go"
|
||||
to: "modules/cooler/sitecache.go"
|
||||
via: "wireSiteCache publishes a cooler purger from the alias keys only when the framework keys are unset"
|
||||
pattern: "NewSiteCachePurgerFor"
|
||||
prohibitions:
|
||||
- statement: "No breaking change to the Journal, menu or FAQ plugins: existing keys, exported names, routes and admin behaviour keep working."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Nothing is pushed or tagged in any repository without the human's reply at the Task 4 checkpoint."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "Framework code and docs never name a consuming application."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 4 of 5, wave 2): the folded site-cache purge todo (D-10, D-11). The Journal-only purger becomes a framework service in `cooler`; the FAQ plugin and vue-faq-layer prove it end to end (the tracer), cabana gains a reorder hook so the menu plugin purges after saves, deletes and reorders, Journal moves onto the service with alias keys, the shared Nuxt route lands in all three layers, and, after the human's go-ahead, the app pointers move.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- Identifiers: `cooler.SiteCachePurger`, `cooler.NewSiteCachePurger(purgeURL, token)` (errors name the framework keys), `cooler.NewSiteCachePurgerFor(urlKey, tokenKey, purgeURL, token)` (errors name the given keys, used by the Journal alias path), `cooler.PurgeSiteCacheAfterCommit`, `cooler.SiteCacheFromApp`, constants `SiteCachePurgeURLKey`, `SiteCachePurgeTokenKey`, `SiteCachePurgeTimeout`, `SiteCachePurgeMinTokenLength`.
|
||||
- Purge kinds: `faq` (slug = the FAQ slug), `menu` (slug = the menu code), `menu_entry` (slug = the entry id, or `partition:<id>` for a reorder), Journal keeps `post` and `category`. The Nuxt route purges every route-cache entry regardless of kind; the kind only reaches its log line.
|
||||
- The hook is `pact.ReorderAfterSave` with `ReorderAfterSave(ctx context.Context, partition *uint64) error`, the reorder request's partition, so a partitioned reorder (menu entries per menu) can name what changed.
|
||||
- The shared Nuxt helper lives in `server/lib/siteCachePurge.ts`, outside Nitro's and Nuxt's auto-import directories, so three layers can carry the same exports without duplicate auto-import warnings; the route imports it by relative path. Its exported names differ from the Journal layer's `app/utils/siteCache.ts` (kept unchanged for the alias route).
|
||||
- Layer releases are additive minors: vue-faq-layer 0.2.0, vue-menu-layer 0.2.0, vue-journal-layer 0.4.0. Plugin repos get no tag (apps mount them as submodules). Plugin go.mod require lines stay on the current framework tag; the plugins build through their checked-in go.work replace until plan 05's release decision tags the framework.
|
||||
- Only `sm-grzybyfunkcjonalne-app` gets pointer bumps here: it mounts all three plugins and its pointers sit at the plugins' pre-adoption heads. `sm-summercmsio-app` mounts Journal at a commit 45 commits behind main, so its bump is a separate follow-up recorded in the SUMMARY, not part of this phase.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `modules/cooler/sitecache.go`: the identifiers listed in Decisions; config keys `site_cache.purge_url` (SUMMER_SITE_CACHE__PURGE_URL) and `site_cache.purge_token` (SUMMER_SITE_CACHE__PURGE_TOKEN); `Install` publishes the purger.
|
||||
- `modules/pact/capabilities.go`: `pact.ReorderAfterSave`; `modules/cabana/reorder.go` calls it inside the reorder transaction.
|
||||
- Plugins: FAQ `FormAfterCreate`/`FormAfterUpdate` (purge appended) and `FormAfterDelete`; menu `menusAdminController` with an app accessor and `FormAfterCreate`/`FormAfterUpdate`/`FormAfterDelete`; `menuEntriesAdminController` `FormAfterDelete` and `ReorderAfterSave`; a `purgeSiteCache` helper in each plugin's controllers package; Journal alias wiring and `classes` aliases.
|
||||
- Layers: `server/lib/siteCachePurge.ts` (`MIN_SITE_PURGE_TOKEN_LENGTH`, `SITE_ROUTE_CACHE_BASE`, `authorizeSitePurge`, `purgeSiteRouteCache`, `parseSitePurgeEvent`), `server/api/__site__/cache/purge.post.ts`, runtimeConfig `siteCache.purgeToken`, route rule `/api/__site__/**`, specs `tests/sitePurge.test.ts` (names start with `sitePurge:`), versions 0.2.0 / 0.2.0 / 0.4.0, tags `v0.2.0` (faq, menu) and `v0.4.0` (journal) on the human's reply.
|
||||
- Tests: `modules/cooler/sitecache_test.go` (the Journal purger tests ported: `TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest`); `TestFaqAdminPurgesSiteCache` and `TestMenuAdminPurgesSiteCache` (Postgres) in the plugins. Full unit coverage is plan 05.
|
||||
- Docs: `docs/services/caching.md` section "Site cache purge"; cooler README and root README summary sentence; pact and cabana READMEs; `docs/backend/admin-controllers.md` hook table row; `docs/backend/lists-and-filters.md` reorder hook note; plugin and layer READMEs.
|
||||
|
||||
<objective>
|
||||
Turn the Journal-only site-cache purge into a framework service that every admin save can use, adopt it in the FAQ, menu and Journal plugins (with a new reorder hook in cabana) and ship the shared Nuxt purge route in all three layers, committing framework first, then plugin and layer repos, pushing only on the human's go-ahead, then moving the app pointers.
|
||||
|
||||
Purpose: D-10 and D-11 (the folded todo `site-cache-purge-for-menu-and-faq`), placement per D-19, docs per SC-4.
|
||||
Output: commits in summercms.go, three plugin repos, three layer repos and, after the reply, the app and its vue app.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-PATTERNS.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
@.planning/notes/core-plugins-own-repos.md
|
||||
@.planning/todos/pending/site-cache-purge-for-menu-and-faq.md
|
||||
|
||||
Repositories (all siblings of summercms.go under /media/nvme/dev/golem15/summercms.io/summercms): sm-faq-plugin (main 919cf06 at planning time), sm-menu-plugin (main 9c0f833), sm-journal-plugin (main 31d901c, tags up to v0.1.1), vue-faq-layer (main 321bc90, v0.1.0), vue-menu-layer (main 2deac88, v0.1.0), vue-journal-layer (main 25e9bfe, v0.3.0), sm-grzybyfunkcjonalne-app (submodules plugins/golem15/{journal,menu,faq} and vue-grzybyfunkcjonalne-app, which pins the three layers by tag in package.json). Each plugin's checked-in go.work replaces summercms with ../summercms.go, so plugin tests compile against this checkout. Layers run `node --test` through `pnpm --dir <layer> test`.
|
||||
|
||||
Concurrency and commit rules: plans 19.3-02 and 19.3-03 run in the same wave in summercms.go and own other files. Phase 18.1 plan 08 (pending at planning time) edits modules/pact/capabilities.go, modules/cabana/crud.go, the pact and cabana READMEs and docs/backend/admin-controllers.md; Task 2 asserts a clean tree for those paths first. Commit in each repo separately, staging by explicit path, no co-author tags; never stage files inside a submodule from the app repo; use `ssu` for submodule pointer work in the app. The framework commit of a task lands before the plugin or layer commit that uses it. Nothing is pushed or tagged before the Task 4 reply. Docker-backed tests skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN in the SUMMARY when no daemon answers.
|
||||
|
||||
<interfaces>
|
||||
Existing seams (read; move or extend, do not fork):
|
||||
|
||||
- `../sm-journal-plugin/classes/site_cache.go` (whole file): `SiteCachePurger`, `NewSiteCachePurger` (validation: both or neither, http(s) with host, no user info or fragment, http only to loopback, token at least `SiteCachePurgeMinTokenLength` = 32), `Endpoint`, `Purge` (2 s timeout, `context.WithoutCancel`, 4 KiB answer read, log lines without the token, `purgeTransportError`), `PurgeSiteCacheAfterCommit` (lagoon.AfterCommit); `../sm-journal-plugin/classes/site_cache_test.go` (`TestSiteCachePurgeConfig`, `TestSiteCachePurgeRequest` assert `journal:` log messages); `../sm-journal-plugin/controllers/site_cache.go` (Lookup plus cabana.TxFromContext); `../sm-journal-plugin/plugin.go` `wireSiteCache` (:60-82, runs in Boot); `../sm-journal-plugin/controllers/categories.go` :64-88 (hook pattern); `../sm-journal-plugin/site_cache_admin_test.go` (purge receiver harness).
|
||||
- `modules/pact/capabilities.go` :748-765 (`FormAfterCreate`, `FormAfterUpdate`, `FormAfterDelete` doc style); `modules/cabana/reorder.go` `reorderPost` (:297-359, the Transaction closure, `txCtx`, the node loop, `lifecycleFailure`), `modules/cabana/crud.go` :1025-1040 (`FormAfterDelete` call pattern), `modules/cabana/tx_context.go` `TxFromContext`; `modules/lagoon/transaction.go` :103 `AfterCommit`.
|
||||
- `../sm-faq-plugin/controllers/faqs.go` (`app func() *backpack.App` field, `FormAfterCreate`/`FormAfterUpdate` = requireLocale, asserts :73-74), `../sm-faq-plugin/faq_smoke_test.go` `bootFAQ` (cfg.Set for config keys).
|
||||
- `../sm-menu-plugin/controllers/admin_registry.go` (`AdminControllers(app)`), `menus.go` (no app field yet), `menuentries.go` (`app` field, `FormAfterCreate`/`FormAfterUpdate` with checkPlacement :186-260, asserts :268-279), `controllers/menuentries/config_reorder.yaml` (partition menu_id), `../sm-menu-plugin/menu_smoke_test.go` (boot harness, reorder calls).
|
||||
- `../vue-journal-layer/server/api/__journal__/cache/purge.post.ts`, `app/utils/siteCache.ts` (authorizePurge, purgeRouteCache, parsePurgeEvent), `tests/siteCache.test.ts`, `nuxt.config.ts` :27-51 (runtimeConfig and the cache:false rule with its comment); `../vue-menu-layer/nuxt.config.ts`, `../vue-faq-layer/nuxt.config.ts`.
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: saving a FAQ in the admin purges the site's Nuxt route cache through cooler's purger and the shared /api/__site__/cache/purge route in vue-faq-layer</name>
|
||||
<reversibility rating="costly">D-10: site_cache.purge_url, site_cache.purge_token and the /api/__site__/cache/purge route with NUXT_SITE_CACHE_PURGE_TOKEN become deployment configuration across sites.</reversibility>
|
||||
<files>modules/cooler/sitecache.go, modules/cooler/cooler.go, modules/cooler/sitecache_test.go, modules/cooler/README.md, docs/services/caching.md, README.md, ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/controllers/site_cache.go, ../sm-faq-plugin/site_cache_admin_test.go, ../sm-faq-plugin/README.md, ../vue-faq-layer/server/lib/siteCachePurge.ts, ../vue-faq-layer/server/api/__site__/cache/purge.post.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-faq-layer/README.md, ../vue-faq-layer/package.json</files>
|
||||
<read_first>../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/controllers/categories.go, ../sm-journal-plugin/site_cache_admin_test.go, modules/cooler/cooler.go and modules/cooler/README.md (plan 01), docs/services/caching.md (plan 01), modules/lagoon/transaction.go (AfterCommit), ../sm-faq-plugin/controllers/faqs.go, ../sm-faq-plugin/faq_smoke_test.go, ../sm-faq-plugin/README.md, ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts, ../vue-journal-layer/app/utils/siteCache.ts, ../vue-journal-layer/tests/siteCache.test.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-faq-layer/nuxt.config.ts, ../vue-faq-layer/package.json, ../vue-faq-layer/README.md, 19.3-RESEARCH.md (Pattern 8, Pitfall 10)</read_first>
|
||||
<action>Framework first (one summercms.go commit, README and docs included), per D-10. modules/cooler/sitecache.go: move the Journal purger nearly verbatim with these changes: key constants SiteCachePurgeURLKey = site_cache.purge_url and SiteCachePurgeTokenKey = site_cache.purge_token; NewSiteCachePurger(purgeURL, token) delegates to NewSiteCachePurgerFor(SiteCachePurgeURLKey, SiteCachePurgeTokenKey, purgeURL, token), whose validation errors name the keys it is given; log messages `cooler: site cache purged` and `cooler: site cache purge failed` (attributes kind, slug, status, purged; never the token or the full URL); PurgeSiteCacheAfterCommit(ctx, db, p, kind, slug) unchanged (nil purger or nil db does nothing); `SiteCacheFromApp(app *backpack.App) *SiteCachePurger` returns a published purger, else builds one from the framework keys and publishes it (both keys empty or an invalid config returns nil; an invalid config logs one error naming the key). No debounce: one POST per committed save. cooler.go Install: unless a *SiteCachePurger is already published (a plugin's alias), build it from the framework keys; an invalid config is a boot error; a non-nil purger is published and one info line names its Endpoint. cooler imports neither surf nor cabana. Port the Journal tests to modules/cooler/sitecache_test.go as TestSiteCachePurgeConfig and TestSiteCachePurgeRequest with the framework keys and the cooler log messages. Docs: docs/services/caching.md section "Site cache purge" (both keys with SUMMER_ variables, loopback-only http, token length, after-commit timing, failures never fail a save, the Nuxt side: POST /api/__site__/cache/purge with NUXT_SITE_CACHE_PURGE_TOKEN and its cache:false route rule, a plugin hook example using cabana.TxFromContext and SiteCacheFromApp under a neutral plugin name); cooler README Features, API reference and Configuration rows; the cooler summary sentence in its README and the root README row both gain "and the site-cache purge after admin saves".
|
||||
|
||||
FAQ plugin (one sm-faq-plugin commit), per D-11: controllers/site_cache.go adds `purgeSiteCache(ctx context.Context, app func() *backpack.App, kind, slug string)` taking the transaction from cabana.TxFromContext and the purger from cooler.SiteCacheFromApp, doing nothing when either is missing. faqs.go: FormAfterCreate and FormAfterUpdate run requireLocale first and purge kind faq with the FAQ's slug only when it passed; a new FormAfterDelete purges kind faq; add the pact.FormAfterDelete compile-time assert. README: a "Site cache" paragraph naming the framework keys. site_cache_admin_test.go `TestFaqAdminPurgesSiteCache` (Postgres through the existing TestMain; an httptest receiver set through cfg.Set of the two framework keys with a 32-character token): create, update and delete each send exactly one POST with Bearer token and kind faq after the commit; a create refused by the locale check sends none; a receiver answering 500 leaves the save successful.
|
||||
|
||||
vue-faq-layer (one commit), per D-11: server/lib/siteCachePurge.ts ports authorize, purge and parse from the Journal util under the names MIN_SITE_PURGE_TOKEN_LENGTH, SITE_ROUTE_CACHE_BASE, authorizeSitePurge, purgeSiteRouteCache and parseSitePurgeEvent (kind accepted when it matches lower-case letters and underscores up to 40 characters, else unknown; slug sanitised as in the Journal util). server/api/__site__/cache/purge.post.ts mirrors the Journal route: no-store header, runtimeConfig siteCache.purgeToken, 404 when disabled or proxied, 401 unauthorized, answer {purged}, log prefix [site-cache]; it imports the lib by relative path and names no layer. nuxt.config.ts gains runtimeConfig siteCache.purgeToken '' (comment naming NUXT_SITE_CACHE_PURGE_TOKEN and the 32-character minimum) and routeRules '/api/__site__/**': { cache: false } with the Journal layer's explanatory comment. tests/sitePurge.test.ts (names start with "sitePurge:"): authorize ok and unauthorized with a 32-character token, purge removes only nitro:routes: keys from a fake storage, and a hash pin: sha256 of server/lib/siteCachePurge.ts and of the route file equal the constants SHARED_LIB_SHA256 and SHARED_ROUTE_SHA256 (write the files, compute the hashes, then write the constants; Tasks 2 and 3 copy the files and these constants byte for byte). README section "Site cache purge"; package.json version 0.2.0.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cooler && go test -count=1 -short -run '^(TestSiteCachePurgeConfig|TestSiteCachePurgeRequest)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 2 && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go -C ../sm-faq-plugin vet ./... && go -C ../sm-faq-plugin test -count=1 -run '^TestFaqAdminPurgesSiteCache$' -v ./... 2>&1 | grep -q -- '^--- PASS: TestFaqAdminPurgesSiteCache' && OUT=$(pnpm --dir ../vue-faq-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -cE '^ok [0-9]+ - sitePurge:' | grep -qE '^[3-9]$|^[1-9][0-9]$'</automated>
|
||||
<fails_when>Non-zero exit: vet or the cooler suite is red, the ported purger tests did not both pass, the docs checker failed, the FAQ purge test did not print "--- PASS" (Docker missing or a purge missing or extra), a layer spec failed ("# fail" not 0), or fewer than three "sitePurge:" specs passed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'SiteCachePurgeURLKey *= *"site_cache.purge_url"' modules/cooler/sitecache.go` prints one line.
|
||||
- `go list -deps ./modules/cooler | grep -E 'summercms/modules/(surf|cabana)$'` prints nothing.
|
||||
- `grep -n 'FormAfterDelete' ../sm-faq-plugin/controllers/faqs.go` prints at least two lines (method and assert).
|
||||
- `grep -n "'/api/__site__/\*\*': { cache: false }" ../vue-faq-layer/nuxt.config.ts` prints one line and `grep -n '"version": "0.2.0"' ../vue-faq-layer/package.json` prints one line.
|
||||
- `git -C ../sm-faq-plugin log --format=%ct -1` is not earlier than `git log --format=%ct -1 -- modules/cooler/sitecache.go` (the framework commit came first).
|
||||
</acceptance_criteria>
|
||||
<done>An editor saves a FAQ and the site shows it on the next page view instead of after the SWR window.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Saving, deleting or reordering menus and menu entries purges the site cache: cabana runs a new pact.ReorderAfterSave hook inside the reorder transaction, the menu plugin adopts the purge and vue-menu-layer ships the shared route</name>
|
||||
<reversibility rating="costly">D-11: pact.ReorderAfterSave is a public plugin interface; renaming or re-shaping it breaks every plugin that implements it.</reversibility>
|
||||
<precondition>`git status --porcelain -- modules/pact modules/cabana docs/backend` in summercms.go prints nothing (no in-flight Phase 18.1 edits in the shared cabana and pact files).</precondition>
|
||||
<files>modules/pact/capabilities.go, modules/pact/README.md, modules/cabana/reorder.go, modules/cabana/README.md, docs/backend/admin-controllers.md, docs/backend/lists-and-filters.md, ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/site_cache.go, ../sm-menu-plugin/site_cache_admin_test.go, ../sm-menu-plugin/README.md, ../vue-menu-layer/server/lib/siteCachePurge.ts, ../vue-menu-layer/server/api/__site__/cache/purge.post.ts, ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-menu-layer/README.md, ../vue-menu-layer/package.json</files>
|
||||
<read_first>modules/pact/capabilities.go (:735-770), modules/pact/README.md, modules/cabana/reorder.go (:240-360), modules/cabana/crud.go (:870-890 lifecycleFailure, :1025-1040), modules/cabana/README.md (reorder rows), docs/backend/admin-controllers.md (:200-230 hook table), docs/backend/lists-and-filters.md (Tree reorder), ../sm-menu-plugin/controllers/admin_registry.go, ../sm-menu-plugin/controllers/menus.go, ../sm-menu-plugin/controllers/menuentries.go, ../sm-menu-plugin/controllers/menuentries/config_reorder.yaml, ../sm-menu-plugin/menu_smoke_test.go (boot harness and reorder requests), ../sm-menu-plugin/README.md, ../sm-faq-plugin/controllers/site_cache.go (Task 1 helper), the Task 1 files of vue-faq-layer (server/lib, route, tests/sitePurge.test.ts with the two hash constants), ../vue-menu-layer/nuxt.config.ts, ../vue-menu-layer/package.json, ../vue-menu-layer/README.md</read_first>
|
||||
<action>Framework first (one summercms.go commit), per D-11 and RESEARCH Pitfall 9. pact/capabilities.go: `ReorderAfterSave` interface with `ReorderAfterSave(ctx context.Context, partition *uint64) error`, documented like the FormAfter hooks: optional, runs after a reorder save wrote the new order, inside the same transaction (cabana.TxFromContext works), an error rolls the reorder back. cabana/reorder.go reorderPost: inside the Transaction closure, after the node loop and before the tree is read again, type-assert cc.Controller to pact.ReorderAfterSave and call it with txCtx and in.Partition, passing its error through lifecycleFailure. Docs: pact README API row; cabana README reorder description; docs/backend/admin-controllers.md hook table row for pact.ReorderAfterSave; docs/backend/lists-and-filters.md Tree reorder paragraph on the hook (purging a cache after a reorder as the example).
|
||||
|
||||
Menu plugin (one sm-menu-plugin commit): controllers/site_cache.go with the same purgeSiteCache helper as the FAQ plugin; admin_registry.go passes app to menusAdminController, which gains the app field; menus.go adds FormAfterCreate, FormAfterUpdate and FormAfterDelete purging kind menu with the menu code, plus their compile-time asserts; menuentries.go appends the purge (kind menu_entry, slug the entry id) to FormAfterCreate and FormAfterUpdate after their existing checks pass, adds FormAfterDelete, and adds ReorderAfterSave purging kind menu_entry with slug partition:<id> (or reorder without a partition), plus asserts. README: the "Site cache" paragraph. site_cache_admin_test.go `TestMenuAdminPurgesSiteCache` (Postgres through the existing TestMain, receiver through the framework keys): menu create, menu update, entry create, entry update, the reorder POST and entry delete each send exactly one purge after the commit with the expected kind; an entry refused by the placement check sends none; a 500 receiver leaves saves and the reorder successful.
|
||||
|
||||
vue-menu-layer (one commit): copy server/lib/siteCachePurge.ts, server/api/__site__/cache/purge.post.ts and tests/sitePurge.test.ts byte for byte from vue-faq-layer (the hash constants stay identical); nuxt.config.ts gains runtimeConfig siteCache.purgeToken and the '/api/__site__/**' cache:false rule with the same comments; README section "Site cache purge"; package.json version 0.2.0.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go test -count=1 -short ./modules/cabana ./modules/pact && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && go -C ../sm-menu-plugin vet ./... && go -C ../sm-menu-plugin test -count=1 -run '^TestMenuAdminPurgesSiteCache$' -v ./... 2>&1 | grep -q -- '^--- PASS: TestMenuAdminPurgesSiteCache' && cmp ../vue-faq-layer/server/lib/siteCachePurge.ts ../vue-menu-layer/server/lib/siteCachePurge.ts && cmp ../vue-faq-layer/server/api/__site__/cache/purge.post.ts ../vue-menu-layer/server/api/__site__/cache/purge.post.ts && OUT=$(pnpm --dir ../vue-menu-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^ok [0-9]+ - sitePurge:'</automated>
|
||||
<fails_when>Non-zero exit: vet or the cabana and pact short suites are red, the docs checker failed, the menu purge test did not print "--- PASS" (Docker missing, or a purge missing after a save, delete or reorder), the shared layer files differ between vue-faq-layer and vue-menu-layer (cmp non-zero), or a layer spec failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'ReorderAfterSave(ctx context.Context, partition \*uint64) error' modules/pact/capabilities.go` prints one line and `grep -n 'pact.ReorderAfterSave' modules/cabana/reorder.go` prints one line.
|
||||
- `grep -n 'ReorderAfterSave' docs/backend/admin-controllers.md modules/pact/README.md` prints at least one line per file.
|
||||
- `grep -cE 'func \([a-z]* ?menusAdminController\) FormAfterDelete\(' ../sm-menu-plugin/controllers/menus.go` prints 1 and `grep -cE 'func \([a-z]* ?menuEntriesAdminController\) ReorderAfterSave\(' ../sm-menu-plugin/controllers/menuentries.go` prints 1.
|
||||
- `grep -n '"version": "0.2.0"' ../vue-menu-layer/package.json` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>An editor reorders or edits a menu and every page shows the new navigation on its next view.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Journal saves purge through the framework service, old Journal purge keys and the old Nuxt route keep working for one release, and vue-journal-layer ships the shared route</name>
|
||||
<files>../sm-journal-plugin/plugin.go, ../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/README.md, ../vue-journal-layer/server/lib/siteCachePurge.ts, ../vue-journal-layer/server/api/__site__/cache/purge.post.ts, ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/tests/sitePurge.test.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go (:50-90), ../sm-journal-plugin/classes/site_cache.go, ../sm-journal-plugin/classes/site_cache_test.go, ../sm-journal-plugin/controllers/site_cache.go, ../sm-journal-plugin/controllers/posts.go (:240-265), ../sm-journal-plugin/site_cache_admin_test.go (alias env), ../sm-journal-plugin/README.md (site cache section), modules/cooler/sitecache.go (Task 1), ../vue-journal-layer/nuxt.config.ts, ../vue-journal-layer/README.md, ../vue-journal-layer/package.json, the Task 1 shared files of vue-faq-layer</read_first>
|
||||
<action>Per D-11; Journal is a core plugin, so nothing it exports or reads today may break. classes/site_cache.go becomes a thin alias layer for one release: `type SiteCachePurger = cooler.SiteCachePurger`; the two key constants keep their golem15.journal.site_cache.* values with a Deprecated comment pointing at cooler.SiteCachePurgeURLKey and cooler.SiteCachePurgeTokenKey; SiteCachePurgeTimeout and SiteCachePurgeMinTokenLength alias cooler's; NewSiteCachePurger(purgeURL, token) returns cooler.NewSiteCachePurgerFor with the Journal keys; PurgeSiteCacheAfterCommit forwards to cooler's. plugin.go wireSiteCache: when site_cache.purge_url or site_cache.purge_token is set, do nothing (the framework owns the purger) and log one warning if the Journal keys are also set (they are ignored); otherwise, when the Journal keys are set, build the purger from them through classes.NewSiteCachePurger, publish it (so cooler.Install and SiteCacheFromApp reuse it and the menu and FAQ plugins purge through it too) and log one deprecation warning naming both framework keys; neither set: nothing. controllers/site_cache.go uses cooler.SiteCacheFromApp. classes/site_cache_test.go keeps the alias-key config cases (errors name the Journal keys) and moves its log-line assertions to the cooler messages; the request cases now live in cooler (Task 1). README: the site cache section documents the framework keys and the alias keys as deprecated for one release. Commit in sm-journal-plugin.
|
||||
|
||||
vue-journal-layer (one commit): copy the two shared files and tests/sitePurge.test.ts byte for byte from vue-faq-layer; nuxt.config.ts gains runtimeConfig siteCache.purgeToken and the '/api/__site__/**' cache:false rule beside the existing journal ones, which stay (the old /api/__journal__/cache/purge route, app/utils/siteCache.ts and journal.cachePurgeToken are unchanged and documented as aliases for one release); README section for the shared route and the alias; package.json version 0.4.0.</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-journal-plugin vet ./... && go -C ../sm-journal-plugin test -count=1 ./... && go -C ../sm-journal-plugin test -count=1 -run '^(TestCategoriesAdminPurgesSiteCache|TestPostsAdminPurgesSiteCache|TestSiteCachePurgeConfig)$' -v ./... 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3 && cmp ../vue-faq-layer/server/lib/siteCachePurge.ts ../vue-journal-layer/server/lib/siteCachePurge.ts && cmp ../vue-faq-layer/server/api/__site__/cache/purge.post.ts ../vue-journal-layer/server/api/__site__/cache/purge.post.ts && OUT=$(pnpm --dir ../vue-journal-layer test 2>&1) && printf '%s\n' "$OUT" | grep -qE '^# fail 0$' && printf '%s\n' "$OUT" | grep -qE '^ok [0-9]+ - sitePurge:'</automated>
|
||||
<fails_when>Non-zero exit: vet or the Journal suite is red, fewer than three "--- PASS" lines for the two alias-key admin purge tests and the alias config test (Docker missing, or the alias path broke), the shared layer files differ from vue-faq-layer, or a layer spec (old siteCache or new sitePurge) failed.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -n 'type SiteCachePurger = cooler.SiteCachePurger' ../sm-journal-plugin/classes/site_cache.go` prints one line.
|
||||
- `grep -n 'SiteCachePurgeURLKey *= *"golem15.journal.site_cache.purge_url"' ../sm-journal-plugin/classes/site_cache.go` prints one line (alias key kept).
|
||||
- `test -f ../vue-journal-layer/server/api/__journal__/cache/purge.post.ts` exits 0 and `grep -n "'/api/__journal__/\*\*': { cache: false }" ../vue-journal-layer/nuxt.config.ts` prints one line (old route kept).
|
||||
- `grep -n '"version": "0.4.0"' ../vue-journal-layer/package.json` prints one line.
|
||||
</acceptance_criteria>
|
||||
<done>Existing Journal deployments keep purging with their current keys, and new deployments configure one framework purge that serves Journal, menus and FAQs.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 4: Push decision: publish the plugin and layer commits (and layer tags), with or without the framework commits, or hold</name>
|
||||
<read_first>.planning/notes/core-plugins-own-repos.md, this plan's Task 1 to 3 SUMMARY notes (commit hashes per repository, Docker tests run or NOT RUN), .planning/phases/18.1-payment-gateway-subscriptions/18.1-05-SUMMARY.md (how a layer release checkpoint was recorded)</read_first>
|
||||
<action>Do everything automatable, print a sheet, then stop. For summercms.go, sm-faq-plugin, sm-menu-plugin, sm-journal-plugin, vue-faq-layer, vue-menu-layer and vue-journal-layer: `git status --porcelain` empty for this plan's files, `git fetch origin --tags`, `git log --oneline HEAD..origin/<branch>` empty (otherwise stop and report; never merge or rebase on your own), and `git ls-remote --tags origin` shows none of v0.2.0 (faq and menu layers) and v0.4.0 (journal layer). Run the three layer test suites and the three plugin suites once more. Sheet: per repository the commits that would be pushed; the layer tags; that the plugins import modules/cooler, which exists only in local summercms.go commits until summercms master is pushed (apps build through replace ../summercms.go, so local builds work either way); that summercms master may also carry plans 19.3-02 and 19.3-03 commits (list them); that Task 5 (app pointer bumps) needs the plugin commits and layer tags on origin. No push and no tag before the reply. After "push-all": push summercms master (no tag), the three plugin main branches, the three layer main branches, then create and push annotated tags v0.2.0 (vue-faq-layer, vue-menu-layer) and v0.4.0 (vue-journal-layer); never force. After "push-plugins-layers": the same without summercms. After "hold": nothing, and Task 5 is skipped. Record the reply verbatim and every pushed hash and tag in the SUMMARY.</action>
|
||||
<decision>Push the plugin and layer repositories (and tag the layers) now, together with summercms master, without it, or hold?</decision>
|
||||
<context>Pushed commits and tags are read by every site that pins the layers by tag and by everyone who clones the plugins; a tag cannot be moved, only superseded. The framework tag itself is decided in plan 05.</context>
|
||||
<options>
|
||||
<option id="push-all">
|
||||
<name>Push summercms master, the plugins and the layers, and tag the layers</name>
|
||||
<pros>Every pushed plugin builds against origin; Task 5 can bump the app now.</pros>
|
||||
<cons>summercms master carries untagged wave-2 work before the phase's unit tests (plan 05).</cons>
|
||||
</option>
|
||||
<option id="push-plugins-layers">
|
||||
<name>Push the plugins and layers and tag the layers; keep summercms local</name>
|
||||
<pros>The framework stays unpublished until plan 05's release decision; Task 5 can run.</pros>
|
||||
<cons>The pushed plugins do not build for anyone without this framework checkout until summercms master is pushed.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold everything</name>
|
||||
<pros>One publication for the whole phase at plan 05.</pros>
|
||||
<cons>Task 5 is skipped; the app keeps its current pointers until a later bump.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "push-all", "push-plugins-layers" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>go -C ../sm-faq-plugin test -count=1 ./... && go -C ../sm-menu-plugin test -count=1 ./... && go -C ../sm-journal-plugin test -count=1 ./... && pnpm --dir ../vue-faq-layer test >/dev/null && pnpm --dir ../vue-menu-layer test >/dev/null && pnpm --dir ../vue-journal-layer test >/dev/null && echo push-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "push-gate-ok" absent: a plugin or layer suite is red; the executor fixes it before presenting the sheet.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the reply verbatim; after a push reply, `git -C ../vue-faq-layer ls-remote --tags origin refs/tags/v0.2.0`, `git -C ../vue-menu-layer ls-remote --tags origin refs/tags/v0.2.0` and `git -C ../vue-journal-layer ls-remote --tags origin refs/tags/v0.4.0` each print one line and each plugin's `git rev-parse main` equals `git rev-parse origin/main`.
|
||||
- After "hold", none of those tags exists on origin and `git -C ../sm-faq-plugin log --oneline origin/main..main` lists the Task 1 commit.
|
||||
</acceptance_criteria>
|
||||
<done>The human decided what is published, and the record names every pushed commit and tag.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: The app picks up the purge: bump its Journal, menu and FAQ plugin pointers and its vue app's layer tags in separate commits (only after a push reply)</name>
|
||||
<precondition>Task 4's reply was "push-all" or "push-plugins-layers" (the plugin commits and the layer tags are on origin); after "hold" this task is skipped and the SUMMARY records it.</precondition>
|
||||
<files>../sm-grzybyfunkcjonalne-app/plugins/golem15/journal, ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu, ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq, ../sm-grzybyfunkcjonalne-app/go.mod, ../sm-grzybyfunkcjonalne-app/go.sum, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/pnpm-lock.yaml</files>
|
||||
<read_first>../sm-grzybyfunkcjonalne-app/.gitmodules, ../sm-grzybyfunkcjonalne-app/go.mod (replace lines), ../sm-grzybyfunkcjonalne-app/DEPLOY.md (site cache purge keys), ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json, ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/nuxt.config.ts (routeRules), .planning/notes/core-plugins-own-repos.md</read_first>
|
||||
<action>Per D-11's order (plugins pushed first, pointer bumps as separate commits). In the app: check `git status --porcelain` and stop on unrelated uncommitted work. Move plugins/golem15/journal, menu and faq to the pushed main heads (with `ssu` or `git -C <submodule> fetch` and checkout of the pushed hash; never stage files inside a submodule from the app repo); run `go mod tidy` in the app (the framework replace now brings go-redis, otter and the AWS SDK modules) and `go build ./...`; commit go.mod and go.sum alone (`chore(deps): tidy for summercms cooler`) and then the three pointers (`chore: bump journal, menu and faq plugins for the framework site-cache purge`). In vue-grzybyfunkcjonalne-app (its own repo): set vue-journal-layer to #v0.4.0, vue-menu-layer and vue-faq-layer to #v0.2.0 in package.json, run `pnpm install`, `pnpm run build` and its tests if present, commit package.json and pnpm-lock.yaml there; then commit the vue app pointer in the app repo separately. The deployment keeps its golem15.journal.site_cache.* and NUXT_JOURNAL_CACHE_PURGE_TOKEN values: the alias path makes menu and FAQ saves purge through the old Journal route, so no server config changes now; record moving the deployment to site_cache.* and NUXT_SITE_CACHE_PURGE_TOKEN as a follow-up. Nothing is pushed here (plan 05's release decision covers the app and vue app pushes).</action>
|
||||
<verify>
|
||||
<automated>go -C ../sm-grzybyfunkcjonalne-app build ./... && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/faq rev-parse HEAD)" = "$(git -C ../sm-faq-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/menu rev-parse HEAD)" = "$(git -C ../sm-menu-plugin rev-parse main)" && test "$(git -C ../sm-grzybyfunkcjonalne-app/plugins/golem15/journal rev-parse HEAD)" = "$(git -C ../sm-journal-plugin rev-parse main)" && grep -q 'vue-journal-layer.git#v0.4.0' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json && test -z "$(git -C ../sm-grzybyfunkcjonalne-app status --porcelain -- go.mod go.sum plugins vue-grzybyfunkcjonalne-app)"</automated>
|
||||
<fails_when>Non-zero exit: the app does not build, a submodule pointer differs from the plugin's main head, the journal layer pin is not v0.4.0, or the bumps are left uncommitted.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `git -C ../sm-grzybyfunkcjonalne-app log --oneline -3` shows the tidy commit and the pointer commit as separate commits (plus the vue app pointer commit).
|
||||
- `grep -c '#v0.2.0' ../sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json` prints 2.
|
||||
</acceptance_criteria>
|
||||
<done>The app runs the adopted plugins and the layer versions that carry the shared purge route, with its deployment config unchanged.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Go app to the Nitro server | a bearer-token POST over loopback (or https) after admin saves |
|
||||
| network to the Nitro purge route | anyone who can reach the site can call the route |
|
||||
| plugin hooks to the save transaction | hook errors roll back admin writes |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-23 | Denial of service / Spoofing | the shared Nitro purge route | medium | mitigate | 404 below a 32-character token and for proxied requests (X-Forwarded-For, X-Real-IP, Forwarded), sha256 plus timingSafeEqual bearer compare, cache:false route rule in every layer; sitePurge specs |
|
||||
| T-19.3-24 | Information disclosure | purge token in Go logs and errors | high | mitigate | validation errors name keys only; log lines carry kind, slug, status and endpoint host and path, never the token or query; ported TestSiteCachePurgeRequest asserts it |
|
||||
| T-19.3-25 | Information disclosure | token sent in clear over the network | high | mitigate | http purge URLs must point at a loopback host, other hosts require https; user info refused |
|
||||
| T-19.3-26 | Denial of service | a slow or failing purge breaking admin saves | medium | mitigate | after-commit only (lagoon.AfterCommit), detached context with a 2 s timeout, failures logged and swallowed; the plugin tests assert a 500 receiver leaves saves successful |
|
||||
| T-19.3-27 | Tampering | purge before commit letting a page re-cache stale content | low | mitigate | PurgeSiteCacheAfterCommit registers on the save transaction; the FAQ and menu tests check the purge arrives after the commit |
|
||||
| T-19.3-28 | Tampering | a ReorderAfterSave hook failing | low | accept | the hook runs inside the reorder transaction by design so its failure rolls the reorder back, as FormAfter hooks do; purge hooks never return errors |
|
||||
| T-19.3-29 | Tampering | the three copies of the shared route drifting apart | low | mitigate | byte-identical files checked with cmp in Tasks 2 and 3 and a pinned sha256 in each layer's spec |
|
||||
| T-19.3-SC | Tampering | npm/pip/cargo or Go module installs | high | mitigate | no new packages: the layers reuse node:crypto and Nitro, the plugins use framework modules only; the app's go mod tidy only records modules the framework already pinned (decision note of plan 01) |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: cooler suite and the ported purger tests, docs checker, the FAQ Postgres purge test, the vue-faq-layer specs.
|
||||
- Task 2: cabana and pact short suites, docs checker, the menu Postgres purge test (saves, deletes, reorder), byte equality of the shared files, the vue-menu-layer specs.
|
||||
- Task 3: the whole Journal suite with the alias tests, byte equality, the vue-journal-layer specs (old and new).
|
||||
- Task 4: every plugin and layer suite before the push decision.
|
||||
- Task 5: the app builds on the bumped pointers.
|
||||
- Manual (19.3-VALIDATION.md): save a menu entry in a running admin and see the page update without waiting for the SWR window.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- D-10: cooler holds the purge service with framework keys; failures never fail a save.
|
||||
- D-11: FAQ, menu (incl. reorder) and Journal purge after committed saves; three layers ship the shared route; Journal aliases keep existing deployments working.
|
||||
- The cross-repo order holds and nothing is pushed without the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md` when done, with the commits per repository, the identifiers and keys added, the shared-file hashes, the Task 4 reply verbatim and every pushed hash and tag, the Docker tests run or NOT RUN, and the follow-ups (deployment move to site_cache.* and NUXT_SITE_CACHE_PURGE_TOKEN; the sm-summercmsio-app Journal pointer).
|
||||
</output>
|
||||
@@ -0,0 +1,324 @@
|
||||
---
|
||||
phase: 19.3-framework-transport-and-infrastructure-for-wavepath
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on: ["19.3-01", "19.3-02", "19.3-03", "19.3-04"]
|
||||
files_modified:
|
||||
- scripts/check-phase19.3.sh
|
||||
- modules/bouncer/cookie_test.go
|
||||
- modules/bouncer/turnstile_test.go
|
||||
- modules/surf/cors_profile_test.go
|
||||
- modules/surf/apidocs_test.go
|
||||
- modules/surf/store_lookup_test.go
|
||||
- modules/cabana/reorder_hook_test.go
|
||||
- internal/build/manifest_tags_test.go
|
||||
- modules/lagoon/attach/s3_test.go
|
||||
- modules/lagoon/attach/diskext_test.go
|
||||
- modules/lagoon/attach/store_test.go
|
||||
- modules/lagoon/attach/url_test.go
|
||||
- go.mod
|
||||
- go.sum
|
||||
- modules/cooler/memory_test.go
|
||||
- modules/cooler/redis_test.go
|
||||
- modules/cooler/ratelimit_test.go
|
||||
- modules/cooler/docker_test.go
|
||||
- modules/cooler/install_test.go
|
||||
- modules/cooler/sitecache_test.go
|
||||
- ../sm-journal-plugin/site_cache_keys_test.go
|
||||
- ../sm-faq-plugin/controllers/site_cache_test.go
|
||||
- ../sm-menu-plugin/controllers/site_cache_test.go
|
||||
- ../vue-faq-layer/tests/sitePurge.test.ts
|
||||
- ../vue-menu-layer/tests/sitePurge.test.ts
|
||||
- ../vue-journal-layer/tests/sitePurge.test.ts
|
||||
- ../sm-faq-plugin/go.mod
|
||||
- ../sm-faq-plugin/go.sum
|
||||
- ../sm-menu-plugin/go.mod
|
||||
- ../sm-menu-plugin/go.sum
|
||||
- ../sm-journal-plugin/go.mod
|
||||
- ../sm-journal-plugin/go.sum
|
||||
- .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
autonomous: false
|
||||
requirements: [SC-1, SC-2, SC-3, SC-4, D-01, D-02, D-03, D-04, D-05, D-07, D-08, D-09, D-10, D-11, D-12, D-13, D-15, D-16, D-17, D-18]
|
||||
estimate:
|
||||
tokens: 210000
|
||||
raw_tokens: 210000
|
||||
tasks: 7
|
||||
confidence: low
|
||||
must_haves:
|
||||
truths:
|
||||
- "SC-4: every piece of new code in the phase has unit tests delivered in this, the phase's last plan, covering the Wave 0 list of 19.3-VALIDATION.md: bouncer cookies and Turnstile, surf CORS hardening and profiles, api-docs and the store lookup, cabana's reorder hook, internal/build tags, attach S3 (both tag modes), DiskExtension, the Store regression and public_url, cooler memory, Redis, rate-limit parity, Install, FromApp and the site-cache purge, the plugin purge helpers and the layer purge route."
|
||||
- "D-01..D-03 (tests): TokenCookie carries name, path, domain, Secure, SameSite Lax and HttpOnly with Max-Age equal to the refresh window (refresh_ttl*60, not ttl); ForgetTokenCookie clears with matching attributes; the refresh handler rotates the cookie and blacklists the old jti; a missing cookie answers 401; an invalid domain, same_site none without Secure and secure false in production fail LoadCookieConfig."
|
||||
- "D-04, D-05 (tests): credentials with * fail boot globally and per profile; bad and PHP-delimited patterns fail; wildcard origin entries match subdomains only as anchored patterns; Vary: Origin is on every dynamic answer including preflight and a disallowed origin; the api and www subdomain case works with credentials; non-credentialed * answers are unchanged; profile routes get a working OPTIONS preflight and invalid profile setups fail boot."
|
||||
- "D-07..D-09 (tests): the Redis rate-limit store and surf.MemoryStore produce the same (allowed, attempts, retryAfter) sequence on one table of attempts (miniredis with FastForward, and real Redis in Docker); concurrent attempts never admit more than max; a Redis error falls back to memory with one warning; the memory cache is bounded and its Increment is atomic; Redis Flush leaves keys outside <prefix>cache: alone; BuildRouter uses a published Store and never dials Redis on its own."
|
||||
- "D-10, D-11 (tests): a purge runs only after commit (a rolled-back save sends none), a failing or slow receiver never fails the save, SiteCacheFromApp builds lazily and Install refuses a bad config; framework keys win over the Journal alias keys and the alias path logs a deprecation; the plugin helpers do nothing without a transaction or purger; each layer's purge route specs cover disabled, proxied, unauthorized, ok, kind parsing and slug sanitising, with the shared files' hashes pinned."
|
||||
- "D-12, D-13, D-15..D-18 (tests): build.tags load, validate and round-trip through SaveManifest and reach go build args; s3:// opens with the tag and names the tag without it; DiskExtension's table keeps consistent client extensions and rewrites script-capable ones; public_url builds direct URLs and refuses malformed bases; api-docs is off by default, pins 5.33.1 with three SRI hashes and serves ordered compact JSON; Turnstile's open and closed matrix, timeout and body restore hold."
|
||||
- "scripts/check-phase19.3.sh fails closed: a required test that fails, skips or does not run fails its stage; --all runs every stage and says NOT RUN for Docker stages when no daemon answers."
|
||||
- "miniredis is installed only after a blocking human check of the package; nothing is pushed or tagged without the human's reply at the release checkpoint."
|
||||
artifacts:
|
||||
- path: "scripts/check-phase19.3.sh"
|
||||
provides: "the phase gate with go, docker, plugins, layers and docs stages"
|
||||
contains: "check-phase19.3"
|
||||
- path: "modules/cooler/ratelimit_test.go"
|
||||
provides: "the parity table shared by MemoryStore and the Redis store"
|
||||
contains: "TestRateLimitParity"
|
||||
- path: "modules/bouncer/cookie_test.go"
|
||||
provides: "cookie builder, loader and handler tests"
|
||||
contains: "TestTokenCookieMaxAgeIsRefreshWindow"
|
||||
- path: "modules/lagoon/attach/diskext_test.go"
|
||||
provides: "DiskExtension table tests"
|
||||
contains: "TestDiskExtensionTable"
|
||||
key_links:
|
||||
- from: "scripts/check-phase19.3.sh"
|
||||
to: "modules/cooler/ratelimit_test.go"
|
||||
via: "the go and docker stages require TestRateLimitParity by name"
|
||||
pattern: "TestRateLimitParity"
|
||||
- from: "modules/cooler/ratelimit_test.go"
|
||||
to: "modules/surf/limiter_store.go"
|
||||
via: "the external test package cooler_test runs the same table against surf.NewMemoryStore"
|
||||
pattern: "surf.NewMemoryStore"
|
||||
prohibitions:
|
||||
- statement: "Tests never reach the network beyond localhost, httptest servers, miniredis and testcontainers."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
- statement: "No production code changes in this plan except fixes for defects the new tests expose, each recorded as a deviation."
|
||||
status: unresolved
|
||||
verification: judgment
|
||||
---
|
||||
|
||||
## Phase Goal
|
||||
|
||||
ROADMAP Goal (not in user-story format; no story is invented, see plan 01): the framework has the transport and infrastructure pieces a multi-client, multi-instance application needs: JWT cookie refresh across subdomains, credentialed CORS, a cache module with a Redis-backed rate-limit store, an S3 blob option, an OpenAPI page and Turnstile verification.
|
||||
|
||||
This plan's slice (plan 5 of 5, wave 3): SC-4's unit tests, delivered last as CLAUDE.md requires, behind a fail-closed phase gate script, then the phase's release decision.
|
||||
|
||||
## Spec-less probe fallback
|
||||
|
||||
Skipped visibly: the phase has no requirement IDs to probe, so no probe predicates were generated (see plan 01).
|
||||
|
||||
## Decisions taken in this plan (Claude's discretion)
|
||||
|
||||
- The gate follows scripts/check-phase18.1.sh: named required tests per stage, `go test -v` output checked for a `--- PASS` line per name, a skip or a missing name fails the stage, `--all` reports Docker stages as NOT RUN without a daemon (an env var makes that fail instead).
|
||||
- Smoke tests from plans 01 to 04 stay; this plan adds the unit files next to them and extends the plan-01 store lookup file and the plan-04 purger test file instead of duplicating them.
|
||||
- Rate-limit parity runs one table of (key, max, decay, advance) steps through a small adapter: for MemoryStore the test waits on real time with short decays; for miniredis it calls FastForward; for Docker Redis it waits. The table includes window expiry, denial without increment and retryAfter never growing.
|
||||
- The release proposal is the highest `v0.1.*` tag plus one (v0.1.27 at planning time), pushed only on the human's reply, after which the three plugins' go.mod require that tag.
|
||||
|
||||
## Artifacts this phase produces (this plan)
|
||||
|
||||
- `scripts/check-phase19.3.sh` with stages `go`, `docker`, `plugins`, `layers`, `docs` and `--all`.
|
||||
- Test files: `modules/bouncer/cookie_test.go`, `modules/bouncer/turnstile_test.go`, `modules/surf/cors_profile_test.go`, `modules/surf/apidocs_test.go`, additions in `modules/surf/store_lookup_test.go`, `modules/cabana/reorder_hook_test.go`, `internal/build/manifest_tags_test.go`, `modules/lagoon/attach/s3_test.go`, `modules/lagoon/attach/diskext_test.go`, additions in `modules/lagoon/attach/store_test.go` and `url_test.go`, `modules/cooler/memory_test.go`, `redis_test.go`, `ratelimit_test.go`, `docker_test.go`, `install_test.go`, additions in `sitecache_test.go`, `../sm-journal-plugin/site_cache_keys_test.go`, `../sm-faq-plugin/controllers/site_cache_test.go`, `../sm-menu-plugin/controllers/site_cache_test.go`, extended `tests/sitePurge.test.ts` in the three layers.
|
||||
- Test dependency `github.com/alicebob/miniredis/v2` v2.40.0 (after the human check).
|
||||
- 19.3-VALIDATION.md per-task map filled and `wave_0_complete: true`.
|
||||
- Release: proposed framework tag, plugin go.mod bumps to it, pushes, all on the human's reply.
|
||||
|
||||
<objective>
|
||||
Deliver the unit tests for all new code of the phase, a fail-closed phase gate that runs them by name, and the release decision.
|
||||
|
||||
Purpose: SC-4 ("the new code has unit tests, delivered in the phase's last plan") and the test rows of 19.3-VALIDATION.md for every decision.
|
||||
Output: test files in summercms.go, the three plugins and the three layers; the gate script; the release record.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/msd-core/workflows/execute-plan.md
|
||||
@~/.claude/msd-core/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-01-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-02-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-03-SUMMARY.md
|
||||
@.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-04-SUMMARY.md
|
||||
|
||||
Rules: tests only; a production fix is allowed only for a defect a new test exposes, committed separately as `fix(19.3): …` and recorded as a deviation. Commit per repository, staging by explicit path, no co-author tags. Before each task run `git status --porcelain` on the paths it touches and stop on foreign uncommitted edits (Phase 18.1 work may be in flight in cabana). Docker suites skip under `-short`; run them on a Docker host (`sudo -u golem` when the socket is denied) and record NOT RUN when no daemon answers. Nothing is pushed or tagged before the Task 7 reply.
|
||||
|
||||
<interfaces>
|
||||
Code under test (identifiers from the plan SUMMARYs; read them, do not re-derive):
|
||||
|
||||
- bouncer (plan 02): `CookieConfig`, `CookieConfigSource`, `LoadCookieConfig`, `TokenCookie`, `ForgetTokenCookie`, `CookieRefreshHandler`, `CookieLogoutHandler`, `CookieResult`, `ErrNoTokenCookie`, `Turnstile`, `NewTurnstile`, `Verify`, `Middleware`, `TurnstileVerdict`, `TurnstileWriter`, `TurnstileMaxTokenLength`; existing `Mint`, `Refresh`, `NewMemoryBlacklist`.
|
||||
- surf (plans 01, 02): `LoadCORSConfig`, `LoadCORSProfiles`, factories `cors` and `turnstile`, OPTIONS routes, `APIDocsProvider`, `SwaggerUIVersion`, `BuildRouter` store lookup, `installCooler`; existing `TestCORSOptionsMatchesLaravel` and the plan-02 smoke file.
|
||||
- cabana and pact (plan 04): `pact.ReorderAfterSave`, `reorderPost`; the reorder Postgres harness in `modules/cabana/reorder_flat_test.go` and the tree reorder tests.
|
||||
- internal/build (plan 03): `Manifest.Tags`, `ValidateBuildTag`, `goBuildArgs`, `SaveManifest`.
|
||||
- attach (plan 03): `S3BuildTag`, `s3Enabled`, `DiskExtension`, `PublicURLBase`, `PublicURL`, `Store`, the infra smoke file.
|
||||
- cooler (plans 01, 04): `Install`, `Handle`, `FromApp`, `OpenRedis`, `NewMemoryCache`, `NewRedisCache`, `NewRateLimitStore`, `SiteCachePurger`, `NewSiteCachePurger`, `NewSiteCachePurgerFor`, `PurgeSiteCacheAfterCommit`, `SiteCacheFromApp`, config key constants.
|
||||
- Plugins (plan 04): each controllers package's `purgeSiteCache` helper, Journal `wireSiteCache` and `classes` aliases; layers: `server/lib/siteCachePurge.ts` exports and the pinned hash constants.
|
||||
- Gate model: `scripts/check-phase18.1.sh` (stages, required-name lists, NOT RUN handling).
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="tracer">
|
||||
<name>Task 1: Tracer: scripts/check-phase19.3.sh runs the bouncer cookie and Turnstile unit suites by name and fails when one is missing, skipped or red</name>
|
||||
<files>scripts/check-phase19.3.sh, modules/bouncer/cookie_test.go, modules/bouncer/turnstile_test.go</files>
|
||||
<read_first>scripts/check-phase18.1.sh (structure, required-name lists, run helper, NOT RUN handling), modules/bouncer/cookie.go, modules/bouncer/turnstile.go, modules/bouncer/refresh_test.go (fixtures and helpers), modules/surf/transport_smoke_test.go (plan 02 smokes), 19.3-VALIDATION.md (SC-1 and Turnstile rows)</read_first>
|
||||
<action>Gate: create scripts/check-phase19.3.sh (bash, set -euo pipefail, NO_COLOR) modelled on check-phase18.1.sh with a run helper that executes `go test -count=1 -v -run '^(names)$'` for a package and fails unless every required name prints a `--- PASS` line (a SKIP or absent name fails); stage `go` (go vet ./... and go vet -tags summercms_s3 ./modules/lagoon/..., go test -short ./..., then the required Docker-free names per package); placeholders for stages docker, plugins, layers and docs that later tasks fill; `--all` runs every stage; a Docker stage without a daemon prints NOT RUN and fails only when PHASE193_REQUIRE_DOCKER=1. Environment overrides for the sibling paths like check-phase18.1.sh. Header comment lists the stages and failure rules and names no application.
|
||||
|
||||
Tests (per D-01..D-03, D-18): modules/bouncer/cookie_test.go: TestLoadCookieConfigDefaults (empty source: token, /, host-only, Lax, Secure true for https app.url and false for http), TestLoadCookieConfigErrors (table: invalid domain, same_site none with Secure false, secure false in production, unknown same_site, path without leading slash; each error names its key), TestTokenCookieAttributes, TestTokenCookieMaxAgeIsRefreshWindow (20160 minutes gives Max-Age 1209600 while the access ttl is 60 minutes), TestForgetTokenCookie (same attributes, empty value, MaxAge -1), TestCookieRefreshHandlerRotatesAndBlacklists (real bouncer.Refresh with a MemoryBlacklist: new cookie value, old jti blacklisted, writer got 200 and the token), TestCookieRefreshHandlerMissingCookie (401, ErrNoTokenCookie, no Set-Cookie), TestCookieRefreshHandlerRefreshError (401, cookie untouched), TestCookieLogoutHandler (revoke called with the cookie token, forget cookie set, 200; no cookie still forgets), TestCookieHandlerOptionsValidated. modules/bouncer/turnstile_test.go against an httptest siteverify (Endpoint override): TestTurnstileVerifyMatrix (table over open and closed, empty secret in development and production, empty token, a 2049-character token, success true, success false, non-2xx, invalid JSON; asserts the verdict and whether the fake was called), TestTurnstileVerifyTimeout (a fake that sleeps past a 50 ms context deadline gives Failed with a timeout error), TestTurnstileFormFields (secret, response and remoteip received; remoteip absent for an empty ip), TestTurnstileMiddlewareTokenSources (query, JSON body, urlencoded form), TestTurnstileMiddlewareRestoresBody, TestTurnstileMiddlewareStatuses (503, 422, pass; nil writer gives the neutral default bodies), TestTurnstileNeverLogsSecret (a captured slog handler sees neither the secret nor the token after a transport error). Add all names to the go stage.</action>
|
||||
<verify>
|
||||
<automated>bash -n scripts/check-phase19.3.sh && bash scripts/check-phase19.3.sh go && go test -count=1 -short -run '^(TestTokenCookieMaxAgeIsRefreshWindow|TestCookieRefreshHandlerRotatesAndBlacklists|TestTurnstileVerifyMatrix|TestTurnstileMiddlewareRestoresBody)$' -v ./modules/bouncer 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: the script has a syntax error, its go stage fails (a required name missing, skipped or failing, or vet red), or fewer than four of the sampled bouncer tests print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestTurnstileVerifyMatrix\|TestCookieRefreshHandlerRotatesAndBlacklists' scripts/check-phase19.3.sh` prints at least 2.
|
||||
- Renaming one required test locally (not committed) makes `bash scripts/check-phase19.3.sh go` exit non-zero; record the check in the SUMMARY.
|
||||
- `bash scripts/check-phase19.3.sh nosuchstage` exits non-zero with a usage line listing go, docker, plugins, layers, docs and --all.
|
||||
</acceptance_criteria>
|
||||
<done>The phase has a gate that proves the cookie and Turnstile behaviour by name and cannot pass on a silently skipped test.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Unit tests for surf (CORS hardening and profiles, api-docs, store lookup) and cabana's reorder hook, wired into the gate</name>
|
||||
<files>modules/surf/cors_profile_test.go, modules/surf/apidocs_test.go, modules/surf/store_lookup_test.go, modules/cabana/reorder_hook_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>modules/surf/cors.go, modules/surf/router.go, modules/surf/apidocs.go, modules/surf/cors_test.go, modules/surf/cors_coverage_test.go, modules/surf/transport_smoke_test.go, modules/surf/store_lookup_test.go, modules/cabana/reorder.go, modules/cabana/reorder_flat_test.go (Postgres harness), modules/pact/capabilities.go (ReorderAfterSave), 19.3-RESEARCH.md (Pattern 4, Pattern 9)</read_first>
|
||||
<action>Per D-04, D-05, D-09, D-11, D-16, D-17. cors_profile_test.go: TestCORSCredentialsWildcardRefused (global and profile, error names the key), TestCORSPatternErrors (uncompilable and #-delimited patterns), TestCORSWildcardOriginEntry (https://*.example.com allows https://www.example.com and refuses https://example.com.evil.org), TestCORSVaryOnDynamicAnswers (allowed, disallowed and preflight responses carry Vary: Origin when credentials or patterns are set), TestCORSSubdomainCredentialed (api host, www origin, credentials: exact origin and Allow-Credentials on GET and preflight), TestCORSStarUnchanged (a * config without credentials produces the same header set as before for GET and OPTIONS; keep TestCORSOptionsMatchesLaravel green), TestCORSProfilePreflightRoute, TestCORSProfileUnknown, TestCORSProfileWithPathsRefused, TestCORSProfileOverlapGlobal, TestCORSProfileConflictingProfilesSamePath, TestTurnstileFactoryParams (open and closed accepted, anything else fails boot). apidocs_test.go: TestAPIDocsOffByDefault, TestAPIDocsServesPinnedPage (5.33.1, three integrity attributes equal to the RESEARCH hashes, crossorigin, CSP nonce equals the script nonce, nosniff), TestAPIDocsSpecJSONOrder (YAML keys keep their order, output is compact), TestAPIDocsJSONSpecInput, TestAPIDocsProviderCount (zero and two providers fail), TestAPIDocsPathValidation, TestAPIDocsTitleEscaped (a title with markup is escaped). store_lookup_test.go additions: TestBuildRouterStoreFallback, TestBuildRouterUsesPublishedStore, TestBuildRouterNeverDials (redis config without install builds instantly with a MemoryStore). cabana reorder_hook_test.go (Postgres harness, skips under -short): TestReorderHookRunsInTx (the hook sees the new sort values through cabana.TxFromContext and receives the partition), TestReorderHookErrorRollsBack (sort values unchanged, error answer), TestReorderWithoutHookUnchanged. Add every name to the gate: the Docker-free ones to the go stage, the cabana ones to the docker stage.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && bash scripts/check-phase19.3.sh go && go test -count=1 -run '^(TestReorderHookRunsInTx|TestReorderHookErrorRollsBack|TestReorderWithoutHookUnchanged)$' -v ./modules/cabana 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 3</automated>
|
||||
<fails_when>Non-zero exit: vet fails, the gate's go stage fails (a required surf name missing, skipped or red), or fewer than three cabana hook tests print "--- PASS" (Docker missing or a hook defect).</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestCORSSubdomainCredentialed\|TestAPIDocsServesPinnedPage\|TestBuildRouterNeverDials\|TestReorderHookRunsInTx' scripts/check-phase19.3.sh` prints at least 4.
|
||||
- `go test -count=1 -short -run '^TestCORSOptionsMatchesLaravel$' ./modules/surf` exits 0.
|
||||
</acceptance_criteria>
|
||||
<done>CORS, api-docs, the store lookup and the reorder hook are pinned by tests the gate requires.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: Unit tests for summer.yaml build tags, the S3 tag guard in both build modes, DiskExtension, the Store regression table and public_url, wired into the gate</name>
|
||||
<files>internal/build/manifest_tags_test.go, modules/lagoon/attach/s3_test.go, modules/lagoon/attach/diskext_test.go, modules/lagoon/attach/store_test.go, modules/lagoon/attach/url_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>internal/build/manifest.go, internal/build/build.go, internal/build/build_test.go, internal/build/manifest_tags_smoke_test.go, modules/lagoon/attach/bucket.go, modules/lagoon/attach/s3_on.go, modules/lagoon/attach/s3_off.go, modules/lagoon/attach/diskext.go, modules/lagoon/attach/store.go, modules/lagoon/attach/store_test.go, modules/lagoon/attach/url_test.go, modules/lagoon/attach/infra_smoke_test.go, 19.3-RESEARCH.md (Pattern 5, Pattern 6 probe table)</read_first>
|
||||
<action>Per D-12, D-13, D-15. internal/build/manifest_tags_test.go: TestManifestBuildTags (load, trim, dedupe), TestManifestBuildTagsInvalid (dash, space, upper case, non-list build.tags, non-mapping build), TestSaveManifestKeepsBuildTags (plugin:add-style round trip keeps tags and plugin order; no build block when empty), TestGoBuildArgs (no tags: no -tags argument; tags: one -tags=a,b argument before -o). attach/s3_test.go (no build constraint; branches on s3Enabled): TestOpenBucketS3 (without the tag the error names S3BuildTag, build.tags and -tags; with the tag the bucket opens with dummy AWS env and closes; an uppercase S3 scheme behaves the same). attach/diskext_test.go: TestDiskExtensionTable (rows from the RESEARCH probe: png body as html gives png, jpeg body keeps jpeg and maps png to jpg, mkv under video/webm kept, docx under application/zip kept, ics under text/plain kept, html under text/plain gives txt, svg under text/xml gives txt, js under application/octet-stream gives bin, pdf as txt gives pdf). store_test.go addition TestStoreDiskExtension (Postgres, skips under -short): the regression table through Store with URL and file_name assertions. url_test.go additions: TestPublicURLBase (trailing slashes, key joining, thumbnails through PublicURL), TestPublicURLValidation (ftp, user info, query, fragment, relative value each fail naming storage.uploads.public_url; unset keeps the prefix). Add names to the gate: Docker-free ones to the go stage (also run the attach names with -tags summercms_s3 in that stage), TestStoreDiskExtension to the docker stage.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && go vet -tags summercms_s3 ./modules/lagoon/... && bash scripts/check-phase19.3.sh go && go test -count=1 -short -tags summercms_s3 -run '^TestOpenBucketS3$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestOpenBucketS3$' && go test -count=1 -run '^TestStoreDiskExtension$' -v ./modules/lagoon/attach 2>&1 | grep -q -- '^--- PASS: TestStoreDiskExtension$'</automated>
|
||||
<fails_when>Non-zero exit: vet fails in either tag mode, the gate's go stage fails, the tagged S3 test or the Postgres Store regression did not print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `grep -c 'TestDiskExtensionTable\|TestSaveManifestKeepsBuildTags\|TestOpenBucketS3\|TestPublicURLValidation\|TestStoreDiskExtension' scripts/check-phase19.3.sh` prints at least 5.
|
||||
- `grep -n 'summercms_s3' scripts/check-phase19.3.sh` prints at least one line (the tagged run is part of the gate).
|
||||
</acceptance_criteria>
|
||||
<done>The S3 opt-in, the build tags, the disk-name fix and public_url are pinned in both build modes.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:human-verify" gate="blocking-human">
|
||||
<name>Task 4: Package legitimacy check before installing github.com/alicebob/miniredis/v2 v2.40.0 (test-only)</name>
|
||||
<read_first>.planning/notes/2026-10-15-cooler-go-redis-otter.md (miniredis paragraph), 19.3-RESEARCH.md (Package Legitimacy Audit, Supporting stack, assumption A3)</read_first>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the human's reply verbatim ("approved" or "use-docker-only") before any go.mod change of Task 5.
|
||||
- Until the reply, `grep -c 'alicebob/miniredis' go.mod` prints 0.
|
||||
</acceptance_criteria>
|
||||
<what-built>Nothing installed yet. RESEARCH verified the module and version on proxy.golang.org (v2.40.0 published 2026-10-10, VCS origin github.com/alicebob/miniredis, one dependency gopher-lua) but marked the package choice [ASSUMED] (name from training). Plan 01's decision note names it as test-only. Never auto-approved.</what-built>
|
||||
<how-to-verify>
|
||||
1. Open https://pkg.go.dev/github.com/alicebob/miniredis/v2 and https://github.com/alicebob/miniredis: confirm the repository, the maintainer, the MIT licence, recent activity and that v2.40.0 is a tagged release.
|
||||
2. Run `GOFLAGS=-mod=mod go list -m -json github.com/alicebob/miniredis/v2@v2.40.0` in a scratch module and confirm Origin.URL is https://github.com/alicebob/miniredis.
|
||||
3. Confirm it is acceptable as a test-only dependency (it lands in go.mod and go.sum; no production package imports it).
|
||||
</how-to-verify>
|
||||
<resume-signal>Reply "approved" to install it in Task 5, or "use-docker-only" to drop miniredis (Task 5 then runs the Redis tests on testcontainers only, all in the docker stage).</resume-signal>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 5: Unit tests for cooler: memory and Redis caches, rate-limit parity with MemoryStore, Install and FromApp, and the after-commit site-cache purge, wired into the gate</name>
|
||||
<precondition>Task 4's reply is recorded in the SUMMARY; miniredis is installed only after "approved".</precondition>
|
||||
<files>go.mod, go.sum, modules/cooler/memory_test.go, modules/cooler/redis_test.go, modules/cooler/ratelimit_test.go, modules/cooler/docker_test.go, modules/cooler/install_test.go, modules/cooler/sitecache_test.go, scripts/check-phase19.3.sh</files>
|
||||
<read_first>modules/cooler/*.go and modules/cooler/smoke_test.go, modules/surf/limiter_store.go, modules/surf/limiter_test.go (MemoryStore expectations), modules/lagoon/transaction.go (AfterCommit), modules/beachcomber/postgres_test.go (TestMain with flag.Parse and testing.Short), modules/postcard/mailpit_test.go, 19.3-RESEARCH.md (Pattern 2 probe output, Pitfalls 13 and 14, Docker-gated test example)</read_first>
|
||||
<action>Per D-07..D-10. After "approved": `go get github.com/alicebob/miniredis/v2@v2.40.0` (test files only import it). memory_test.go: TestMemoryCache (every method, ttl 0 forever, copy-on-get), TestMemoryCacheTTL (expiry through a short TTL), TestMemoryCacheBounded (max_entries respected), TestMemoryIncrementConcurrent (100 goroutines, final value exact, TTL kept), TestRememberDegrades (a failing driver still returns the callback value and logs once). redis_test.go (miniredis, Docker-free): TestRedisCache (methods, keys under <prefix>cache:, PX TTL with FastForward), TestRedisFlushOnlyPrefix (rl keys and foreign keys survive). ratelimit_test.go (package cooler_test): TestRateLimitParity (one table through surf.NewMemoryStore and NewRateLimitStore on miniredis with FastForward: first attempt opens, denial does not increment, window never extends, retryAfter never grows, expiry opens a new window), TestRateLimitConcurrentNeverExceedsMax (50 goroutines, max 10, exactly 10 admitted), TestRateLimitFallbackOnError (stopped miniredis: fallback store answers, one warning per minute), TestRateLimitKeyPrefix. install_test.go: TestInstallConfigErrors (unknown driver and store values, missing redis.url, malformed URL without the password in the error), TestInstallPingWarning (unreachable Redis: no error, one warning), TestInstallReusesFromApp, TestFromAppLazy (no dial at construction), TestInstallSiteCacheValidation (bad site_cache config is a boot error; a published alias purger is reused). sitecache_test.go additions: TestSiteCacheAfterCommitOnly (Postgres: rollback sends nothing, commit sends one), TestSiteCacheFailureNeverFails (500 and a slow receiver past SiteCachePurgeTimeout leave the transaction committed), TestSiteCacheFromApp (builds once, publishes, nil when unset). docker_test.go (skips under -short; redis:7-alpine): TestRateLimitParityDocker (the same table on real Redis), TestRedisCacheDocker. If the reply was "use-docker-only", move the miniredis cases into docker_test.go on testcontainers and record the change. Add the names to the go and docker stages of the gate.</action>
|
||||
<verify>
|
||||
<automated>go vet ./... && bash scripts/check-phase19.3.sh go && go test -count=1 -run '^(TestRateLimitParityDocker|TestRedisCacheDocker|TestSiteCacheAfterCommitOnly|TestSiteCacheFailureNeverFails)$' -v ./modules/cooler 2>&1 | grep -c -- '^--- PASS: Test' | grep -qx 4</automated>
|
||||
<fails_when>Non-zero exit: vet fails, the gate's go stage fails (a cooler name missing, skipped or red), or fewer than four Docker-backed cooler tests print "--- PASS".</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- After "approved": `grep -n 'github.com/alicebob/miniredis/v2 v2.40.0' go.mod` prints one line and `grep -rl 'alicebob/miniredis' --include='*.go' modules | grep -vc '_test.go$'` prints 0.
|
||||
- `grep -c 'TestRateLimitParity\|TestRateLimitConcurrentNeverExceedsMax\|TestSiteCacheAfterCommitOnly' scripts/check-phase19.3.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>The cache, the shared rate limits and the purge service are proven equivalent to the semantics they replace, fast in the -short loop and on real Redis.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 6: Unit tests for the plugin purge wiring and the layers' purge route, the plugins and layers stages of the gate, and the validation map</name>
|
||||
<files>../sm-journal-plugin/site_cache_keys_test.go, ../sm-faq-plugin/controllers/site_cache_test.go, ../sm-menu-plugin/controllers/site_cache_test.go, ../vue-faq-layer/tests/sitePurge.test.ts, ../vue-menu-layer/tests/sitePurge.test.ts, ../vue-journal-layer/tests/sitePurge.test.ts, scripts/check-phase19.3.sh, .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</files>
|
||||
<read_first>../sm-journal-plugin/plugin.go (wireSiteCache), ../sm-journal-plugin/classes/site_cache.go, ../sm-faq-plugin/controllers/site_cache.go, ../sm-menu-plugin/controllers/site_cache.go, the three layers' server/lib/siteCachePurge.ts and tests/sitePurge.test.ts (plan 04), ../vue-journal-layer/tests/siteCache.test.ts (matrix style), scripts/check-phase18.1.sh (plugins and layers stages), 19.3-VALIDATION.md</read_first>
|
||||
<action>Per D-10, D-11. sm-journal-plugin/site_cache_keys_test.go (Docker-free, boots the plugin on a compass config without a database): TestSiteCacheFrameworkKeysWin (framework keys set: wireSiteCache publishes nothing; alias keys also set: one warning that they are ignored), TestSiteCacheAliasKeysDeprecated (only alias keys: a purger is published whose validation errors name the Journal keys, one deprecation warning naming both framework keys), TestSiteCacheNoKeys. sm-faq-plugin and sm-menu-plugin controllers/site_cache_test.go: TestPurgeSiteCacheWithoutTx and TestPurgeSiteCacheWithoutPurger (no panic, no request). Layers: extend tests/sitePurge.test.ts identically in all three (the file stays byte-identical; its hash constants cover only the lib and route files): sitePurge: disabled below 32 characters, proxied before the token check, unauthorized for a wrong or malformed bearer, ok in constant time over sha256 digests, purge removes only nitro:routes: keys and returns the count, parseSitePurgeEvent accepts faq, menu, menu_entry, post and category, maps other kinds to unknown, strips control characters and cuts slugs at 200. Commit each repository separately. Gate: stage plugins runs go vet ./... and go test -short ./... in the three plugins with the required names, and their Docker tests (TestFaqAdminPurgesSiteCache, TestMenuAdminPurgesSiteCache, TestCategoriesAdminPurgesSiteCache, TestPostsAdminPurgesSiteCache) in the docker stage; stage layers runs pnpm test in the three layers, requires "# fail 0", the sitePurge: names and cmp equality of the shared files across the layers; stage docs runs TestDocsTree, summer docs:build --check and a case-insensitive grep that no new framework file names a consuming application. Fill 19.3-VALIDATION.md's per-task map with the final commands and statuses and set wave_0_complete true (leave nyquist_compliant to /msd-validate-phase).</action>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh plugins && bash scripts/check-phase19.3.sh layers && bash scripts/check-phase19.3.sh docs && grep -q '^wave_0_complete: true$' .planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-VALIDATION.md</automated>
|
||||
<fails_when>Non-zero exit: a plugin, layer or docs stage fails (a required name missing, skipped or red, a layer "# fail" other than 0, the shared layer files differing), or VALIDATION.md still says wave_0_complete false.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `cmp ../vue-faq-layer/tests/sitePurge.test.ts ../vue-menu-layer/tests/sitePurge.test.ts` and `cmp ../vue-faq-layer/tests/sitePurge.test.ts ../vue-journal-layer/tests/sitePurge.test.ts` exit 0.
|
||||
- `grep -c 'TestSiteCacheFrameworkKeysWin\|TestPurgeSiteCacheWithoutTx\|sitePurge:' scripts/check-phase19.3.sh` prints at least 3.
|
||||
</acceptance_criteria>
|
||||
<done>The purge adoption across plugins and layers is pinned by tests and the gate covers every repository the phase touched.</done>
|
||||
</task>
|
||||
|
||||
<task type="checkpoint:decision" gate="blocking-human">
|
||||
<name>Task 7: Release gate: the whole phase gate green, then a human decides the framework tag, the plugin require bumps and the pushes</name>
|
||||
<read_first>.planning/phases/18.1-payment-gateway-subscriptions/18.1-01-SUMMARY.md (release gate record), .planning/notes/2026-10-07-embedded-admin-release-gate.md, 19.3-04-SUMMARY.md (what plan 04 pushed or held), this plan's Task 1 to 6 SUMMARY notes</read_first>
|
||||
<action>Do everything automatable, print a release sheet, then stop. In summercms.go: `git status --porcelain -- modules internal cmd scripts docs go.mod go.sum` prints nothing, `git fetch origin --tags`, `git log --oneline HEAD..origin/master` prints nothing (otherwise stop and report). Propose the highest `git tag -l 'v0.1.*' --sort=-v:refname` plus one and confirm with `git ls-remote --tags origin` that it is free. Run `bash scripts/check-phase19.3.sh --all` (verify). Sheet: the proposed tag; the public surfaces it publishes (cooler and its keys redis.url, cache.*, http.rate_limit.store, site_cache.*; auth.cookie.* and the bouncer cookie and Turnstile API; http.cors hardening, http.cors_profiles and cors:<profile>; turnstile.*; http.api_docs.*; summer.yaml build.tags and the summercms_s3 tag; storage.uploads.public_url; pact.ReorderAfterSave); the framework commits to push, including any earlier unpushed master commits (list them); the plugin and layer commits still unpushed after plan 04 and the app commits from plan 04 Task 5; that apps building against ../summercms.go need `go mod tidy` once for go-redis, otter and the AWS SDK modules; the Docker stages run or NOT RUN. No push and no tag before the reply. After "release": annotated tag on master, push master and the tag; in sm-faq-plugin, sm-menu-plugin and sm-journal-plugin set the summercms require to the new tag with `GOWORK=off go get git.golem15.com/golem15/summercms@<tag>` and `GOWORK=off go mod tidy`, run their suites, commit go.mod and go.sum, push main; push the layer test commits; push the app and vue app commits of plan 04 Task 5 (vue app first, then the app). After "tag-only": tag and push summercms only. After "hold": nothing. Never force. Record the reply verbatim and every pushed hash and tag.</action>
|
||||
<decision>Tag and push the framework release, bump and push the plugins, and push the layer and app commits now?</decision>
|
||||
<context>A pushed tag is read by plugin go.mod files and the Go module proxy and publishes every new key and interface of this phase; it cannot be moved, only superseded. Holding costs nothing locally: apps and plugins build through replace and go.work.</context>
|
||||
<options>
|
||||
<option id="release">
|
||||
<name>Tag and push everything</name>
|
||||
<pros>Plugins require a framework version that contains cooler; every repository on origin builds on its own.</pros>
|
||||
<cons>A defect found in UAT needs a follow-up tag.</cons>
|
||||
</option>
|
||||
<option id="tag-only">
|
||||
<name>Tag and push the framework only</name>
|
||||
<pros>The framework surface is published; plugin and app pushes wait for UAT.</pros>
|
||||
<cons>Plugins on origin keep requiring the old tag until bumped.</cons>
|
||||
</option>
|
||||
<option id="hold">
|
||||
<name>Hold until UAT (/msd-verify-work)</name>
|
||||
<pros>One publication after manual checks (subdomain cookies, a real S3 bucket, a live purge).</pros>
|
||||
<cons>Nothing on origin carries the phase yet.</cons>
|
||||
</option>
|
||||
</options>
|
||||
<resume-signal>Reply "release", "tag-only" or "hold".</resume-signal>
|
||||
<verify>
|
||||
<automated>bash scripts/check-phase19.3.sh --all && go test -count=1 -run '^TestDocsTree$' ./cmd/summer && (cd examples/hello && go test ./...) && echo release-gate-ok</automated>
|
||||
<fails_when>Non-zero exit or "release-gate-ok" absent: a gate stage, the docs checker or the examples/hello workspace tests are red; the executor fixes the defect (as a recorded deviation) before presenting the sheet and never presents a red gate.</fails_when>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- The SUMMARY records the proposed tag, the reply verbatim and, after "release" or "tag-only", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` non-empty with `git rev-parse master` equal to `git rev-parse origin/master`.
|
||||
- After "release", `grep -n "git.golem15.com/golem15/summercms $RELEASE_TAG" ../sm-faq-plugin/go.mod ../sm-menu-plugin/go.mod ../sm-journal-plugin/go.mod` prints one line per file.
|
||||
- After "hold", `git ls-remote --tags origin "refs/tags/$RELEASE_TAG"` prints nothing.
|
||||
</acceptance_criteria>
|
||||
<done>The phase is either published with plugins pinned to it, or held with a record of exactly what waits.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Go module proxy to the build | a new test-only module enters go.sum |
|
||||
| test harnesses to the network | httptest, miniredis and testcontainers only |
|
||||
| local checkouts to origin | tags and pushes published to every consumer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|----------|-------------|-----------------|
|
||||
| T-19.3-30 | Repudiation | the phase gate passing on skipped or unmatched tests | medium | mitigate | named required tests per stage; a missing, skipped or failing name fails the stage; Task 1 proves it by renaming a test locally |
|
||||
| T-19.3-31 | Tampering | publishing an unverified release | medium | mitigate | Task 7 blocking checkpoint after the full gate; no force pushes; tag proposed and checked free on origin |
|
||||
| T-19.3-32 | Information disclosure | tests asserting secrets stay out of logs | low | mitigate | TestTurnstileNeverLogsSecret, the redaction test for redis.url, the purge token log assertions |
|
||||
| T-19.3-SC | Tampering | Go module install github.com/alicebob/miniredis/v2 v2.40.0 ([ASSUMED] name) | high | mitigate | blocking human legitimacy check (Task 4) before go get; test-only import verified by the Task 5 acceptance criterion; "use-docker-only" fallback avoids the module entirely |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
- Task 1: the gate script parses and its go stage passes with the bouncer names; a renamed test makes it fail.
|
||||
- Tasks 2, 3, 5: the go stage with each task's names plus the Docker-backed names of that task.
|
||||
- Task 6: the plugins, layers and docs stages; VALIDATION.md updated.
|
||||
- Task 7: `scripts/check-phase19.3.sh --all`, TestDocsTree and the examples/hello workspace tests before the human decision.
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- SC-4: unit tests for all new code, delivered in the phase's last plan, behind a fail-closed gate.
|
||||
- Every decision D-01..D-19 with code has at least one named test in the gate.
|
||||
- miniredis installed only after the human check; the release published only on the human's reply.
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
Create `.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-05-SUMMARY.md` when done, with the test files and names per repository, the gate stages run or NOT RUN, the Task 4 and Task 7 replies verbatim, every pushed hash and tag, and any production fix made as a deviation.
|
||||
</output>
|
||||
@@ -384,7 +384,7 @@ PHP [VERIFIED: wavepath.org …/TurnstileVerifier.php]: siteverify `https://chal
|
||||
Design: `bouncer.Turnstile{Secret, Production bool, Endpoint (test override), Client *http.Client{Timeout: 5s}}` with `Verify(ctx, token, ip string, failClosed bool) (Verdict, error)`, so the middleware can tell "unconfigured" (503) from "failed" (422). Register the `turnstile` factory in `surf.BuildRouter` (it has `ClientIP` and trusted proxies; `bouncer` cannot import `surf`). The writer comes from `app.Lookup[bouncer.TurnstileWriter]()` with a neutral default JSON body. Config: `turnstile.secret` (env `SUMMER_TURNSTILE__SECRET`), optional `turnstile.field` (default `turnstile_token`). The middleware reads the field from a JSON or form body and **restores `r.Body`** (the body cap is already applied outside named middleware [VERIFIED: modules/surf/router.go:442-445]).
|
||||
|
||||
### Pattern 8: Site-cache purge (D-10/D-11)
|
||||
Move `SiteCachePurger` from `sm-journal-plugin/classes/site_cache.go` into `cooler` nearly verbatim: URL/token validation (http only to loopback; token ≥ `SiteCachePurgeMinTokenLength = 32`), `SiteCachePurgeTimeout = 2 * time.Second`, `context.WithoutCancel`, a 4 KiB answer read, no token in logs, and `PurgeSiteCacheAfterCommit` via `lagoon.AfterCommit` [VERIFIED: sm-journal-plugin/classes/site_cache.go:21-48,114-184]. Framework keys `site_cache.purge_url` / `site_cache.purge_token`. Journal alias: in its `Register`, when the framework keys are unset and `golem15.journal.site_cache.purge_url`/`purge_token` are set [site_cache.go:24,27], build the cooler purger from the alias keys and log a deprecation warning. The framework never names the plugin's keys. Hooks: Journal already purges in `FormAfterCreate/Update/Delete` [VERIFIED: sm-journal-plugin/controllers/categories.go:64-88]; FAQ and menu add them. **Reorder has no hook** (Pitfall 9). Coalesce bursts (bulk delete of N rows means N after-commit purges) with a per-purger trailing debounce or an in-flight flag.
|
||||
Move `SiteCachePurger` from `sm-journal-plugin/classes/site_cache.go` into `cooler` nearly verbatim: URL/token validation (http only to loopback; token ≥ `SiteCachePurgeMinTokenLength = 32`), `SiteCachePurgeTimeout = 2 * time.Second`, `context.WithoutCancel`, a 4 KiB answer read, no token in logs, and `PurgeSiteCacheAfterCommit` via `lagoon.AfterCommit` [VERIFIED: sm-journal-plugin/classes/site_cache.go:21-48,114-184]. Framework keys `site_cache.purge_url` / `site_cache.purge_token`. Journal alias: in its `Register`, when the framework keys are unset and `golem15.journal.site_cache.purge_url`/`purge_token` are set [site_cache.go:24,27], build the cooler purger from the alias keys and log a deprecation warning. The framework never names the plugin's keys. Hooks: Journal already purges in `FormAfterCreate/Update/Delete` [VERIFIED: sm-journal-plugin/controllers/categories.go:64-88]; FAQ and menu add them. **Reorder has no hook** (Pitfall 9). Coalesce bursts (bulk delete of N rows means N after-commit purges) with a per-purger trailing debounce or an in-flight flag. (Superseded: the user chose no debounce, one POST per committed save; see Open Questions Q4.)
|
||||
|
||||
Nuxt side: Nitro dedupes scanned handlers by route+method+env and keeps the first [VERIFIED: vue-journal-layer node_modules nitropack@2.13.4 dist/core/index.mjs:894-901]. So the shared place can be **the same route file in each of vue-journal-layer, vue-menu-layer and vue-faq-layer** (`server/api/__site__/cache/purge.post.ts`, runtimeConfig `siteCache.purgeToken`, env `NUXT_SITE_CACHE_PURGE_TOKEN`), with no new repo needed. The Journal layer keeps `/api/__journal__/cache/purge` and `journal.cachePurgeToken` as aliases for one release. Each layer needs `routeRules: { '/api/__site__/**': { cache: false } }` (Pitfall 10). Sites consume layers as pnpm git deps pinned by tag (`"vue-journal-layer": "git+https://…/vue-journal-layer.git#v0.3.0"` [VERIFIED: sm-grzybyfunkcjonalne-app/vue-grzybyfunkcjonalne-app/package.json:20-22]), so each layer needs a new tag plus a site bump.
|
||||
|
||||
@@ -544,18 +544,25 @@ func TestMain(m *testing.M) {
|
||||
| A4 | Nitro's first-wins dedupe follows layer priority (project first) | Pattern 8 | Harmless if the route files are identical; diverging copies would make the winner layer-order dependent |
|
||||
| A5 | Laravel's cache prefix default is `slug(APP_NAME,'_').'_cache_'`; recommend `cache.prefix` defaulting from `app.name` | cooler config | Cosmetic; only matters for key layout docs |
|
||||
| A6 | Symfony serializes PHP cookies as lower-case attributes (`path=/; domain=…; secure; httponly; samesite=lax`) | Pitfall 1 | Parity fixtures that compare Set-Cookie bytes need normalization |
|
||||
| A7 | Fallback-to-memory is the preferred Redis-outage behaviour for rate limits | Pitfall 13 | Product choice; confirm with the user |
|
||||
| A7 | Fallback-to-memory is the preferred Redis-outage behaviour for rate limits | Pitfall 13 | Product choice; confirmed by the user (Open Questions Q3) |
|
||||
|
||||
## Open Questions
|
||||
## Open Questions (RESOLVED)
|
||||
|
||||
1. **Shared Nuxt purge route: same file in three layers, or a new tiny layer repo?**
|
||||
All five questions were answered by the user during planning (2026-10-15). The answers below are authoritative and the plans follow them.
|
||||
|
||||
1. **Shared Nuxt purge route: same file in three layers, or a new tiny layer repo?** (RESOLVED)
|
||||
- What we know: Nitro dedupes duplicate routes (first wins), so three identical copies work without a new repo.
|
||||
- What's unclear: whether the user prefers a new `vue-*-layer` repo (needs creating, and every site adds it to `extends`).
|
||||
- Recommendation: three identical copies plus a test in each layer that compares the shared util file's hash.
|
||||
2. **Turnstile env name.** PHP reads `TURNSTILE_SECRET`; compass maps only `SUMMER_*`. Recommendation: config key `turnstile.secret` (env `SUMMER_TURNSTILE__SECRET`), with the deploy docs mapping the old name.
|
||||
3. **Redis outage policy for rate limits** (A7). Recommendation: memory fallback plus a warning.
|
||||
4. **Purge coalescing.** Debounce window (for example 250 ms trailing) vs one purge per commit. Recommendation: trailing debounce in `cooler`.
|
||||
5. **Plan count.** Suggested lean split for the user to confirm: (1) decision notes + `cooler` cache/Redis store + serve wiring + caching/rate-limit docs; (2) bouncer cookies + Turnstile + surf CORS hardening/profiles + api-docs; (3) attach S3 tag + `summer.yaml` `build.tags` + `public_url` + extension helper; (4) site-cache purge in `cooler` + cabana reorder hook + Journal/menu/FAQ plugins + three vue layers + app pointer bumps; (5) unit tests. Plan 4 depends on plan 1; plans 2 and 3 are independent.
|
||||
- **RESOLVED:** the same route file is copied into `vue-journal-layer`, `vue-menu-layer` and `vue-faq-layer`, each with its own `'/api/__site__/**': { cache: false }` route rule. No new layer repo. (Plan 04.)
|
||||
2. **Turnstile env name.** PHP reads `TURNSTILE_SECRET`; compass maps only `SUMMER_*`. Recommendation: config key `turnstile.secret` (env `SUMMER_TURNSTILE__SECRET`), with the deploy docs mapping the old name. (RESOLVED)
|
||||
- **RESOLVED:** config key `turnstile.secret`, set through `SUMMER_TURNSTILE__SECRET`; the docs record the mapping from the PHP `TURNSTILE_SECRET` variable. (Plan 02.)
|
||||
3. **Redis outage policy for rate limits** (A7). Recommendation: memory fallback plus a warning. (RESOLVED)
|
||||
- **RESOLVED:** when Redis is down, the rate limiter falls back to a per-instance `surf.MemoryStore` and logs a warning. (Plan 01.)
|
||||
4. **Purge coalescing.** Debounce window (for example 250 ms trailing) vs one purge per commit. Recommendation: trailing debounce in `cooler`. (RESOLVED)
|
||||
- **RESOLVED:** no debounce. One purge POST per committed save; the recommendation above is superseded. (Plan 04.)
|
||||
5. **Plan count.** Suggested lean split for the user to confirm: (1) decision notes + `cooler` cache/Redis store + serve wiring + caching/rate-limit docs; (2) bouncer cookies + Turnstile + surf CORS hardening/profiles + api-docs; (3) attach S3 tag + `summer.yaml` `build.tags` + `public_url` + extension helper; (4) site-cache purge in `cooler` + cabana reorder hook + Journal/menu/FAQ plugins + three vue layers + app pointer bumps; (5) unit tests. Plan 4 depends on plan 1; plans 2 and 3 are independent. (RESOLVED)
|
||||
- **RESOLVED:** the 5-plan layout above is confirmed.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# API Coverage — Cloudflare Turnstile siteverify, Redis (go-redis v9), S3 (gocloud s3blob)
|
||||
|
||||
> Full coverage by default. Opt-outs are explicit, reasoned decisions.
|
||||
|
||||
Scope: the three external services phase 19.3 integrates. Turnstile siteverify is called by `bouncer.Turnstile` (plan 19.3-02, D-18) with the PHP `TurnstileVerifier` semantics as the contract. Redis is reached through go-redis v9.23.0 from `cooler` (plan 19.3-01, D-07..D-09). S3 and S3-compatible stores are reached through `gocloud.dev/blob/s3blob` behind the `summercms_s3` build tag (plan 19.3-03, D-13, D-14). Capability names carry the service as a prefix so they stay unique in one matrix.
|
||||
|
||||
| capability | decision | reason |
|
||||
|---|---|---|
|
||||
| turnstile: siteverify POST as form fields secret and response | INTEGRATE | same transport as PHP (Http::asForm) |
|
||||
| turnstile: remoteip field from the trusted client IP | INTEGRATE | surf.ClientIP with http.trusted_proxies, as PHP passes $request->ip() |
|
||||
| turnstile: 5 s request timeout, context-aware | INTEGRATE | D-18; applied to both modes |
|
||||
| turnstile: read success from the answer (success === true) | INTEGRATE | |
|
||||
| turnstile: error-codes read for the log line (never the token or secret) | INTEGRATE | diagnostics only; the verdict stays success-based |
|
||||
| turnstile: fail-open mode (empty secret passes) | INTEGRATE | D-18 registration path |
|
||||
| turnstile: fail-closed mode (503 unconfigured in production, 422 on missing token, false, error or timeout) | INTEGRATE | D-18 guest entry path |
|
||||
| turnstile: reject a token longer than 2048 characters without a call | INTEGRATE | Cloudflare's documented token limit; saves a siteverify call |
|
||||
| turnstile: idempotency_key for retried verification | OPT-OUT | the verifier makes exactly one attempt per request and never retries, so there is nothing to deduplicate |
|
||||
| turnstile: hostname check of the answer | OPT-OUT | not in the PHP contract; the site key is bound to its hostnames in the Cloudflare dashboard |
|
||||
| turnstile: action check of the answer | OPT-OUT | not in the PHP contract; the widgets of the ported clients set no action |
|
||||
| turnstile: cdata and challenge_ts fields | OPT-OUT | not in the PHP contract; tokens are single-use and expire after 300 s on Cloudflare's side |
|
||||
| turnstile: JSON request body instead of form | OPT-OUT | form encoding is what PHP sends; Cloudflare accepts both |
|
||||
| redis: connection from redis.url (redis://, rediss:// TLS, unix://, ACL user and password in the URL) | INTEGRATE | go-redis ParseURL; the URL never appears in an error or log |
|
||||
| redis: client handshake without CLIENT SETINFO and without maintenance notifications | INTEGRATE | DisableIdentity and maintnotifications ModeDisabled (RESEARCH Pitfall 14) |
|
||||
| redis: PING at serve start-up with a 3 s timeout | INTEGRATE | an unreachable server is a warning, never a boot failure (user answer: memory fallback) |
|
||||
| redis: GET, SET with PX, EXISTS, UNLINK, INCRBY for the cache | INTEGRATE | D-08 Get, Set, Has, Forget, Increment |
|
||||
| redis: SCAN MATCH under the cache prefix plus UNLINK for Flush | INTEGRATE | Flush never empties the whole database (rate-limit counters and other apps share it) |
|
||||
| redis: EVALSHA with EVAL fallback for the fixed-window rate-limit script | INTEGRATE | go-redis NewScript and Run; probed on Redis 7.2.5 |
|
||||
| redis: Sentinel and Cluster clients | OPT-OUT | not needed yet: one redis.url names one server; a later phase adds them if a deployment needs them |
|
||||
| redis: pub/sub, streams and locks | OPT-OUT | explicitly out of scope: D-08 excludes tags and locks until a ported plugin needs them |
|
||||
| redis: Redis-backed bouncer.BlacklistStore | OPT-OUT | deferred in CONTEXT (optional, not required by the success criteria) |
|
||||
| s3: open s3://bucket through gocloud URL query (region, endpoint, use_path_style, disable_https) | INTEGRATE | D-14; no parallel s3.* key set |
|
||||
| s3: request_checksum_calculation and response_checksum_validation query parameters | INTEGRATE | passed through gocloud for S3-compatible stores (R2, MinIO, Hetzner); documented |
|
||||
| s3: credentials from the standard AWS environment and shared config (profile, role) | INTEGRATE | D-14; never in summer config |
|
||||
| s3: server-side encryption (ssetype, kmskeyid) and accelerate, dualstack, fips | INTEGRATE | native gocloud URL parameters; no framework code, documented only |
|
||||
| s3: object write, read, delete and list through blob.Bucket | INTEGRATE | the existing attach paths (Store, StaticHandler, DeleteKeys) run unchanged on the s3 driver |
|
||||
| s3: public object URLs through storage.uploads.public_url | INTEGRATE | D-12 |
|
||||
| s3: presigned GET URLs | OPT-OUT | not needed: public files use public_url or the /storage/uploads proxy route (D-12) |
|
||||
| s3: bucket creation and lifecycle or CORS configuration | OPT-OUT | operator-managed outside the application |
|
||||
Reference in New Issue
Block a user