# 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