Files
summercms/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-PATTERNS.md
2026-10-05 19:29:31 +02:00

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.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):

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_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):

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 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 "<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 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