diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md new file mode 100644 index 0000000..fef79ad --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md @@ -0,0 +1,697 @@ +# Phase 14.1: oauth-identities-and-fonoteka-me-routes - Research + +**Researched:** 2026-10-05 +**Domain:** JWT social-identity list/unlink parity and personal-token `/me` contract close-out +**Confidence:** HIGH + + +## User Constraints (from CONTEXT.md) + +**CRITICAL:** If CONTEXT.md exists from $gsd-discuss-phase, copy locked decisions here verbatim. These MUST be honored by the planner. + +### Locked Decisions + +### Where the identity code lives +- **D-01:** The `OAuthIdentity` model and the `golem15_user_oauth_identities` migration live in **sm-user-plugin**. This mirrors PHP, where Golem15.User v3.3.0 owns the table. The change to the shared plugin is additive only. — **Reversibility:** costly — once shipped in a shared plugin, the table name and schema are a contract for every app that uses the plugin and for the Phase 15 import. +- **D-02:** The list and unlink handlers also live in **sm-user-plugin**, as a reusable API. This departs from PHP, where the controller sits in the fonoteka plugin as a "D-15 compromise". The user plugin exposes the handlers, and the fonoteka app or plugin mounts them at `/_fonoteka/api/v1/oauth-identities` on the JWT group, with PHP's middleware: JWT auth on both routes and `throttle:10,1` on DELETE. The user plugin must not register these routes under its own `/_user/api/v1` group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, which `routes.snapshot` checks. — **Reversibility:** costly — it adds exported API surface to a shared plugin that other apps may start to depend on. +- **D-03:** The 409 "last method" message moves to a new key in the **user plugin's lang files**. The EN and PL texts are identical to PHP's `golem15.fonoteka::lang.oauth.last_method_blocked`: + - 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ę." + + A host can override the text through the normal lang override. +- **D-04:** The DELETE provider whitelist is a **mount option**. It defaults to `google`, `facebook` and `github`, and the host can narrow or extend it. A provider outside the list returns the same 404 that PHP's route constraint produces. The researcher confirms PHP's exact bytes for a constraint miss compared with a missing row. +- **D-05:** The responses are built from an explicit map (`provider`, and `linked_at` as an ISO-8601 string or null), never by serializing the model. The model carries encrypted tokens and profile data. The same rule applies in PHP. +- **D-06:** Unlink is fail-closed, as in PHP. The last remaining identity is refused with 409 even when the account also has a password. Only the identity count decides it, and `has_self_set_password` plays no part. + +### Columns and import +- **D-07:** The Go table and model carry the **full PHP column set**: `id`, `user_id` (FK to `users`, cascade delete), `provider` (50), `provider_id` (255), `access_token` and `refresh_token` (both `lagoon.Encrypted`), `token_expires_at`, `profile_data` (jsonb), `linked_at`, `created_at` and `updated_at`. Both unique indexes are kept: `(user_id, provider)` and `(provider, provider_id)`. The PHP backfill from the legacy `users.oauth_*` columns is not ported, because Phase 15 imports the rows directly. — **Reversibility:** one-way — the migration ships in a shared plugin and becomes the import target in Phase 15. +- **D-08:** No Winter-import mapper is written in this phase. Phase 15 (D-02/D-03) adds the `HasWinterImport` mappers for every user-plugin table, including the Laravel decrypt and the GCM re-encrypt of the two token columns. + +### `/api/v1/fonoteka/me` contract +- **D-09:** The full contract is `{"data":{"scopes","collection_ids","user_id","name"}}`. The one gap in today's Go handler (`me_token_controller.go`) is `collection_ids`. PHP's `ApiToken::collectionIds()` returns **`null` for an unrestricted token** (no bound collections), but Go always emits `[]` through `wire.Slice`. Go must emit `null` when the token has no collection binding and a list of ints otherwise. `scopes` keeps the PHP fallback `[]`. This is a parity fix, not a shape change. + +### Parity coverage +- **D-10:** New recorded PHP cases: + - **List with linked rows:** alice is seeded with `facebook` and `google` identities, which exercises ordering by provider, the `{provider, linked_at}` shape and the `linked_at` format. The seeded rows carry token and profile values, so the fixture shows that no secret leaks. + - **`/me` with an unrestricted token:** `"collection_ids": null`. + + The existing cases stay: GET with an empty list, DELETE with a missing row (404), and `/me` with a restricted token. Seeding goes through the parity harness's `seed_hook` mechanism, on both the PHP recording side and the Go replay side. +- **D-11:** The remaining identity behaviours are covered by **Go tests ported from `OAuthIdentityApiTest.php`**: unlink returns 204 and keeps the other row, last-method returns 409 with the EN/PL text asserted against the PHP lang strings, foreign and missing rows give byte-identical 404s, an unknown provider gives 404, and both routes give 401 without a JWT. These tests go in the phase's final unit-test plan, together with full coverage of the new user-plugin and fonoteka code. Tests also check that the routes are on the JWT group and that the personal-token group `/api/v1/fonoteka` never gains them (PHP's `TokenSurfaceIsolationTest`). +- **D-12:** All three manifest entries flip from `pending` to `ported`. Phase 15's preflight then sees zero pending routes. + +### Social-login-only accounts at cutover +- **D-13:** No code in this phase. The lockout risk is recorded for the **Phase 15 preflight**. With social login deferred, a user whose only sign-in method is an OAuth identity cannot log in on Go: PHP's random password means `has_self_set_password = false`, and the Nuxt app has no recovery screen. The preflight counts such users in the production dump. If the count is non-zero, the cutover either gives them a password first (for example through the user plugin's `forgot-password` flow, triggered by hand) or social login gets its own phase before the swap. + +### Folded Todos +- **`orphan-pending-routes.md`** ("Give oauth-identities and /api/v1/fonoteka/me a roadmap phase before cutover"): this phase is that home. The todo moves to done when the phase completes. + +### Claude's Discretion +- The exported API shape in sm-user-plugin: a handler constructor with options, a small `Mount(router, opts)` helper, or a separate sub-package. +- How the fonoteka side wires the mount, and where the throttle name for `throttle:10,1` is declared. +- The Go timestamp formatting needed to match Laravel's `toIso8601String()` (`+00:00` offset, not `Z`). The researcher confirms the exact format against the recorded fixture. +- The plan split, subject to the CLAUDE.md lean-mode rule: unit tests come in the last plan, and the plan count is confirmed before PLAN.md files are written. +- The README and docs updates required by CLAUDE.md for the sm-user-plugin API and any changes to the framework modules. + +### Deferred Ideas (OUT OF SCOPE) +- **Social login port** (`/oauth/{provider}` redirect and callback, linking a new provider, `oauth-complete`, `oauth-register-complete`, the password-bootstrap OTP): still its own phase. It needs an OAuth-client dependency decision. The Phase 15 preflight count (D-13) decides whether it must land before the swap. +- **Winter-import mapper for `golem15_user_oauth_identities`**: Phase 15 (D-08). + + + +## Phase Requirements + +Requirement IDs were TBD at discuss time. These v1 rows in REQUIREMENTS.md are the ones this phase actually moves: + +| ID | Description | Research Support | +|----|-------------|------------------| +| API-09 | All 154 routes are registered on the correct groups with identical paths, methods, status codes and bodies | Flip the three remaining `pending` manifest entries to `ported`. Corpus today is 175 recorded / 172 ported / 3 pending; this phase makes 175/175/0. | +| QA-05 | Cutover: the parity harness is green on all 154 routes and vue-fonoteka-app and fonoteka-mcp run unchanged against the Go backend | Recorded PHP cases plus Go replay; Nuxt `ConnectedAccounts.vue` and MCP `me()` stay unchanged. | +| HTTP-01 | Unknown and malformed ids both return 404 on ownership-scoped resources | Missing identity, foreign identity, and unknown provider share Winter HTML 404. | +| HTTP-03 | Three mutually exclusive auth groups share the same handlers with different route subsets | Identity routes JWT-only; `/me` stays on the personal-token group. | +| HTTP-04 | Rate limiter ports Płytarium's inline throttles 1:1 | DELETE carries `throttle:10,1`. | +| HTTP-06 | Empty arrays serialize as [], timestamps as +00:00 | `scopes` stays `[]`; `linked_at` uses `wire.Time`; `collection_ids` is the D-09 exception (null when unrestricted). | +| DATA-02 | Each plugin ships a gormigrate migration set with up and down; AutoMigrate is never the schema source | New timestamped migration in sm-user-plugin `updates/`. | +| DATA-07 | Custom casts for jsonable columns and encrypted-at-rest secrets | `lagoon.Encrypted` tokens; `lagoon.Jsonable` profile_data. | +| I18N-01 | Translation keys use `vendor.plugin::group.key` | New `golem15.user::lang.oauth.last_method_blocked` EN/PL. | + + +## Project Constraints (from CLAUDE.md) + +- Lean planning: fewer, larger plans. Unit tests are always the last plan of a phase. Confirm plan count with the user before writing PLAN.md. +- Standard library first. Add a dependency only when STACK.md or a locked phase decision names it. +- Compiled plugins registered at build time. No runtime plugin loading. +- API parity is the acceptance test. Do not "improve" response shapes. +- Two repositories: `summercms.go` stays app-agnostic; application code lives in `fonoteka.go` and `sm-user-plugin`. +- A change to a module's exported API, config keys or CLI commands updates that module's README (and `docs/` when the framework surface changes) in the same change. +- Framework READMEs never name a consuming application. +- `go vet` and `go test ./...` green at every commit. Planning docs and code in separate commits. No co-author tags. + + +## Summary + +Phase 14.1 is a three-route parity close-out, not a new SSO stack. sm-user-plugin already owns users, JWT, and `user_api_tokens`; this phase adds `golem15_user_oauth_identities` plus two exported handlers. The fonoteka plugin mounts those handlers on the existing JWT group at `/_fonoteka/api/v1/oauth-identities` and does **not** add them to `/_user/api/v1` or `/api/v1/fonoteka`. `GET /api/v1/fonoteka/me` is already mounted; the only code change is `collection_ids` null vs `[]`. + +The 404 contract is Winter HTML (`text/html; charset=UTF-8`, Polish "Nie znaleziono strony"), not JSON and not Go's `http.NotFound` body. PHP's `HttpException(404, 'OAuth identity not found')` never appears in the recorded fixture. PHP's `{provider}` constraint `google|facebook|github` produces the same production 404 page as a missing row. Go `surf.Where` / `constrain` answers with `http.NotFound` (`404 page not found\n`). The allow-list therefore belongs in the handler, with the host injecting `api.WriteWinterHTTPError`. + +No new Go modules. Reuse GORM, gormigrate, `lagoon.Encrypted`, `lagoon.Jsonable`, `wire.WriteJSON` / `wire.Time` / `wire.Slice`, phrasebook, surf `throttle:10,1`, and the existing Winter 404 page. + +**Primary recommendation:** Two lean plans — (1) model + migration + exported handlers + fonoteka mount + `/me` null fix + parity recording/manifest flip in `fonoteka.go` and `sm-user-plugin`; (2) unit tests last. Do not add `g.Where("provider", ...)`. Rewrite `assertPortedMismatch` because it currently proves the GET identity route is unported. + + + +## Architectural Responsibility Map + +| Capability | Primary Tier | Secondary Tier | Rationale | +|------------|-------------|----------------|-----------| +| List linked identities | API / Backend | Database / Storage | JWT-only GET; explicit `{provider, linked_at}` map from `golem15_user_oauth_identities`. | +| Unlink one identity | API / Backend | Database / Storage | JWT + `throttle:10,1`; 204 / Winter 404 / 409 JSON; fail-closed on identity count. | +| Provider allow-list | API / Backend | — | Mount option; unknown provider is the same 404 as a missing row. | +| Encrypted tokens / profile_data | Database / Storage | API / Backend | At-rest AES-GCM and jsonb; never serialized on these routes. | +| Personal-token `/me` | API / Backend | — | Already on `/api/v1/fonoteka`; D-09 is a JSON-null fix. | +| Nuxt Connected accounts tab | Browser / Client | — | Unchanged consumer; 409 copy is client-side, status is server. | +| fonoteka-mcp `me()` | API / Backend | — | Unchanged client; extra `collection_ids` is ignored by its TypeScript type. | +| Social login / import mapper | — | — | Deferred (D-08, social-login phase). | + + + +## Standard Stack + +No new packages. Versions are the pins already in `sm-user-plugin/go.mod` [VERIFIED: `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/go.mod:3-14`]. + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| Go | 1.27.0 | Language | Project pin; probed `go version go1.27.0-X:nodwarf5`. | +| GORM | v1.31.2 | ORM | Decided stack; user plugin already requires it. | +| gormigrate/v2 | v2.1.7 | Up/down migrations | DATA-02; `updates.Register` + `updates.All()`. | +| lagoon.Encrypted | in-tree | AES-256-GCM columns | DATA-07; redacting marshal. | +| lagoon.Jsonable | in-tree | JSON column Scan/Value | profile_data; Scan works on jsonb even though `GormDataType` is `text`. | +| wire | in-tree | JSON, Carbon time, empty slices | HTTP-06; D-09 exception for `collection_ids`. | +| backpack / pact / surf / bouncer | in-tree | App, plugin, router, JWT | Existing JWT group and 401 writer. | +| phrasebook | in-tree | `golem15.user::lang.*` | D-03 409 text. | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| golang-jwt/jwt/v5 | v5.3.1 | JWT (already mounted) | Do not re-parse tokens in identity handlers. | +| testcontainers-go (+ postgres) | v0.44.0 | Real Postgres for migration tests | User plugin `updates/*_test.go` pattern. | +| testify | v1.12.1 (indirect) | Assertions | Existing `assert`/`require` style; keep `func Test...(*testing.T)`. | +| go-i18n/v2 | v2.6.1 (indirect) | CLDR via phrasebook | Do not import directly. | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Handler constructors + host mount | `Mount(router, opts)` helper | A Mount helper that wrote Winter 404 would import fonoteka's `api` package into the user plugin. Rejected. | +| `surf.Where("provider", "google\|facebook\|github")` | Handler allow-list | `Where`/`constrain` uses `http.NotFound` [VERIFIED: `modules/surf/params.go:81-89`]. That body is not Winter HTML. | +| JSON 404 with `OAuth identity not found` | Winter HTML 404 | PHP throws that message, but the recorded DELETE fixture is the Polish Winter page. | +| `wire.Slice` for `collection_ids` | JSON null when empty | Phase 8 D-20 used Slice on both arrays; D-09 supersedes it for `collection_ids` only. | +| New throttle bucket | Inline `"throttle:10,1"` | JWT group already uses that string on CSV import and collection switch. | + +**Installation:** none. Do not add require lines. + +**Version verification:** pins read from `sm-user-plugin/go.mod` this session. No registry lookup for new names. + + +## Package Legitimacy Audit + +This phase installs no external packages. + +| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition | +|---------|----------|-----|-----------|-------------|---------|-------------| +| — | — | — | — | — | — | No new packages | + +**Packages removed due to [SLOP] verdict:** none +**Packages flagged as suspicious [SUS]:** none + + +## Architecture Patterns + +### System Architecture Diagram + +```text +Nuxt ConnectedAccounts / MCP me() + | JWT Bearer | inv_ token Bearer + v v +/_fonoteka/api/v1 (jwt.auth, locale, /api/v1/fonoteka + locale.from-principal, (inv_token, throttle:fonoteka-api-token) + must-change-password) + | + +-- GET /oauth-identities --------> user.OAuthIdentitiesIndex + | | query user_id, ORDER BY provider + | | map {provider, linked_at: *wire.Time} + | v + | 200 {"data":[...]} secrets never in map + | + +-- DELETE /oauth-identities/{provider} throttle:10,1 + | 1. jwt.auth (no JWT -> 401 JSON) + | 2. provider in allow-list? else Winter HTML 404 + | 3. row for (user_id, provider)? else Winter HTML 404 + | 4. count(*)==1? -> 409 {"error": phrasebook} + | 5. delete -> 204 empty + v + Postgres golem15_user_oauth_identities + (Encrypted tokens, jsonb profile_data) + +Personal-token GET /me + | bouncer.Credential (*models.ApiToken) already matched + | scopes: wire.Slice (nil -> []) + | collection_ids: null if !Valid or len==0, else list of ints + v + 200 {"data":{scopes, collection_ids, user_id, name}} +``` + +### Recommended Project Structure +``` +fonoteka.go/plugins/golem15/user/ # sm-user-plugin +├── models/oauth_identity.go # TableName, Encrypted, Jsonable, init Register +├── updates/202610050001_create_oauth_identities.go +├── updates/oauth_identities_test.go +├── controllers/oauth_identities.go # Index + Destroy constructors +├── lang/en/lang.yaml # oauth.last_method_blocked +├── lang/pl/lang.yaml +└── README.md # exported API + table + +fonoteka.go/plugins/golem15/fonoteka/ +├── routes.go # JWT mount; no Where on provider +├── controllers/api/me_token_controller.go # D-09 collection_ids null +└── phase08_coverage_test.go # assertRouteSurfaces jwt-only + +fonoteka.go/parity/ +├── manifest.yaml # three pending -> ported; extra cases +├── parity_test.go # 175/175; rewrite assertPortedMismatch +├── fonoteka_seed_test.go # extras oauth-identities / me-unrestricted +├── fonoteka_reset.php # same extras +└── fixtures/routes/ # keep empty GET, missing DELETE, restricted /me; + # add list-with-rows and unrestricted /me +``` + +### Pattern 1: Exported handlers, host mount (D-02) +**What:** sm-user-plugin exports constructors. Fonoteka mounts them on the JWT group. The user plugin's `/_user/api/v1` group stays unchanged [VERIFIED: `fonoteka.go/plugins/golem15/user/routes.go:9-30`]. +**When to use:** Shared plugin owns the table; host owns the public path and Winter 404 page. +**Recommend (discretion):** + +```go +// sm-user-plugin/controllers/oauth_identities.go +type OAuthIdentitiesOptions struct { + Providers []string // default google, facebook, github + WriteNotFound func(http.ResponseWriter, *http.Request) +} + +func OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc { /* ... */ } + +func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc { /* ... */ } +``` + +Fonoteka JWT group [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/routes.go:48`]: + +```go +r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password"), func(g pact.Router) { + // ... + g.Get("/oauth-identities", userctrl.OAuthIdentitiesIndex(p.app)) + 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") +}) +``` + +Default providers match PHP [VERIFIED: `fonoteka/plugins/golem15/fonoteka/routes.php:321`]: `->where('provider', 'google|facebook|github')->middleware('throttle:10,1')`. + +Do **not** add a `Mount` helper that imports `fonoteka/controllers/api`. The user plugin cannot take that dependency. + +### Pattern 2: Explicit response map (D-05) +PHP index [VERIFIED: `fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php:31-39`]: + +```php +$values = OAuthIdentity::where('user_id', $user->id) + ->orderBy('provider') + ->get() + ->map(static fn (OAuthIdentity $identity) => [ + 'provider' => (string) $identity->provider, + 'linked_at' => $identity->linked_at?->toIso8601String(), + ]); +return response()->json(['data' => $values->values()]); +``` + +Go: `wire.WriteJSON` of `map[string]any{"data": rows}` where each row is `map[string]any{"provider": ..., "linked_at": (*wire.Time | nil)}`. Never `json.Marshal` the GORM model. `lagoon.Encrypted.MarshalJSON` redacts to `"[redacted]"` [VERIFIED: `modules/lagoon/encrypted.go:49-52`], which would still leak the key name if the model were serialized. PHP tests forbid `access_token`, `refresh_token`, `profile_data`, `user_id`, and the plaintext secret in the body [VERIFIED: `fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php:50-55`]. + +### Pattern 3: Fail-closed unlink (D-06) +PHP destroy [VERIFIED: `fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php:52-68`]: + +```php +$identity = OAuthIdentity::where('user_id', $user->id)->where('provider', $provider)->first(); +if (!$identity) { throw new HttpException(404, 'OAuth identity not found'); } +if (OAuthIdentity::where('user_id', $user->id)->count() === 1) { + return response()->json(['error' => Lang::get('golem15.fonoteka::lang.oauth.last_method_blocked')], 409); +} +$identity->delete(); +return response()->noContent(); +``` + +Go must count identities only. `User.HasSelfSetPassword` exists [VERIFIED: `fonoteka.go/plugins/golem15/user/models/user.go:27`] and must not be read. 409 body: `{"error": }` with key `golem15.user::lang.oauth.last_method_blocked` (D-03). EN/PL strings [VERIFIED: `fonoteka/plugins/golem15/fonoteka/lang/en/lang.php:135-137`] and [VERIFIED: `fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php:135-137`]: + +- 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ę.` + +204: empty body, status 204 (PHP `noContent()`). + +### Pattern 4: Winter HTML 404, not router 404 +Recorded missing-row DELETE [VERIFIED: `fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt.yaml:13-31`]: status 404, `Content-Type: "text/html; charset=UTF-8"`, title `Nie znaleziono strony`. That page is `winter_404.html` [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html:1-14`]. Host writers: `WriteWinterHTTPError` [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go:44-72`]. + +The user plugin's `writeWinterErrorPage` always writes **500** [VERIFIED: `fonoteka.go/plugins/golem15/user/controllers/api_controller.go:1073-1077`]. Do not reuse it for identity 404s. + +PHP constraint miss and missing row are the same Winter production 404 page (this session: fixture is missing-row; Laravel unmatched `{provider}` also renders that page). Go `constrain` [VERIFIED: `modules/surf/params.go:85-89`]: `http.NotFound(w, r)`. `routes.snapshot` records path+method+auth group only, not the regex [VERIFIED: `fonoteka.go/parity/routes.snapshot:101-102`]: + +``` +GET /_fonoteka/api/v1/oauth-identities jwt +DELETE /_fonoteka/api/v1/oauth-identities/{provider} jwt +``` + +Precedent: oauth `request_id` constraint was loosened so the controller 404 is reachable [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/routes.go:238-254`]. + +### Pattern 5: D-09 `/me` collection_ids null +PHP [VERIFIED: `fonoteka/plugins/golem15/fonoteka/models/ApiToken.php:89-94`]: + +```php +public function collectionIds(): ?array +{ + $ids = $this->collection_ids; + return is_array($ids) && $ids !== [] ? array_values(array_map('intval', $ids)) : null; +} +``` + +PHP me [VERIFIED: `fonoteka/plugins/golem15/fonoteka/controllers/api/MeTokenController.php:29-36`]: `'scopes' => $token?->scopes ?? []`, `'collection_ids' => $token?->collectionIds()`. + +Today's Go [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go:41-46`] uses `wire.Slice` on both. Change only `collection_ids`: if `!token.CollectionIDs.Valid || len(token.CollectionIDs.Get()) == 0` emit JSON `null`; else emit the int list. Keep `scopes` as `wire.Slice`. Rewrite `TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName` [VERIFIED: `me_token_controller_test.go:113-117`], which currently forbids `"collection_ids":null`. + +Restricted fixture stays `collection_ids:[{{id:token}}]` [VERIFIED: `GET__api_v1_fonoteka_me_personal_token.yaml:19`]. MCP `MeResponse` only types `scopes`, `user_id`, `name` [VERIFIED: `fonoteka/fonoteka-mcp/src/client.ts:54-59`]; extra `collection_ids` is ignored. + +### Pattern 6: Migration + model registration +Follow `202610040001_create_api_tokens.go` [VERIFIED: lines 7-36]: `tx.Exec` DDL, `init() { Register(...) }`, postgres test via `gormigrate.New(..., TableName: "summer_migrations_golem15_user", UseTransaction: true)`. `plugin.go` already returns `updates.All()` / `models.All()` [VERIFIED: `plugin.go:207-209`]. New model `init() { Register(OAuthIdentity{}) }` like `APIToken`. + +Table [VERIFIED: PHP migration `create_oauth_identities_table.php:14-36`]: `golem15_user_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. Do **not** port the PHP `users.oauth_*` backfill (D-07). + +`profile_data jsonb` (D-07) even though `Jsonable.GormDataType` returns `text` [VERIFIED: `modules/lagoon/jsonable.go:84-85`]. Use SQL `jsonb` in the migration; `Jsonable.Scan` accepts `[]byte` from Postgres jsonb. + +### Anti-Patterns to Avoid +- **Serializing the model:** leaks Encrypted keys as `"[redacted]"` and `profile_data`. +- **`g.Where("provider", ...)`:** text/plain 404, not Winter HTML; also 404s unauthenticated unknown providers before `jwt.auth` (PHP constraint is pre-auth; D-11 401 tests use `/google`). +- **JSON 404 with the HttpException message:** not what PHP production recorded. +- **`wire.Slice` on `collection_ids`:** contradicts D-09. +- **Registering identity routes on `/_user/api/v1`:** D-02 forbids it. +- **Consulting `has_self_set_password` on unlink:** D-06. +- **Winter-import mapper / social login:** D-08 / deferred. +- **Leaving `assertPortedMismatch` pointed at GET oauth-identities:** it currently requires that replay to fail with 200 vs 404 [VERIFIED: `parity_test.go:456-500`]. + + + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| AES at rest | Custom cipher | `lagoon.Encrypted` | Key derivation, previous-key Scan, redacting marshal already exist. | +| JSON column | `json.RawMessage` + ad-hoc null | `lagoon.Jsonable[map[string]any]` | Valid vs empty vs SQL NULL. | +| Carbon `+00:00` | `time.RFC3339` / `Z` | `wire.Time` | Layout `2006-01-02T15:04:05` + `+00:00` [VERIFIED: `modules/wire/response.go:32-43`]. | +| Winter 404 HTML | String-concat PHP page | `api.WriteWinterHTTPError` | Origin substitution, headers, embed. | +| 401 JSON | New envelope | Group `jwt.auth` → `bouncer.write401` | `{"error":true,"message":...}` + `Cache-Control: no-cache, private` [VERIFIED: `modules/bouncer/jwt.go:367-373`]. | +| 409 locale strings | Hard-coded English | phrasebook `Get` | I18N-01; D-03 key in user lang YAML. | +| Empty JSON array | Custom nil check for scopes | `wire.Slice` | HTTP-06 for `scopes` and GET empty `data`. | +| Migrations | GORM AutoMigrate | gormigrate `tx.Exec` | DATA-02. | +| Rate limit | New bucket type | surf `"throttle:10,1"` | Same string as CSV import [VERIFIED: `routes.go:60`]. | + +**Key insight:** The parity bytes already exist. The work is wiring PHP's controller into the Go plugin split without inventing a second 404 writer or a second crypto path. + + +## Runtime State Inventory + +This phase adds a table and ports routes. It does not rename existing keys. Inventory is for the new schema and secrets. + +| Category | Items Found | Action Required | +|----------|-------------|------------------| +| Stored data | New table `golem15_user_oauth_identities` (PHP already has it in production dumps). Unique indexes and cascade FK as in PHP. No backfill from `users.oauth_*`. | Code: gormigrate in sm-user-plugin. Data: Phase 15 import (out of scope). Empty on fresh Go DBs until seed/import. | +| Live service config | None — no n8n/Datadog/Centrifugo config keys for these routes. | None — verified: routes are in-process HTTP. | +| OS-registered state | None — no systemd/cron names for oauth-identities. | None — verified: prune-notifications is unrelated. | +| Secrets/env vars | `access_token` / `refresh_token` columns; `app.key` already required for `lagoon.Encrypted`. No new env names. Laravel ciphertext is not decryptable by GCM until Phase 15. | Code: Encrypted columns. Do not decrypt PHP rows in this phase (D-08). Seed tests use `lagoon.NewEncrypted` under the test app key. | +| Build artifacts | sm-user-plugin submodule; `routes.snapshot` already lists the three paths. | Rebuild via normal `go test` / air. Snapshot does not need regex. `expectedPortedRoutes` 172→175. | + +**Nothing found in category:** Live service config and OS-registered state — none for this surface. + + +## Common Pitfalls + +### Pitfall 1: Router constraint vs Winter 404 +**What goes wrong:** `g.Where("provider", "google|facebook|github")` returns `404 page not found\n` (`text/plain`). +**Why it happens:** `constrain` calls `http.NotFound` [VERIFIED: `modules/surf/params.go:85-89`]. +**How to avoid:** No `Where` on `{provider}`. Check the allow-list in Destroy; call host `WriteNotFound`. +**Warning signs:** Parity DELETE unknown-provider (Go unit test) fails Content-Type or body; unauthenticated `/linkedin` 404s instead of 401. + +### Pitfall 2: HttpException message vs recorded 404 +**What goes wrong:** JSON `{"message":"OAuth identity not found"}`. +**Why it happens:** PHP throws `HttpException(404, 'OAuth identity not found')` [VERIFIED: `OAuthIdentityApiController.php:56-58`], but Winter production renders HTML. Recorded fixture is HTML [VERIFIED: DELETE fixture lines 13-31]. +**How to avoid:** Always Winter HTML for missing, foreign, and unknown provider. Foreign and missing bodies must be byte-identical [VERIFIED: `OAuthIdentityApiTest.php:101-113`]. +**Warning signs:** Nuxt treats 404 as JSON parse error; parity HTML diff. + +### Pitfall 3: `wire.Slice` on `collection_ids` +**What goes wrong:** unrestricted `/me` emits `"collection_ids":[]`. +**Why it happens:** Phase 8 D-20 comment says arrays never serialize as null [VERIFIED: `me_token_controller.go:18-19`]. D-09 supersedes that for this field only. +**How to avoid:** Null when invalid or empty; Slice only for `scopes`. Update the nil-array unit test. +**Warning signs:** D-10 unrestricted fixture fails; MCP still works (it ignores the field). + +### Pitfall 4: `linked_at` as `Z` +**What goes wrong:** `"2026-10-05T12:00:00Z"` vs Carbon `+00:00`. +**Why it happens:** `time.Time` RFC3339. Existing GET fixture is empty `{"data":[]}` [VERIFIED: GET oauth-identities fixture line 18], so D-10 recording pins the format. +**How to avoid:** `wire.Time` [VERIFIED: `modules/wire/response.go:41-43`]: `t.UTC().Format("2006-01-02T15:04:05") + "+00:00"`. Null `linked_at` stays JSON null. +**Warning signs:** D-10 list-with-rows fixture timestamp mismatch. + +### Pitfall 5: `assertPortedMismatch` still targets GET oauth-identities +**What goes wrong:** After the route is ported, the helper fatals `"unported PHP route must not pass"`. +**Why it happens:** Phase 14 used this pending route as the negative probe [VERIFIED: `parity_test.go:458-461`]. +**How to avoid:** Point it at a synthetic mismatch (the sibling `assertPortedMutationCaught` pattern) or a deliberately wrong expected status on a ported fixture. There will be zero pending routes (D-12). +**Warning signs:** `TestParityCorpus` fails only on this helper after 175/175. + +### Pitfall 6: phase08 `assertAbsent("oauth-identit")` +**What goes wrong:** Surface isolation test fails once routes exist. +**Why it happens:** Deferred comment [VERIFIED: `phase08_coverage_test.go:663-666`]. +**How to avoid:** `assertRouteSurfaces(t, rt, GET, "/oauth-identities", true, false)` and DELETE `"/oauth-identities/{provider}"` jwt-only, matching PHP [VERIFIED: `TokenSurfaceIsolationTest.php:185-189`]. +**Warning signs:** `test_oauth_identity_routes_exist_on_jwt_surface_only` fail. + +### Pitfall 7: Putting identity routes on `/_user/api/v1` +**What goes wrong:** Snapshot and Nuxt break (`baseURL` is `/_fonoteka/api/v1`). +**Why it happens:** Natural place for user-plugin handlers. +**How to avoid:** D-02; leave `user/routes.go` unchanged [VERIFIED: lines 9-30]. +**Warning signs:** Nuxt `fetchOAuthIdentities` 404 [VERIFIED: `useFonoteka.ts:501-507`]. + +### Pitfall 8: Seed extras only on one side +**What goes wrong:** PHP recording and Go replay diverge. +**Why it happens:** D-10 needs alice facebook+google and an unrestricted token. Extras live in both `fonoteka_reset.php` `$extras` [VERIFIED: lines 119-128] and `fonotekaCaseExtras` [VERIFIED: `fonoteka_seed_test.go:37-38`]. +**How to avoid:** Add the same extra names on both sides; key extras as `"#"`. Keep empty GET / missing DELETE / restricted `/me`. +**Warning signs:** Empty-list fixture suddenly contains rows; secrets appear in GET body. + +### Pitfall 9: Manifest counts +**What goes wrong:** `expectedPHPRoutes = 175`, `expectedPortedRoutes = 172` [VERIFIED: `parity_test.go:28-29`] left unchanged. Phase 14 gate `EXPECTED_PENDING=3` is a Phase 14 artifact, not this phase's gate. +**How to avoid:** 175 / 175 / 0. Flip three `status: pending` entries [VERIFIED: manifest.yaml 2801-2805, 2819-2823, 3406-3410]. +**Warning signs:** Corpus summary still prints `pending 3`. + + + +## Code Examples + +Verified in-repo values. Quotes are the checkable source. + +### Table, indexes, constraint +```php +// [VERIFIED: fonoteka/plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php:14-36] +Schema::create('golem15_user_oauth_identities', function (Blueprint $table) { + $table->increments('id'); + $table->integer('user_id')->unsigned(); + $table->string('provider', 50); + $table->string('provider_id', 255); + $table->text('access_token')->nullable(); + $table->text('refresh_token')->nullable(); + $table->timestamp('token_expires_at')->nullable(); + $table->json('profile_data')->nullable(); + $table->timestamp('linked_at')->nullable(); + $table->timestamps(); + $table->unique(['user_id', 'provider'], 'oauth_identities_user_provider_unique'); + $table->unique(['provider', 'provider_id'], 'oauth_identities_provider_identity_unique'); + $table->foreign('user_id')->references('id')->on('users')->onDelete('cascade'); +}); +``` + +PHP model table name [VERIFIED: `OAuthIdentity.php:19`]: `public $table = 'golem15_user_oauth_identities';` +PHP `$guarded = ['*']` [VERIFIED: `OAuthIdentity.php:21`]. + +### Route mount, throttle, providers +```php +// [VERIFIED: fonoteka/plugins/golem15/fonoteka/routes.php:320-321] +Route::get('oauth-identities', ...); +Route::delete('oauth-identities/{provider}', ...)->where('provider', 'google|facebook|github')->middleware('throttle:10,1'); +``` + +Go JWT group middleware [VERIFIED: `fonoteka.go/plugins/golem15/fonoteka/routes.go:48`]: `surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password")`. +Go `/me` mount [VERIFIED: `routes.go:262-265`]: `r.Group("/api/v1/fonoteka", surf.Use("inv_token", "throttle:fonoteka-api-token"), ...)` then `g.Get("/me", api.MeToken(p.app), "inv.scope:read")`. + +### Empty GET and restricted `/me` bytes +``` +// [VERIFIED: GET___fonoteka_api_v1_oauth-identities_jwt.yaml:18] +{"data":[]} + +// [VERIFIED: GET__api_v1_fonoteka_me_personal_token.yaml:19] +{"data":{"scopes":["read","write","ai"],"collection_ids":[{{id:token}}],"user_id":{{id:token}},"name":"parity-mcp"}} +``` + +### wire.Time and Slice +```go +// [VERIFIED: modules/wire/response.go:32-43, 83-89] +type Time struct{ time.Time } +func (t Time) MarshalJSON() ([]byte, error) { + s := t.UTC().Format("2006-01-02T15:04:05") + "+00:00" + return []byte(`"` + s + `"`), nil +} +func Slice[T any](s []T) []T { + if s == nil { return []T{} } + return s +} +``` + +### Encrypted redaction +```go +// [VERIFIED: modules/lagoon/encrypted.go:49-52] +func (e Encrypted) MarshalJSON() ([]byte, error) { + return json.Marshal(redactedLiteral) // "[redacted]" +} +``` + +### Nuxt consumers (unchanged) +```ts +// [VERIFIED: vue-fonoteka-app/shared/types/fonoteka.ts:364-371] +export interface OAuthIdentityMeta { + provider: string + linked_at: string | null +} +export interface OAuthIdentitiesResponse { + data: OAuthIdentityMeta[] +} + +// [VERIFIED: stores/fonoteka.ts:728-745] 409 uses client copy, not backend English +``` + +### Suggested Destroy skeleton (planner/executor) +```go +func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + provider := r.PathValue("provider") + if !allowed(opts.Providers, provider) { + opts.WriteNotFound(w, r) + return + } + user, _ := bouncer.User(r.Context()) + // lookup by user.ID + provider; missing -> WriteNotFound + // count == 1 -> wire.WriteJSON 409 {"error": tr.Get(ctx, "golem15.user::lang.oauth.last_method_blocked", nil)} + // delete -> w.WriteHeader(204) + } +} +``` +Do not copy this as production without filling DB lookups; names `OAuthIdentitiesDestroy`, `WriteNotFound`, `golem15.user::lang.oauth.last_method_blocked`, providers `google|facebook|github`, statuses 404/409/204 are locked. + + + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| PHP controller in fonoteka plugin (D-15 compromise) | Handlers in sm-user-plugin, host mount (D-02) | This phase | Shared plugin owns the table API; paths stay `/_fonoteka/...`. | +| Phase 8 D-20: both arrays via `wire.Slice` | D-09: `collection_ids` null when unrestricted | This phase | Must rewrite the nil-array unit test. | +| Identity routes `assertAbsent` | `assertRouteSurfaces` jwt-only | This phase | phase08 coverage comment is obsolete. | +| `assertPortedMismatch` probes pending GET identities | Synthetic mismatch on a ported fixture | This phase | Zero pending routes. | +| Laravel `encrypt()` ciphertext | `lagoon.Encrypted` AES-GCM | Existing | Import remap is Phase 15, not here. | + +**Deprecated/outdated:** +- Treating `/me` as "minimal bootstrap" with `[]` for unrestricted collections. +- Assuming PHP 404 JSON from `HttpException` message text. + + +## Assumptions Log + +| # | Claim | Section | Risk if Wrong | +|---|-------|---------|---------------| +| A1 | Unauthenticated unknown provider is 401 in Go (jwt.auth first) vs PHP 404 (constraint before middleware). D-11 401 tests use `/google`. Not a recorded parity case. | Pitfall 1 | If a future fixture hits `/linkedin` without JWT, PHP 404 vs Go 401. Keep tests on `/google` for 401. | +| A2 | Laravel `$table->json()` on this Postgres is json; D-07 still requires jsonb. Jsonable Scan accepts both. | Pattern 6 | Column type drift at Phase 15 import if dump is `json` not `jsonb`. Planner should CREATE as jsonb per D-07. | + +No other `[ASSUMED]` implementation claims. Timestamp format is verified via `wire.Time` source; D-10 recording confirms bytes. + +## Open Questions + +1. **`assertPortedMismatch` replacement** + - What we know: it requires GET oauth-identities replay to fail 200 vs 404. + - What's unclear: exact replacement helper (synthetic mutation vs drop). + - Recommendation: reuse `assertPortedMutationCaught` style; do not keep a pending-route probe after D-12. + +2. **Unrestricted `/me` token identity** + - What we know: `mcp-read` is restricted (`collection_ids:[{{id:token}}]`). + - What's unclear: token name/store key for the new D-10 case. + - Recommendation: new seed extra mints a second alice token with SQL NULL / empty `collection_ids`; do not mutate `mcp-read`. + +## Environment Availability + +| Dependency | Required By | Available | Version | Fallback | +|------------|------------|-----------|---------|----------| +| Go | build/test | ✓ | 1.27.0-X:nodwarf5 | — | +| Docker | testcontainers | ✓ | 29.7.2 | — | +| PostgreSQL | migrations, parity | ✓ | psql 18.6 | testcontainers if local cluster busy | +| PHP CLI | D-10 recording | ✓ | 8.5.10 | — | + +**Missing dependencies with no fallback:** none +**Missing dependencies with fallback:** none + +## Validation Architecture + +### Test Framework +| Property | Value | +|----------|-------| +| Framework | Go `testing` + testcontainers-go v0.44.0 (user plugin); parity `tide` replay in `fonoteka.go/parity` | +| Config file | none — `go test`; gate script is Phase 14's `scripts/check-phase14.sh` (do not retarget it; this phase is 14.1) | +| Quick run command | `go test ./plugins/golem15/user/... ./plugins/golem15/fonoteka/controllers/api/... -count=1` in fonoteka.go workspace | +| Full suite command | `go vet ./... && go test ./... -count=1` in summercms.go; same plus `./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/...` in fonoteka.go | + +### Phase Requirements → Test Map +| Req ID | Behavior | Test Type | Automated Command | File Exists? | +|--------|----------|-----------|-------------------|-------------| +| API-09 | Three routes ported, 0 pending | parity | `go test ./parity -run TestParityCorpus -count=1` | ✅ extend | +| HTTP-03 / D-11 | JWT-only identities; token group never gains them | unit | `go test ./plugins/golem15/fonoteka -run 'TokenSurface\|oauth_identity' -count=1` | ✅ flip `assertAbsent` | +| HTTP-01 / D-04 | Missing, foreign, unknown provider → identical Winter 404 | unit | new `oauth_identities_test.go` | ❌ Wave 0 | +| D-06 / I18N-01 | Last identity 409 EN/PL | unit | same | ❌ Wave 0 | +| D-11 | Unlink 204 keeps other row; 401 without JWT | unit | port of `OAuthIdentityApiTest.php` | ❌ Wave 0 | +| D-09 | `/me` unrestricted `collection_ids` null | unit + parity | rewrite `TestMeTokenHandlerNil...`; new fixture | ✅ rewrite | +| DATA-02 | Migration up/down, indexes, jsonb, FK | integration | `go test ./plugins/golem15/user/updates -count=1` | ❌ Wave 0 | +| DATA-07 | Encrypted tokens never in GET body | unit + parity | PHP test port + D-10 fixture | ❌ Wave 0 | +| HTTP-04 | DELETE `throttle:10,1` | unit (middleware list) | `assertRouteSurfaces` + middleware contains throttle | ✅ extend | +| QA-05 | Nuxt/MCP unchanged | manual-only | exercise Connected accounts + MCP `me()` against Go | N/A consumers frozen | + +### Sampling Rate +- **Per task commit:** quick run command above + `go vet` on touched packages +- **Per wave merge:** full suite in both repos +- **Phase gate:** `TestParityCorpus` prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`; user plugin and fonoteka `go test ./...` green + +### Wave 0 Gaps +- [ ] `plugins/golem15/user/controllers/oauth_identities_test.go` — D-11 behaviours +- [ ] `plugins/golem15/user/updates/oauth_identities_test.go` — migration up/down +- [ ] Rewrite `me_token_controller_test.go` nil-collection assertion +- [ ] Rewrite `parity/parity_test.go` `assertPortedMismatch` +- [ ] D-10 fixtures + `fonotekaCaseExtras` / `fonoteka_reset.php` extras +- [ ] Flip `phase08_coverage_test.go` oauth identity `assertAbsent` +- [ ] sm-user-plugin README API + table row + +Existing infrastructure covers parity replay, testcontainers migration tests, and JWT group assembly. No new test framework. + +## Security Domain + +### Applicable ASVS Categories + +| ASVS Category | Applies | Standard Control | +|---------------|---------|-----------------| +| V2 Authentication | yes | Group `jwt.auth` / `inv_token`; do not re-parse Bearer in handlers | +| V3 Session Management | no | No new cookies or refresh | +| V4 Access Control | yes | Owner-scoped `user_id`; foreign unlink is 404 not 403; token group never lists identities | +| V5 Input Validation | yes | Provider allow-list; `{provider}` is a path string, not SQL | +| V6 Cryptography | yes | `lagoon.Encrypted` for tokens; never log `Reveal()` | + +### Known Threat Patterns for this stack + +| Pattern | STRIDE | Standard Mitigation | +|---------|--------|---------------------| +| Token/profile leak in GET body | Information Disclosure | Explicit map; Encrypted `json:"-"`; D-10 fixture with secrets that must not appear | +| IDOR unlink of another user's identity | Elevation / Tampering | `WHERE user_id = caller`; 404 not 403 | +| Last-method lockout bypass | Elevation | Fail-closed count===1 → 409; ignore password flag | +| Mass assignment of tokens | Tampering | Empty Fillable / no `lagoon.Fill` on this model; tests assign fields explicitly | +| Unknown provider probing | Information Disclosure | Same Winter 404 as missing row | +| Identity routes on personal token | Elevation | TokenSurfaceIsolation; never mount on `/api/v1/fonoteka` | +| Throttle bypass on DELETE | Denial of Service | `"throttle:10,1"` on the DELETE registration | +| Laravel ciphertext treated as GCM | Tampering / Disclosure | No import this phase (D-08) | + +## Recommended Plan Split (discretion) + +Lean mode: **two plans**, confirm with the user before PLAN.md. + +1. **14.1-01 Implementation** — model, migration, lang keys, exported handlers, fonoteka JWT mount without `Where`, `/me` null fix, parity extras + PHP recording + manifest flip + snapshot (already has paths) + `expectedPortedRoutes` + `assertPortedMismatch` rewrite + README. Smoke: one Go test that GET empty list returns `{"data":[]}` is allowed; full coverage is plan 2. +2. **14.1-02 Unit tests (last)** — port `OAuthIdentityApiTest.php`, EN/PL 409, byte-identical 404s, unknown provider, 401 on `/google`, jwt-only surfaces, migration tests, rewritten MeToken tests, threat tests for secret leak. + +Do not put framework module API changes in summercms.go unless a bug in `wire`/`lagoon` is found (none expected). + +## Sources + +### Primary (HIGH confidence) +- PHP `OAuthIdentityApiController.php`, `MeTokenController.php`, `ApiToken.php`, `OAuthIdentity.php`, `create_oauth_identities_table.php`, `routes.php:320-321`, lang EN/PL `oauth.last_method_blocked`, `OAuthIdentityApiTest.php`, `TokenSurfaceIsolationTest.php` — Read this session +- Go `me_token_controller.go` + test, `http_errors.go`, `winter_404.html`, `routes.go`, `user/routes.go`, `plugin.go`, `parity_test.go`, fixtures, `manifest.yaml`, `routes.snapshot`, `wire/response.go`, `lagoon/{encrypted,jsonable}.go`, `surf/params.go`, `bouncer/jwt.go` — Read this session +- Nuxt types/composables/store; MCP `client.ts` — Read this session +- `14.1-CONTEXT.md` locked decisions; `REQUIREMENTS.md`; `CLAUDE.md` + +### Secondary (MEDIUM confidence) +- Laravel unmatched route constraint renders the same Winter 404 page as `HttpException(404)` in production (`APP_DEBUG=false`). Confirmed by the recorded missing-row HTML and Winter's production error pages already embedded in Go; constraint-miss was not a separate recorded fixture. + +### Tertiary (LOW confidence) +- None required; no new libraries. + +## Metadata + +**Research scope:** +- Core technology: Go net/http handlers, GORM/gormigrate, existing SummerCMS modules +- Ecosystem: no new packages +- Patterns: host-mounted shared-plugin handlers, Winter HTML 404, explicit DTO map, fail-closed unlink +- Pitfalls: surf.Where, Slice-on-collection_ids, assertPortedMismatch, phase08 assertAbsent + +**Confidence breakdown:** +- Standard stack: HIGH — in-repo pins, no new deps +- Architecture: HIGH — PHP controller + Go mount points read in full +- Pitfalls: HIGH — fixtures and failing-helper sites read in full +- Code examples: HIGH — verbatim quotes with line ranges + +**Research date:** 2026-10-05 +**Valid until:** 2026-11-04 (30 days; in-repo contract, not a fast-moving ecosystem) + +--- + +*Phase: 14.1-oauth-identities-and-fonoteka-me-routes* +*Research completed: 2026-10-05* +*Ready for planning: yes*