Files
summercms/.planning/phases/03-first-vertical-slice-genres-end-to-end/03-01-PLAN.md
2026-09-17 18:40:13 +02:00

160 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
phase: 03-first-vertical-slice-genres-end-to-end
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
- go.mod
- go.sum
- bonfire/command.go
- internal/build/build.go
- pact/capabilities.go
- party/registry.go
- lagoon/connection.go
- lagoon/migrations.go
- lagoon/commands.go
- surf/router.go
- surf/middleware.go
- bouncer/jwt.go
- bouncer/context.go
- cmd/summer/main.go
- ../fonoteka.go/go.mod
- ../fonoteka.go/go.work
- ../fonoteka.go/README.md
- ../fonoteka.go/summer.yaml
- ../fonoteka.go/config/app.yaml
- ../fonoteka.go/parity/synthetic_test.go
- ../fonoteka.go/parity/genre_smoke_test.go
- ../fonoteka.go/app/app.go
- ../fonoteka.go/main.go
- ../fonoteka.go/plugins.gen.go
- ../fonoteka.go/plugins/golem15/user/plugin.go
- ../fonoteka.go/plugins/golem15/user/config/config.yaml
- ../fonoteka.go/plugins/golem15/user/migrations.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/migrations.go
- ../fonoteka.go/plugins/golem15/fonoteka/genre.go
autonomous: true
requirements: [DATA-01, DATA-02, HTTP-01, HTTP-02, QA-04]
must_haves:
truths:
- "D-07 D-08 D-09 D-10 D-11: a valid HS256 token for a persisted user reaches the route; missing, malformed, expired, wrong-algorithm, bad-signature and unknown-user tokens do not."
- "D-12 D-13 D-14: golem15.fonoteka requires golem15.user and mounts GET /_fonoteka/api/v1/genres through the named middleware pipeline, including jwt.auth and inv.must-change-password."
- "D-16 D-17 D-18 D-19: a fresh Polish-ICU Postgres database migrates the user and Fonoteka sets in dependency order and contains the 15 canonical genres without a seeder command or AutoMigrate."
- "DATA-01: GORM and application services use the same pgx-stdlib *sql.DB; the River listener pool is a documented Phase 11 seam."
artifacts:
- path: lagoon/connection.go
provides: One checked pgx stdlib SQL pool passed to GORM
- path: lagoon/migrations.go
provides: Per-plugin gormigrate sets and version tables
- path: surf/router.go
provides: Group declarations compiled to net/http ServeMux
- path: bouncer/jwt.go
provides: Pinned HS256 verifier and request identity
- path: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
provides: First real app route mounted through the user plugin
- path: ../fonoteka.go/app/app.go
provides: Reusable app boot seam shared by generated main and parity tests
key_links:
- from: party/registry.go
to: pact/capabilities.go
via: optional capability discovery in Requires order
- from: lagoon/connection.go
to: backpack/app.go
via: app-scoped *sql.DB and *gorm.DB publication
- from: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
to: ../fonoteka.go/plugins/golem15/user/plugin.go
via: Requires and named jwt.auth lookup
---
<objective>
**As a** Fonoteka user with a valid token, **I want to** request the seeded genre list from a real Postgres backed Go app, **so that** the first complete config → plugin → DB → auth → HTTP path exists.
Purpose: Establish a runnable vertical slice on the actual app binary. Plan 02 refines the query and Plan 03 makes the recorded PHP parity fixture green.
Output: Shared database and migration runtime, ServeMux group and middleware registry, HS256 verifier, two app plugins, and a JWT protected genre route.
</objective>
<execution_context>
@/home/jin/.codex/get-shit-done/workflows/execute-plan.md
@/home/jin/.codex/get-shit-done/templates/summary.md
</execution_context>
<context>
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-VALIDATION.md
@.planning/phases/03-first-vertical-slice-genres-end-to-end/03-PATTERNS.md
<interfaces>
`party.Plugin` has `ID`, `Requires`, `Register(*backpack.App) error`, `Boot(*backpack.App) error`; `party.Activate` returns dependency ordered plugins. `pact.HasConfig` and `HasCommands` are current optional capability patterns. `backpack.App` publishes app scoped services. `bonfire.Command` uses string flags and `Run(context.Context, bonfire.Input, bonfire.Output) error`; `validCommandName` currently permits only `build` and `dev` as bare names. `internal/build/build.go` generates app `main.go` and blank imports. The app parity suite already has a pgx `*sql.DB` from testcontainers.
</interfaces>
</context>
<tasks>
<task type="auto">
<name>Task 1: Initialize the real app database through plugin migrations</name>
<files>go.mod, go.sum, bonfire/command.go, internal/build/build.go, pact/capabilities.go, party/registry.go, lagoon/connection.go, lagoon/migrations.go, lagoon/commands.go, cmd/summer/main.go, ../fonoteka.go/go.mod, ../fonoteka.go/go.work, ../fonoteka.go/README.md, ../fonoteka.go/summer.yaml, ../fonoteka.go/config/app.yaml, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/main.go, ../fonoteka.go/plugins.gen.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/user/config/config.yaml, ../fonoteka.go/plugins/golem15/user/migrations.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/migrations.go, ../fonoteka.go/plugins/golem15/fonoteka/genre.go</files>
<read_first>go.mod, bonfire/command.go, cmd/summer/main.go, internal/build/build.go, pact/capabilities.go, party/registry.go, backpack/app.go, compass/config.go, compass/env.go, ../fonoteka.go/go.mod, ../fonoteka.go/README.md, ../fonoteka.go/parity/synthetic_test.go, examples/hello/main.go, examples/hello/summer.yaml, examples/hello/plugins/base/config/config.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/version.yaml, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.0.9/rename_taxonomy_and_notes_columns.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/updates/v1.1.0/seed_genre_and_various_artist_taxonomy.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/Collection.php, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-RESEARCH.md</read_first>
<action>Build the smallest useful command path: `fonoteka migrate` and `summer migrate` in an app directory use the same `bonfire` command contract to load config, activate generated plugin imports, open `database.dsn` (env `SUMMER_DATABASE__DSN`) through `sql.Open("pgx", ...)`, `PingContext`, publish that exact `*sql.DB` and a GORM handle opened with `postgres.Config{Conn: sqlDB}`, then run each plugin's ordered `[]*gormigrate.Migration` from `pact.HasMigrations` in `party.Activate` order. Extend `bonfire.validCommandName` for bare `serve` and `migrate`; keep colon names for `migrate:rollback` and `migrate:status`. The `summer` tool may delegate app runtime commands to the generated binary, but cannot import app packages or recurse. Give each plugin a validated, separate history table (`summer_migrations_golem15_user`, `summer_migrations_golem15_fonoteka`) with transactions. Create the app workspace and two modules; `golem15.fonoteka.Requires()` returns `[]string{"golem15.user"}`. The user set creates minimal `users` with `must_change_password`; its config file declares `jwt.secret` under the merged `golem15.user.jwt.secret` path (env `SUMMER_GOLEM15__USER__JWT__SECRET`) without a production default. The Fonoteka set creates full `golem15_fonoteka_genres` directly, with no `item_categories` detour, plus minimal collections/albums/editor pivot/context tables required by D-01–D-03, then seeds the 15 slug/name pairs in PHP order in a separate idempotent data migration with a reverse callback. Name the PHP update files in migration comments. Use explicit DDL, never `AutoMigrate`. Do not create a River listener pool. Document `CREATE DATABASE ... TEMPLATE template0 ENCODING 'UTF8' LOCALE_PROVIDER icu ICU_LOCALE 'pl-PL'` in app README/config setup, and make boot check Postgres 16 `pg_database.datlocprovider='i'` and `daticulocale='pl-PL'` before migration. Set the existing Postgres 16 testcontainer's `POSTGRES_INITDB_ARGS` to `--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8` via `testcontainers.WithEnv`, so both synthetic and real app tests use the same database default. Resolve only the modules named in RESEARCH.md; inspect `go.mod`/`go.sum` and official upstream paths because slopcheck's age heuristic flagged recent GORM/gormigrate versions.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- A fresh Postgres 16 database with ICU `pl-PL` accepts `fonoteka migrate`, yields exactly 15 genre slugs in PHP seed order, and has separate user/Fonoteka history tables.
- A libc or non-Polish database fails before migrations with an actionable locale error; no migration calls `AutoMigrate`.
- The same `*sql.DB` is supplied to GORM and app services; the separate River listener pool is deferred to Phase 11.
- Both root and app `go vet ./...` and `go test ./...` exit 0 before commit.
</acceptance_criteria>
<done>A developer can initialize the actual Fonoteka Go database with one command and immediately query the 15 migrated genres.</done>
</task>
<task type="auto">
<name>Task 2: Serve the seeded genre list behind real cross-plugin JWT middleware</name>
<files>pact/capabilities.go, party/registry.go, surf/router.go, surf/middleware.go, bouncer/jwt.go, bouncer/context.go, lagoon/commands.go, internal/build/build.go, ../fonoteka.go/app/app.go, ../fonoteka.go/parity/genre_smoke_test.go, ../fonoteka.go/main.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/genre.go</files>
<read_first>pact/capabilities.go, party/registry.go, bonfire/command.go, internal/build/build.go, backpack/app.go, towel/context.go, ../fonoteka.go/main.go, ../fonoteka.go/parity/synthetic_test.go, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/plugins/golem15/user/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/middleware/JwtAuthenticate.php, /media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Http/Middleware/BaseMiddleware.php, /media/nvme/dev/golem15/fonoteka/vendor/php-open-source-saver/jwt-auth/src/Claims/Expiration.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/middleware/RequirePasswordChange.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/routes.php, /media/nvme/dev/golem15/fonoteka/config/jwt.php, ../fonoteka.go/parity/fixtures/routes/get_genres_jwt.yaml, .planning/phases/03-first-vertical-slice-genres-end-to-end/03-CONTEXT.md</read_first>
<action>Add `pact.HasRoutes`, `HasMiddleware`, and `HasModels` as optional interfaces, then assemble a per-app `surf` router on `http.NewServeMux` after all plugins register. Implement `Group(prefix, surf.Use(names...), callback)`, `Get(path, http.HandlerFunc)`, and per-route named middleware; compile the genre declaration as `GET /_fonoteka/api/v1/genres`. Resolve middleware names at boot, with a missing name error containing the registering plugin ID and name. Wrap every route in recover → CORS → locale → auth group → must-change-password → org context → rate limit → handler; recover returns opaque JSON 500, CORS preflight uses configured `http.cors.allowed_origins` and bypasses auth, locale stores `Accept-Language` in context, org context is a slot, rate limit is a no-op interface. In `bouncer`, parse JWT with `WithValidMethods([]string{"HS256"})` and `WithExpirationRequired`, require nonempty `sub`, load the corresponding persisted user through a `UserProvider`, and store it through an unexported context key. `golem15.user` registers `jwt.auth`, reads a nonempty secret from `golem15.user.jwt.secret` with `SUMMER_GOLEM15__USER__JWT__SECRET` overlay, and reproduces PHP's 401 JSON `{"error":true,"message":...}`. Use `Token not provided` for a missing bearer, `Token has expired` for an expired token, and `User not found` for an unknown subject as named in the PHP JWT package; capture the PHP response for malformed and bad-signature cases on the isolated Phase 2 PHP server before finalizing those translations. `golem15.fonoteka` registers `inv.must-change-password`, returns `{"error":"Password change required","must_change_password":true}` with 423, and references both names in its group. The initial handler reads all migrated genres through GORM and emits a dedicated `data` DTO with `id`, `name`, `slug`, integer `album_count:0`, an initialized array, `Content-Type: application/json` and `Cache-Control: no-cache, private`; Plan 02 replaces the zero count with the complete scoped query. Expose a graceful signal-aware `serve` command on both binaries. Put the app boot and handler assembly behind an importable `../fonoteka.go/app` package that accepts config, plugin IDs and the existing SQL pool; generated main and parity tests call the same seam, with compiled plugin imports kept in the appropriate entry point. Add a focused `genre_smoke_test.go` in the existing Postgres test package that boots this seam and sends one valid-token HTTP request without marking corpus routes ported yet. Use a fixed test-only token secret; token issuing remains outside production APIs.</action>
<verify><automated>go vet ./... &amp;&amp; go test ./... &amp;&amp; (cd ../fonoteka.go &amp;&amp; go vet ./... &amp;&amp; go test ./...)</automated></verify>
<acceptance_criteria>
- A valid HS256 token for a persisted user returns 200 from `GET /_fonoteka/api/v1/genres` and a nonempty `data` array read from Postgres.
- Missing, malformed, expired, wrong-algorithm, bad-signature, missing-subject and unknown-user tokens never reach the handler; a locked user receives PHP's 423 JSON.
- Missing `jwt.auth` or `inv.must-change-password` fails boot with the plugin and middleware name; the framework imports no Fonoteka package.
- Both binaries expose `serve`; `summer build` regenerates the app entry point without losing runtime commands; root and app vet/test exit 0.
</acceptance_criteria>
<done>A token-bearing user can fetch the real seeded genre list through the full named middleware path.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|---|---|
| Config and Go module resolution → process | Secret, DSN and dependency metadata enter the runtime. |
| Bearer token → user lookup → handler | Untrusted claims cross into authenticated request context. |
| Plugin declarations → ServeMux | A missing or misordered named guard could expose a route. |
## STRIDE Threat Register
| Threat ID | Category | Severity | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-03-01 | Tampering | high | migrations/locale | mitigate | Explicit immutable DDL, per-plugin history tables, database locale check before migrations. |
| T-03-02 | Elevation of privilege | high | `surf` middleware registry | mitigate | Fail boot on missing guard, preserve fixed stage order, inspect route registration. |
| T-03-03 | Spoofing | high | `bouncer`/`jwt.auth` | mitigate | Pin HS256, require exp/sub, verify signature and persisted user, reject empty secret. |
| T-03-SC | Tampering | medium | Go module resolution | mitigate | Official upstream path verification plus `go.mod`/`go.sum` review; document slopcheck's recent-version false positives. |
</threat_model>
<verification>
Demonstrate database migration and one authenticated HTTP request on Postgres 16 ICU `pl-PL`. Run root and app vet/test after each task; preserve Phase 2 parity tests even though the route stays pending until Plan 03.
</verification>
<success_criteria>
The app binary migrates and serves the 15 seeded genres from Postgres through a real JWT guard and cross-plugin named middleware, using the same SQL pool as GORM and no `AutoMigrate`.
</success_criteria>
<output>
Create `.planning/phases/03-first-vertical-slice-genres-end-to-end/03-01-SUMMARY.md` after completion.
</output>