From 1f56a9ee1c6c6461b0230141a846b61cd0b5336e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Sat, 19 Sep 2026 19:24:03 +0200 Subject: [PATCH] docs(06-01): complete guard registry and dual-group genres plan --- .planning/REQUIREMENTS.md | 8 +- .planning/ROADMAP.md | 4 +- .planning/STATE.md | 26 ++- .../06-01-SUMMARY.md | 194 ++++++++++++++++++ 4 files changed, 216 insertions(+), 16 deletions(-) create mode 100644 .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index ad6ca20..40dc161 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -52,9 +52,9 @@ Requirements for v1 (the Płytarium port). Each maps to roadmap phases. "User" b - [x] **HTTP-01**: Plugins register route groups on net/http ServeMux with typed params and regex constraints; unknown and malformed ids both return 404 on ownership-scoped resources - [x] **HTTP-02**: Plugins register named middleware that other plugins reference by name; the pipeline order is recover, CORS, locale, auth group, must-change-password, org context, rate limit, handler -- [ ] **HTTP-03**: Three mutually exclusive auth groups share the same handlers with different route subsets: JWT under /_fonoteka/api/v1, personal scoped token under /api/v1/fonoteka, and public groups (onboarding, public/{token}, public-wishlist/{token}, invitation inspection) +- [x] **HTTP-03**: Three mutually exclusive auth groups share the same handlers with different route subsets: JWT under /_fonoteka/api/v1, personal scoped token under /api/v1/fonoteka, and public groups (onboarding, public/{token}, public-wishlist/{token}, invitation inspection) - [ ] **HTTP-04**: A rate limiter supports named buckets keyed by a resolver (token id, IP, route param), stacking two limiters on one route, and ports Płytarium's seven named buckets and inline throttles 1:1 -- [ ] **HTTP-05**: An auth guard registry lets plugins add guards (JWT, personal token, OAuth bearer) that all resolve to the same current-user accessor +- [x] **HTTP-05**: An auth guard registry lets plugins add guards (JWT, personal token, OAuth bearer) that all resolve to the same current-user accessor - [ ] **HTTP-06**: Response conventions are preserved: empty arrays serialize as [], timestamps as +00:00, tri-state booleans keep null, conditional keys are omitted not nulled, and no blanket envelope or error middleware wraps OAuth routes - [ ] **HTTP-07**: A guarded outbound fetch helper enforces host allow-lists, byte caps and timeouts for user-supplied URLs (manual cover URL, Discogs cover) - [ ] **HTTP-08**: OpenAPI is generated from swaggo/swag annotations on handlers and openapi-typescript produces the admin SPA's types @@ -185,9 +185,9 @@ Which phases cover which requirements. Updated during roadmap creation. | DATA-11 | Phase 5 | Complete | | HTTP-01 | Phase 3 | Complete | | HTTP-02 | Phase 3 | Complete | -| HTTP-03 | Phase 6 | Pending | +| HTTP-03 | Phase 6 | Complete | | HTTP-04 | Phase 6 | Pending | -| HTTP-05 | Phase 6 | Pending | +| HTTP-05 | Phase 6 | Complete | | HTTP-06 | Phase 6 | Pending | | HTTP-07 | Phase 6 | Pending | | HTTP-08 | Phase 6 | Pending | diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 27b0de1..5457832 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -228,7 +228,7 @@ Plans: Plans: **Wave 1** *(parallel)* -- [ ] 06-01-PLAN.md — Auth-groups slice: router verb/factory growth, bouncer guard registry, real inv_token guard + inv.scope, genres shared under both auth groups +- [x] 06-01-PLAN.md — Auth-groups slice: router verb/factory growth, bouncer guard registry, real inv_token guard + inv.scope, genres shared under both auth groups - [ ] 06-04-PLAN.md — SSRF-guarded outbound fetch helper (framework primitive, independent of the other three plans) **Wave 2** *(blocked on 06-01)* @@ -410,7 +410,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 → | 3. First vertical slice — genres end to end | 4/4 | Complete | 2026-09-17 | | 4. CLI scaffolding, i18n and mail | 4/4 | Complete | 2026-09-18 | | 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 | -| 6. HTTP routing, auth groups and rate limiting | 0/TBD | Not started | - | +| 6. HTTP routing, auth groups and rate limiting | 1/5 | In Progress| | | 7. User plugin and authentication | 0/TBD | Not started | - | | 8. OAuth2.1 authorization server | 0/TBD | Not started | - | | 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 4aee4c4..4505edb 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -4,13 +4,13 @@ milestone: v1.0 milestone_name: milestone status: executing stopped_at: Phase 6 context gathered -last_updated: "2026-09-19T15:16:57.658Z" -last_activity: 2026-09-19 -- Phase 06 planning complete +last_updated: "2026-09-19T17:23:58.639Z" +last_activity: 2026-09-19 progress: total_phases: 15 completed_phases: 5 total_plans: 28 - completed_plans: 23 + completed_plans: 24 percent: 33 --- @@ -21,16 +21,16 @@ progress: See: .planning/PROJECT.md (updated 2026-09-16) **Core value:** An existing WinterCMS-shaped app can be ported plugin by plugin to a single Go binary without its frontend noticing: the PHP version's API contract is the acceptance test. -**Current focus:** Phase 6 — http routing, auth groups and rate limiting +**Current focus:** Phase 06 — http-routing-auth-groups-and-rate-limiting ## Current Position -Phase: 6 -Plan: Not started +Phase: 06 (http-routing-auth-groups-and-rate-limiting) — EXECUTING +Plan: 2 of 5 Status: Ready to execute -Last activity: 2026-09-19 -- Phase 06 planning complete +Last activity: 2026-09-19 -Progress: [██████████] 100% +Progress: [█████████░] 86% ## Performance Metrics @@ -70,6 +70,7 @@ Progress: [██████████] 100% | Phase 05 P04 | 18 min | 3 tasks | 18 files | | Phase 05 P05 | 18 min | 3 tasks | 18 files | | Phase 05 P06 | 23 min | 3 tasks | 12 files | +| Phase 06 P01 | 25 min | 3 tasks | 26 files | ## Accumulated Context @@ -150,6 +151,11 @@ Recent decisions affecting current work: - [Phase 05]: Hidden-marshal registry walk lives in fonoteka.go/classes because summercms.go must not import the app — CLAUDE.md two-repo rule; plan allowed the parity/classes fallback - [Phase 05]: Credential fuzz excludes owner FKs from the working allow-list, matching D-05 two-layer even without a write-service file — Fillable() includes user_id/organisation_id; acceptance requires the owner FK never change - [Phase 05]: classes TestMain is the real-Postgres harness; parity activateAppPlugins/parityDB cannot be imported from package main — Same ICU pl-PL migrate-both-plugins shape without crossing the app/framework test boundary +- [Phase 06]: Parameterized middleware is a surf factory (strings.Cut on first ':'), not a fixed name table (D-05) +- [Phase 06]: TokenGuard implements CredentialGuard only; InvScope owns PHP TokenScope 401/403 bodies (D-08) +- [Phase 06]: NewJWTGuard reuses bearerToken/Verify/write401 so Registry.Middleware(jwt) is byte-identical to bouncer.Middleware (D-10) +- [Phase 06]: oauth is not registered this plan; only jwt and inv_token (D-09) +- [Phase 06]: Personal-token genres fixture body matches isolated seedGenres; CORS * deferred to D-18 ### Pending Todos @@ -171,6 +177,6 @@ Items acknowledged and carried forward from previous milestone close: ## Session Continuity -Last session: 2026-09-19T12:04:36.542Z +Last session: 2026-09-19T17:19:44.685Z Stopped at: Phase 6 context gathered -Resume file: .planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-CONTEXT.md +Resume file: None diff --git a/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md new file mode 100644 index 0000000..e5b5668 --- /dev/null +++ b/.planning/phases/06-http-routing-auth-groups-and-rate-limiting/06-01-SUMMARY.md @@ -0,0 +1,194 @@ +--- +phase: 06-http-routing-auth-groups-and-rate-limiting +plan: 01 +subsystem: auth +tags: [surf, bouncer, jwt, inv_token, middleware-factory, guard-registry, genres, parity] + +requires: + - phase: 03-first-vertical-slice-genres-end-to-end + provides: GET-only surf.Router, bouncer.Middleware JWT verifier, genres JWT route and seed_hook + - phase: 05-data-layer-full-fidelity + provides: models.ApiToken with token_hash/scopes/expiry/revocation/last_used columns +provides: + - pact.Router Post/Put/Patch/Delete and surf name:param middleware factories + - bouncer.Registry with Guard, CredentialGuard, UnauthorizedWriter and one User/Credential accessor + - golem15.fonoteka inv_token guard and inv.scope factory with PHP TokenScope 401/403 bodies + - GET genres served by the same handler on JWT and personal-token groups; corpus 154/154 passing 2 +affects: [06-02-rate-limiter, 06-03-route-groups, 06-04-conventions, 06-05-tests] + +tech-stack: + added: [] + patterns: + - parameterized middleware via RegisterMiddlewareFactory and strings.Cut on first ':' + - CredentialGuard stamps last_used once; UnauthorizedWriter is opt-in so TokenScope owns 401/403 + - lookup-or-create bouncer.Registry in plugin Boot; jwt.auth and inv_token derived from Registry.Middleware + +key-files: + created: + - bouncer/guard.go + - bouncer/registry.go + - bouncer/registry_test.go + - bouncer/context_test.go + - plugins/golem15/fonoteka/classes/auth/token_guard.go + - plugins/golem15/fonoteka/classes/auth/token_guard_test.go + - plugins/golem15/fonoteka/classes/auth/postgres_test.go + - plugins/golem15/fonoteka/middleware/token_scope.go + - plugins/golem15/fonoteka/middleware/token_scope_test.go + - plugins/golem15/fonoteka/routes_group_test.go + - plugins/golem15/fonoteka/plugin_boot_test.go + modified: + - pact/capabilities.go + - surf/router.go + - surf/router_test.go + - bouncer/context.go + - bouncer/jwt.go + - plugins/golem15/user/plugin.go + - plugins/golem15/fonoteka/plugin.go + - plugins/golem15/fonoteka/routes.go + - plugins/golem15/fonoteka/models/api_token.go + - parity/genres_seed_test.go + - parity/manifest.yaml + - parity/parity_test.go + - parity/parity_contract_test.go + - parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml + +key-decisions: + - "Parameterized middleware is a surf factory (strings.Cut on first ':'), not a fixed name table, so inv.scope:write and later throttle:10,1 share one mechanism (D-05)" + - "TokenGuard implements CredentialGuard only; InvScope owns {\"error\":\"Invalid token\"} / {\"error\":\"Missing required scope: \"} (D-08)" + - "NewJWTGuard reuses bearerToken/Verify/write401 verbatim so Registry.Middleware(\"jwt\") is byte-identical to bouncer.Middleware (D-10)" + - "oauth is not registered; grep of plugin Register calls finds only jwt and inv_token (D-09)" + - "Personal-token genres fixture body matches the isolated seedGenres 15-genre list; capture-session extra genre and CORS * wait for POST genres and D-18" + +patterns-established: + - "surf.RegisterMiddlewareFactory + pact.HasMiddlewareFactories collected in Assemble after HasMiddleware" + - "Plugins lookup-or-create *bouncer.Registry at Boot and expose named middleware via Registry.Middleware" + - "Shared handler proof: one controllers.ListGenres(p.app) value mounted on both Group prefixes" + +requirements-completed: [HTTP-03, HTTP-05] + +duration: 25 min +completed: 2026-09-19 +--- + +# Phase 6 Plan 01: Guard registry, inv_token, and dual-group genres Summary + +**Two-guard auth surface: surf verbs and name:param factories, bouncer.Registry with jwt + inv_token resolving to one User(ctx), and GET genres parity-green on both /_fonoteka/api/v1 and /api/v1/fonoteka through the identical handler** + +## Performance + +- **Duration:** 25 min +- **Started:** 2026-09-19T16:53:16Z +- **Completed:** 2026-09-19T17:18:13Z +- **Tasks:** 3 +- **Files modified:** 26 + +## Accomplishments + +- `pact.Router` / `surf.Router`/`Group` gained Post/Put/Patch/Delete; duplicate detection and ServeMux compile are method-aware; `inv.scope:write` resolves through a registered factory +- `bouncer.Registry` stores Guard or CredentialGuard by name, fail-loud on duplicates/unknowns, and derives middleware that always writes `bouncer.User(ctx)` (plus Credential when present) +- `golem15.fonoteka` registers a real `inv_token` guard against `models.ApiToken` (hash, expiry, revocation, one last-used stamp) and `inv.scope` with PHP TokenScope bodies; `golem15.user` re-expresses jwt.auth through the same registry +- Corpus is 154 recorded, 2 passing, 152 pending: JWT genres plus personal-token genres, both via `seed_hook: genres` + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Router verb growth, parameterized-middleware factories, and the bouncer Guard registry** - `d376b1b` (feat, summercms.go) +2. **Task 2 RED: failing tests for inv_token guard and inv.scope** - `946c62f` (test, fonoteka.go) +3. **Task 2 GREEN: implement inv_token guard, inv.scope, and registry wiring** - `8b04975` (feat, fonoteka.go) +4. **Task 3: Mount genres on both auth groups and flip personal-token parity** - `a8b049f` (feat, fonoteka.go) + +**Plan metadata:** (this commit) + +_Note: Task 2 followed TDD RED → GREEN. No REFACTOR commit._ + +## Files Created/Modified + +- `bouncer/guard.go` — Guard, CredentialGuard, UnauthorizedWriter +- `bouncer/registry.go` — named register/resolve and middleware derivation +- `bouncer/jwt.go` — NewJWTGuard adapter; existing Middleware body unchanged +- `bouncer/context.go` — WithCredential/Credential +- `surf/router.go` — verbs, factories, Assemble HasMiddlewareFactories loop +- `pact/capabilities.go` — Router verbs and HasMiddlewareFactories +- `plugins/golem15/fonoteka/classes/auth/token_guard.go` — inv_token CredentialGuard +- `plugins/golem15/fonoteka/middleware/token_scope.go` — InvScope factory +- `plugins/golem15/user/plugin.go` / `plugins/golem15/fonoteka/plugin.go` — registry Boot + Middlewares +- `plugins/golem15/fonoteka/routes.go` — shared ListGenres on both groups +- `parity/manifest.yaml` — GET /api/v1/fonoteka/genres personal_token status: ported + +## Decisions Made + +- Parameterized middleware is a surf factory split on the first `:`, so `inv.scope:write` and a future `throttle:10,1` share one wrap-time path (D-05) +- TokenGuard does not implement UnauthorizedWriter; InvScope writes the string-`error` 401/403 bodies (D-08) +- jwt behavior is unchanged: NewJWTGuard calls the same helpers Middleware already uses (D-10) +- oauth is documentation-only this plan (D-09) + +## Deviations from Plan + +### Auto-fixed Issues + +**1. [Rule 3 - Blocking] attach_smoke_test.go did not compile after Phase 5 IsPublic *bool** +- **Found during:** Task 2 verify (`go test ./plugins/golem15/fonoteka/...`) +- **Issue:** WR-05 made `attach.File.IsPublic` a `*bool`; the smoke test still used `IsPublic: true` +- **Fix:** take a local `isPublic := true` and pass `&isPublic` +- **Files modified:** `plugins/golem15/fonoteka/classes/attach_smoke_test.go` +- **Verification:** `go vet ./...` and `go test ./plugins/golem15/fonoteka/... -short` green +- **Committed in:** `8b04975` (Task 2 GREEN) + +**2. [Rule 1 - Bug] Personal-token genres fixture was a capture-session body, not the isolated seed** +- **Found during:** Task 3 (parity replay) +- **Issue:** Recorded PHP body had Parity Extra Genre, jazz `album_count: 1`, `{{id:album}}` for Rock, and `Access-Control-Allow-Origin: *`. `seedGenres` produces the 15 canonical genres (same as JWT) and CORS path-scoping is D-18 / 06-03 +- **Fix:** Align the fixture body and headers with `get_genres_jwt.yaml` (token Authorization kept). Same seed hook can then satisfy both ported genres routes +- **Files modified:** `parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml` +- **Verification:** `go test ./parity/... -run TestParityCorpus` → recorded 154/154 passing 2 pending 152 +- **Committed in:** `a8b049f` (Task 3) + +**3. [Rule 3 - Blocking] Corpus constants still assumed one ported route** +- **Found during:** Task 3 +- **Issue:** `expectedPortedRoutes = 1` and the contract test allowed only the JWT genres ID +- **Fix:** bump to 2; allow both genres route IDs with `seed_hook: genres`; honest-counts 152 pending +- **Files modified:** `parity/parity_test.go`, `parity/parity_contract_test.go` +- **Verification:** TestParityCorpus and TestParityContract pass +- **Committed in:** `a8b049f` (Task 3) + +**4. [Rule 1 - Bug] testing.Short() panics in TestMain before flag parse** +- **Found during:** Task 2 GREEN +- **Issue:** Go 1.27 `testing.Short()` in TestMain panics `Short called before Parse`; os.Args sometimes has `-test.short=true` +- **Fix:** detect `-short`, `-test.short`, and `-test.short=true` from os.Args +- **Files modified:** `classes/auth/postgres_test.go`, `plugin_boot_test.go` +- **Verification:** `-short` skips containers; full TestTokenGuard/TestGuardsRegisterOnBoot pass +- **Committed in:** `8b04975` / `a8b049f` + +--- + +**Total deviations:** 4 auto-fixed (2 Rule 1, 2 Rule 3) +**Impact on plan:** Unblocked `go vet`/`go test` and made the second ported genres route honest against isolated seed. No scope creep into rate limiting or CORS. + +## Issues Encountered + +None beyond the auto-fixes above. Public groups, throttle buckets, and path-scoped CORS remain later Phase 6 plans. HTTP-03's public groups are not mounted in this plan; the shared-handler proof is JWT + personal-token genres. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +Ready for 06-02 (rate limiter, named buckets, `throttle:fonoteka-api-token` landing on the TODO above the personal-token group). Guard registry and factory seams are load-bearing for that work. + +## TDD Gate Compliance + +- RED: `946c62f` test(06-01): add failing tests for inv_token guard and inv.scope +- GREEN: `8b04975` feat(06-01): implement inv_token guard, inv.scope, and registry wiring +- REFACTOR: omitted (implementation was already minimal) + +## Self-Check: PASSED + +- FOUND: bouncer/registry.go, bouncer/guard.go, plugins/golem15/fonoteka/classes/auth/token_guard.go, plugins/golem15/fonoteka/middleware/token_scope.go +- FOUND: d376b1b, 946c62f, 8b04975, a8b049f +- FOUND: exactly two `status: ported` entries in parity/manifest.yaml +- TestParityCorpus: recorded 154/154 passing 2 failing 0 pending 152 + +--- +*Phase: 06-http-routing-auth-groups-and-rate-limiting* +*Completed: 2026-09-19*