docs(14.1): record code review and verification

WR-01 is fixed; WR-02 and the three info findings stay open. The phase
goal is 8/8 with corpus 175/175/0.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jakub Zych
2026-10-05 21:48:08 +02:00
parent c7c796cb45
commit eb84825724
3 changed files with 322 additions and 0 deletions

View File

@@ -0,0 +1,186 @@
---
phase: 14.1-oauth-identities-and-fonoteka-me-routes
verified: 2026-10-05T20:00:00Z
status: passed
score: 8/8 must-haves verified
covered_files:
- ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-PLAN.md"
- ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-01-SUMMARY.md"
- ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-PLAN.md"
- ".planning/phases/14.1-oauth-identities-and-fonoteka-me-routes/14.1-02-SUMMARY.md"
covered_digest: "v2:sha256:a8355b54246ac18cf06c363cbfe9fe2c15ab07074939d3b90042cc2534099f73"
covered_files_note: "verification.fingerprint covers only paths under the summercms.go root. Sibling implementation at re-verification: fonoteka.go 7fc790d (master ahead 10 of origin, clean tree), sm-user-plugin cf5c6b9 (master ahead 27 of origin, clean tree). Framework tree has no 14.1 module edits."
behavior_unverified: 0
overrides_applied: 0
mvp_mode_note: "ROADMAP marks Phase 14.1 mode: mvp, but the ROADMAP goal is not a User Story. Plan 01/02 objectives carry the story. As in Phases 1, 3, 5 and 8 to 14, ROADMAP success criteria are the contract; User Flow Coverage is derived from them plus the plan story."
decision_coverage:
honored: 13
total: 13
not_honored: []
gaps: []
deferred:
- truth: "Live Nuxt Settings → Connected accounts and fonoteka-mcp me() against the Go backend"
addressed_in: "Phase 15"
evidence: "Phase 15 success criteria 3 and 4: vue-fonoteka-app runs unchanged for a full manual session; fonoteka-mcp completes install/auth and representative tool calls. Plan 02 human-check is that cutover UAT, not a 14.1 code gap."
---
# Phase 14.1: OAuth identities and fonoteka me routes Verification Report
**Phase Goal:** 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. They are `GET /_fonoteka/api/v1/oauth-identities`, `DELETE /_fonoteka/api/v1/oauth-identities/{provider}` and `GET /api/v1/fonoteka/me` (the full contract, not only the minimal Phase 8 D-20 version).
**Verified:** 2026-10-05T20:00:00Z
**Status:** passed
**Re-verification:** Yes — closed the DATA-07 Hidden marshal gap after WR-01 (`json:"-"` on `ProfileData`, `expectedUserModels = 8`, Jsonable sentinel skip). `TestHiddenNeverMarshals` and `TestSchemaMatchesPHPSnapshot` PASS on fonoteka.go 7fc790d / sm-user-plugin cf5c6b9.
**MVP note:** ROADMAP marks this phase `mode: mvp`, but the goal is not a User Story. Plan objectives carry `As a signed-in Nuxt user…`. ROADMAP success criteria remain the contract.
## User Flow Coverage
User story (from plan objectives): 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.
| Step | Expected | Evidence | Status |
|------|----------|----------|--------|
| List connected accounts | Empty list is `{"data":[]}`; linked rows are facebook then google with `linked_at` `+00:00`, no secrets | `OAuthIdentitiesIndex`; fixtures `GET___fonoteka_api_v1_oauth-identities_jwt.yaml` and `__linked.yaml`; `TestOAuthIdentitiesIndexEmptyList` PASS; corpus `GET___fonoteka_api_v1_oauth-identities_jwt` PASS | ✓ |
| Unlink one provider | 204 empty when a sibling remains; Winter HTML 404 for missing/foreign/unknown; 409 localized last-method | `OAuthIdentitiesDestroy`; DELETE fixture; D-11 tests PASS; corpus `DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt` PASS | ✓ |
| MCP `/me` bootstrap | Restricted token lists collection ids; unrestricted emits `"collection_ids":null`; `scopes` stay `[]` | `MeToken`; both `/me` fixtures; `TestMeTokenHandlerNilScopesSerializeAsEmptyArrayAndCollectionIDsAsJSONNull` PASS; corpus both `/me` routes PASS | ✓ |
| Zero pending routes | Manifest 175 ported, corpus pending 0 | `manifest.yaml` 175×`ported`; `expectedPortedRoutes = 175`; `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0` | ✓ |
## Goal Achievement
### Observable Truths
| # | Truth | Status | Evidence |
| --- | --- | --- | --- |
| 1 | GET oauth-identities lists the signed-in user's linked social identities exactly as PHP returns them | ✓ VERIFIED | Empty body `{"data":[]}` (`TestOAuthIdentitiesIndexEmptyList`). Linked extra seeds facebook+google Encrypted tokens; recorded PHP body is `[{"provider":"facebook","linked_at":"2026-01-15T12:00:00+00:00"},{"provider":"google","linked_at":"2026-02-01T08:30:00+00:00"}]`. Corpus subtest PASS. Index builds an explicit two-key map, ordered by provider, `linked_at` via `wire.Time` UTC. |
| 2 | DELETE oauth-identities/{provider} unlinks one identity with PHP's status codes and error shapes | ✓ VERIFIED | 204 empty + sibling remains (`TestOAuthIdentitiesDestroyNoContentKeepsSibling`). Last-method 409 EN/PL texts match lang YAML, including when `has_self_set_password` is true. Missing/foreign/unknown (`linkedin`) share byte-identical Winter HTML 404. Unauthenticated DELETE `/google` is 401 JSON. Recorded DELETE case is PHP Winter 404. Handler has no `HasSelfSetPassword`. Mux `{provider}` is unconstrained; allow-list lives in Destroy. |
| 3 | GET /api/v1/fonoteka/me matches the PHP response for fonoteka-mcp token callers | ✓ VERIFIED | Restricted fixture `collection_ids:[{{id:collection}}]`, `name` `parity-mcp`. Unrestricted extra `me-unrestricted` records `"collection_ids":null`, `name` `parity-unrestricted`. Handler emits JSON null unless `CollectionIDs.Valid && len>0`; `scopes` stay `wire.Slice`. Unit test requires `"collection_ids":null` and fails on `"scopes":null`. Corpus both cases PASS. |
| 4 | All three manifest entries flip from pending to ported with recorded PHP cases, leaving zero pending routes | ✓ VERIFIED | Manifest: 175 routes, all `status: ported`, pending list empty. Three ids ported with cases empty GET, linked GET, missing DELETE, restricted `/me`, unrestricted `/me`. `expectedPHPRoutes = expectedPortedRoutes = 175`. `assertPortedMismatch` wraps a ported fixture with `mutateStatus` (no `unported PHP route must not pass`). Verifier run: `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`. |
| 5 | `golem15_user_oauth_identities` exists via gormigrate with both unique indexes, cascade FK, jsonb `profile_data`, and `lagoon.Encrypted` token columns | ✓ VERIFIED | Migration ID `202610050001_create_oauth_identities`, `tx.Exec` DDL, no `AutoMigrate`. `TestOAuthIdentitiesMigration` PASS: table, columns, jsonb udt, both unique indexes, `user_id` → `users` ON DELETE CASCADE, table gone after rollback. Model `TableName`, empty `Fillable`, Encrypted tokens `json:"-"`. |
| 6 | Identity routes exist on the JWT group only; DELETE carries `throttle:10,1`; `/_user` and `/api/v1/fonoteka` never gain identity paths | ✓ VERIFIED | `routes.go` JWT group mounts Index/Destroy; `WriteNotFound` → `WriteWinterHTTPError`; DELETE `"throttle:10,1"`; no `Where("provider"`. User plugin `routes.go` has no identity strings (still `/_user/api/v1` only). Personal-token group serves `GET /me` only. `test_oauth_identity_routes_exist_on_jwt_surface_only` PASS (`assertRouteSurfaces` jwt-only + throttle contains-check). |
| 7 | GET list with seeded Encrypted tokens never contains plaintext, Encrypted JSON keys, or `[redacted]` | ✓ VERIFIED | `TestOAuthIdentitiesIndexDoesNotLeakEncryptedTokensOrProfileData` PASS. Linked PHP fixture body has only `provider`/`linked_at` despite seeded `parity-oauth-*-SECRET` and profile emails on both PHP reset and Go `seedParityOAuthIdentities`. |
| 8 | Registering OAuthIdentity keeps the DATA-07 Hidden marshal backstop green | ✓ VERIFIED | `ProfileData` is `json:"-"` (cf5c6b9). `expectedUserModels = 8` and Jsonable sentinel skip (7fc790d). `go -C fonoteka.go test ./plugins/golem15/fonoteka/classes -count=1 -run TestHiddenNeverMarshals` PASS. |
**Score:** 8/8 truths verified (0 present, behavior-unverified)
### Deferred Items
| # | Item | Addressed In | Evidence |
|---|------|-------------|----------|
| 1 | Live Nuxt Connected accounts list/unlink and fonoteka-mcp `me()` against Go | Phase 15 | Phase 15 SC3/SC4. 14.1's QA-05 slice is the corpus at 175/175/0. |
D-08 (Winter-import mapper / Laravel decrypt) and D-13 (social-login-only lockout preflight) stay out of this phase per CONTEXT; they are Phase 15, not 14.1 gaps.
## Required Artifacts
gsd-tools `verify.artifacts`: plan 01 6/6 passed, plan 02 4/4 passed. Manual three-level check:
| Artifact | Expected | Status | Details |
| -------- | -------- | ------ | ------- |
| `plugins/golem15/user/models/oauth_identity.go` | OAuthIdentity, TableName, Hidden, Register | ✓ VERIFIED | Exists, substantive, registered. `ProfileData` is `json:"-"` with Encrypted tokens. HTTP list is FLOWING. |
| `plugins/golem15/user/updates/202610050001_create_oauth_identities.go` | gormigrate ID + unique indexes | ✓ VERIFIED | DDL + rollback; wired via `init` Register. |
| `plugins/golem15/user/controllers/oauth_identities.go` | Index/Destroy/Options | ✓ VERIFIED | Wired from fonoteka JWT group; queries `OAuthIdentity` by `user_id`. |
| `plugins/golem15/user/lang/{en,pl}/lang.yaml` | `last_method_blocked` | ✓ VERIFIED | Exact PHP EN/PL strings; handler key `golem15.user::lang.oauth.last_method_blocked`. |
| `plugins/golem15/fonoteka/routes.go` | JWT GET/DELETE mounts | ✓ VERIFIED | Lines 240–246. |
| `plugins/golem15/fonoteka/controllers/api/me_token_controller.go` | D-09 null `collection_ids` | ✓ VERIFIED | No `wire.Slice(collectionIDs)`. |
| `plugins/golem15/user/oauth_identities_test.go` | D-11 + secret-leak | ✓ VERIFIED | Eight `TestOAuthIdentities*` funcs, all PASS this run. |
| `plugins/golem15/user/updates/oauth_identities_test.go` | migration up/down | ✓ VERIFIED | PASS this run. |
| `plugins/golem15/fonoteka/controllers/api/me_token_controller_test.go` | `"collection_ids":null` | ✓ VERIFIED | PASS this run. |
| `plugins/golem15/fonoteka/phase08_coverage_test.go` | jwt-only surfaces | ✓ VERIFIED | Subtest PASS. |
| `plugins/golem15/user/README.md` | host-mounted constructors | ✓ VERIFIED | `OAuthIdentitiesIndex/Destroy/Options`; says "the host application"; no consuming-application name. |
## Key Link Verification
gsd-tools `verify.key-links` rejected sibling `../fonoteka.go/...` paths (`Source path rejected — resolves outside the project directory`). Manual wiring:
| From | To | Via | Status | Details |
| ---- | --- | --- | ------ | ------- |
| fonoteka `routes.go` | `controllers/oauth_identities.go` | JWT `OAuthIdentitiesIndex` / `OAuthIdentitiesDestroy`; `WriteNotFound` → `WriteWinterHTTPError` | WIRED | Import `sm-user-plugin/controllers as userctrl`. |
| `oauth_identities.go` | `models/oauth_identity.go` | Index/Destroy `Where("user_id = ?", user.ID)` | WIRED | Count-then-delete is sequential (see WR-02). |
| `me_token_controller.go` | `models.ApiToken` | `bouncer.Credential` as `*models.ApiToken`, `CollectionIDs` | WIRED | FLOWING from matched credential; no re-query. |
| `oauth_identities_test.go` | constructors | `httptest` + `bouncer.WithUser` | WIRED | All D-11 tests call Index/Destroy. |
| `parity_test.go` | `manifest.yaml` | `TestParityCorpus` 175/175 | WIRED | Constants and coverage subtest. |
## Data-Flow Trace (Level 4)
| Artifact | Data Variable | Source | Produces Real Data | Status |
| -------- | ------------- | ------ | ------------------ | ------ |
| OAuthIdentitiesIndex | `data[].provider`, `linked_at` | GORM `Find` on `golem15_user_oauth_identities` for caller `user_id` | Yes (empty slice or DB rows) | ✓ FLOWING |
| OAuthIdentitiesDestroy | 204 / 404 / 409 | Count + Delete of caller rows; host Winter 404 writer | Yes | ✓ FLOWING |
| MeToken | `collection_ids`, `scopes` | `bouncer.Credential` `*models.ApiToken` | Yes (null vs int list; Slice for scopes) | ✓ FLOWING |
| Linked GET fixture | `data` | PHP-recorded extras; Go `seedParityOAuthIdentities` | Yes; secrets not in body | ✓ FLOWING |
## Behavioral Spot-Checks
| Behavior | Command | Result | Status |
| -------- | ------- | ------ | ------ |
| D-11 + secret-leak + empty list + 204 | `go -C $FONOTEKA test ./plugins/golem15/user ./plugins/golem15/user/updates -count=1 -run 'TestOAuthIdentities'` | All PASS including `TestOAuthIdentitiesMigration` (10.3s / 10.8s) | ✓ PASS |
| jwt-only surfaces + MeToken null | `go -C $FONOTEKA test ./plugins/golem15/fonoteka ./plugins/golem15/fonoteka/controllers/api -count=1 -run 'TestOAuthTokenSurfaceIsolationCoverage\|TestMeToken'` | `test_oauth_identity_routes_exist_on_jwt_surface_only` PASS; MeToken nil/restricted/no-requery PASS | ✓ PASS |
| Corpus 175/175/0 | `go -C $FONOTEKA test ./parity -count=1 -v -run 'TestParityCorpus' -timeout 30m` | `recorded 175/175 passing 175 failing 0 unrecorded 0 pending 0`; oauth-identities GET/DELETE and both `/me` subtests PASS (47s replay) | ✓ PASS |
| DATA-07 Hidden marshal backstop | `go -C $FONOTEKA test ./plugins/golem15/fonoteka/classes -count=1 -run 'TestHiddenNeverMarshals'` | PASS on 7fc790d (count 8, ProfileData json:"-") | ✓ PASS |
## Probe Execution
| Probe | Command | Result | Status |
| ----- | ------- | ------ | ------ |
| — | — | No `scripts/*/tests/probe-*.sh` and no phase-declared probes | SKIPPED |
## Requirements Coverage
REQUIREMENTS.md maps none of these IDs to Phase 14.1 (`Requirements: TBD` on the ROADMAP row). Plans claimed them as continuation of completed v1 rows. No orphaned IDs (nothing mapped to 14.1 that a plan omitted). Every claimed ID is accounted for:
| Requirement | Source Plan | Description | Status | Evidence |
| ----------- | ---------- | ----------- | ------ | -------- |
| API-09 | 01, 02 | All routes on correct groups with identical paths/methods/status/bodies | ✓ SATISFIED for this slice | Corpus 175/175/0; three former pending ids ported |
| HTTP-01 | 01, 02 | Unknown/malformed ids 404 on ownership-scoped resources | ✓ SATISFIED | Unconstrained `{provider}`; missing/foreign/unknown share Winter 404 |
| HTTP-03 | 01, 02 | JWT vs personal-token vs public groups | ✓ SATISFIED | jwt-only identity surfaces; token group has `/me` not identities |
| HTTP-04 | 01 | Inline throttles 1:1 | ✓ SATISFIED | DELETE `"throttle:10,1"` |
| HTTP-06 | 01 | `[]` vs null, `+00:00` timestamps | ✓ SATISFIED | Empty list `[]`; `linked_at` `+00:00`; `/me` `collection_ids` null exception per D-09 |
| DATA-02 | 01, 02 | gormigrate up/down; AutoMigrate never schema source | ✓ SATISFIED | Migration test PASS; no AutoMigrate |
| DATA-07 | 01, 02 | Jsonable + Encrypted hidden from serialization | ✓ SATISFIED | HTTP list leak tests PASS; `TestHiddenNeverMarshals` PASS after `json:"-"` on `ProfileData` |
| I18N-01 | 01, 02 | `vendor.plugin::group.key` YAML en/pl | ✓ SATISFIED | `golem15.user::lang.oauth.last_method_blocked`; EN/PL 409 tests PASS |
| QA-05 | 02 | Parity green; consumers unchanged | ✓ SATISFIED for this slice | Corpus 175/175/0. Live Nuxt/MCP deferred to Phase 15 |
**Prohibitions (test-tier, wired):** DELETE allow-list in handler not mux (Winter 404 tests + no `Where("provider"`); two-key list map (secret-leak test); constructors not on `/_user` or token group (phase08); last-method count-only (409-with-password); gormigrate not AutoMigrate; 401 probe uses `/google`; pending never counts as passing (`expectedPortedRoutes = 175`).
## Decision Coverage
All 13 trackable CONTEXT.md decisions (D-01..D-13) are honored by shipped artifacts (`check.decision-coverage-verify`: honored 13/13, blocking false). D-08 and D-13 are honored as explicit deferrals to Phase 15.
## Test Quality Audit
| Test File | Linked Req | Active | Skipped | Circular | Assertion Level | Verdict |
|-----------|-----------|--------|---------|----------|-----------------|---------|
| `user/oauth_identities_test.go` | HTTP-01, I18N-01, DATA-07 | 7 tests | 0 | No | Behavioral (204/409/401/404 bytes, leak needles) | OK |
| `user/updates/oauth_identities_test.go` | DATA-02 | 1 | 0 (`-short` skip via dedicatedDB only) | No | Value (jsonb, index names, CASCADE) | OK |
| `me_token_controller_test.go` | HTTP-06, D-09 | 3 | 0 | No | Value (`"collection_ids":null`) | OK |
| `phase08_coverage_test.go` | HTTP-03, HTTP-04 | oauth-identity subtest | 0 | No | Behavioral (jwt-only + throttle) | OK |
| `parity/parity_test.go` | API-09, QA-05 | TestParityCorpus | `-short` skips replay | No (PHP-recorded fixtures) | Behavioral replay | OK |
| `fonoteka/classes/hidden_marshal_test.go` | DATA-07 | 1 | 0 | No | Value (model count + json tags) | OK |
**Disabled tests on requirements:** 0
**Circular patterns detected:** 0 (linked `/me` fixtures recorded from isolated PHP, not from Go)
**Insufficient assertions:** 0 on the named 14.1 tests
**Provenance:** VALID — PHP `php_parity.sh` recordings for empty GET, linked GET, DELETE 404, restricted `/me`, unrestricted `/me`
## Anti-Patterns Found
| File | Line | Pattern | Severity | Impact |
| ---- | ---- | ------- | -------- | ------ |
| `controllers/oauth_identities.go` | 81–98 | Count then Delete without transaction | ⚠️ Warning | 14.1-REVIEW WR-02: two concurrent unlinks of the last two providers can both observe n==2. Sequential PHP/Go tests cannot see it. `throttle:10,1` does not serialize in-flight requests. |
| `oauth_identities_test.go` | 155–180 | Winter 404 asserted against a stub, not `WriteWinterHTTPError` | ℹ️ Info | IN-01. Host wiring + recorded DELETE fixture still prove production bytes. |
| `me_token_controller_test.go` | nil token | `Valid && empty slice` branch untested | ℹ️ Info | IN-02. Handler condition is `Valid && len>0`; unused branch. |
| Index `Find` | 31–48 | Decrypts Encrypted columns unused by the response | ℹ️ Info | IN-03. Not a JSON leak. |
No `TBD`/`FIXME`/`XXX` in phase implementation files. Plan 01 SUMMARY overclaimed `json:"-"` on `profile_data`; the PLAN task text only required Encrypted tokens tagged `json:"-"` and Hidden() for all three.
## Human Verification Required
N/A — API-parity / foundation phase. ROADMAP success criteria are programmatically checkable. Plan 02's live Nuxt/MCP check is deferred to Phase 15 (see Deferred Items).
## Gaps Summary
None. The three orphan routes are ported and the parity corpus is `175/175/0`. The DATA-07 marshal backstop is green after the WR-01 fix. WR-02 remains an open review warning, not a failed success criterion.
---
_Verified: 2026-10-05T20:00:00Z_
_Verifier: the agent (gsd-verifier)_