48 KiB
Phase 14.1: oauth-identities-and-fonoteka-me-routes - Research
Researched: 2026-10-05
Domain: JWT social-identity list/unlink parity and personal-token /me contract close-out
Confidence: HIGH
<user_constraints>
User Constraints (from CONTEXT.md)
CRITICAL: If CONTEXT.md exists from $gsd-discuss-phase, copy locked decisions here verbatim. These MUST be honored by the planner.
Locked Decisions
Where the identity code lives
-
D-01: The
OAuthIdentitymodel and thegolem15_user_oauth_identitiesmigration live in sm-user-plugin. This mirrors PHP, where Golem15.User v3.3.0 owns the table. The change to the shared plugin is additive only. — Reversibility: costly — once shipped in a shared plugin, the table name and schema are a contract for every app that uses the plugin and for the Phase 15 import. -
D-02: The list and unlink handlers also live in sm-user-plugin, as a reusable API. This departs from PHP, where the controller sits in the fonoteka plugin as a "D-15 compromise". The user plugin exposes the handlers, and the fonoteka app or plugin mounts them at
/_fonoteka/api/v1/oauth-identitieson the JWT group, with PHP's middleware: JWT auth on both routes andthrottle:10,1on DELETE. The user plugin must not register these routes under its own/_user/api/v1group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, whichroutes.snapshotchecks. — Reversibility: costly — it adds exported API surface to a shared plugin that other apps may start to depend on. -
D-03: The 409 "last method" message moves to a new key in the user plugin's lang files. The EN and PL texts are identical to PHP's
golem15.fonoteka::lang.oauth.last_method_blocked:- EN: "This is the only remaining way to sign in. Link another method before disconnecting this one."
- PL: "To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę."
A host can override the text through the normal lang override.
-
D-04: The DELETE provider whitelist is a mount option. It defaults to
google,facebookandgithub, and the host can narrow or extend it. A provider outside the list returns the same 404 that PHP's route constraint produces. The researcher confirms PHP's exact bytes for a constraint miss compared with a missing row. -
D-05: The responses are built from an explicit map (
provider, andlinked_atas an ISO-8601 string or null), never by serializing the model. The model carries encrypted tokens and profile data. The same rule applies in PHP. -
D-06: Unlink is fail-closed, as in PHP. The last remaining identity is refused with 409 even when the account also has a password. Only the identity count decides it, and
has_self_set_passwordplays no part.
Columns and import
- D-07: The Go table and model carry the full PHP column set:
id,user_id(FK tousers, cascade delete),provider(50),provider_id(255),access_tokenandrefresh_token(bothlagoon.Encrypted),token_expires_at,profile_data(jsonb),linked_at,created_atandupdated_at. Both unique indexes are kept:(user_id, provider)and(provider, provider_id). The PHP backfill from the legacyusers.oauth_*columns is not ported, because Phase 15 imports the rows directly. — Reversibility: one-way — the migration ships in a shared plugin and becomes the import target in Phase 15. - D-08: No Winter-import mapper is written in this phase. Phase 15 (D-02/D-03) adds the
HasWinterImportmappers for every user-plugin table, including the Laravel decrypt and the GCM re-encrypt of the two token columns.
/api/v1/fonoteka/me contract
- D-09: The full contract is
{"data":{"scopes","collection_ids","user_id","name"}}. The one gap in today's Go handler (me_token_controller.go) iscollection_ids. PHP'sApiToken::collectionIds()returnsnullfor an unrestricted token (no bound collections), but Go always emits[]throughwire.Slice. Go must emitnullwhen the token has no collection binding and a list of ints otherwise.scopeskeeps the PHP fallback[]. This is a parity fix, not a shape change.
Parity coverage
-
D-10: New recorded PHP cases:
- List with linked rows: alice is seeded with
facebookandgoogleidentities, which exercises ordering by provider, the{provider, linked_at}shape and thelinked_atformat. The seeded rows carry token and profile values, so the fixture shows that no secret leaks. /mewith an unrestricted token:"collection_ids": null.
The existing cases stay: GET with an empty list, DELETE with a missing row (404), and
/mewith a restricted token. Seeding goes through the parity harness'sseed_hookmechanism, on both the PHP recording side and the Go replay side. - List with linked rows: alice is seeded with
-
D-11: The remaining identity behaviours are covered by Go tests ported from
OAuthIdentityApiTest.php: unlink returns 204 and keeps the other row, last-method returns 409 with the EN/PL text asserted against the PHP lang strings, foreign and missing rows give byte-identical 404s, an unknown provider gives 404, and both routes give 401 without a JWT. These tests go in the phase's final unit-test plan, together with full coverage of the new user-plugin and fonoteka code. Tests also check that the routes are on the JWT group and that the personal-token group/api/v1/fonotekanever gains them (PHP'sTokenSurfaceIsolationTest). -
D-12: All three manifest entries flip from
pendingtoported. Phase 15's preflight then sees zero pending routes.
Social-login-only accounts at cutover
- D-13: No code in this phase. The lockout risk is recorded for the Phase 15 preflight. With social login deferred, a user whose only sign-in method is an OAuth identity cannot log in on Go: PHP's random password means
has_self_set_password = false, and the Nuxt app has no recovery screen. The preflight counts such users in the production dump. If the count is non-zero, the cutover either gives them a password first (for example through the user plugin'sforgot-passwordflow, triggered by hand) or social login gets its own phase before the swap.
Folded Todos
orphan-pending-routes.md("Give oauth-identities and /api/v1/fonoteka/me a roadmap phase before cutover"): this phase is that home. The todo moves to done when the phase completes.
Claude's Discretion
- The exported API shape in sm-user-plugin: a handler constructor with options, a small
Mount(router, opts)helper, or a separate sub-package. - How the fonoteka side wires the mount, and where the throttle name for
throttle:10,1is declared. - The Go timestamp formatting needed to match Laravel's
toIso8601String()(+00:00offset, notZ). The researcher confirms the exact format against the recorded fixture. - The plan split, subject to the CLAUDE.md lean-mode rule: unit tests come in the last plan, and the plan count is confirmed before PLAN.md files are written.
- The README and docs updates required by CLAUDE.md for the sm-user-plugin API and any changes to the framework modules.
Deferred Ideas (OUT OF SCOPE)
- Social login port (
/oauth/{provider}redirect and callback, linking a new provider,oauth-complete,oauth-register-complete, the password-bootstrap OTP): still its own phase. It needs an OAuth-client dependency decision. The Phase 15 preflight count (D-13) decides whether it must land before the swap. - Winter-import mapper for
golem15_user_oauth_identities: Phase 15 (D-08). </user_constraints>
<phase_requirements>
Phase Requirements
Requirement IDs were TBD at discuss time. These v1 rows in REQUIREMENTS.md are the ones this phase actually moves:
| ID | Description | Research Support |
|---|---|---|
| API-09 | All 154 routes are registered on the correct groups with identical paths, methods, status codes and bodies | Flip the three remaining pending manifest entries to ported. Corpus today is 175 recorded / 172 ported / 3 pending; this phase makes 175/175/0. |
| QA-05 | Cutover: the parity harness is green on all 154 routes and vue-fonoteka-app and fonoteka-mcp run unchanged against the Go backend | Recorded PHP cases plus Go replay; Nuxt ConnectedAccounts.vue and MCP me() stay unchanged. |
| HTTP-01 | Unknown and malformed ids both return 404 on ownership-scoped resources | Missing identity, foreign identity, and unknown provider share Winter HTML 404. |
| HTTP-03 | Three mutually exclusive auth groups share the same handlers with different route subsets | Identity routes JWT-only; /me stays on the personal-token group. |
| HTTP-04 | Rate limiter ports Płytarium's inline throttles 1:1 | DELETE carries throttle:10,1. |
| HTTP-06 | Empty arrays serialize as [], timestamps as +00:00 | scopes stays []; linked_at uses wire.Time; collection_ids is the D-09 exception (null when unrestricted). |
| DATA-02 | Each plugin ships a gormigrate migration set with up and down; AutoMigrate is never the schema source | New timestamped migration in sm-user-plugin updates/. |
| DATA-07 | Custom casts for jsonable columns and encrypted-at-rest secrets | lagoon.Encrypted tokens; lagoon.Jsonable profile_data. |
| I18N-01 | Translation keys use vendor.plugin::group.key |
New golem15.user::lang.oauth.last_method_blocked EN/PL. |
| </phase_requirements> |
Project Constraints (from CLAUDE.md)
- Lean planning: fewer, larger plans. Unit tests are always the last plan of a phase. Confirm plan count with the user before writing PLAN.md.
- Standard library first. Add a dependency only when STACK.md or a locked phase decision names it.
- Compiled plugins registered at build time. No runtime plugin loading.
- API parity is the acceptance test. Do not "improve" response shapes.
- Two repositories:
summercms.gostays app-agnostic; application code lives infonoteka.goandsm-user-plugin. - A change to a module's exported API, config keys or CLI commands updates that module's README (and
docs/when the framework surface changes) in the same change. - Framework READMEs never name a consuming application.
go vetandgo test ./...green at every commit. Planning docs and code in separate commits. No co-author tags.
<research_summary>
Summary
Phase 14.1 is a three-route parity close-out, not a new SSO stack. sm-user-plugin already owns users, JWT, and user_api_tokens; this phase adds golem15_user_oauth_identities plus two exported handlers. The fonoteka plugin mounts those handlers on the existing JWT group at /_fonoteka/api/v1/oauth-identities and does not add them to /_user/api/v1 or /api/v1/fonoteka. GET /api/v1/fonoteka/me is already mounted; the only code change is collection_ids null vs [].
The 404 contract is Winter HTML (text/html; charset=UTF-8, Polish "Nie znaleziono strony"), not JSON and not Go's http.NotFound body. PHP's HttpException(404, 'OAuth identity not found') never appears in the recorded fixture. PHP's {provider} constraint google|facebook|github produces the same production 404 page as a missing row. Go surf.Where / constrain answers with http.NotFound (404 page not found\n). The allow-list therefore belongs in the handler, with the host injecting api.WriteWinterHTTPError.
No new Go modules. Reuse GORM, gormigrate, lagoon.Encrypted, lagoon.Jsonable, wire.WriteJSON / wire.Time / wire.Slice, phrasebook, surf throttle:10,1, and the existing Winter 404 page.
Primary recommendation: Two lean plans — (1) model + migration + exported handlers + fonoteka mount + /me null fix + parity recording/manifest flip in fonoteka.go and sm-user-plugin; (2) unit tests last. Do not add g.Where("provider", ...). Rewrite assertPortedMismatch because it currently proves the GET identity route is unported.
</research_summary>
<architectural_responsibility_map>
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| List linked identities | API / Backend | Database / Storage | JWT-only GET; explicit {provider, linked_at} map from golem15_user_oauth_identities. |
| Unlink one identity | API / Backend | Database / Storage | JWT + throttle:10,1; 204 / Winter 404 / 409 JSON; fail-closed on identity count. |
| Provider allow-list | API / Backend | — | Mount option; unknown provider is the same 404 as a missing row. |
| Encrypted tokens / profile_data | Database / Storage | API / Backend | At-rest AES-GCM and jsonb; never serialized on these routes. |
Personal-token /me |
API / Backend | — | Already on /api/v1/fonoteka; D-09 is a JSON-null fix. |
| Nuxt Connected accounts tab | Browser / Client | — | Unchanged consumer; 409 copy is client-side, status is server. |
fonoteka-mcp me() |
API / Backend | — | Unchanged client; extra collection_ids is ignored by its TypeScript type. |
| Social login / import mapper | — | — | Deferred (D-08, social-login phase). |
| </architectural_responsibility_map> |
<standard_stack>
Standard Stack
No new packages. Versions are the pins already in sm-user-plugin/go.mod [VERIFIED: /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/go.mod:3-14].
Core
| Library | Version | Purpose | Why Standard |
|---|---|---|---|
| Go | 1.27.0 | Language | Project pin; probed go version go1.27.0-X:nodwarf5. |
| GORM | v1.31.2 | ORM | Decided stack; user plugin already requires it. |
| gormigrate/v2 | v2.1.7 | Up/down migrations | DATA-02; updates.Register + updates.All(). |
| lagoon.Encrypted | in-tree | AES-256-GCM columns | DATA-07; redacting marshal. |
| lagoon.Jsonable | in-tree | JSON column Scan/Value | profile_data; Scan works on jsonb even though GormDataType is text. |
| wire | in-tree | JSON, Carbon time, empty slices | HTTP-06; D-09 exception for collection_ids. |
| backpack / pact / surf / bouncer | in-tree | App, plugin, router, JWT | Existing JWT group and 401 writer. |
| phrasebook | in-tree | golem15.user::lang.* |
D-03 409 text. |
Supporting
| Library | Version | Purpose | When to Use |
|---|---|---|---|
| golang-jwt/jwt/v5 | v5.3.1 | JWT (already mounted) | Do not re-parse tokens in identity handlers. |
| testcontainers-go (+ postgres) | v0.44.0 | Real Postgres for migration tests | User plugin updates/*_test.go pattern. |
| testify | v1.12.1 (indirect) | Assertions | Existing assert/require style; keep func Test...(*testing.T). |
| go-i18n/v2 | v2.6.1 (indirect) | CLDR via phrasebook | Do not import directly. |
Alternatives Considered
| Instead of | Could Use | Tradeoff |
|---|---|---|
| Handler constructors + host mount | Mount(router, opts) helper |
A Mount helper that wrote Winter 404 would import fonoteka's api package into the user plugin. Rejected. |
surf.Where("provider", "google|facebook|github") |
Handler allow-list | Where/constrain uses http.NotFound [VERIFIED: modules/surf/params.go:81-89]. That body is not Winter HTML. |
JSON 404 with OAuth identity not found |
Winter HTML 404 | PHP throws that message, but the recorded DELETE fixture is the Polish Winter page. |
wire.Slice for collection_ids |
JSON null when empty | Phase 8 D-20 used Slice on both arrays; D-09 supersedes it for collection_ids only. |
| New throttle bucket | Inline "throttle:10,1" |
JWT group already uses that string on CSV import and collection switch. |
Installation: none. Do not add require lines.
Version verification: pins read from sm-user-plugin/go.mod this session. No registry lookup for new names.
</standard_stack>
Package Legitimacy Audit
This phase installs no external packages.
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---|---|---|---|---|---|---|
| — | — | — | — | — | — | No new packages |
Packages removed due to [SLOP] verdict: none Packages flagged as suspicious [SUS]: none
<architecture_patterns>
Architecture Patterns
System Architecture Diagram
Nuxt ConnectedAccounts / MCP me()
| JWT Bearer | inv_ token Bearer
v v
/_fonoteka/api/v1 (jwt.auth, locale, /api/v1/fonoteka
locale.from-principal, (inv_token, throttle:fonoteka-api-token)
must-change-password)
|
+-- GET /oauth-identities --------> user.OAuthIdentitiesIndex
| | query user_id, ORDER BY provider
| | map {provider, linked_at: *wire.Time}
| v
| 200 {"data":[...]} secrets never in map
|
+-- DELETE /oauth-identities/{provider} throttle:10,1
| 1. jwt.auth (no JWT -> 401 JSON)
| 2. provider in allow-list? else Winter HTML 404
| 3. row for (user_id, provider)? else Winter HTML 404
| 4. count(*)==1? -> 409 {"error": phrasebook}
| 5. delete -> 204 empty
v
Postgres golem15_user_oauth_identities
(Encrypted tokens, jsonb profile_data)
Personal-token GET /me
| bouncer.Credential (*models.ApiToken) already matched
| scopes: wire.Slice (nil -> [])
| collection_ids: null if !Valid or len==0, else list of ints
v
200 {"data":{scopes, collection_ids, user_id, name}}
Recommended Project Structure
fonoteka.go/plugins/golem15/user/ # sm-user-plugin
├── models/oauth_identity.go # TableName, Encrypted, Jsonable, init Register
├── updates/202610050001_create_oauth_identities.go
├── updates/oauth_identities_test.go
├── controllers/oauth_identities.go # Index + Destroy constructors
├── lang/en/lang.yaml # oauth.last_method_blocked
├── lang/pl/lang.yaml
└── README.md # exported API + table
fonoteka.go/plugins/golem15/fonoteka/
├── routes.go # JWT mount; no Where on provider
├── controllers/api/me_token_controller.go # D-09 collection_ids null
└── phase08_coverage_test.go # assertRouteSurfaces jwt-only
fonoteka.go/parity/
├── manifest.yaml # three pending -> ported; extra cases
├── parity_test.go # 175/175; rewrite assertPortedMismatch
├── fonoteka_seed_test.go # extras oauth-identities / me-unrestricted
├── fonoteka_reset.php # same extras
└── fixtures/routes/ # keep empty GET, missing DELETE, restricted /me;
# add list-with-rows and unrestricted /me
Pattern 1: Exported handlers, host mount (D-02)
What: sm-user-plugin exports constructors. Fonoteka mounts them on the JWT group. The user plugin's /_user/api/v1 group stays unchanged [VERIFIED: fonoteka.go/plugins/golem15/user/routes.go:9-30].
When to use: Shared plugin owns the table; host owns the public path and Winter 404 page.
Recommend (discretion):
// sm-user-plugin/controllers/oauth_identities.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 { /* ... */ }
Fonoteka JWT group [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/routes.go:48]:
r.Group("/_fonoteka/api/v1", surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password"), func(g pact.Router) {
// ...
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")
})
Default providers match PHP [VERIFIED: fonoteka/plugins/golem15/fonoteka/routes.php:321]: ->where('provider', 'google|facebook|github')->middleware('throttle:10,1').
Do not add a Mount helper that imports fonoteka/controllers/api. The user plugin cannot take that dependency.
Pattern 2: Explicit response map (D-05)
PHP index [VERIFIED: fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php:31-39]:
$values = OAuthIdentity::where('user_id', $user->id)
->orderBy('provider')
->get()
->map(static fn (OAuthIdentity $identity) => [
'provider' => (string) $identity->provider,
'linked_at' => $identity->linked_at?->toIso8601String(),
]);
return response()->json(['data' => $values->values()]);
Go: wire.WriteJSON of map[string]any{"data": rows} where each row is map[string]any{"provider": ..., "linked_at": (*wire.Time | nil)}. Never json.Marshal the GORM model. lagoon.Encrypted.MarshalJSON redacts to "[redacted]" [VERIFIED: modules/lagoon/encrypted.go:49-52], which would still leak the key name if the model were serialized. PHP tests forbid access_token, refresh_token, profile_data, user_id, and the plaintext secret in the body [VERIFIED: fonoteka/plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php:50-55].
Pattern 3: Fail-closed unlink (D-06)
PHP destroy [VERIFIED: fonoteka/plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php:52-68]:
$identity = OAuthIdentity::where('user_id', $user->id)->where('provider', $provider)->first();
if (!$identity) { throw new HttpException(404, 'OAuth identity not found'); }
if (OAuthIdentity::where('user_id', $user->id)->count() === 1) {
return response()->json(['error' => Lang::get('golem15.fonoteka::lang.oauth.last_method_blocked')], 409);
}
$identity->delete();
return response()->noContent();
Go must count identities only. User.HasSelfSetPassword exists [VERIFIED: fonoteka.go/plugins/golem15/user/models/user.go:27] and must not be read. 409 body: {"error": <phrasebook Get>} with key golem15.user::lang.oauth.last_method_blocked (D-03). EN/PL strings [VERIFIED: fonoteka/plugins/golem15/fonoteka/lang/en/lang.php:135-137] and [VERIFIED: fonoteka/plugins/golem15/fonoteka/lang/pl/lang.php:135-137]:
- EN:
This is the only remaining way to sign in. Link another method before disconnecting this one. - PL:
To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę.
204: empty body, status 204 (PHP noContent()).
Pattern 4: Winter HTML 404, not router 404
Recorded missing-row DELETE [VERIFIED: fonoteka.go/parity/fixtures/routes/DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt.yaml:13-31]: status 404, Content-Type: "text/html; charset=UTF-8", title Nie znaleziono strony. That page is winter_404.html [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/controllers/api/winter_404.html:1-14]. Host writers: WriteWinterHTTPError [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/controllers/api/http_errors.go:44-72].
The user plugin's writeWinterErrorPage always writes 500 [VERIFIED: fonoteka.go/plugins/golem15/user/controllers/api_controller.go:1073-1077]. Do not reuse it for identity 404s.
PHP constraint miss and missing row are the same Winter production 404 page (this session: fixture is missing-row; Laravel unmatched {provider} also renders that page). Go constrain [VERIFIED: modules/surf/params.go:85-89]: http.NotFound(w, r). routes.snapshot records path+method+auth group only, not the regex [VERIFIED: fonoteka.go/parity/routes.snapshot:101-102]:
GET /_fonoteka/api/v1/oauth-identities jwt
DELETE /_fonoteka/api/v1/oauth-identities/{provider} jwt
Precedent: oauth request_id constraint was loosened so the controller 404 is reachable [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/routes.go:238-254].
Pattern 5: D-09 /me collection_ids null
PHP [VERIFIED: fonoteka/plugins/golem15/fonoteka/models/ApiToken.php:89-94]:
public function collectionIds(): ?array
{
$ids = $this->collection_ids;
return is_array($ids) && $ids !== [] ? array_values(array_map('intval', $ids)) : null;
}
PHP me [VERIFIED: fonoteka/plugins/golem15/fonoteka/controllers/api/MeTokenController.php:29-36]: 'scopes' => $token?->scopes ?? [], 'collection_ids' => $token?->collectionIds().
Today's Go [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go:41-46] uses wire.Slice on both. Change only collection_ids: if !token.CollectionIDs.Valid || len(token.CollectionIDs.Get()) == 0 emit JSON null; else emit the int list. Keep scopes as wire.Slice. Rewrite TestMeTokenHandlerNilScopesAndCollectionIDsSerializeAsEmptyArraysAndNullableName [VERIFIED: me_token_controller_test.go:113-117], which currently forbids "collection_ids":null.
Restricted fixture stays collection_ids:[{{id:token}}] [VERIFIED: GET__api_v1_fonoteka_me_personal_token.yaml:19]. MCP MeResponse only types scopes, user_id, name [VERIFIED: fonoteka/fonoteka-mcp/src/client.ts:54-59]; extra collection_ids is ignored.
Pattern 6: Migration + model registration
Follow 202610040001_create_api_tokens.go [VERIFIED: lines 7-36]: tx.Exec DDL, init() { Register(...) }, postgres test via gormigrate.New(..., TableName: "summer_migrations_golem15_user", UseTransaction: true). plugin.go already returns updates.All() / models.All() [VERIFIED: plugin.go:207-209]. New model init() { Register(OAuthIdentity{}) } like APIToken.
Table [VERIFIED: PHP migration create_oauth_identities_table.php:14-36]: golem15_user_oauth_identities; unique oauth_identities_user_provider_unique on (user_id, provider); unique oauth_identities_provider_identity_unique on (provider, provider_id); FK user_id → users(id) ON DELETE CASCADE. Do not port the PHP users.oauth_* backfill (D-07).
profile_data jsonb (D-07) even though Jsonable.GormDataType returns text [VERIFIED: modules/lagoon/jsonable.go:84-85]. Use SQL jsonb in the migration; Jsonable.Scan accepts []byte from Postgres jsonb.
Anti-Patterns to Avoid
- Serializing the model: leaks Encrypted keys as
"[redacted]"andprofile_data. g.Where("provider", ...): text/plain 404, not Winter HTML; also 404s unauthenticated unknown providers beforejwt.auth(PHP constraint is pre-auth; D-11 401 tests use/google).- JSON 404 with the HttpException message: not what PHP production recorded.
wire.Sliceoncollection_ids: contradicts D-09.- Registering identity routes on
/_user/api/v1: D-02 forbids it. - Consulting
has_self_set_passwordon unlink: D-06. - Winter-import mapper / social login: D-08 / deferred.
- Leaving
assertPortedMismatchpointed at GET oauth-identities: it currently requires that replay to fail with 200 vs 404 [VERIFIED:parity_test.go:456-500]. </architecture_patterns>
<dont_hand_roll>
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| AES at rest | Custom cipher | lagoon.Encrypted |
Key derivation, previous-key Scan, redacting marshal already exist. |
| JSON column | json.RawMessage + ad-hoc null |
lagoon.Jsonable[map[string]any] |
Valid vs empty vs SQL NULL. |
Carbon +00:00 |
time.RFC3339 / Z |
wire.Time |
Layout 2006-01-02T15:04:05 + +00:00 [VERIFIED: modules/wire/response.go:32-43]. |
| Winter 404 HTML | String-concat PHP page | api.WriteWinterHTTPError |
Origin substitution, headers, embed. |
| 401 JSON | New envelope | Group jwt.auth → bouncer.write401 |
{"error":true,"message":...} + Cache-Control: no-cache, private [VERIFIED: modules/bouncer/jwt.go:367-373]. |
| 409 locale strings | Hard-coded English | phrasebook Get |
I18N-01; D-03 key in user lang YAML. |
| Empty JSON array | Custom nil check for scopes | wire.Slice |
HTTP-06 for scopes and GET empty data. |
| Migrations | GORM AutoMigrate | gormigrate tx.Exec |
DATA-02. |
| Rate limit | New bucket type | surf "throttle:10,1" |
Same string as CSV import [VERIFIED: routes.go:60]. |
Key insight: The parity bytes already exist. The work is wiring PHP's controller into the Go plugin split without inventing a second 404 writer or a second crypto path. </dont_hand_roll>
Runtime State Inventory
This phase adds a table and ports routes. It does not rename existing keys. Inventory is for the new schema and secrets.
| Category | Items Found | Action Required |
|---|---|---|
| Stored data | New table golem15_user_oauth_identities (PHP already has it in production dumps). Unique indexes and cascade FK as in PHP. No backfill from users.oauth_*. |
Code: gormigrate in sm-user-plugin. Data: Phase 15 import (out of scope). Empty on fresh Go DBs until seed/import. |
| Live service config | None — no n8n/Datadog/Centrifugo config keys for these routes. | None — verified: routes are in-process HTTP. |
| OS-registered state | None — no systemd/cron names for oauth-identities. | None — verified: prune-notifications is unrelated. |
| Secrets/env vars | access_token / refresh_token columns; app.key already required for lagoon.Encrypted. No new env names. Laravel ciphertext is not decryptable by GCM until Phase 15. |
Code: Encrypted columns. Do not decrypt PHP rows in this phase (D-08). Seed tests use lagoon.NewEncrypted under the test app key. |
| Build artifacts | sm-user-plugin submodule; routes.snapshot already lists the three paths. |
Rebuild via normal go test / air. Snapshot does not need regex. expectedPortedRoutes 172→175. |
Nothing found in category: Live service config and OS-registered state — none for this surface.
<common_pitfalls>
Common Pitfalls
Pitfall 1: Router constraint vs Winter 404
What goes wrong: g.Where("provider", "google|facebook|github") returns 404 page not found\n (text/plain).
Why it happens: constrain calls http.NotFound [VERIFIED: modules/surf/params.go:85-89].
How to avoid: No Where on {provider}. Check the allow-list in Destroy; call host WriteNotFound.
Warning signs: Parity DELETE unknown-provider (Go unit test) fails Content-Type or body; unauthenticated /linkedin 404s instead of 401.
Pitfall 2: HttpException message vs recorded 404
What goes wrong: JSON {"message":"OAuth identity not found"}.
Why it happens: PHP throws HttpException(404, 'OAuth identity not found') [VERIFIED: OAuthIdentityApiController.php:56-58], but Winter production renders HTML. Recorded fixture is HTML [VERIFIED: DELETE fixture lines 13-31].
How to avoid: Always Winter HTML for missing, foreign, and unknown provider. Foreign and missing bodies must be byte-identical [VERIFIED: OAuthIdentityApiTest.php:101-113].
Warning signs: Nuxt treats 404 as JSON parse error; parity HTML diff.
Pitfall 3: wire.Slice on collection_ids
What goes wrong: unrestricted /me emits "collection_ids":[].
Why it happens: Phase 8 D-20 comment says arrays never serialize as null [VERIFIED: me_token_controller.go:18-19]. D-09 supersedes that for this field only.
How to avoid: Null when invalid or empty; Slice only for scopes. Update the nil-array unit test.
Warning signs: D-10 unrestricted fixture fails; MCP still works (it ignores the field).
Pitfall 4: linked_at as Z
What goes wrong: "2026-10-05T12:00:00Z" vs Carbon +00:00.
Why it happens: time.Time RFC3339. Existing GET fixture is empty {"data":[]} [VERIFIED: GET oauth-identities fixture line 18], so D-10 recording pins the format.
How to avoid: wire.Time [VERIFIED: modules/wire/response.go:41-43]: t.UTC().Format("2006-01-02T15:04:05") + "+00:00". Null linked_at stays JSON null.
Warning signs: D-10 list-with-rows fixture timestamp mismatch.
Pitfall 5: assertPortedMismatch still targets GET oauth-identities
What goes wrong: After the route is ported, the helper fatals "unported PHP route must not pass".
Why it happens: Phase 14 used this pending route as the negative probe [VERIFIED: parity_test.go:458-461].
How to avoid: Point it at a synthetic mismatch (the sibling assertPortedMutationCaught pattern) or a deliberately wrong expected status on a ported fixture. There will be zero pending routes (D-12).
Warning signs: TestParityCorpus fails only on this helper after 175/175.
Pitfall 6: phase08 assertAbsent("oauth-identit")
What goes wrong: Surface isolation test fails once routes exist.
Why it happens: Deferred comment [VERIFIED: phase08_coverage_test.go:663-666].
How to avoid: assertRouteSurfaces(t, rt, GET, "/oauth-identities", true, false) and DELETE "/oauth-identities/{provider}" jwt-only, matching PHP [VERIFIED: TokenSurfaceIsolationTest.php:185-189].
Warning signs: test_oauth_identity_routes_exist_on_jwt_surface_only fail.
Pitfall 7: Putting identity routes on /_user/api/v1
What goes wrong: Snapshot and Nuxt break (baseURL is /_fonoteka/api/v1).
Why it happens: Natural place for user-plugin handlers.
How to avoid: D-02; leave user/routes.go unchanged [VERIFIED: lines 9-30].
Warning signs: Nuxt fetchOAuthIdentities 404 [VERIFIED: useFonoteka.ts:501-507].
Pitfall 8: Seed extras only on one side
What goes wrong: PHP recording and Go replay diverge.
Why it happens: D-10 needs alice facebook+google and an unrestricted token. Extras live in both fonoteka_reset.php $extras [VERIFIED: lines 119-128] and fonotekaCaseExtras [VERIFIED: fonoteka_seed_test.go:37-38].
How to avoid: Add the same extra names on both sides; key extras as "<route id>#<case id>". Keep empty GET / missing DELETE / restricted /me.
Warning signs: Empty-list fixture suddenly contains rows; secrets appear in GET body.
Pitfall 9: Manifest counts
What goes wrong: expectedPHPRoutes = 175, expectedPortedRoutes = 172 [VERIFIED: parity_test.go:28-29] left unchanged. Phase 14 gate EXPECTED_PENDING=3 is a Phase 14 artifact, not this phase's gate.
How to avoid: 175 / 175 / 0. Flip three status: pending entries [VERIFIED: manifest.yaml 2801-2805, 2819-2823, 3406-3410].
Warning signs: Corpus summary still prints pending 3.
</common_pitfalls>
<code_examples>
Code Examples
Verified in-repo values. Quotes are the checkable source.
Table, indexes, constraint
// [VERIFIED: fonoteka/plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php:14-36]
Schema::create('golem15_user_oauth_identities', function (Blueprint $table) {
$table->increments('id');
$table->integer('user_id')->unsigned();
$table->string('provider', 50);
$table->string('provider_id', 255);
$table->text('access_token')->nullable();
$table->text('refresh_token')->nullable();
$table->timestamp('token_expires_at')->nullable();
$table->json('profile_data')->nullable();
$table->timestamp('linked_at')->nullable();
$table->timestamps();
$table->unique(['user_id', 'provider'], 'oauth_identities_user_provider_unique');
$table->unique(['provider', 'provider_id'], 'oauth_identities_provider_identity_unique');
$table->foreign('user_id')->references('id')->on('users')->onDelete('cascade');
});
PHP model table name [VERIFIED: OAuthIdentity.php:19]: public $table = 'golem15_user_oauth_identities';
PHP $guarded = ['*'] [VERIFIED: OAuthIdentity.php:21].
Route mount, throttle, providers
// [VERIFIED: fonoteka/plugins/golem15/fonoteka/routes.php:320-321]
Route::get('oauth-identities', ...);
Route::delete('oauth-identities/{provider}', ...)->where('provider', 'google|facebook|github')->middleware('throttle:10,1');
Go JWT group middleware [VERIFIED: fonoteka.go/plugins/golem15/fonoteka/routes.go:48]: surf.Use("jwt.auth", "locale.from-principal", "inv.must-change-password").
Go /me mount [VERIFIED: routes.go:262-265]: r.Group("/api/v1/fonoteka", surf.Use("inv_token", "throttle:fonoteka-api-token"), ...) then g.Get("/me", api.MeToken(p.app), "inv.scope:read").
Empty GET and restricted /me bytes
// [VERIFIED: GET___fonoteka_api_v1_oauth-identities_jwt.yaml:18]
{"data":[]}
// [VERIFIED: GET__api_v1_fonoteka_me_personal_token.yaml:19]
{"data":{"scopes":["read","write","ai"],"collection_ids":[{{id:token}}],"user_id":{{id:token}},"name":"parity-mcp"}}
wire.Time and Slice
// [VERIFIED: modules/wire/response.go:32-43, 83-89]
type Time struct{ time.Time }
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
}
Encrypted redaction
// [VERIFIED: modules/lagoon/encrypted.go:49-52]
func (e Encrypted) MarshalJSON() ([]byte, error) {
return json.Marshal(redactedLiteral) // "[redacted]"
}
Nuxt consumers (unchanged)
// [VERIFIED: vue-fonoteka-app/shared/types/fonoteka.ts:364-371]
export interface OAuthIdentityMeta {
provider: string
linked_at: string | null
}
export interface OAuthIdentitiesResponse {
data: OAuthIdentityMeta[]
}
// [VERIFIED: stores/fonoteka.ts:728-745] 409 uses client copy, not backend English
Suggested Destroy skeleton (planner/executor)
func OAuthIdentitiesDestroy(app *backpack.App, opts OAuthIdentitiesOptions) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
provider := r.PathValue("provider")
if !allowed(opts.Providers, provider) {
opts.WriteNotFound(w, r)
return
}
user, _ := bouncer.User(r.Context())
// lookup by user.ID + provider; missing -> WriteNotFound
// count == 1 -> wire.WriteJSON 409 {"error": tr.Get(ctx, "golem15.user::lang.oauth.last_method_blocked", nil)}
// delete -> w.WriteHeader(204)
}
}
Do not copy this as production without filling DB lookups; names OAuthIdentitiesDestroy, WriteNotFound, golem15.user::lang.oauth.last_method_blocked, providers google|facebook|github, statuses 404/409/204 are locked.
</code_examples>
<sota_updates>
State of the Art
| Old Approach | Current Approach | When Changed | Impact |
|---|---|---|---|
| PHP controller in fonoteka plugin (D-15 compromise) | Handlers in sm-user-plugin, host mount (D-02) | This phase | Shared plugin owns the table API; paths stay /_fonoteka/.... |
Phase 8 D-20: both arrays via wire.Slice |
D-09: collection_ids null when unrestricted |
This phase | Must rewrite the nil-array unit test. |
Identity routes assertAbsent |
assertRouteSurfaces jwt-only |
This phase | phase08 coverage comment is obsolete. |
assertPortedMismatch probes pending GET identities |
Synthetic mismatch on a ported fixture | This phase | Zero pending routes. |
Laravel encrypt() ciphertext |
lagoon.Encrypted AES-GCM |
Existing | Import remap is Phase 15, not here. |
Deprecated/outdated:
- Treating
/meas "minimal bootstrap" with[]for unrestricted collections. - Assuming PHP 404 JSON from
HttpExceptionmessage text. </sota_updates>
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Unauthenticated unknown provider is 401 in Go (jwt.auth first) vs PHP 404 (constraint before middleware). D-11 401 tests use /google. Not a recorded parity case. |
Pitfall 1 | If a future fixture hits /linkedin without JWT, PHP 404 vs Go 401. Keep tests on /google for 401. |
| A2 | Laravel $table->json() on this Postgres is json; D-07 still requires jsonb. Jsonable Scan accepts both. |
Pattern 6 | Column type drift at Phase 15 import if dump is json not jsonb. Planner should CREATE as jsonb per D-07. |
No other [ASSUMED] implementation claims. Timestamp format is verified via wire.Time source; D-10 recording confirms bytes.
Open Questions
-
assertPortedMismatchreplacement- What we know: it requires GET oauth-identities replay to fail 200 vs 404.
- What's unclear: exact replacement helper (synthetic mutation vs drop).
- Recommendation: reuse
assertPortedMutationCaughtstyle; do not keep a pending-route probe after D-12.
-
Unrestricted
/metoken identity- What we know:
mcp-readis restricted (collection_ids:[{{id:token}}]). - What's unclear: token name/store key for the new D-10 case.
- Recommendation: new seed extra mints a second alice token with SQL NULL / empty
collection_ids; do not mutatemcp-read.
- What we know:
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Go | build/test | ✓ | 1.27.0-X:nodwarf5 | — |
| Docker | testcontainers | ✓ | 29.7.2 | — |
| PostgreSQL | migrations, parity | ✓ | psql 18.6 | testcontainers if local cluster busy |
| PHP CLI | D-10 recording | ✓ | 8.5.10 | — |
Missing dependencies with no fallback: none Missing dependencies with fallback: none
Validation Architecture
Test Framework
| Property | Value |
|---|---|
| Framework | Go testing + testcontainers-go v0.44.0 (user plugin); parity tide replay in fonoteka.go/parity |
| Config file | none — go test; gate script is Phase 14's scripts/check-phase14.sh (do not retarget it; this phase is 14.1) |
| Quick run command | go test ./plugins/golem15/user/... ./plugins/golem15/fonoteka/controllers/api/... -count=1 in fonoteka.go workspace |
| Full suite command | go vet ./... && go test ./... -count=1 in summercms.go; same plus ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... in fonoteka.go |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| API-09 | Three routes ported, 0 pending | parity | go test ./parity -run TestParityCorpus -count=1 |
✅ extend |
| HTTP-03 / D-11 | JWT-only identities; token group never gains them | unit | go test ./plugins/golem15/fonoteka -run 'TokenSurface|oauth_identity' -count=1 |
✅ flip assertAbsent |
| HTTP-01 / D-04 | Missing, foreign, unknown provider → identical Winter 404 | unit | new oauth_identities_test.go |
❌ Wave 0 |
| D-06 / I18N-01 | Last identity 409 EN/PL | unit | same | ❌ Wave 0 |
| D-11 | Unlink 204 keeps other row; 401 without JWT | unit | port of OAuthIdentityApiTest.php |
❌ Wave 0 |
| D-09 | /me unrestricted collection_ids null |
unit + parity | rewrite TestMeTokenHandlerNil...; new fixture |
✅ rewrite |
| DATA-02 | Migration up/down, indexes, jsonb, FK | integration | go test ./plugins/golem15/user/updates -count=1 |
❌ Wave 0 |
| DATA-07 | Encrypted tokens never in GET body | unit + parity | PHP test port + D-10 fixture | ❌ Wave 0 |
| HTTP-04 | DELETE throttle:10,1 |
unit (middleware list) | assertRouteSurfaces + middleware contains throttle |
✅ extend |
| QA-05 | Nuxt/MCP unchanged | manual-only | exercise Connected accounts + MCP me() against Go |
N/A consumers frozen |
Sampling Rate
- Per task commit: quick run command above +
go veton touched packages - Per wave merge: full suite in both repos
- Phase gate:
TestParityCorpusprintsrecorded 175/175 passing 175 failing 0 unrecorded 0 pending 0; user plugin and fonotekago test ./...green
Wave 0 Gaps
plugins/golem15/user/controllers/oauth_identities_test.go— D-11 behavioursplugins/golem15/user/updates/oauth_identities_test.go— migration up/down- Rewrite
me_token_controller_test.gonil-collection assertion - Rewrite
parity/parity_test.goassertPortedMismatch - D-10 fixtures +
fonotekaCaseExtras/fonoteka_reset.phpextras - Flip
phase08_coverage_test.gooauth identityassertAbsent - sm-user-plugin README API + table row
Existing infrastructure covers parity replay, testcontainers migration tests, and JWT group assembly. No new test framework.
Security Domain
Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | yes | Group jwt.auth / inv_token; do not re-parse Bearer in handlers |
| V3 Session Management | no | No new cookies or refresh |
| V4 Access Control | yes | Owner-scoped user_id; foreign unlink is 404 not 403; token group never lists identities |
| V5 Input Validation | yes | Provider allow-list; {provider} is a path string, not SQL |
| V6 Cryptography | yes | lagoon.Encrypted for tokens; never log Reveal() |
Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Token/profile leak in GET body | Information Disclosure | Explicit map; Encrypted json:"-"; D-10 fixture with secrets that must not appear |
| IDOR unlink of another user's identity | Elevation / Tampering | WHERE user_id = caller; 404 not 403 |
| Last-method lockout bypass | Elevation | Fail-closed count===1 → 409; ignore password flag |
| Mass assignment of tokens | Tampering | Empty Fillable / no lagoon.Fill on this model; tests assign fields explicitly |
| Unknown provider probing | Information Disclosure | Same Winter 404 as missing row |
| Identity routes on personal token | Elevation | TokenSurfaceIsolation; never mount on /api/v1/fonoteka |
| Throttle bypass on DELETE | Denial of Service | "throttle:10,1" on the DELETE registration |
| Laravel ciphertext treated as GCM | Tampering / Disclosure | No import this phase (D-08) |
Recommended Plan Split (discretion)
Lean mode: two plans, confirm with the user before PLAN.md.
- 14.1-01 Implementation — model, migration, lang keys, exported handlers, fonoteka JWT mount without
Where,/menull fix, parity extras + PHP recording + manifest flip + snapshot (already has paths) +expectedPortedRoutes+assertPortedMismatchrewrite + README. Smoke: one Go test that GET empty list returns{"data":[]}is allowed; full coverage is plan 2. - 14.1-02 Unit tests (last) — port
OAuthIdentityApiTest.php, EN/PL 409, byte-identical 404s, unknown provider, 401 on/google, jwt-only surfaces, migration tests, rewritten MeToken tests, threat tests for secret leak.
Do not put framework module API changes in summercms.go unless a bug in wire/lagoon is found (none expected).
Sources
Primary (HIGH confidence)
- PHP
OAuthIdentityApiController.php,MeTokenController.php,ApiToken.php,OAuthIdentity.php,create_oauth_identities_table.php,routes.php:320-321, lang EN/PLoauth.last_method_blocked,OAuthIdentityApiTest.php,TokenSurfaceIsolationTest.php— Read this session - Go
me_token_controller.go+ test,http_errors.go,winter_404.html,routes.go,user/routes.go,plugin.go,parity_test.go, fixtures,manifest.yaml,routes.snapshot,wire/response.go,lagoon/{encrypted,jsonable}.go,surf/params.go,bouncer/jwt.go— Read this session - Nuxt types/composables/store; MCP
client.ts— Read this session 14.1-CONTEXT.mdlocked decisions;REQUIREMENTS.md;CLAUDE.md
Secondary (MEDIUM confidence)
- Laravel unmatched route constraint renders the same Winter 404 page as
HttpException(404)in production (APP_DEBUG=false). Confirmed by the recorded missing-row HTML and Winter's production error pages already embedded in Go; constraint-miss was not a separate recorded fixture.
Tertiary (LOW confidence)
- None required; no new libraries.
Metadata
Research scope:
- Core technology: Go net/http handlers, GORM/gormigrate, existing SummerCMS modules
- Ecosystem: no new packages
- Patterns: host-mounted shared-plugin handlers, Winter HTML 404, explicit DTO map, fail-closed unlink
- Pitfalls: surf.Where, Slice-on-collection_ids, assertPortedMismatch, phase08 assertAbsent
Confidence breakdown:
- Standard stack: HIGH — in-repo pins, no new deps
- Architecture: HIGH — PHP controller + Go mount points read in full
- Pitfalls: HIGH — fixtures and failing-helper sites read in full
- Code examples: HIGH — verbatim quotes with line ranges
Research date: 2026-10-05 Valid until: 2026-11-04 (30 days; in-repo contract, not a fast-moving ecosystem)
Phase: 14.1-oauth-identities-and-fonoteka-me-routes Research completed: 2026-10-05 Ready for planning: yes