Files
summercms/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md
2026-10-04 14:52:32 +02:00

148 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Phase 14.1: OAuth identities and fonoteka me routes - Context
**Gathered:** 2026-10-04
**Status:** Ready for planning
<domain>
## Phase Boundary
The three manifest routes that are still `pending` are ported and pass the parity diff, which leaves zero pending routes before the Phase 15 cutover:
- `GET /_fonoteka/api/v1/oauth-identities`: lists the JWT caller's linked social identities as `{"data":[{"provider","linked_at"}]}`, ordered by provider.
- `DELETE /_fonoteka/api/v1/oauth-identities/{provider}`: unlinks one identity. It returns 204 on success, 404 when the identity is missing or belongs to another user (the two bodies are byte-identical), 409 with a localized `error` when the identity is the caller's last one, and 401 without a JWT. The provider is constrained to `google|facebook|github`, and the route has `throttle:10,1`.
- `GET /api/v1/fonoteka/me` (personal-token group): Phase 8 D-20 already serves it. This phase closes the remaining contract gap and flips the manifest entry.
These routes back the Nuxt **Settings → Connected accounts** tab (`ConnectedAccounts.vue`) and the fonoteka-mcp bootstrap.
Repos: `fonoteka.go`, whose app and fonoteka plugin mount the routes and hold the parity fixtures and manifest, and `sm-user-plugin` (`fonoteka.go/plugins/golem15/user`), which gets the model, the migration and the list/unlink handlers.
Out of scope: social login itself. That means the `/oauth/{provider}` redirect and callback, `oauth-complete`, `oauth-register-complete`, linking a new provider, and the password-bootstrap OTP, all still deferred by Phase 7 D-03. The "link another provider" affordance on the Nuxt tab stays non-functional on Go. The Winter-import mapper for the new table also stays out (Phase 15).
</domain>
<decisions>
## Implementation Decisions
### Where the identity code lives
- **D-01:** The `OAuthIdentity` model and the `golem15_user_oauth_identities` migration live in **sm-user-plugin**. This mirrors PHP, where Golem15.User v3.3.0 owns the table. The change to the shared plugin is additive only. — **Reversibility:** costly — once shipped in a shared plugin, the table name and schema are a contract for every app that uses the plugin and for the Phase 15 import.
- **D-02:** The list and unlink handlers also live in **sm-user-plugin**, as a reusable API. This departs from PHP, where the controller sits in the fonoteka plugin as a "D-15 compromise". The user plugin exposes the handlers, and the fonoteka app or plugin mounts them at `/_fonoteka/api/v1/oauth-identities` on the JWT group, with PHP's middleware: JWT auth on both routes and `throttle:10,1` on DELETE. The user plugin must not register these routes under its own `/_user/api/v1` group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, which `routes.snapshot` checks. — **Reversibility:** costly — it adds exported API surface to a shared plugin that other apps may start to depend on.
- **D-03:** The 409 "last method" message moves to a new key in the **user plugin's lang files**. The EN and PL texts are identical to PHP's `golem15.fonoteka::lang.oauth.last_method_blocked`:
- EN: "This is the only remaining way to sign in. Link another method before disconnecting this one."
- PL: "To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę."
A host can override the text through the normal lang override.
- **D-04:** The DELETE provider whitelist is a **mount option**. It defaults to `google`, `facebook` and `github`, and the host can narrow or extend it. A provider outside the list returns the same 404 that PHP's route constraint produces. The researcher confirms PHP's exact bytes for a constraint miss compared with a missing row.
- **D-05:** The responses are built from an explicit map (`provider`, and `linked_at` as an ISO-8601 string or null), never by serializing the model. The model carries encrypted tokens and profile data. The same rule applies in PHP.
- **D-06:** Unlink is fail-closed, as in PHP. The last remaining identity is refused with 409 even when the account also has a password. Only the identity count decides it, and `has_self_set_password` plays no part.
### Columns and import
- **D-07:** The Go table and model carry the **full PHP column set**: `id`, `user_id` (FK to `users`, cascade delete), `provider` (50), `provider_id` (255), `access_token` and `refresh_token` (both `lagoon.Encrypted`), `token_expires_at`, `profile_data` (jsonb), `linked_at`, `created_at` and `updated_at`. Both unique indexes are kept: `(user_id, provider)` and `(provider, provider_id)`. The PHP backfill from the legacy `users.oauth_*` columns is not ported, because Phase 15 imports the rows directly. — **Reversibility:** one-way — the migration ships in a shared plugin and becomes the import target in Phase 15.
- **D-08:** No Winter-import mapper is written in this phase. Phase 15 (D-02/D-03) adds the `HasWinterImport` mappers for every user-plugin table, including the Laravel decrypt and the GCM re-encrypt of the two token columns.
### `/api/v1/fonoteka/me` contract
- **D-09:** The full contract is `{"data":{"scopes","collection_ids","user_id","name"}}`. The one gap in today's Go handler (`me_token_controller.go`) is `collection_ids`. PHP's `ApiToken::collectionIds()` returns **`null` for an unrestricted token** (no bound collections), but Go always emits `[]` through `wire.Slice`. Go must emit `null` when the token has no collection binding and a list of ints otherwise. `scopes` keeps the PHP fallback `[]`. This is a parity fix, not a shape change.
### Parity coverage
- **D-10:** New recorded PHP cases:
- **List with linked rows:** alice is seeded with `facebook` and `google` identities, which exercises ordering by provider, the `{provider, linked_at}` shape and the `linked_at` format. The seeded rows carry token and profile values, so the fixture shows that no secret leaks.
- **`/me` with an unrestricted token:** `"collection_ids": null`.
The existing cases stay: GET with an empty list, DELETE with a missing row (404), and `/me` with a restricted token. Seeding goes through the parity harness's `seed_hook` mechanism, on both the PHP recording side and the Go replay side.
- **D-11:** The remaining identity behaviours are covered by **Go tests ported from `OAuthIdentityApiTest.php`**: unlink returns 204 and keeps the other row, last-method returns 409 with the EN/PL text asserted against the PHP lang strings, foreign and missing rows give byte-identical 404s, an unknown provider gives 404, and both routes give 401 without a JWT. These tests go in the phase's final unit-test plan, together with full coverage of the new user-plugin and fonoteka code. Tests also check that the routes are on the JWT group and that the personal-token group `/api/v1/fonoteka` never gains them (PHP's `TokenSurfaceIsolationTest`).
- **D-12:** All three manifest entries flip from `pending` to `ported`. Phase 15's preflight then sees zero pending routes.
### Social-login-only accounts at cutover
- **D-13:** No code in this phase. The lockout risk is recorded for the **Phase 15 preflight**. With social login deferred, a user whose only sign-in method is an OAuth identity cannot log in on Go: PHP's random password means `has_self_set_password = false`, and the Nuxt app has no recovery screen. The preflight counts such users in the production dump. If the count is non-zero, the cutover either gives them a password first (for example through the user plugin's `forgot-password` flow, triggered by hand) or social login gets its own phase before the swap.
### Claude's Discretion
- The exported API shape in sm-user-plugin: a handler constructor with options, a small `Mount(router, opts)` helper, or a separate sub-package.
- How the fonoteka side wires the mount, and where the throttle name for `throttle:10,1` is declared.
- The Go timestamp formatting needed to match Laravel's `toIso8601String()` (`+00:00` offset, not `Z`). The researcher confirms the exact format against the recorded fixture.
- The plan split, subject to the CLAUDE.md lean-mode rule: unit tests come in the last plan, and the plan count is confirmed before PLAN.md files are written.
- The README and docs updates required by CLAUDE.md for the sm-user-plugin API and any changes to the framework modules.
### Folded Todos
- **`orphan-pending-routes.md`** ("Give oauth-identities and /api/v1/fonoteka/me a roadmap phase before cutover"): this phase is that home. The todo moves to done when the phase completes.
</decisions>
<canonical_refs>
## Canonical References
**Downstream agents MUST read these before planning or implementing.**
### Phase scope and prior decisions
- `.planning/ROADMAP.md` § Phase 14.1: goal and success criteria
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md` D-09: why these routes were left pending
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` D-03: social login deferral and the `has_self_set_password` caveat
- `.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md` D-20: the minimal `/me` port
- `.planning/phases/15-cutover/15-CONTEXT.md` D-02, D-03, D-11, D-13: import mappers, re-encryption, the zero-pending gate and the preflight
- `.planning/todos/pending/orphan-pending-routes.md`: folded todo
### PHP source of truth (`/media/nvme/dev/golem15/fonoteka`)
- `plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php`: list and unlink behaviour, the explicit-map rule, the 409 rule
- `plugins/golem15/fonoteka/controllers/api/MeTokenController.php`: the `/me` response
- `plugins/golem15/fonoteka/models/ApiToken.php` `collectionIds()`: null when unrestricted
- `plugins/golem15/fonoteka/routes.php` (around lines 310–321): mount group, provider constraint, throttle
- `plugins/golem15/fonoteka/lang/en/lang.php` and `lang/pl/lang.php` `oauth.last_method_blocked`: the 409 text
- `plugins/golem15/user/models/OAuthIdentity.php`: model, encrypted token accessors, profile_data cast
- `plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php`: schema and unique indexes
- `plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php`: behaviours to port into Go tests (D-11)
- `plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php`: identity routes never on the token group
- `plugins/golem15/fonoteka/tests/security/OAuthRevocationTest.php` and `tests/functional/OAuthTokenTest.php`: `/me` with revoked and granted tokens
### Consumers (contract, unchanged)
- `vue-fonoteka-app/app/components/fonoteka/ConnectedAccounts.vue`, `app/composables/useFonoteka.ts` (`fetchOAuthIdentities`), `app/stores/fonoteka.ts` (`unlinkOAuthIdentity`)
- `fonoteka-mcp/src/client.ts` (`MeResponse`, `/api/v1/fonoteka/me`)
### Go side
- `fonoteka.go/parity/manifest.yaml`: the three `pending` entries (around lines 2801, 2819 and 3406)
- `fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_oauth-identities_jwt.yaml`, `DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt.yaml`, `GET__api_v1_fonoteka_me_personal_token.yaml`
- `fonoteka.go/parity/README.md`, `php_parity.sh`, `fixtures/seed/bootstrap.yaml`: recording and seeding
</canonical_refs>
<code_context>
## Existing Code Insights
### Reusable Assets
- `fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go`: the existing `/me` handler. It reads `bouncer.Credential` and `bouncer.User` from context, so only the `collection_ids` null handling changes.
- `lagoon.Encrypted`: already used by `user_discogs_credential.go`, `user_ai_credential.go` and `org_ai_credential.go` for encrypted columns. Use it for `access_token` and `refresh_token`.
- `lagoon.Jsonable[...]`: JSON column pattern (`api_token.go`). It suits `profile_data`.
- `wire.WriteJSON` and `wire.Slice`: response helpers. `wire.Slice` is the reason `[]` replaces null today.
- `plugins/golem15/user/lang/{en,pl}`: the user plugin already has lang files, which receive the D-03 key.
### Established Patterns
- sm-user-plugin registers its own routes under `/_user/api/v1` in `routes.go` with `surf.Use("throttle:user-api")`. The new identity handlers are exported for the host to mount (D-02), not added to that group.
- Migrations in `plugins/golem15/user/updates/` use timestamped Go files (`202610020001_create_user_groups.go`) with a registry and postgres tests.
- Route-group assertions already exist in `fonoteka/routes_group_test.go` and `oauth_tools_test.go`. Extend them for the new JWT routes and the token-surface exclusion.
### Integration Points
- The fonoteka plugin's JWT route group under `/_fonoteka/api/v1`, where the identity handlers are mounted.
- `routes.snapshot` must match PHP's path, method and auth group for both identity routes.
- Parity `seed_hook`s for the identity rows (PHP recording and Go replay).
- `go.work` already includes `./plugins/golem15/user`. Commit sm-user-plugin changes in that submodule (use `ssu`).
</code_context>
<specifics>
## Specific Ideas
- The handlers back Settings → Connected accounts. Disconnect must keep working for real household accounts imported in Phase 15.
- The 409 text and the 404 bytes must be byte-identical to PHP, so the Nuxt dialog behaves the same.
</specifics>
<deferred>
## Deferred Ideas
- **Social login port** (`/oauth/{provider}` redirect and callback, linking a new provider, `oauth-complete`, `oauth-register-complete`, the password-bootstrap OTP): still its own phase. It needs an OAuth-client dependency decision. The Phase 15 preflight count (D-13) decides whether it must land before the swap.
- **Winter-import mapper for `golem15_user_oauth_identities`**: Phase 15 (D-08).
</deferred>
---
*Phase: 14.1-oauth-identities-and-fonoteka-me-routes*
*Context gathered: 2026-10-04*