diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md new file mode 100644 index 0000000..cb56ec6 --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-CONTEXT.md @@ -0,0 +1,176 @@ +# Phase 19.3: Framework transport and infrastructure for WavePath - Context + +**Gathered:** 2026-10-15 +**Status:** Ready for planning + + +## Phase Boundary + +The framework gains the transport and infrastructure pieces a multi-client, multi-instance application needs: + +- JWT cookie transport for refresh across subdomains. +- Credentialed CORS. +- A cache module with memory and Redis drivers, plus a Redis-backed `surf.Store` so rate limits hold across instances. +- An S3 blob option selected through config. +- An opt-in `/api-docs` page and `/api-docs.json`. +- Cloudflare Turnstile verification. + +Two todos are folded in: a framework-level site-cache purge service, adopted by the Journal, menu and FAQ plugins, and hardening of upload disk-name extensions. + +Framework code never names WavePath or any other application (READMEs, docs and examples use neutral names). Repos touched: `summercms.go`. For the purge adoption only: `sm-journal-plugin`, `sm-menu-plugin`, `sm-faq-plugin` and the matching vue layers (`vue-journal-layer`, `vue-menu-layer`, `vue-faq-layer`). + +Not in this phase: OIDC (19.4), social login (19.5), 2FA and device flow (19.6), and plugin-registrable token scopes (dropped, see the division note). + + + + +## Implementation 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. + +### Folded Todos +- **Purge the Nuxt site cache on menu and FAQ admin saves** (`.planning/todos/pending/site-cache-purge-for-menu-and-faq.md`, g15office `zugdxorb`). Today the purge is Journal-specific, so menu and FAQ edits show up only after the 300 s SWR TTL. Folded as D-10 and D-11 with full plugin adoption. +- **Uploads keep the client file extension (stored XSS)** (`.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md`, g15office `bemfi2zs`). Folded as D-15. Only the framework side is in scope; fonoteka call sites are a follow-up. + + + + +## Canonical References + +**Downstream agents MUST read these before planning or implementing.** + +### Scope and division +- `.planning/ROADMAP.md` § Phase 19.3: goal and success criteria. +- `.planning/notes/wavepath-framework-asks-division.md`: which WavePath asks land in 19.3 and which were dropped (token scopes). +- `/media/nvme/dev/jz/wavepath.org/.planning/notes/summercms-framework-asks-for-wavepath.md`: the hand-off list, sections A3, A6 and A8. + +### PHP reference behaviour +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/api/AuthApiController.php`: `tokenCookie`, `forgetTokenCookie`, `refresh`, `logout` (cookie attributes, Max-Age = refresh window, rotation). +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/classes/auth/TurnstileVerifier.php`: fail-open vs fail-closed semantics, 5 s timeout. +- `/media/nvme/dev/jz/wavepath.org/plugins/golem15/wavepath/controllers/ApiDocsController.php`: the `/api-docs` UI and `/api-docs.json` spec serving, pinned CDN. +- `/media/nvme/dev/jz/wavepath.org/config/session.php`: SESSION_DOMAIN and same_site. + +### Research and dependency rules +- `.planning/research/ARCHITECTURE.md` (line ~73): the `cooler` module role. +- `.planning/research/go-ecosystem.md` (Cache row): otter and go-redis. +- `.planning/research/STACK.md`: the decided dependency set. go-redis needs a decision note. +- `../IDEA_LIB_NAMES.md`: the original `summer-cooler` name. + +### Folded todos +- `.planning/todos/pending/site-cache-purge-for-menu-and-faq.md` +- `.planning/todos/pending/2026-10-11-uploads-keep-client-extension-stored-xss.md` +- `.planning/phases/12-p-ytarium-api-collections-and-albums/12-REVIEW.md` (WR-01): origin of the XSS finding. + +### Repo conventions +- `CLAUDE.md`: doc rules (README and docs in the same commit), beach-module rules, sibling-repo commit order. +- `.planning/notes/core-plugins-own-repos.md`: plugin repo and submodule flow for the purge adoption. + + + + +## Existing Code Insights + +### Reusable Assets +- `modules/bouncer/refresh.go`: `Refresh`, `RefreshAudience` and `RefreshAudienceFor` already rotate the token and blacklist the old JTI with a grace window. The cookie handler wraps these. +- `modules/bouncer/jwt.go`: `NewJWTGuard` and `NewBackendJWTGuard` already read tokens from configured cookie names after the bearer token. +- `modules/bouncer/blacklist.go`: the `BlacklistStore` interface. A Redis implementation in `cooler` is a natural extra, but it is not required. +- `modules/surf/cors.go`: `CORSConfig` (Laravel `config/cors.php` keys, including `supports_credentials` and origin patterns) and `pathScopedCORS`. +- `modules/surf/limiter_store.go`: the `surf.Store` interface (`Attempt(key, max, decay)`) and `MemoryStore`. The Redis store must match its semantics. +- `modules/lagoon/attach/bucket.go`: `OpenBucket` and `normalizeBucketURL` on `gocloud.dev/blob` (file and mem blank-imported today). S3 is the `s3blob` driver behind a build tag. +- `modules/lagoon/attach/store.go`: `Store` already sniffs the content type, which is where the disk-name extension fix goes. +- `../sm-journal-plugin/classes/site_cache.go`: the existing Journal purge to generalise. +- `internal/build`: generates `main.go` and `plugins.gen.go` from `summer.yaml`, which is where build tags get set. + +### Established Patterns +- Opt-in routes gated by config (`storage.uploads.serve` mounts the uploads route in `surf.BuildRouter`). `/api-docs` follows the same pattern. +- Named middleware and middleware factories (`RegisterMiddlewareFactory`, `name:param`). Turnstile and named CORS use them. +- Per-guard JWT config keys (`golem15.user.jwt.ttl/refresh_ttl`, `admin.jwt.*`). +- New dependencies need a decision note first. YAML goes through `goccy/go-yaml`. + +### Integration Points +- Boot wiring: the framework reads `redis.url`, `cache.driver` and `http.rate_limit.store` and installs the `cooler` Redis store into surf's limiter before routes are built. +- `summer build` and `summer.yaml`: the S3 build tag. +- Plugins (Journal, menu, FAQ): call the purge service after committed admin saves. + + + + +## Specific Ideas + +- The cookie must outlive the access token (Max-Age = `refresh_ttl`). The PHP comment explains the re-login loop that happens otherwise. +- Swagger UI is pinned to `swagger-ui-dist@5` on jsDelivr, as in PHP. +- Turnstile keeps the two PHP modes exactly: registration fails open, guest entry fails closed. + + + + +## Deferred Ideas + +- 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) +- **Benchmark Płytarium on WinterCMS vs SummerCMS** (`2026-10-01-benchmark-...`): matched on keywords only; unrelated to 19.3. +- **Port the FAQ plugin as sm-faq-plugin**: already its own repo; only the purge wiring touches it here. + + + +--- + +*Phase: 19.3-framework-transport-and-infrastructure-for-wavepath* +*Context gathered: 2026-10-15* diff --git a/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-DISCUSSION-LOG.md b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-DISCUSSION-LOG.md new file mode 100644 index 0000000..343c94c --- /dev/null +++ b/.planning/phases/19.3-framework-transport-and-infrastructure-for-wavepath/19.3-DISCUSSION-LOG.md @@ -0,0 +1,123 @@ +# Phase 19.3: Framework transport and infrastructure for WavePath - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered. + +**Date:** 2026-10-15 +**Phase:** 19.3-framework-transport-and-infrastructure-for-wavepath +**Areas discussed:** Cookie refresh ownership, Cache + Redis store, S3 option & public URLs, api-docs & Turnstile, Module placement + +--- + +## Todos + +| Option | Description | Selected | +|--------|-------------|----------| +| Site cache purge (menu/FAQ) | Framework-level Nuxt purge service | ✓ | +| Uploads stored-XSS extension | Disk-name extension from sniffed MIME in lagoon/attach | ✓ | +| None | Keep todos separate | | + +## Cookie refresh ownership + +| Option | Description | Selected | +|--------|-------------|----------| +| Helpers + optional handler | Cookie builders plus a ready-made refresh/logout handler with a writer func | ✓ | +| Helpers only | Apps write their own handler | | +| Full handler in sm-user-plugin | Change the shared plugin's /refresh | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Framework `auth.cookie.*` | One block; Max-Age from the guard's refresh_ttl | ✓ | +| Per guard | `golem15.user.jwt.cookie.*`, `admin.jwt.cookie.*` | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Match PHP | token, /, httpOnly, Lax, host-only, Secure on https | ✓ | +| Stricter | Strict + always Secure | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Harden + test existing | Boot check, exact-origin echo, Vary, subdomain tests | | +| Add named middleware too | Hardening plus a per-group named CORS middleware | ✓ | + +## Cache + Redis store + +| Option | Description | Selected | +|--------|-------------|----------| +| go-redis v9 | Official, most widely used, already in research | ✓ | +| rueidis | Faster, smaller ecosystem | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Laravel-core subset | Get/Set/Has/Forget/Remember/Increment/Flush, no tags | ✓ | +| Include tags | Laravel cache tags | | +| Minimal KV | Get/Set/Delete only | | + +| Option | Description | Selected | +|--------|-------------|----------| +| One redis block, auto-wired | redis.url + cache.driver + http.rate_limit.store | ✓ | +| App wires it | App builds client and sets the store | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Framework service only | Plugin adoption as follow-up | | +| Service + plugin adoption | Journal (alias keys), menu, FAQ and Nuxt route in this phase | ✓ | + +## S3 option & public URLs + +| Option | Description | Selected | +|--------|-------------|----------| +| Configurable base URL | `storage.uploads.public_url`, else proxy | ✓ | +| Always proxy | Stream everything through Go | | +| Presigned URLs | Direct and presigned | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Always linked | Blank import, +10–15 MB | | +| Build tag | Opt-in tag set by summer build from summer.yaml | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| Via bucket_url query | gocloud native params, AWS env creds | ✓ | +| Explicit config keys | `storage.uploads.s3.*` | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Sniffed-MIME extension in attach | Framework helper + test; fonoteka sites follow-up | ✓ | +| Also fix fonoteka call sites | Include app changes in this phase | | + +## api-docs & Turnstile + +| Option | Description | Selected | +|--------|-------------|----------| +| App/plugin-supplied file | Embedded YAML/JSON, as PHP | ✓ | +| Generated from swag | Reuse the cabana pipeline | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Opt-in via config | `http.api_docs.enabled`, Swagger UI from pinned CDN | ✓ | +| Plugin capability | pact interface | | +| Embed Swagger UI | ~1.5 MB in binary | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Verifier + middleware factory | PHP fail-open/closed semantics | ✓ | +| Verifier only | Plugins call it in handlers | | + +## Module placement + +**User's choice:** First asked for a suggestion ("api-docs can go to surf, turnstile i guess too, or do we have a better module for that? what do you suggest for cache itself, do we have some earlier research around it maybe?"). Claude pointed to `cooler` in research/ARCHITECTURE.md and IDEA_LIB_NAMES.md and proposed: cooler (cache, Redis Store, purge), surf (api-docs), bouncer (Turnstile). + +| Option | Description | Selected | +|--------|-------------|----------| +| Yes, as suggested | cooler / surf / bouncer | ✓ | +| Turnstile in surf instead | | | +| Purge in its own module | | | + +## Claude's Discretion + +- Go identifiers, handler signatures, the named CORS profile config, otter vs stdlib map, the build-tag name. + +## Deferred Ideas + +- fonoteka call sites onto the extension helper; cache tags and locks; swag-generated api-docs; a Redis BlacklistStore.