diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md new file mode 100644 index 0000000..4d90e5f --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-RESEARCH.md @@ -0,0 +1,676 @@ +# Phase 19.3: Framework transport and infrastructure for WavePath - Research + +**Researched:** 2026-10-15 +**Domain:** Go HTTP transport (cookies, CORS), Redis cache and distributed rate limiting, gocloud blob/S3, OpenAPI page, Cloudflare Turnstile, cross-repo site-cache purge +**Confidence:** HIGH for the in-repo seams and dependency versions; MEDIUM for the S3-compatible-provider and Nuxt-layer details + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions + +#### Cookie transport (bouncer) +- **D-01:** `bouncer` ships config-driven cookie builders (`TokenCookie` and `ForgetTokenCookie` or similar) and an optional ready-made cookie refresh/logout handler. The handler reads the token cookie, calls the existing `Refresh*` path (which already rotates the token and blacklists the old JTI) and sets the rotated cookie. The app supplies a writer func so it keeps its own response envelope. The shared `sm-user-plugin` is NOT changed to set cookies; apps opt in. +- **D-02:** Cookie attributes live in one framework block, `auth.cookie.*`: `name`, `domain`, `secure`, `same_site`, `path`. Cookie Max-Age comes from the guard's `refresh_ttl`, not its `ttl`: the cookie must outlive the access token, or refresh has no cookie to read (PHP comment in `AuthApiController::tokenCookie`). The existing per-guard `ttl`/`refresh_ttl` keys (`golem15.user.jwt.*`, `admin.jwt.*`) stay where they are. — **Reversibility:** costly — apps' config files and deploy docs reference these keys once they ship. +- **D-03:** Defaults when unset match PHP: name `token`, path `/`, httpOnly, `SameSite=Lax`, no Domain (host-only), Secure when the app URL is https, Max-Age = refresh window (20160 min default). + +#### Credentialed CORS (surf) +- **D-04:** Harden the existing path-scoped `http.cors` (`surf.CORSConfig.SupportsCredentials`): + - Refuse, or at least loudly reject at boot, credentials combined with a `*` origin. + - Always echo the exact origin, and set `Vary: Origin` on preflight too. + - Test the api ↔ www subdomain case. +- **D-05:** Also add a named CORS middleware (for example `cors:`) so a route group can carry its own CORS policy on top of the global path-scoped one. + +#### Cache and rate-limit store (new module `cooler`) +- **D-06:** New module **`cooler`** for the cache. The name comes from the research: `.planning/research/ARCHITECTURE.md` lists `cooler` for "in-process cache, rate-limit buckets", and `IDEA_LIB_NAMES.md` has `summer-cooler`. It gets a beach-name README and a row in the root table. +- **D-07:** Redis client is **go-redis v9** (`github.com/redis/go-redis/v9`). A decision note under `.planning/notes/` names it before code lands. The memory driver is otter v2 (research/go-ecosystem.md) or a stdlib map; the researcher picks one and the note records it. — **Reversibility:** costly — every Redis-touching module and app config builds on this client. +- **D-08:** The cache API is a Laravel-core subset: Get, Set, Has, Forget, Remember, Increment and Flush, with TTL and a per-app key prefix. No tags. An atomic lock only if a 19.x port is shown to need it. +- **D-09:** Selection uses one shared Redis block, `redis.url`, plus `cache.driver: memory|redis` and `http.rate_limit.store: memory|redis`. The framework wires everything at boot, so apps import nothing. `cooler` implements `surf.Store` for Redis (an atomic fixed-window script with the same semantics as `surf.MemoryStore`), so `surf` takes no Redis dependency. + +#### Site-cache purge (folded todo, in `cooler`) +- **D-10:** A framework purge service in `cooler`: + - One configured target: `site_cache.purge_url` and `site_cache.purge_token`. + - A loopback POST with a bearer token, sent after a committed admin save. + - Failures are logged and never fail the save. +- **D-11:** Plugin adoption is in this phase: + - Journal moves onto the service. Its current `golem15.journal.site_cache.*` keys stay as aliases for one release. + - The menu plugin purges after a menu or menu-entry save, reorder or delete. The FAQ plugin purges after a FAQ save or delete. + - The Nuxt purge route moves out of `vue-journal-layer` to a shared place, so a site without Journal still gets purges. + - Plugin repos are committed and pushed first; pointer bumps in apps are separate commits. + - Core plugins must not break: the alias keys keep existing Journal deployments working. + +#### S3 and uploads (lagoon/attach) +- **D-12:** New key `storage.uploads.public_url`. When set (a bucket or CDN base), `File` URLs are that base plus the partition path. When unset, files go through the existing `/storage/uploads` proxy route, which works for S3 too. +- **D-13:** The S3 driver (`gocloud.dev/blob/s3blob`) is opt-in through a **build tag**. `summer build` sets the tag from `summer.yaml`, so an app still never imports the driver itself. A `bucket_url: s3://…` in a binary built without the tag must fail at boot with an error that names the tag. +- **D-14:** S3-compatible endpoints (MinIO, R2, Hetzner) are configured through gocloud's native `bucket_url` query (`region`, `endpoint`, `use_path_style`). Credentials come from the standard AWS env vars. No parallel `s3.*` key set. +- **D-15:** Folded stored-XSS fix: `attach.Store` takes the disk-name extension from the sniffed MIME type, through one shared helper. The client name stays only in `file_name`. A regression test checks that a PNG uploaded as `x.html` gets a `.png` disk name and URL. The deploy docs gain a note on nosniff and a file-type allow-list. App call sites (fonoteka `storeUpload`/`StorePublicFile`) move to the helper in a follow-up, not in this phase. + +#### API docs (surf) +- **D-16:** The spec is supplied by an app or plugin: an embedded YAML or JSON file, converted to JSON once and cached. This matches PHP, which serves a hand-written YAML covering only the public surface. Nothing is generated from swag here. +- **D-17:** Routes are opt-in through config (`http.api_docs.enabled` plus the path), off by default. `/api-docs` serves a Swagger UI page from pinned jsDelivr `swagger-ui-dist@5` with SRI, like PHP. `/api-docs.json` serves the spec. Both are public and unauthenticated. + +#### Turnstile (bouncer) +- **D-18:** `bouncer` gets a Turnstile verifier, `Verify(ctx, token, ip, failClosed)`, with a 5 s timeout, plus a named middleware factory such as `turnstile:open|closed`. Semantics follow PHP `TurnstileVerifier`: + - Fail-open: an empty secret passes (dev no-op, the registration path). + - Fail-closed: 503 when unconfigured in production, 422 on a missing token, a siteverify false, an exception or a timeout (the guest entry path). + - Error bodies come from a writer func, so the app keeps the PHP shape. The secret is read from config, which maps the `TURNSTILE_*` env. + +#### Module placement +- **D-19:** `cooler` holds the cache, the Redis `surf.Store` adapter and the site-cache purge. `surf` holds `/api-docs` and the named CORS middleware. `bouncer` holds the cookie helpers and handler, and Turnstile. `lagoon/attach` holds the S3 tag, `public_url` and the extension fix. + +### Claude's Discretion +- The exact Go identifiers, the handler signatures and the name of the named-CORS-middleware profile config. +- otter vs a stdlib map for the memory cache driver (recorded in the decision note). +- Whether the token cookie builder lives on a guard or as free functions. +- The exact build-tag name and how `summer.yaml` declares it. + +### Deferred Ideas (OUT OF SCOPE) +- Switching fonoteka.go's `storeUpload`/`StorePublicFile` to the new extension helper: a follow-up quick task in fonoteka.go. +- Cache tags and atomic locks: add them only when a ported plugin needs them. +- Generating `/api-docs` specs from swag annotations for frontend routes: later, if wanted. +- A Redis-backed `bouncer.BlacklistStore`: optional, not required by the criteria. + +Reviewed todos not folded: the WinterCMS-vs-SummerCMS benchmark, and porting the FAQ plugin (already its own repo). + + + +## Phase Requirements + +No requirement IDs are mapped (ROADMAP: `**Requirements**: TBD`). The success criteria and decisions stand in for them: + +| ID | Description | Research Support | +|----|-------------|------------------| +| SC-1 | bouncer refresh issues a configurable httpOnly `token` cookie and blacklists the old JTI; TTL/refresh window are config keys; credentialed CORS middleware | §Cookie transport, §CORS hardening, §Named CORS profiles; Pitfalls 1-4 | +| SC-2 | cooler with memory and Redis drivers; Redis-backed `surf.Store`; decision note first | §Standard Stack, §Redis fixed-window script (probed against Redis 7.2.5), §Boot wiring without import cycles | +| SC-3 | S3 via config (build tag), `/api-docs` + `/api-docs.json`, Turnstile middleware | §S3 build tag, §API docs, §Turnstile | +| SC-4 | READMEs and docs/ updated; unit tests in the last plan | §Docs checker constraints, §Validation Architecture | +| D-10/D-11 | Site-cache purge service and plugin/layer adoption | §Site-cache purge, Pitfall 9 (no reorder hook in cabana), Pitfall 10 (Nitro route cache) | +| D-15 | Disk-name extension from the sniffed type | §Extension helper (DetectContentType probe), Pitfall 8 | + + +## Project Constraints (from CLAUDE.md) + +- One Go module `git.golem15.com/golem15/summercms`; a new module (`cooler`) gets a beach name, a README in the standard structure (H1, one-sentence summary, import line, `**Name:**` mnemonic, Overview, Features, Usage, API reference, optional Configuration/CLI, Dependencies, Testing) and a row in the root `README.md` table whose purpose text reuses the summary sentence. +- **A new dependency needs a research doc or a decision note naming it** (`.planning/research/STACK.md` holds the decided set). go-redis, otter and (test-only) miniredis each need to be named in a note before code lands. `gocloud.dev/blob/s3blob` is a sub-package of the already-required `gocloud.dev v0.46.0`, but it pulls the AWS SDK v2 into `go.sum`; name it in the same note. +- YAML is parsed with `goccy/go-yaml`, never `gopkg.in/yaml.v3`. +- Standard library first. +- A change to a module's exported API, config keys, CLI commands or dependencies updates that module's README and the affected `docs/` pages **in the same commit**. Every identifier in a README or docs page must exist in the package (the docs checker enforces it); config keys are reviewed by hand. +- Framework code never names a consuming application (READMEs, docs, examples use neutral names such as `blog` or `acme`). The docs checker's forbidden pattern covers only two names (see §Docs checker); "WavePath" is not covered, so review by hand. +- Keep `go vet ./...` and `go test ./...` green at every commit. Docker tests skip under `-short`; a `TestMain` that starts containers calls `flag.Parse()` and checks `testing.Short()`. +- Lean planning: few, large plans. Present the plan count with one-line scopes and wait for confirmation before writing PLAN.md files. +- **Unit tests are the last plan of the phase.** Earlier plans may carry smoke tests. +- STATE.md updates go through `msd_run query state.*` handlers; g15office gets a comment when the phase advances. +- No co-author tags; one logical change per commit; planning docs and code in separate commits; stage files by explicit path; check for other sessions' in-flight edits before touching shared files such as cabana (phase 18.1 is executing in parallel). +- Sibling plugin repos: commit and push the plugin first, then the pointer bump in the app as a separate commit. Never stage files inside a submodule from the app repo. Don't push, tag or deploy without the user's go-ahead. +- User global rule: core plugins (user, blog/journal, pages, payment) must not get breaking changes unless asked. This is why D-11 keeps the Journal alias keys. + +## Summary + +Every piece of this phase lands on an existing, well-defined seam, so most of the research is about wiring rather than new technology. `surf.Store` is a three-value interface with no context and no error (`Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration)`), so a Redis implementation in `cooler` satisfies it structurally without importing `surf`. That lets `surf` import `cooler` (not the reverse) and keeps the dependency graph acyclic. The fixed-window Lua script below was run against the local Redis 7.2.5 and reproduces `MemoryStore` exactly: the first attempt opens the window, a denied attempt does not increment, and the window never extends. + +The biggest traps are not in the libraries. First, `surf.BuildRouter` hard-codes `NewMemoryStore` and is also called by `route:list`, so the Redis store must be published before `serve` builds the router, and `route:list` must never dial Redis. Second, `cabana`'s reorder POST runs no controller hook today, so "menu purges after reorder" (D-11) needs a new optional `pact` hook. Third, route-level `cors:` middleware never sees a preflight, because the stdlib ServeMux answers `OPTIONS` on a `GET`-only pattern with 405 before any middleware runs; the router has to register `OPTIONS` routes for profiled routes. Fourth, every app's `scripts/build.sh` runs `summer build` and then a second, raw `go build` for linux/amd64, which drops any tag `summer build` set. The D-13 boot error is what catches that. + +PHP parity notes that change the plan: the PHP `/api-docs` page does **not** use SRI and loads the floating `swagger-ui-dist@5`, so "with SRI, like PHP" requires pinning an exact version (5.33.1 is recommended; the hashes are below). PHP's php-cors v1.4.0 reflects **any** origin when `*` is combined with credentials, which is exactly what D-04 forbids, so Go diverges from PHP deliberately. Turnstile's PHP "fail-open" mode only skips verification when the secret is empty. With a secret set, both modes reject a `success:false`. + +**Primary recommendation:** Build `cooler` first (decision note, then the cache with an otter v2 memory driver, the go-redis v9.23.0 driver, the Redis `surf.Store` and the `serve`-time wiring via `app.Lookup[surf.Store]`). Then the transport pieces in `bouncer` and `surf`, the attach S3/extension work, and the purge adoption across repos, with unit tests as the last plan. + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| Token cookie build/clear, refresh/logout handler | API / Backend (`bouncer`) | Browser (cookie jar) | Cookie attributes are server-decided; the handler wraps `bouncer.Refresh*` | +| Credentialed CORS (global + profiles) | API / Backend (`surf`) | Browser (enforces) | Outermost HTTP wrapper; preflights answered before routing | +| Cache (memory/Redis) | API / Backend (`cooler`) | Database/Storage (Redis) | Shared state for multi-instance apps | +| Distributed rate limit | API / Backend (`surf` limiter) | Database/Storage (Redis via `cooler`) | `surf` keeps the policy; `cooler` owns the atomic counter | +| Site-cache purge | API / Backend (`cooler` service, plugin hooks) | Frontend Server (Nuxt/Nitro purge route) | After-commit POST from Go to the SSR server on loopback | +| S3 uploads, `public_url` | Database/Storage (`lagoon/attach`, bucket) | CDN / Static | Bucket choice is boot config; URL base may be a CDN | +| Disk-name extension (XSS) | API / Backend (`attach.Store`) | CDN / Static (nginx type mapping) | The extension decides the Content-Type a static server sends | +| `/api-docs` page and JSON | API / Backend (`surf`) | CDN (jsDelivr assets with SRI) | Server renders the shell; the browser loads pinned assets | +| Turnstile verification | API / Backend (`bouncer` verifier, `surf` factory) | External (Cloudflare siteverify) | Secret never leaves the server | + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `github.com/redis/go-redis/v9` | **v9.23.0** (2026-10-05) [VERIFIED: proxy.golang.org @latest] | Redis client for `cooler` | Locked by D-07; official Redis client; `go 1.26.0` in its go.mod, fine for the project's Go 1.27 [VERIFIED: proxy.golang.org .mod] | +| `github.com/maypok86/otter/v2` | **v2.3.0** (2025-12-22) [VERIFIED: proxy.golang.org @latest] | Memory cache driver | Named in `.planning/research/ARCHITECTURE.md:73` (`cooler` row: `otter`) and `go-ecosystem.md:29`; recommendation reasoning below | +| `gocloud.dev/blob/s3blob` | part of `gocloud.dev v0.46.0` (already in go.mod) [VERIFIED: go.mod] | S3 driver behind a build tag | Locked by D-13/D-14; `Scheme = "s3"` registered in `init()` [VERIFIED: gocloud.dev@v0.46.0/blob/s3blob/s3blob.go:87-110] | +| `github.com/goccy/go-yaml` | v1.19.2 (already in go.mod) | YAML→JSON for the api-docs spec | Project rule; `yaml.YAMLToJSON` uses `UseOrderedMap()`, so key order survives [VERIFIED: goccy/go-yaml@v1.19.2/yaml.go:240-251] | +| stdlib `net/http` | Go 1.27 | Cookies (`http.Cookie`, `(*http.Cookie).Valid`), CORS, Turnstile client | Standard library first | + +### Supporting (test only) +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `github.com/alicebob/miniredis/v2` | v2.40.0 (2026-10-10) [VERIFIED: proxy.golang.org]; name from training [ASSUMED provenance] | In-process Redis for `-short` unit tests | Supports EVAL/EVALSHA/SCRIPT LOAD, PTTL, SET, INCR/INCRBY, SCAN, UNLINK, and `FastForward` for TTLs [VERIFIED: miniredis@v2.40.0/README.md:47-95,221-225,253]; one dep (`gopher-lua`) | +| `github.com/testcontainers/testcontainers-go` | v0.44.0 (already required) | Docker-gated real-Redis test | Use the generic `testcontainers.Run(ctx, "redis:7-alpine", …)` as `modules/postcard/mailpit_test.go:47-58` does. No `modules/redis` sub-module is needed | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| otter v2 | stdlib `map` + mutex + sweeper | ~100 lines, no dependency, but **unbounded**: a key-per-client cache (`Remember("user:"+id)`) grows forever; adding LRU bounds is a hand-rolled cache. otter gives a size bound (`MaximumSize`), per-entry TTL (`ExpiryCalculator`), atomic `Compute` for Increment and an injectable `Clock` for tests, with no runtime dependencies (go.mod requires only testify, for its own tests) [VERIFIED: otter@v2.3.0 go.mod, options.go:71-154, cache.go:157-224] | +| miniredis for unit tests | testcontainers Redis only | Real Redis is authoritative but needs Docker (skipped under `-short`); use both: miniredis for the fast loop, one Docker test that runs the same script on real Redis | +| Build tag for S3 (locked) | Generated blank import in `plugins.gen.go` | A generated import survives the apps' second raw `go build`; a tag does not (Pitfall 6). D-13 locks the tag, so keep the boot-time error as the safety net | + +**Installation (framework module):** +```bash +go get github.com/redis/go-redis/v9@v9.23.0 github.com/maypok86/otter/v2@v2.3.0 +go get github.com/alicebob/miniredis/v2@v2.40.0 # test-only, after its decision note +go mod tidy # also pulls aws-sdk-go-v2 into go.sum once the tagged s3blob import exists +``` + +**Memory-driver recommendation for the decision note (D-07 discretion): otter v2.** Reasons: bounded memory (DoS-safe when keys derive from request data), per-entry TTL including "forever" (otter's docs: "To indicate no expiration, an entry may be given an excessively long period" [VERIFIED: otter@v2.3.0/expiry_calculator.go:23-37]), lock-scoped `Compute` for an atomic Increment, a testable `Clock`, and the framework's own architecture research already names it. + +## Package Legitimacy Audit + +The `package-legitimacy` seam supports npm/pypi/crates only; Go modules were checked against proxy.golang.org (existence, publish time, VCS origin). + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| github.com/redis/go-redis/v9 v9.23.0 | proxy.golang.org | v9.23.0 10 days; module years | n/a (Go) | github.com/redis/go-redis (proxy Origin) | OK (manual) | Approved; named in CONTEXT D-07 and project research | +| github.com/maypok86/otter/v2 v2.3.0 | proxy.golang.org | ~10 months | n/a | github.com/maypok86/otter | OK (manual) | Approved; named in project research | +| github.com/alicebob/miniredis/v2 v2.40.0 | proxy.golang.org | 5 days (module years old) | n/a | github.com/alicebob/miniredis | OK (manual), name `[ASSUMED]` | Test-only; needs a decision note, and the planner adds `checkpoint:human-verify` before `go get` | +| swagger-ui-dist (CDN asset, not installed) | npm | 5.33.2: 3 days | 13.5M/wk | github.com/swagger-api/swagger-ui | **SUS** (`too-new`, for 5.33.2) | Pin **5.33.1** (published 2026-10-01) instead; jsDelivr bytes match the npm tarball (checked) | + +**Packages removed due to [SLOP] verdict:** none. (The seam reports `SLOP` for `swagger-ui-dist@5.33.1` only because it treats `name@version` as a package name; the package-level check is `SUS: too-new`, which applies to the 3-day-old latest.) +**Packages flagged as suspicious [SUS]:** swagger-ui-dist latest (5.33.2). Avoided by pinning 5.33.1. + +## Architecture Patterns + +### System Architecture Diagram + +``` + ┌──────────── summer serve (surf.ServeCommand) ───────────────┐ + config (compass) ──► │ lagoon.Open → attach.OpenBucket(s3:// needs tag) → cooler. │ + redis.url, cache.*, │ Install(app): parse redis.url, PING (timeout) → publish │ + http.rate_limit.store │ cooler.Cache, *cooler.SiteCache, and (store=redis) │ + │ surf.Store ← cooler.NewRateLimitStore(client, memFallback)│ + │ BuildRouter: store := app.Lookup[surf.Store] else Memory │ + └─────────────────────────────┬───────────────────────────────┘ + ▼ + HTTP request ─► accessLog ─► stripLocale ─► pathScopedCORS (global http.cors; answers + OPTIONS on CORS paths, Vary: Origin) ─► ServeMux ─► per-route chain: + recordPattern → recover → locale → bodyLimit → [named mw: cors:, + turnstile:open|closed, throttle:… → surf.Store.Attempt ──► Redis EVALSHA + (fallback: per-instance MemoryStore on error)] → handler + │ + cookie refresh: POST /…/auth/refresh ─► bouncer.CookieRefreshHandler: read + auth.cookie.name → app Refresh func (bouncer.Refresh*: rotate + blacklist JTI) + → Set-Cookie (Max-Age = refresh_ttl) → app writer (PHP envelope) + │ + admin save (cabana tx) ─► FormAfter{Create,Update,Delete} / new reorder hook ─► + cooler.PurgeAfterCommit ─► lagoon.AfterCommit ─► POST loopback + /api/__site__/cache/purge (Bearer) ─► Nitro: remove nitro:routes:* keys + │ + GET /api-docs ─► HTML shell (jsDelivr swagger-ui-dist@5.33.1 + SRI) ─► GET /api-docs.json + ─► spec bytes (provider YAML→JSON once, cached) +``` + +### Recommended Project Structure +``` +modules/cooler/ +├── README.md # beach name, summary, Configuration (redis.url, cache.*, site_cache.*) +├── cooler.go # Cache interface, Install(ctx, app), FromApp(app), key prefixing +├── memory.go # otter v2 driver ([]byte values, per-entry TTL) +├── redis.go # go-redis client from redis.url (no URL in errors) +├── ratelimit.go # Redis fixed-window Attempt (Lua) + memory fallback +├── sitecache.go # SiteCachePurger, PurgeAfterCommit (moved from the Journal plugin) +└── *_test.go # miniredis (-short) + Docker real Redis +modules/bouncer/cookie.go # CookieConfig, LoadCookieConfig, TokenCookie/ForgetTokenCookie, handlers +modules/bouncer/turnstile.go # Turnstile verifier (Verify) + Middleware(mode, ip, writer) +modules/surf/cors.go # hardening + profile middleware + OPTIONS registration +modules/surf/apidocs.go # APIDocsProvider, mount behind http.api_docs.enabled +modules/lagoon/attach/s3_on.go / s3_off.go # //go:build summercms_s3 / !summercms_s3 +modules/lagoon/attach/diskext.go # DiskExtension(contentType, clientExt) +internal/build/manifest.go # summer.yaml build.tags (load + SaveManifest round-trip) +``` + +### Pattern 1: Boot wiring without an import cycle +**What:** `cooler` implements `surf.Store` structurally; `surf` imports `cooler`; `serve` publishes the store under the interface type before `BuildRouter`. +**Evidence:** `surf.Store` [VERIFIED: modules/surf/limiter_store.go:11-13]: +```go +type Store interface { + Attempt(key string, max int, decay time.Duration) (allowed bool, attempts int, retryAfter time.Duration) +} +``` +`BuildRouter` today [VERIFIED: modules/surf/router.go:475-477]: +```go + // longest bucket decay is 1 minute; sweep at 2x + lim := NewFixedWindowLimiter(NewMemoryStore(2*time.Minute), trusted) + r.limiter = lim +``` +`app.Publish[T]` keys the registry by `reflect.TypeFor[T]()` [VERIFIED: modules/backpack/services.go:22-48], so the store must be published as `app.Publish[surf.Store](…)`, which only `surf` (or `main`) can write. Import graph today: `lagoon` depends on compass, festival, backpack, bonfire, lagoon/attach, pact, towel, phrasebook, postcard, signpost and party (not surf), and `bouncer` depends on nothing in the framework [VERIFIED: `go list -deps`], so `surf → cooler → {lagoon, compass, backpack, go-redis}` is acyclic. +**Example:** +```go +// modules/surf/serve.go, inside ServeCommand Run, after publishUploads: +c, err := cooler.Install(ctx, app) // parses redis.url, pings when a redis driver is selected, publishes cooler.Cache +if err != nil { return err } +defer c.Close() +if c.RateLimitRedis() { // http.rate_limit.store == "redis" + if err := app.Publish[Store](c.RateLimitStore(NewMemoryStore(2 * time.Minute))); err != nil { return err } +} +r, err := BuildRouter(app, plugins) + +// modules/surf/router.go, BuildRouter: +var store Store +if app != nil { + if s, ok := app.Lookup[Store](); ok && s != nil { store = s } +} +if store == nil { store = NewMemoryStore(2 * time.Minute) } +lim := NewFixedWindowLimiter(store, trusted) +``` +`route:list` calls only `BuildRouter`, so it keeps the memory store and never touches Redis. Note `conga`'s dedicated worker command does not go through `serve`; a job that needs the cache should call `cooler.FromApp(app)`, which constructs lazily (go-redis `NewClient` does not dial until the first command). + +### Pattern 2: Redis fixed-window script with MemoryStore semantics +**Semantics to match** [VERIFIED: modules/surf/limiter_store.go:68-93]: an expired or missing entry resets to `count: 0, resetAt: now.Add(decay)`; `retryAfter = resetAt - now` (floored at 0); `if e.count >= max { return false, e.count, retryAfter }`; otherwise `e.count++` and allow. Expiry is lazy and the window never extends. + +```lua +-- KEYS[1] = rl:; ARGV[1] = max; ARGV[2] = decay in ms (>= 1) +local max = tonumber(ARGV[1]) +local decay = tonumber(ARGV[2]) +local count = tonumber(redis.call('GET', KEYS[1]) or '0') +local ttl = redis.call('PTTL', KEYS[1]) +if ttl < 0 then -- -2 missing, -1 no expiry: open a fresh window + count = 0 + redis.call('SET', KEYS[1], 0, 'PX', decay) + ttl = decay +end +if count >= max then + return {0, count, ttl} -- denied: count unchanged +end +count = redis.call('INCR', KEYS[1]) -- INCR keeps the TTL +return {1, count, ttl} +``` +Probe on the local Redis 7.2.5 with max=3, decay=2000 ms [VERIFIED: redis-cli --eval, this session]: +``` +1 1 2000 +1 2 1957 +1 3 1952 +0 3 1945 <- denied, count stays 3 +TTL=1939 GET=3 +1 1 2000 <- after 2.1 s: new window +``` +Go side: `var attemptScript = redis.NewScript(src)`; `res, err := attemptScript.Run(ctx, client, []string{key}, max, ms).Int64Slice()` (EVALSHA with EVAL fallback) [VERIFIED: go-redis@v9.23.0/script.go:34,193 and command.go:887]. Use `context.WithTimeout(context.Background(), ~500ms)` because `Attempt` has no ctx. Clamp `decay.Milliseconds()` to at least 1, or `SET PX 0` errors. Redis uses its own clock, so instance clock skew does not matter. + +### Pattern 3: Cookie transport in bouncer (D-01..D-03) +PHP reference [VERIFIED: wavepath.org …/AuthApiController.php:342-381]: `cookie(self::COOKIE_NAME, $token, $minutes = config('jwt.refresh_ttl', 20160), '/', domain, secure, true /*httpOnly*/, false, 'lax')`; forget = the same attributes, empty value and far-past expiry. Domain is `WAVEPATH_COOKIE_DOMAIN` or null; Secure is true in production or on a secure request [VERIFIED: …/CookieAttributes.php]. The `token` cookie is **unencrypted** (`config/cookie.php` `unencryptedCookies` lists `'token'`) [VERIFIED: wavepath.org config/cookie.php:17-22], so Go can read cookies the PHP app issued. User-plugin defaults: `ttl: 60`, `refresh_ttl: 20160`, `blacklist_grace: 0` [VERIFIED: fonoteka.go/plugins/golem15/user/config/config.yaml]. +```go +// bouncer/cookie.go (identifiers are Claude's discretion) +type CookieConfig struct { + Name string // auth.cookie.name, default "token" + Domain string // auth.cookie.domain, default "" (host-only) + Path string // auth.cookie.path, default "/" + Secure bool // auth.cookie.secure, default: app.url scheme == https + SameSite http.SameSite // auth.cookie.same_site: lax|strict|none, default lax +} +func (c CookieConfig) TokenCookie(token string, refreshTTL time.Duration) *http.Cookie { + return &http.Cookie{Name: c.Name, Value: token, Path: c.Path, Domain: c.Domain, + MaxAge: int(refreshTTL / time.Second), Expires: time.Now().Add(refreshTTL), + HttpOnly: true, Secure: c.Secure, SameSite: c.SameSite} +} +func (c CookieConfig) ForgetTokenCookie() *http.Cookie { /* same attrs, Value "", MaxAge -1, Expires Unix(0) */ } + +// Handler: app supplies Refresh (choose Refresh / RefreshAudienceFor) and the envelope writer. +type CookieRefreshOptions struct { + Cookie CookieConfig + RefreshTTL time.Duration + Refresh func(ctx context.Context, token string) (string, error) + Write func(w http.ResponseWriter, r *http.Request, status int, err error) +} +``` +Validate at boot with `(*http.Cookie).Valid()`, which rejects an invalid Domain [VERIFIED: GOROOT net/http/cookie.go:349-353]. Reject `same_site: none` without Secure, and refuse `secure: false` in production, mirroring `adminCookieSecure` [VERIFIED: modules/cabana/auth.go:503-517]. Production is `app.Config.Environment() == "production"` (`SUMMER_ENV`) [VERIFIED: modules/compass/config.go:75-83]. + +### Pattern 4: CORS hardening (D-04) and profiles (D-05) +Current keys [VERIFIED: modules/surf/cors.go:13-22]: `paths`, `allowed_methods`, `allowed_origins`, `allowed_origins_patterns`, `allowed_headers`, `exposed_headers`, `max_age`, `supports_credentials`. Today `corsAllowOrigin` returns `"*"` when any origin is allowed, even with credentials [VERIFIED: cors.go:134-137]; `Vary: Origin` is only set when an origin is allowed and the value is not `*` [cors.go:71-75]; invalid `allowed_origins_patterns` are silently skipped [cors.go:44-51]; and wildcard entries in `allowed_origins` such as `https://*.example.com` are treated as literals. php-cors v1.4.0 converts them to patterns [VERIFIED: wavepath vendor/fruitcake/php-cors/src/CorsService.php:100-131]. + +PHP behaviour [VERIFIED: CorsService.php:204-220]: `*` without credentials sends `*`; a single listed origin sends that origin unconditionally; otherwise it echoes an allowed origin and **always** adds `Vary: Origin`; `*` **with** credentials reflects every origin. Recommended Go rules: +1. Boot error: `supports_credentials: true` with `*` in `allowed_origins` (global and every profile). +2. Boot error: an `allowed_origins_patterns` entry that does not compile. Also error on a PHP-delimited pattern such as `#^https://…$#`, since Go would compile it as literal `#` characters. +3. Translate `*` inside an `allowed_origins` entry into an anchored pattern (php-cors `convertWildcardToPattern`). +4. When the answer is dynamic (credentials, patterns or more than one origin), add `Vary: Origin` to **every** response on a CORS path, preflight and disallowed origin included. Keep the current `*` non-credentialed behaviour byte-identical: all four apps run `allowed_origins: ["*"]` without credentials, and `TestCORSOptionsMatchesLaravel` pins the preflight headers [VERIFIED: modules/surf/cors_coverage_test.go:99-147]. + +Profiles: config `http.cors_profiles.` with the `CORSConfig` keys minus `paths`. Factory `cors` registered in `BuildRouter` like `throttle`. Because `ServeMux` answers `OPTIONS` on a `GET`/`POST` pattern with 405 before route middleware, `Router.compile` must register `OPTIONS ` for every route whose chain contains `cors:

