Files
summercms/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-PLAN.md
Jakub Zych 5c65a94db0 docs(14.1): revise plans from checker
Slash-form -run is the only way go test executes the nested phase08 jwt-surface subtest, so verify can emit its PASS line.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-05 19:45:21 +02:00

37 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
phase plan type wave depends_on files_modified autonomous requirements estimate must_haves
14.1-oauth-identities-and-fonoteka-me-routes 01 execute 1
../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/
false
API-09
HTTP-01
HTTP-03
HTTP-04
HTTP-06
DATA-02
DATA-07
I18N-01
tokens raw_tokens tasks confidence
320000 320000 4 low
truths artifacts key_links prohibitions
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": <golem15.user::lang.oauth.last_method_blocked>}` 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).
path provides contains
../fonoteka.go/plugins/golem15/user/models/oauth_identity.go OAuthIdentity, TableName, Hidden, init Register golem15_user_oauth_identities
path provides contains
../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go gormigrate ID 202610050001_create_oauth_identities oauth_identities_user_provider_unique
path provides contains
../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go OAuthIdentitiesOptions, OAuthIdentitiesIndex, OAuthIdentitiesDestroy func OAuthIdentitiesIndex(
path provides contains
../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml golem15.user::lang.oauth.last_method_blocked EN last_method_blocked:
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/routes.go JWT mount of GET and DELETE oauth-identities OAuthIdentitiesIndex
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go D-09 collection_ids JSON null collection_ids
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/routes.go ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go host JWT group calls OAuthIdentitiesIndex and OAuthIdentitiesDestroy; WriteNotFound is api.WriteWinterHTTPError OAuthIdentitiesDestroy
from to via pattern
../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go Index/Destroy query golem15_user_oauth_identities by caller user_id OAuthIdentity
from to via pattern
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go ../fonoteka.go/plugins/golem15/fonoteka/models MeToken reads bouncer.Credential as *models.ApiToken CollectionIDs CollectionIDs
requirement_id category statement status verification
HTTP-01 safety 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 resolved test
requirement_id category statement status verification
DATA-07 privacy List and unlink responses are an explicit two-key map (provider, linked_at); Encrypted token columns and profile_data never appear as JSON keys resolved test
requirement_id category statement status verification
HTTP-03 safety 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 resolved test
requirement_id category statement status verification
HTTP-01 safety Unlink fail-closed uses identity count for this user_id only; the user password flag is unused resolved test
requirement_id category statement status verification
DATA-02 safety The table is created by gormigrate tx.Exec DDL in sm-user-plugin updates/; GORM schema sync is not the source resolved 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.

<execution_context> @/.codex/gsd-core/workflows/execute-plan.md @/.codex/gsd-core/templates/summary.md </execution_context>

@.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. Ship the full PHP column set in sm-user-plugin (locked D-01 and D-07) Matches Golem15.User v3.3.0; Phase 15 can import rows into this table; other apps share one contract. The table name, indexes and Encrypted columns become a shared-plugin contract immediately. Create the table only in the fonoteka plugin The shared plugin stays unchanged. Contradicts D-01; PHP ownership is Golem15.User; other apps would fork the schema. Do not migrate in this phase More time to review columns. The tracer cannot list identities; Phase 14.1 cannot flip the three pending routes. .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 -v -run 'TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only' <fails_when>Any command exits non-zero; the verbose user-plugin run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthIdentitiesIndexEmptyList"; the verbose fonoteka run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only".</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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 -v -run 'TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only' <fails_when>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 verbose fonoteka run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only".</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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 "&lt;route id&gt;#&lt;case id&gt;" 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 &lt;0600 vars&gt; (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 <fails_when>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.</fails_when> <acceptance_criteria> - 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. </acceptance_criteria> 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.

<threat_model>

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. </threat_model>

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).

<success_criteria>

  • 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. </success_criteria>
Create `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-SUMMARY.md` when done