Files
summercms/.planning/phases/08-oauth2-1-authorization-server/08-05-SUMMARY.md
2026-09-23 21:11:45 +02:00

19 KiB

phase, plan, subsystem, tags, requires, provides, affects, tech-stack, key-files, key-decisions, patterns-established, requirements-completed, duration, completed
phase plan subsystem tags requires provides affects tech-stack key-files key-decisions patterns-established requirements-completed duration completed
08-oauth2-1-authorization-server 05 auth
oauth2
rfc6749
consent
wristband
gorm
surf
jwt-group
playwright
phase plan provides
08-oauth2-1-authorization-server 04 wristband.Server.Token (authorization_code grant), the post-consent AuthCodeRecord shape (CodeHash set, RequestID nil, UserID set), and the unmounted POST /oauth/mcp/token route
wristband.Server.PendingRequest/IssueCode/DenyPending: app-agnostic consent operations (owner-bound lookup, code issuance, denial) that collapse missing/foreign/used/expired/already-issued pending rows to one ErrPendingNotFound
fonoteka JWT-group consent API: GET oauth/request/{request_id}, POST oauth/consent, POST oauth/deny, wired on the existing jwt.auth/locale.from-principal/inv.must-change-password group
POST /oauth/mcp/token mounted on the raw group with its named throttle, closing the 08-04 D-09 gap -- a full authorize->consent->token PKCE round trip now works end to end against the assembled app
scripts/check-phase8-ui.mjs: a self-validating, read-only 32-scenario UI-contract harness for the unchanged Nuxt consent/connected-app surfaces, with a --final-gate seam reserved for 08-10
08-06-lifecycle-and-sweeps
08-07-oauth-client-command
08-08-mcp-me-prerequisite
08-09-parity-and-real-mcp-gate
08-10-unit-tests-and-security-review
added patterns
Protocol-owned mutation + app-owned policy split: wristband.IssueCode trusts the caller's already-computed granted-scope intersection and server-resolved collection ids (persist + redirect only); the app controller owns MINTABLE_SCOPES intersection and ActiveCollectionResolver, matching the PHP OAuthConsentController/OAuthCodeManager boundary exactly
surf Where() binds to the immediately preceding route only, not the whole enclosing group -- constraints on a group with multiple path-parameterized routes must be interleaved route-by-route, not batched at the end
RED/GREEN staged via a saved-then-reverted working tree (stub handlers + reverted routes.go/plugin.go for the RED commit, restored for GREEN) so both scripts/check-phase8-red.sh sentinels observe a genuine fail-closed failure even though the full implementation was written and verified before either commit
check-phase8-ui.mjs: a locked per-category scenario-count manifest (EXPECTED_COUNTS) catches accidental UI-SPEC coverage drift at self-test time rather than relying on manual review
created modified
wristband/consent.go
wristband/consent_test.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go
../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go
scripts/check-phase8-ui.mjs
wristband/stores.go
wristband/server.go
wristband/registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
AuthCodeStore.MarkIssued gained scopes/collectionIDs/expiresAt parameters (was codeHash/userID only) because PHP's issueCode overwrites all of code_hash/request_id/user_id/scopes/collection_ids/expires_at in one UPDATE, not just the first two; the narrower 08-02 signature could not persist consent's granted-scope subset or the server-resolved collection pin
ClientStore gained MarkConsented (idempotent via a WHERE consented_at IS NULL guard) to stamp OAuthClient.consented_at once, matching D-08 and DCR's SweepUnconsented dependency on that column
wristband.Options gained CodeTTL (600s default) so IssueCode sets a fresh code expiry from consent time rather than reusing the pending row's original 08-03 expiry, matching PHP's independent CODE_TTL_SECONDS constant; plugin.go wires it from the already-declared-but-previously-unconsumed golem15.fonoteka.oauth.code_ttl_seconds config key
Consent's canonical scope ordering (read -> write -> ai) is computed app-side against auth.MintableScopes's own declared order, not by replicating PHP's array_intersect (which preserves the first array's order); 08-UI-SPEC.md's explicit 'canonical read -> write -> ai order' language is treated as the authoritative contract over the PHP source's incidental ordering
POST /oauth/mcp/token is mounted in this plan (not 08-04) per 08-04-SUMMARY.md's own explicit deferral: only once consent produces a real issued code does an end-to-end /token call through the real route have anything to exchange
check-phase8-ui.mjs's --final-gate mode is scaffolded (verify:oauth-return-path, verify:oauth-i18n, and a Playwright-matrix seam) but deliberately fatal-errors unless PHASE8_UI_ALLOW_FINAL_GATE=1 is set, and is never invoked by this plan; 08-10 is its sole execution site per the plan's own success criteria
AUTH-05/06/07 remain Pending in REQUIREMENTS.md: this plan ships consent plus the token mount, but refresh rotation (08-06) and connected-apps list/revoke (also deferred, not in this plan's file list despite the phase directory's 'consent-and-connected-apps' name) are still outstanding pieces of those requirements
oauthDeps(w, r, app) is the JWT-group consent controller's shared dependency resolver (gdb + bouncer.Principal + *wristband.Server via app.Lookup), the seam any later connected-apps controller in this package should reuse
~55min 2026-09-23

