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

15 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 02 auth
oauth2
rfc7591
dcr
wristband
gorm
transactions
postgres
advisory-lock
phase plan provides
08-oauth2-1-authorization-server 01 wristband package skeleton (Options/Server/DefaultOptions), the assembled raw route group, and scripts/check-phase8-red.sh
Corrected OAuth lifecycle schema: client_secret_hash/request_id/code_hash/user_id nullable, nine PHP-equivalent named indexes, additive gormigrate migration with a refusing (non-destructive) rollback
wristband.Backend/wristband.Tx transaction-scoped store bundle (ClientStore/AuthCodeStore/RefreshTokenStore/AccessTokenIssuer) plus an in-memory implementation for framework tests
wristband.Register: exact RFC 7591 DCR handler (validation order, 64 KiB body bound, control-char stripping, one-time secret, sha256-hash-only persistence)
fonoteka classes/auth.OAuthStore: the GORM adapter satisfying wristband.Backend, with an advisory-lock-serialized sweep+cap+create and row-locked (FOR UPDATE) code/refresh reads for later plans
POST /oauth/mcp/register mounted on the assembled raw group with only its named throttle; golem15.fonoteka.oauth.* config wired into the wristband server at Boot
08-03-authorize
08-04-token-exchange
08-05-consent-and-connected-apps
08-06-lifecycle-and-sweeps
08-07-oauth-client-command
08-09-parity-and-real-mcp-gate
08-10-unit-tests-and-security-review
added patterns
Transaction-scoped store bundle: wristband.Backend.WithinTx hands one *gorm.DB-backed wristband.Tx to every store method for a single request, so sweep+cap+create (and later code-exchange/refresh-rotation) can never straddle two transactions
Postgres advisory transaction lock (pg_advisory_xact_lock) serializes the DCR cap check + insert instead of row-level locking an aggregate, closing the count-then-insert race under concurrency
RED/GREEN split per repo per task: each task's RED test (stubbed handler/store method returning a placeholder error) is committed before the real implementation, verified fail-closed via scripts/check-phase8-red.sh
created modified
wristband/stores.go
wristband/crypto.go
wristband/register.go
wristband/registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/updates/21_oauth_schema_correction.go
../fonoteka.go/plugins/golem15/fonoteka/updates/oauth_schema_correction_test.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go
../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml
../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go
../fonoteka.go/plugins/golem15/fonoteka/models/oauth_auth_code.go
../fonoteka.go/plugins/golem15/fonoteka/plugin.go
../fonoteka.go/plugins/golem15/fonoteka/routes.go
The corrective migration file is named 21_oauth_schema_correction.go, not 12_ as the plan's file list suggested (discretionary per 08-PATTERNS.md): Go's init()-order registration follows filename order, and gormigrate.RollbackLast() rolls back the last element of the registered slice, not the migration with the numerically highest ID. Naming it 12_ placed it before 20_remaining.go's migrations in the slice, so RollbackLast() rolled back the wrong migration; 21_ fixes the ordering to match its later migration ID.
Rollback refuses (returns an error, changes nothing) rather than deleting or coercing rows when any row still depends on a nullable lifecycle column; it only restores the original NOT NULL constraints and drops the added indexes once no such row exists.
wristband.Tx ships the full ClientStore/AuthCodeStore/RefreshTokenStore/AccessTokenIssuer surface now (08-RESEARCH.md Pattern 1) even though only ClientStore is exercised by this plan's DCR handler, so 08-03/08-04 reuse the same transaction seam without another interface change. AuthCodeStore/RefreshTokenStore intentionally omit an expiry-sweep method (D-17's sweep) — that's 08-06's Lifecycle plan's responsibility, not invented here.
Register strips C0 control characters and DEL from client_name at capture time (matching the regex PHP applies only at ConnectedApp/Consent display time), per 08-CONTEXT.md's discretion note to 'port PHP's regex' for control-character handling. PHP does not strip at DCR time itself; this is a defensive superset, not a byte-parity requirement, and is called out here in case a later parity check expects the PHP-unfiltered raw name.
Config keys live under golem15.fonoteka.oauth.* (the plugin's bare ID, matching compass.MergePlugin's path convention and the existing golem15.user.jwt.* precedent), not the plugins.golem15.fonoteka.oauth.* form floated as a discussion option in D-03; D-03 explicitly left the exact layout to implementation discretion.
pending_request_ttl_seconds/code_ttl_seconds/access_token_ttl_seconds/refresh_token_ttl_seconds/resource are declared in config/config.yaml with PHP-parity defaults per D-03, but no Go field consumes them yet — only dcr_client_cap, dcr_unconsented_sweep_seconds and register_max_body_bytes are wired into wristband.Options in this plan, since authorize/token don't exist until 08-03/08-04.
AUTH-05/AUTH-06/AUTH-07 are NOT marked complete in REQUIREMENTS.md, continuing 08-01's decision: this plan ships DCR only (one of AUTH-05's several RFC surfaces); authorize/PKCE/consent/token/refresh remain for later Phase 8 plans, and marking these Complete now would misrepresent phase progress.
oauthClientRegistrationLockKey (pg_advisory_xact_lock over hashtext) is the pattern for any future app-tier count-then-mutate invariant that must be atomic under concurrent transactions without a dedicated lock table.
~30min 2026-09-23

