--- 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 # uncalibrated (sample_count 0); locked 2-plan lean split — do not split 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. 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. @~/.codex/gsd-core/workflows/execute-plan.md @~/.codex/gsd-core/templates/summary.md @.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 - 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). ## 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 and shipped `TestOAuthIdentitiesDestroyNoContentKeepsSibling` (204 + sibling remains); this plan keeps the full D-11 matrix and adds throttle contains-check if Task 3 of 01 left a gap. - Token estimates are uncalibrated (sample_count 0, confidence low) under the locked 2-plan lean split; do not split this phase. - Do not retarget `scripts/check-phase14.sh` (`EXPECTED_PENDING=3` is a Phase 14 artifact). Task 1: Port OAuthIdentityApiTest.php — 204, EN/PL 409, Winter 404s, 401 /google, jwt-only surfaces ../fonoteka.go/plugins/golem15/user/oauth_identities_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, ../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 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`. go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user ./plugins/golem15/fonoteka -count=1 -v -run 'OAuthIdentit|TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only' Non-zero exit; verbose run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthTokenSurfaceIsolationCoverage/test_oauth_identity_routes_exist_on_jwt_surface_only". - `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. D-11 identity behaviours and jwt-only surfaces fail when broken, including EN/PL 409 and byte-identical Winter 404s. Task 2: Migration up/down, MeToken null collection_ids, and secret-leak threat tests ../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 ../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) 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). go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/controllers/api/... && 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' 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. - `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). Migration up/down, D-09 `/me` null, and Encrypted-token leak tests fail when their protections are removed. Task 3: TestParityCorpus is 175 recorded, 175 passing, 0 pending ../fonoteka.go/parity/parity_test.go ../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 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). go -C ../fonoteka.go vet ./parity/... ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m Non-zero exit; summary line is not `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`. - `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`. 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). ## 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. 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. 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. - 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. Create `.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-SUMMARY.md` when done