Phase 08 Plan 05: Consent and Connected Apps (Consent Slice) Summary

Owner-bound JWT consent (show/allow/deny) backed by new wristband.Server.PendingRequest/IssueCode/DenyPending operations, POST /oauth/mcp/token finally mounted to close 08-04's deferred gap, and a self-validating 32-scenario read-only Playwright-contract harness for the unchanged Nuxt consent/connected-app UI.

Performance

  • Duration: ~55 min
  • Started: ~2026-09-23T19:05:00Z (approx., continuing from 08-04)
  • Completed: 2026-09-23T21:05:00Z (approx.)
  • Tasks: 3 completed (5 commits: RED/GREEN pair in fonoteka.go for Task 1/2, one feat commit each in summercms.go for the wristband prerequisite and the UI harness)
  • Files modified: 12 (5 created + 3 modified in summercms.go's wristband/scripts; 3 created + 3 modified in fonoteka.go)

Accomplishments

  • wristband.Server.PendingRequest/IssueCode/DenyPending port PHP OAuthConsentController::pendingFor/OAuthCodeManager::issueCode as app-agnostic protocol operations: every missing, foreign-owner, used, expired, or already-issued pending row collapses to the identical ErrPendingNotFound (T-08-CROSS-USER/T-08-REQUEST-LEAK); IssueCode trusts the caller's already-computed granted scopes/collection ids and returns the ordered redirect_to URL through the existing RFC 3986 encoder
  • AuthCodeStore.MarkIssued was extended (scopes/collectionIDs/expiresAt) and ClientStore.MarkConsented was added because the narrower 08-02 store interface could not express PHP's full single-UPDATE issueCode semantics; both changes are covered by real-Postgres assertions in oauth_store.go's existing test package
  • fonoteka's JWT-group ConsentShow/ConsentStore/ConsentDeny grant exactly submitted ∩ pending-request ∩ MINTABLE_SCOPES, pin collection ids exclusively through ResolveActiveCollection, and never accept a client-supplied collection id; an empty granted intersection is an exact 422 {"error":"No grantable scopes"}
  • POST /oauth/mcp/token is now mounted on the raw group with throttle:fonoteka-oauth-token, closing the gap 08-04 deliberately left open -- a full authorize -> consent(JWT) -> token PKCE round trip now produces a real inv_-prefixed access token through the assembled app and real Postgres
  • scripts/check-phase8-ui.mjs encodes the complete 08-UI-SPEC.md consent/connected-app state/accessibility/responsive/i18n matrix as a 32-scenario, 7-category catalog; --contract-self-test validates catalog completeness, guarded-Nuxt-file hash stability, and @playwright/test resolvability in ~50ms without booting anything; --final-gate is scaffolded but refuses to run without an explicit opt-in env var, reserved for 08-10
  • Both scripts/check-phase8-red.sh sentinel gates (PHASE8_RED:consent-controller, PHASE8_RED:consent-app) were verified fail-closed against genuine 501-stub/unmounted-route RED states before GREEN was restored; the full GREEN suite (go vet/go test/go test -race) is green in both fonoteka.go/plugins/golem15/fonoteka and summercms.go

Task Commits

Each task was committed atomically (TDD RED then GREEN where applicable, split per repo):

  1. Task 1: consent RED anchor + wristband prerequisite -- a1fa9c6 (feat, summercms.go): wristband.Server.PendingRequest/IssueCode/DenyPending, the MarkIssued/MarkConsented interface extensions, Options.CodeTTL; 306b06e (test, fonoteka.go): TestPhase8RedConsentController/TestPhase8RedConsentApp fail against 501 stub handlers and unmounted routes (PHASE8_RED:consent-controller/PHASE8_RED:consent-app), plus the compile-forced oauth_store.go adapter update. Both sentinels verified fail-closed via scripts/check-phase8-red.sh.
  2. Task 2: implement owner-bound JWT consent and mount raw token route -- a52e8fa (feat, fonoteka.go): the real ConsentShow/ConsentStore/ConsentDeny handlers, routes.go's consent-route and /oauth/mcp/token mounts, plugin.go's *wristband.Server publish and code_ttl_seconds wiring, and the full GREEN test matrix (T-08-CROSS-USER, T-08-SCOPE-CEILING, empty-scope 422, deny redirect + single-use, missing-handle 404, JWT/raw route-surface isolation).
  3. Task 3: read-only UI-contract harness -- fac9648 (feat, summercms.go): scripts/check-phase8-ui.mjs.

Plan metadata: committed as part of this summary/state-update commit.

Note: Tasks 1/2 carry tdd="true"; the RED commit's fonoteka.go changes required including oauth_store.go (a hard compile dependency on wristband's extended Tx interface) even though the plan's Task 1 file list names only the two test files -- see Deviations.

