docs(19.3): add research and validation strategy
This commit is contained in:
@@ -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>
|
||||
## 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:<profile>`) 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).
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## 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 |
|
||||
</phase_requirements>
|
||||
|
||||
## 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:<profile>` 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:<profile>,
|
||||
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] = <prefix>rl:<bucket key>; 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.<name>` 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 <path>` for every route whose chain contains `cors:<p>`, 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=<joined>` 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://<account>.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 <script>`→`text/plain; charset=utf-8`, `<html>`→`text/html; charset=utf-8`, `<?xml`→`text/xml`, pdf→`application/pdf`, woff2→`font/woff2`, rar→`application/x-rar-compressed`, avif and short mp4 and OLE .doc→`application/octet-stream`. `mime.ExtensionsByType("image/jpeg")` returned `[.jfif .jpe .jpeg .jpg .pjp .pjpeg]` on this host: host-dependent, so do not use it.
|
||||
|
||||
Recommended rule:
|
||||
```go
|
||||
// DiskExtension picks the stored extension. clientExt is already lower-cased and allow-listed.
|
||||
// 1. A specific sniffed type (table: image/png→png, image/jpeg→{jpg,jpeg}, image/gif, image/webp,
|
||||
// application/pdf, audio/mpeg→mp3, video/webm→{webm,mkv}, font/woff2, application/x-rar-compressed→rar, …):
|
||||
// keep clientExt when it belongs to that type's set, else the type's canonical extension.
|
||||
// 2. A generic sniff (application/octet-stream, application/zip, text/plain, text/html, text/xml):
|
||||
// keep clientExt unless it is script-capable (html, htm, xhtml, xht, shtml, svg, svgz, xml, xsl,
|
||||
// xslt, js, mjs, css, swf, php, phtml): then "txt" for text/*, "bin" otherwise.
|
||||
func DiskExtension(contentType, clientExt string) string
|
||||
```
|
||||
The allow-list check stays on the client extension, so accepted uploads do not change; only the stored name and URL change for mismatched uploads, as the todo expects.
|
||||
|
||||
### Pattern 7: Turnstile (D-18)
|
||||
PHP [VERIFIED: wavepath.org …/TurnstileVerifier.php]: siteverify `https://challenges.cloudflare.com/turnstile/v0/siteverify`, form fields `secret`, `response`, `remoteip`. **Open mode:** an empty secret returns true; otherwise POST (no explicit timeout, exceptions not caught) and return `json('success') ?? false`. **Closed mode:** an empty secret returns `app.env !== 'production'`; an empty token returns false; POST with `timeout(5)` and `success === true`; any exception returns false. Controllers check `isConfiguredForGuests()` first and answer 503 `guest_unavailable`, then 422 `captcha_failed`; registration answers 422 `TURNSTILE_FAILED` [VERIFIED: GuestCallController.php:62-70, AuthApiController.php:68-72,182-189]. The token comes from the request field `turnstile_token` (`$request->get`). Cloudflare [CITED: developers.cloudflare.com/turnstile/get-started/server-side-validation/]: form or JSON accepted; tokens are at most 2048 chars, valid for 300 s and single-use (`timeout-or-duplicate`); optional `idempotency_key`. Test secrets [CITED: developers.cloudflare.com/turnstile/troubleshooting/testing/]: `1x0000000000000000000000000000000AA` passes, `2x0000000000000000000000000000000AA` fails, `3x0000000000000000000000000000000AA` returns timeout-or-duplicate, each with the dummy token `XXXX.DUMMY.TOKEN.XXXX`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
### Pattern 9: API docs (D-16/D-17)
|
||||
PHP serves `GET api-docs` (inline HTML) and `GET api-docs.json` (YAML parsed once, process-cached, `JSON_UNESCAPED_SLASHES`, 500 "API specification is unavailable." without leaking a path) [VERIFIED: wavepath.org …/ApiDocsController.php; routes.php:1086-1087]. PHP loads `https://cdn.jsdelivr.net/npm/swagger-ui-dist@5` **without** `integrity`. Go: a surf-defined optional interface (`surf.APIDocsProvider { APIDocsSpec() ([]byte, error) }`, type-asserted like `BucketProvider` [router.go:121-126]); boot error when `http.api_docs.enabled` is true and zero or more than one provider exists. Convert with `yaml.YAMLToJSON` then `json.Compact`, once at boot. Mount with `GroupRaw` like `mountUploads` [VERIFIED: modules/surf/uploads.go:148-170]. Config keys: `http.api_docs.enabled`, `http.api_docs.path` (default `/api-docs`; JSON at `<path>.json`), optional `http.api_docs.title`.
|
||||
|
||||
Pinned assets, swagger-ui-dist **5.33.1** [VERIFIED: npm pack tarball and jsDelivr bytes, sha384, this session]:
|
||||
```
|
||||
swagger-ui.css sha384-Ov4/wv3j2bmct8cDc5X4ngJZohVPzEmc6uDPH8WeljUxO5vtoykvMEfbu9Vh6RaW
|
||||
swagger-ui-bundle.js sha384-ZPehFMQommnnuaZ4rpxgkgTT2DKFVp4hZC/7pLit+9Lek9T1YGSo23eHFbvNkXkw
|
||||
swagger-ui-standalone-preset.js sha384-My2aDM4r2Mbm3ybHcubKm9O9U8FEjvF/O5nGvE9YK5dzqOTbWEKa79RPJ1krdMaF
|
||||
```
|
||||
(5.33.2 bundle is `sha384-gNnUp++d7WqR9OKX4OMoUmV2w0JxEs1uu6NPXgcynGClIX5DOu6ol4Lp+lINyZcJ`; its css and preset hashes equal 5.33.1's.) Use `<link … integrity="…" crossorigin="anonymous">` and `<script … integrity="…" crossorigin="anonymous">`, and pass the spec URL into `SwaggerUIBundle({url: …})` from config with `html/template` escaping.
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
- **`FLUSHDB` for `Cache.Flush`** (Laravel's Redis store does this): it wipes rate-limit counters and every other app on the same DB. Use `SCAN MATCH <prefix>cache:*` plus `UNLINK` in batches.
|
||||
- **Publishing the Redis store from `BuildRouter`**: `route:list` would dial Redis.
|
||||
- **Shared key space for cache and limiter**: use `<prefix>cache:` and `<prefix>rl:`.
|
||||
- **Route-level CORS without OPTIONS routes**: preflights get a 405.
|
||||
- **`mime.ExtensionsByType` for disk names**: host-dependent output.
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Bounded TTL cache | map + LRU + sweeper | otter v2 | Eviction, per-entry expiry and lock-scoped compute are subtle |
|
||||
| Redis protocol, pooling, scripts | raw RESP client | go-redis v9 `NewScript`/`Run` | EVALSHA/NOSCRIPT fallback, pooling, TLS (`rediss://`) |
|
||||
| S3 signing and endpoints | AWS SigV4 client | gocloud `s3blob` (tagged) | Checksums and path style are already URL params |
|
||||
| Cookie serialization | string building | `http.SetCookie` + `(*http.Cookie).Valid()` | Domain validation and attribute escaping |
|
||||
| YAML→JSON | custom walker | `goccy/go-yaml` `YAMLToJSON` | Keeps key order (`UseOrderedMap`) |
|
||||
| Atomic check-and-increment | GET then INCR from Go | single Lua script | Two round trips race across instances |
|
||||
| Site-cache purge HTTP | a new client per plugin | the existing Journal purger moved to `cooler` | Already hardened (loopback-only http, token length, no token in logs) |
|
||||
|
||||
**Key insight:** in this phase every library choice is small; the risk is in seams (boot order, ServeMux preflights, manifest round-trips, a second `go build`).
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: Go strips the leading dot of a cookie Domain and silently drops an invalid one
|
||||
**What goes wrong:** `Domain: ".example.com"` is written as `Domain=example.com`; an invalid domain is logged and dropped, which makes a host-only cookie, so refresh breaks across subdomains without any error [VERIFIED: GOROOT net/http/cookie.go:277-292].
|
||||
**How to avoid:** validate `auth.cookie.domain` at boot with `(*http.Cookie).Valid()`. Expect byte differences from PHP's `domain=.x` in parity fixtures (browsers treat both the same).
|
||||
|
||||
### Pitfall 2: Cookie Max-Age from `ttl` instead of `refresh_ttl`
|
||||
**What goes wrong:** the cookie dies with the access token and the SPA loops on re-login (PHP comment, AuthApiController.php:344-349). **How to avoid:** the builder takes `refreshTTL`, and a test asserts `MaxAge == refresh_ttl*60`.
|
||||
|
||||
### Pitfall 3: CORS `*` + credentials (php-cors reflects any origin)
|
||||
**How to avoid:** boot error (D-04). **Warning sign:** a config that "works" with credentials and `*` in PHP.
|
||||
|
||||
### Pitfall 4: Missing `Vary: Origin` on dynamic CORS answers
|
||||
**What goes wrong:** a shared cache stores a response without `Access-Control-Allow-Origin` (or with another origin) and serves it to a different origin. **How to avoid:** always `Vary: Origin` on CORS paths when the origin answer is dynamic, including preflight (D-04).
|
||||
|
||||
### Pitfall 5: `cors:<profile>` never sees preflights
|
||||
**What goes wrong:** ServeMux returns 405 for `OPTIONS` on `GET /x` before middleware [ASSUMED: Go 1.22+ ServeMux method-mismatch behaviour; confirm with a router test]. **How to avoid:** register `OPTIONS` routes for profiled routes, plus a boot check against overlap with global `http.cors.paths`.
|
||||
|
||||
### Pitfall 6: App build scripts drop the S3 tag
|
||||
**What goes wrong:** `scripts/build.sh` runs `summer build`, then `CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go -C "$ROOT" build -trimpath -o bin/grzybyfunkcjonalne .` [VERIFIED: sm-grzybyfunkcjonalne-app/scripts/build.sh:52], with no tags. **How to avoid:** the D-13 boot error names the tag; the docs tell raw `go build` users to add `-tags summercms_s3` (or `GOFLAGS=-tags=summercms_s3`). Optionally `summer build` can print the tag line.
|
||||
|
||||
### Pitfall 7: Redis URL credentials leak through errors
|
||||
**What goes wrong:** `url.Parse` errors echo the whole URL. Probe: `parse "redis://user:s3cretpw@host:6379/0\x7f": net/url: invalid control character in URL` [VERIFIED: go run, this session]. **How to avoid:** wrap as `cooler: redis.url is invalid` without `%w` of the raw error, or redact; never log the URL.
|
||||
|
||||
### Pitfall 8: Extension "fix" that breaks downloads
|
||||
**What goes wrong:** blindly mapping the sniffed type renames `.docx`/`.xlsx` to `.zip` and `.ics` to `.txt`. **How to avoid:** apply rule 2 of Pattern 6 (keep a consistent or non-script client extension for generic sniffs).
|
||||
|
||||
### Pitfall 9: cabana reorder runs no controller hook
|
||||
**What goes wrong:** `reorderPost` updates sort/parent columns with `UpdateColumns` inside a transaction and calls no `pact` hook [VERIFIED: modules/cabana/reorder.go:297-358], so menu-entry reorders cannot purge today. `deleteRecord` does run `pact.FormAfterDelete` (single and bulk) [VERIFIED: modules/cabana/crud.go:1025-1040, callers crud.go:274,328]. **How to avoid:** add an optional `pact` interface (for example `ReorderAfterSave(ctx context.Context) error`) called inside the reorder transaction (so `cabana.TxFromContext` works). That is a framework change in cabana/pact with README/docs updates; check for in-flight cabana edits first.
|
||||
|
||||
### Pitfall 10: The Nitro route cache swallows the purge route
|
||||
**What goes wrong:** with a site rule `'/**': { swr }`, Nitro wraps every matching handler in its cached handler, so the purge answers from cache [VERIFIED: vue-journal-layer/nuxt.config.ts:45-50 comment and rule `'/api/__journal__/**': { cache: false }`]. **How to avoid:** every layer that ships the shared route also ships its `cache: false` rule.
|
||||
|
||||
### Pitfall 11: `SaveManifest` drops new summer.yaml keys
|
||||
See Pattern 5. Add a round-trip test.
|
||||
|
||||
### Pitfall 12: The Turnstile middleware consumes the body
|
||||
**How to avoid:** buffer (already capped), parse, restore `r.Body`. Also the PHP guest path validates `name` before Turnstile; a middleware runs before the handler, so the error order changes. Offer the verifier for in-handler use where parity on error order matters.
|
||||
|
||||
### Pitfall 13: Redis outage behaviour of the rate limiter
|
||||
`Attempt` cannot return an error. **Recommend:** on a Redis error, fall back to a per-instance `MemoryStore` passed in by `surf`, and log once per interval. The alternatives are deny-all (an outage becomes a site-wide 429) or allow-all (the limit disappears).
|
||||
|
||||
### Pitfall 14: go-redis default handshakes against miniredis and older servers
|
||||
go-redis v9.23.0 defaults `maintnotifications` to `ModeAuto` ("Client tries to send command, disables feature on error") and sends `CLIENT SETINFO` unless `DisableIdentity` is set [VERIFIED: go-redis@v9.23.0/maintnotifications/config.go:18-20,71,138; options.go:406-416,441-446]. **Recommend:** set `MaintNotificationsConfig: &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}` and `DisableIdentity: true` in `cooler` for quiet logs.
|
||||
|
||||
### Pitfall 15: `public_url` bypasses the `is_public` check
|
||||
**What goes wrong:** the proxy route serves only `is_public` rows (`StaticHandlerPublic`); a public-read bucket or CDN serves any key, private files included (keys are 22 random hex characters). **How to avoid:** document that `public_url` requires a bucket holding only public files, or accept unguessable keys knowingly. Optionally use `public_url` only when `IsPublic` is set.
|
||||
|
||||
## Code Examples
|
||||
|
||||
### Memory driver with per-entry TTL (otter v2)
|
||||
```go
|
||||
// Source: otter@v2.3.0 options.go / expiry_calculator.go / cache.go (verified API names)
|
||||
type entry struct{ val []byte; ttl time.Duration } // ttl<=0 means forever
|
||||
const forever = 50 * 365 * 24 * time.Hour
|
||||
c, err := otter.New(&otter.Options[string, entry]{
|
||||
MaximumSize: cfgMaxEntries, // cache.memory.max_entries, e.g. 10000
|
||||
ExpiryCalculator: otter.ExpiryWritingFunc(func(e otter.Entry[string, entry]) time.Duration {
|
||||
if e.Value.ttl <= 0 { return forever }
|
||||
return e.Value.ttl
|
||||
}),
|
||||
})
|
||||
// Increment: atomic per key; it keeps the existing TTL via entry.ttl, as Redis INCRBY does.
|
||||
v, _ := c.Compute(key, func(old entry, found bool) (entry, otter.ComputeOp) {
|
||||
n := int64(0)
|
||||
if found { n, _ = strconv.ParseInt(string(old.val), 10, 64) }
|
||||
return entry{val: []byte(strconv.FormatInt(n+by, 10)), ttl: old.ttl}, otter.WriteOp
|
||||
})
|
||||
```
|
||||
(Check `ExpiryWritingFunc` update semantics during implementation: it recomputes on update, so carry `ttl` in the value as shown. Note `ttl` here is the original duration, so an Increment restarts the window; if exact Redis semantics matter, store an absolute `expiresAt` and return `time.Until(expiresAt)`.)
|
||||
|
||||
### Redis client from `redis.url`
|
||||
```go
|
||||
// Source: go-redis@v9.23.0 options.go:875-905 (ParseURL supports redis://, rediss://, unix://; unknown query params error)
|
||||
opt, err := redis.ParseURL(raw)
|
||||
if err != nil { return nil, errors.New("cooler: redis.url is not a valid redis:// rediss:// or unix:// URL") } // never echo raw
|
||||
opt.DisableIdentity = true
|
||||
opt.MaintNotificationsConfig = &maintnotifications.Config{Mode: maintnotifications.ModeDisabled}
|
||||
client := redis.NewClient(opt)
|
||||
pctx, cancel := context.WithTimeout(ctx, 3*time.Second); defer cancel()
|
||||
if err := client.Ping(pctx).Err(); err != nil { return nil, fmt.Errorf("cooler: redis ping failed: %v", redactErr(err)) }
|
||||
```
|
||||
|
||||
### Docker-gated real-Redis test (mirror the postcard pattern)
|
||||
```go
|
||||
func TestMain(m *testing.M) {
|
||||
flag.Parse()
|
||||
if testing.Short() { os.Exit(m.Run()) }
|
||||
ctx := context.Background()
|
||||
ctr, err := testcontainers.Run(ctx, "redis:7-alpine",
|
||||
testcontainers.WithExposedPorts("6379/tcp"),
|
||||
testcontainers.WithWaitStrategy(wait.ForListeningPort("6379/tcp")))
|
||||
// … set redisURL, run, testcontainers.TerminateContainer(ctr)
|
||||
}
|
||||
```
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Floating `swagger-ui-dist@5` CDN without integrity (PHP) | exact version + SRI | — | A floating version cannot carry SRI |
|
||||
| AWS SDK default request checksums everywhere | `request_checksum_calculation=when_required` for S3-compatible stores | SDK v2 2025 defaults [ASSUMED date] | Some MinIO/R2/Hetzner setups reject the default CRC checksums [ASSUMED]; the URL params exist [VERIFIED] |
|
||||
| Per-instance rate limits (`surf.MemoryStore`) | Redis fixed window shared by instances | this phase | `docs/services/rate-limiting.md` currently says "each application instance counts on its own" and must be updated |
|
||||
|
||||
**Deprecated/outdated:** `gopkg.in/yaml.v3` (archived, project rule); the go-redis `DisableIndentity` misspelling (deprecated, use `DisableIdentity`) [VERIFIED: options.go:406-416].
|
||||
|
||||
## Docs checker constraints
|
||||
|
||||
- `summer docs:build --check` / `go test ./cmd/summer -run TestDocsTree` checks identifiers, links, anchors, `src=` snippets and command names. Identifiers are indexed from the non-test Go files of every directory under `modules/`, keyed by the **last path element**. Two packages with the same last element are a problem [VERIFIED: internal/docsite/check_identifiers.go:20-60], so do not name a sub-package after an existing package (no `modules/cooler/redis` if anything else is `redis`; prefer files in `cooler`).
|
||||
- Every module directory with Go files needs a `README.md`; READMEs are published in the `api` section automatically [VERIFIED: internal/docsite/load.go:318-344].
|
||||
- The forbidden-name check is `regexp.MustCompile(`(?i)fonoteka|p[lł][yý]tarium`)` [VERIFIED: internal/docsite/check_forbidden.go:14]. WavePath is not covered, so review by hand (or extend the pattern in this phase).
|
||||
- `go/parser` indexes build-tagged files regardless of tags, so identifiers in `s3_on.go` count.
|
||||
- Pages to touch: `docs/services/rate-limiting.md` (Redis store), `docs/services/storage.md` (S3, `public_url`, extension, nosniff/allow-list deploy note), `docs/services/authentication.md` (cookie transport, Turnstile), `docs/services/routing.md` (CORS hardening, profiles, OPTIONS, api-docs), `docs/database/attachments.md` (disk name), a new `docs/services/caching.md`, and the `summer build` pages (`docs/console/*`) for `build.tags`.
|
||||
|
||||
## Assumptions Log
|
||||
|
||||
| # | Claim | Section | Risk if Wrong |
|
||||
|---|-------|---------|---------------|
|
||||
| A1 | ServeMux answers OPTIONS on a GET-only pattern with 405 before middleware | Pitfall 5 | Profile preflight design is wrong; a router test settles it in minutes |
|
||||
| A2 | S3-compatible stores (R2/MinIO/Hetzner) may reject the SDK's default checksums; `when_required` fixes it | Pattern 5, State of the Art | Uploads to a provider fail; documented URL params mitigate |
|
||||
| A3 | miniredis is a suitable test double (name from training; existence and features verified) | Standard Stack | Needs the decision note and user confirmation as a new test dependency |
|
||||
| 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 |
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Shared Nuxt purge route: same file in three layers, or a new tiny layer repo?**
|
||||
- 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.
|
||||
|
||||
## Environment Availability
|
||||
|
||||
| Dependency | Required By | Available | Version | Fallback |
|
||||
|------------|------------|-----------|---------|----------|
|
||||
| Go | everything | ✓ | go1.27.0 | — |
|
||||
| Docker daemon | testcontainers Redis/Postgres tests | ✓ (`docker ps` ok) | client 29.7.2 | `-short` with miniredis |
|
||||
| Redis server (local) | manual probes | ✓ | 7.2.5 | testcontainers `redis:7-alpine` / `redis:8-alpine` (images present locally) |
|
||||
| redis-cli | probes | ✓ | — | — |
|
||||
| node / pnpm / npm | vue layer tests (`node --test`) | ✓ | — | — |
|
||||
| S3/MinIO | real S3 smoke | ✗ (no MinIO image found) | — | Open the bucket in a unit test only (s3blob opens lazily); real upload stays a human UAT item |
|
||||
| Cloudflare siteverify | Turnstile live check | network-dependent | — | `httptest` server via the verifier's endpoint override |
|
||||
|
||||
**Missing dependencies with no fallback:** none.
|
||||
**Missing dependencies with fallback:** MinIO/S3 (unit-level open test + human UAT).
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go `testing` (Go 1.27); `node --test` in the vue layers |
|
||||
| Config file | none (go.mod); layers: `package.json` `"test": "node --test --test-reporter=tap tests/*.test.ts"` |
|
||||
| Quick run command | `go test -short ./modules/cooler ./modules/surf ./modules/bouncer ./modules/lagoon/... ./internal/build` |
|
||||
| Tagged run | `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach` |
|
||||
| Full suite command | `go vet ./... && go test ./... && go test ./cmd/summer -run TestDocsTree && (cd examples/hello && go test ./...)` |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| SC-1 | TokenCookie attrs (name/path/domain/secure/samesite, MaxAge=refresh_ttl); forget cookie; refresh handler rotates and blacklists the old JTI; invalid domain or `none` without Secure fails boot | unit | `go test -short ./modules/bouncer -run 'TestTokenCookie|TestCookieRefresh'` | ❌ Wave 0 |
|
||||
| SC-1 | CORS: credentials+`*` boot error; echo exact origin; `Vary: Origin` on preflight and disallowed origin; api↔www subdomain; `*` non-credentialed unchanged; profile OPTIONS route | unit | `go test -short ./modules/surf -run 'TestCORS'` | partial (cors_test.go, cors_coverage_test.go exist) |
|
||||
| SC-2 | Memory driver Get/Set/Has/Forget/Remember/Increment/Flush with TTL and prefix | unit | `go test -short ./modules/cooler -run TestMemory` | ❌ Wave 0 |
|
||||
| SC-2 | Redis driver + Flush only under prefix | unit (miniredis) + docker | `go test ./modules/cooler -run TestRedis` | ❌ Wave 0 |
|
||||
| SC-2 | Redis `Attempt` matches `MemoryStore` (table-driven against both stores; FastForward for expiry; concurrent attempts never exceed max) | unit + docker | `go test ./modules/cooler -run TestRateLimitParity` | ❌ Wave 0 |
|
||||
| SC-2 | BuildRouter uses a published `surf.Store`; route:list does not dial | unit | `go test -short ./modules/surf -run TestBuildRouterStore` | ❌ Wave 0 |
|
||||
| SC-3 | `s3://` without the tag: boot error names `summercms_s3`; with the tag it opens | unit (both tag modes) | `go test -short ./modules/lagoon/attach -run TestOpenBucketS3` (+ `-tags summercms_s3`) | ❌ Wave 0 |
|
||||
| SC-3 | `summer.yaml` `build.tags` → `go build -tags`; SaveManifest round-trip | unit | `go test -short ./internal/build -run 'TestManifestBuildTags|TestSaveManifest'` | ❌ Wave 0 |
|
||||
| SC-3 | `public_url` URL building | unit | `go test -short ./modules/lagoon/attach -run TestPublicURL` | partial (url_test.go) |
|
||||
| D-15 | PNG uploaded as `x.html` gets a `.png` disk name and URL; docx keeps docx; html-as-txt stays txt | unit | `go test -short ./modules/lagoon/attach -run 'TestStoreDiskExtension'` | ❌ Wave 0 |
|
||||
| SC-3 | `/api-docs` HTML carries the pinned version and SRI; `/api-docs.json` compact JSON in key order; off by default; boot errors for zero or two providers | unit | `go test -short ./modules/surf -run TestAPIDocs` | ❌ Wave 0 |
|
||||
| SC-3 | Turnstile open/closed matrix (empty secret dev/prod, missing token, success false, timeout via slow httptest, body restored) | unit | `go test -short ./modules/bouncer -run TestTurnstile` | ❌ Wave 0 |
|
||||
| D-10/11 | Purger validation, after-commit only, failure never fails the save, alias keys | unit | `go test -short ./modules/cooler -run TestSiteCache`; in plugin repos `go test ./...` (inside an app checkout) | ❌ Wave 0 (journal tests exist to port) |
|
||||
| D-11 | cabana reorder hook runs inside the tx | unit | `go test -short ./modules/cabana -run TestReorderHook` | ❌ Wave 0 |
|
||||
| D-11 | Nuxt shared purge route authorize/purge util | unit (node) | `pnpm -C ../vue-menu-layer test` (and faq, journal) | ❌ Wave 0 (journal util tests exist) |
|
||||
| SC-4 | Docs identifiers/links | docs | `go test ./cmd/summer -run TestDocsTree` | ✅ |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** the quick run command for the touched packages, plus `go vet ./...`.
|
||||
- **Per wave merge:** the full suite command (Docker available) plus the tagged run.
|
||||
- **Phase gate:** full suite + tagged run + docs checker green before `/msd-verify-work`; plugin repos' `go test ./...` from an app checkout; layer `pnpm test`.
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `modules/cooler/*_test.go`: memory, redis (miniredis), Docker TestMain, rate-limit parity table shared with `MemoryStore`
|
||||
- [ ] `modules/bouncer/cookie_test.go`, `turnstile_test.go`
|
||||
- [ ] `modules/surf/cors_profile_test.go`, `apidocs_test.go`, store-lookup test
|
||||
- [ ] `modules/lagoon/attach/s3_test.go` (both tag modes), `diskext_test.go`, Store regression
|
||||
- [ ] `internal/build` manifest tags + SaveManifest round-trip
|
||||
- [ ] `modules/cabana` reorder hook test
|
||||
- [ ] Framework dependency install: `go get github.com/alicebob/miniredis/v2@v2.40.0` (after its decision note)
|
||||
|
||||
## Security Domain
|
||||
|
||||
### Applicable ASVS Categories
|
||||
|
||||
| ASVS Category | Applies | Standard Control |
|
||||
|---------------|---------|-----------------|
|
||||
| V2 Authentication | yes (Turnstile anti-automation) | `bouncer` verifier against Cloudflare siteverify; secret server-side only |
|
||||
| V3 Session Management | yes (token cookie) | httpOnly, Secure, SameSite=Lax, Max-Age=refresh window, rotation + JTI blacklist (`bouncer.Refresh*`, Postgres blacklist) |
|
||||
| V4 Access Control | yes (CORS, purge route, api-docs public by design) | Exact-origin allow-list; purge bearer token ≥32 chars, loopback-only http, proxied requests refused |
|
||||
| V5 Input Validation | yes | Upload sniffing + extension helper; config validation at boot (cookie domain, CORS patterns, tags regex, purge URL) |
|
||||
| V6 Cryptography | no new crypto | Constant-time token compare stays in the Nuxt util (sha256 + timingSafeEqual) |
|
||||
| V12 Files and Resources | yes | Disk extension from content, nosniff + CSP sandbox on `/storage/uploads`, `public_url` exposure documented |
|
||||
| V14 Configuration | yes | Secrets via `SUMMER_*` env; Redis URL never logged; SRI on CDN assets |
|
||||
|
||||
### Known Threat Patterns for this stack
|
||||
|
||||
| Pattern | STRIDE | Standard Mitigation |
|
||||
|---------|--------|---------------------|
|
||||
| Credentialed CORS reflecting any origin | Information disclosure | Boot error for `*` + credentials; exact-origin echo; `Vary: Origin` |
|
||||
| Cache poisoning across origins | Tampering | `Vary: Origin` on every dynamic CORS response |
|
||||
| CSRF on cookie-authenticated endpoints from sibling subdomains (SameSite=Lax treats same-site as safe) | Spoofing/Tampering | CORS is not CSRF protection: require JSON or a custom header on state-changing cookie routes; optionally check `Origin` in the cookie handler against the allowed list |
|
||||
| Cookie scoped too widely (`Domain=.example.com` reaches every subdomain) | Information disclosure | Domain explicit and opt-in (default host-only); document the subdomain trust boundary |
|
||||
| Stored XSS via a disk-name extension | Tampering/Elevation | `DiskExtension` from the sniffed type; never keep script-capable extensions; nginx nosniff + allow-list |
|
||||
| Private files via `public_url` | Information disclosure | Bucket only for public files, or proxy route; document |
|
||||
| Turnstile bypass by an empty secret in production | Spoofing | Closed mode: 503 in production when unconfigured; open mode only on registration paths |
|
||||
| Turnstile token replay | Spoofing | Single-use enforced by Cloudflare (`timeout-or-duplicate`); never cache verdicts per token |
|
||||
| Siteverify hang causing request pile-up | DoS | 5 s client timeout; context-aware request |
|
||||
| Redis credential leak in logs | Information disclosure | Never format `redis.url` or raw `url.Parse` errors (Pitfall 7) |
|
||||
| Rate limit bypass during a Redis outage | DoS/Abuse | Memory fallback per instance plus a warning |
|
||||
| Redis key injection or collision | Tampering | Key prefixing `<prefix>rl:` / `<prefix>cache:`; keys are opaque strings to Redis (no command injection through go-redis args) |
|
||||
| CDN asset tampering on `/api-docs` | Tampering | Exact version + SRI sha384 + `crossorigin="anonymous"` |
|
||||
| Purge route abuse | DoS | Bearer ≥32 chars, constant-time compare, refuses proxied requests, nginx `limit_except GET HEAD` |
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- In-repo source read this session: `modules/surf/{limiter_store.go,limiter.go,cors.go,router.go,serve.go,uploads.go}`, `modules/lagoon/attach/{bucket.go,store.go,thumb.go}`, `modules/bouncer/{refresh.go,jwt.go,blacklist.go}`, `modules/cabana/{auth.go,reorder.go,crud.go}`, `modules/compass/{config.go,env.go}`, `modules/backpack/services.go`, `internal/build/{build.go,manifest.go}`, `internal/docsite/{check_identifiers.go,check_forbidden.go}`
|
||||
- Sibling repos: `sm-journal-plugin/classes/site_cache.go`, `controllers/{categories.go,site_cache.go}`; `vue-journal-layer/{server/api/__journal__/cache/purge.post.ts,app/utils/siteCache.ts,nuxt.config.ts}`; `sm-grzybyfunkcjonalne-app/{scripts/build.sh,summer.yaml,.gitmodules,DEPLOY.md}`; fonoteka.go user plugin `api_controller.go` and `config.yaml`
|
||||
- PHP reference: wavepath.org `AuthApiController.php`, `CookieAttributes.php`, `TurnstileVerifier.php`, `GuestCallController.php`, `ApiDocsController.php`, `config/cors.php`, `config/cookie.php`, `vendor/fruitcake/php-cors` v1.4.0
|
||||
- Module sources from proxy.golang.org: go-redis v9.23.0, otter v2.3.0, miniredis v2.40.0; gocloud.dev v0.46.0 from the module cache; nitropack 2.13.4 dist
|
||||
- Probes: Redis 7.2.5 Lua script run; Go `http.DetectContentType` and `url.Parse` runs; swagger-ui-dist tarball vs jsDelivr sha384
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- developers.cloudflare.com Turnstile server-side validation and testing pages (fetched this session)
|
||||
|
||||
### Tertiary (LOW confidence)
|
||||
- S3-compatible provider checksum incompatibilities; Laravel cache prefix default; Symfony cookie serialization (marked [ASSUMED])
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH. Versions come from proxy.golang.org, and API identifiers were read from the module sources.
|
||||
- Architecture: HIGH. Boot order, import graph and the reorder-hook gap were read from source; the Lua script was probed on real Redis.
|
||||
- Pitfalls: HIGH for the in-repo ones; MEDIUM for S3-compatible providers and the ServeMux 405 behaviour (A1, a one-test check).
|
||||
|
||||
**Research date:** 2026-10-15
|
||||
**Valid until:** 2026-11-14 (go-redis and swagger-ui-dist release often; re-check pins before tagging)
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
phase: "19.3"
|
||||
slug: "framework-transport-and-infrastructure-for-wavepath"
|
||||
# status lifecycle: draft (seeded by plan-phase) → validated (set by validate-phase §6)
|
||||
# audit-milestone §5.5 distinguishes NOT-VALIDATED (draft) from PARTIAL (validated + nyquist_compliant: false) (#2117)
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: "2026-10-15"
|
||||
---
|
||||
|
||||
# Phase 19.3 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | Go `testing` (miniredis for fast Redis tests, testcontainers Redis behind `-short`); `node --test` in the vue layers |
|
||||
| **Config file** | none (go.mod); layers: `package.json` `test` script |
|
||||
| **Quick run command** | `go test -short ./modules/cooler ./modules/surf ./modules/bouncer ./modules/lagoon/... ./internal/build` |
|
||||
| **Tagged run** | `go vet -tags summercms_s3 ./modules/lagoon/... && go test -short -tags summercms_s3 ./modules/lagoon/attach` |
|
||||
| **Full suite command** | `go vet ./... && go test ./... && go test ./cmd/summer -run TestDocsTree && (cd examples/hello && go test ./...)` |
|
||||
| **Estimated runtime** | ~30 s quick, ~180 s full (Docker) |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** quick run command for the touched packages, plus `go vet ./...`
|
||||
- **After every plan wave:** full suite command plus the tagged run
|
||||
- **Before `/msd-verify-work`:** full suite + tagged run + docs checker green; plugin repos' `go test ./...` from an app checkout; layer `pnpm test`
|
||||
- **Max feedback latency:** 60 seconds (quick run)
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
Filled by the planner per task. Requirement → test map from RESEARCH.md:
|
||||
|
||||
| Req | Behavior | Test Type | Automated Command | File Exists | Status |
|
||||
|-----|----------|-----------|-------------------|-------------|--------|
|
||||
| SC-1 | TokenCookie attrs, MaxAge=refresh_ttl, forget cookie, refresh handler rotates + blacklists JTI, invalid cookie config fails boot | unit | `go test -short ./modules/bouncer -run 'TestTokenCookie\|TestCookieRefresh'` | ❌ W0 | ⬜ pending |
|
||||
| SC-1 | CORS credentials+`*` boot error, exact origin echo, `Vary: Origin` on preflight, api↔www, `*` non-credentialed unchanged, profile OPTIONS route | unit | `go test -short ./modules/surf -run 'TestCORS'` | partial | ⬜ pending |
|
||||
| SC-2 | Memory driver Get/Set/Has/Forget/Remember/Increment/Flush, TTL, prefix | unit | `go test -short ./modules/cooler -run TestMemory` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | Redis driver, Flush only under prefix | unit + docker | `go test ./modules/cooler -run TestRedis` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | Redis `Attempt` parity with `MemoryStore` | unit + docker | `go test ./modules/cooler -run TestRateLimitParity` | ❌ W0 | ⬜ pending |
|
||||
| SC-2 | BuildRouter uses published `surf.Store`; route:list does not dial | unit | `go test -short ./modules/surf -run TestBuildRouterStore` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | `s3://` without tag fails naming `summercms_s3`; with tag opens | unit (both tag modes) | `go test -short ./modules/lagoon/attach -run TestOpenBucketS3` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | `summer.yaml` `build.tags` → `go build -tags`; SaveManifest round-trip | unit | `go test -short ./internal/build -run 'TestManifestBuildTags\|TestSaveManifest'` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | `public_url` URL building | unit | `go test -short ./modules/lagoon/attach -run TestPublicURL` | partial | ⬜ pending |
|
||||
| D-15 | PNG as `x.html` → `.png`; docx keeps docx; html-as-txt stays txt | unit | `go test -short ./modules/lagoon/attach -run TestStoreDiskExtension` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | `/api-docs` pinned version + SRI; `/api-docs.json`; off by default | unit | `go test -short ./modules/surf -run TestAPIDocs` | ❌ W0 | ⬜ pending |
|
||||
| SC-3 | Turnstile open/closed matrix, timeout, body restored | unit | `go test -short ./modules/bouncer -run TestTurnstile` | ❌ W0 | ⬜ pending |
|
||||
| D-10/11 | Purger after-commit only, failure never fails save, alias keys | unit | `go test -short ./modules/cooler -run TestSiteCache` | ❌ W0 | ⬜ pending |
|
||||
| D-11 | cabana reorder hook runs inside the tx | unit | `go test -short ./modules/cabana -run TestReorderHook` | ❌ W0 | ⬜ pending |
|
||||
| D-11 | Nuxt shared purge route | unit (node) | `pnpm -C ../vue-menu-layer test` (and faq, journal) | ❌ W0 | ⬜ pending |
|
||||
| SC-4 | Docs identifiers/links | docs | `go test ./cmd/summer -run TestDocsTree` | ✅ | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `modules/cooler/*_test.go`: memory, redis (miniredis), Docker TestMain, rate-limit parity table shared with `MemoryStore`
|
||||
- [ ] `modules/bouncer/cookie_test.go`, `turnstile_test.go`
|
||||
- [ ] `modules/surf/cors_profile_test.go`, `apidocs_test.go`, store-lookup test
|
||||
- [ ] `modules/lagoon/attach/s3_test.go` (both tag modes), `diskext_test.go`, Store regression
|
||||
- [ ] `internal/build` manifest tags + SaveManifest round-trip
|
||||
- [ ] `modules/cabana` reorder hook test
|
||||
- [ ] Dependency install: `github.com/alicebob/miniredis/v2@v2.40.0` (after its decision note)
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| Cookie refresh across real api/www subdomains in a browser | SC-1 | Needs real DNS + TLS | Deploy to staging, log in on www, wait past access TTL, confirm refresh via cookie |
|
||||
| S3-compatible provider (R2/MinIO/Hetzner) upload | SC-3 | Needs real bucket credentials | Build with `summercms_s3`, set `bucket_url`, upload a file, fetch via `public_url` and proxy |
|
||||
| Nuxt purge after menu/FAQ save | D-11 | Needs running Nuxt + app | Save a menu entry in admin, confirm the page updates without waiting for SWR TTL |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 60s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
Reference in New Issue
Block a user