docs(14.1): create phase plan

This commit is contained in:
Jakub Zych
2026-10-05 19:36:32 +02:00
parent a160bda55a
commit 3bd461ec68
2 changed files with 576 additions and 0 deletions

View File

@@ -0,0 +1,336 @@
---
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
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\": <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)."
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.
<objective>
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.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<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
<interfaces>
- 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 `"<route id>#<case id>"`.
- 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ę.`
</interfaces>
</context>
## 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` (plan 02 expands coverage).
## 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).
<tasks>
<task type="checkpoint:decision" gate="blocking-human">
<name>Task 1: Confirm the one-way golem15_user_oauth_identities table in sm-user-plugin</name>
<decision>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.</decision>
<context>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.</context>
<options>
<option id="ship-shared-full">
<name>Ship the full PHP column set in sm-user-plugin (locked D-01 and D-07)</name>
<pros>Matches Golem15.User v3.3.0; Phase 15 can import rows into this table; other apps share one contract.</pros>
<cons>The table name, indexes and Encrypted columns become a shared-plugin contract immediately.</cons>
</option>
<option id="app-local">
<name>Create the table only in the fonoteka plugin</name>
<pros>The shared plugin stays unchanged.</pros>
<cons>Contradicts D-01; PHP ownership is Golem15.User; other apps would fork the schema.</cons>
</option>
<option id="hold">
<name>Do not migrate in this phase</name>
<pros>More time to review columns.</pros>
<cons>The tracer cannot list identities; Phase 14.1 cannot flip the three pending routes.</cons>
</option>
</options>
<read_first>.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</read_first>
<acceptance_criteria>
- 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.
</acceptance_criteria>
<resume-signal>Answer ship-shared-full, app-local, or hold.</resume-signal>
</task>
<task type="tracer">
<name>Task 2: End-to-end GET empty list — {"data":[]} through model, migration, Index, and the JWT mount</name>
<reversibility rating="one-way">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.</reversibility>
<precondition>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).</precondition>
<files>../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</files>
<read_first>../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)</read_first>
<action>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.
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/user -count=1 -v -run '^(TestOAuthIdentitiesIndexEmptyList)$' &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only'</automated>
<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 fonoteka run fails the oauth-identity surface subtest.</fails_when>
</verify>
<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>
<done>A signed-in user with no linked identities receives `{"data":[]}` from the JWT GET, proving model, migration, explicit map, and host mount together.</done>
</task>
<task type="auto">
<name>Task 3: Unlink one identity — 204, Winter HTML 404, 409 last-method, throttle:10,1</name>
<files>../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.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</files>
<read_first>../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)</read_first>
<action>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).
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only'</automated>
<fails_when>Non-zero exit; the fonoteka run prints "--- FAIL" or "no tests to run" for the oauth-identity surface subtest.</fails_when>
</verify>
<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.
</acceptance_criteria>
<done>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.</done>
</task>
<task type="auto">
<name>Task 4: Close /me collection_ids null, record D-10 extras, flip three routes to ported, document the exported API</name>
<files>../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</files>
<read_first>../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)</read_first>
<action>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.
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m</automated>
<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>
</verify>
<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>
<done>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.</done>
</task>
</tasks>
<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>
<verification>
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).
</verification>
<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>
<output>
Create `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-SUMMARY.md` when done
</output>

View File

