diff --git a/.planning/STATE.md b/.planning/STATE.md index 83d1f17..4105b57 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -2,14 +2,14 @@ gsd_state_version: 1.0 milestone: v1.0 milestone_name: milestone -status: planning +status: executing stopped_at: Phase 8 UI-SPEC approved -last_updated: "2026-09-23T10:52:49.807Z" -last_activity: 2026-09-23 +last_updated: "2026-09-23T16:42:33.535Z" +last_activity: 2026-09-23 -- Phase 08 planning complete progress: total_phases: 15 completed_phases: 7 - total_plans: 45 + total_plans: 55 completed_plans: 45 percent: 47 --- @@ -27,8 +27,8 @@ See: .planning/PROJECT.md (updated 2026-09-16) Phase: 08 Plan: Not started -Status: Ready to plan -Last activity: 2026-09-23 +Status: Ready to execute +Last activity: 2026-09-23 -- Phase 08 planning complete Progress: [██████████] 100% @@ -214,7 +214,7 @@ None yet. ### Blockers/Concerns -- Phase 8 (OAuth2.1) needs a pre-planning check of `wavepath.org/plugins/golem15/oauthserver` to resolve whether `ClientCredentialsStorage`/`TokenExchangeStorage` are needed — flagged in research/SUMMARY.md Gaps, unresolved. +- ~~Phase 8 (OAuth2.1) pre-planning check of the PHP OAuth server for `ClientCredentialsStorage`/`TokenExchangeStorage`~~ — resolved 2026-09-23 during Phase 8 discussion/research: the PHP server is hand-rolled and supports only `authorization_code`/`refresh_token`, so neither interface is needed (see 08-CONTEXT.md, 08-RESEARCH.md). - Phase 9 (admin schema pipeline / relation manager) is the least-precedented design surface in the research — plan with `--research-phase`. - Phase 11 (River dual-driver split) is documented but unverified against a real build — plan with `--research-phase` and budget a timed-latency test. diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-02-PLAN.md b/.planning/phases/08-oauth2-1-authorization-server/08-02-PLAN.md index 4ebf649..9cea583 100644 --- a/.planning/phases/08-oauth2-1-authorization-server/08-02-PLAN.md +++ b/.planning/phases/08-oauth2-1-authorization-server/08-02-PLAN.md @@ -16,7 +16,6 @@ files_modified: - ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go - ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go - ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml - - ../fonoteka.go/config/app.yaml - ../fonoteka.go/plugins/golem15/fonoteka/plugin.go - ../fonoteka.go/plugins/golem15/fonoteka/routes.go - ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go @@ -24,6 +23,7 @@ autonomous: true requirements: [AUTH-05, AUTH-06, AUTH-07] must_haves: truths: + - "D-03: Configuration defaults pending requests and authorization codes to 600s, access tokens to 3600s, refresh tokens to 30 days, the DCR client cap to 200, and the unconsented-client sweep to 24h; derives issuer from app.url with its trailing slash trimmed, defaults the RFC 8707 resource to https://mcp.plytarium.com/mcp, and builds consent URLs as app.url + /connect?request=." - "D-07: Public clients and multiple pending authorization requests persist through one transaction-scoped GORM adapter." - "D-17: Expiry sweeps delete only expired lifecycle rows and retain unexpired replay evidence." - "D-02/D-21: A connector can dynamically register through the assembled JSON-only 64 KiB-bounded route and receive an exact persistent response." @@ -111,7 +111,7 @@ Output: Corrected models/migration, wristband DCR, GORM backend, configured raw - Raw client secrets are returned once, SHA-256 hashes alone persist, and verification uses `crypto/subtle.ConstantTimeCompare` over fixed transforms. - Sweep/cap/create share one transaction; concurrent cap-1 registration yields one success and one native error. - D-01/D-02/D-04/D-05/D-06/D-07/D-17/D-21: add app-agnostic Backend/Tx records, deterministic clock/entropy seams, fixed-transform crypto, a local exact response writer, and the RFC 7591 handler. Apply `http.MaxBytesReader` before decode and return the native `invalid_client_metadata` body for overflow/malformed/non-JSON. Strip control characters, cap names at 120, return raw secrets once, and persist hashes only. Implement the app GORM adapter using only the callback `*gorm.DB`; serialize stale sweep/cap/create in one transaction and expose later row-lock lifecycle methods without importing GORM into wristband. First validate exact `TestPhase8RedRegistration` and `TestPhase8RedRegistrationStore` JSON RED streams, then make focused unit/Postgres tests green. + D-01/D-02/D-04/D-05/D-06/D-07/D-17/D-21: add app-agnostic Backend/Tx records, deterministic clock/entropy seams, fixed-transform crypto, a local exact response writer, and the RFC 7591 handler. Apply `http.MaxBytesReader` before decode and return the native `invalid_client_metadata` body for overflow/malformed/non-JSON. Strip control characters, cap names at 120, return raw secrets once, and persist hashes only. Implement the app GORM adapter using only the callback `*gorm.DB`; serialize stale sweep/cap/create in one transaction and expose later row-lock lifecycle methods without importing GORM into wristband. Before implementation, run `scripts/check-phase8-red.sh go PHASE8_RED:registration git.golem15.com/golem15/summercms/wristband TestPhase8RedRegistration -- go test -json ./wristband -run '^TestPhase8RedRegistration$' -count=1` and `scripts/check-phase8-red.sh go PHASE8_RED:registration-store git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth TestPhase8RedRegistrationStore -- bash -lc "cd ../fonoteka.go && go test -json ./plugins/golem15/fonoteka/classes/auth -run '^TestPhase8RedRegistrationStore$' -count=1"`; each invocation must observe its exact sentinel once, the anchored selected test and named package only, and zero unexpected fail actions/package/test events, build/setup/syntax failures, panics, malformed JSON events, or zero-test selection. Then make focused unit/Postgres tests green. go test ./wristband -run '^Test(Register|Registration)' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth -run '^TestOAuth(RegistrationStore|RegistrationCap)$' -count=1) @@ -126,7 +126,7 @@ Output: Corrected models/migration, wristband DCR, GORM backend, configured raw Task 3: Configure and mount persistent DCR on the assembled raw surface - ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/config/app.yaml, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go + ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go .planning/phases/08-oauth2-1-authorization-server/08-UI-SPEC.md ../fonoteka.go/plugins/golem15/fonoteka/plugin.go @@ -141,7 +141,7 @@ Output: Corrected models/migration, wristband DCR, GORM backend, configured raw - Only register carries `throttle:fonoteka-oauth-register`; metadata remains raw with no middleware. - Config defaults are pending/code 600s, access 3600s, refresh 30 days, DCR cap 200, stale age 24h, resource URL, and register max 65,536. - D-03: add `plugins.golem15.fonoteka.oauth.*` defaults and construct the store-backed server in Plugin.Boot while preserving 08-01 metadata. D-09: mount register in the raw group with only its named throttle. D-10/D-12: register no oauth guard and add no rich Bearer/resource-server surface. Add an assembled real-Postgres `TestPhase8RedRegistrationApp` first, validate its exact JSON RED stream, then assert exact bytes/headers, durable reload, middleware isolation, config values, and unchanged metadata. + D-03: add `plugins.golem15.fonoteka.oauth.*` defaults and construct the store-backed server in Plugin.Boot while preserving 08-01 metadata. Read the existing `../fonoteka.go/config/app.yaml` only as the `app.url` source; do not modify it. D-09: mount register in the raw group with only its named throttle. D-10/D-12: register no oauth guard and add no rich Bearer/resource-server surface. Add an assembled real-Postgres `TestPhase8RedRegistrationApp`, then before implementation run `scripts/check-phase8-red.sh go PHASE8_RED:registration-app git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka TestPhase8RedRegistrationApp -- bash -lc "cd ../fonoteka.go && go test -json ./plugins/golem15/fonoteka -run '^TestPhase8RedRegistrationApp$' -count=1"`; it must observe the exact sentinel once, the anchored selected test and named package only, and zero unexpected fail actions/package/test events, build/setup/syntax failures, panics, malformed JSON events, or zero-test selection. Then assert exact bytes/headers, durable reload, middleware isolation, config values, and unchanged metadata. (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka -run '^TestOAuth(RegisterAssembled|MetadataAssembled|RawRegistrationSurface)$' -count=1) diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-06-PLAN.md b/.planning/phases/08-oauth2-1-authorization-server/08-06-PLAN.md index d5a9c26..8cece69 100644 --- a/.planning/phases/08-oauth2-1-authorization-server/08-06-PLAN.md +++ b/.planning/phases/08-oauth2-1-authorization-server/08-06-PLAN.md @@ -135,7 +135,7 @@ Existing serializer: Implement D-04, D-05, D-07, and D-17's refresh branch in wristband and the GORM adapter. Authenticate the client using the same Basic-over-form rule, hash the presented refresh secret, lock its row, reject expired/revoked/wrong-client grants, and rotate atomically by revoking the old access token, minting/persisting its successor, creating the next refresh secret/hash, and linking `rotated_to_id`. If a spent token is presented, traverse and revoke the whole lineage and associated access tokens, return nil from the transaction so the kill commits, then return `invalid_grant` from the handler. Keep the old scopes, collection IDs, offline flag, and client binding. Sweep only rows whose `expires_at` is past; do not delete unexpired replay evidence. - go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 && cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1 + go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 && (cd ../fonoteka.go && go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1) - Normal refresh and sequential/concurrent replay tests pass under real Postgres. diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-09-PLAN.md b/.planning/phases/08-oauth2-1-authorization-server/08-09-PLAN.md index 7ab0584..c9aafe6 100644 --- a/.planning/phases/08-oauth2-1-authorization-server/08-09-PLAN.md +++ b/.planning/phases/08-oauth2-1-authorization-server/08-09-PLAN.md @@ -132,9 +132,9 @@ Unchanged MCP inputs: - Every request id, code, verifier, client secret, access token, and refresh token is represented only by a typed placeholder in committed fixtures; private vars are mode 0600. - Nine manifest entries become ported only after their exact route replay passes; pending never increments passing. - D-16: extend the existing capture script/rules and use the Phase 2 isolated-PHP process to record the locked lifecycle. Issue the confidential client through `fonoteka:oauth-client`; exercise scope ceiling truncation and invalid-scope redirect with `client_secret_basic`. Capture all secret-bearing values with explicit pkce/token/credential categories into the private store, confirm both vars files are 0600, and commit only symbolic variable references. Add full and projected replays through `newConfiguredTarget` with real Postgres. After each of the four raw and five JWT route subtests passes, change only those manifest entries to `status: ported`; keep honest corpus accounting. + D-16: extend the existing capture script/rules and use the Phase 2 isolated-PHP process to record the locked lifecycle. Issue the confidential client through `fonoteka:oauth-client`; exercise scope ceiling truncation and invalid-scope redirect with `client_secret_basic`. Capture all secret-bearing values with explicit pkce/token/credential categories into the task-verifiable private files `/tmp/summercms-parity/mcp-lifecycle.vars` and `/tmp/summercms-parity/pkce.vars`; create each with mode 0600, retain them only through the focused verification below, and commit only symbolic variable references. Add full and projected replays through `newConfiguredTarget` with real Postgres. After each of the four raw and five JWT route subtests passes, change only those manifest entries to `status: ported`; keep honest corpus accounting. - (cd ../fonoteka.go && go test ./parity -run '^TestOAuthFlows$' -count=1) + (cd ../fonoteka.go && go test ./parity -run '^TestOAuthFlows$' -count=1 && go run ./parity/check_corpus.go --manifest parity/manifest.yaml --fixtures parity/fixtures --check-secrets) && test "$(stat -c '%a' /tmp/summercms-parity/mcp-lifecycle.vars)" = 600 && test "$(stat -c '%a' /tmp/summercms-parity/pkce.vars)" = 600 - `mcp-lifecycle.yaml` contains the locked sequence and placeholder references, not recoverable credential values. diff --git a/.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md b/.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md new file mode 100644 index 0000000..9eec5d6 --- /dev/null +++ b/.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md @@ -0,0 +1,639 @@ +# Phase 08: OAuth2.1 Authorization Server - Pattern Map + +**Mapped:** 2026-09-23 +**Files analyzed:** 40 concrete or logical new/modified targets +**Analogs found:** 34 / 40 (six protocol/security targets have no native Go analog) + +## Scope Notes + +- The target list comes from `08-CONTEXT.md` D-01 through D-21 and `08-RESEARCH.md`'s recommended structure, schema correction, Wave 0 gaps, and validation map. +- `wristband` is framework-only and must never import `fonoteka.go`, GORM, or app models. +- The PHP files are the byte/state source of truth. They are contract analogs, not Go structure analogs. +- Existing untracked `go.work.sum` and concurrently created phase artifacts were not touched. +- The current repository differs from two research assumptions: `ApiTokenManager` still hard-codes `inv_`, and `bonfire` supports scalar string flags only. Both gaps are included below. + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | +|---|---|---|---|---| +| `wristband/server.go` | provider/controller | request-response | `surf/router.go`; `wire/response.go` | role-match | +| `wristband/stores.go` | provider/service | CRUD + transactional | `bouncer/registry.go` (interfaces); app `classes/active_collection.go` (transaction) | partial | +| `wristband/authorize.go` | controller/service | request-response + CRUD | PHP `OAuthAuthorizeController.php` | contract-exact; no Go analog | +| `wristband/token.go` | controller/service | request-response + transactional CRUD | PHP `OAuthTokenController.php`; `OAuthCodeManager.php` | contract-exact; no Go analog | +| `wristband/register.go` | controller/service | request-response + CRUD | PHP `OAuthRegisterController.php` | contract-exact; no Go analog | +| `wristband/crypto.go` | utility | transform | app `classes/auth/api_token_manager.go`; `token_guard.go` | role-match | +| `wristband/*_test.go` (including in-memory store) | test/provider | request-response + CRUD + concurrency | `bouncer/*_test.go`; app `classes/auth/api_token_manager_test.go` | role-match | +| `bonfire/command.go` | config/contract | command input | existing `bonfire/command.go` | modification; exact home | +| `bonfire/root.go` | provider | command parsing | existing `bonfire/root.go` | modification; exact home | +| `bonfire/output_test.go` | test | command parsing | `TestFlagAndArgumentParsing` in same file | exact | +| `scripts/check-phase8.sh` | config/test gate | batch | `scripts/check-phase3.sh` | exact family | +| `../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml` | config | transform | user plugin `config/config.yaml` | exact | +| `../fonoteka.go/config/app.yaml` | config | transform | existing app config | exact home | +| `../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go` | model | CRUD | same file + `models/api_token.go` | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go` | model | CRUD | same file + `models/api_token.go` | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/updates/12_oauth_schema_correction.go` (name discretionary) | migration | batch/DDL | `updates/11_secrets_slice.go` | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/updates/*oauth*_test.go` | test | batch/DDL | `classes/auth/postgres_test.go`; existing migration tests | role-match | +| `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go` | service/provider | transactional CRUD | `classes/active_collection.go`; `collection_write_service.go` | role-match; no row-lock analog | +| `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_token_issuer.go` | service/adapter | CRUD | `classes/auth/api_token_manager.go` | exact seam | +| `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/api_token_manager.go` | service | CRUD + transform | same file | modification; exact home | +| `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go` and issuer tests | test | transactional CRUD + concurrency | `classes/auth/postgres_test.go`; `api_token_manager_test.go` | exact harness | +| `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go` | controller | request-response + CRUD | `controllers/api/token_api_controller.go` | role/data-flow exact | +| `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go` | controller | request-response + CRUD | `controllers/api/token_api_controller.go` | role/data-flow exact | +| `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go` | controller | request-response | `controllers/api/me_locale_controller.go`; PHP `MeTokenController.php` | role-match + contract-exact | +| `../fonoteka.go/plugins/golem15/fonoteka/controllers/api/*oauth*_test.go` and `me_token_controller_test.go` | test | request-response + CRUD | route/controller tests; PHP OAuth tests | role-match | +| `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go` | utility/command | CRUD | user plugin command registration; PHP `IssueOAuthClient.php` | role-match + contract-exact | +| `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go` | test | command + CRUD | user `console_test.go` | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/routes.go` | route | request-response | same file | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` | provider/config | event-driven bootstrap | same file; user `plugin.go` | exact | +| `../fonoteka.go/plugins/golem15/fonoteka/routes_*test.go` / `plugin_boot_test.go` | test | request-response + bootstrap | same files | exact | +| `../fonoteka.go/parity/oauth_flow_test.go` | test | request-response replay | `parity/nuxt_flow_test.go` | exact | +| `../fonoteka.go/parity/fixtures/mcp/mcp-lifecycle.yaml` | test fixture | request-response sequence | existing `mcp-oauth.yaml`, `mcp-tools.yaml` | exact family | +| `../fonoteka.go/parity/capture_clients.mjs` | utility/test client | event-driven + request-response | same file's `captureMcpOauth` | exact | +| `../fonoteka.go/parity/capture-rules.yaml` | config | transform/capture | existing OAuth capture rules | exact | +| `../fonoteka.go/parity/manifest.yaml` | config | batch/replay | existing nine pending entries | exact | +| `.planning/ROADMAP.md` | config/docs | transform | Phase 8 row/section in same file | exact | +| `.planning/REQUIREMENTS.md` | config/docs | transform | `AUTH-05` row in same file | exact | +| `.planning/notes/*oauth*.md` (exact name discretionary) | config/docs | transform | existing decision notes | role-match | +| `.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md` | test/audit | batch | Phase 6 `06-SECURITY-REVIEW.md` | exact template | +| Phase 6 supersession note (prefer Phase 8 plan/summary; do not silently rewrite locked history) | config/docs | transform | Phase 6 D-09 references | partial | + +## Pattern Assignments + +### `wristband/server.go`, `authorize.go`, `token.go`, `register.go` + +**Structural analogs:** `surf/router.go`, `wire/response.go` +**Contract analogs:** PHP OAuth controllers and `OAuthCodeManager.php` + +**Raw route behavior** (`surf/router.go:149-169`, `369-422`): + +```go +func (r *Router) GroupRaw(prefix string, middleware []string, fn func(pact.Router)) { + r.openGroup(prefix, middleware, true, fn) +} + +func (r *Router) wrap(rt route) (http.Handler, error) { + h := constrain(rt.handler, rt.constraints) + // ... apply per-route middleware ... + if rt.raw { + h = recoverBare(h) + } else { + h = recoverJSON(h) + } + return h, nil +} +``` + +Copy the `http.HandlerFunc` shape, but keep RFC response writing inside `wristband`; do not reuse house validation/envelopes. The server should expose handler methods suitable for direct `pact.Router` registration. + +**Exact JSON writer convention** (`wire/response.go:10-23`): + +```go +func WriteJSON(w http.ResponseWriter, status int, v any) { + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + WriteOpaque500(w) + return + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n"))) +} +``` + +Follow its no-trailing-newline and no-HTML-escaping technique, but use a wristband-local raw writer so encoding failures and protocol errors never acquire the house 500 envelope. + +**Authorize validation order** (PHP `OAuthAuthorizeController.php:25-61`): + +```php +$client = $clientId !== '' ? OAuthClient::where('client_id', $clientId)->first() : null; +if ($client === null || !$client->isUsable()) { + return $this->localError('Unknown client.'); +} +$redirectUri = (string) $request->query('redirect_uri', ''); +if ($redirectUri === '' || !in_array($redirectUri, $client->redirectUris() ?? [], true)) { + return $this->localError('Unregistered redirect URI.'); +} +// Only later failures redirect to the now-trusted URI. +``` + +Preserve this order exactly: client, exact redirect allow-list, response type, method, challenge, scopes/ceiling, resource, pending creation. Unknown client/redirect is local text 400 with no `Location`. + +**Ordered redirect construction** (PHP `OAuthAuthorizeController.php:146-169`): + +```php +$params = [ + 'error' => $error, + 'error_description' => $description, + 'iss' => rtrim((string) config('app.url'), '/'), +]; +if ($state !== null) { + $params['state'] = $state; +} +$query = http_build_query($params, '', '&', PHP_QUERY_RFC3986); +``` + +Implement one ordered-pair RFC 3986 helper. Do not use `url.Values.Encode()` because it sorts keys and uses form-style space encoding. + +**Token dispatch/error pattern** (PHP `OAuthTokenController.php:21-63`, `143-151`): + +```php +if ($grantType === '') return $this->rfcError('invalid_request'); +if ($grantType !== 'authorization_code' && $grantType !== 'refresh_token') { + return $this->rfcError('unsupported_grant_type'); +} +// authenticate client, then dispatch grant +$response->header('WWW-Authenticate', 'Basic realm="OAuth"'); +``` + +Go-specific parser rule from D-02: reject JSON first, then `r.ParseForm()` and read `r.Form` (body wins over query). Basic credentials override form credentials. The only 401 challenge is exactly `Basic realm="OAuth"`. + +**Registration body bound:** wrap only `/register` decoding with `http.MaxBytesReader` at 64 KiB and translate overflow to the ordinary `invalid_client_metadata` body. Raw routes are exempt from the house body-limit middleware (`surf/router.go:369-422`), so the bound belongs in wristband. + +### `wristband/stores.go` and app `classes/auth/oauth_store.go` + +**Closest Go transaction pattern:** `classes/active_collection.go:34-71` + +```go +func ResolveActiveCollection(ctx context.Context, gdb *gorm.DB, userID uint) (*models.Collection, error) { + var out *models.Collection + err := gdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // every read/write uses tx + return nil + }) + if err != nil { + return nil, err + } + return out, nil +} +``` + +Use the same context-bound single closure on the app side, but expose only app-agnostic wristband interfaces: + +```go +type Backend interface { + WithinTx(context.Context, func(Tx) error) error +} + +type Tx interface { + ClientStore + AuthCodeStore + RefreshTokenStore + AccessTokenIssuer +} +``` + +There is **no existing Go row-lock analog**. The adapter must add `clause.Locking{Strength: "UPDATE"}` for code and refresh lookup. `wristband` must not import `gorm.io/gorm/clause`. + +**Replay commit pattern** (PHP `OAuthCodeManager.php:163-232`): + +```php +$replayed = false; +$result = DB::transaction(function () use (&$replayed) { + $record = OAuthRefreshToken::where(...)->lockForUpdate()->first(); + if ($record->rotated_to_id !== null) { + $this->revokeLineage($record); + $replayed = true; + return null; + } + // rotate normally +}); +if ($replayed || $result === null) { + throw new OAuthInvalidGrantException(); +} +``` + +In Go, record an outcome, return `nil` from `WithinTx` so lineage revocation commits, then return `ErrInvalidGrant` outside the transaction. Returning the OAuth error inside the GORM callback would roll back the security action. + +### `wristband/crypto.go` + +**Analog:** app `classes/auth/api_token_manager.go:23-44` and `token_guard.go:66-69` + +```go +raw := make([]byte, 32) +if _, err := rand.Read(raw); err != nil { + return "", nil, err +} +secret := MintablePrefix + base64.RawURLEncoding.EncodeToString(raw) + +func hashToken(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return hex.EncodeToString(sum[:]) +} +``` + +Reuse the primitives, not the app function: wristband opaque values have no `inv_` prefix. Client ID = 16 random bytes; client secret/code/refresh/request handle use their PHP byte lengths; encode with `base64.RawURLEncoding`; persist SHA-256 hex only. Add `crypto/subtle.ConstantTimeCompare` on fixed-length transformed values for both client secret and S256 PKCE. + +### `wristband/*_test.go` + +**Analog:** app `classes/auth/api_token_manager_test.go:13-51` + +```go +secret, token, err := MintPersonalToken(1, "my token", nil, nil, nil) +if err != nil { t.Fatal(err) } +raw, err := base64.RawURLEncoding.DecodeString(strings.TrimPrefix(secret, "inv_")) +if err != nil || len(raw) != 32 { t.Fatalf(...) } +sum := sha256.Sum256([]byte(secret)) +if token.TokenHash != hex.EncodeToString(sum[:]) { t.Fatalf(...) } +``` + +Keep tests as plain Go table tests with deterministic clock/random injection. The in-memory backend should hold one mutex for the full transaction closure so concurrency tests model the seam rather than individual map operations. + +Required named families: metadata bytes/order; authorize validation and redirects; token parser asymmetry; Basic/post/none auth; PKCE syntax and comparison; code replay; refresh rotation/replay commit; DCR validation, cap and 64 KiB bound; expiry sweep; ordered URL encoding; no-sensitive-value logging. + +### App OAuth models and schema correction + +**Model analog:** `models/api_token.go:10-36` + +```go +type ApiToken struct { + TokenHash string `gorm:"column:token_hash" json:"-"` + OAuthClientID *string `gorm:"column:oauth_client_id"` +} + +func (ApiToken) Hidden() []string { return []string{"token_hash"} } +``` + +Preserve `json:"-"` plus `Hidden()` for every secret hash. Change only the required OAuth fields: + +- `OAuthClient.ClientSecretHash string` -> `*string`. +- `OAuthAuthCode.RequestID string` -> `*string`. +- `OAuthAuthCode.CodeHash string` -> `*string`. +- `OAuthAuthCode.UserID uint` -> `*uint`. + +**Migration analog:** `updates/11_secrets_slice.go:47-109`, registered by `updates/registry.go:7-14` + +```go +oauthTablesMigration = &gormigrate.Migration{ + ID: "202609180010_create_oauth_tables", + Migrate: func(tx *gorm.DB) error { + return execStmts(tx, []string{/* ordered SQL */}) + }, + Rollback: func(tx *gorm.DB) error { + return execStmts(tx, []string{/* reverse-safe SQL */}) + }, +} + +func init() { Register(apiTokenMigration, oauthTablesMigration, credentialsTablesMigration) } +``` + +Add a new migration ID; do not edit the already-applied `202609180010` migration. `up` drops four `NOT NULL` constraints and adds PHP-equivalent operational indexes idempotently. `down` must refuse safely if null lifecycle rows exist rather than coercing or deleting data. + +**Postgres test harness:** `classes/auth/postgres_test.go:37-52`, `122-156` + +```go +func TestMain(m *testing.M) { + if !testShort() { authPGErr = startAuthPostgres(ctx) } + code := m.Run() + stopAuthPostgres() + os.Exit(code) +} + +func authDB(t testing.TB) *gorm.DB { + if testing.Short() { t.Skip("requires testcontainers postgres") } + // lagoon.Use, migrate user then fonoteka, register hooks +} +``` + +Reuse this package harness. Add real-Postgres proofs for two simultaneous pending rows, pending-to-code nulling, public-client null secret, indexes, migration rollback refusal, `FOR UPDATE` code double-spend, concurrent refresh, replay lineage kill, and serialized DCR cap. + +### App token issuer and `api_token_manager.go` + +**Analog:** `classes/auth/api_token_manager.go:21-57` + +```go +func MintPersonalToken(userID uint, name string, scopes []string, expiresAt *time.Time, collectionIDs []uint) (string, *models.ApiToken, error) { + // generate inv_ secret and construct model; caller persists +} + +func RevokeToken(db *gorm.DB, token *models.ApiToken) error { + now := time.Now() + if err := db.Model(token).Update("revoked_at", now).Error; err != nil { return err } + token.RevokedAt = &now + return nil +} +``` + +The adapter should mint, set `OAuthClientID`, and persist through the transaction-scoped `*gorm.DB`. Revoke by access-token ID through the same transaction. Keep raw secret return one-way. + +Repository drift to resolve: line 14 currently declares `const MintablePrefix = "inv_"`; D-11 says the prefix is config-backed with `inv_` as the fonoteka value. Preserve byte parity and update tests so no default path changes the emitted prefix. + +### Consent and connected-app controllers + +**Analog:** `controllers/api/token_api_controller.go` + +**Imports/dependency pattern** (`:13-20`): + +```go +import ( + "git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/classes" + "git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/classes/auth" + "git.golem15.com/golem15/fonoteka/plugins/golem15/fonoteka/models" + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/bouncer" + "git.golem15.com/golem15/summercms/lagoon" + "git.golem15.com/golem15/summercms/wire" + "gorm.io/gorm" +) +``` + +**JWT dependency/auth pattern** (`:195-210`): + +```go +func deps(w http.ResponseWriter, r *http.Request, app *backpack.App) (*gorm.DB, *bouncer.Principal, bool) { + gdb, ok := app.Lookup[*gorm.DB]() + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeOpaque500(w) + return nil, nil, false + } + return gdb, user, true +} +``` + +Routes provide JWT auth. Controllers still derive the user from context and scope every pending/client/token query by ownership. Missing, stale, consumed, or foreign request handles collapse to the exact 404 contract. + +**Validation and active collection** (`:35-67`): + +```go +errs, err := lagoon.Validate(r.Context(), gdb, models.ApiToken{}, rules, fields, nil) +if len(errs) > 0 { + writeValidation(w, errs) + return +} +active, err := classes.ResolveActiveCollection(r.Context(), gdb, user.ID) +``` + +For consent, validate `request_id` and each submitted scope, then intersect submitted ∩ pending requested ∩ `auth.MintableScopes`. Collection IDs come only from `ResolveActiveCollection`; never trust request collection IDs. + +**Connected-app serialization/revocation** (`:90-113`, `117-172`): + +```go +err := gdb.WithContext(r.Context()). + Where("user_id = ? AND oauth_client_id IS NOT NULL AND revoked_at IS NULL", user.ID). + Order("created_at DESC").Find(&rows).Error + +data = append(data, serializeToken(gdb, &rows[i])) + +if err := auth.RevokeToken(gdb.WithContext(r.Context()), &token); err != nil { ... } +``` + +Reuse `serializeToken` rather than duplicating token fields, append sanitized `client_name`, count manual tokens separately, and call wristband lineage revocation in the same operation. Foreign/missing/manual IDs all return `{"error":"Token not found"}` with 404. + +### `me_token_controller.go` + +**Go handler analog:** `controllers/api/me_locale_controller.go:11-24` +**Exact response contract:** PHP `MeTokenController.php:22-37` + +```php +return response()->json(['data' => [ + 'scopes' => $token?->scopes ?? [], + 'collection_ids' => $token?->collectionIds(), + 'user_id' => $user?->id, + 'name' => $token?->name, +]]); +``` + +The `inv_token` guard already puts the matched `*models.ApiToken` in `bouncer.Credential` (`token_guard.go:32-64`). Read that credential and `bouncer.User`; do not perform a second bearer parse or database token lookup. Route it under `inv_token`, `throttle:fonoteka-api-token`, `inv.scope:read` and retain the existing backend 401 body/header behavior. + +### `routes.go`, `plugin.go`, and OAuth config + +**Route analog:** app `routes.go:10-43` + +```go +r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password"), func(g pact.Router) { + // JWT-owned token/consent/connected-app handlers +}) +r.Group("/api/v1/fonoteka", surf.Use("inv_token", "throttle:fonoteka-api-token", "inv.scope:read"), func(g pact.Router) { + // personal-token API including /me +}) +r.GroupRaw("/", surf.Use(), func(g pact.Router) { + // RFC handlers; throttle only token and register per route +}) +``` + +Register `/token` and `/register` with variadic per-route middleware names, not group middleware. Do not attach `jwt.auth`, `inv_token`, `inv.scope`, or any house-tagged middleware to raw RFC routes. + +**Plugin boot/provider analog:** app `plugin.go:44-83` + +```go +func (p *Plugin) Boot(app *backpack.App) error { + p.app = app + if gdb, ok := app.Lookup[*gorm.DB](); ok { + // construct/register app services + } + return nil +} +``` + +Construct one wristband server from config, GORM store backend, and access-token issuer during boot; store it on `Plugin` for route factories and commands. Do not register an `oauth` guard. + +**Config embedding analog:** user `plugin.go:41-43`, `165`; user `config/config.yaml:1-22` + +```go +//go:embed config +var configFS embed.FS + +func (p *Plugin) ConfigFS() fs.FS { return configFS } +``` + +Add `pact.HasConfig`, plugin defaults for pending/code/access/refresh TTLs, DCR cap, stale-client age, resource, paths/scopes/metadata values, and 64 KiB register limit. `app.url` belongs in app config/environment and is trimmed once while constructing options. Never hard-code the issuer into wristband. + +### `fonoteka:oauth-client` and the `bonfire` repeatable-flag gap + +**Registration analog:** user `plugin.go:188-190` + +```go +func (p *Plugin) Commands() []bonfire.Command { + return []bonfire.Command{console.RequirePasswordChange(p.app)} +} +``` + +**Command value analog:** example greeter plugin `plugin.go:78-118` + +```go +return []bonfire.Command{{ + Name: "greeter:hello", + Description: "Print the configured application name", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + out.Printf("name=%s\n", name) + return nil + }, +}} +``` + +**Exact required PHP signature/output:** `IssueOAuthClient.php:15-21`, `60-77`, `80-101` + +```text +fonoteka:oauth-client {name?} + --redirect-uri=* --scope=* --auth-method=client_secret_post + --client-id= --list + +client_id= +client_secret= +This secret is not recoverable and must be pasted into the connector now. +``` + +`--list` prints IDs, names, revocation, redirects, and ceiling but never a secret. + +**Gap:** `bonfire/root.go:65-75` always calls Cobra `String`/`StringP`, and `bonfire.Input` exposes only `Flag(name) (string, bool)` (`command.go:40-45`). Repeatable PHP `=*` options therefore have no current analog. Extend the command contract/root wiring with an explicit string-slice flag shape and test repeated values in `bonfire/output_test.go`; do not parse `os.Args` inside the app command. + +### Parity replay, lifecycle capture, and phase gate + +**Replay analog:** `../fonoteka.go/parity/nuxt_flow_test.go:13-48` + +```go +db := parityDB(t) +applyPluginMigrations(t, db) +h, cfg := newConfiguredTarget(t, db) +srv := httptest.NewServer(h) +t.Cleanup(srv.Close) + +flow, err := tide.LoadFlow(filepath.Join(dir, "nuxt-auth.yaml")) +store, err := tide.OpenStore("") +_, err = tide.ReplayFlow(t.Context(), flow, tide.ReplayConfig{ + Target: srv.URL, Store: store, BaseDir: dir, +}) +``` + +Create `oauth_flow_test.go` with named projections for OAuth-relevant steps from `mcp-oauth` and `mcp-tools`, plus full `mcp-lifecycle`. Projection must fail if an expected step disappears; do not replay unrelated later-phase browser calls. + +**Secret capture pattern:** `capture-rules.yaml:53-97` + +```yaml +- method: POST + path: /oauth/mcp/token + capture: + - from: request.form + name: code_verifier + as: pkce:mcp + category: pkce + - from: response.json + path: $.refresh_token + as: token:oauth-refresh + category: token +``` + +Extend categories/rules for every new lifecycle secret. Keep private vars and `pkce.vars` mode 0600; committed fixtures contain placeholders only. + +**Gate family:** `scripts/check-phase3.sh:6-63` + +```bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$(cd "$ROOT/../fonoteka.go" && pwd)" + +run_module() { + local dir="$2" + ( + cd "$dir" + go vet ./... + go test ./... + go test -race ./... + ) +} +``` + +Preserve fail-closed Docker checks, both-module vet/test/race, focused parity, corpus `--check-secrets`, and cleanup traps. Add real fonoteka-mcp startup with the three required environment variables and drive protected-resource discovery → AS metadata → DCR → PKCE authorize/login/consent → token → MCP tool → refresh. Do not modify the sibling MCP source. + +### Planning and security artifacts + +**Security review analog:** `06-SECURITY-REVIEW.md:1-18`, `30-64` + +```yaml +phase: 06 +status: verified +threats_total: 34 +threats_closed: 34 +threats_open: 0 +``` + +Copy the front matter, verdict summary, trust-boundary table, threat register, per-threat evidence, and command evidence structure. Phase 08 must map every `T-08-*` ID to a named failing-when-broken test and finish with zero unmapped threats. + +Correct Phase 8's ROADMAP/REQUIREMENTS wording from zitadel/oidc to the direct standard-library wristband port and correct the resource-server/header ownership. Record D-01 in a decision note. For Phase 6 D-09, prefer an explicit supersession note in the Phase 8 plan/summary over rewriting locked historical context unless the planner is specifically authorized to update historical docs. + +## Shared Patterns + +### Framework/App Boundary + +**Source:** `CLAUDE.md`; current package imports +**Apply to:** every `wristband/*` and app adapter file + +`wristband` owns protocol records, parsing, policy, transitions, response shapes, and interfaces. `fonoteka.go` owns GORM, users, collections, token models, config, and controllers. Verify with an import-boundary test/grep: framework code must contain no `fonoteka` import. + +### Authentication and Surface Isolation + +**Source:** app `routes.go:12-21`, `token_guard.go:32-64`, `surf/router.go:369-422` +**Apply to:** routes and all controllers + +- JWT group: consent and connected-app management only. +- Personal-token group: `/me` and resource APIs only, using existing `inv_token` and `inv.scope`. +- Raw group: metadata/authorize/token/register only; no auth guard and no house middleware. +- Never register the retired `oauth` guard. + +### Response Serialization + +**Source:** `wire/response.go:10-30`; app `token_api_controller.go:230-244` +**Apply to:** all handlers + +- App controllers use `wire.WriteJSON` through the established `writeJSON` helper and keep PHP's `Cache-Control: no-cache, private` behavior where recorded. +- Wristband raw handlers use local exact writers and per-path cache headers; no `data` envelope, no generic house validation/error body. +- Backend token-surface 401 stays `{"error":"Invalid token"}` with no new Bearer challenge. + +### Error Handling + +**Source:** `token_api_controller.go:117-144`, PHP OAuth controllers +**Apply to:** app controllers and raw handlers + +App ownership misses collapse to exact 404; unexpected storage/config/encoding failures are opaque. OAuth client/input/grant failures map to endpoint-native RFC bodies and statuses. Never expose storage errors or secrets in descriptions/logs. + +### Database Transactions and Locking + +**Source:** `active_collection.go:34-71`; PHP `OAuthCodeManager.php:97-232` +**Apply to:** code exchange, refresh rotation/replay, consent, revoke, DCR cap/sweep + +Bind context once, run every operation on the passed transaction handle, acquire row locks before checking single-use state, and keep mint/revoke/link updates atomic. Commit replay lineage revocation before returning the protocol error. + +### Testing + +**Source:** `classes/auth/postgres_test.go`; `nuxt_flow_test.go`; Phase 6 security review +**Apply to:** all plans + +Fast wristband behavior tests use the in-memory backend. Persistence/locking/migration and concurrency tests use the existing real-Postgres harness and skip only under `testing.Short()`. Route tests inspect the assembled surf route table, not a hand-built imitation. The final audit maps all 103 PHP OAuth test methods to named Go tests/subtests. + +### Sensitive Values + +**Source:** `capture-rules.yaml:53-97`; model `json:"-"`/`Hidden()` patterns +**Apply to:** stores, controllers, commands, capture, gates + +Never log request IDs, codes, verifiers, client secrets, access tokens, or refresh tokens. Store only hashes except the one-time command/DCR response. Keep fixture vars private and run corpus secret scanning in the gate. + +## No Native Go Analog Found + +| File/Concern | Role | Data Flow | Reason / Required Reference | +|---|---|---|---| +| `wristband/authorize.go` | controller/service | request-response + CRUD | No Go authorization-server state machine exists. Port validation order and bytes from PHP `OAuthAuthorizeController.php`, guided by RESEARCH patterns 3 and 5. | +| `wristband/token.go` refresh replay outcome | service | transactional CRUD | No Go lineage-rotation implementation exists. Port PHP `OAuthCodeManager.php:163-232`; ensure the replay kill commits before `invalid_grant`. | +| App `oauth_store.go` row locks | provider | transactional CRUD | Repository has GORM transactions but no `clause.Locking` use. Add app-only `FOR UPDATE` and real-Postgres contention tests. | +| `wristband/register.go` atomic cap | service | transactional CRUD | No atomic cap/sweep/create analog. Backend API must serialize the operation; test concurrent cap-1 registration. | +| Bonfire repeatable flags | config/provider | command parsing | Current `Flag` and `Input` are scalar-only. Extend bonfire with string-slice support before implementing exact `=*` options. | +| Ordered RFC 3986 query helper | utility | transform | Existing Go URL helpers do not preserve PHP insertion order/space bytes. Implement and fixture-test an ordered-pair encoder. | + +## Planner Warnings + +1. Schema correction is Wave 0. Handlers cannot model public clients or multiple pending requests until the four nullable fields and indexes are fixed. +2. Returning `ErrInvalidGrant` from inside the GORM callback rolls back replay revocation. Treat replay as a committed transaction outcome. +3. Validate the client and exact redirect URI before constructing any redirect. +4. Reject JSON `/token` requests before `ParseForm`; otherwise query parameters can make a JSON request succeed. +5. Keep rotated/revoked refresh rows until expiry; they are replay evidence. +6. Do not mount `/me`, consent, or connected apps on the wrong auth group. +7. Do not use `body.limit` on the raw registration route for D-21: its generic error shape would violate the endpoint-native `invalid_client_metadata` contract. +8. Do not claim D-14 until the real Node MCP starts, consumes `/api/v1/fonoteka/me`, completes a tool call, and refreshes against the Go backend. + +## Metadata + +**Analog search scope:** `summercms.go/{surf,wire,bouncer,bonfire,scripts}`, sibling `fonoteka.go/plugins/golem15/fonoteka`, sibling `fonoteka.go/parity`, canonical PHP OAuth implementation and unchanged MCP consumer +**Primary Go analogs:** `surf/router.go`, app `controllers/api/token_api_controller.go`, app `classes/auth/api_token_manager.go`, app `updates/11_secrets_slice.go`, app `parity/nuxt_flow_test.go` +**Contract sources:** PHP OAuth controllers/manager/client command and `MeTokenController.php` +**Pattern extraction date:** 2026-09-23