docs(08): finalize oauth plans after final checker pass

This commit is contained in:
Jakub Zych
2026-09-23 18:42:49 +02:00
parent 2d7ac66605
commit a59e69211d
5 changed files with 653 additions and 14 deletions

View File

@@ -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.

View File

@@ -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=<opaque>."
- "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.
</behavior>
<action>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.</action>
<action>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 &amp;&amp; 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.</action>
<verify>
<automated>go test ./wristband -run '^Test(Register|Registration)' -count=1 &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/classes/auth -run '^TestOAuth(RegistrationStore|RegistrationCap)$' -count=1)</automated>
</verify>
@@ -126,7 +126,7 @@ Output: Corrected models/migration, wristband DCR, GORM backend, configured raw
<task type="auto" tdd="true">
<name>Task 3: Configure and mount persistent DCR on the assembled raw surface</name>
<files>../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</files>
<files>../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</files>
<read_first>
.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.
</behavior>
<action>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.</action>
<action>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 &amp;&amp; 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.</action>
<verify>
<automated>(cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka -run '^TestOAuth(RegisterAssembled|MetadataAssembled|RawRegistrationSurface)$' -count=1)</automated>
</verify>

View File

@@ -135,7 +135,7 @@ Existing serializer:
</behavior>
<action>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.</action>
<verify>
<automated>go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 &amp;&amp; cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1</automated>
<automated>go test ./wristband -run 'Test(Refresh|Replay|Sweep)' -count=1 &amp;&amp; (cd ../fonoteka.go &amp;&amp; go test ./plugins/golem15/fonoteka/classes/auth -run 'TestOAuth(Refresh|Replay|Sweep)' -count=1)</automated>
</verify>
<acceptance_criteria>
- Normal refresh and sequential/concurrent replay tests pass under real Postgres.

View File

@@ -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.
</behavior>
<action>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.</action>
<action>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.</action>
<verify>
<automated>(cd ../fonoteka.go &amp;&amp; go test ./parity -run '^TestOAuthFlows$' -count=1)</automated>
<automated>(cd ../fonoteka.go &amp;&amp; go test ./parity -run '^TestOAuthFlows$' -count=1 &amp;&amp; go run ./parity/check_corpus.go --manifest parity/manifest.yaml --fixtures parity/fixtures --check-secrets) &amp;&amp; test "$(stat -c '%a' /tmp/summercms-parity/mcp-lifecycle.vars)" = 600 &amp;&amp; test "$(stat -c '%a' /tmp/summercms-parity/pkce.vars)" = 600</automated>
</verify>
<acceptance_criteria>
- `mcp-lifecycle.yaml` contains the locked sequence and placeholder references, not recoverable credential values.

View File

@@ -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=<id>
client_secret=<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