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

14 KiB
Raw Blame History

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_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_hooks 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>

## 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