37 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, estimate, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | estimate | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 14.1-oauth-identities-and-fonoteka-me-routes | 01 | execute | 1 |
|
false |
|
|
|
Phase Goal
As a signed-in Nuxt user (and as a fonoteka-mcp token caller), I want to list and unlink connected OAuth identities and receive the full personal-token /me body, so that Settings → Connected accounts and MCP bootstrap work against Go with zero pending routes.
ROADMAP Phase 14.1 goal (source): The three manifest routes no phase owned (14-CONTEXT D-09) are ported and pass the parity diff, so that no route is left pending before cutover.
This plan's slice: the production path — model, migration, exported Index/Destroy, JWT mount, /me null fix, PHP extras/recording and the manifest flip. Full unit coverage is plan 02.
Purpose: Nuxt Connected accounts and fonoteka-mcp me() need PHP bytes before Phase 15. Decisions: D-01 through D-12 (D-08 and D-13 are out of this phase).
Output: sm-user-plugin model/migration/handlers/lang/README; fonoteka JWT mount and MeToken fix; parity extras, recordings, counts.
Repos: fonoteka.go and the sm-user-plugin submodule at plugins/golem15/user. Do not change summercms.go framework modules. Commits are path-scoped (plugin submodule, then app); never add co-author tags. Planning docs stay out of code commits.
<execution_context>
@/.codex/gsd-core/workflows/execute-plan.md
@/.codex/gsd-core/templates/summary.md
</execution_context>
Artifacts this phase produces
models.OAuthIdentity(TableNamegolem15_user_oauth_identities; columns D-07;lagoon.Encryptedaccess_token/refresh_tokenwithjson:"-";lagoon.Jsonable[map[string]any]profile_data;Hiddenthose three secrets).- gormigrate
202610050001_create_oauth_identities(uniqueoauth_identities_user_provider_uniqueon(user_id, provider), uniqueoauth_identities_provider_identity_uniqueon(provider, provider_id), FKuser_id→users(id)ON DELETE CASCADE,profile_data jsonb). controllers.OAuthIdentitiesOptions,OAuthIdentitiesIndex(*backpack.App) http.HandlerFunc,OAuthIdentitiesDestroy(*backpack.App, OAuthIdentitiesOptions) http.HandlerFunc.- Phrase
golem15.user::lang.oauth.last_method_blockedinlang/en/lang.yamlandlang/pl/lang.yaml. - Host JWT mount: GET
/oauth-identities, DELETE/oauth-identities/{provider}with"throttle:10,1"andWriteNotFound→api.WriteWinterHTTPError. - MeToken D-09:
collection_idsJSON null when!Validor empty. - Parity extras
oauth-identities-linkedandme-unrestricted; new recorded cases; three routesported;expectedPortedRoutes = 175; rewrittenassertPortedMismatch. - sm-user-plugin README: table row, exported-constructor subsection (host-chosen path; no consuming-application name), models list
OAuthIdentity. - Smoke:
TestOAuthIdentitiesIndexEmptyListandTestOAuthIdentitiesDestroyNoContentKeepsSibling(plan 02 expands the full D-11 matrix).
Assumptions
- Assumption-delta: adding identities beside password is a second sign-in method, but D-06 already fail-closes unlink on identity count and D-13 already deferred social-login-only lockout to the Phase 15 preflight. This plan does not reopen those.
- D-08 Winter-import mapper and social-login redirect/callback stay deferred.
- Unauthenticated unknown provider is 401 in Go (
jwt.authfirst); D-11 401 tests use/google(research A1). Not a recorded parity case. profile_datais SQLjsonbper D-07 even thoughJsonable.GormDataTypeistext(research A2).- No new Go modules. Discretion: constructors + host mount, not a
Mounthelper (that helper would import fonoteka'sapipackage into the user plugin). - Token estimates are uncalibrated (sample_count 0, confidence low) under the locked 2-plan lean split; do not split this phase.
(1) models/oauth_identity.go: type OAuthIdentity with id, user_id, provider, provider_id, access_token and refresh_token as lagoon.Encrypted tagged json:"-", token_expires_at *time.Time, profile_data lagoon.Jsonable[map[string]any], linked_at *time.Time, timestamps. TableName() returns golem15_user_oauth_identities. Hidden() lists access_token, refresh_token, profile_data. Fillable() returns an empty slice (PHP $guarded = ['*']; tests assign struct fields). init() { Register(OAuthIdentity{}) }. Do not edit plugin.go.
(2) updates/202610050001_create_oauth_identities.go: gormigrate ID 202610050001_create_oauth_identities, init() { Register(...) }. Migrate via tx.Exec / execStmts: CREATE TABLE with SERIAL PK, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, provider VARCHAR(50) NOT NULL, provider_id VARCHAR(255) NOT NULL, token columns TEXT NULL, token_expires_at TIMESTAMPTZ NULL, profile_data jsonb NULL, linked_at TIMESTAMPTZ NULL, timestamps TIMESTAMPTZ NOT NULL DEFAULT NOW(), unique index oauth_identities_user_provider_unique on (user_id, provider), unique index oauth_identities_provider_identity_unique on (provider, provider_id). Rollback DROP TABLE IF EXISTS. No users.oauth_* backfill (D-07).
(3) controllers/oauth_identities.go: type OAuthIdentitiesOptions with Providers []string and WriteNotFound func(http.ResponseWriter, *http.Request). OAuthIdentitiesIndex(app *backpack.App) http.HandlerFunc reads bouncer.User, loads rows Where("user_id = ?", user.ID).Order("provider"), builds []map[string]any each with keys provider (string) and linked_at (*wire.Time UTC or nil), writes writeJSON 200 map[string]any{"data": wire.Slice(rows)}. Principal missing is fail-closed 401 via the existing helper, matching APITokenIndex. Index does not marshal the GORM struct.
(4) Host mount, D-02: in the JWT group in fonoteka routes.go, import git.golem15.com/golem15/sm-user-plugin/controllers as userctrl and g.Get("/oauth-identities", userctrl.OAuthIdentitiesIndex(p.app)). Leave plugins/golem15/user/routes.go byte-identical. Leave the personal-token group without this path.
(5) phase08_coverage_test.go: in test_oauth_identity_routes_exist_on_jwt_surface_only, replace the deferred-absent probe with assertRouteSurfaces(t, rt, http.MethodGet, "/oauth-identities", true, false) so the assembled router accepts the GET mount (Pitfall 6). DELETE surfaces wait for Task 3.
(6) Smoke TestOAuthIdentitiesIndexEmptyList in plugin-root oauth_identities_test.go (package user): sessionApp, insertUser, bouncer.WithUser, controllers.OAuthIdentitiesIndex(app).ServeHTTP on GET /_fonoteka/api/v1/oauth-identities, status 200, body {"data":[]} (no other top-level keys), Cache-Control contains no-cache. Full D-11 matrix is plan 02.
go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user -count=1 -v -run '^(TestOAuthIdentitiesIndexEmptyList)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only'
<fails_when>Any command exits non-zero; the verbose user-plugin run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthIdentitiesIndexEmptyList"; the fonoteka run fails the oauth-identity surface subtest.</fails_when>
<acceptance_criteria>
- grep -c 'func (OAuthIdentity) TableName()' ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go prints at least 1 and grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/models/oauth_identity.go prints at least 1.
- grep -c '202610050001_create_oauth_identities' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go prints at least 1 and grep -c 'oauth_identities_user_provider_unique' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go prints at least 1.
- grep -c 'AutoMigrate' ../fonoteka.go/plugins/golem15/user/updates/202610050001_create_oauth_identities.go prints 0.
- grep -c 'func OAuthIdentitiesIndex(' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go prints at least 1.
- grep -c 'OAuthIdentitiesIndex' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints at least 1.
- git -C ../fonoteka.go/plugins/golem15/user diff -- routes.go prints nothing.
- grep -c 'TestOAuthIdentitiesIndexEmptyList' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
</acceptance_criteria>
A signed-in user with no linked identities receives {"data":[]} from the JWT GET, proving model, migration, explicit map, and host mount together.
(1) Lang: sibling oauth: group (not under account:) in both locale files. EN last_method_blocked text is exactly This is the only remaining way to sign in. Link another method before disconnecting this one. PL text is exactly To jedyna droga logowania na to konto. Najpierw podłącz inną, zanim odetniesz tę. Handler key golem15.user::lang.oauth.last_method_blocked.
(2) OAuthIdentitiesDestroy(app, opts): provider is r.PathValue("provider"). If opts.Providers is nil or empty, the allow-list is google, facebook, github (D-04; host may pass a narrower or wider slice). A provider outside the list, a missing row for (user_id, provider), and a foreign row all call opts.WriteNotFound (same function, so bodies match). If WriteNotFound is nil, write the existing opaque 500 helper rather than Go's default 404 page. Count identities for this user_id only; when count is 1, writeJSON 409 {"error": tr.Get(ctx, "golem15.user::lang.oauth.last_method_blocked", nil)}. Otherwise delete and w.WriteHeader(http.StatusNoContent) with empty body (PHP noContent()). Do not copy APITokenDestroy's 200 JSON. Password flags on the user row are unused (D-06).
(3) JWT group: 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"). Leave the path value unconstrained so the handler 404 is reachable (Pitfall 1; oauth request_id precedent). Personal-token group unchanged.
(4) phase08: assertRouteSurfaces(t, rt, http.MethodDelete, "/oauth-identities/{provider}", true, false) in the same oauth-identity subtest. Assert the DELETE route's middleware list contains throttle:10,1 (CSV import is the analog).
(5) Smoke TestOAuthIdentitiesDestroyNoContentKeepsSibling in plugin-root oauth_identities_test.go (package user): sessionApp, insertUser, insert two OAuthIdentity rows for that user (facebook and google), bouncer.WithUser, OAuthIdentitiesDestroy(app, OAuthIdentitiesOptions{WriteNotFound: stub that fatals if called}) on DELETE /_fonoteka/api/v1/oauth-identities/facebook. Assert http.StatusNoContent (204), empty body, and the google row still exists for that user_id. Status 200 with a JSON body fails this test. Full D-11 matrix (Winter HTML 404, 409 last-method, 401) stays in plan 02.
go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... && go -C ../fonoteka.go test ./plugins/golem15/user -count=1 -v -run '^(TestOAuthIdentitiesDestroyNoContentKeepsSibling)$' && go -C ../fonoteka.go test ./plugins/golem15/fonoteka -count=1 -run 'test_oauth_identity_routes_exist_on_jwt_surface_only'
<fails_when>Non-zero exit; the verbose user-plugin run prints "--- FAIL", "no tests to run" or "--- SKIP", or lacks "--- PASS: TestOAuthIdentitiesDestroyNoContentKeepsSibling"; status 200 JSON would fail that test; the fonoteka run fails the oauth-identity surface subtest.</fails_when>
<acceptance_criteria>
- grep -c 'func OAuthIdentitiesDestroy(' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go prints at least 1.
- grep -c 'HasSelfSetPassword' ../fonoteka.go/plugins/golem15/user/controllers/oauth_identities.go prints 0.
- grep -c 'last_method_blocked:' ../fonoteka.go/plugins/golem15/user/lang/en/lang.yaml prints at least 1 and grep -c 'last_method_blocked:' ../fonoteka.go/plugins/golem15/user/lang/pl/lang.yaml prints at least 1.
- grep -F 'throttle:10,1' ../fonoteka.go/plugins/golem15/fonoteka/routes.go | grep -c 'oauth-identities/{provider}' prints at least 1.
- grep -c 'Where("provider"' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints 0.
- grep -c 'OAuthIdentitiesDestroy' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints at least 1.
- grep -c 'WriteWinterHTTPError' ../fonoteka.go/plugins/golem15/fonoteka/routes.go prints at least 1.
- git -C ../fonoteka.go/plugins/golem15/user diff -- routes.go prints nothing.
- grep -c 'TestOAuthIdentitiesDestroyNoContentKeepsSibling' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
- grep -c 'StatusNoContent' ../fonoteka.go/plugins/golem15/user/oauth_identities_test.go prints at least 1.
</acceptance_criteria>
A JWT caller can unlink a non-last identity (204), is blocked on the last one (409 localized error), and sees Winter HTML 404 for unknown/missing/foreign providers, with DELETE rate-limited 10/1.
(1) MeToken: keep scopes as wire.Slice. For collection_ids, when the matched *models.ApiToken has invalid or empty CollectionIDs emit JSON null (D-09); otherwise emit the int list. Keep reading bouncer.User and bouncer.Credential from context. Rewrite the comment that currently says both arrays never serialize as null so it names scopes only.
(2) Seed extras on both sides with the same names. Extra oauth-identities-linked: alice rows for facebook and google (order-by-provider coverage) with non-empty Encrypted tokens and profile_data so the GET fixture proves secrets stay off the wire. Extra me-unrestricted: a second alice personal token whose collection_ids is SQL NULL/empty; do not change the existing mcp-read restricted token. Map extras in fonotekaCaseExtras as "<route id>#<case id>" and in fonoteka_reset.php $extras. Leave empty GET, missing DELETE, and restricted /me on the plain reset (no extra).
(3) Record PHP for the two new cases via isolated parity/php_parity.sh reset + serve and summer parity:record --manifest parity/manifest.yaml --fixtures parity/fixtures --target http://127.0.0.1:8423 --vars <0600 vars> (README recording flow). New GET list-with-rows case on GET /_fonoteka/api/v1/oauth-identities jwt; new unrestricted /me case on GET /api/v1/fonoteka/me personal_token whose body includes "collection_ids": null. Do not hand-author response bytes. Existing fixtures stay.
(4) Flip all three route entries from status: pending to ported. Set expectedPortedRoutes = 175 (keep expectedPHPRoutes = 175). Rewrite assertPortedMismatch to wrap a ported fixture with a status-mutating handler and require tide.MismatchError, following assertPortedMutationCaught / mutateAlbumCount. After D-12 there is no pending-route probe.
(5) sm-user-plugin README in the same change (CLAUDE.md): add golem15_user_oauth_identities to the Overview table list; add an exported host-mounted subsection for OAuthIdentitiesIndex, OAuthIdentitiesDestroy, OAuthIdentitiesOptions (the host chooses the path; say "the host application", never a consuming-application name); add the migration row and OAuthIdentity to the models list. Do not add identity paths to the /_user/api/v1 handler table.
go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/... && go -C ../fonoteka.go test ./parity -count=1 -run 'TestParityCorpus' -timeout 30m
<fails_when>Non-zero exit; corpus summary is not recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0, or it prints pending 3 / passing 172.</fails_when>
<acceptance_criteria>
- grep -c 'wire.Slice(collectionIDs)' ../fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go prints 0.
- grep -c 'golem15_user_oauth_identities' ../fonoteka.go/plugins/golem15/user/README.md prints at least 1 and grep -c 'OAuthIdentitiesIndex' ../fonoteka.go/plugins/golem15/user/README.md prints at least 1.
- grep -nE 'expectedPortedRoutes = 175' ../fonoteka.go/parity/parity_test.go prints a matching line (non-empty).
- grep -c 'unported PHP route must not pass' ../fonoteka.go/parity/parity_test.go prints 0.
- grep -c 'oauth-identities-linked' ../fonoteka.go/parity/fonoteka_seed_test.go prints at least 1 and grep -c 'oauth-identities-linked' ../fonoteka.go/parity/fonoteka_reset.php prints at least 1.
- grep -c 'me-unrestricted' ../fonoteka.go/parity/fonoteka_seed_test.go prints at least 1 and grep -c 'me-unrestricted' ../fonoteka.go/parity/fonoteka_reset.php prints at least 1.
- Three manifest route ids (GET /_fonoteka/api/v1/oauth-identities jwt, DELETE /_fonoteka/api/v1/oauth-identities/{provider} jwt, GET /api/v1/fonoteka/me personal_token) have status: ported and none of those blocks still say status: pending.
</acceptance_criteria>
Unrestricted /me emits JSON null collection_ids, D-10 PHP extras are recorded, all three routes are ported at 175/175/0, and the shared plugin README names the exported constructors.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
JWT client → /_fonoteka/api/v1/oauth-identities |
Untrusted Bearer and {provider} path; responses must not leak Encrypted tokens or profile_data |
Personal-token client → /api/v1/fonoteka/me |
Already-matched inv_ credential; collection binding is authorization data |
| Host plugin → sm-user-plugin constructors | Host injects Winter 404 writer; user plugin must not import the application api package |
Postgres golem15_user_oauth_identities |
At-rest Encrypted tokens; cascade delete with users |
STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
|---|---|---|---|---|---|
| T-14.1-01 | Information Disclosure | OAuthIdentitiesIndex | high | mitigate | Explicit {provider, linked_at} map; Encrypted columns json:"-" and Hidden; D-10 fixture seeds secrets that must not appear (plan 02 asserts) |
| T-14.1-02 | Elevation of Privilege | OAuthIdentitiesDestroy | high | mitigate | Lookup user_id = caller AND provider; missing and foreign share Winter 404 (not 403) |
| T-14.1-03 | Elevation of Privilege | OAuthIdentitiesDestroy last-method | high | mitigate | Count identities for this user_id; refuse with 409 when count is 1; password flags unused (D-06) |
| T-14.1-04 | Tampering | OAuthIdentity Fillable | medium | mitigate | Empty Fillable; no lagoon.Fill on this model; tests assign fields explicitly |
| T-14.1-05 | Information Disclosure | Destroy provider allow-list | medium | mitigate | Unknown provider uses the same WriteNotFound as a missing row |
| T-14.1-06 | Elevation of Privilege | fonoteka routes.go | high | mitigate | Mount on JWT group only; personal-token group unchanged; user plugin /_user group unchanged |
| T-14.1-07 | Denial of Service | DELETE registration | medium | mitigate | "throttle:10,1" on DELETE only |
| T-14.1-08 | Tampering | Encrypted columns | medium | mitigate | Seed and tests use lagoon.NewEncrypted under the app key; Laravel ciphertext is not imported (D-08, Phase 15) |
| T-14.1-SC | Tampering | package installs | high | mitigate | No new modules; package-legitimacy gate N/A |
ASVS L1: all high threats mitigated; medium threats sit on the JWT/token trust boundary and are mitigated. </threat_model>
After Task 4: `go -C ../fonoteka.go vet ./plugins/golem15/user/... ./plugins/golem15/fonoteka/... ./parity/...` and `TestParityCorpus` prints `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`. Framework `go vet ./...` in summercms.go stays green (no module edits).<success_criteria>
- GET empty list is
{"data":[]}. - DELETE is mounted with
throttle:10,1and unconstrained{provider}. /meunrestrictedcollection_idsis JSON null.- Three manifest routes are ported;
expectedPortedRoutesis 175. - sm-user-plugin README documents the exported constructors without naming a consuming application. </success_criteria>