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