diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md new file mode 100644 index 0000000..8505a22 --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md @@ -0,0 +1,147 @@ +# Phase 14.1: OAuth identities and fonoteka me routes - Context + +**Gathered:** 2026-10-04 +**Status:** Ready for planning + + +## 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). + + + + +## 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. + + + + +## 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 + + + + +## 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`). + + + + +## 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. + + + + +## 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). + + + +--- + +*Phase: 14.1-oauth-identities-and-fonoteka-me-routes* +*Context gathered: 2026-10-04* diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-DISCUSSION-LOG.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-DISCUSSION-LOG.md new file mode 100644 index 0000000..53ad658 --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-DISCUSSION-LOG.md @@ -0,0 +1,87 @@ +# Phase 14.1: OAuth identities and fonoteka me routes - Discussion Log + +> **Audit trail only.** Do not use as input to planning, research, or execution agents. +> Decisions are captured in CONTEXT.md. This log preserves the alternatives considered. + +**Date:** 2026-10-04 +**Phase:** 14.1-oauth-identities-and-fonoteka-me-routes +**Areas discussed:** where the identity table lives, token columns and import, parity case coverage, social-login-only users at cutover + +The scout found two things before the discussion: + +- `GET /api/v1/fonoteka/me` is already served in Go. Its only gap is that `collection_ids` is `[]` where PHP returns `null` for an unrestricted token. This is decided by the PHP contract and was not asked. +- Go has no model or migration for `golem15_user_oauth_identities`. + +--- + +## Where the identity table lives + +| Option | Description | Selected | +|--------|-------------|----------| +| sm-user-plugin | Mirrors PHP, where Golem15.User owns the table; additive change | ✓ | +| fonoteka plugin | Leaves the shared plugin untouched, but the table sits in the wrong plugin | | + +The user asked what the handlers are for. They back Settings → Connected accounts in the Nuxt app, which lists linked Google, Facebook and GitHub logins and lets the user disconnect them. Unlinking the last one returns 409. + +| Option | Description | Selected | +|--------|-------------|----------| +| fonoteka plugin | Same as PHP | | +| user plugin API | Reusable handlers that fonoteka mounts at its prefix | ✓ | + +| Option | Description | Selected | +|--------|-------------|----------| +| User plugin lang, same text | New key in the user plugin with identical EN/PL text | ✓ | +| Host passes the message | The mount supplies the message key | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Mount option, default the three | google/facebook/github by default; the host can narrow or extend | ✓ | +| Hard-coded three | Matches PHP exactly | | + +## Token columns and import + +| Option | Description | Selected | +|--------|-------------|----------| +| Full PHP column set | Encrypted tokens, jsonb profile_data, both unique indexes | ✓ | +| Full columns, tokens nulled at import | Drop the stored provider tokens at import | | +| Only what list/unlink need | Minimal columns | | + +| Option | Description | Selected | +|--------|-------------|----------| +| No, Phase 15 writes the import mapper | HasWinterImport does not exist yet | ✓ | +| Yes, note the transforms now | Docs only | | + +## Parity case coverage + +Identity cases to record (multi-select): list with linked rows ✓, unlink 204 and last-method 409 ✗, foreign vs missing 404 ✗, unknown provider and 401 ✗. + +| Option | Description | Selected | +|--------|-------------|----------| +| Add unrestricted-token case (/me) | `collection_ids: null` case | ✓ | +| Also scope variants | Read-only and revoked tokens | | + +| Option | Description | Selected | +|--------|-------------|----------| +| Go tests ported from the PHP tests | Covers the unselected behaviours in the final unit-test plan | ✓ | +| Keep only what's recorded | | | + +## Social-login-only users at cutover + +| Option | Description | Selected | +|--------|-------------|----------| +| Flag for the Phase 15 preflight | Count OAuth-only users in the dump; give them a password first, or schedule a social login phase | ✓ | +| Accept, nobody uses it | | | +| Pull social login into scope | Big scope jump | | + +## Claude's Discretion + +- The shape of the exported sm-user-plugin API and how fonoteka mounts it +- Where the throttle for `throttle:10,1` is declared +- ISO-8601 formatting that matches Laravel's `toIso8601String()` +- Plan split (unit tests come last; the plan count is confirmed first) +- README and docs updates + +## Deferred Ideas + +- Social login port (its own phase; the Phase 15 preflight decides the urgency) +- Winter-import mapper for the identities table (Phase 15)