14 KiB
Phase 14.1: OAuth identities and fonoteka me routes - Context
Gathered: 2026-10-04 Status: Ready for planning
## Phase BoundaryThe 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 localizederrorwhen the identity is the caller's last one, and 401 without a JWT. The provider is constrained togoogle|facebook|github, and the route hasthrottle: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).
Where the identity code lives
-
D-01: The
OAuthIdentitymodel and thegolem15_user_oauth_identitiesmigration 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-identitieson the JWT group, with PHP's middleware: JWT auth on both routes andthrottle:10,1on DELETE. The user plugin must not register these routes under its own/_user/api/v1group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, whichroutes.snapshotchecks. — 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,facebookandgithub, 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, andlinked_atas 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_passwordplays no part.
Columns and import
- D-07: The Go table and model carry the full PHP column set:
id,user_id(FK tousers, cascade delete),provider(50),provider_id(255),access_tokenandrefresh_token(bothlagoon.Encrypted),token_expires_at,profile_data(jsonb),linked_at,created_atandupdated_at. Both unique indexes are kept:(user_id, provider)and(provider, provider_id). The PHP backfill from the legacyusers.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
HasWinterImportmappers 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) iscollection_ids. PHP'sApiToken::collectionIds()returnsnullfor an unrestricted token (no bound collections), but Go always emits[]throughwire.Slice. Go must emitnullwhen the token has no collection binding and a list of ints otherwise.scopeskeeps 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
facebookandgoogleidentities, which exercises ordering by provider, the{provider, linked_at}shape and thelinked_atformat. The seeded rows carry token and profile values, so the fixture shows that no secret leaks. /mewith an unrestricted token:"collection_ids": null.
The existing cases stay: GET with an empty list, DELETE with a missing row (404), and
/mewith a restricted token. Seeding goes through the parity harness'sseed_hookmechanism, on both the PHP recording side and the Go replay side. - List with linked rows: alice is seeded with
-
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/fonotekanever gains them (PHP'sTokenSurfaceIsolationTest). -
D-12: All three manifest entries flip from
pendingtoported. 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'sforgot-passwordflow, 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,1is declared. - The Go timestamp formatting needed to match Laravel's
toIso8601String()(+00:00offset, notZ). 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.mdD-09: why these routes were left pending.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.mdD-03: social login deferral and thehas_self_set_passwordcaveat.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.mdD-20: the minimal/meport.planning/phases/15-cutover/15-CONTEXT.mdD-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 ruleplugins/golem15/fonoteka/controllers/api/MeTokenController.php: the/meresponseplugins/golem15/fonoteka/models/ApiToken.phpcollectionIds(): null when unrestrictedplugins/golem15/fonoteka/routes.php(around lines 310–321): mount group, provider constraint, throttleplugins/golem15/fonoteka/lang/en/lang.phpandlang/pl/lang.phpoauth.last_method_blocked: the 409 textplugins/golem15/user/models/OAuthIdentity.php: model, encrypted token accessors, profile_data castplugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php: schema and unique indexesplugins/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 groupplugins/golem15/fonoteka/tests/security/OAuthRevocationTest.phpandtests/functional/OAuthTokenTest.php:/mewith 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 threependingentries (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.yamlfonoteka.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/mehandler. It readsbouncer.Credentialandbouncer.Userfrom context, so only thecollection_idsnull handling changes.lagoon.Encrypted: already used byuser_discogs_credential.go,user_ai_credential.goandorg_ai_credential.gofor encrypted columns. Use it foraccess_tokenandrefresh_token.lagoon.Jsonable[...]: JSON column pattern (api_token.go). It suitsprofile_data.wire.WriteJSONandwire.Slice: response helpers.wire.Sliceis 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/v1inroutes.gowithsurf.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.goandoauth_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.snapshotmust 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.workalready includes./plugins/golem15/user. Commit sm-user-plugin changes in that submodule (usessu).
</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.
- 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