From 1f228f85bdaa0aa301b288afab540311feab3ace Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Mon, 5 Oct 2026 19:29:31 +0200 Subject: [PATCH] docs(14.1): add pattern map --- .../14.1-PATTERNS.md | 559 ++++++++++++++++++ 1 file changed, 559 insertions(+) create mode 100644 .planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md diff --git a/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md new file mode 100644 index 0000000..3a8d0e9 --- /dev/null +++ b/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md @@ -0,0 +1,559 @@ +# Phase 14.1: oauth-identities-and-fonoteka-me-routes - Pattern Map + +**Mapped:** 2026-10-05 +**Files analyzed:** 18 +**Analogs found:** 18 / 18 (two caveats: no production 204 handler; no jsonb DDL in sm-user-plugin) + +Plan count is locked at 2: **14.1-01 implementation** (model, migration, lang, exported handlers, fonoteka JWT mount, `/me` null fix, parity extras/recording/manifest, README; smoke GET empty list allowed), **14.1-02 unit tests last**. + +Path roots: +- `FW` = `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go` +- `APP` = `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go` +- `FON` = `APP/plugins/golem15/fonoteka` +- `USR` = `APP/plugins/golem15/user` (sm-user-plugin submodule) + +All analog paths below are git-tracked (`git ls-files` in `summercms.go`, `fonoteka.go`, and inside the user submodule). + +Do **not** modify `USR/routes.go`. Identity handlers are exported; the host mounts them (D-02). + +## File Classification + +| New/Modified File | Role | Data Flow | Closest Analog | Match Quality | Plan | +|-------------------|------|-----------|----------------|---------------|------| +| `USR/models/oauth_identity.go` | model | CRUD | `USR/models/api_token.go` + `FON/models/user_discogs_credential.go` | exact (struct/register + Encrypted) | 01 | +| `USR/updates/202610050001_create_oauth_identities.go` | migration | CRUD | `USR/updates/202610040001_create_api_tokens.go` + unique index from `USR/updates/202610040003_create_frontend_permissions.go` | exact | 01 | +| `USR/controllers/oauth_identities.go` | controller | request-response | `USR/controllers/api_tokens.go` | exact | 01 | +| `USR/lang/en/lang.yaml` | config | transform | itself (`account:` / `plugin:` keys) | exact | 01 | +| `USR/lang/pl/lang.yaml` | config | transform | itself | exact | 01 | +| `USR/README.md` | docs | — | itself (API reference + migration table) | exact | 01 | +| `FON/routes.go` | route | request-response | JWT group `routes.go:48` + `"throttle:10,1"` on CSV/switch; `Where` loosening at `:238-254` | exact | 01 | +| `FON/controllers/api/me_token_controller.go` | controller | request-response | itself | exact (modify) | 01 | +| `APP/parity/manifest.yaml` | config | — | pending entries `:2801-2836`, `:3406-3422` | exact | 01 | +| `APP/parity/parity_test.go` | test | request-response | `assertPortedMismatch` `:456-501` → rewrite like `assertPortedMutationCaught` `:503-529` | exact | 01 | +| `APP/parity/fonoteka_seed_test.go` | test | CRUD | `fonotekaCaseExtras` `:37-80` | exact | 01 | +| `APP/parity/fonoteka_reset.php` | config | CRUD | `$extras` `:119-145` | exact | 01 | +| `APP/parity/fixtures/routes/*oauth-identities*` (keep empty GET; add list-with-rows) | fixture | request-response | `GET___fonoteka_api_v1_oauth-identities_jwt.yaml` | exact | 01 | +| `APP/parity/fixtures/routes/*fonoteka_me*` (keep restricted; add unrestricted) | fixture | request-response | `GET__api_v1_fonoteka_me_personal_token.yaml` | exact | 01 | +| `USR/oauth_identities_test.go` (or `controllers/oauth_identities_test.go`) | test | request-response | `USR/api_tokens_test.go` + `USR/api_tokens_edge_test.go` | exact | 02 | +| `USR/updates/oauth_identities_test.go` | test | CRUD | `USR/updates/api_tokens_test.go` | exact | 02 | +| `FON/controllers/api/me_token_controller_test.go` | test | request-response | itself (`TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName`) | exact (rewrite) | 02 | +| `FON/phase08_coverage_test.go` | test | request-response | `assertAbsent` oauth-identit `:663-666` → `assertRouteSurfaces` `:1017-1049` | exact | 02 | + +Leave unchanged: `USR/routes.go` (`/_user/api/v1` group), `USR/plugin.go` (`Migrations()`/`Models()` already return `updates.All()`/`models.All()`), `USR/controllers/api_controller.go` `writeWinterErrorPage` (always 500 — not a 404 analog). + +## Pattern Assignments + +### `USR/models/oauth_identity.go` (model, CRUD) — plan 01 + +**Analog (registration + Jsonable + TableName):** `USR/models/api_token.go` + +**Imports + struct + init** (lines 1-28, 50): +```go +package models + +import ( + "slices" + "time" + + "git.golem15.com/golem15/summercms/modules/lagoon" +) + +type APIToken struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + Name *string `gorm:"column:name"` + TokenHash string `gorm:"column:token_hash" json:"-"` + Scopes lagoon.Jsonable[[]string] `gorm:"column:scopes"` + CollectionIDs lagoon.Jsonable[[]uint] `gorm:"column:collection_ids"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + // ... +} + +func (APIToken) TableName() string { return "user_api_tokens" } + +func init() { Register(APIToken{}) } +``` + +Copy: package `models`, GORM tags, `TableName()`, `init() { Register(...) }`. `plugin.go:207-209` already returns `updates.All()` / `models.All()` — do not edit `plugin.go`. + +**Do not copy** `APIToken.Fillable` listing secret columns. D-05 / mass-assignment: tests assign fields explicitly; keep Fillable empty or omit secrets. PHP `$guarded = ['*']`. + +**Analog (Encrypted + json:"-"):** `FON/models/user_discogs_credential.go` lines 10-28: +```go +type UserDiscogsCredential struct { + ID uint `gorm:"column:id;primaryKey"` + UserID uint `gorm:"column:user_id"` + Token lagoon.Encrypted `gorm:"column:token" json:"-"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (UserDiscogsCredential) Hidden() []string { return []string{"token"} } +``` + +OAuthIdentity columns (D-07): `id`, `user_id`, `provider` (50), `provider_id` (255), `access_token`/`refresh_token` (`lagoon.Encrypted`, `json:"-"`), `token_expires_at *time.Time`, `profile_data lagoon.Jsonable[map[string]any]`, `linked_at *time.Time`, `created_at`, `updated_at`. `TableName()` → `golem15_user_oauth_identities`. Hidden: `access_token`, `refresh_token`, `profile_data`. + +**Do not read** `USR/models/user.go:27` `HasSelfSetPassword` from unlink (D-06). + +--- + +### `USR/updates/202610050001_create_oauth_identities.go` (migration, CRUD) — plan 01 + +**Analog:** `USR/updates/202610040001_create_api_tokens.go` lines 1-36 + +**Imports + Register + tx.Exec DDL + cascade FK:** +```go +package updates + +import ( + "github.com/go-gormigrate/gormigrate/v2" + "gorm.io/gorm" +) + +var apiTokenMigrations = []*gormigrate.Migration{ + { + ID: "202610040001_create_user_api_tokens", + Migrate: func(tx *gorm.DB) error { + return tx.Exec(`CREATE TABLE user_api_tokens ( + id BIGSERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + // ... + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX ...`).Error + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS user_api_tokens").Error + }, + }, +} + +func init() { Register(apiTokenMigrations...) } +``` + +**Unique-index analog:** `USR/updates/202610040003_create_frontend_permissions.go` lines 15-26: +```go +return execStmts(tx, []string{ + `CREATE TABLE golem15_user_frontend_permissions ( ... )`, + `CREATE UNIQUE INDEX golem15_user_frontend_permissions_code_unique ON golem15_user_frontend_permissions (code)`, +}) +``` + +Copy that named unique-index style for: +- `oauth_identities_user_provider_unique` on `(user_id, provider)` +- `oauth_identities_provider_identity_unique` on `(provider, provider_id)` + +**Deviation (no analog in USR DDL):** `profile_data jsonb` per D-07. User-plugin Jsonable columns today are `TEXT` (`scopes TEXT NULL` in the api_tokens migration). `lagoon.Jsonable.GormDataType` still returns `"text"` (`FW/modules/lagoon/jsonable.go:84-85`) but `Scan` accepts `[]byte` from Postgres jsonb (`jsonable.go:43-44`). Encrypted tokens are `TEXT` like `FON/updates/11_secrets_slice.go:118-119` (`api_key TEXT NOT NULL`). Do **not** port the PHP `users.oauth_*` backfill. + +--- + +### `USR/controllers/oauth_identities.go` (controller, request-response) — plan 01 + +**Analog:** `USR/controllers/api_tokens.go` + +**Imports** (lines 3-17): +```go +import ( + "errors" + "net/http" + // ... + "git.golem15.com/golem15/sm-user-plugin/models" + "git.golem15.com/golem15/summercms/modules/backpack" + "git.golem15.com/golem15/summercms/modules/bouncer" + "git.golem15.com/golem15/summercms/modules/wire" + "gorm.io/gorm" +) +``` + +Add `phrasebook` for the 409 key (same package already uses it in `api_controller.go`). + +**Constructor + owner-scoped list + explicit map** (lines 65-84): +```go +func APITokenIndex(app *backpack.App) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + db, user, ok := apiTokenDeps(w, r, app) + if !ok { + return + } + var rows []models.APIToken + if err := db.WithContext(r.Context()). + Where("user_id = ? AND oauth_client_id IS NULL AND revoked_at IS NULL", user.ID). + Order("created_at DESC").Find(&rows).Error; err != nil { + writeOpaque500(w) + return + } + data := make([]map[string]any, 0, len(rows)) + for i := range rows { + data = append(data, apiTokenJSON(&rows[i])) + } + writeJSON(w, http.StatusOK, map[string]any{"data": wire.Slice(data)}) + } +} +``` + +Index for identities: `Where("user_id = ?", user.ID).Order("provider")`. Each row is `map[string]any{"provider": ..., "linked_at": (*wire.Time | nil)}` — never `json.Marshal` the GORM model (Encrypted would emit `"[redacted]"` and leak key names). Empty list: `wire.Slice` so `{"data":[]}`. + +**Auth deps** (lines 113-128) — copy, do not re-parse JWT: +```go +func apiTokenDeps(...) (*gorm.DB, *bouncer.Principal, bool) { + user, ok := bouncer.User(r.Context()) + if !ok || user == nil || user.ID == 0 { + writeUnauthorized(w) + return nil, nil, false + } + return db, user, true +} +``` + +On the JWT group, `jwt.auth` already 401s before the handler. Keep the principal check as a fail-closed belt. + +**Owner-scoped destroy + foreign/missing 404** (lines 87-110): +```go +id, err := strconv.ParseUint(r.PathValue("id"), 10, 64) +// ... +err = db.WithContext(r.Context()).Where("user_id = ? AND oauth_client_id IS NULL", user.ID).First(&token, id).Error +if errors.Is(err, gorm.ErrRecordNotFound) { + writeJSON(w, http.StatusNotFound, map[string]any{"error": "Token not found"}) + return +} +``` + +Copy the `user_id` + path-value lookup. **Do not copy** the JSON 404 body. Identity 404 is Winter HTML via host `WriteNotFound` (see Shared Patterns). Path value is `r.PathValue("provider")`. + +**409 i18n analog:** `USR/controllers/api_controller.go` lines 1041-1070: +```go +func appTranslator(app *backpack.App) *phrasebook.Translator { ... } + +func accountMessage(ctx context.Context, app *backpack.App, key, fallback string) string { + tr := appTranslator(app) + s := tr.Get(ctx, "golem15.user::lang.account."+key, nil) + // ... +} +``` + +409 body: `writeJSON(w, http.StatusConflict, map[string]any{"error": tr.Get(ctx, "golem15.user::lang.oauth.last_method_blocked", nil)})`. Count identities for this `user_id` only. Ignore `HasSelfSetPassword`. + +**204:** no production handler analog. PHP `noContent()` → `w.WriteHeader(http.StatusNoContent)` with empty body. Do **not** copy `APITokenDestroy`'s `200 {"data":{"revoked":true}}`. + +**JSON helper analog:** `USR/controllers/api_controller.go:1025-1028`: +```go +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Cache-Control", "no-cache, private") + wire.WriteJSON(w, status, v) +} +``` + +Same-package `writeJSON` (not a second encoder). `linked_at` uses `*wire.Time` (`FW/modules/wire/response.go:41-43`). + +**Options shape (discretion, RESEARCH):** +```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 { ... } +``` + +Do **not** add a `Mount` helper that imports `FON/controllers/api`. Allow-list miss → same `WriteNotFound` as missing/foreign row. No `surf.Where("provider", ...)`. + +**Nullable Carbon analog:** `FON/controllers/api/wishlist_subscriptions_controller.go:170,194-196`: +```go +SubscribedAt *wire.Time `json:"subscribed_at"` +if sub.SubscribedAt != nil { + e.SubscribedAt = &wire.Time{Time: sub.SubscribedAt.UTC()} +} +``` + +--- + +### `USR/lang/{en,pl}/lang.yaml` (config) — plan 01 + +**Analog:** existing files. EN starts: +```yaml +account: + invalid_login: Invalid email or password +``` + +Add a sibling `oauth:` group (D-03), not under `account:`: +```yaml +oauth: + last_method_blocked: This is the only remaining way to sign in. Link another method before disconnecting this one. +``` + +PL (byte-identical to PHP `golem15.fonoteka::lang.oauth.last_method_blocked`): +```yaml +oauth: + last_method_blocked: To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę. +``` + +Key consumed as `golem15.user::lang.oauth.last_method_blocked`. + +--- + +### `USR/README.md` (docs) — plan 01 + +**Analog:** itself. Overview sentence listing tables (`README.md:15`) currently names `users` extensions, `user_throttle`, `jwt_blacklist`, `user_api_tokens`, organisations, groups, frontend permissions — add `golem15_user_oauth_identities`. + +API reference table (`README.md:85-104`) lists only `/_user/api/v1` routes. Add an **exported, host-mounted** subsection for `OAuthIdentitiesIndex` / `OAuthIdentitiesDestroy` / `OAuthIdentitiesOptions`. Do **not** add those paths to the `/_user/api/v1` table (D-02). Framework READMEs never name a consuming application — say "the host application" and a path the host chooses. + +Migration table (`README.md:128-134`): add `202610050001_create_oauth_identities` (or the ID you ship) with the table, unique indexes, cascade FK, jsonb `profile_data`, Encrypted tokens. + +Models list (`README.md:149`): add `OAuthIdentity`. + +--- + +### `FON/routes.go` (route) — plan 01 + +**Analog:** JWT group + inline throttle + no-Where-on-opaque-id. + +**Group middleware** (line 48): +```go +r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password"), func(g pact.Router) { +``` + +**Throttle string** (lines 60, 91): +```go +g.Post("/import/csv", api.CsvImportStore(p.app), "throttle:10,1") +g.Post("/collections/{id}/switch", api.CollectionSwitch(p.app), "throttle:10,1") +``` + +DELETE identities uses the same `"throttle:10,1"` argument. GET identities has no extra throttle. + +**Do not add `g.Where("provider", "google|facebook|github")`.** Precedent: oauth `request_id` was loosened so the controller 404 is reachable (`routes.go:238-254`). `surf.Where`/`constrain` answers `http.NotFound` (`text/plain` `404 page not found\n`). + +**Personal-token group stays unchanged** (lines 262-265): +```go +r.Group("/api/v1/fonoteka", surf.Use("inv_token", "throttle:fonoteka-api-token"), func(g pact.Router) { + g.Get("/me", api.MeToken(p.app), "inv.scope:read") +``` + +Do not mount oauth-identities here. + +**Host wiring (discretion):** import `git.golem15.com/golem15/sm-user-plugin/controllers` as `userctrl`. Inside the JWT group: +```go +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") +``` + +--- + +### `FON/controllers/api/me_token_controller.go` (controller) — plan 01 + +**Analog:** itself, lines 20-47. Change only `collection_ids`. + +Today (lines 41-46) — **do not keep Slice on collection_ids**: +```go +wire.WriteJSON(w, http.StatusOK, map[string]any{"data": map[string]any{ + "scopes": wire.Slice(scopes), + "collection_ids": wire.Slice(collectionIDs), + "user_id": userID, + "name": name, +}}) +``` + +D-09: if `!token.CollectionIDs.Valid || len(token.CollectionIDs.Get()) == 0` emit JSON `null`; else the int list. `scopes` stays `wire.Slice`. Comment on lines 18-19 ("never serialize as null") is superseded for `collection_ids` only. + +**Null-when-invalid analog:** `FW/modules/wire/response.go:70-80` `TriBool` (`Valid=false` marshals `null`). Implementation can use `any`/`nil` in the map rather than a new type. + +Auth: keep `bouncer.User` + `bouncer.Credential` already on context; never re-parse the bearer (lines 25-38). + +--- + +### Parity files — plan 01 + +**`APP/parity/manifest.yaml` analog:** pending blocks at lines 2801-2836 and 3406-3422. Flip `status: pending` → `ported` on all three route ids. Add D-10 cases (list-with-rows; unrestricted `/me`) without deleting the empty GET, missing DELETE, or restricted `/me` cases. + +**`APP/parity/parity_test.go` analog:** +- Counts (lines 28-29): `expectedPHPRoutes = 175`, `expectedPortedRoutes = 172` → `175` / `175`. +- `assertPortedMismatch` (lines 456-501) currently requires GET oauth-identities replay to fail 200 vs 404. After D-12 that helper fatals `"unported PHP route must not pass"`. +- Replacement analog: `assertPortedMutationCaught` (lines 503-529) — load a **ported** fixture, wrap the handler with `mutateAlbumCount`, assert `tide.MismatchError`. Do not keep a pending-route probe. + +**`APP/parity/fonoteka_seed_test.go` analog** (`fonotekaCaseExtras` lines 34-80): keys are `"#"` or a reused case id. Add extras for the new list-with-rows and unrestricted-`/me` cases. Leave empty GET / missing DELETE / restricted `/me` unmapped (plain reset). + +**`APP/parity/fonoteka_reset.php` analog** (lines 119-145): `$extras` from `PARITY_CASE`; named extras insert rows. Add the same extra names as Go. Seed alice `facebook`+`google` identities **with token/profile values** so the GET fixture proves secrets do not leak. Unrestricted `/me`: mint a **second** alice token with SQL NULL/empty `collection_ids`; do not mutate `mcp-read`. + +**Fixture analog:** `APP/parity/fixtures/routes/GET___fonoteka_api_v1_oauth-identities_jwt.yaml` (empty `{"data":[]}`, `Cache-Control: no-cache, private`). DELETE missing-row fixture is Winter HTML (`text/html; charset=UTF-8`, title `Nie znaleziono strony`) — new 404 tests must match those bytes, not JSON. + +--- + +### `USR/oauth_identities_test.go` (test) — plan 02 + +**Analog:** `USR/api_tokens_test.go` lines 82-113 (httptest + `bouncer.WithUser` + call constructor directly) and `USR/api_tokens_edge_test.go` lines 87-102 (foreign destroy 404, list does not leak other users' rows). + +Copy: +- `sessionApp(t, ...)`, `insertUser`, `httptest.NewRecorder`, `controllers.X(app).ServeHTTP` +- Secret-leak assertion: `strings.Contains(listed.Body.String(), secret)` must be false (`api_tokens_test.go:111-112`) +- Foreign vs owner: same 404 status (`api_tokens_edge_test.go:92-94`) — identities also require **byte-identical** Winter HTML bodies + +Port PHP `OAuthIdentityApiTest.php` behaviours (D-11): 204 keeps the other row; last-method 409 EN/PL against the lang strings; missing/foreign/unknown provider Winter 404; 401 without JWT on `/google` (not `/linkedin` — unauthenticated unknown provider is 401 because `jwt.auth` runs first). Do not consult `has_self_set_password` in assertions except to prove 409 still fires when the user has a password. + +Prefer plugin-root `USR/oauth_identities_test.go` (same package `user` as `api_tokens_test.go`) over `controllers/` unless the planner wants an internal test of unexported helpers. + +--- + +### `USR/updates/oauth_identities_test.go` (test) — plan 02 + +**Analog:** `USR/updates/api_tokens_test.go` lines 11-42 + +```go +sqlDB := dedicatedDB(t, "user_api_tokens") +db, err := lagoon.Use(t.Context(), sqlDB) +m := gormigrate.New(db, &gormigrate.Options{TableName: "summer_migrations_golem15_user", UseTransaction: true}, All()) +if err := m.Migrate(); err != nil { t.Fatal(err) } +if !db.Migrator().HasTable((&models.APIToken{}).TableName()) { t.Fatal(...) } +// HasColumn loop +if err := m.RollbackMigration(apiTokenMigrations[0]); err != nil { t.Fatal(err) } +``` + +Reuse `dedicatedDB` from `USR/updates/postgres_test.go` (testcontainers, skip on `-short`). Extra assertions vs analog: unique index names, `profile_data` udt `jsonb`, FK on `user_id` → `users(id)` ON DELETE CASCADE. Type check analog: `information_schema.columns` query at `api_tokens_test.go:29-35`. + +--- + +### `FON/controllers/api/me_token_controller_test.go` (test) — plan 02 + +**Analog:** itself. Rewrite `TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName` (lines 78-117). Today it forbids `"collection_ids":null` (lines 113-116). After D-09: `scopes` still `[]`; `collection_ids` **must** be JSON null when `!Valid` or `len==0`. Keep `TestMeTokenHandlerExactFourFieldsWithScopesAndCollectionIDs` (restricted list of ints) and the no-requery test. + +Setup analog (`meTokenRequest`, lines 19-24): `bouncer.WithUser` + `bouncer.WithCredential` — do not go through HTTP auth. + +--- + +### `FON/phase08_coverage_test.go` (test) — plan 02 + +**Replace** `assertAbsent(t, "oauth-identit", "identities")` at lines 663-666. + +**Analog:** `assertRouteSurfaces` lines 1017-1049: +```go +func assertRouteSurfaces(t *testing.T, rt []surf.RouteInfo, method, suffix string, wantJWT, wantToken bool) { + // matches "/_fonoteka/api/v1"+suffix with jwt.auth + // matches "/api/v1/fonoteka"+suffix with exactly one inv.scope +} +``` + +Calls: +```go +assertRouteSurfaces(t, rt, http.MethodGet, "/oauth-identities", true, false) +assertRouteSurfaces(t, rt, http.MethodDelete, "/oauth-identities/{provider}", true, false) +``` + +Throttle: extend a middleware contains-check (CSV import already jwt-only). DELETE must list `throttle:10,1`. JWT-only matches PHP `TokenSurfaceIsolationTest`. + +--- + +## Shared Patterns + +### Authentication (JWT group, do not re-parse) + +**Source:** `FON/routes.go:48`; `USR/controllers/api_tokens.go:123-127`; `FW` implied `bouncer.write401` +**Apply to:** `oauth_identities.go` Index/Destroy; fonoteka mount + +Handlers read `bouncer.User(r.Context())`. 401 without JWT is the group's `jwt.auth`, envelope `{"error":true,"message":...}` + `Cache-Control: no-cache, private`. D-11 401 tests use `/google` so the allow-list is not involved. + +### Winter HTML 404 (host-injected) + +**Source:** `FON/controllers/api/http_errors.go:44-73` +**Apply to:** Destroy allow-list miss, missing row, foreign row (byte-identical) + +```go +func writeWinterHTTPError(w http.ResponseWriter, app *backpack.App, status int) { + // embed winter_.html, Origin from app.url + w.Header().Set("Content-Type", "text/html; charset=UTF-8") + w.Header().Set("Cache-Control", "no-cache, private") + w.WriteHeader(status) +} +func WriteWinterHTTPError(w http.ResponseWriter, app *backpack.App, status int) { + writeWinterHTTPError(w, app, status) +} +``` + +**Anti-pattern:** `USR/controllers/api_controller.go:1073-1077` `writeWinterErrorPage` always writes **500**. Do not reuse it for identity 404s. + +### JSON responses + Carbon time + empty arrays + +**Source:** `USR/controllers/api_controller.go:1025-1028`; `FW/modules/wire/response.go:10-23, 32-43, 83-89` +**Apply to:** Index `{"data":[...]}`; 409 `{"error":...}`; `/me` scopes + +```go +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 +} +``` + +Never `time.RFC3339` / `Z` for `linked_at`. + +### Encrypted secrets stay off the wire + +**Source:** `FW/modules/lagoon/encrypted.go:49-52`; `FON/models/user_discogs_credential.go` `json:"-"` +**Apply to:** model tags; Index map; D-10 fixture with secrets; plan-02 leak tests + +```go +func (e Encrypted) MarshalJSON() ([]byte, error) { + return json.Marshal(redactedLiteral) // "[redacted]" +} +``` + +Explicit map only. Seed via `lagoon.NewEncrypted` under the test app key. Do not `Reveal()` in logs. + +### Phrasebook keys + +**Source:** `USR/controllers/api_controller.go:1066` `golem15.user::lang.account.`+key +**Apply to:** 409 last-method text + +I18N-01: `golem15.user::lang.oauth.last_method_blocked`. Do not hard-code English in the handler. + +### Exported constructors, host mount + +**Source:** `USR/controllers/api_tokens.go` constructors + `USR/routes.go:26-28` (shows jwt.auth on registration) vs D-02 (host chooses path) +**Apply to:** identity handlers live in USR; mount lives in `FON/routes.go` JWT group + +`USR/routes.go:9-30` stays the `/_user/api/v1` list without oauth-identities. + +### Migration registry + +**Source:** `USR/updates/registry.go`; `USR/models/registry.go`; `USR/plugin.go:207-209` +**Apply to:** new migration + model files only (`init` Register). No `AutoMigrate`. + +### Rate limit + +**Source:** `FON/routes.go:60` `"throttle:10,1"` +**Apply to:** DELETE registration only. No new bucket type. + +## No Analog Found + +| File / concern | Role | Data Flow | Reason | +|----------------|------|-----------|--------| +| `profile_data jsonb` column type | migration | CRUD | sm-user-plugin Jsonable columns are `TEXT`. Use D-07 `jsonb` anyway; Scan still works. | +| HTTP 204 empty body | controller | request-response | No production controller uses `StatusNoContent` (only CORS/tests). Use `w.WriteHeader(http.StatusNoContent)` with empty body; do not copy token destroy's 200 JSON. | + +Planner should still copy surrounding patterns (gormigrate Register, owner-scoped destroy) from the analogs above. + +## Anti-patterns (do not copy) + +- `g.Where("provider", "google|facebook|github")` → text/plain 404; also 404s unauthenticated unknown providers before `jwt.auth`. +- JSON 404 with PHP's `OAuth identity not found`. +- `wire.Slice` on `/me` `collection_ids`. +- Registering identity routes on `/_user/api/v1`. +- Consulting `HasSelfSetPassword` on unlink. +- Serializing the GORM model (Encrypted `"[redacted]"` + `profile_data`). +- `USR` `writeWinterErrorPage` for 404. +- Leaving `assertPortedMismatch` pointed at GET oauth-identities. +- Leaving `phase08` `assertAbsent("oauth-identit")`. +- Winter-import mapper / social login (D-08 / deferred). +- Framework module API changes in `summercms.go` (none expected). + +## Metadata + +**Analog search scope:** `USR/{models,updates,controllers,lang,README,routes,plugin.go,api_tokens*_test.go}`, `FON/{routes.go,controllers/api,models,phase08_coverage_test.go,updates/11_secrets_slice.go}`, `APP/parity/{parity_test.go,fonoteka_seed_test.go,fonoteka_reset.php,manifest.yaml,fixtures/routes}`, `FW/modules/{wire,lagoon}` +**Files scanned:** ~40 user-plugin sources, ~15 fonoteka/parity/framework analogs read in full +**Pattern extraction date:** 2026-10-05 +**Git-tracked gate:** analogs verified via `git ls-files` in `summercms.go`, `fonoteka.go`, and `plugins/golem15/user`