Files
summercms/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-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

20 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 02 execute 2
14.1-01
../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
true
API-09
QA-05
HTTP-01
HTTP-03
DATA-02
DATA-07
I18N-01
tokens raw_tokens tasks confidence
280000 280000 3 low
truths artifacts key_links prohibitions
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`.
path provides contains
../fonoteka.go/plugins/golem15/user/oauth_identities_test.go D-11 API tests and secret-leak assertions TestOAuthIdentities
path provides contains
../fonoteka.go/plugins/golem15/user/updates/oauth_identities_test.go migration up/down golem15_user_oauth_identities
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go D-09 nil collection_ids JSON null collection_ids
path provides contains
../fonoteka.go/plugins/golem15/fonoteka/phase08_coverage_test.go jwt-only identity surfaces oauth-identities
from to via pattern
../fonoteka.go/plugins/golem15/user/oauth_identities_test.go ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go httptest calls OAuthIdentitiesIndex/Destroy constructors with bouncer.WithUser OAuthIdentitiesDestroy
from to via pattern
../fonoteka.go/parity/parity_test.go ../fonoteka.go/parity/manifest.yaml TestParityCorpus counts 175 recorded and 175 ported expectedPortedRoutes
requirement_id category statement status verification
HTTP-01 safety 401 tests use path /google so jwt.auth runs; an unauthenticated unknown provider is not used as the 401 probe resolved test
requirement_id category statement status verification
DATA-07 privacy Secret-leak tests fail if the GET body contains plaintext tokens, Encrypted JSON keys, or the redacted literal resolved test
requirement_id category statement status verification
API-09 transparency Pending routes MUST NOT count as passing; the corpus gate is 175/175/0 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.

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.

<execution_context> @/.codex/gsd-core/workflows/execute-plan.md @/.codex/gsd-core/templates/summary.md </execution_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 - 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' <fails_when>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".</fails_when> <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> 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' <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> <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> 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).

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

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.

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