docs(14.1): research phase domain
This commit is contained in:
@@ -0,0 +1,697 @@
|
||||
# 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
|
||||
|
||||
```text
|
||||
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):**
|
||||
|
||||
```go
|
||||
// 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`]:
|
||||
|
||||
```go
|
||||
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`]:
|
||||
|
||||
```php
|
||||
$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`]:
|
||||
|
||||
```php
|
||||
$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`]:
|
||||
|
||||
```php
|
||||
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
|
||||
```php
|
||||
// [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
|
||||
```php
|
||||
// [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
|
||||
```go
|
||||
// [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
|
||||
```go
|
||||
// [VERIFIED: modules/lagoon/encrypted.go:49-52]
|
||||
func (e Encrypted) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(redactedLiteral) // "[redacted]"
|
||||
}
|
||||
```
|
||||
|
||||
### Nuxt consumers (unchanged)
|
||||
```ts
|
||||
// [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)
|
||||
```go
|
||||
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) |
|
||||
|
||||
## Recommended Plan Split (discretion)
|
||||
|
||||
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*
|
||||
Reference in New Issue
Block a user