Files Created/Modified

  • wristband/consent.go -- Server.PendingRequest, Server.IssueCode, Server.DenyPending, lookupOwnedPending, ErrPendingNotFound, ErrNoGrantableScopes
  • wristband/consent_test.go -- in-memory-backend unit matrix: client/scope resolution, foreign-owner/missing-handle not-found, ordered redirect construction, empty-grant rejection, deny consumption + single-use
  • wristband/stores.go -- AuthCodeStore.MarkIssued signature extension, ClientStore.MarkConsented
  • wristband/server.go -- Options.CodeTTL (600s default)
  • wristband/registration_test.go -- memoryTx.MarkIssued/MarkConsented updated to satisfy the extended interface
  • ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go -- oauthTx.MarkIssued/MarkConsented GORM implementations
  • ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go -- ConsentShow/ConsentStore/ConsentDeny, oauthDeps, canonicalMintableIntersection, intersectStrings, readConsentScopes, untrustedName
  • ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller_test.go -- package-local real-Postgres harness (TestMain, apiDB) plus TestPhase8RedConsentController
  • ../fonoteka.go/plugins/golem15/fonoteka/plugin.go -- publishes *wristband.Server; wires code_ttl_seconds
  • ../fonoteka.go/plugins/golem15/fonoteka/routes.go -- mounts the three JWT-group consent routes and POST /oauth/mcp/token
  • ../fonoteka.go/plugins/golem15/fonoteka/oauth_connect_test.go -- TestPhase8RedConsentApp plus TestOAuthConsentCrossUserIsNotFound, TestOAuthConsentScopeCeilingViaShow, TestOAuthConsentEmptySubmittedScopeIntersectionIs422, TestOAuthDenyRedirectsAccessDeniedAndConsumesRequest, TestOAuthConsentMissingHandleIsNotFoundWithNoRequest, TestOAuthConsentSurfaceIsolation
  • scripts/check-phase8-ui.mjs -- the read-only UI-contract harness (Task 3)

Decisions Made

See frontmatter key-decisions. The two most load-bearing: (1) MarkIssued's signature had to grow beyond 08-02's original codeHash/userID-only shape because PHP's issueCode is a single UPDATE touching six columns, not two -- consent could not otherwise persist the user's actual granted-scope subset or the server-resolved collection pin; (2) canonical scope ordering in the consent show/allow response follows auth.MintableScopes's declared order (read, write, ai) per 08-UI-SPEC.md's explicit language, not PHP's array_intersect, which happens to preserve first-array order and would leak submission-order-dependent behavior into a contract the UI-SPEC documents as fixed.

Deviations from Plan

Auto-fixed Issues

