58 KiB
Phase 8: OAuth2.1 authorization server - Research
Researched: 2026-09-23
Domain: OAuth 2.1-style authorization-code server, PKCE, dynamic registration, refresh rotation, and exact PHP/client wire parity
Confidence: HIGH for the PHP/client contract, RFC requirements, and final end-to-end gate after the /api/v1/fonoteka/me scope decision was resolved
<user_constraints>
User Constraints (from CONTEXT.md)
Everything in this block is copied from
08-CONTEXT.mdand has locked-decision provenance. [VERIFIED:.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md]
Locked Decisions
Server engine
- D-01: Direct port on the standard library (
crypto/rand,crypto/sha256,crypto/subtle,net/url,encoding/base64RawURLEncoding). zitadel/oidc is not added. It was evaluated and dropped because it is an OIDC provider whose metadata document, error bodies and token model differ from the PHP bytes and cannot mintinv_tokens; PITFALLS.md already warned against trusting its defaults. A decision note (.planning/notes/) records this, and the "on zitadel/oidc" wording in ROADMAP.md Phase 8 and REQUIREMENTS.md AUTH-05 is corrected at plan time. STACK.md/ARCHITECTURE.md rows naming zitadel are superseded by this decision, not edited. - D-02: Parameter sources follow PHP per endpoint.
/tokenreads the urlencoded body plus the query string (GoParseFormsemantics, matching Laravelinput()for the clients that exist); a JSON body on/tokenisinvalid_request./registeris JSON-only: a non-JSON content type isinvalid_client_metadata"Request must be application/json"./authorizereads the query only. - D-03: Lifetimes and caps are config keys with PHP values as defaults (pending request 600 s, code 600 s, access token 3600 s, refresh token 30 days, DCR cap 200 clients, unconsented-client sweep after 24 h). Issuer is
app.urlwith trailing slash trimmed; the expected RFC 8707 resource isfonoteka.mcp.resource(defaulthttps://mcp.plytarium.com/mcp); the consent URL isapp.url+/connect?request=<opaque>. Exact key layout (the option discussed wasplugins.golem15.fonoteka.oauth.*for the app side, with wristband taking an options struct) is Claude's discretion. - D-04: Security-load-bearing treatment as in Phases 3 and 6: every plan carries
T-08-xxthreats (PKCE bypass, code replay, refresh replay and lineage kill, open redirect on authorize, client-secret timing, scope-ceiling escalation, cross-user consent, request_id leakage in logs, DCR flooding) and the phase closes with08-SECURITY-REVIEW.mdmapping each threat to a failing-when-broken test. Client-secret comparison iscrypto/subtle.ConstantTimeCompareover the sha256 hex, PKCE S256 comparison is constant-time too.
Package home and interfaces
- D-05: A new framework package
summercms.go/wristband(festival wristband, pairs withbouncer) owns the RFC surface: metadata document, authorize validation and pending-request creation, token endpoint (client authentication withclient_secret_post/client_secret_basic/none, code exchange, refresh rotation with lineage kill on replay), RFC 7591 registration with its validation rules and caps, PKCE, scope parsing and scope ceiling, redirect-URI allow-list rules, the unconsented-client sweep and the expiry sweep (D-16). It is app-agnostic: scope list, endpoint paths, issuer, resource, consent-URL builder and TTLs are options; it never imports fonoteka. - D-06: PHP's RFC-minimal response shapes are wristband's defaults. There are no response hooks; fonoteka adds nothing to reach byte parity. Metadata field values (
service_documentation,scopes_supported, auth methods,authorization_response_iss_parameter_supported) are options with the PHP values as fonoteka's config. - D-07: Storage seam: the app implements
ClientStore,AuthCodeStore,RefreshTokenStore(backed by the Phase 5 GORM modelsOAuthClient,OAuthAuthCode,OAuthRefreshToken) and anAccessTokenIssuerthat fonoteka satisfies with its Phase 7ApiTokenManager(mint with name, scopes, expiry, collection ids; revoke by id), then stampsoauth_client_id. Code exchange and refresh rotation run in one transaction with row locks as PHP'slockForUpdatedoes, so the store seam must expose a transaction boundary. Exact interface names and signatures are Claude's discretion; wristband ships an in-memory store for its own tests. - D-08: The app keeps what touches app shapes: consent show/store/deny on the JWT group (using
ActiveCollectionResolverforcollection_nameandcollection_ids,MINTABLE_SCOPESintersection,consented_atstamping), connected-apps index/destroy (Phase 7serializeTokenplusclient_name, revoke cascading through wristband's lineage kill), the/connectURL and route mounting. Consent calls wristband operations (issue code, deny pending) rather than touching code rows directly. - D-09: Routes mount on the Phase 6 raw group from
fonoteka.goroutes.go, withthrottle:fonoteka-oauth-tokenon/tokenandthrottle:fonoteka-oauth-registeron/registeras per-route middleware (P6 D-01 buckets already exist). Handlers are net/http handlers with swag annotations like the rest of the app. - D-10: The
oauthguard reserved by Phase 6 D-09 is retired. OAuth-issued tokens areinv_tokens theinv_tokenguard already authenticates;oauth_client_idis what distinguishes them in connected-apps. Wristband registers no guard. The Phase 6 reservation is closed with a note in the plan and in the Phase 6 docs wording. - D-11: The
inv_prefix stays for Płytarium because fonoteka-mcp's install script rejects any other prefix and its HTTP transport accepts onlyBearer inv_. The Phase 7 token manager reads its prefix from config withinv_as Płytarium's value, so nothing on the wire changes. A summer-themed framework default is deferred.
End-to-end proof and the header contract
- D-12: Success criterion 3 is reworded at plan time. The rich
WWW-Authenticate: Bearer ... resource_metadata=...header and the/.well-known/oauth-protected-resourcedocument are emitted by fonoteka-mcp (the resource server), not by the backend. The backend's contract is: exactlyWWW-Authenticate: Basic realm="OAuth"oninvalid_clientat/token, and the unchanged Phase 6 token-surface 401{"error":"Invalid token"}with no new headers. Adding RFC 6750 headers to backend 401s is rejected as a contract change. - D-13: Recorded MCP flows (
mcp-oauth,mcp-tools, and the newmcp-lifecyclefrom D-15) replay in the Go test suite infonoteka.go/parity, the waynuxt_flow_test.goreplaysnuxt-auth. - D-14: A
scripts/check-phase8.shgate (same family ascheck-phase2.sh/check-phase3.sh) boots the Go app on Postgres, starts the real Node fonoteka-mcp from/media/nvme/dev/golem15/fonoteka/fonoteka-mcpwithFONOTEKA_API_URL,FONOTEKA_MCP_PUBLIC_URLandFONOTEKA_MCP_AUTH_SERVERpointed at the Go backend, and drives the discovery chain with a scripted client: protected-resource metadata, the MCP's 401 hint, authorization-server metadata, DCR, PKCE authorize, login and consent through the JWT API, token, an MCP tool call with the issuedinv_token, then refresh. fonoteka-mcp is not modified. The scripted client's language and home are Claude's discretion (parity/capture_clients.mjsexists as a precedent). - D-15: No live vendor connect (Claude, ChatGPT, Grok) is part of acceptance; the automated gates cover the same DCR-plus-PKCE chain those apps use. The first real vendor connect happens at cutover.
Parity corpus and lifecycle
- D-16 (corpus): A second PHP flow,
mcp-lifecycle, is recorded against the isolated PHP instance with the Phase 2tidetooling (capture rules, private 0600 vars store,pkce.vars, no live secrets in git): DCR → authorize → consent → token → refresh → replay of the spent refresh token (invalid_grant, lineage dead) → connected-apps list showing the app → revoke → refresh after revoke fails → a deny path; plus a confidential client issued byfonoteka:oauth-clientwith a scope ceiling usingclient_secret_basic, showing ceiling truncation and theinvalid_scoperedirect. The four RFC route entries and five JWT-group entries inparity/manifest.yamlflip pending → ported through the usual gate; pending never counts as passing. - D-17 (sweep): Wristband adds an expiry sweep that PHP lacks, run where PHP runs its client sweep (
/register) and also on/token, deleting only rows pastexpires_at: pending requests, codes (used or not) and refresh rows. Revoked or rotated rows that have not expired stay, so replay detection and connected-apps semantics match PHP. No timer, no goroutine; Phase 11 may move it into a River job. It is unobservable in recorded replays because nothing expires within a run; the schema-diff and db-capture harness must not be affected. - D-18 (tests): All PHP OAuth tests are ported (functional: OAuthAuthorizeTest, OAuthClientCommandTest, OAuthMetadataTest, OAuthMigrationTest, OAuthRegisterTest, OAuthTokenTest; security: OAuthConsentScopeCeilingTest, OAuthRefreshRotationTest, OAuthRevocationTest, TokenSurfaceIsolationTest). Framework behaviour tests run on wristband with the in-memory store; app tests run on real Postgres through the existing
classesTestMain harness, and route-surface isolation tests inspect the surf route table as Phase 6 did. Each PHP test method maps to a named Go test so coverage can be audited. - D-19 (command):
fonoteka:oauth-clientis ported infonoteka.goas a bonfire command scaffolded the Phase 4 way with the same signature (name,--redirect-uri=*,--scope=*,--auth-method,--client-id,--list) and the same output lines (client_id=,client_secret=printed once, the non-recoverable warning,--listnever printing a secret). It is thin over wristband's client issuing helper and the appClientStore.
Claude's Discretion
- Interface names and signatures in wristband, the transaction seam, in-memory store design, and where shared helpers (base64url, sha256 hex, constant-time compare) live.
- Config key layout for wristband options on the fonoteka side (D-03).
- The gate's scripted client (Go program vs Node script) and how the gate obtains Postgres.
- Logging policy inside wristband (never log
request_id, codes, secrets, verifiers), error-text constants, and howclient_namecontrol characters are stripped (port PHP's regex). - How
Content-Typeis judged for/register(PHPisJson()is "contains /json").
Deferred Ideas (OUT OF SCOPE)
- Summer-themed default token prefix for the framework and other projects;
inv_is the Inventory-era legacy and Płytarium must keep it while fonoteka-mcp is the client (D-11). - Moving the on-request expiry sweep into a River job — Phase 11.
- A generic framework-side client-issuing command (the app-side port was chosen, D-19).
- Live Claude, ChatGPT and Grok connects against the Go backend — cutover UAT, not this phase (D-15).
- Social login
/oauth/{provider}and theoauth-identitiesJWT routes — still deferred from Phase 7; not part of this OAuth server. </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AUTH-05 | OAuth2.1 authorization server: RFC 8414 metadata, authorization code + PKCE + consent, code/refresh token grants, RFC 7591 DCR, RFC 8707 resource handling, and exact discovery/header contracts | The RFC contract matrix, PHP wire-contract inventory, wristband architecture, PKCE/redirect/client-auth rules, and resource-server boundary below turn this into implementable slices. The zitadel/oidc wording is superseded by D-01. [VERIFIED: 08-CONTEXT.md; CITED: https://www.rfc-editor.org/rfc/rfc8414.html] |
| AUTH-06 | OAuth routes are form-urlencoded, CSRF-free, rate limited, and return unwrapped RFC bodies with PHP cache headers | The endpoint input matrix defines query/form/JSON parsing separately; raw-group mounting preserves CSRF/envelope isolation; existing named buckets are already registered; recorded fixtures define exact cache headers. [VERIFIED: PHP controllers, routes.php, fonoteka.go/plugin.go, parity fixtures] |
| AUTH-07 | Connected apps list/revoke; OAuth models persist correctly; fonoteka-mcp install/auth completes unchanged | The schema correction, GORM adapter, connected-app ownership/revocation rules, parity projection, lifecycle recording, and real-MCP gate analysis below cover the requirement and identify the missing /me prerequisite. [VERIFIED: codebase grep] |
| </phase_requirements> |
Summary
Build this phase as an exact behavioral port with an app-agnostic state machine, not as a generic standards library. wristband should own parsing, RFC decisions, opaque secret generation/hashing, redirect and scope policy, code/refresh state transitions, and minimal raw responses; fonoteka.go should own GORM persistence, access-token minting, active-collection consent, connected-app serialization, routes, config, and the app command. No new external Go package is needed. [VERIFIED: 08-CONTEXT.md D-01/D-05..D-10; VERIFIED: current go.mod]
The current Go data layer must be repaired before handlers can work. The shipped schema declares oauth_clients.client_secret_hash, oauth_auth_codes.code_hash, oauth_auth_codes.request_id, and oauth_auth_codes.user_id NOT NULL, while public DCR and the pending→code transition require those columns to be nullable; corresponding Go model fields are also non-pointer values. The same migration omitted the PHP non-unique operational indexes. This was not caught because Phase 5 explicitly stopped comparing nullability. Use a new corrective gormigrate migration plus pointer-field corrections; editing only handlers cannot succeed. [VERIFIED: ../fonoteka.go/plugins/golem15/fonoteka/updates/11_secrets_slice.go; VERIFIED: PHP create_oauth_tables.php; VERIFIED: Phase 5 summary/verification]
The other planning blocker is acceptance scope: the real Node MCP server calls GET /api/v1/fonoteka/me before it constructs its MCP tool server, but that endpoint is neither implemented in Go nor included in Phase 8's enumerated route boundary. Therefore D-14's “real MCP tool call” is impossible without either adding that small prerequisite route to Phase 8 or narrowing the gate to OAuth completion plus a direct bearer call to the already-ported /genres. Resolve this before final plans; the OAuth protocol work itself is otherwise well specified. [VERIFIED: /media/nvme/dev/golem15/fonoteka/fonoteka-mcp/src/http.ts; VERIFIED: ../fonoteka.go/plugins/golem15/fonoteka/routes.go; VERIFIED: mcp-tools.yaml; VERIFIED: 08-CONTEXT.md]
Primary recommendation: repair the schema first, then implement wristband around a transaction-scoped store bundle, wire the app endpoints and command, and finish with parity/lifecycle/security gates; do not allow the final plan to claim real-MCP success until the /me prerequisite decision is explicit. [VERIFIED: codebase analysis]
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| RFC 8414 metadata and RFC-minimal error/success bodies | API / Backend (wristband) |
App config | Protocol response construction is reusable; endpoint values and issuer are deployment options. [VERIFIED: D-05/D-06] |
| Authorization request validation, PKCE, scope ceiling, redirect allow-list | API / Backend (wristband) |
Database / Storage | These are protocol state-machine rules; the stores persist the resulting pending/code state. [VERIFIED: D-05/D-07] |
| Dynamic registration and client-secret verification | API / Backend (wristband) |
Database / Storage | Validation and one-time secret issuance are generic; fonoteka persists client rows and enforces the global cap atomically. [VERIFIED: D-05/D-07; CITED: https://www.rfc-editor.org/rfc/rfc7591.html] |
| Code exchange, refresh rotation/replay kill, expiry sweep | API / Backend (wristband) |
Database / Storage | The state transitions belong to the framework, but correctness depends on app-provided transaction and row-lock semantics. [VERIFIED: D-05/D-07/D-17] |
| Consent, active collection, connected-app responses | API / Backend (fonoteka.go) |
Database / Storage | These responses depend on app users, collections, and serializeToken; the framework must not import app models. [VERIFIED: D-08] |
| Access-token authentication after issue | API / Backend (inv_token) |
Database / Storage | OAuth access tokens remain ordinary inv_ API tokens; there is no OAuth guard. [VERIFIED: D-10/D-11; VERIFIED: token_guard.go] |
| Protected-resource metadata and rich Bearer challenge | Resource server (fonoteka-mcp) |
API / Backend authorization server | RFC 9728 discovery is emitted by the MCP resource server, while the backend emits only Basic on token-endpoint invalid_client. [VERIFIED: D-12; CITED: https://www.rfc-editor.org/rfc/rfc9728.html] |
/connect rendering and consent interaction |
Browser / Client (existing Nuxt) | API / Backend JWT routes | The unchanged Nuxt app renders the user interaction and calls the app-owned consent API. [VERIFIED: useFonoteka.ts, fonoteka.ts, D-08] |
Standard Stack
Core
| Library / package | Version | Purpose | Why Standard |
|---|---|---|---|
Go standard library: crypto/rand, crypto/sha256, crypto/subtle, encoding/base64, encoding/json, net/http, net/url |
Go 1.27.0 | Secret generation, SHA-256 hashing, constant-time comparison, RawURL encoding, HTTP parsing/serialization | Locked D-01 and sufficient for the exact minimal protocol; ConstantTimeCompare is content-independent for equal-length slices. [VERIFIED: go version; CITED: https://pkg.go.dev/crypto/subtle] |
Existing surf, wire, bouncer, bonfire, compass packages |
repository version | Raw routing/throttles, exact JSON, JWT/token context, command registration, config | These are the established Phase 4/6/7 integration seams; introducing parallel infrastructure would violate the existing app/kernel boundaries. [VERIFIED: codebase grep; VERIFIED: CLAUDE.md] |
GORM + Postgres adapter in fonoteka.go |
GORM v1.31.2; postgres driver v1.6.3 | Persistent stores, transactions, row locking, schema correction | Already selected and installed; app persistence must remain outside framework wristband. [VERIFIED: go.mod; VERIFIED: D-07] |
Existing tide parity harness |
repository version | Replay PHP route and MCP lifecycle fixtures | It already handles substitutions, secret categories, private vars, and pending/ported accounting. [VERIFIED: tide/; VERIFIED: capture_clients.mjs; VERIFIED: D-13/D-16] |
Supporting
| Library / tool | Version | Purpose | When to Use |
|---|---|---|---|
| Node.js | v22.23.2 | Run the unchanged MCP server and scripted discovery client | Only in the parity/e2e gate, not in production Go code. [VERIFIED: environment probe] |
@modelcontextprotocol/sdk from the existing MCP checkout |
existing lockfile installation (package.json range ^1.12.1) |
Exercise discovery, DCR, authorize, and exchange through the same client helpers used by capture tooling | Reuse via createRequire as capture_clients.mjs does; do not add it to this Go repo. [VERIFIED: MCP package.json; VERIFIED: dependency check] |
| Testcontainers-backed Postgres harness | existing v0.44.0 | Real transaction, lock, constraint, and migration tests | App-store tests and lifecycle/concurrency tests; skip only under the existing testing.Short() convention. [VERIFIED: go.mod; VERIFIED: classes/postgres_test.go] |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
Direct standard-library wristband |
zitadel/oidc |
Rejected by locked D-01: OIDC metadata/error/token defaults do not match the PHP byte contract or inv_ access-token model. [VERIFIED: D-01] |
| App-provided storage adapter | GORM types imported into wristband |
Rejected: it would make the framework depend on an app persistence choice and break the established “framework never imports fonoteka” boundary. [VERIFIED: CLAUDE.md; VERIFIED: D-05/D-07] |
| Existing recorded/client-driven tests | Hand-authored curl-only smoke | Curl cannot prove the actual MCP SDK's discovery and parsing behavior; it remains useful only for focused diagnostics. [VERIFIED: D-14; VERIFIED: capture_clients.mjs] |
Installation: none. This phase adds no third-party package. [VERIFIED: D-01]
Package Legitimacy Audit
No external package is installed by this phase, so the package-legitimacy gate is not applicable. The Node MCP SDK and Playwright are already installed in the unchanged sibling client repositories and are reused only by tests. [VERIFIED: D-01; VERIFIED: environment dependency check]
Exact Contract Matrix
| Surface | Input contract | Success contract | Error contract / headers |
|---|---|---|---|
GET /.well-known/oauth-authorization-server |
No auth; raw group | Exact unwrapped 11-field document in PHP insertion order; recorded header Cache-Control: no-cache, private |
No house envelope. [VERIFIED: OAuthMetadataController.php; VERIFIED: recorded route fixture] |
GET /oauth/mcp/authorize |
Query only; validate client then exact registered redirect before any redirect | Store pending request; 302 to <issuer>/connect?request=<opaque>; Cache-Control: no-store, private in recorded flow |
Unknown client/redirect: local 400 text, no Location; later errors: trusted 302 with ordered error, error_description, iss, optional state. [VERIFIED: OAuthAuthorizeController.php; VERIFIED: fixtures] |
POST /oauth/mcp/token |
Form body plus query using ParseForm body precedence; JSON body returns invalid_request; Basic overrides form credentials |
Bare JSON {access_token,token_type,expires_in,refresh_token,scope}; Cache-Control: no-store, private; PHP explicitly adds Pragma: no-cache though the current tide fixture does not retain it |
Bare 400 error object; invalid client is 401 with exactly WWW-Authenticate: Basic realm="OAuth"; recorded error cache header is no-cache, private. [VERIFIED: D-02; VERIFIED: OAuthTokenController.php; VERIFIED: fixtures; CITED: https://go.dev/pkg/net/http/] |
POST /oauth/mcp/register |
JSON-only; PHP isJson() accepts content types containing /json or +json |
201 exact DCR body; confidential secret returned once; Cache-Control: no-store, private |
400 {error,error_description} with invalid_redirect_uri or invalid_client_metadata. [VERIFIED: Laravel InteractsWithContentTypes.php; VERIFIED: OAuthRegisterController.php; VERIFIED: fixture] |
| JWT consent routes | Existing JWT group only; JSON bodies; opaque request handle | Exact data wrappers and redirect_to strings consumed by Nuxt |
Missing/stale/foreign request → exact 404; empty grant intersection → exact 422. [VERIFIED: OAuthConsentController.php; VERIFIED: Nuxt store] |
| JWT connected-app routes | Existing JWT group only; user ownership | Token serializer fields plus sanitized client_name; manual count; revoke response |
Foreign, missing, or manual token id → identical 404. [VERIFIED: ConnectedAppController.php] |
RFC 6749 requires token/credential-bearing responses to carry Cache-Control: no-store and Pragma: no-cache; RFC 7591 examples do the same for registration responses. The implementation must nevertheless assert the recorded PHP headers rather than globally “normalizing” every error path. [CITED: https://www.rfc-editor.org/rfc/rfc6749.html; CITED: https://www.rfc-editor.org/rfc/rfc7591.html; VERIFIED: parity fixtures]
Architecture Patterns
System Architecture Diagram
MCP client / vendor
├─ GET MCP protected-resource metadata ───────────────> fonoteka-mcp (resource server)
├─ unauthenticated /mcp ── WWW-Authenticate hint ─────> fonoteka-mcp
└─ RFC 8414 / DCR / authorize / token ────────────────> Go backend raw group
│
v
wristband handlers/state machine
│
┌─────────────────────────────────────┼──────────────────────────┐
v v v
ClientStore AuthCodeStore RefreshTokenStore
\____________________________ transaction bundle __________________/
│
v
fonoteka GORM/Postgres adapter
│
browser → existing Nuxt /connect → JWT consent API ──┤
v
AccessTokenIssuer → ordinary inv_ ApiToken
│
v
inv_token guard → protected fonoteka API / MCP tools
This keeps the authorization server and resource server distinct, preserves the framework/app import boundary, and makes the transaction seam the only path for code and refresh state changes. [VERIFIED: D-05..D-12]
Recommended Project Structure
summercms.go/
├── wristband/
│ ├── server.go # options, route handlers, RFC response writers
│ ├── stores.go # app-agnostic records + transaction-scoped interfaces
│ ├── authorize.go # redirect/scope/resource/PKCE validation
│ ├── token.go # client auth, code exchange, refresh rotation
│ ├── register.go # DCR validation, issue helper, caps/sweeps
│ ├── crypto.go # random base64url, sha256 hex, constant-time compare
│ ├── memory_store_test.go # in-memory implementation may stay in _test files
│ └── *_test.go # framework behavior matrix
└── scripts/check-phase8.sh
fonoteka.go/plugins/golem15/fonoteka/
├── updates/ # additive OAuth nullability/index correction
├── models/oauth_*.go # nullable fields corrected to pointers
├── classes/auth/oauth_store.go
├── classes/auth/oauth_token_issuer.go
├── controllers/api/oauth_*.go # app-owned consent + connected-app handlers
├── console/oauth_client.go
├── routes.go
└── plugin.go # construct/publish server; expose command
fonoteka.go/parity/
├── oauth_flow_test.go
├── fixtures/mcp/mcp-lifecycle.yaml
├── capture_clients.mjs
├── capture-rules.yaml
└── manifest.yaml
File placement follows D-05/D-08 and existing project package conventions; exact filenames are discretionary. [VERIFIED: D-05/D-08; VERIFIED: repository structure]
Pattern 1: Transaction-scoped store bundle
What: wristband should request a transaction-scoped bundle whose client/code/refresh/token-issuer methods all operate on the same transaction. Do not expose *gorm.DB from the app to the framework and do not let individual stores silently start nested transactions. [VERIFIED: D-07; VERIFIED: project import boundary]
Why: Code exchange must atomically consume the code, mint/persist the inv_ access token, and create the refresh row. Rotation must atomically revoke the old access token, mint the successor, and link refresh rows. [VERIFIED: PHP OAuthCodeManager.php; CITED: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/]
Example:
// Project-shaped interface recommendation; names remain discretionary.
type Backend interface {
WithinTx(context.Context, func(Tx) error) error
}
type Tx interface {
ClientStore
AuthCodeStore
RefreshTokenStore
AccessTokenIssuer
}
The GORM adapter should implement WithinTx with db.WithContext(ctx).Transaction(...) and use clause.Locking{Strength: "UPDATE"} for the presented code/refresh row; the in-memory implementation should hold one mutex for the closure. [VERIFIED: existing GORM stack; VERIFIED: D-07]
Pattern 2: Commit replay revocation before returning invalid_grant
What: A spent refresh-token replay is a committed security action followed by an OAuth error response. The transaction callback must return success after revoking the lineage, then the outer layer returns invalid_grant; returning the protocol error from inside GORM's transaction callback would roll back the revocations. [VERIFIED: PHP OAuthCodeManager::rotateRefresh; VERIFIED: GORM transaction semantics in existing code]
Example:
outcome := rotationOutcome{}
err := backend.WithinTx(ctx, func(tx Tx) error {
// lock, inspect, rotate or revoke lineage
if replayed {
outcome.replayed = true
return nil // commit lineage kill
}
return nil
})
if err == nil && outcome.replayed {
return tokenResult{}, ErrInvalidGrant
}
Pattern 3: Ordered RFC 3986 redirect construction
What: Use one helper accepting ordered key/value pairs. Do not use url.Values.Encode() directly: it sorts keys, while PHP's http_build_query(..., PHP_QUERY_RFC3986) preserves insertion order; Go's QueryEscape also uses + for spaces and therefore needs RFC 3986 normalization to %20. [VERIFIED: PHP authorize/consent controllers; VERIFIED: Go net/url behavior]
Required orders: error redirect is error, error_description, iss, optional state; consent success is code, iss, optional state; deny is error, iss, optional state. Existing redirect query text must be retained and the new ordered query appended with &. [VERIFIED: PHP controllers]
Pattern 4: Hash opaque credentials; compare fixed transforms
func sha256Hex(raw string) string {
sum := sha256.Sum256([]byte(raw))
return hex.EncodeToString(sum[:])
}
func constantEqual(left, right string) bool {
return subtle.ConstantTimeCompare([]byte(left), []byte(right)) == 1
}
func s256(verifier string) string {
sum := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(sum[:])
}
Client-secret comparison is between two fixed-length SHA-256 hex strings; PKCE comparison is between the stored challenge and the 43-character S256 result. Enforce verifier/challenge syntax and method before comparing. [VERIFIED: D-04; CITED: https://www.rfc-editor.org/rfc/rfc7636.html; CITED: https://pkg.go.dev/crypto/subtle]
Pattern 5: Explicit parser per endpoint
/authorize reads only r.URL.Query(). /token first rejects JSON input, then calls ParseForm and uses r.Form.Get, whose body value takes precedence over query values. /register validates a JSON content type using the locked PHP-compatible /json or +json test and decodes exactly one object. Do not share the house JSON DTO reader across these raw handlers. [VERIFIED: D-02; VERIFIED: PHP controllers; CITED: https://go.dev/pkg/net/http/]
Anti-Patterns to Avoid
- Returning a replay error from inside the transaction: rolls back lineage revocation. Commit first, then emit
invalid_grant. [VERIFIED: PHP lifecycle analysis] - One loose
map[string]anyparser for every endpoint: destroys the locked query/form/JSON asymmetry and can make JSON/tokenrequests succeed. [VERIFIED: D-02] url.Values.Encode()for redirect parity: changes key ordering and encodes spaces differently from PHP RFC3986 output. [VERIFIED: codebase comparison]- Deleting all rotated/revoked refresh rows during sweeps: removes replay evidence and breaks connected-app lineage behavior; only expired rows are swept. [VERIFIED: D-17]
- Counting clients then inserting outside one serialized store operation: concurrent DCR can exceed the 200-client cap. The app adapter needs an atomic cap-check/create operation or equivalent database serialization. [VERIFIED: D-03/D-04; code analysis]
- Using the existing non-pointer OAuth models unchanged: empty strings/zero user IDs cannot model pending/code state and collide with unique/foreign-key constraints. [VERIFIED: Go schema/model inspection]
- Attaching house middleware or a new
oauthguard: violates raw RFC response shapes and D-10. [VERIFIED: D-09/D-10] - Adding the MCP's RFC 9728 Bearer challenge to backend token routes: assigns resource-server behavior to the authorization server and breaks the locked backend contract. [VERIFIED: D-12; CITED: https://www.rfc-editor.org/rfc/rfc9728.html]
Required Schema Correction
The following correction must precede functional storage tests. [VERIFIED: Go/PHP migration diff]
| Table / field | Current Go schema | Required runtime shape | Why |
|---|---|---|---|
oauth_clients.client_secret_hash |
TEXT NOT NULL; Go string |
nullable; Go *string |
DCR public clients using auth method none have no secret. [VERIFIED: PHP DCR controller/migration] |
oauth_auth_codes.request_id |
TEXT NOT NULL UNIQUE; Go string |
nullable unique; Go *string |
Issuing the code nulls the request handle so it cannot be reused. [VERIFIED: PHP code manager/migration] |
oauth_auth_codes.code_hash |
TEXT NOT NULL UNIQUE; Go string |
nullable unique; Go *string |
Pending pre-consent rows do not yet have a code; empty strings would permit only one pending row. [VERIFIED: PHP code manager/migration] |
oauth_auth_codes.user_id |
INTEGER NOT NULL FK; Go uint |
nullable FK; Go *uint |
The user is unknown before consent. [VERIFIED: PHP code manager/migration] |
| operational indexes | only inline unique constraints | add PHP-equivalent named indexes for consent/client/refresh lineage/API-token client lookups | Connected-app, sweep, client, and lineage queries otherwise scan tables; D-18 requires migration behavior coverage. [VERIFIED: PHP migrations; VERIFIED: Go migration] |
Use an additive migration because 202609180010_create_oauth_tables may already be present in migration history. Its up should drop the four NOT NULL constraints and create missing indexes idempotently; down must refuse or safely handle rows containing nulls before re-adding constraints, so planner acceptance must define the rollback behavior explicitly. [VERIFIED: existing gormigrate history model; ASSUMED]
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| Entropy / token encoding | PRNG, UUID token, padded/base64-standard codec | crypto/rand + base64.RawURLEncoding |
Matches the PHP byte lengths and avoids predictable or URL-hostile secrets. [VERIFIED: D-01; VERIFIED: PHP sources] |
| Secret/hash equality | ==, bytes.Equal, early-return character loop |
crypto/subtle.ConstantTimeCompare on fixed transforms |
Locked security control and direct equivalent of PHP hash_equals. [VERIFIED: D-04; CITED: https://pkg.go.dev/crypto/subtle] |
| Form parsing | custom split on &/= |
net/http.Request.ParseForm after explicit content-type policy |
Correct percent decoding and locked body-over-query precedence. [VERIFIED: D-02; CITED: https://go.dev/pkg/net/http/] |
| JSON / response escaping | string-concatenated JSON | encoding/json through a raw response writer |
Keeps RFC bodies unwrapped without creating malformed or injectable output. [VERIFIED: D-01/D-06] |
| Transaction emulation | compensation logic after independent DB writes | app-provided real transaction with row locks | Code/refresh lifecycle invariants require atomicity under concurrency. [VERIFIED: D-07; VERIFIED: PHP lockForUpdate] |
| Access token format/guard | new OAuth/JWT token type and guard | Phase 7 MintPersonalToken/RevokeToken and inv_token |
The unchanged MCP accepts inv_ only and connected apps distinguish tokens by oauth_client_id. [VERIFIED: D-10/D-11; VERIFIED: MCP source] |
Key insight: hand-roll only the small protocol orchestration that is intentionally byte-specific; reuse the standard cryptographic/HTTP primitives and existing project services for everything security-sensitive beneath it. [VERIFIED: D-01 and codebase architecture]
Common Pitfalls
Pitfall 1: Schema looks present but cannot represent the state machine
What goes wrong: first DCR public-client insert or pending authorization insert fails on NOT NULL/FK constraints; repeated pending rows can collide on an empty code hash. [VERIFIED: Go migration/model inspection]
How to avoid: corrective migration and pointer models are Wave 0, with a real-Postgres test that creates two simultaneous pending rows and transitions one to a code. [VERIFIED: code analysis]
Warning signs: implementation tests rely only on in-memory storage; app tests never insert a pre-consent row. [VERIFIED: risk analysis]
Pitfall 2: Refresh replay revocation silently rolls back
What goes wrong: handler returns invalid_grant, but the active successor still works because lineage changes were rolled back with the error. [VERIFIED: transaction analysis]
How to avoid: committed replay outcome pattern plus a test that replays the old token and then proves both old and current access/refresh credentials are dead. [VERIFIED: PHP behavior; CITED: https://www.rfc-editor.org/rfc/rfc9700.html]
Pitfall 3: Redirect validation occurs after error redirect construction
What goes wrong: attacker-controlled redirect_uri becomes an open redirect carrying OAuth error data. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html]
How to avoid: unknown client and any non-exact registered redirect are local text 400s with no Location; only later failures use the trusted URI. [VERIFIED: PHP authorize controller]
Pitfall 4: ParseForm accidentally accepts JSON-token calls through the query
What goes wrong: a request with JSON content type and query parameters succeeds even though D-02 requires invalid_request. [VERIFIED: Go parser analysis]
How to avoid: reject JSON/non-form body policy before reading the merged form; test JSON with and without query parameters. [VERIFIED: D-02]
Pitfall 5: Generic URL encoder changes Location bytes
What goes wrong: key order changes or spaces become +, failing the PHP redirect contract and possibly client/state tests. [VERIFIED: PHP/Go encoding comparison]
How to avoid: ordered RFC3986 pair encoder with exact fixtures for existing-query, Unicode, spaces, reserved characters, and optional state. [VERIFIED: code analysis]
Pitfall 6: DCR cap check races
What goes wrong: concurrent registrations all see a count below 200 and insert more clients. [VERIFIED: PHP controller pattern and concurrency analysis] How to avoid: make sweep + cap check + client creation one transactionally serialized store operation; add a concurrent “cap-1” test. [VERIFIED: D-04 threat list]
Pitfall 7: The broad recorded browser flow is replayed without projection
What goes wrong: mcp-oauth.yaml contains unrelated onboarding, feedback, notifications, context, and album-stat calls that belong to later phases, so full replay fails for reasons unrelated to OAuth. mcp-tools.yaml also begins with the currently missing /api/v1/fonoteka/me. [VERIFIED: fixture inspection; VERIFIED: current routes]
How to avoid: record a clean mcp-lifecycle flow and explicitly project mcp-oauth/mcp-tools to named OAuth-relevant steps, failing if expected steps disappear. Resolve /me before claiming the real MCP tool gate. [VERIFIED: D-13/D-16; code analysis]
Pitfall 8: Testing only sequential use
What goes wrong: two simultaneous code exchanges, refreshes, consents, or DCR registrations violate single-use/cap guarantees despite passing normal tests. [VERIFIED: security analysis]
How to avoid: real-Postgres goroutine tests with synchronization barriers for each single-use transition and go test -race. [VERIFIED: D-04/D-07]
Pitfall 9: Secret-bearing values reach logs or fixtures
What goes wrong: request handles, authorization codes, verifiers, client secrets, access tokens, or refresh tokens become recoverable from CI logs or git. [VERIFIED: D-04/D-16]
How to avoid: no value logging in wristband; capture categories must cover every secret shape; vars remain mode 0600; add a source grep and check-secrets gate. [VERIFIED: tide patterns and D-16]
State of the Art
| Old / broad approach | Current applicable approach | When changed | Impact |
|---|---|---|---|
| OAuth 2.0 authorization code without mandatory PKCE | OAuth 2.1 draft and RFC 9700 require/support PKCE, with S256 the only non-disclosing method | RFC 9700 published Jan 2025; OAuth 2.1 is still Internet-Draft revision 16 dated 2026-09-02 | Name the phase “OAuth2.1-style” accurately in implementation docs: normative pieces come from published RFCs plus the current draft; enforce S256 for every supported flow. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html; CITED: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/] |
| Reusable long-lived public-client refresh token | Rotation or sender-constraining with replay detection | RFC 9700 | Existing rotate-and-kill-lineage design is the correct BCP pattern; retain predecessor links until expiry. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html] |
| Client mix-up mitigated only by distinct redirect URI | Authorization response iss and metadata capability flag |
RFC 9207 (2022) | Preserve iss on success/error redirect and authorization_response_iss_parameter_supported: true exactly. [CITED: https://www.rfc-editor.org/rfc/rfc9207.html; VERIFIED: PHP metadata/redirects] |
| Ad-hoc protected resource discovery | RFC 9728 resource metadata and resource_metadata challenge parameter |
RFC 9728 (2025) | This is owned by fonoteka-mcp, not the Go authorization server; integration tests must keep that boundary explicit. [CITED: https://www.rfc-editor.org/rfc/rfc9728.html; VERIFIED: D-12] |
Deprecated/outdated: OAuth 2.1 is not yet an RFC as of this research date; calling the backend “RFC 2.1 compliant” without naming the underlying published RFCs and current draft would overstate standardization status. [CITED: https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/]
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Go testing on Go 1.27; real Postgres via existing testcontainers v0.44.0 harness; Node SDK/real MCP only for the phase gate. [VERIFIED: environment and repo] |
| Config file | none; package-local _test.go, existing TestMain, go.work, and shell gate. [VERIFIED: repository scan] |
| Quick run command | go test ./wristband -count=1 from summercms.go; app focus: go test ./plugins/golem15/fonoteka/... -run 'TestOAuth' -count=1 from fonoteka.go. [VERIFIED: project Go test conventions] |
| Full suite command | scripts/check-phase8.sh, which must include both repos' go vet ./..., go test ./..., go test -race ./..., parity replay, corpus audit, and real-MCP discovery/lifecycle checks. [VERIFIED: CLAUDE.md; VERIFIED: prior gate scripts; VERIFIED: D-14] |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| AUTH-05 | Exact metadata, authorize validation/redirects, S256 PKCE, code exchange, refresh, DCR, resource check, iss |
framework unit + app integration | `go test ./wristband -run 'Test(Metadata | Authorize |
| AUTH-05 | Actual MCP SDK performs discovery/DCR/authorize/exchange | e2e | scripts/check-phase8.sh |
❌ Wave 0 |
| AUTH-06 | raw-group isolation, form/query precedence, JSON rejection, rate limits, unwrapped exact bodies/cache headers | route/integration/parity | `go test ./plugins/golem15/fonoteka/... ./parity -run 'TestOAuth | TestParityCorpus' -count=1` |
| AUTH-07 | public/confidential models persist, consent binds active collection, connected-app list/revoke kills chain | Postgres integration | go test ./plugins/golem15/fonoteka/classes/... ./plugins/golem15/fonoteka/controllers/... -run 'TestOAuth' -count=1 |
❌ Wave 0 |
| AUTH-07 | nine manifest routes replay and lifecycle fixture proves rotation/replay/revoke/deny | parity | `go test ./parity -run 'TestOAuthFlows | TestParityCorpus' -count=1` |
| AUTH-07 | command issues/adds/lists client without reprinting secret | command integration | go test ./plugins/golem15/fonoteka/... -run TestOAuthClientCommand -count=1 |
❌ Wave 0 |
Threat → Failing-When-Broken Test Map
| Threat | Required named proof |
|---|---|
| T-08-PKCE | reject missing/plain/wrong/invalid-length verifier; tampering the constant-time comparison or S256 transform makes a test fail. [VERIFIED: D-04; CITED: https://www.rfc-editor.org/rfc/rfc7636.html] |
| T-08-CODE-REPLAY | two sequential and two concurrent exchanges yield one usable grant only; code row becomes used under lock. [VERIFIED: D-04/D-07] |
| T-08-REFRESH-REPLAY | spent-token replay commits lineage kill; current access and refresh fail afterward; concurrent double-refresh cannot leave a usable branch. [VERIFIED: D-04; CITED: https://www.rfc-editor.org/rfc/rfc9700.html] |
| T-08-OPEN-REDIRECT | unknown client/unregistered URI/trailing-slash mismatch have no Location; later error goes only to exact registered URI. [VERIFIED: PHP tests; CITED: https://www.rfc-editor.org/rfc/rfc9700.html] |
| T-08-SECRET-TIMING | static source test/grep rejects direct comparison in wristband; behavior test covers Basic/post invalid secret and fixed hash transformation. [VERIFIED: D-04] |
| T-08-SCOPE-CEILING | authorize truncates requested data scopes, retains offline flag, rejects empty data intersection, consent cannot add scopes or collection IDs. [VERIFIED: PHP security tests] |
| T-08-CROSS-USER | a consumed/bound pending handle cannot be shown/consented/denied by another user; foreign connected-app IDs are identical 404. [VERIFIED: PHP controller rules/tests] |
| T-08-REQUEST-LEAK | log-capture tests and source grep prove no request id/code/secret/verifier/token logging; tide secret scan passes. [VERIFIED: D-04/D-16] |
| T-08-DCR-FLOOD | rate limiter hits 429, cap is atomic under concurrency, stale dynamic clients sweep but artisan clients survive, oversized JSON body is bounded after cap decision. [VERIFIED: D-03/D-04; ASSUMED body-size control] |
| T-08-SURFACE | raw routes have neither JWT/inv-scope/web/house middleware; JWT OAuth management routes never appear under personal-token prefix. [VERIFIED: PHP TokenSurfaceIsolationTest; VERIFIED: Phase 6 route-table pattern] |
PHP Test Port Audit
The locked source suite contains 103 named methods: 11 authorize, 8 client-command, 2 metadata, 7 migration, 10 register, 10 token, 7 consent/scope, 10 refresh rotation, 8 revocation, and 30 surface-isolation methods. The last plan must maintain a manifest mapping every PHP method name to one Go test/subtest; “equivalent overall coverage” is not sufficient under D-18. [VERIFIED: PHP test inventory grep; VERIFIED: D-18]
Sampling Rate
- Per task commit: focused package test under 30 seconds; pure
wristbandtests should use the in-memory store. [VERIFIED: D-18] - Per wave merge: affected repo
go vet ./...andgo test ./...; database waves include focused non-short real-Postgres tests. [VERIFIED:CLAUDE.md] - Phase gate: both repos' full vet/test/race, all nine manifest entries ported and passing, lifecycle flow, secret scan, security-review threat map, and the resolved form of the real-MCP gate. [VERIFIED: D-04/D-13/D-14/D-16/D-18]
Wave 0 Gaps
wristband/*_test.go— table tests for metadata/authorize/token/register/PKCE/refresh plus deterministic clock/random injection. [VERIFIED: no package exists]fonoteka.go/.../updates/*oauth*_test.go— nullability, multiple pending rows, transition, indexes, up/down behavior. [VERIFIED: missing]fonoteka.go/.../classes/auth/oauth_store_test.go— real-Postgres locking, code double-spend, refresh replay commit, concurrent branches, sweeps. [VERIFIED: missing]- controller/route/command OAuth tests mapping all PHP methods. [VERIFIED: missing]
fonoteka.go/parity/oauth_flow_test.goand cleanmcp-lifecycle.yaml; explicit projection for noisy existing client flows. [VERIFIED: missing]scripts/check-phase8.sh; dependency and port cleanup; real MCP startup; exact discovery chain. [VERIFIED: missing]08-SECURITY-REVIEW.mdtemplate/register populated from plan threat IDs and executed evidence. [VERIFIED: D-04]
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | yes | Existing JWT for consent and exact client authentication methods at /token; unknown/revoked clients fail closed. [VERIFIED: context/PHP] |
| V3 Session Management | yes | Single-use 600-second pending/code state, rotating 30-day refresh state, revocation, expiry sweep. [VERIFIED: D-03/D-17] |
| V4 Access Control | yes | Active-collection pin, scope intersection/ceiling, ownership-scoped connected apps, JWT-only management surface. [VERIFIED: D-08/PHP tests] |
| V5 Input Validation | yes | Endpoint-specific parser, exact redirect allow-list, strict scope/grant/auth-method sets, bounded lengths/counts. [VERIFIED: PHP controllers] |
| V6 Cryptography | yes | crypto/rand, SHA-256 opaque-secret transforms, S256 PKCE, crypto/subtle, raw secrets returned once and never stored. [VERIFIED: D-01/D-04; CITED: https://www.rfc-editor.org/rfc/rfc7636.html] |
| V7 Error/Logging | yes | Minimal protocol errors, no secret/request handle logs, raw-vs-house separation. [VERIFIED: D-04/D-06/D-09] |
| V8 Data Protection | yes | Hash-only client/code/refresh persistence, hidden model fields, private fixture vars and secret scan. [VERIFIED: PHP/Go models; VERIFIED: D-16] |
| V13 API | yes | Content-type contracts, rate limits, no envelope, exact headers, DCR cap. [VERIFIED: AUTH-06/D-02/D-09] |
Known Threat Patterns for This Stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Authorization-code interception/injection | Spoofing / Elevation | Per-request S256 PKCE, code/client/redirect/resource binding, single-use row lock. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html] |
| Open redirect / mix-up | Spoofing / Information Disclosure | Validate exact registered redirect before redirecting; return iss and advertise support. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html; CITED: https://www.rfc-editor.org/rfc/rfc9207.html] |
| Refresh-token theft and replay | Spoofing / Elevation | Rotation, relationship retention, whole-lineage/access-grant revocation on spent-token replay. [CITED: https://www.rfc-editor.org/rfc/rfc9700.html] |
| Client-secret timing oracle | Information Disclosure | Compare fixed SHA-256 hex with crypto/subtle.ConstantTimeCompare; never compare raw variable-length secret. [VERIFIED: D-04; CITED: https://pkg.go.dev/crypto/subtle] |
| Scope/tenant escalation at consent | Elevation | Intersect submitted ∩ requested ∩ mintable; apply client ceiling before display; derive collection ids server-side. [VERIFIED: PHP consent tests] |
| DCR storage/resource exhaustion | Denial of Service | Existing per-IP limiter, atomic 200-client cap, 24-hour unconsented sweep, bounded decoder. [VERIFIED: D-03/D-09; ASSUMED body bound] |
| Request-handle leakage | Information Disclosure | 256-bit opaque value, no logs, private fixtures, null after issuance, short expiry. [VERIFIED: PHP manager; VERIFIED: D-04/D-16] |
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Go | build/tests | ✓ | go1.27.0-X:nodwarf5 |
— [VERIFIED: environment probe] |
| Node.js | real MCP/scripted SDK gate | ✓ | v22.23.2 | Go scripted client can cover protocol, but not actual SDK compatibility. [VERIFIED: environment probe] |
| npm / pnpm | existing client builds | ✓ | npm 12.0.2 / pnpm 11.3.0 | use installed lockfile tooling. [VERIFIED: environment probe] |
| MCP + Nuxt node_modules | capture/e2e dependencies | ✓ | dependency resolver check passes | fail gate with the existing actionable capture_clients --check-deps message. [VERIFIED: dependency check] |
| Docker CLI | Postgres tests/gate | ✓ CLI; daemon unavailable in this sandbox session | Docker 29.7.2 | Existing project policy treats unavailable daemon as gate failure; rerun where daemon is permitted. [VERIFIED: environment probe; VERIFIED: prior gate scripts] |
| PostgreSQL client/service | diagnostics | psql ✓; local server ✗ | psql 18.6; no local response | testcontainers once Docker is available. [VERIFIED: environment probe] |
Missing dependencies with no fallback: an accessible Docker daemon is required for the final real-Postgres phase gate in this environment. [VERIFIED: environment probe; VERIFIED: existing gate policy]
Missing dependencies with fallback: none for final acceptance; pure wristband tests can run in-memory while implementation is in progress. [VERIFIED: D-18]
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Additive schema-correction down behavior may need to refuse when null lifecycle rows exist rather than destructively coercing them. |
Required Schema Correction | Planner must choose a safe rollback contract; careless down migration can destroy pending/client data. |
| A2 | Resolved by CONTEXT D-21: /register has a 64 KiB request-body bound and oversized input returns invalid_client_metadata. |
Threat map / Security | Closed by the user decision; plans must retain the exact bound and endpoint-native error contract. |
Resolved Planning Questions
-
How is D-14's real MCP tool call reconciled with the Phase Boundary? — Resolved: add the prerequisite route.
- What we know:
fonoteka-mcpcalls/api/v1/fonoteka/mebefore it constructs the MCP server; that route is absent from current Go routes and from the Phase 8 endpoint list. [VERIFIED: MCP source and Go routes] - What's unclear: whether Phase 8 may port this one prerequisite route or D-14 should stop at token + direct bearer proof until the API phase. [VERIFIED: context conflict]
- Decision: add the minimal exact
/api/v1/fonoteka/mepersonal-token route to Phase 8 as an explicitly approved prerequisite; this preserves the locked “real MCP tool call unchanged” acceptance. [LOCKED: user decision 2026-09-23; CONTEXT D-20]
- What we know:
-
What request-body limit should raw OAuth machine endpoints enforce? — Resolved: 64 KiB.
- What we know: the raw group intentionally bypasses the house body-limit middleware;
ParseFormhas an internal 10 MB cap, but the JSON register decoder is otherwise unbounded. Valid registration data is limited to five 512-character redirect URIs plus small metadata. [VERIFIED: surf/raw behavior; CITED: https://go.dev/pkg/net/http/; VERIFIED: PHP validation] - What's unclear: the desired explicit cap and error body for an oversized DCR document. [ASSUMED]
- Decision: configure a 64 KiB
wristbandmaximum registration request size and return the endpoint's normalinvalid_client_metadataresponse. [LOCKED: user decision 2026-09-23; CONTEXT D-21]
- What we know: the raw group intentionally bypasses the house body-limit middleware;
Sources
Primary (HIGH confidence)
08-CONTEXT.md,CLAUDE.md, Phase 6/7 context and Phase 6 security review — locked project boundaries, route/security conventions, and acceptance rules. [VERIFIED: local files]- PHP
OAuthCodeManager, four OAuth controllers, consent/connected-app controllers,OAuthClient, command, migrations, and all named OAuth/security tests — byte/state source of truth. [VERIFIED: local source inspection] - Go
fonoteka.gomodels, migrations, token manager/guard, routes/plugin, parity manifest/fixtures/capture tooling — current implementation and concrete gaps. [VERIFIED: local source inspection] - fonoteka-mcp
http.ts,config.ts,install.tsand Nuxt composable/store — unchanged client contracts and the/megate blocker. [VERIFIED: local source inspection] - https://www.rfc-editor.org/rfc/rfc6749.html — token responses/errors/cache and client authentication.
- https://www.rfc-editor.org/rfc/rfc7591.html — JSON DCR, response/error shapes, rate-limit allowance.
- https://www.rfc-editor.org/rfc/rfc7636.html — verifier syntax and S256 transform.
- https://www.rfc-editor.org/rfc/rfc8414.html — authorization-server metadata.
- https://www.rfc-editor.org/rfc/rfc8707.html — resource indicator semantics.
- https://www.rfc-editor.org/rfc/rfc9207.html — authorization response issuer identification.
- https://www.rfc-editor.org/rfc/rfc9700.html — OAuth security BCP, exact redirect matching, PKCE, refresh rotation/replay response.
- https://www.rfc-editor.org/rfc/rfc9728.html — protected-resource metadata and
resource_metadatachallenge ownership. - https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/ — current OAuth 2.1 revision/status as of research date.
- https://go.dev/pkg/net/http/ and https://pkg.go.dev/crypto/subtle —
ParseFormprecedence and constant-time primitive behavior.
Secondary (MEDIUM confidence)
- None; recommendations are based on primary standards and live source/contracts. [VERIFIED: research log]
Tertiary (LOW confidence)
- None. The two discretionary recommendations are isolated in the Assumptions Log. [VERIFIED: research log]
Metadata
Confidence breakdown:
- Standard stack: HIGH — locked standard-library decision and installed project stack were inspected directly. [VERIFIED: D-01; VERIFIED: repo]
- Architecture: HIGH — framework/app/resource-server boundaries and state transitions are explicit in context and live source. [VERIFIED: context/source]
- Wire contract: HIGH — PHP controllers plus recorded fixtures and unchanged clients were inspected. [VERIFIED: source/fixtures]
- Validation architecture: HIGH for unit/integration mapping; MEDIUM for final e2e until
/mescope is resolved and Docker is available. [VERIFIED: repo/environment] - Pitfalls: HIGH — key findings come from direct schema/state-machine and fixture-flow comparison; only body-cap size is assumed. [VERIFIED: code analysis]
Research date: 2026-09-23 Valid until: 2026-10-23 for RFC/library status; internal code findings remain valid until the referenced migrations/routes change.