docs(08-07): complete oauth-client-command plan

This commit is contained in:
Jakub Zych
2026-09-23 22:05:44 +02:00
parent 398353b135
commit 27845490e8
3 changed files with 184 additions and 9 deletions

View File

@@ -349,7 +349,7 @@ Plans:
**Wave 7** *(parallel; blocked on 08-06)*
- [ ] 08-07-PLAN.md — Provision confidential clients through the exact operator command
- [x] 08-07-PLAN.md — Provision confidential clients through the exact operator command
- [ ] 08-08-PLAN.md — Serve the MCP personal-token bootstrap on the existing token surface
**Wave 8** *(blocked on 08-07 and 08-08)*
@@ -496,7 +496,7 @@ Phases execute in numeric order: 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8 →
| 5. Data layer full fidelity | 6/6 | Complete | 2026-09-18 |
| 6. HTTP routing, auth groups and rate limiting | 14/14 | Complete | 2026-09-21 |
| 7. User plugin and authentication | 8/8 | Complete | 2026-09-23 |
| 8. OAuth2.1 authorization server | 6/10 | In Progress| |
| 8. OAuth2.1 authorization server | 7/10 | In Progress| |
| 9. Backend admin authentication and schema pipeline | 0/TBD | Not started | - |
| 10. Admin Vue SPA | 0/TBD | Not started | - |
| 11. Jobs, realtime and search infrastructure | 0/TBD | Not started | - |

View File

@@ -3,14 +3,14 @@ gsd_state_version: 1.0
milestone: v1.0
milestone_name: milestone
status: executing
stopped_at: Completed 08-06-PLAN.md
last_updated: "2026-09-23T19:42:20.033Z"
stopped_at: Completed 08-07-PLAN.md
last_updated: "2026-09-23T20:05:28.781Z"
last_activity: 2026-09-23
progress:
total_phases: 15
completed_phases: 7
total_plans: 55
completed_plans: 51
completed_plans: 52
percent: 47
---
@@ -26,11 +26,11 @@ See: .planning/PROJECT.md (updated 2026-09-16)
## Current Position
Phase: 08 (oauth2-1-authorization-server) — EXECUTING
Plan: 7 of 10
Plan: 8 of 10
Status: Ready to execute
Last activity: 2026-09-23
Progress: [█████████░] 93%
Progress: [██████████] 95%
## Performance Metrics
@@ -97,6 +97,7 @@ Progress: [█████████░] 93%
| Phase 08 P04 | 15min | 2 tasks | 5 files |
| Phase 08 P05 | 55min | 3 tasks | 12 files |
| Phase 08 P06 | 50min | 3 tasks | 10 files |
| Phase 08 P07 | 35min | 2 tasks | 9 files |
## Accumulated Context
@@ -238,6 +239,11 @@ Recent decisions affecting current work:
- [Phase 08 P06]: RevokeLineage walks forward only through RotatedToID; sufficient for both replay-kill and connected-app-revoke because every normal rotation already revokes its own predecessor's access token and connected-app revoke always starts from the terminal row
- [Phase 08 P06]: Fixed RevokeLineage (GORM adapter and in-memory test double) to also revoke each visited row's linked access token -- the 08-02-era method only stamped the refresh row itself, leaving a replayed lineage's live access token usable
- [Phase 08 P06]: AUTH-05/06/07 remain Pending in REQUIREMENTS.md: refresh-rotation and connected-apps pieces are done, but AUTH-05/07's unchanged-fonoteka-mcp clause needs 08-08/08-09, and AUTH-06's CSRF/rate-limit/cache-header claims are reconciled by 08-10's security review
- [Phase ?]: [Phase 08 P07]: bonfire.Flag.Repeatable / Input.Flags(name) is the new shape for PHP-parity =* console options (Cobra StringSlice), with no change to existing scalar/bare Flag callers
- [Phase ?]: [Phase 08 P07]: fonoteka:oauth-client create reuses wristband.Tx.CreateWithCap(ctx, rec, math.MaxInt32) instead of adding an uncapped Create method -- operator-issued clients are never subject to DCR's 200-client cap
- [Phase ?]: [Phase 08 P07]: list/update operate directly on models.OAuthClient via *gorm.DB rather than extending wristband.ClientStore -- only client issuance needs the shared wristband hash/validation path (T-08-SECRET-TIMING)
- [Phase ?]: [Phase 08 P07]: Fixed clientRecordToModel to persist ScopeCeiling, a mapping gap silent since 08-02 because DCR never sets a ceiling
- [Phase ?]: [Phase 08 P07]: AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md -- both requirements' full text still needs 08-08/08-09's unchanged fonoteka-mcp install/auth flow proof
### Pending Todos
@@ -259,6 +265,6 @@ Items acknowledged and carried forward from previous milestone close:
## Session Continuity
Last session: 2026-09-23T19:42:20.015Z
Stopped at: Completed 08-06-PLAN.md
Last session: 2026-09-23T20:05:28.751Z
Stopped at: Completed 08-07-PLAN.md
Resume file: None

