docs(14.1): capture phase context
This commit is contained in:
@@ -0,0 +1,147 @@
|
||||
# Phase 14.1: OAuth identities and fonoteka me routes - Context
|
||||
|
||||
**Gathered:** 2026-10-04
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
The three manifest routes that are still `pending` are ported and pass the parity diff, which leaves zero pending routes before the Phase 15 cutover:
|
||||
|
||||
- `GET /_fonoteka/api/v1/oauth-identities`: lists the JWT caller's linked social identities as `{"data":[{"provider","linked_at"}]}`, ordered by provider.
|
||||
- `DELETE /_fonoteka/api/v1/oauth-identities/{provider}`: unlinks one identity. It returns 204 on success, 404 when the identity is missing or belongs to another user (the two bodies are byte-identical), 409 with a localized `error` when the identity is the caller's last one, and 401 without a JWT. The provider is constrained to `google|facebook|github`, and the route has `throttle:10,1`.
|
||||
- `GET /api/v1/fonoteka/me` (personal-token group): Phase 8 D-20 already serves it. This phase closes the remaining contract gap and flips the manifest entry.
|
||||
|
||||
These routes back the Nuxt **Settings → Connected accounts** tab (`ConnectedAccounts.vue`) and the fonoteka-mcp bootstrap.
|
||||
|
||||
Repos: `fonoteka.go`, whose app and fonoteka plugin mount the routes and hold the parity fixtures and manifest, and `sm-user-plugin` (`fonoteka.go/plugins/golem15/user`), which gets the model, the migration and the list/unlink handlers.
|
||||
|
||||
Out of scope: social login itself. That means the `/oauth/{provider}` redirect and callback, `oauth-complete`, `oauth-register-complete`, linking a new provider, and the password-bootstrap OTP, all still deferred by Phase 7 D-03. The "link another provider" affordance on the Nuxt tab stays non-functional on Go. The Winter-import mapper for the new table also stays out (Phase 15).
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Phase scope and prior decisions
|
||||
- `.planning/ROADMAP.md` § Phase 14.1: goal and success criteria
|
||||
- `.planning/phases/14-domain-jobs-and-external-integrations/14-CONTEXT.md` D-09: why these routes were left pending
|
||||
- `.planning/phases/07-user-plugin-and-authentication/07-CONTEXT.md` D-03: social login deferral and the `has_self_set_password` caveat
|
||||
- `.planning/phases/08-oauth2-1-authorization-server/08-CONTEXT.md` D-20: the minimal `/me` port
|
||||
- `.planning/phases/15-cutover/15-CONTEXT.md` D-02, D-03, D-11, D-13: import mappers, re-encryption, the zero-pending gate and the preflight
|
||||
- `.planning/todos/pending/orphan-pending-routes.md`: folded todo
|
||||
|
||||
### PHP source of truth (`/media/nvme/dev/golem15/fonoteka`)
|
||||
- `plugins/golem15/fonoteka/controllers/api/OAuthIdentityApiController.php`: list and unlink behaviour, the explicit-map rule, the 409 rule
|
||||
- `plugins/golem15/fonoteka/controllers/api/MeTokenController.php`: the `/me` response
|
||||
- `plugins/golem15/fonoteka/models/ApiToken.php` `collectionIds()`: null when unrestricted
|
||||
- `plugins/golem15/fonoteka/routes.php` (around lines 310–321): mount group, provider constraint, throttle
|
||||
- `plugins/golem15/fonoteka/lang/en/lang.php` and `lang/pl/lang.php` `oauth.last_method_blocked`: the 409 text
|
||||
- `plugins/golem15/user/models/OAuthIdentity.php`: model, encrypted token accessors, profile_data cast
|
||||
- `plugins/golem15/user/updates/v3.3.0/create_oauth_identities_table.php`: schema and unique indexes
|
||||
- `plugins/golem15/fonoteka/tests/functional/OAuthIdentityApiTest.php`: behaviours to port into Go tests (D-11)
|
||||
- `plugins/golem15/fonoteka/tests/security/TokenSurfaceIsolationTest.php`: identity routes never on the token group
|
||||
- `plugins/golem15/fonoteka/tests/security/OAuthRevocationTest.php` and `tests/functional/OAuthTokenTest.php`: `/me` with revoked and granted tokens
|
||||
|
||||
### Consumers (contract, unchanged)
|
||||
- `vue-fonoteka-app/app/components/fonoteka/ConnectedAccounts.vue`, `app/composables/useFonoteka.ts` (`fetchOAuthIdentities`), `app/stores/fonoteka.ts` (`unlinkOAuthIdentity`)
|
||||
- `fonoteka-mcp/src/client.ts` (`MeResponse`, `/api/v1/fonoteka/me`)
|
||||
|
||||
### Go side
|
||||
- `fonoteka.go/parity/manifest.yaml`: the three `pending` entries (around lines 2801, 2819 and 3406)
|
||||
- `fonoteka.go/parity/fixtures/routes/GET___fonoteka_api_v1_oauth-identities_jwt.yaml`, `DELETE___fonoteka_api_v1_oauth-identities_{provider}_jwt.yaml`, `GET__api_v1_fonoteka_me_personal_token.yaml`
|
||||
- `fonoteka.go/parity/README.md`, `php_parity.sh`, `fixtures/seed/bootstrap.yaml`: recording and seeding
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `fonoteka.go/plugins/golem15/fonoteka/controllers/api/me_token_controller.go`: the existing `/me` handler. It reads `bouncer.Credential` and `bouncer.User` from context, so only the `collection_ids` null handling changes.
|
||||
- `lagoon.Encrypted`: already used by `user_discogs_credential.go`, `user_ai_credential.go` and `org_ai_credential.go` for encrypted columns. Use it for `access_token` and `refresh_token`.
|
||||
- `lagoon.Jsonable[...]`: JSON column pattern (`api_token.go`). It suits `profile_data`.
|
||||
- `wire.WriteJSON` and `wire.Slice`: response helpers. `wire.Slice` is the reason `[]` replaces null today.
|
||||
- `plugins/golem15/user/lang/{en,pl}`: the user plugin already has lang files, which receive the D-03 key.
|
||||
|
||||
### Established Patterns
|
||||
- sm-user-plugin registers its own routes under `/_user/api/v1` in `routes.go` with `surf.Use("throttle:user-api")`. The new identity handlers are exported for the host to mount (D-02), not added to that group.
|
||||
- Migrations in `plugins/golem15/user/updates/` use timestamped Go files (`202610020001_create_user_groups.go`) with a registry and postgres tests.
|
||||
- Route-group assertions already exist in `fonoteka/routes_group_test.go` and `oauth_tools_test.go`. Extend them for the new JWT routes and the token-surface exclusion.
|
||||
|
||||
### Integration Points
|
||||
- The fonoteka plugin's JWT route group under `/_fonoteka/api/v1`, where the identity handlers are mounted.
|
||||
- `routes.snapshot` must match PHP's path, method and auth group for both identity routes.
|
||||
- Parity `seed_hook`s for the identity rows (PHP recording and Go replay).
|
||||
- `go.work` already includes `./plugins/golem15/user`. Commit sm-user-plugin changes in that submodule (use `ssu`).
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- The handlers back Settings → Connected accounts. Disconnect must keep working for real household accounts imported in Phase 15.
|
||||
- The 409 text and the 404 bytes must be byte-identical to PHP, so the Nuxt dialog behaves the same.
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- **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).
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 14.1-oauth-identities-and-fonoteka-me-routes*
|
||||
*Context gathered: 2026-10-04*
|
||||
@@ -0,0 +1,87 @@
|
||||
# Phase 14.1: OAuth identities and fonoteka me routes - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md. This log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-10-04
|
||||
**Phase:** 14.1-oauth-identities-and-fonoteka-me-routes
|
||||
**Areas discussed:** where the identity table lives, token columns and import, parity case coverage, social-login-only users at cutover
|
||||
|
||||
The scout found two things before the discussion:
|
||||
|
||||
- `GET /api/v1/fonoteka/me` is already served in Go. Its only gap is that `collection_ids` is `[]` where PHP returns `null` for an unrestricted token. This is decided by the PHP contract and was not asked.
|
||||
- Go has no model or migration for `golem15_user_oauth_identities`.
|
||||
|
||||
---
|
||||
|
||||
## Where the identity table lives
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| sm-user-plugin | Mirrors PHP, where Golem15.User owns the table; additive change | ✓ |
|
||||
| fonoteka plugin | Leaves the shared plugin untouched, but the table sits in the wrong plugin | |
|
||||
|
||||
The user asked what the handlers are for. They back Settings → Connected accounts in the Nuxt app, which lists linked Google, Facebook and GitHub logins and lets the user disconnect them. Unlinking the last one returns 409.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| fonoteka plugin | Same as PHP | |
|
||||
| user plugin API | Reusable handlers that fonoteka mounts at its prefix | ✓ |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| User plugin lang, same text | New key in the user plugin with identical EN/PL text | ✓ |
|
||||
| Host passes the message | The mount supplies the message key | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Mount option, default the three | google/facebook/github by default; the host can narrow or extend | ✓ |
|
||||
| Hard-coded three | Matches PHP exactly | |
|
||||
|
||||
## Token columns and import
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Full PHP column set | Encrypted tokens, jsonb profile_data, both unique indexes | ✓ |
|
||||
| Full columns, tokens nulled at import | Drop the stored provider tokens at import | |
|
||||
| Only what list/unlink need | Minimal columns | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| No, Phase 15 writes the import mapper | HasWinterImport does not exist yet | ✓ |
|
||||
| Yes, note the transforms now | Docs only | |
|
||||
|
||||
## Parity case coverage
|
||||
|
||||
Identity cases to record (multi-select): list with linked rows ✓, unlink 204 and last-method 409 ✗, foreign vs missing 404 ✗, unknown provider and 401 ✗.
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Add unrestricted-token case (/me) | `collection_ids: null` case | ✓ |
|
||||
| Also scope variants | Read-only and revoked tokens | |
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Go tests ported from the PHP tests | Covers the unselected behaviours in the final unit-test plan | ✓ |
|
||||
| Keep only what's recorded | | |
|
||||
|
||||
## Social-login-only users at cutover
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| Flag for the Phase 15 preflight | Count OAuth-only users in the dump; give them a password first, or schedule a social login phase | ✓ |
|
||||
| Accept, nobody uses it | | |
|
||||
| Pull social login into scope | Big scope jump | |
|
||||
|
||||
## Claude's Discretion
|
||||
|
||||
- The shape of the exported sm-user-plugin API and how fonoteka mounts it
|
||||
- Where the throttle for `throttle:10,1` is declared
|
||||
- ISO-8601 formatting that matches Laravel's `toIso8601String()`
|
||||
- Plan split (unit tests come last; the plan count is confirmed first)
|
||||
- README and docs updates
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- Social login port (its own phase; the Phase 15 preflight decides the urgency)
|
||||
- Winter-import mapper for the identities table (Phase 15)
|
||||
Reference in New Issue
Block a user