--- phase: 14.1-oauth-identities-and-fonoteka-me-routes plan: 01 type: execute wave: 1 depends_on: [] files_modified: - ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go - ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go - ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go - ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go - ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml - ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml - ../fonoteka.go/plugins/golem15/user/README.md - ../fonoteka.go/plugins/golem15/fonoteka/routes.go - ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go - ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go - ../fonoteka.go/parity/manifest.yaml - ../fonoteka.go/parity/parity_test.go - ../fonoteka.go/parity/fonoteka_seed_test.go - ../fonoteka.go/parity/fonoteka_reset.php - ../fonoteka.go/parity/fixtures/routes/ autonomous: false requirements: [API-09, HTTP-01, HTTP-03, HTTP-04, HTTP-06, DATA-02, DATA-07, I18N-01] estimate: tokens: 320000 raw_tokens: 320000 tasks: 4 confidence: low # uncalibrated (sample_count 0); locked 2-plan lean split — do not split must_haves: truths: - "Per D-02, a JWT caller can GET `/_fonoteka/api/v1/oauth-identities` and receive `{\"data\":[]}` when they have no rows, and `{\"data\":[{\"provider\",\"linked_at\"},...]}` ordered by provider when they have rows, with `linked_at` as Carbon `+00:00` or JSON null (HTTP-06)." - "Per D-04, D-06 and HTTP-01, DELETE `/_fonoteka/api/v1/oauth-identities/{provider}` answers 204 empty when another identity remains, Winter HTML 404 (same bytes) for an unknown provider, a missing row and a foreign row, and 409 `{\"error\": }` when the caller has exactly one identity, even if the account also has a password." - "Per D-09 and HTTP-06, GET `/api/v1/fonoteka/me` emits `collection_ids` as JSON null when the token has no collection binding and as a list of ints otherwise; `scopes` still falls back to `[]`." - "Per D-10 and D-12, the three manifest entries are `ported`, extras seed alice facebook+google identities (with token and profile values) and a second unrestricted alice token, and `expectedPortedRoutes` is 175." - "Per D-01/D-07 and DATA-02, `golem15_user_oauth_identities` exists via gormigrate in sm-user-plugin with both unique indexes, cascade FK, jsonb `profile_data`, and `lagoon.Encrypted` token columns (DATA-07)." artifacts: - path: "../fonoteka.go/plugins/golem15/user/models/oauth_identity.go" provides: "OAuthIdentity, TableName, Hidden, init Register" contains: "golem15_user_oauth_identities" - path: "../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go" provides: "gormigrate ID 202610050001_create_oauth_identities" contains: "oauth_identities_user_provider_unique" - path: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go" provides: "OAuthIdentitiesOptions, OAuthIdentitiesIndex, OAuthIdentitiesDestroy" contains: "func OAuthIdentitiesIndex(" - path: "../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml" provides: "golem15.user::lang.oauth.last_method_blocked EN" contains: "last_method_blocked:" - path: "../fonoteka.go/plugins/golem15/fonoteka/routes.go" provides: "JWT mount of GET and DELETE oauth-identities" contains: "OAuthIdentitiesIndex" - path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go" provides: "D-09 collection_ids JSON null" contains: "collection_ids" key_links: - from: "../fonoteka.go/plugins/golem15/fonoteka/routes.go" to: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go" via: "host JWT group calls OAuthIdentitiesIndex and OAuthIdentitiesDestroy; WriteNotFound is api.WriteWinterHTTPError" pattern: "OAuthIdentitiesDestroy" - from: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go" to: "../fonoteka.go/plugins/golem15/user/models/oauth_identity.go" via: "Index/Destroy query golem15_user_oauth_identities by caller user_id" pattern: "OAuthIdentity" - from: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go" to: "../fonoteka.go/plugins/golem15/fonoteka/models" via: "MeToken reads bouncer.Credential as *models.ApiToken CollectionIDs" pattern: "CollectionIDs" prohibitions: - requirement_id: HTTP-01 category: safety statement: "The DELETE provider check lives in the Destroy handler allow-list; the JWT group registration leaves {provider} unconstrained so Winter HTML 404 is reachable for unknown, missing and foreign providers" status: resolved verification: test - requirement_id: DATA-07 category: privacy statement: "List and unlink responses are an explicit two-key map (provider, linked_at); Encrypted token columns and profile_data never appear as JSON keys" status: resolved verification: test - requirement_id: HTTP-03 category: safety statement: "Identity constructors are exported for the host; sm-user-plugin routes.go stays the /_user/api/v1 group only, and /api/v1/fonoteka never gains identity paths" status: resolved verification: test - requirement_id: HTTP-01 category: safety statement: "Unlink fail-closed uses identity count for this user_id only; the user password flag is unused" status: resolved verification: test - requirement_id: DATA-02 category: safety statement: "The table is created by gormigrate tx.Exec DDL in sm-user-plugin updates/; GORM schema sync is not the source" status: resolved verification: test --- ## Phase Goal **As a** signed-in Nuxt user (and as a fonoteka-mcp token caller), **I want to** list and unlink connected OAuth identities and receive the full personal-token `/me` body, **so that** Settings → Connected accounts and MCP bootstrap work against Go with zero pending routes. ROADMAP Phase 14.1 goal (source): The three manifest routes no phase owned (14-CONTEXT D-09) are ported and pass the parity diff, so that no route is left pending before cutover. This plan's slice: the production path — model, migration, exported Index/Destroy, JWT mount, `/me` null fix, PHP extras/recording and the manifest flip. Full unit coverage is plan 02. Ship the OAuth-identity table and host-mounted JWT list/unlink handlers in sm-user-plugin, close the D-09 `/me` `collection_ids` gap, and flip the three pending manifest routes to ported with recorded PHP extras. Purpose: Nuxt Connected accounts and fonoteka-mcp `me()` need PHP bytes before Phase 15. Decisions: D-01 through D-12 (D-08 and D-13 are out of this phase). Output: sm-user-plugin model/migration/handlers/lang/README; fonoteka JWT mount and MeToken fix; parity extras, recordings, counts. Repos: fonoteka.go and the sm-user-plugin submodule at `plugins/golem15/user`. Do not change summercms.go framework modules. Commits are path-scoped (plugin submodule, then app); never add co-author tags. Planning docs stay out of code commits. @~/.codex/gsd-core/workflows/execute-plan.md @~/.codex/gsd-core/templates/summary.md @.planning/PROJECT.md @.planning/STATE.md @.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md @.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md @.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md @../fonoteka.go/plugins/golem15/user/models/api_token.go @../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go @../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go @../fonoteka.go/plugins/golem15/fonoteka/routes.go @../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go @../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go @../fonoteka.go/parity/parity_test.go - sm-user-plugin: `plugin.go` already returns `updates.All()` / `models.All()` — new files `init` Register only. `routes.go` lines 9-30 are `/_user/api/v1` and stay that way (D-02). `controllers.writeJSON` (`api_controller.go:1025`) sets `Cache-Control: no-cache, private` then `wire.WriteJSON`. `writeWinterErrorPage` always writes 500 — not a 404 analog. `sessionApp` / `insertUser` live in `session_test.go`. Import path `git.golem15.com/golem15/sm-user-plugin/controllers`. - OAuthIdentitiesOptions (discretion, RESEARCH): `Providers []string` (default google, facebook, github when nil/empty), `WriteNotFound func(http.ResponseWriter, *http.Request)` injected by the host. - Fonoteka JWT group (`routes.go:48`): `surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password")`. Inline throttle string already used: `"throttle:10,1"` on CSV import and collection switch. Personal-token group (`routes.go:262-265`) already serves `GET /me` with `inv.scope:read`. - Winter 404: `api.WriteWinterHTTPError(w, app, http.StatusNotFound)` (`http_errors.go:44-72`), `text/html; charset=UTF-8`, Polish title from `winter_404.html`. - `wire.Time` layout `2006-01-02T15:04:05` + `+00:00`. `wire.Slice` stays on GET `data` and on `/me` `scopes` only. - Parity: `expectedPHPRoutes = 175`, `expectedPortedRoutes = 172`; `assertPortedMismatch` currently probes `GET /_fonoteka/api/v1/oauth-identities jwt` and fatals `unported PHP route must not pass`. Replacement analog: `assertPortedMutationCaught` + `mutateAlbumCount`. Manifest pending blocks at `manifest.yaml` ~2801 and ~3406. `fonotekaCaseExtras` keys are `"#"`. - PHP 409 EN/PL (byte-identical): 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ę.` ## Artifacts this phase produces - `models.OAuthIdentity` (`TableName` `golem15_user_oauth_identities`; columns D-07; `lagoon.Encrypted` `access_token`/`refresh_token` with `json:"-"`; `lagoon.Jsonable[map[string]any]` `profile_data`; `Hidden` those three secrets). - gormigrate `202610050001_create_oauth_identities` (unique `oauth_identities_user_provider_unique` on `(user_id, provider)`, unique `oauth_identities_provider_identity_unique` on `(provider, provider_id)`, FK `user_id` → `users(id)` ON DELETE CASCADE, `profile_data jsonb`). - `controllers.OAuthIdentitiesOptions`, `OAuthIdentitiesIndex(*backpack.App) http.HandlerFunc`, `OAuthIdentitiesDestroy(*backpack.App, OAuthIdentitiesOptions) http.HandlerFunc`. - Phrase `golem15.user::lang.oauth.last_method_blocked` in `lang/en/lang.yaml` and `lang/pl/lang.yaml`. - Host JWT mount: GET `/oauth-identities`, DELETE `/oauth-identities/{provider}` with `"throttle:10,1"` and `WriteNotFound` → `api.WriteWinterHTTPError`. - MeToken D-09: `collection_ids` JSON null when `!Valid` or empty. - Parity extras `oauth-identities-linked` and `me-unrestricted`; new recorded cases; three routes `ported`; `expectedPortedRoutes = 175`; rewritten `assertPortedMismatch`. - sm-user-plugin README: table row, exported-constructor subsection (host-chosen path; no consuming-application name), models list `OAuthIdentity`. - Smoke: `TestOAuthIdentitiesIndexEmptyList` and `TestOAuthIdentitiesDestroyNoContentKeepsSibling` (plan 02 expands the full D-11 matrix). ## Assumptions - Assumption-delta: adding identities beside password is a second sign-in method, but D-06 already fail-closes unlink on identity count and D-13 already deferred social-login-only lockout to the Phase 15 preflight. This plan does not reopen those. - D-08 Winter-import mapper and social-login redirect/callback stay deferred. - Unauthenticated unknown provider is 401 in Go (`jwt.auth` first); D-11 401 tests use `/google` (research A1). Not a recorded parity case. - `profile_data` is SQL `jsonb` per D-07 even though `Jsonable.GormDataType` is `text` (research A2). - No new Go modules. Discretion: constructors + host mount, not a `Mount` helper (that helper would import fonoteka's `api` package into the user plugin). - Token estimates are uncalibrated (sample_count 0, confidence low) under the locked 2-plan lean split; do not split this phase. Task 1: Confirm the one-way golem15_user_oauth_identities table in sm-user-plugin Create table `golem15_user_oauth_identities` in the shared sm-user-plugin with the full PHP column set (D-01, D-07). This migration is the schema source for every app that uses the plugin and is the Phase 15 import target. One-way door: once this gormigrate ships in the shared plugin, renaming the table, dropping Encrypted token columns, or moving the table into the application plugin requires a data migration for every consumer and a rewritten Phase 15 import. CONTEXT.md already chose sm-user-plugin plus the full PHP columns (id, user_id cascade FK, provider 50, provider_id 255, Encrypted access_token and refresh_token, token_expires_at, jsonb profile_data, linked_at, timestamps, both unique indexes). The PHP users.oauth_* backfill is not ported. Undo cost after ship: a new plugin migration and a Phase 15 mapper rewrite. .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-CONTEXT.md (D-01, D-07), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md (Pattern 6, Runtime State Inventory), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php - The user answered with one of the option ids; the answer is recorded in the plan summary. - Work on Task 2 starts only after `ship-shared-full`. With `app-local` or `hold`, this plan stops and the contradiction with D-01/D-07 is recorded. Answer ship-shared-full, app-local, or hold. Task 2: End-to-end GET empty list — {"data":[]} through model, migration, Index, and the JWT mount The gormigrate in sm-user-plugin becomes the shared-plugin schema and the Phase 15 import target; changing the table later needs another migration for every consumer. Task 1 answered ship-shared-full. Docker can start the user-plugin testcontainers Postgres used by sessionApp (TestMain fails closed when the container cannot start; -short is not a pass for this tracer). ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go, ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go, ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go ../fonoteka.go/plugins/golem15/user/models/api_token.go (struct, TableName, init Register, Hidden), ../fonoteka.go/plugins/golem15/fonoteka/models/user_discogs_credential.go (Encrypted json:"-"), ../fonoteka.go/plugins/golem15/user/updates/202610040001_create_api_tokens.go, ../fonoteka.go/plugins/golem15/user/updates/202610040003_create_frontend_permissions.go (named unique indexes via execStmts), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (APITokenIndex owner-scoped Find, explicit map, writeJSON), ../fonoteka.go/plugins/golem15/user/session_test.go (sessionApp, insertUser), ../fonoteka.go/plugins/golem15/user/plugin.go (Migrations/Models already All()), ../fonoteka.go/plugins/golem15/user/routes.go (leave unchanged), ../fonoteka.go/plugins/golem15/fonoteka/routes.go (JWT group line 48), ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go (assertAbsent oauth-identit ~663-666, assertRouteSurfaces ~1017), /media/nvme/dev/golem15/fonoteka/plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php, .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md (OAuthIdentity and Index analogs) Per D-01, D-02, D-05, D-07, DATA-02, DATA-07, HTTP-03, HTTP-06. One production path: JWT GET empty list. (1) models/oauth_identity.go: type `OAuthIdentity` with `id`, `user_id`, `provider`, `provider_id`, `access_token` and `refresh_token` as `lagoon.Encrypted` tagged `json:"-"`, `token_expires_at *time.Time`, `profile_data lagoon.Jsonable[map[string]any]`, `linked_at *time.Time`, timestamps. `TableName()` returns `golem15_user_oauth_identities`. `Hidden()` lists `access_token`, `refresh_token`, `profile_data`. `Fillable()` returns an empty slice (PHP `$guarded = ['*']`; tests assign struct fields). `init() { Register(OAuthIdentity{}) }`. Do not edit plugin.go. (2) updates/202610050001_create_oauth_identities.go: gormigrate ID `202610050001_create_oauth_identities`, `init() { Register(...) }`. Migrate via `tx.Exec` / `execStmts`: CREATE TABLE with SERIAL PK, `user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE`, `provider VARCHAR(50) NOT NULL`, `provider_id VARCHAR(255) NOT NULL`, token columns TEXT NULL, `token_expires_at TIMESTAMPTZ NULL`, `profile_data jsonb NULL`, `linked_at TIMESTAMPTZ NULL`, timestamps TIMESTAMPTZ NOT NULL DEFAULT NOW(), unique index `oauth_identities_user_provider_unique` on `(user_id, provider)`, unique index `oauth_identities_provider_identity_unique` on `(provider, provider_id)`. Rollback DROP TABLE IF EXISTS. No users.oauth_* backfill (D-07). (3) controllers/oauth_identities.go: type `OAuthIdentitiesOptions` with `Providers []string` and `WriteNotFound func(http.ResponseWriter, *http.Request)`. `OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc` reads `bouncer.User`, loads rows `Where("user_id = ?", user.ID).Order("provider")`, builds `[]map[string]any` each with keys `provider` (string) and `linked_at` (`*wire.Time` UTC or nil), writes `writeJSON` 200 `map[string]any{"data": wire.Slice(rows)}`. Principal missing is fail-closed 401 via the existing helper, matching APITokenIndex. Index does not marshal the GORM struct. (4) Host mount, D-02: in the JWT group in fonoteka `routes.go`, import `git.golem15.com/golem15/sm-user-plugin/controllers` as `userctrl` and `g.Get("/oauth-identities", userctrl.OAuthIdentitiesIndex(p.app))`. Leave `plugins/golem15/user/routes.go` byte-identical. Leave the personal-token group without this path. (5) phase08_coverage_test.go: in `test_oauth_identity_routes_exist_on_jwt_surface_only`, replace the deferred-absent probe with `assertRouteSurfaces(t, rt, http.MethodGet, "/oauth-identities", true, false)` so the assembled router accepts the GET mount (Pitfall 6). DELETE surfaces wait for Task 3. (6) Smoke `TestOAuthIdentitiesIndexEmptyList` in plugin-root `oauth_identities_test.go` (package `user`): `sessionApp`, `insertUser`, `bouncer.WithUser`, `controllers.OAuthIdentitiesIndex(app).ServeHTTP` on GET `/_fonoteka/api/v1/oauth-identities`, status 200, body `{"data":[]}` (no other top-level keys), `Cache-Control` contains `no-cache`. Full D-11 matrix is plan 02. go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user -count=1 -v -run '^(TestOAuthIdentitiesIndexEmptyList)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only' Any command exits non-zero; the verbose user-plugin run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthIdentitiesIndexEmptyList"; the fonoteka run fails the oauth-identity surface subtest. - `grep -c 'func (OAuthIdentity) TableName()' ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go` prints at least 1 and `grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go` prints at least 1. - `grep -c '202610050001_create_oauth_identities' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go` prints at least 1 and `grep -c 'oauth_identities_user_provider_unique' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go` prints at least 1. - `grep -c 'AutoMigrate' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go` prints 0. - `grep -c 'func OAuthIdentitiesIndex(' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints at least 1. - `grep -c 'OAuthIdentitiesIndex' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints at least 1. - `git -C ../fonoteka.go/plugins/golem15/user diff -- routes.go` prints nothing. - `grep -c 'TestOAuthIdentitiesIndexEmptyList' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1. A signed-in user with no linked identities receives `{"data":[]}` from the JWT GET, proving model, migration, explicit map, and host mount together. Task 3: Unlink one identity — 204, Winter HTML 404, 409 last-method, throttle:10,1 ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/routes.go, ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go (Task 2 Index), ../fonoteka.go/plugins/golem15/user/controllers/api_tokens.go (owner-scoped Destroy First), ../fonoteka.go/plugins/golem15/user/controllers/api_controller.go (writeJSON, appTranslator, accountMessage phrasebook Get), ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go (WriteWinterHTTPError), ../fonoteka.go/plugins/golem15/fonoteka/routes.go (JWT group, throttle:10,1 on CSV/switch, request_id Where loosening ~238-254), ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go (assertRouteSurfaces), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php (destroy), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md (Patterns 3-4, Pitfalls 1-2) Per D-02, D-03, D-04, D-06, HTTP-01, HTTP-04, I18N-01. (1) Lang: sibling `oauth:` group (not under `account:`) in both locale files. EN last_method_blocked text is exactly `This is the only remaining way to sign in. Link another method before disconnecting this one.` PL text is exactly `To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę.` Handler key `golem15.user::lang.oauth.last_method_blocked`. (2) `OAuthIdentitiesDestroy(app, opts)`: provider is `r.PathValue("provider")`. If `opts.Providers` is nil or empty, the allow-list is google, facebook, github (D-04; host may pass a narrower or wider slice). A provider outside the list, a missing row for `(user_id, provider)`, and a foreign row all call `opts.WriteNotFound` (same function, so bodies match). If WriteNotFound is nil, write the existing opaque 500 helper rather than Go's default 404 page. Count identities for this `user_id` only; when count is 1, `writeJSON` 409 `{"error": tr.Get(ctx, "golem15.user::lang.oauth.last_method_blocked", nil)}`. Otherwise delete and `w.WriteHeader(http.StatusNoContent)` with empty body (PHP `noContent()`). Do not copy APITokenDestroy's 200 JSON. Password flags on the user row are unused (D-06). (3) JWT group: `g.Delete("/oauth-identities/{provider}", userctrl.OAuthIdentitiesDestroy(p.app, userctrl.OAuthIdentitiesOptions{WriteNotFound: func(w http.ResponseWriter, r *http.Request) { api.WriteWinterHTTPError(w, p.app, http.StatusNotFound) }}), "throttle:10,1")`. Leave the path value unconstrained so the handler 404 is reachable (Pitfall 1; oauth request_id precedent). Personal-token group unchanged. (4) phase08: `assertRouteSurfaces(t, rt, http.MethodDelete, "/oauth-identities/{provider}", true, false)` in the same oauth-identity subtest. Assert the DELETE route's middleware list contains `throttle:10,1` (CSV import is the analog). (5) Smoke `TestOAuthIdentitiesDestroyNoContentKeepsSibling` in plugin-root `oauth_identities_test.go` (package `user`): `sessionApp`, `insertUser`, insert two `OAuthIdentity` rows for that user (`facebook` and `google`), `bouncer.WithUser`, `OAuthIdentitiesDestroy(app, OAuthIdentitiesOptions{WriteNotFound: stub that fatals if called})` on DELETE `/_fonoteka/api/v1/oauth-identities/facebook`. Assert `http.StatusNoContent` (204), empty body, and the google row still exists for that `user_id`. Status 200 with a JSON body fails this test. Full D-11 matrix (Winter HTML 404, 409 last-method, 401) stays in plan 02. go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user -count=1 -v -run '^(TestOAuthIdentitiesDestroyNoContentKeepsSibling)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only' Non-zero exit; the verbose user-plugin run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthIdentitiesDestroyNoContentKeepsSibling"; status 200 JSON would fail that test; the fonoteka run fails the oauth-identity surface subtest. - `grep -c 'func OAuthIdentitiesDestroy(' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints at least 1. - `grep -c 'HasSelfSetPassword' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go` prints 0. - `grep -c 'last_method_blocked:' ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml` prints at least 1 and `grep -c 'last_method_blocked:' ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml` prints at least 1. - `grep -F 'throttle:10,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -c 'oauth-identities/{provider}'` prints at least 1. - `grep -c 'Where("provider"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints 0. - `grep -c 'OAuthIdentitiesDestroy' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints at least 1. - `grep -c 'WriteWinterHTTPError' ../fonoteka.go/plugins/golem15/fonoteka/routes.go` prints at least 1. - `git -C ../fonoteka.go/plugins/golem15/user diff -- routes.go` prints nothing. - `grep -c 'TestOAuthIdentitiesDestroyNoContentKeepsSibling' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1. - `grep -c 'StatusNoContent' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1. A JWT caller can unlink a non-last identity (204), is blocked on the last one (409 localized error), and sees Winter HTML 404 for unknown/missing/foreign providers, with DELETE rate-limited 10/1. Task 4: Close /me collection_ids null, record D-10 extras, flip three routes to ported, document the exported API ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go, ../fonoteka.go/parity/manifest.yaml, ../fonoteka.go/parity/parity_test.go, ../fonoteka.go/parity/fonoteka_seed_test.go, ../fonoteka.go/parity/fonoteka_reset.php, ../fonoteka.go/parity/fixtures/routes/, ../fonoteka.go/plugins/golem15/user/README.md ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go (lines 18-46; D-09 supersedes the never-null comment for collection_ids only), ../fonoteka.go/parity/parity_test.go (expectedPortedRoutes, assertPortedMismatch ~456-501, assertPortedMutationCaught ~503-529), ../fonoteka.go/parity/fonoteka_seed_test.go (fonotekaCaseExtras), ../fonoteka.go/parity/fonoteka_reset.php ($extras ~119-145), ../fonoteka.go/parity/manifest.yaml (pending blocks ~2801-2836 and ~3406), ../fonoteka.go/parity/README.md (php_parity.sh reset/serve, summer parity:record --manifest), ../fonoteka.go/plugins/golem15/user/README.md (Overview table list ~15, API reference ~81-104, migrations ~128, models ~149), /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/models/ApiToken.php (collectionIds), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md (Pattern 5, Pitfalls 3/5/8/9) Per D-09, D-10, D-12, HTTP-06, API-09. I18N-01 keys already landed in Task 3; this task documents them. (1) MeToken: keep `scopes` as `wire.Slice`. For `collection_ids`, when the matched `*models.ApiToken` has invalid or empty `CollectionIDs` emit JSON null (D-09); otherwise emit the int list. Keep reading `bouncer.User` and `bouncer.Credential` from context. Rewrite the comment that currently says both arrays never serialize as null so it names `scopes` only. (2) Seed extras on both sides with the same names. Extra `oauth-identities-linked`: alice rows for `facebook` and `google` (order-by-provider coverage) with non-empty Encrypted tokens and profile_data so the GET fixture proves secrets stay off the wire. Extra `me-unrestricted`: a second alice personal token whose `collection_ids` is SQL NULL/empty; do not change the existing `mcp-read` restricted token. Map extras in `fonotekaCaseExtras` as `"<route id>#<case id>"` and in `fonoteka_reset.php` `$extras`. Leave empty GET, missing DELETE, and restricted `/me` on the plain reset (no extra). (3) Record PHP for the two new cases via isolated `parity/php_parity.sh reset` + `serve` and `summer parity:record --manifest parity/manifest.yaml --fixtures parity/fixtures --target http://127.0.0.1:8423 --vars <0600 vars>` (README recording flow). New GET list-with-rows case on `GET /_fonoteka/api/v1/oauth-identities jwt`; new unrestricted `/me` case on `GET /api/v1/fonoteka/me personal_token` whose body includes `"collection_ids": null`. Do not hand-author response bytes. Existing fixtures stay. (4) Flip all three route entries from `status: pending` to `ported`. Set `expectedPortedRoutes = 175` (keep `expectedPHPRoutes = 175`). Rewrite `assertPortedMismatch` to wrap a ported fixture with a status-mutating handler and require `tide.MismatchError`, following `assertPortedMutationCaught` / `mutateAlbumCount`. After D-12 there is no pending-route probe. (5) sm-user-plugin README in the same change (CLAUDE.md): add `golem15_user_oauth_identities` to the Overview table list; add an exported host-mounted subsection for `OAuthIdentitiesIndex`, `OAuthIdentitiesDestroy`, `OAuthIdentitiesOptions` (the host chooses the path; say "the host application", never a consuming-application name); add the migration row and `OAuthIdentity` to the models list. Do not add identity paths to the `/_user/api/v1` handler table. go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... && go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m Non-zero exit; corpus summary is not `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`, or it prints `pending 3` / `passing 172`. - `grep -c 'wire.Slice(collectionIDs)' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go` prints 0. - `grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1 and `grep -c 'OAuthIdentitiesIndex' ../fonoteka.go/plugins/golem15/user/README.md` prints at least 1. - `grep -nE 'expectedPortedRoutes = 175' ../fonoteka.go/parity/parity_test.go` prints a matching line (non-empty). - `grep -c 'unported PHP route must not pass' ../fonoteka.go/parity/parity_test.go` prints 0. - `grep -c 'oauth-identities-linked' ../fonoteka.go/parity/fonoteka_seed_test.go` prints at least 1 and `grep -c 'oauth-identities-linked' ../fonoteka.go/parity/fonoteka_reset.php` prints at least 1. - `grep -c 'me-unrestricted' ../fonoteka.go/parity/fonoteka_seed_test.go` prints at least 1 and `grep -c 'me-unrestricted' ../fonoteka.go/parity/fonoteka_reset.php` prints at least 1. - Three manifest route ids (`GET /_fonoteka/api/v1/oauth-identities jwt`, `DELETE /_fonoteka/api/v1/oauth-identities/{provider} jwt`, `GET /api/v1/fonoteka/me personal_token`) have `status: ported` and none of those blocks still say `status: pending`. Unrestricted `/me` emits JSON null collection_ids, D-10 PHP extras are recorded, all three routes are ported at 175/175/0, and the shared plugin README names the exported constructors. ## Trust Boundaries | Boundary | Description | |----------|-------------| | JWT client → `/_fonoteka/api/v1/oauth-identities` | Untrusted Bearer and `{provider}` path; responses must not leak Encrypted tokens or profile_data | | Personal-token client → `/api/v1/fonoteka/me` | Already-matched `inv_` credential; collection binding is authorization data | | Host plugin → sm-user-plugin constructors | Host injects Winter 404 writer; user plugin must not import the application `api` package | | Postgres `golem15_user_oauth_identities` | At-rest Encrypted tokens; cascade delete with users | ## STRIDE Threat Register | Threat ID | Category | Component | Severity | Disposition | Mitigation Plan | |-----------|----------|-----------|----------|-------------|-----------------| | T-14.1-01 | Information Disclosure | OAuthIdentitiesIndex | high | mitigate | Explicit `{provider, linked_at}` map; Encrypted columns `json:"-"` and Hidden; D-10 fixture seeds secrets that must not appear (plan 02 asserts) | | T-14.1-02 | Elevation of Privilege | OAuthIdentitiesDestroy | high | mitigate | Lookup `user_id = caller` AND provider; missing and foreign share Winter 404 (not 403) | | T-14.1-03 | Elevation of Privilege | OAuthIdentitiesDestroy last-method | high | mitigate | Count identities for this user_id; refuse with 409 when count is 1; password flags unused (D-06) | | T-14.1-04 | Tampering | OAuthIdentity Fillable | medium | mitigate | Empty Fillable; no `lagoon.Fill` on this model; tests assign fields explicitly | | T-14.1-05 | Information Disclosure | Destroy provider allow-list | medium | mitigate | Unknown provider uses the same WriteNotFound as a missing row | | T-14.1-06 | Elevation of Privilege | fonoteka routes.go | high | mitigate | Mount on JWT group only; personal-token group unchanged; user plugin `/_user` group unchanged | | T-14.1-07 | Denial of Service | DELETE registration | medium | mitigate | `"throttle:10,1"` on DELETE only | | T-14.1-08 | Tampering | Encrypted columns | medium | mitigate | Seed and tests use `lagoon.NewEncrypted` under the app key; Laravel ciphertext is not imported (D-08, Phase 15) | | T-14.1-SC | Tampering | package installs | high | mitigate | No new modules; package-legitimacy gate N/A | ASVS L1: all high threats mitigated; medium threats sit on the JWT/token trust boundary and are mitigated. After Task 4: `go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/...` and `TestParityCorpus` prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`. Framework `go vet ./...` in summercms.go stays green (no module edits). - GET empty list is `{"data":[]}`. - DELETE is mounted with `throttle:10,1` and unconstrained `{provider}`. - `/me` unrestricted `collection_ids` is JSON null. - Three manifest routes are ported; `expectedPortedRoutes` is 175. - sm-user-plugin README documents the exported constructors without naming a consuming application. Create `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-SUMMARY.md` when done