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