1. [Rule 2 - Missing functionality] Extended wristband.AuthCodeStore.MarkIssued and added ClientStore.MarkConsented

  • Found during: Task 1, while designing wristband.Server.IssueCode
  • Issue: 08-02's MarkIssued(ctx, id, codeHash, userID) could not express PHP OAuthCodeManager::issueCode's full write (it also overwrites scopes, collection_ids, and expires_at), and no store method existed to stamp OAuthClient.consented_at once (D-08's explicit "consented_at stamping" requirement, and a precondition for DCR's SweepUnconsented to correctly skip consented clients)
  • Fix: Extended MarkIssued's signature to accept scopes []string, collectionIDs []uint, expiresAt time.Time; added MarkConsented(ctx, clientID) error with an idempotent WHERE consented_at IS NULL guard. Updated the GORM adapter (oauth_store.go) and the framework's in-memory test double (registration_test.go's memoryTx) to match
  • Files modified: wristband/stores.go, wristband/registration_test.go, ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
  • Verification: wristband/consent_test.go's TestIssueCodeGrantsOnlySubmittedScopesAndReturnsOrderedRedirect; classes/auth package's real-Postgres suite stays green (go test ./..., go test -race ./...)
  • Committed in: a1fa9c6 (summercms.go), 306b06e (fonoteka.go, compile-forced alongside the RED test commit)

2. [Rule 2 - Missing functionality] Added wristband.Options.CodeTTL

  • Found during: Task 1, implementing IssueCode's fresh expiry
  • Issue: PHP's OAuthCodeManager::CODE_TTL_SECONDS is a distinct constant from PENDING_TTL_SECONDS; wristband's Options had no field for it, and 08-02's config.yaml already declared code_ttl_seconds with no Go consumer (flagged as an open seam in 08-02-SUMMARY.md)
  • Fix: Added Options.CodeTTL (600s PHP-parity default) and wired golem15.fonoteka.oauth.code_ttl_seconds into it in plugin.go
  • Files modified: wristband/server.go, ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
  • Verification: wristband/consent_test.go exercises the default; no config-key behavior change to any currently-passing test
  • Committed in: a1fa9c6 (summercms.go), a52e8fa (fonoteka.go)

3. [Rule 1 - Bug] Fixed surf.Where("request_id", ...) binding to the wrong route

  • Found during: Task 2, first GREEN test run (TestPhase8RedConsentApp failed with surf: Where("request_id") is not a path parameter of /_fonoteka/api/v1/oauth/deny)
  • Issue: g.Where() constrains only the immediately preceding registered route (matching PHP's ->where()), not every route registered since the last Where() call; the three consent routes were registered before a single trailing Where("request_id", ...), so the constraint attached to /oauth/deny (the last-added route, which has no {request_id} parameter) instead of /oauth/request/{request_id}
  • Fix: Moved g.Where("request_id", ...) to sit immediately after g.Get("/oauth/request/{request_id}", ...), before the two POST routes
  • Files modified: ../fonoteka.go/plugins/golem15/fonoteka/routes.go
  • Verification: Full GREEN suite (go test ./..., go test -race ./...) passes in fonoteka.go/plugins/golem15/fonoteka
  • Committed in: a52e8fa

Total deviations: 3 auto-fixed (2 missing functionality, 1 bug) Impact on plan: No scope change beyond the plan's own stated D-08 behavior (consent's scope/collection persistence and consented_at stamping); the Where() bug was self-caught by the plan's own GREEN test suite before commit.

Issues Encountered

None beyond the auto-fixed items above. Full go vet/go test ./.../go test -race ./... are green in fonoteka.go/plugins/golem15/fonoteka (and its sibling classes, classes/auth, controllers/api, middleware, models, updates packages) and in summercms.go. The root fonoteka.go/parity module and plugins/golem15/user module were also checked (go vet/go test) and remain green; no new failures were introduced outside this plan's files.

User Setup Required

None -- no external service configuration required.

Next Phase Readiness

  • POST /oauth/mcp/token is now live end to end through the assembled app; 08-06 (lifecycle and sweeps) can build refresh rotation directly against the same mounted route rather than needing to mount it itself.
  • wristband.Server.IssueCode/DenyPending's pattern (protocol persists + redirects, app computes policy) is the established seam for any future consent-adjacent operation.
  • Connected-apps list/revoke (GET/DELETE .../oauth/connected-apps) and fonoteka-mcp's /api/v1/fonoteka/me prerequisite remain unbuilt -- neither was in this plan's file list despite the phase directory's "consent-and-connected-apps" name; 08-06/08-08 (per ROADMAP.md) own them.
  • scripts/check-phase8-ui.mjs's scenario catalog and guarded-file list are ready for 08-10 to wire into a real Playwright spec via --final-gate; no further catalog changes should be needed unless a new UI-SPEC state is added.
  • AUTH-05/06/07 remain Pending in REQUIREMENTS.md, continuing 08-01 through 08-04's decision: refresh rotation and connected-apps list/revoke are still outstanding pieces of those requirements.
  • No blockers.

Self-Check: PASSED

  • FOUND: wristband/consent.go, wristband/consent_test.go, wristband/stores.go, wristband/server.go, wristband/registration_test.go
  • FOUND: scripts/check-phase8-ui.mjs
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/oauth_consent_controller.go, oauth_consent_controller_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, routes.go, oauth_connect_test.go
  • FOUND commits (summercms.go): a1fa9c6, fac9648
  • FOUND commits (fonoteka.go): 306b06e, a52e8fa

Phase: 08-oauth2-1-authorization-server Completed: 2026-09-23