Phase 08 Plan 02: Persistence and Registration Summary

Corrected OAuth lifecycle schema plus a transaction-scoped, advisory-lock-serialized RFC 7591 Dynamic Client Registration slice, live end to end from the assembled Go app through real Postgres.

Performance

  • Duration: ~30 min
  • Started: 2026-09-23T17:18:26Z (approx., continuing from 08-01)
  • Completed: 2026-09-23T17:48:00Z (approx.)
  • Tasks: 3 completed (6 commits: RED/GREEN pairs across both repos)
  • Files modified: 14 (5 created + 2 modified in summercms.go's wristband package; 5 created + 4 modified in fonoteka.go)

Accomplishments

  • The shipped OAuth schema (202609180010_create_oauth_tables) could not represent a pre-consent pending authorization row or a public DCR client; a new additive migration relaxes the four wrongly-NOT NULL columns and adds all nine PHP-equivalent named indexes, with a rollback that refuses rather than destroys data when any row still depends on nullability
  • wristband gained its transaction-scoped Backend/Tx store bundle and an in-memory implementation for framework-only tests, plus fixed-transform crypto helpers (crypto/rand base64url, sha256 hex, crypto/subtle.ConstantTimeCompare, S256)
  • wristband.Register is an exact byte-for-byte port of OAuthRegisterController::register's validation order, redirect-URI/grant/response-type/auth-method rules, 64 KiB body bound, and one-time-secret/hash-only persistence contract
  • fonoteka's OAuthStore GORM adapter satisfies wristband.Backend: CreateWithCap serializes sweep+cap-check+create via pg_advisory_xact_lock, proven by a synchronized real-Postgres cap-1 test (TestOAuthRegistrationCap) that yields exactly one created row and one ErrClientCapReached under concurrency
  • POST /oauth/mcp/register is live on the assembled app's raw route group with only its named throttle; a public and a confidential client each register successfully against real Postgres and reload through a fresh transaction with hash-only rows

Task Commits

Each task's RED test was committed and verified fail-closed via scripts/check-phase8-red.sh before its GREEN implementation:

  1. Task 1: OAuth schema correction
    • 0b18d27 (test, fonoteka.go): TestPhase8RedOAuthSchema fails against the still-NOT NULL schema (PHASE8_RED:persistence-schema)
    • 4536b3e (feat, fonoteka.go): the additive correction migration (21_oauth_schema_correction.go) and pointer-ified model fields
  2. Task 2a: wristband registration (RED) — c026b83 (test, summercms.go): TestPhase8RedRegistration fails against a 501 stub (PHASE8_RED:registration); adds stores.go/crypto.go and the Options/Server seams Task 2b: wristband registration (GREEN) — c0b1e3c (feat, summercms.go): the exact RFC 7591 Register handler Task 2c: OAuth store (RED) — fec99f0 (test, fonoteka.go): TestPhase8RedRegistrationStore fails against a stubbed CreateWithCap (PHASE8_RED:registration-store) Task 2d: OAuth store (GREEN) — 381de57 (feat, fonoteka.go): the transaction-scoped GORM adapter with the advisory lock and row-lock reads
  3. Task 3a: assembled mount (RED) — 5891c7c (test, fonoteka.go): TestPhase8RedRegistrationApp fails 404 against the unmounted route (PHASE8_RED:registration-app); adds golem15.fonoteka.oauth.* config and wires the store-backed server into Plugin.Boot Task 3b: assembled mount (GREEN) — ebdb249 (feat, fonoteka.go): mounts POST /oauth/mcp/register on the raw group with only its named throttle

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

Note: all three tasks carry tdd="true"; RED/GREEN pairs land as separate commits, split per repo where both repos changed (Tasks 2 and 3).

Files Created/Modified

  • wristband/stores.go — ClientRecord/AuthCodeRecord/RefreshTokenRecord/IssuedToken, ClientStore/AuthCodeStore/RefreshTokenStore/AccessTokenIssuer, Tx, Backend, ErrClientCapReached
  • wristband/crypto.go — randomBase64URL, sha256Hex, constantEqual, s256Challenge
  • wristband/register.go — the RFC 7591 Server.Register handler and its PHP-ported validation helpers
  • wristband/registration_test.go — TestPhase8RedRegistration plus the in-memory memoryBackend/memoryTx and the full public/confidential/bounds/sweep/cap test matrix
  • wristband/server.go — Options gains DCRClientCap/DCRUnconsentedSweepAge/RegisterMaxBodyBytes; Server gains backend/now/randomBytes and SetBackend
  • ../fonoteka.go/plugins/golem15/fonoteka/updates/21_oauth_schema_correction.go — the additive, refusing-rollback correction migration
  • ../fonoteka.go/plugins/golem15/fonoteka/updates/oauth_schema_correction_test.go — the package's own real-Postgres harness plus the RED/GREEN schema tests
  • ../fonoteka.go/plugins/golem15/fonoteka/models/oauth_client.go, oauth_auth_code.go — pointer-ified nullable fields
  • ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go — OAuthStore/oauthTx (the wristband.Backend/wristband.Tx GORM adapter) and record↔model conversions
  • ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go — TestPhase8RedRegistrationStore, TestOAuthRegistrationStore, TestOAuthRegistrationCap
  • ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml — golem15.fonoteka.oauth.* defaults (new file; the plugin had no config before this plan)
  • ../fonoteka.go/plugins/golem15/fonoteka/plugin.go — pact.HasConfig, config-driven wristband.Options, SetBackend(auth.NewOAuthStore(gdb))
  • ../fonoteka.go/plugins/golem15/fonoteka/routes.go — mounts POST /oauth/mcp/register with throttle:fonoteka-oauth-register
  • ../fonoteka.go/plugins/golem15/fonoteka/oauth_registration_test.go — TestPhase8RedRegistrationApp, TestOAuthRegisterAssembled, TestOAuthRegisterOversizedAndWrongContentType, TestOAuthRawRegistrationSurface

Decisions Made

See frontmatter key-decisions. The most load-bearing one: the corrective migration file had to be renamed from the plan's suggested 12_ prefix to 21_ because Go's init() file-order registration — not the migration's own ID — determines gormigrate slice position, and RollbackLast() rolls back the slice's last element. 12_ before 20_remaining.go meant RollbackLast() targeted the wrong migration; this was caught by TestOAuthSchemaCorrection's rollback-refusal assertion failing with "rollback succeeded while null-dependent rows exist" during GREEN verification, fixed, and re-verified (Rule 1 — bug, self-caught before commit, not a deviation requiring separate documentation since it's the initial implementation, not a later fix).