View File

@@ -0,0 +1,169 @@
---
phase: 08-oauth2-1-authorization-server
plan: 07
subsystem: auth
tags: [oauth2, rfc7591, dcr, bonfire, cobra, cli, wristband, gorm]
# Dependency graph
requires:
- phase: 08-oauth2-1-authorization-server
plan: 06
provides: "wristband.Server.Revoke, D-17 expiry sweep, refresh rotation/lineage-kill, and the fonoteka classes/auth.OAuthStore GORM adapter this plan's command reuses for client persistence"
provides:
- "bonfire.Flag.Repeatable / Input.Flags(name): an ordered, multi-occurrence string flag (Cobra StringSlice) alongside the existing scalar/bare Flag contract, with no change to existing callers"
- "wristband.IssueClientCredentials / wristband.RejectRedirectURI: the exact random-id/secret/hash and redirect-URI validation RFC 7591 registration uses, exported so an app-owned command can share the identical path instead of re-deriving it"
- "fonoteka:oauth-client (OAuthClientCommand): create a confidential, ceiling-bounded OAuth client with a one-time secret; --client-id adds redirect URIs without rotating the secret; --list prints id/name/revocation/redirects/ceiling and never a secret or hash"
- "Fixed clientRecordToModel to persist ScopeCeiling, closing a gap silent since 08-02 (DCR never sets a ceiling, so nothing had exercised the missing mapping until this plan's command needed it)"
affects: [08-08-mcp-me-prerequisite, 08-09-parity-and-real-mcp-gate, 08-10-unit-tests-and-security-review]
# Tech tracking
tech-stack:
added: []
patterns:
- "Repeatable bonfire flags: Command.Flags declares Repeatable:true, wrap() registers a Cobra StringSlice instead of a scalar String flag, and Input.Flags(name) reads it back in insertion order; scalar/bare flags on the same command are unaffected. Any future PHP-parity `=*` console option should follow this same shape."
- "Shared client-issuing path: wristband.IssueClientCredentials/RejectRedirectURI are the one place client_id/client_secret generation, hashing and redirect-URI validation happen; both RFC 7591 registration and the operator command call into them rather than each re-deriving the rule (T-08-SECRET-TIMING)."
- "CreateWithCap reused with math.MaxInt32 for operator-issued clients: the atomic locked count+insert wristband.Tx.CreateWithCap already provides is reused for its insert path, with the cap effectively disabled, rather than adding a second uncapped Create method to the ClientStore interface."
key-files:
created:
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go
- ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go
- wristband/client_issue.go
modified:
- bonfire/command.go
- bonfire/root.go
- bonfire/output_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go
- ../fonoteka.go/plugins/golem15/user/console_test.go
key-decisions:
- "List/update operate directly on models.OAuthClient via *gorm.DB rather than extending wristband.ClientStore with List/Update methods: only Create needs the shared wristband hash/validation path (T-08-SECRET-TIMING); list/redirect-URI-merge are ordinary app-layer queries, matching how other app controllers (token_api_controller.go) already query models directly instead of going through wristband."
- "Create reuses wristband.Tx.CreateWithCap(ctx, rec, math.MaxInt32) instead of adding an uncapped Create method to ClientStore: an operator-issued client is never subject to DCR's 200-client cap (D-03/D-21 caps DCR flooding only), and reusing CreateWithCap keeps exactly one atomic insert path instead of two."
- "wristband/client_issue.go is a new exported file (not in the plan's literal files_modified list) because D-19's threat mitigation explicitly requires a shared hash/validation path between DCR and the operator command; wristband.IssueClientCredentials/RejectRedirectURI wrap the existing unexported randomBase64URL/sha256Hex/rejectRedirectURI so there is still exactly one implementation of each rule."
- "Fixed clientRecordToModel (classes/auth/oauth_store.go) to map ClientRecord.ScopeCeiling onto the persisted model: this field has existed on wristband.ClientRecord since 08-02 but nothing ever set it on a ClientRecord before this plan, so the missing mapping was silent until the operator command's ceiling needed to survive CreateWithCap."
- "AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md, continuing 08-02..08-06's decision: both requirements' full text also depends on an unchanged fonoteka-mcp install/auth flow proof that only 08-08/08-09 can establish; this plan ships the D-19 operator command only."
patterns-established:
- "bonfire.Flag.Repeatable / Input.Flags(name) is the established shape for any future PHP `=*` console option; use it instead of inventing a second flag-collection mechanism."
requirements-completed: []
# Metrics
duration: ~35min (approx.)
completed: 2026-09-23
---
# Phase 08 Plan 07: OAuth Client Operator Command Summary
**fonoteka:oauth-client bonfire command (create/--client-id/--list) sharing wristband's exact client-issuing hash/validation path, on top of a new repeatable-flag contract in bonfire (Flag.Repeatable / Input.Flags).**
## Performance
- **Duration:** ~35 min (approx.)
- **Started:** ~2026-09-23T19:25:00Z (approx.)
- **Completed:** ~2026-09-23T20:00:08Z (approx.)
- **Tasks:** 2 completed (4 commits: RED/GREEN pair in summercms.go for bonfire's repeatable-flag seam, RED test + one GREEN commit in fonoteka.go for the command)
- **Files modified:** 9 (3 created + 3 modified in summercms.go's bonfire/wristband packages; 3 modified in fonoteka.go, one of them a pre-existing sibling test file fixed for the new Input method)
## Accomplishments
- `bonfire.Flag` gained `Repeatable` and `Input` gained `Flags(name) []string`: a Repeatable flag registers as a Cobra `StringSlice` instead of a scalar `String` flag, so `--redirect-uri=a --redirect-uri=b` is readable back in insertion order without disturbing any existing scalar/bare flag on the same command (`TestFlagAndArgumentParsing` and every other pre-existing bonfire test stayed green unchanged)
- `wristband.IssueClientCredentials`/`RejectRedirectURI` export the exact random-id/secret/sha256-hash and redirect-URI validation RFC 7591 registration already used internally, so the operator command shares one implementation of "how a client secret is generated and hashed" with DCR (T-08-SECRET-TIMING) instead of re-deriving it
- `fonoteka:oauth-client` (`OAuthClientCommand`) ports `IssueOAuthClient.php` byte-for-byte in shape: create prints `client_id=`/`client_secret=` once plus the non-recoverable warning, `--client-id <id>` merges new `--redirect-uri` values into an existing client without touching its secret hash, and `--list` prints `client_id=`/`client_name=`/`revoked=`/`redirect_uri=`/`scope_ceiling=` for every client and never a secret or hash
- Fixed a silent gap in `clientRecordToModel` (fonoteka's GORM adapter): `wristband.ClientRecord.ScopeCeiling` has existed since 08-02 but was never mapped onto the persisted `models.OAuthClient` row, because DCR-created clients never set a ceiling; this plan's command is the first caller that needs the ceiling to actually survive `CreateWithCap`, and the gap would have silently dropped every `--scope` value without the fix
- Both Phase 8 RED sentinels (`PHASE8_RED:bonfire-flags` in `bonfire`, `PHASE8_RED:oauth-command` in the fonoteka `console` package) were verified fail-closed via `scripts/check-phase8-red.sh` against the genuine pre-implementation state (unwired `Repeatable`; a "not implemented" command stub) before their GREEN commits; `go vet`/`go test`/`go test -race` are green across `summercms.go` and every touched `fonoteka.go` workspace module (root `fonoteka`/`parity`, `plugins/golem15/fonoteka` and 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:
1. **Task 1: repeatable-flag and command RED anchors**
- `7096a90` (test, summercms.go): `TestPhase8RedBonfireFlags` fails against unwired `Repeatable` (`PHASE8_RED:bonfire-flags`); adds the compiling seam (`Flag.Repeatable`, `Input.Flags`, `cobraInput.Flags`)
- `4efce33` (test, fonoteka.go): `TestPhase8RedOAuthClientCommand` fails against the "not implemented" command stub (`PHASE8_RED:oauth-command`); adds `console/oauth_client.go`'s exact flag/argument contract and fixes `user/console_test.go`'s `emailArg` to satisfy the extended `bonfire.Input` interface
2. **Task 2: implement repeatable flags and the exact OAuth client command**
- `398353b` (feat, summercms.go): wires `Repeatable` into Cobra `StringSlice` registration in `wrap()`; exports `wristband.IssueClientCredentials`/`RejectRedirectURI`; adds `TestRepeatableFlagUnsetReturnsEmpty`/`TestRepeatableFlagCoexistsWithBareAndScalar`
- `9e82cac` (feat, fonoteka.go): the real `OAuthClientCommand` create/update/list implementation, `Plugin.Commands()` registration, the `clientRecordToModel` `ScopeCeiling` fix, and the full `OAuthClientCommandTest` parity test matrix
**Plan metadata:** committed as part of this summary/state-update commit.
_Note: both tasks carry `tdd="true"`; RED/GREEN pairs land as separate commits, split per repo._
## Files Created/Modified
- `bonfire/command.go` — `Flag.Repeatable`, `Input.Flags(name) []string`, `cobraInput.Flags`
- `bonfire/root.go` — `wrap()` registers a Repeatable flag as Cobra `StringSliceP`/`StringSlice`
- `bonfire/output_test.go` — `TestPhase8RedBonfireFlags`, `TestRepeatableFlagUnsetReturnsEmpty`, `TestRepeatableFlagCoexistsWithBareAndScalar`
- `wristband/client_issue.go` — `IssueClientCredentials`, `RejectRedirectURI` (exported wrappers over existing unexported primitives)
- `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go` — `OAuthClientCommand`: create/`--client-id`/`--list`, plus `oauthClientCollectRedirectURIs`/`oauthClientCollectScopeCeiling`/`oauthClientMergeUniqueURIs` helpers
- `../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client_test.go` — `TestPhase8RedOAuthClientCommand` plus the `OAuthClientCommandTest` parity matrix (list-never-leaks-secret, add-redirect-uri-keeps-secret, unknown client_id, scope-ceiling persistence/listing, invalid-scope rejects without creating a client, 1..5-redirect-uri bound) and the package's own real-Postgres `TestMain` harness
- `../fonoteka.go/plugins/golem15/fonoteka/plugin.go` — `Commands()` registers `console.OAuthClientCommand(p.app)`; `pact.HasCommands` assertion
- `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go` — `clientRecordToModel` now maps `ScopeCeiling`
- `../fonoteka.go/plugins/golem15/user/console_test.go` — `emailArg.Flags(string) []string` to satisfy the extended `bonfire.Input` interface
## Decisions Made
See frontmatter `key-decisions`. The most load-bearing: list/update deliberately stay outside the `wristband.ClientStore` abstraction (ordinary `*gorm.DB` queries against `models.OAuthClient`), while create deliberately goes through `wristband.Tx.CreateWithCap` with `math.MaxInt32` as the cap — only client *issuance* needs the shared hash/validation path the threat model calls for; list/redirect-URI-merge are plain app-layer reads/writes with no security-sensitive generation logic to share.
## Deviations from Plan
### Auto-fixed Issues
**1. [Rule 3 - Blocking] `user/console_test.go`'s `emailArg` did not satisfy the extended `bonfire.Input` interface**
- **Found during:** Task 1, immediately after adding `Input.Flags` to `bonfire/command.go`
- **Issue:** `bonfire.Input` gaining a new method broke compilation of every existing structural implementer; `user/console_test.go`'s `emailArg` (used by `TestRequirePasswordChange`) is the only other one in either repo
- **Fix:** Added `func (e emailArg) Flags(string) []string { return nil }`
- **Files modified:** `../fonoteka.go/plugins/golem15/user/console_test.go`
- **Verification:** `go build ./...` and `go test ./...` green in the `plugins/golem15/user` module
- **Committed in:** `4efce33` (Task 1 RED commit, fonoteka.go)
**2. [Rule 2 - Missing Critical] `clientRecordToModel` never persisted `ScopeCeiling`**
- **Found during:** Task 2, while wiring the create path's scope-ceiling persistence
- **Issue:** `wristband.ClientRecord.ScopeCeiling` has existed since 08-02, and `clientModelToRecord` already read it back, but the reverse mapping in `clientRecordToModel` was missing — every ceiling passed to `CreateWithCap` would have been silently dropped, defeating the ceiling this plan's command exists to set (T-08-SCOPE-CEILING)
- **Fix:** `clientRecordToModel` now maps `ScopeCeiling` onto the persisted `models.OAuthClient` row
- **Files modified:** `../fonoteka.go/plugins/golem15/fonoteka/classes/auth/oauth_store.go`
- **Verification:** `TestOAuthClientCommandScopeCeilingPersistsAndListsNeverTheSecret`
- **Committed in:** `9e82cac` (Task 2 GREEN commit, fonoteka.go)
**3. [Rule 2 - Missing Critical] `wristband/client_issue.go` added beyond the plan's literal `files_modified` list**
- **Found during:** Task 2, implementing the create path
- **Issue:** The plan's threat model (T-08-SECRET-TIMING) requires the command to share DCR's exact hash/validation path, but no exported wristband function existed for random client-id/secret generation, hashing, or redirect-URI validation
- **Fix:** Added `wristband/client_issue.go` exporting `IssueClientCredentials`/`RejectRedirectURI` as thin wrappers over the existing unexported `randomBase64URL`/`sha256Hex`/`rejectRedirectURI`, adding no new logic
- **Files modified:** `wristband/client_issue.go` (new)
- **Verification:** `go test ./wristband/... -race`; `TestOAuthClientCommandListPrintsClientIDAndURIsNeverTheSecret` et al. exercise the shared path end to end
- **Committed in:** `398353b` (Task 2 GREEN commit, summercms.go)
---
**Total deviations:** 3 auto-fixed (1 blocking compile fix, 2 missing-critical-functionality additions required by the plan's own threat model and objective)
**Impact on plan:** No scope change; all three were necessary for the plan's stated D-19/T-08-SCOPE-CEILING/T-08-SECRET-TIMING behavior to actually work, not separate feature additions.
## Issues Encountered
None beyond the auto-fixed items above. Full `go vet`/`go test ./...` (and `go test -race` on the touched packages) are green in `summercms.go` and in every `fonoteka.go` workspace module: the root `fonoteka`/`parity` module, `plugins/golem15/fonoteka` and its `classes`, `classes/auth`, `console`, `controllers/api`, `middleware`, `models`, `updates` subpackages, and `plugins/golem15/user` and its `classes`/`updates` subpackages.
## User Setup Required
None — no external service configuration required.
## Next Phase Readiness
- `fonoteka:oauth-client` is available for operators to issue confidential clients for chat-app connectors ahead of 08-09's real-MCP gate, and for the D-16 lifecycle corpus's confidential-client-with-ceiling fixture.
- `bonfire.Flag.Repeatable`/`Input.Flags` is the established shape for any future PHP `=*` console option; no further bonfire interface changes are anticipated for the remaining Phase 8 plans' known scope.
- AUTH-05/AUTH-07 remain Pending in REQUIREMENTS.md: this plan ships the D-19 operator command, but both requirements' full text also needs 08-08/08-09's unchanged-fonoteka-mcp-install/auth-flow proof.
- `08-08` (mcp-me prerequisite) and `08-09` (parity-and-real-mcp-gate) can proceed without any further change to this plan's surface.
- No blockers.
## Self-Check: PASSED
- FOUND: bonfire/command.go, bonfire/root.go, bonfire/output_test.go
- FOUND: wristband/client_issue.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/console/oauth_client.go, oauth_client_test.go
- FOUND: ../fonoteka.go/plugins/golem15/fonoteka/plugin.go, classes/auth/oauth_store.go
- FOUND: ../fonoteka.go/plugins/golem15/user/console_test.go
- FOUND commits (summercms.go): 7096a90, 398353b
- FOUND commits (fonoteka.go): 4efce33, 9e82cac
---
*Phase: 08-oauth2-1-authorization-server*
*Completed: 2026-09-23*