26 KiB
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.goAPP=/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.goFON=APP/plugins/golem15/fonotekaUSR=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):
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:
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:
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:
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_uniqueon(user_id, provider)oauth_identities_provider_identity_uniqueon(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):
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):
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:
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):
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:
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:
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):
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:
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:
account:
invalid_login: Invalid email or password
Add a sibling oauth: group (D-03), not under account::
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):
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):
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):
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):
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:
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:
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 withmutateAlbumCount, asserttide.MismatchError. Do not keep a pending-route probe.
APP/parity/fonoteka_seed_test.go analog (fonotekaCaseExtras lines 34-80): keys are "<route id>#<case id>" 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
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:
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:
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)
func writeWinterHTTPError(w http.ResponseWriter, app *backpack.App, status int) {
// embed winter_<status>.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
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
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 beforejwt.auth.- JSON 404 with PHP's
OAuth identity not found. wire.Sliceon/mecollection_ids.- Registering identity routes on
/_user/api/v1. - Consulting
HasSelfSetPasswordon unlink. - Serializing the GORM model (Encrypted
"[redacted]"+profile_data). USRwriteWinterErrorPagefor 404.- Leaving
assertPortedMismatchpointed at GET oauth-identities. - Leaving
phase08assertAbsent("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