Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-PATTERNS.md

31 KiB

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):

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):

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):

$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):

$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):

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

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:

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):

$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

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

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

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

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

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

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.

Analog: controllers/api/token_api_controller.go

Imports/dependency pattern (:13-20):

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):

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):

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):

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

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

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

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: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

func (p *Plugin) Commands() []bonfire.Command {
	return []bonfire.Command{console.RequirePasswordChange(p.app)}
}

Command value analog: example greeter plugin plugin.go:78-118

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

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

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

- 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

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

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