docs(14.1): add pattern map
This commit is contained in:
@@ -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 `"<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
|
||||||
|
|
||||||
|
```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_<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
|
||||||
|
|
||||||
|
```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`
|
||||||
Reference in New Issue
Block a user