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