@@ -0,0 +1,240 @@
---
phase: 14.1-oauth-identities-and-fonoteka-me-routes
plan: 02
type: execute
wave: 2
depends_on: ["14.1-01"]
files_modified:
- ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go
- ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go
- ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
- ../fonoteka.go/parity/parity_test.go
autonomous: true
requirements: [API-09, QA-05, HTTP-01, HTTP-03, DATA-02, DATA-07, I18N-01]
estimate:
tokens: 280000
raw_tokens: 280000
tasks: 3
confidence: low
must_haves:
truths:
- "Per D-11, Go tests ported from OAuthIdentityApiTest.php prove unlink 204 keeps the other row, last-method 409 EN and PL texts match the user-plugin lang strings, missing/foreign/unknown-provider Winter HTML 404 bodies are byte-identical, and both identity routes return 401 without a JWT on `/google`."
- "Per HTTP-03 and PHP TokenSurfaceIsolationTest, identity routes exist on the JWT group only; `/api/v1/fonoteka` never lists them; DELETE middleware includes `throttle:10,1`."
- "Per DATA-02, the oauth-identities migration migrates up and rolls back: table, both unique indexes, jsonb `profile_data`, and cascade FK are present after up and absent after down."
- "Per D-09, `TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName` (renamed as needed) requires `scopes` as `[]` and `collection_ids` as JSON null."
- "Per DATA-07 and T-14.1-01, GET list with seeded Encrypted tokens never contains the plaintext, the key names `access_token`/`refresh_token`/`profile_data`, or `[redacted]`."
- "Per API-09, QA-05 and D-12, `TestParityCorpus` prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`."
artifacts:
- path: "../fonoteka.go/plugins/golem15/user/oauth_identities_test.go"
provides: "D-11 API tests and secret-leak assertions"
contains: "TestOAuthIdentities"
- path: "../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go"
provides: "migration up/down"
contains: "golem15_user_oauth_identities"
- path: "../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go"
provides: "D-09 nil collection_ids JSON null"
contains: "collection_ids"
- path: "../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go"
provides: "jwt-only identity surfaces"
contains: "oauth-identities"
key_links:
- from: "../fonoteka.go/plugins/golem15/user/oauth_identities_test.go"
to: "../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go"
via: "httptest calls OAuthIdentitiesIndex/Destroy constructors with bouncer.WithUser"
pattern: "OAuthIdentitiesDestroy"
- from: "../fonoteka.go/parity/parity_test.go"
to: "../fonoteka.go/parity/manifest.yaml"
via: "TestParityCorpus counts 175 recorded and 175 ported"
pattern: "expectedPortedRoutes"
prohibitions:
- requirement_id: HTTP-01
category: safety
statement: "401 tests use path /google so jwt.auth runs; an unauthenticated unknown provider is not used as the 401 probe"
status: resolved
verification: test
- requirement_id: DATA-07
category: privacy
statement: "Secret-leak tests fail if the GET body contains plaintext tokens, Encrypted JSON keys, or the redacted literal"
status: resolved
verification: test
- requirement_id: API-09
category: transparency
statement: "Pending routes MUST NOT count as passing; the corpus gate is 175/175/0"
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.
This plan's slice: the dedicated unit-test plan (CLAUDE.md lean mode). Plan 01 already shipped the production path; this plan makes every D-11 behaviour, migration, `/me` null, threat, and corpus count fail when broken.
<objective>
Port OAuthIdentityApiTest.php, prove EN/PL 409, byte-identical Winter 404s, unknown provider, 401 on `/google`, jwt-only TokenSurfaceIsolation, migration up/down, rewritten MeToken nil-collection, secret-leak threat tests, and TestParityCorpus 175/175/0.
Purpose: lean-mode last plan; QA-05 / API-09 evidence for `/gsd-verify-work 14.1`.
Output: tests in sm-user-plugin, fonoteka plugin, and parity. No summercms.go module API changes.
Repos: fonoteka.go / sm-user-plugin. Never add co-author tags.
</objective>
<execution_context>
@~/.codex/gsd-core/workflows/execute-plan.md
@~/.codex/gsd-core/templates/summary.md
</execution_context>
<context>
@.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-PLAN.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
@.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VALIDATION.md
@../fonoteka.go/plugins/golem15/user/api_tokens_test.go
@../fonoteka.go/plugins/golem15/user/api_tokens_edge_test.go
@../fonoteka.go/plugins/golem15/user/updates/api_tokens_test.go
@../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go
@../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php
@/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php
<interfaces>
- Plan 01 exports `OAuthIdentitiesIndex`, `OAuthIdentitiesDestroy`, `OAuthIdentitiesOptions`, `OAuthIdentity`, migration `202610050001_create_oauth_identities`, JWT mount, D-09 MeToken, extras `oauth-identities-linked` / `me-unrestricted`, `expectedPortedRoutes = 175`.
- Analog tests: `api_tokens_test.go` (httptest + `bouncer.WithUser` + constructor), `api_tokens_edge_test.go` (foreign destroy 404, list isolation), `updates/api_tokens_test.go` (`dedicatedDB`, `gormigrate.New` with `TableName: "summer_migrations_golem15_user"`, `HasTable`/`HasColumn`, `RollbackMigration`).
- MeToken tests: `meTokenRequest` uses `bouncer.WithUser` + `bouncer.WithCredential`; `TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName` currently forbids `"collection_ids":null` (lines 113-117) — D-09 reverses that field only; `scopes` still must not be JSON null. Keep `TestMeTokenHandlerExactFourFieldsWithScopesAndCollectionIDs` and the no-requery test.
- phase08 `assertRouteSurfaces(method, suffix, wantJWT, wantToken)`; DELETE must list `throttle:10,1`.
- Winter 404 bytes: recorded DELETE fixture `text/html; charset=UTF-8`, title `Nie znaleziono strony`. Foreign and missing bodies must `bytes.Equal`.
- 401 without JWT is group `jwt.auth` (`{"error":true,"message":...}` + `Cache-Control: no-cache, private`). Probe path `/google` (research A1).
</interfaces>
</context>
## Artifacts this phase produces
(This plan's share.)
- `TestOAuthIdentitiesIndexEmptyList` (from 01) plus D-11: unlink 204 keeps sibling row, 409 EN/PL, missing/foreign/unknown Winter 404, 401 on `/google`, secret-leak, last-method still 409 when the account has a password.
- `updates/oauth_identities_test.go` migration up/down (skip on `-short` via existing `dedicatedDB`).
- Rewritten MeToken nil-collection test requiring `"collection_ids":null` and `"scopes":[]`.
- phase08 jwt-only GET and DELETE plus DELETE `throttle:10,1`.
- `TestParityCorpus` 175/175/0.
## Assumptions
- Assumption-delta remains closed: D-06/D-13 already answered second-method lockout; tests prove count-only 409, they do not add social login.
- Plan 01 flipped GET (and DELETE) surfaces; this plan asserts them fail-closed and adds throttle contains-check if Task 3 of 01 left a gap.
- Do not retarget `scripts/check-phase14.sh` (`EXPECTED_PENDING=3` is a Phase 14 artifact).
<tasks>
<task type="auto">
<name>Task 1: Port OAuthIdentityApiTest.php — 204, EN/PL 409, Winter 404s, 401 /google, jwt-only surfaces</name>
<files>../fonoteka.go/plugins/golem15/user/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go</files>
<read_first>/media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php, /media/nvme/dev/golem15/fonoteka/plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php, ../fonoteka.go/plugins/golem15/user/api_tokens_test.go, ../fonoteka.go/plugins/golem15/user/api_tokens_edge_test.go, ../fonoteka.go/plugins/golem15/user/session_test.go (sessionApp, insertUser), ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml, ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml, ../fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt.yaml, ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go (assertRouteSurfaces, oauth-identity subtest), ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go</read_first>
<action>Per D-04, D-06, D-11, HTTP-01, HTTP-03, I18N-01.
(1) Expand plugin-root `oauth_identities_test.go` (package `user`) using `sessionApp`, `insertUser`, `httptest`, `bouncer.WithUser`, and the constructors. Seed rows with struct literals (empty Fillable). Destroy tests pass `OAuthIdentitiesOptions{WriteNotFound: ...}` writing the same Winter HTML 404 bytes the host uses (`WriteWinterHTTPError` via a test double that copies `winter_404.html` headers/body, or a stub that records identical bytes for every 404 branch).
Behaviours: unlink 204 empty body and the sibling provider row remains; last remaining identity returns 409 JSON `error` equal to the EN string when locale is en and the PL string when locale is pl (phrasebook Get / request locale analog already used in account tests); missing, foreign, and unknown provider (`linkedin` while authenticated) return status 404, `Content-Type: text/html; charset=UTF-8`, and byte-identical bodies; 401 without a JWT on GET and DELETE `/google` (not an unknown provider). Last-method 409 still fires when that user also has a password (D-06). Keep `TestOAuthIdentitiesIndexEmptyList`.
(2) phase08 `test_oauth_identity_routes_exist_on_jwt_surface_only`: `assertRouteSurfaces` GET `/oauth-identities` jwt-only and DELETE `/oauth-identities/{provider}` jwt-only. Assert DELETE middleware contains `throttle:10,1`. No identity suffix on `/api/v1/fonoteka`.
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/user ./plugins/golem15/fonoteka -count=1 -v -run 'OAuthIdentit|oauth_identity_routes_exist_on_jwt'</automated>
<fails_when>Non-zero exit; verbose run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks PASS for the oauth-identity tests including the phase08 jwt-only subtest.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'StatusNoContent' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1.
- `grep -c 'last_method_blocked' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1.
- `grep -c '/google' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1.
- `grep -c 'assertRouteSurfaces(t, rt, http.MethodGet, "/oauth-identities", true, false)' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go` prints at least 1.
- `grep -c 'assertRouteSurfaces(t, rt, http.MethodDelete, "/oauth-identities/{provider}", true, false)' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go` prints at least 1.
- `grep -c 'assertAbsent(t, "oauth-identit"' ../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go` prints 0.
</acceptance_criteria>
<done>D-11 identity behaviours and jwt-only surfaces fail when broken, including EN/PL 409 and byte-identical Winter 404s.</done>
</task>
<task type="auto">
<name>Task 2: Migration up/down, MeToken null collection_ids, and secret-leak threat tests</name>
<files>../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go, ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go</files>
<read_first>../fonoteka.go/plugins/golem15/user/updates/api_tokens_test.go, ../fonoteka.go/plugins/golem15/user/updates/postgres_test.go (dedicatedDB, -short skip), ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go, ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go (nil-array test ~78-117), modules/lagoon/encrypted.go (MarshalJSON redactedLiteral), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md (DATA-02/DATA-07 validation map, T-14.1-01)</read_first>
<action>Per D-07, D-09, DATA-02, DATA-07.
(1) `updates/oauth_identities_test.go`: `dedicatedDB`, `lagoon.Use`, `gormigrate.New(..., TableName: "summer_migrations_golem15_user", UseTransaction: true, All())`, `Migrate()`, assert `HasTable` `golem15_user_oauth_identities`, columns including `access_token`, `refresh_token`, `profile_data`, `linked_at`, unique index names `oauth_identities_user_provider_unique` and `oauth_identities_provider_identity_unique`, `information_schema.columns` udt `jsonb` for `profile_data`, FK `user_id` → `users(id)` ON DELETE CASCADE. `RollbackMigration` of this migration drops the table. Skip through existing dedicatedDB/`-short` behaviour; a missing container is a fail, not a pass.
(2) Rewrite `TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName`: `scopes` remains a JSON array (not null); `collection_ids` MUST be the JSON null token when CollectionIDs is invalid or empty (D-09). Keep the four-field restricted test and the no-requery test. Rename the test if the old name would lie.
(3) Secret-leak (T-14.1-01 / DATA-07): insert an identity with `lagoon.NewEncrypted` plaintext plus `profile_data` containing a distinctive string; GET Index body must not contain the plaintext, must not contain JSON keys `access_token`, `refresh_token`, or `profile_data`, and must not contain `[redacted]` (Encrypted marshal leak of key names). Same assertion on the D-10 list-with-rows shape (provider + linked_at only).
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/controllers/api/... &amp;&amp; go -C ../fonoteka.go test ./plugins/golem15/user/updates ./plugins/golem15/user ./plugins/golem15/fonoteka/controllers/api -count=1 -v -run 'OAuthIdentit|MeTokenHandlerNil|oauth_identities'</automated>
<fails_when>Non-zero exit; verbose run prints "--- FAIL", "no tests to run" or "--- SKIP" for the named migration, MeToken nil, or secret-leak tests; updates tests skip because Postgres is missing.</fails_when>
</verify>
<acceptance_criteria>
- `grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go` prints at least 1 and `grep -c 'jsonb' ../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go` prints at least 1.
- `grep -c '"collection_ids":null' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go` prints at least 1.
- `grep -c '"scopes":null' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go` prints at least 1 (the rewritten test still treats a null scopes token as failure).
- `grep -c 'access_token' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go` prints at least 1 (the leak assertion names the keys that must be absent from the body).
</acceptance_criteria>
<done>Migration up/down, D-09 `/me` null, and Encrypted-token leak tests fail when their protections are removed.</done>
</task>
<task type="auto">
<name>Task 3: TestParityCorpus is 175 recorded, 175 passing, 0 pending</name>
<files>../fonoteka.go/parity/parity_test.go</files>
<read_first>../fonoteka.go/parity/parity_test.go (expectedPHPRoutes, expectedPortedRoutes, TestParityCorpus, assertPortedMismatch after 14.1-01), ../fonoteka.go/parity/manifest.yaml (three ported identity/me routes), .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-VALIDATION.md</read_first>
<action>Per D-12, API-09, QA-05. Confirm `expectedPHPRoutes` and `expectedPortedRoutes` are both 175 and `assertPortedMismatch` no longer probes an unported identity GET. Run the corpus. If a fixture drifts, re-record through `php_parity.sh` + `summer parity:record` as in plan 01 Task 4 — do not hand-edit PHP response bytes. Do not change `scripts/check-phase14.sh` pending counts (Phase 14 gate stays historical).
</action>
<verify>
<automated>go -C ../fonoteka.go vet ./parity/... ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... &amp;&amp; go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m</automated>
<fails_when>Non-zero exit; summary line is not `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`.</fails_when>
</verify>
<acceptance_criteria>
- `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.
- Corpus output contains `pending 0` and `passing 175`.
</acceptance_criteria>
<done>Zero pending routes: the parity harness is green on the three formerly orphan routes, which is the API-09/QA-05 evidence this phase can give (Nuxt/MCP stay unchanged consumers).</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Test process → handlers | Tests must not mint production JWTs or log `Encrypted.Reveal()` |
| Corpus replay → Go app | Pending must never count as passing |
| Test 404 writer → Destroy | Foreign/missing/unknown must be indistinguishable |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|-----------|----------|-----------|----------|-------------|-----------------|
| T-14.1-09 | Information Disclosure | oauth_identities_test.go GET | high | mitigate | Fail if body contains plaintext, Encrypted JSON keys, or `[redacted]` |
| T-14.1-10 | Information Disclosure | Destroy 404 tests | high | mitigate | Byte-identical Winter HTML for missing, foreign, unknown; JSON 404 fails the test |
| T-14.1-11 | Tampering | TestParityCorpus | high | mitigate | expectedPortedRoutes 175; pending 0; rewritten mismatch helper on a ported fixture |
| T-14.1-12 | Elevation of Privilege | phase08 TokenSurfaceIsolation | high | mitigate | assertRouteSurfaces jwt-only; token group never gains identity paths |
| T-14.1-SC | Tampering | package installs | high | mitigate | No new modules |
ASVS L1: all high threats mitigated. Plan 01's T-14.1-01..08 remain in force; these IDs are the test-plane controls that make those mitigations fail-closed.
</threat_model>
<verification>
Full app-side gate: `go -C ../fonoteka.go vet ./...` and `go -C ../fonoteka.go test ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... -count=1`. Corpus 175/175/0. Framework tree in summercms.go unchanged besides this planning commit.
<human-check>
QA-05 consumers are frozen: sign in to the Nuxt app, open Settings → Connected accounts against the Go backend (list/unlink). Call fonoteka-mcp `me()` against Go; extra `collection_ids` is ignored by its TypeScript type.
</human-check>
</verification>
<success_criteria>
- D-11 PHP test port is green.
- Migration up/down proven on real Postgres.
- MeToken unrestricted `collection_ids` is JSON null under test.
- Secret-leak tests fail when the explicit map is replaced by model marshal.
- `TestParityCorpus` is 175/175/0.
</success_criteria>
<output>
Create `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-SUMMARY.md` when done
</output>