18 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 | 06 | auth |
|
|
|
|
|
|
|
|
~50min | 2026-09-23 |
Phase 08 Plan 06: Lifecycle and Sweeps (Refresh Rotation + Connected Apps) Summary
Refresh-token rotation with commit-then-kill replay detection, a D-17 expiry sweep on /token, and owner-scoped connected-app list/revoke that cascades through a new wristband.Server.Revoke seam -- all proven against real Postgres including synchronized concurrent-replay and concurrent-rotation row-lock tests.
Performance
- Duration: ~50 min
- Started: ~2026-09-23T19:00:00Z (approx.)
- Completed: ~2026-09-23T19:40:00Z (approx.)
- Tasks: 3 completed (5 commits: RED/GREEN pair in summercms.go for the wristband lifecycle framework, RED test + two GREEN commits in fonoteka.go for the store adapter and the connected-apps controller)
- Files modified: 10 (4 modified in summercms.go's wristband package; 3 created + 3 modified in fonoteka.go)
Accomplishments
wristband.Server.rotateRefreshTokenreplaces 08-04'sinvalid_grantplaceholder with a real port ofOAuthCodeManager::rotateRefresh: a fresh refresh token row-locks, revokes its own old access token, mints a same-scope/same-collection successor pair, and linksrotated_to_id; a replayed (already-rotated) token instead revokes the entire lineage -- every successor row and every linked access token -- and commits that kill before the handler reportsinvalid_grant, so a stolen predecessor can never resurrect a live branch (T-08-REFRESH-REPLAY)RevokeLineage(both the fonoteka GORM adapter and wristband's in-memory test double) was fixed to also revoke each visited row's linked access token -- the 08-02-era method only ever stamped the refresh row's ownrevoked_at, leaving a replayed lineage's currently-live access token usable; this was caught while wiring the real rotation caller, not left as a known gapwristband.Server.Revokeis the new cascade-revoke seam: it revokes an access token and, if a refresh row is still linked to it, kills that row's whole lineage too, in one committed transaction -- the exact operation the connected-app controller needs and never has to reimplement with its own refresh-table SQL- D-17's expiry sweep now also runs on
/token(AuthCodeStore.DeleteExpiredCodes/RefreshTokenStore.DeleteExpiredRefreshTokens), deleting only rows already pastexpires_atwhile unexpired rotated/revoked rows survive as replay evidence -- proven both against the in-memory backend and real Postgres fonoteka'sConnectedAppsIndex/ConnectedAppsDestroyexpose the existing Settings -> Integrations UI's backing API on the JWT group only: live/owner/OAuth-only tokens newest-first viaserializeTokenplus a sanitizedclient_name, and a foreign/missing/manual token id all collapse to the identical{"error":"Token not found"}404 (T-08-CROSS-USER) -- a revoke removes the app from the next list read and kills both the access token and the refresh token in the same request- Both
scripts/check-phase8-red.shsentinels (PHASE8_RED:lifecycle-frameworkinwristband,PHASE8_RED:lifecycle-appin the assembledfonotekaapp) were verified fail-closed against the genuine pre-implementation state (rotation placeholder; unmounted connected-apps routes) before their GREEN commits; the full GREEN suite (go vet/go test/go test -race) is green acrosssummercms.goand everyfonoteka.goworkspace module (fonoteka/parity,plugins/golem15/fonotekaand its subpackages,plugins/golem15/user)
Task Commits
Each task's RED test was committed and verified fail-closed via scripts/check-phase8-red.sh before its GREEN implementation:
- Task 1: lifecycle RED anchors --
b2c2cc0(test, summercms.go):TestPhase8RedLifecycleFrameworkfails against therotateRefreshTokenplaceholder (PHASE8_RED:lifecycle-framework); extendsRefreshTokenStore/AuthCodeStorewith the store seams Task 2 needs and updates the in-memory test double to compile against them.b3c235d(test, fonoteka.go):TestPhase8RedLifecycleAppfails against the unmounted connected-apps routes (PHASE8_RED:lifecycle-app), plus the list/revoke contract test matrix Task 3 turns green. - Task 2: implement refresh rotation, committed replay kill, and exact sweeps --
dab2b8f(feat, summercms.go): the realrotateRefreshToken, the/tokenexpiry sweep, andServer.Revoke.f62a952(feat, fonoteka.go):ByAPITokenIDForUpdate/MarkRotated/DeleteExpiredCodes/DeleteExpiredRefreshTokenson the GORM adapter, theRevokeLineageaccess-token-revocation fix, and real-Postgres proofs (rotation linking, full lineage-kill on replay, concurrent-rotation row-lock winner, sweep retention, connected-app-revoke cascade). - Task 3: connected-app list/revoke on the JWT group --
3b5fda5(feat, fonoteka.go):ConnectedAppsIndex/ConnectedAppsDestroy, their route mount, package-local handler unit tests, and the app-level lifecycle/surface-isolation test suite.
Plan metadata: committed as part of this summary/state-update commit.
Note: Tasks 1/2 carry tdd="true"; RED/GREEN pairs land as separate commits, split per repo. Task 3's RED anchor (TestPhase8RedLifecycleApp) was written and verified alongside Task 1's framework RED commit (both are the plan's single Task 1), then turned green by Task 3's own commit once the controller/routes existed.
Files Created/Modified
wristband/token.go-- realrotateRefreshToken(rotation + replay-kill), the/tokenD-17 sweep call,Server.Revokewristband/token_test.go--TestPhase8RedLifecycleFrameworkplusTestRefreshRotationIssuesNewPairAndKeepsPredecessorAsEvidence,TestRefreshWrongClientIsInvalidGrant,TestRefreshExpiredIsInvalidGrant,TestRefreshUnknownTokenIsInvalidGrant,TestRefreshMissingTokenIsInvalidGrant,TestRefreshConcurrentReplayHasExactlyOneWinner,TestTokenSweepDeletesExpiredRowsButKeepsUnexpiredEvidence,TestServerRevokeKillsAccessAndLineagewristband/stores.go--RefreshTokenStoregainsByAPITokenIDForUpdate/MarkRotated/DeleteExpiredRefreshTokens;AuthCodeStoregainsDeleteExpiredCodeswristband/registration_test.go--memoryTx/memoryBackendupdated for the extended interfaces, arevokedtracking map, aRevokeLineagefix mirroring the GORM adapter, and aMintsecret-uniqueness fix (the old"mem_" + namesecret collided across two mints for the same client name, which a rotation test would have otherwise silently mismatched)../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go--ByAPITokenIDForUpdate,MarkRotated,DeleteExpiredCodes,DeleteExpiredRefreshTokens, andRevokeLineage's access-token-revocation fix../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store_test.go--TestOAuthRefreshRotationLinksPredecessorToSuccessor,TestOAuthRefreshReplayRevokesLineageAndBothAccessTokens,TestOAuthRefreshConcurrentReplayHasExactlyOneWinner,TestOAuthSweepDeletesExpiredCodesAndRefreshTokensKeepsUnexpiredEvidence,TestOAuthConnectedAppRevokeStoreKillsAccessAndLineage../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go--ConnectedAppsIndex,ConnectedAppsDestroy,serializeConnectedApp../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller_test.go-- package-local handler unit tests (list allow-list/forbidden-fields, foreign/missing/owned destroy)../fonoteka.go/plugins/golem15/fonoteka/routes.go-- mountsGET/DELETE /_fonoteka/api/v1/oauth/connected-apps(/{id})on the existing JWT group../fonoteka.go/plugins/golem15/fonoteka/oauth_lifecycle_test.go--TestPhase8RedLifecycleApp,TestConnectedAppsListEmptyThenPopulatedWithManualCount,TestConnectedAppsRevokeForeignAndMissingAndManualShareExact404,TestConnectedAppsRevokeKillsAccessAndRefreshThenDisappearsFromList,TestOAuthConnectedAppsSurfaceIsolation
Decisions Made
See frontmatter key-decisions. The most load-bearing: RevokeLineage's access-token gap was a genuine Rule 1 bug in code that predates this plan (08-02's Tx interface shipped RevokeLineage's signature, and 08-04's placeholder already wired the replay branch to call it) -- without this plan's fix, a replayed refresh token would have correctly killed the refresh-row lineage but left the currently-live access token minted by the last legitimate rotation fully usable, defeating T-08-REFRESH-REPLAY's entire point.
Deviations from Plan
Auto-fixed Issues
1. [Rule 1 - Bug] RevokeLineage did not revoke linked access tokens
- Found during: Task 2, while implementing
rotateRefreshToken's replay branch and designing its verification - Issue:
RevokeLineage(both the GORM adapter from 08-02 and wristband's in-memory test double) only stampedrevoked_aton visited refresh rows; it never touched theApiTokenrow each refresh row'sAPITokenIDpoints at. A replay would correctly mark the whole refresh chain dead but leave the last legitimate access token minted by rotation still verifiable. - Fix:
RevokeLineagenow also row-locks and revokes each visited row's linked access token (if unrevoked), matching PHPOAuthCodeManager::revokeLineage's own per-row access-token revoke - Files modified:
../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go,wristband/registration_test.go - Verification:
TestOAuthRefreshReplayRevokesLineageAndBothAccessTokens,TestPhase8RedLifecycleFramework,TestServerRevokeKillsAccessAndLineage - Committed in:
dab2b8f(summercms.go in-memory double),f62a952(fonoteka.go GORM adapter)
2. [Rule 1 - Bug] In-memory Mint test double minted colliding secrets across rotations
- Found during: Task 1, while designing the lifecycle RED anchor
- Issue:
memoryTx.Mint's secret was"mem_" + namewith no per-mint uniqueness; a rotation test minting a second access token for the same client name would receive the identical secret string as the first, making secret-based identification in tests silently ambiguous - Fix: The secret now embeds the freshly-allocated id (
"mem_<id>_<name>") - Files modified:
wristband/registration_test.go - Verification:
TestPhase8RedLifecycleFrameworkand the other rotation tests correctly distinguish old vs. new access tokens by secret - Committed in:
b2c2cc0
Total deviations: 2 auto-fixed (2 bugs, both pre-existing test-infrastructure/store gaps caught while building this plan's own verification) Impact on plan: No scope change beyond the plan's own stated D-04/D-17/D-08 behavior; both fixes were necessary for the plan's own tests to correctly prove T-08-REFRESH-REPLAY, not separate feature additions.
Issues Encountered
None beyond the auto-fixed items above. Full go vet/go test ./.../go test -race ./... are green in summercms.go (wristband and every other package) and in every fonoteka.go workspace module: the root fonoteka/parity module, plugins/golem15/fonoteka and its classes, classes/auth, controllers/api, middleware, models, updates subpackages, and plugins/golem15/user. scripts/check-phase8-ui.mjs --contract-self-test remains green (no Nuxt-facing change).
User Setup Required
None -- no external service configuration required.
Next Phase Readiness
wristband.Server.Revokeis the established cascade-revoke seam; any future plan needing to kill an access token's OAuth lineage should call it rather than reimplementing refresh-table SQL.RefreshTokenStore/AuthCodeStorenow carry every method 08-02's own "noDeleteExpired-shaped method yet" note flagged as outstanding; no further store-interface changes are anticipated for the remaining Phase 8 plans' known scope.- AUTH-05/06/07 remain Pending in REQUIREMENTS.md: this plan closes the refresh-rotation and connected-apps pieces of their text, but each requirement's full text also depends on an "unchanged fonoteka-mcp install/auth flow" proof that 08-08/08-09 own, plus 08-10's security review reconciling AUTH-06's CSRF/rate-limit/cache-header claims against the complete threat register.
08-07(OAuth client command) and08-08(mcp-me prerequisite) can build directly on this plan'sServer.Revoke/sweep seams without another wristband interface change.- No blockers.
Self-Check: PASSED
- FOUND: wristband/token.go, wristband/token_test.go, wristband/stores.go, wristband/registration_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go, oauth_store_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/connected_app_controller.go, connected_app_controller_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/routes.go, oauth_lifecycle_test.go
- FOUND commits (summercms.go):
b2c2cc0,dab2b8f - FOUND commits (fonoteka.go): b3c235d, f62a952, 3b5fda5
Phase: 08-oauth2-1-authorization-server Completed: 2026-09-23