Deviations from Plan

1. [Rule 1 - Bug] Migration filename changed from 12_ to 21_ for correct init-order/RollbackLast semantics

  • Found during: Task 1, before the GREEN commit (caught by TestOAuthSchemaCorrection's own rollback assertion)
  • Issue: Naming the file 12_oauth_schema_correction.go placed its init()-time Register() call before 20_remaining.go's, making it not the last element of updates.All() despite having the numerically latest migration ID; gormigrate.RollbackLast() rolled back 20_remaining.go's last migration instead
  • Fix: Renamed the file to 21_oauth_schema_correction.go; 08-PATTERNS.md explicitly marks the filename as discretionary
  • Files modified: ../fonoteka.go/plugins/golem15/fonoteka/updates/21_oauth_schema_correction.go (created directly under this name; never committed as 12_)
  • Verification: TestOAuthSchemaCorrection's rollback-refusal and rollback-success assertions both pass
  • Committed in: 4536b3e (Task 1 GREEN commit)

Total deviations: 1 auto-fixed (1 bug) Impact on plan: No scope change; purely a file-naming/ordering correction caught by the plan's own test before commit.

Issues Encountered

None beyond the migration-ordering issue documented above.

User Setup Required

None — no external service configuration required.

Next Phase Readiness

  • wristband.Tx's full store bundle (including row-lock reads and RevokeLineage) is ready for 08-03 (authorize) and 08-04 (token exchange/refresh rotation) to consume without another interface change.
  • golem15.fonoteka.oauth.* config already declares pending_request_ttl_seconds/code_ttl_seconds/access_token_ttl_seconds/refresh_token_ttl_seconds/resource; 08-03/08-04 need only read them, not add new keys.
  • The oauthClientRegistrationLockKey advisory-lock pattern is available for any future atomic count-then-mutate need (e.g. a future per-user rate concern).
  • D-17's expiry sweep (deleting expired pending/code/refresh rows) is explicitly deferred to 08-06 — AuthCodeStore/RefreshTokenStore do not yet have a DeleteExpired-shaped method; 08-06 should add it rather than assume it exists.
  • No blockers.

Self-Check: PASSED

  • FOUND: wristband/stores.go, wristband/crypto.go, wristband/register.go, wristband/registration_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/updates/21_oauth_schema_correction.go, oauth_schema_correction_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, oauth_store_test.go
  • FOUND: ../fonoteka.go/plugins/golem15/fonoteka/config/config.yaml, oauth_registration_test.go
  • FOUND commits (fonoteka.go): 0b18d27, 4536b3e, fec99f0, 381de57, 5891c7c, ebdb249
  • FOUND commits (summercms.go): c026b83, c0b1e3c