Files
summercms/.planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-RESEARCH.md
2026-10-05 19:24:49 +02:00

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 OAuthIdentity model and the golem15_user_oauth_identities migration 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-identities on the JWT group, with PHP's middleware: JWT auth on both routes and throttle:10,1 on DELETE. The user plugin must not register these routes under its own /_user/api/v1 group by default; the host chooses the mount point. Paths, auth group and response bytes match PHP exactly, which routes.snapshot checks. — 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, facebook and github, 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, and linked_at as 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_password plays no part.

Columns and import

  • D-07: The Go table and model carry the full PHP column set: id, user_id (FK to users, cascade delete), provider (50), provider_id (255), access_token and refresh_token (both lagoon.Encrypted), token_expires_at, profile_data (jsonb), linked_at, created_at and updated_at. Both unique indexes are kept: (user_id, provider) and (provider, provider_id). The PHP backfill from the legacy users.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 HasWinterImport mappers 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) is collection_ids. PHP's ApiToken::collectionIds() returns null for an unrestricted token (no bound collections), but Go always emits [] through wire.Slice. Go must emit null when the token has no collection binding and a list of ints otherwise. scopes keeps 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 facebook and google identities, which exercises ordering by provider, the {provider, linked_at} shape and the linked_at format. The seeded rows carry token and profile values, so the fixture shows that no secret leaks.
    • /me with an unrestricted token: "collection_ids": null.

    The existing cases stay: GET with an empty list, DELETE with a missing row (404), and /me with a restricted token. Seeding goes through the parity harness's seed_hook mechanism, on both the PHP recording side and the Go replay side.

  • 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/fonoteka never gains them (PHP's TokenSurfaceIsolationTest).

  • D-12: All three manifest entries flip from pending to ported. 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's forgot-password flow, 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,1 is declared.
  • The Go timestamp formatting needed to match Laravel's toIso8601String() (+00:00 offset, not Z). 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.go stays app-agnostic; application code lives in fonoteka.go and sm-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 vet and go 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}}
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].

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]" and profile_data.
  • g.Where("provider", ...): text/plain 404, not Winter HTML; also 404s unauthenticated unknown providers before jwt.auth (PHP constraint is pre-auth; D-11 401 tests use /google).
  • JSON 404 with the HttpException message: not what PHP production recorded.
  • wire.Slice on collection_ids: contradicts D-09.
  • Registering identity routes on /_user/api/v1: D-02 forbids it.
  • Consulting has_self_set_password on unlink: D-06.
  • Winter-import mapper / social login: D-08 / deferred.
  • Leaving assertPortedMismatch pointed 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 /me as "minimal bootstrap" with [] for unrestricted collections.
  • Assuming PHP 404 JSON from HttpException message 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

  1. assertPortedMismatch replacement

    • 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 assertPortedMutationCaught style; do not keep a pending-route probe after D-12.
  2. Unrestricted /me token identity

    • What we know: mcp-read is 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 mutate mcp-read.

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 vet on touched packages
  • Per wave merge: full suite in both repos
  • Phase gate: TestParityCorpus prints recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0; user plugin and fonoteka go test ./... green

Wave 0 Gaps

  • plugins/golem15/user/controllers/oauth_identities_test.go — D-11 behaviours
  • plugins/golem15/user/updates/oauth_identities_test.go — migration up/down
  • Rewrite me_token_controller_test.go nil-collection assertion
  • Rewrite parity/parity_test.go assertPortedMismatch
  • D-10 fixtures + fonotekaCaseExtras / fonoteka_reset.php extras
  • Flip phase08_coverage_test.go oauth identity assertAbsent
  • 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)

Lean mode: two plans, confirm with the user before PLAN.md.

  1. 14.1-01 Implementation — model, migration, lang keys, exported handlers, fonoteka JWT mount without Where, /me null fix, parity extras + PHP recording + manifest flip + snapshot (already has paths) + expectedPortedRoutes + assertPortedMismatch rewrite + README. Smoke: one Go test that GET empty list returns {"data":[]} is allowed; full coverage is plan 2.
  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/PL oauth.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.md locked 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