`, with a handler that answers the preflight from the profile. Boot error when such a route's path also matches the global `http.cors.paths` (the global wrapper would answer the preflight first). + +### Pattern 5: S3 build tag (D-13/D-14) +```go +// modules/lagoon/attach/s3_on.go +//go:build summercms_s3 +package attach +import _ "gocloud.dev/blob/s3blob" +const s3Enabled = true + +// modules/lagoon/attach/s3_off.go +//go:build !summercms_s3 +package attach +const s3Enabled = false + +// in OpenBucket, before blob.OpenBucket: +if strings.EqualFold(u.Scheme, "s3") && !s3Enabled { + return nil, fmt.Errorf("attach: storage.uploads.bucket_url uses s3:// but this binary was built without the summercms_s3 build tag; add it under build.tags in summer.yaml and rebuild (raw go build needs -tags summercms_s3)") +} +``` +`normalizeBucketURL` already passes unknown schemes through unchanged (`default: return raw, nil`) [VERIFIED: modules/lagoon/attach/bucket.go:42-57]. `summer build` runs `exec.CommandContext(ctx, "go", "build", "-o", binRel, ".")` [VERIFIED: internal/build/build.go:49]; add `-tags=` from the manifest. Recommended `summer.yaml` shape (discretion): +```yaml +build: + tags: + - summercms_s3 +``` +Validate each tag against `^[a-z0-9_]+$`. `SaveManifest` (used by `plugin:add`) rewrites only `module`, `binary` and `plugins` [VERIFIED: internal/build/manifest.go:90-103], so it **must** round-trip `build.tags` or `summer plugin:add` silently drops S3. `dev` watch reuses `build.App` [VERIFIED: internal/dev/watch.go:45-46], so it inherits the tags. + +S3 URL query (gocloud v0.46.0) [VERIFIED: gocloud.dev@v0.46.0/blob/s3blob/s3blob.go:118-136, aws/aws.go:70-180]: s3blob-specific `ssetype`, `kmskeyid`, `accelerate`, `use_path_style` (legacy `s3ForcePathStyle`) and `disable_https`; AWS config `region`, `endpoint`, `hostname_immutable`, `profile`, `role`, `anonymous`, `dualstack`, `fips`, `rate_limiter_capacity`, `request_checksum_calculation`, `response_checksum_validation` (`when_supported|when_required`). Unknown params are an error. Example for an S3-compatible endpoint: +``` +s3://uploads?region=auto&endpoint=https://.r2.cloudflarestorage.com&use_path_style=true&request_checksum_calculation=when_required&response_checksum_validation=when_required +``` + +### Pattern 6: Disk-name extension helper (D-15) +`Store` derives the disk name from the client extension: `diskName, err := newDiskName(ext)` [VERIFIED: modules/lagoon/attach/store.go:151] after sniffing `contentType := baseMediaType(http.DetectContentType(head))` [store.go:141]. The defaults already reject `x.html` (`DefaultFileExtensions` has no html, svg or xml [store.go:54-59]), so the regression test must widen `Limits.Extensions` (for example `[]string{"html", "png"}`). DetectContentType probe (Go 1.27) [VERIFIED: go run, this session]: png→`image/png`, docx/zip→`application/zip`, mkv→`video/webm`, mp3(ID3)→`audio/mpeg`, txt/ics/svg/`hello