From b2405307cff4236b6005fddf3e8cd0f8bc8efc99 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 2 Oct 2026 19:11:24 +0800 Subject: [PATCH 01/11] feat(profile-controller): add X OAuth connect flow - Add startXConnect to fetch the X authorization URL; profile identity is resolved server-side from the bearer token, with an optional linked_address for profile auto-creation - connectX now persists the linked X profile to controller state and returns profile_created when the backend auto-creates a profile - Add disconnectX to clear the linked X profile - Expose XConnectSession type and regenerate method action types --- packages/profile-controller/CHANGELOG.md | 11 + .../ProfileController-method-action-types.ts | 52 ++- .../src/ProfileController.test.ts | 310 +++++++++++++++++- .../src/ProfileController.ts | 129 +++++++- .../src/ProfileService-method-action-types.ts | 24 +- .../src/ProfileService.test.ts | 78 +++++ .../profile-controller/src/ProfileService.ts | 55 +++- packages/profile-controller/src/index.ts | 6 + 8 files changed, 637 insertions(+), 28 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index fee46878524..65547e2fcb0 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -19,6 +19,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) - Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) +- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token) and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint ([#0000](https://github.com/MetaMask/core/pull/0000)) + +### Changed + +- `ProfileController.startXConnect` takes an optional params object and forwards an optional CAIP-10 `linkedAddress` to the backend; the profile is resolved server-side from the verified bearer token ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING**: `ProfileController.connectX` now takes `{ code, state, profileId }`, always fetches the profile from the backend after the connect (the backend auto-creates the profile during X connect when missing), persists both `profile` and `xProfile` in state, and returns `{ profile, xProfile, profileCreated }` instead of just the X profile ([#0000](https://github.com/MetaMask/core/pull/0000)) +- `ProfileService.connectX` response now includes the optional `profile_created` boolean reported by the backend (optional so older backends without the field still validate) ([#0000](https://github.com/MetaMask/core/pull/0000)) +- `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#0000](https://github.com/MetaMask/core/pull/0000)) +- `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#0000](https://github.com/MetaMask/core/pull/0000)) [Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-controller@1.0.1...HEAD [1.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-controller@1.0.0...@metamask/profile-controller@1.0.1 diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 34e6a57f125..e903f142e75 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -81,12 +81,23 @@ export type ProfileControllerCheckUsernameAvailabilityAction = { }; /** - * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. + * Completes the X OAuth flow, updates xProfile and profile in state, and + * returns the connect result. The backend creates the profile if it does + * not exist yet (username derived from the X handle), so the profile is + * always fetched from the backend after a successful connect. * - * @param params - The parameters for the X OAuth PKCE flow. + * The linked X profile is persisted in state immediately after the connect + * succeeds. If the follow-up profile fetch fails, the X link remains + * persisted and a clear error is thrown — the profile in state may then be + * stale until the next successful fetch. + * + * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. * @param params.state - The state parameter returned by the X redirect. - * @returns The linked X profile. + * @param params.profileId - The canonical profile ID the X account is + * linked to (sourced from the auth session). + * @returns The refreshed profile, the linked X profile, and whether the + * backend created the profile during the connect. */ export type ProfileControllerConnectXAction = { type: `ProfileController:connectX`; @@ -94,7 +105,9 @@ export type ProfileControllerConnectXAction = { }; /** - * Fetches the X account linked to the current profile, updates state, and returns the X profile. + * Fetches the X account linked to the authenticated profile, updates state, + * and returns the X profile. The profile is resolved server-side from the + * verified bearer token. * * @returns The linked X profile. */ @@ -103,6 +116,33 @@ export type ProfileControllerFetchAndUpdateXAccountAction = { handler: ProfileController['fetchAndUpdateXAccount']; }; +/** + * Initiates the X OAuth flow by fetching the authorization URL from the backend. + * The profile is resolved server-side from the verified bearer token. + * Returns session data for the caller to use; nothing is stored in controller state. + * + * @param params - Optional parameters for initiating the X OAuth flow. + * @param params.linkedAddress - Optional CAIP-10 account ID to link to the + * profile when it does not exist yet. + * @returns The X authorization URL and state parameter. + */ +export type ProfileControllerStartXConnectAction = { + type: `ProfileController:startXConnect`; + handler: ProfileController['startXConnect']; +}; + +/** + * Disconnects the X account linked to the given profile, clears xProfile from + * state, and marks the profile in state as no longer connected to X. + * + * @param profileId - The ID of the profile to disconnect the linked X account + * from (sourced from {@link getProfile}). + */ +export type ProfileControllerDisconnectXAction = { + type: `ProfileController:disconnectX`; + handler: ProfileController['disconnectX']; +}; + /** * Union of all ProfileController action types. */ @@ -115,4 +155,6 @@ export type ProfileControllerMethodActions = | ProfileControllerDeleteProfileAction | ProfileControllerCheckUsernameAvailabilityAction | ProfileControllerConnectXAction - | ProfileControllerFetchAndUpdateXAccountAction; + | ProfileControllerFetchAndUpdateXAccountAction + | ProfileControllerStartXConnectAction + | ProfileControllerDisconnectXAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 64c242358e6..ead08d81f21 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -66,6 +66,17 @@ const mockMappedXProfile = { updatedAt: '2024-01-02T00:00:00Z', }; +const mockXAuthUrlResponse = { + url: 'https://x.com/i/oauth2/authorize?code_challenge=challenge-123', + state: 'oauth-state-123', +}; + +const mockXConnectSession = { + authorizationUrl: + 'https://x.com/i/oauth2/authorize?code_challenge=challenge-123', + state: 'oauth-state-123', +}; + const mockAvailabilityResponse = { username: 'alice', available: true, @@ -99,7 +110,10 @@ function getMessenger( 'ProfileService:deleteProfile', 'ProfileService:checkUsernameAvailability', 'ProfileService:connectX', + 'ProfileService:getProfile', 'ProfileService:getXAccount', + 'ProfileService:getXAuthUrl', + 'ProfileService:disconnectX', ], events: [], messenger, @@ -555,22 +569,209 @@ describe('ProfileController', () => { }); describe('connectX', () => { - it('calls ProfileService:connectX with code and state and returns the X profile', async () => { + it('connects, fetches the profile, persists both in state, and returns the connect result', async () => { const rootMessenger = getRootMessenger(); - const connectXMock = jest.fn().mockResolvedValue(mockXConnectResponse); + const connectXMock = jest.fn().mockResolvedValue({ + ...mockXConnectResponse, + profile_created: true, + }); mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); + const getProfileMock = jest.fn().mockResolvedValue({ + ...mockProfileResponse, + connected_to_x: true, + }); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + getProfileMock, + ); + + const { controller, messenger } = createController({ rootMessenger }); + const stateChangedListener = jest.fn(); + messenger.subscribe( + 'ProfileController:stateChanged', + stateChangedListener, + ); - const { controller } = createController({ rootMessenger }); const result = await controller.connectX({ code: 'auth-code-123', state: 'state-xyz', + profileId: 'profile-123', }); expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz', }); - expect(result).toStrictEqual(mockMappedXProfile); + expect(getProfileMock).toHaveBeenCalledWith('profile-123'); + expect(result).toStrictEqual({ + profile: { ...mockMappedProfile, connectedToX: true }, + xProfile: mockMappedXProfile, + profileCreated: true, + }); + expect(controller.state.profile).toStrictEqual({ + ...mockMappedProfile, + connectedToX: true, + }); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + // xProfile is persisted in its own update before the profile fetch, + // then the profile update emits a second state change. + expect(stateChangedListener).toHaveBeenCalledTimes(2); + }); + + it('defaults profileCreated to false when the backend does not report it', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + jest.fn().mockResolvedValue(mockProfileResponse), + ); + + const { controller } = createController({ rootMessenger }); + const result = await controller.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + profileId: 'profile-123', + }); + + expect(result.profileCreated).toBe(false); + }); + + it('persists xProfile and rethrows when the follow-up profile fetch fails', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue({ + ...mockXConnectResponse, + profile_created: true, + }), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + jest.fn().mockRejectedValue(new Error('503 Service Unavailable')), + ); + + const { controller } = createController({ rootMessenger }); + + await expect( + controller.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + profileId: 'profile-123', + }), + ).rejects.toThrow( + 'ProfileController: connected the X account, but failed to fetch the profile afterwards', + ); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + // No profile was in state before, and none was fetched — it stays at + // the default (no profile). + expect(controller.state.profile.profileId).toBe(''); + }); + + it('is callable via messenger action', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + jest.fn().mockResolvedValue(mockProfileResponse), + ); + + const { controller } = createController({ rootMessenger }); + + const result = await rootMessenger.call('ProfileController:connectX', { + code: 'auth-code-123', + state: 'state-xyz', + profileId: 'profile-123', + }); + + expect(result).toStrictEqual({ + profile: mockMappedProfile, + xProfile: mockMappedXProfile, + profileCreated: false, + }); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + }); + + describe('startXConnect', () => { + it('calls ProfileService:getXAuthUrl and returns the authorization URL and state', async () => { + const rootMessenger = getRootMessenger(); + const getXAuthUrlMock = jest.fn().mockResolvedValue(mockXAuthUrlResponse); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + getXAuthUrlMock, + ); + + const { controller } = createController({ rootMessenger }); + const result = await controller.startXConnect(); + + expect(getXAuthUrlMock).toHaveBeenCalledTimes(1); + expect(getXAuthUrlMock).toHaveBeenCalledWith(); + expect(result).toStrictEqual(mockXConnectSession); + }); + + it('passes the linked address to ProfileService:getXAuthUrl when given', async () => { + const rootMessenger = getRootMessenger(); + const getXAuthUrlMock = jest.fn().mockResolvedValue(mockXAuthUrlResponse); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + getXAuthUrlMock, + ); + + const { controller } = createController({ rootMessenger }); + const result = await controller.startXConnect({ + linkedAddress: 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', + }); + + expect(getXAuthUrlMock).toHaveBeenCalledWith( + 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', + ); + expect(result).toStrictEqual(mockXConnectSession); + expect(controller.state.xProfile).toBeUndefined(); + }); + + it('does not update controller state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + jest.fn().mockResolvedValue(mockXAuthUrlResponse), + ); + + const { controller } = createController({ rootMessenger }); + const stateBefore = controller.state; + await controller.startXConnect(); + + expect(controller.state).toStrictEqual(stateBefore); + }); + + it('is callable via messenger action', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + jest.fn().mockResolvedValue(mockXAuthUrlResponse), + ); + + const { controller } = createController({ rootMessenger }); + const result = await rootMessenger.call('ProfileController:startXConnect'); + + expect(result).toStrictEqual(mockXConnectSession); expect(controller.state.xProfile).toBeUndefined(); }); }); @@ -578,16 +779,115 @@ describe('ProfileController', () => { describe('fetchAndUpdateXAccount', () => { it('calls ProfileService:getXAccount and updates xProfile in state', async () => { const rootMessenger = getRootMessenger(); + const getXAccountMock = jest.fn().mockResolvedValue(mockXConnectResponse); mockServiceAction( rootMessenger, 'ProfileService:getXAccount', - jest.fn().mockResolvedValue(mockXConnectResponse), + getXAccountMock, ); const { controller } = createController({ rootMessenger }); await controller.fetchAndUpdateXAccount(); + expect(getXAccountMock).toHaveBeenCalledTimes(1); + expect(getXAccountMock).toHaveBeenCalledWith(); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + }); + + describe('disconnectX', () => { + it('calls ProfileService:disconnectX with the profile ID, clears xProfile, and sets connectedToX to false', async () => { + const rootMessenger = getRootMessenger(); + const disconnectXMock = jest.fn().mockResolvedValue(undefined); + mockServiceAction( + rootMessenger, + 'ProfileService:disconnectX', + disconnectXMock, + ); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAccount', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + + const { controller, messenger } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + connectedToX: true, + }, + }, + }); + await controller.fetchAndUpdateXAccount(); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + + const stateChangedListener = jest.fn(); + messenger.subscribe( + 'ProfileController:stateChanged', + stateChangedListener, + ); + + await controller.disconnectX('profile-123'); + + expect(disconnectXMock).toHaveBeenCalledTimes(1); + expect(disconnectXMock).toHaveBeenCalledWith('profile-123'); + expect(controller.state.xProfile).toBeUndefined(); + expect(controller.state.profile.connectedToX).toBe(false); + expect(stateChangedListener).toHaveBeenCalledTimes(1); + }); + + it('does not change profile.connectedToX when the profile ID does not match state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:disconnectX', + jest.fn().mockResolvedValue(undefined), + ); + + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + connectedToX: true, + }, + }, + }); + + await controller.disconnectX('other-profile'); + + expect(controller.state.xProfile).toBeUndefined(); + expect(controller.state.profile.connectedToX).toBe(true); + }); + + it('is callable via messenger action', async () => { + const rootMessenger = getRootMessenger(); + const disconnectXMock = jest.fn().mockResolvedValue(undefined); + mockServiceAction( + rootMessenger, + 'ProfileService:disconnectX', + disconnectXMock, + ); + + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + connectedToX: true, + }, + }, + }); + + await rootMessenger.call('ProfileController:disconnectX', 'profile-123'); + + expect(disconnectXMock).toHaveBeenCalledWith('profile-123'); + expect(controller.state.xProfile).toBeUndefined(); + expect(controller.state.profile.connectedToX).toBe(false); }); }); }); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index d27789b0e19..5cd8bb116b6 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -13,7 +13,10 @@ import type { ProfileServiceConnectXAction, ProfileServiceCreateProfileAction, ProfileServiceDeleteProfileAction, + ProfileServiceDisconnectXAction, + ProfileServiceGetProfileAction, ProfileServiceGetXAccountAction, + ProfileServiceGetXAuthUrlAction, ProfileServiceReplaceProfileAction, ProfileServiceUpdateProfileAction, } from './ProfileService-method-action-types.js'; @@ -73,6 +76,24 @@ export type XProfile = { updatedAt: string; }; +/** Ephemeral session data for initiating the X OAuth flow. Not stored in controller state. */ +export type XConnectSession = { + /** The X authorization URL to open in a system browser. */ + authorizationUrl: string; + /** The OAuth state parameter to compare against the X redirect callback. */ + state: string; +}; + +/** Result of completing the X connect flow. */ +export type XConnectResult = { + /** The MetaMask profile, refreshed from the backend after the connect. */ + profile: Profile; + /** The linked X profile. */ + xProfile: XProfile; + /** Whether the backend created the MetaMask profile during the connect. */ + profileCreated: boolean; +}; + /** State managed by ProfileController. */ export type ProfileControllerState = { profile: Profile; @@ -108,7 +129,10 @@ type AllowedActions = | ProfileServiceDeleteProfileAction | ProfileServiceCheckUsernameAvailabilityAction | ProfileServiceConnectXAction - | ProfileServiceGetXAccountAction; + | ProfileServiceGetProfileAction + | ProfileServiceGetXAccountAction + | ProfileServiceGetXAuthUrlAction + | ProfileServiceDisconnectXAction; export type AllowedEvents = never; @@ -168,6 +192,8 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkUsernameAvailability', 'connectX', 'fetchAndUpdateXAccount', + 'startXConnect', + 'disconnectX', ] as const; // === CONTROLLER === @@ -379,23 +405,63 @@ export class ProfileController extends BaseController< } /** - * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. + * Completes the X OAuth flow, updates xProfile and profile in state, and + * returns the connect result. The backend creates the profile if it does + * not exist yet (username derived from the X handle), so the profile is + * always fetched from the backend after a successful connect. + * + * The linked X profile is persisted in state immediately after the connect + * succeeds. If the follow-up profile fetch fails, the X link remains + * persisted and a clear error is thrown — the profile in state may then be + * stale until the next successful fetch. * - * @param params - The parameters for the X OAuth PKCE flow. + * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. * @param params.state - The state parameter returned by the X redirect. - * @returns The linked X profile. + * @param params.profileId - The canonical profile ID the X account is + * linked to (sourced from the auth session). + * @returns The refreshed profile, the linked X profile, and whether the + * backend created the profile during the connect. */ - async connectX(params: { code: string; state: string }): Promise { - const response = await this.messenger.call( - 'ProfileService:connectX', - params, - ); - return this.#mapXResponseToXProfile(response); + async connectX(params: { + code: string; + state: string; + profileId: string; + }): Promise { + const response = await this.messenger.call('ProfileService:connectX', { + code: params.code, + state: params.state, + }); + const profileCreated = response.profile_created ?? false; + const xProfile = this.#mapXResponseToXProfile(response); + this.update((state) => { + state.xProfile = xProfile; + }); + + let profile: Profile; + try { + const profileResponse = await this.messenger.call( + 'ProfileService:getProfile', + params.profileId, + ); + profile = this.#mapApiResponseToProfile(profileResponse); + } catch (error) { + throw new Error( + 'ProfileController: connected the X account, but failed to fetch the profile afterwards; the X link is persisted and the profile fetch can be retried', + { cause: error }, + ); + } + this.update((state) => { + state.profile = profile; + }); + + return { profile, xProfile, profileCreated }; } /** - * Fetches the X account linked to the current profile, updates state, and returns the X profile. + * Fetches the X account linked to the authenticated profile, updates state, + * and returns the X profile. The profile is resolved server-side from the + * verified bearer token. * * @returns The linked X profile. */ @@ -407,4 +473,45 @@ export class ProfileController extends BaseController< }); return mapped; } + + /** + * Initiates the X OAuth flow by fetching the authorization URL from the backend. + * The profile is resolved server-side from the verified bearer token. + * Returns session data for the caller to use; nothing is stored in controller state. + * + * @param params - Optional parameters for initiating the X OAuth flow. + * @param params.linkedAddress - Optional CAIP-10 account ID to link to the + * profile when it does not exist yet. + * @returns The X authorization URL and state parameter. + */ + async startXConnect(params?: { + linkedAddress?: CaipAccountId; + }): Promise { + const { linkedAddress } = params ?? {}; + const response = + linkedAddress === undefined + ? await this.messenger.call('ProfileService:getXAuthUrl') + : await this.messenger.call( + 'ProfileService:getXAuthUrl', + linkedAddress, + ); + return { authorizationUrl: response.url, state: response.state }; + } + + /** + * Disconnects the X account linked to the given profile, clears xProfile from + * state, and marks the profile in state as no longer connected to X. + * + * @param profileId - The ID of the profile to disconnect the linked X account + * from (sourced from {@link getProfile}). + */ + async disconnectX(profileId: string): Promise { + await this.messenger.call('ProfileService:disconnectX', profileId); + this.update((state) => { + state.xProfile = undefined; + if (state.profile.profileId === profileId) { + state.profile.connectedToX = false; + } + }); + } } diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 146dff93f69..4f98eec326f 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -86,7 +86,10 @@ export type ProfileServiceCheckUsernameAvailabilityAction = { /** * Fetches the X OAuth PKCE authorization URL and its associated state parameter. + * The profile is resolved server-side from the verified bearer token. * + * @param linkedAddress - Optional CAIP-10 account ID to link to the profile + * when it does not exist yet; sent as the `linked_address` query parameter. * @returns An object containing the authorization URL and the state token. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. @@ -98,9 +101,12 @@ export type ProfileServiceGetXAuthUrlAction = { /** * Completes the X OAuth PKCE flow and links the X account to the profile. + * The backend creates the profile if it does not exist yet (username derived + * from the X handle) and reports that via `profile_created`. * * @param params - The OAuth callback code and state from the X redirect. - * @returns The linked X account data. + * @returns The linked X account data, plus `profile_created` when the + * backend created the profile during the connect. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ @@ -111,6 +117,7 @@ export type ProfileServiceConnectXAction = { /** * Fetches the X account currently linked to the authenticated profile. + * The profile is resolved server-side from the verified bearer token. * * @returns The linked X account data. * @throws {HttpError} If the API returns a non-2xx response. @@ -121,6 +128,18 @@ export type ProfileServiceGetXAccountAction = { handler: ProfileService['getXAccount']; }; +/** + * Disconnects the X account linked to the given profile. + * + * @param profileId - The ID of the profile to disconnect the linked X account from. + * @returns The result of the mutation. + * @throws {HttpError} If the API returns a non-2xx response. + */ +export type ProfileServiceDisconnectXAction = { + type: `ProfileService:disconnectX`; + handler: ProfileService['disconnectX']; +}; + /** * Union of all ProfileService action types. */ @@ -133,4 +152,5 @@ export type ProfileServiceMethodActions = | ProfileServiceCheckUsernameAvailabilityAction | ProfileServiceGetXAuthUrlAction | ProfileServiceConnectXAction - | ProfileServiceGetXAccountAction; + | ProfileServiceGetXAccountAction + | ProfileServiceDisconnectXAction; diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index b12e95d1572..366c7c4e62a 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -433,6 +433,25 @@ describe('ProfileService', () => { ); }); + it('sends the linked_address query parameter when a linked address is given', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAuthUrlResponse), + }); + + const service = createService(); + const result = await service.getXAuthUrl( + 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', + ); + + expect(result).toStrictEqual(mockAuthUrlResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/x/authentication-url?linked_address=eip155%3A0%3A0x1234567890abcdef1234567890abcdef12345678`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + it('throws HttpError on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); @@ -484,6 +503,26 @@ describe('ProfileService', () => { }); }); + it('parses profile_created when the backend reports it', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + ...mockXConnectResponse, + profile_created: true, + }), + }); + + const service = createService(); + const result = await service.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + }); + + expect(result.profile_created).toBe(true); + }); + it('throws HttpError on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 401 }); @@ -550,4 +589,43 @@ describe('ProfileService', () => { ); }); }); + + describe('disconnectX', () => { + it('sends DELETE to the profile X endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => null }); + + const service = createService(); + await service.disconnectX('profile-123'); + + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/profile-123/x`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }, + ); + }); + + it('encodes the profile ID in the URL', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => null }); + + const service = createService(); + await service.disconnectX('user/with/slashes'); + + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/user%2Fwith%2Fslashes/x`, + expect.anything(), + ); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = createService(); + + await expect(service.disconnectX('profile-123')).rejects.toThrow( + `${ProfileServiceErrorMessage.DISCONNECT_X_FAILED}: 500`, + ); + }); + }); }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 9f66bdcbd88..67d411c7cdd 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -22,6 +22,7 @@ import { import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { CaipAccountIdStruct } from '@metamask/utils'; +import type { CaipAccountId } from '@metamask/utils'; import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; @@ -43,6 +44,7 @@ export const ProfileServiceErrorMessage = { GET_X_AUTH_URL_FAILED: 'ProfileService: failed to get X authentication URL', CONNECT_X_FAILED: 'ProfileService: failed to connect X account', GET_X_ACCOUNT_FAILED: 'ProfileService: failed to get X account', + DISCONNECT_X_FAILED: 'ProfileService: failed to disconnect X account', } as const; // --------------------------------------------------------------------------- @@ -92,6 +94,17 @@ const XConnectResponseStruct = structType({ updated_at: string(), }); +/** + * Response shape for POST /v1/profiles/x/connect: the linked X account plus + * `profile_created`, indicating whether the backend created the MetaMask + * profile during the connect. Optional so older backends that do not send + * the field still validate. + */ +const ConnectXResponseStruct = intersection([ + XConnectResponseStruct, + structType({ profile_created: optional(boolean()) }), +]); + const CreateProfileResponseStruct = intersection([ ProfileApiResponseStruct, structType({ x_profile: optional(XConnectResponseStruct) }), @@ -144,6 +157,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'getXAuthUrl', 'connectX', 'getXAccount', + 'disconnectX', ] as const; /** The shape of a profile returned by the MetaMask Profile API. */ @@ -160,6 +174,9 @@ export type UsernameAvailabilityResponse = Infer< /** The response shape returned when connecting or fetching an X account. */ export type XConnectResponse = Infer; +/** The response shape returned when completing the X connect flow. */ +export type ConnectXResponse = Infer; + /** The response shape returned when requesting the X OAuth authentication URL. */ export type XAuthUrlResponse = Infer; @@ -478,36 +495,45 @@ export class ProfileService extends BaseDataService< /** * Fetches the X OAuth PKCE authorization URL and its associated state parameter. + * The profile is resolved server-side from the verified bearer token. * + * @param linkedAddress - Optional CAIP-10 account ID to link to the profile + * when it does not exist yet; sent as the `linked_address` query parameter. * @returns An object containing the authorization URL and the state token. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ - async getXAuthUrl(): Promise { + async getXAuthUrl(linkedAddress?: CaipAccountId): Promise { return this.executeMutation({ mutationKey: [`${this.name}:getXAuthUrl`], responseStruct: XAuthUrlResponseStruct, mutationFn: async () => this.#fetch('profiles/x/authentication-url', { error: ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, + ...(linkedAddress === undefined + ? {} + : { searchParams: { linked_address: linkedAddress } }), }), }); } /** * Completes the X OAuth PKCE flow and links the X account to the profile. + * The backend creates the profile if it does not exist yet (username derived + * from the X handle) and reports that via `profile_created`. * * @param params - The OAuth callback code and state from the X redirect. - * @returns The linked X account data. + * @returns The linked X account data, plus `profile_created` when the + * backend created the profile during the connect. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ - async connectX(params: ConnectXParams): Promise { + async connectX(params: ConnectXParams): Promise { return this.executeMutation({ mutationKey: [`${this.name}:connectX`], - responseStruct: XConnectResponseStruct, + responseStruct: ConnectXResponseStruct, mutationFn: async () => - this.#fetch('profiles/x/connect', { + this.#fetch('profiles/x/connect', { method: 'POST', error: ProfileServiceErrorMessage.CONNECT_X_FAILED, json: params, @@ -517,6 +543,7 @@ export class ProfileService extends BaseDataService< /** * Fetches the X account currently linked to the authenticated profile. + * The profile is resolved server-side from the verified bearer token. * * @returns The linked X account data. * @throws {HttpError} If the API returns a non-2xx response. @@ -533,4 +560,22 @@ export class ProfileService extends BaseDataService< }), }); } + + /** + * Disconnects the X account linked to the given profile. + * + * @param profileId - The ID of the profile to disconnect the linked X account from. + * @returns The result of the mutation. + * @throws {HttpError} If the API returns a non-2xx response. + */ + async disconnectX(profileId: string): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:disconnectX`, profileId], + mutationFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}/x`, { + method: 'DELETE', + error: ProfileServiceErrorMessage.DISCONNECT_X_FAILED, + }), + }); + } } diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index b0193cd03be..6899fb26a8c 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -1,6 +1,8 @@ export type { Profile, XProfile, + XConnectSession, + XConnectResult, ProfileControllerState, ProfileControllerGetStateAction, ProfileControllerActions, @@ -17,10 +19,12 @@ export type { ProfileControllerConnectXAction, ProfileControllerCreateProfileAction, ProfileControllerDeleteProfileAction, + ProfileControllerDisconnectXAction, ProfileControllerGetProfileAction, ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXProfileAction, ProfileControllerReplaceProfileAction, + ProfileControllerStartXConnectAction, ProfileControllerUpdateProfileAction, } from './ProfileController-method-action-types.js'; export type { @@ -33,6 +37,7 @@ export type { UsernameAvailabilityResponse, XAuthUrlResponse, XConnectResponse, + ConnectXResponse, XAccountResponse, ProfileServiceActions, ProfileServiceEvents, @@ -47,6 +52,7 @@ export type { ProfileServiceConnectXAction, ProfileServiceCreateProfileAction, ProfileServiceDeleteProfileAction, + ProfileServiceDisconnectXAction, ProfileServiceGetProfileAction, ProfileServiceGetXAccountAction, ProfileServiceGetXAuthUrlAction, From 0e99e1b313fc5d6ff3afc295d1f48a38568c9107 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 2 Oct 2026 20:56:43 +0800 Subject: [PATCH 02/11] fix(profile-controller): treat 404 on disconnectX as already disconnected --- packages/profile-controller/CHANGELOG.md | 4 +-- .../src/ProfileController.test.ts | 4 ++- .../src/ProfileService-method-action-types.ts | 4 ++- .../src/ProfileService.test.ts | 10 ++++++++ .../profile-controller/src/ProfileService.ts | 25 ++++++++++++++----- 5 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 65547e2fcb0..8f066861c9d 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -17,11 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) +- Add `ProfileController` for managing user profile state, exposing `getProfile`, `getXProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `connectX`, `fetchAndUpdateXAccount`, `startXConnect`, and `disconnectX` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) - Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) - Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token) and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#0000](https://github.com/MetaMask/core/pull/0000)) - Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#0000](https://github.com/MetaMask/core/pull/0000)) ### Changed diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index ead08d81f21..85e76bdeeaa 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -769,7 +769,9 @@ describe('ProfileController', () => { ); const { controller } = createController({ rootMessenger }); - const result = await rootMessenger.call('ProfileController:startXConnect'); + const result = await rootMessenger.call( + 'ProfileController:startXConnect', + ); expect(result).toStrictEqual(mockXConnectSession); expect(controller.state.xProfile).toBeUndefined(); diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 4f98eec326f..6622c00310a 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -130,10 +130,12 @@ export type ProfileServiceGetXAccountAction = { /** * Disconnects the X account linked to the given profile. + * A `404` response is treated as success (the X account is already + * disconnected, or the profile is unknown), making the operation idempotent. * * @param profileId - The ID of the profile to disconnect the linked X account from. * @returns The result of the mutation. - * @throws {HttpError} If the API returns a non-2xx response. + * @throws {HttpError} If the API returns a non-2xx response other than `404`. */ export type ProfileServiceDisconnectXAction = { type: `ProfileService:disconnectX`; diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 366c7c4e62a..d000fc66ef2 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -618,6 +618,16 @@ describe('ProfileService', () => { ); }); + it('resolves when the API returns 404 (already disconnected)', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + + const service = createService(); + + await service.disconnectX('profile-123'); + + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + it('throws HttpError on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 67d411c7cdd..abaff4436fd 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -563,19 +563,32 @@ export class ProfileService extends BaseDataService< /** * Disconnects the X account linked to the given profile. + * A `404` response is treated as success (the X account is already + * disconnected, or the profile is unknown), making the operation idempotent. * * @param profileId - The ID of the profile to disconnect the linked X account from. * @returns The result of the mutation. - * @throws {HttpError} If the API returns a non-2xx response. + * @throws {HttpError} If the API returns a non-2xx response other than `404`. */ async disconnectX(profileId: string): Promise { return this.executeMutation({ mutationKey: [`${this.name}:disconnectX`, profileId], - mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}/x`, { - method: 'DELETE', - error: ProfileServiceErrorMessage.DISCONNECT_X_FAILED, - }), + mutationFn: async () => { + try { + return await this.#fetch( + `profiles/${encodeURIComponent(profileId)}/x`, + { + method: 'DELETE', + error: ProfileServiceErrorMessage.DISCONNECT_X_FAILED, + }, + ); + } catch (error) { + if (error instanceof HttpError && error.httpStatus === 404) { + return null; + } + throw error; + } + }, }); } } From 65ef6056518de9644de582081470a4906c7ffd5e Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 2 Oct 2026 21:16:05 +0800 Subject: [PATCH 03/11] chore(profile-controller): move X connect changelog entries to Unreleased --- packages/profile-controller/CHANGELOG.md | 26 +++++++++++++----------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 8f066861c9d..7de0d5a79af 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -7,30 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -## [1.0.1] - -### Changed - -- Bump `@metamask/profile-sync-controller` from `^33.0.0` to `^34.0.0` ([#10662](https://github.com/MetaMask/core/pull/10662)) - -## [1.0.0] - ### Added -- Add `ProfileController` for managing user profile state, exposing `getProfile`, `getXProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `connectX`, `fetchAndUpdateXAccount`, `startXConnect`, and `disconnectX` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) -- Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) -- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token) and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl`, forwarding an optional CAIP-10 `linkedAddress` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#0000](https://github.com/MetaMask/core/pull/0000)) - Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#0000](https://github.com/MetaMask/core/pull/0000)) - Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#0000](https://github.com/MetaMask/core/pull/0000)) ### Changed -- `ProfileController.startXConnect` takes an optional params object and forwards an optional CAIP-10 `linkedAddress` to the backend; the profile is resolved server-side from the verified bearer token ([#0000](https://github.com/MetaMask/core/pull/0000)) - **BREAKING**: `ProfileController.connectX` now takes `{ code, state, profileId }`, always fetches the profile from the backend after the connect (the backend auto-creates the profile during X connect when missing), persists both `profile` and `xProfile` in state, and returns `{ profile, xProfile, profileCreated }` instead of just the X profile ([#0000](https://github.com/MetaMask/core/pull/0000)) - `ProfileService.connectX` response now includes the optional `profile_created` boolean reported by the backend (optional so older backends without the field still validate) ([#0000](https://github.com/MetaMask/core/pull/0000)) - `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#0000](https://github.com/MetaMask/core/pull/0000)) - `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#0000](https://github.com/MetaMask/core/pull/0000)) +## [1.0.1] + +### Changed + +- Bump `@metamask/profile-sync-controller` from `^33.0.0` to `^34.0.0` ([#10662](https://github.com/MetaMask/core/pull/10662)) + +## [1.0.0] + +### Added + +- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) +- Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) + [Unreleased]: https://github.com/MetaMask/core/compare/@metamask/profile-controller@1.0.1...HEAD [1.0.1]: https://github.com/MetaMask/core/compare/@metamask/profile-controller@1.0.0...@metamask/profile-controller@1.0.1 [1.0.0]: https://github.com/MetaMask/core/releases/tag/@metamask/profile-controller@1.0.0 From 3b264bf6dba8f6c9a8fb7f344ca7df8e551c6d6d Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 2 Oct 2026 21:16:58 +0800 Subject: [PATCH 04/11] chore(profile-controller): link changelog entries to #10668 --- packages/profile-controller/CHANGELOG.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 7de0d5a79af..4927c2de2dc 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -9,16 +9,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl`, forwarding an optional CAIP-10 `linkedAddress` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#0000](https://github.com/MetaMask/core/pull/0000)) -- Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#0000](https://github.com/MetaMask/core/pull/0000)) +- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl`, forwarding an optional CAIP-10 `linkedAddress` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#10668](https://github.com/MetaMask/core/pull/10668)) +- Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#10668](https://github.com/MetaMask/core/pull/10668)) +- Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#10668](https://github.com/MetaMask/core/pull/10668)) ### Changed -- **BREAKING**: `ProfileController.connectX` now takes `{ code, state, profileId }`, always fetches the profile from the backend after the connect (the backend auto-creates the profile during X connect when missing), persists both `profile` and `xProfile` in state, and returns `{ profile, xProfile, profileCreated }` instead of just the X profile ([#0000](https://github.com/MetaMask/core/pull/0000)) -- `ProfileService.connectX` response now includes the optional `profile_created` boolean reported by the backend (optional so older backends without the field still validate) ([#0000](https://github.com/MetaMask/core/pull/0000)) -- `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#0000](https://github.com/MetaMask/core/pull/0000)) -- `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#0000](https://github.com/MetaMask/core/pull/0000)) +- **BREAKING**: `ProfileController.connectX` now takes `{ code, state, profileId }`, always fetches the profile from the backend after the connect (the backend auto-creates the profile during X connect when missing), persists both `profile` and `xProfile` in state, and returns `{ profile, xProfile, profileCreated }` instead of just the X profile ([#10668](https://github.com/MetaMask/core/pull/10668)) +- `ProfileService.connectX` response now includes the optional `profile_created` boolean reported by the backend (optional so older backends without the field still validate) ([#10668](https://github.com/MetaMask/core/pull/10668)) +- `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#10668](https://github.com/MetaMask/core/pull/10668)) +- `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#10668](https://github.com/MetaMask/core/pull/10668)) ## [1.0.1] From 628365309235aecd1a54a64a5a7fc80e755260d8 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Oct 2026 19:55:46 +0800 Subject: [PATCH 05/11] fix(profile-controller): expose correct stateChanged event type in messenger Use `ControllerStateChangedEvent` instead of the deprecated `ControllerStateChangeEvent` so the messenger event type matches the `ProfileController:stateChanged` event that BaseController emits. --- packages/profile-controller/CHANGELOG.md | 4 ++++ packages/profile-controller/src/ProfileController.ts | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 4927c2de2dc..5b0b12533df 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -20,6 +20,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#10668](https://github.com/MetaMask/core/pull/10668)) - `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#10668](https://github.com/MetaMask/core/pull/10668)) +### Fixed + +- Fix the `ProfileController` messenger event type: the controller exposes `ProfileController:stateChanged` (emitted on state changes), not `ProfileController:stateChange` ([#10668](https://github.com/MetaMask/core/pull/10668)) + ## [1.0.1] ### Changed diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 5cd8bb116b6..6769c3d1fda 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -1,7 +1,7 @@ import { BaseController } from '@metamask/base-controller'; import type { ControllerGetStateAction, - ControllerStateChangeEvent, + ControllerStateChangedEvent, StateMetadata, } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; @@ -114,7 +114,7 @@ export type ProfileControllerActions = | ProfileControllerMethodActions; /** The `ProfileController:stateChanged` event type. */ -export type ProfileControllerChangeEvent = ControllerStateChangeEvent< +export type ProfileControllerChangeEvent = ControllerStateChangedEvent< typeof controllerName, ProfileControllerState >; From 5714689fe5d8e0fc0b77fb7ec10a18a1995ebd5a Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Thu, 8 Oct 2026 21:25:52 +0800 Subject: [PATCH 06/11] fix: invalidate cache on xauth change --- .../src/ProfileService.test.ts | 91 +++++++++++++++++++ .../profile-controller/src/ProfileService.ts | 12 ++- 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index d000fc66ef2..d0d3afc06dc 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -162,6 +162,21 @@ describe('ProfileService', () => { 'returned an unexpected response', ); }); + + it('serves repeated calls from the cache', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + await service.getProfile('profile-123'); + const result = await service.getProfile('profile-123'); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); }); describe('createProfile', () => { @@ -523,6 +538,56 @@ describe('ProfileService', () => { expect(result.profile_created).toBe(true); }); + it('invalidates the cached profile so the next getProfile reflects the connection', async () => { + const connectedProfileResponse = { + ...mockProfileResponse, + connected_to_x: true, + }; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockXConnectResponse), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(connectedProfileResponse), + }); + + const service = createService(); + await service.getProfile('profile-123'); + await service.connectX({ code: 'auth-code-123', state: 'state-xyz' }); + const result = await service.getProfile('profile-123'); + + expect(result).toStrictEqual(connectedProfileResponse); + }); + + it('keeps the cached profile when the connect request fails', async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }) + .mockResolvedValueOnce({ ok: false, status: 401 }); + + const service = createService(); + await service.getProfile('profile-123'); + await expect( + service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), + ).rejects.toThrow(`${ProfileServiceErrorMessage.CONNECT_X_FAILED}: 401`); + const result = await service.getProfile('profile-123'); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + it('throws HttpError on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 401 }); @@ -606,6 +671,32 @@ describe('ProfileService', () => { ); }); + it('invalidates the cached profile so the next getProfile reflects the disconnection', async () => { + const connectedProfileResponse = { + ...mockProfileResponse, + connected_to_x: true, + }; + mockFetch + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(connectedProfileResponse), + }) + .mockResolvedValueOnce({ ok: true, status: 204, json: () => null }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + await service.getProfile('profile-123'); + await service.disconnectX('profile-123'); + const result = await service.getProfile('profile-123'); + + expect(result).toStrictEqual(mockProfileResponse); + }); + it('encodes the profile ID in the URL', async () => { mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => null }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index abaff4436fd..ad1cc40cf16 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -529,7 +529,7 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If the response does not match the expected shape. */ async connectX(params: ConnectXParams): Promise { - return this.executeMutation({ + const response = await this.executeMutation({ mutationKey: [`${this.name}:connectX`], responseStruct: ConnectXResponseStruct, mutationFn: async () => @@ -539,6 +539,10 @@ export class ProfileService extends BaseDataService< json: params, }), }); + // The response does not identify the profile, so drop every cached + // profile: `connected_to_x` is now out of date. + await this.invalidateQueries({ queryKey: [`${this.name}:getProfile`] }); + return response; } /** @@ -571,7 +575,7 @@ export class ProfileService extends BaseDataService< * @throws {HttpError} If the API returns a non-2xx response other than `404`. */ async disconnectX(profileId: string): Promise { - return this.executeMutation({ + await this.executeMutation({ mutationKey: [`${this.name}:disconnectX`, profileId], mutationFn: async () => { try { @@ -590,5 +594,9 @@ export class ProfileService extends BaseDataService< } }, }); + // The cached profile no longer reflects `connected_to_x`. + await this.invalidateQueries({ + queryKey: [`${this.name}:getProfile`, profileId], + }); } } From bafdee59d58f965d49acb5981f587b48eaeb1308 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Fri, 9 Oct 2026 21:31:17 +0800 Subject: [PATCH 07/11] fix: update failed during x oauth --- .../ProfileController-method-action-types.ts | 5 +- .../src/ProfileController.test.ts | 64 ++++++++++++++++++- .../src/ProfileController.ts | 7 +- 3 files changed, 68 insertions(+), 8 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index e903f142e75..9a922f027b6 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -88,8 +88,9 @@ export type ProfileControllerCheckUsernameAvailabilityAction = { * * The linked X profile is persisted in state immediately after the connect * succeeds. If the follow-up profile fetch fails, the X link remains - * persisted and a clear error is thrown — the profile in state may then be - * stale until the next successful fetch. + * persisted, the profile in state is left unchanged, and a clear error is + * thrown. `connectX` must not be retried because the OAuth code is + * single-use. * * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 85e76bdeeaa..66c9670d9a3 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -670,9 +670,67 @@ describe('ProfileController', () => { 'ProfileController: connected the X account, but failed to fetch the profile afterwards', ); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); - // No profile was in state before, and none was fetched — it stays at - // the default (no profile). - expect(controller.state.profile.profileId).toBe(''); + expect(controller.state.profile).toStrictEqual( + getDefaultProfileControllerState().profile, + ); + }); + + it('leaves the existing profile in state unchanged when the follow-up fetch fails', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + jest.fn().mockRejectedValue(new Error('503 Service Unavailable')), + ); + + const { controller } = createController({ + rootMessenger, + state: { profile: mockMappedProfile }, + }); + + await expect( + controller.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + profileId: mockMappedProfile.profileId, + }), + ).rejects.toThrow('failed to fetch the profile afterwards'); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + + it('does not touch state or fetch the profile when the connect itself fails', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockRejectedValue(new Error('401 Unauthorized')), + ); + const getProfileMock = jest.fn(); + mockServiceAction( + rootMessenger, + 'ProfileService:getProfile', + getProfileMock, + ); + + const { controller } = createController({ rootMessenger }); + + await expect( + controller.connectX({ + code: 'bad-code', + state: 'state-xyz', + profileId: 'profile-123', + }), + ).rejects.toThrow('401 Unauthorized'); + expect(getProfileMock).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultProfileControllerState(), + ); }); it('is callable via messenger action', async () => { diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 6769c3d1fda..40760be89f5 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -412,8 +412,9 @@ export class ProfileController extends BaseController< * * The linked X profile is persisted in state immediately after the connect * succeeds. If the follow-up profile fetch fails, the X link remains - * persisted and a clear error is thrown — the profile in state may then be - * stale until the next successful fetch. + * persisted, the profile in state is left unchanged, and a clear error is + * thrown. `connectX` must not be retried because the OAuth code is + * single-use. * * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. @@ -447,7 +448,7 @@ export class ProfileController extends BaseController< profile = this.#mapApiResponseToProfile(profileResponse); } catch (error) { throw new Error( - 'ProfileController: connected the X account, but failed to fetch the profile afterwards; the X link is persisted and the profile fetch can be retried', + 'ProfileController: connected the X account, but failed to fetch the profile afterwards; the X link is persisted and the profile in state was not updated, so do not retry connectX (the OAuth code is single-use)', { cause: error }, ); } From 6981ed94777a4035a648518bceb18f2c400e3c4f Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 12 Oct 2026 15:40:46 +0800 Subject: [PATCH 08/11] fix: update x connection flow to only happen after profile creation --- packages/profile-controller/CHANGELOG.md | 8 +- .../ProfileController-method-action-types.ts | 32 +- .../src/ProfileController.test.ts | 280 ++++++------------ .../src/ProfileController.ts | 94 ++---- .../src/ProfileService-method-action-types.ts | 8 +- .../src/ProfileService.test.ts | 51 +--- .../profile-controller/src/ProfileService.ts | 40 +-- packages/profile-controller/src/index.ts | 6 +- 8 files changed, 147 insertions(+), 372 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 0eb9e5062a8..7720958851a 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -9,16 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `startXConnect` method to `ProfileController` for initiating the X OAuth flow. It fetches the authorization URL via `ProfileService:getXAuthUrl`, forwarding an optional CAIP-10 `linkedAddress` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#10668](https://github.com/MetaMask/core/pull/10668)) +- Add `startConnectX` method to `ProfileController` for initiating the X OAuth flow. It requires a profile to exist, fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#10668](https://github.com/MetaMask/core/pull/10668)) - Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#10668](https://github.com/MetaMask/core/pull/10668)) - Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#10668](https://github.com/MetaMask/core/pull/10668)) ### Changed -- **BREAKING**: `ProfileController.connectX` now takes `{ code, state, profileId }`, always fetches the profile from the backend after the connect (the backend auto-creates the profile during X connect when missing), persists both `profile` and `xProfile` in state, and returns `{ profile, xProfile, profileCreated }` instead of just the X profile ([#10668](https://github.com/MetaMask/core/pull/10668)) -- `ProfileService.connectX` response now includes the optional `profile_created` boolean reported by the backend (optional so older backends without the field still validate) ([#10668](https://github.com/MetaMask/core/pull/10668)) -- `ProfileService.getXAuthUrl` now accepts an optional CAIP-10 `linkedAddress` parameter, sent as the `linked_address` query parameter (required by the backend when the profile does not exist yet) ([#10668](https://github.com/MetaMask/core/pull/10668)) -- `ProfileController.connectX` persists `xProfile` in state immediately after a successful connect; if the follow-up profile fetch fails, the X link stays persisted and a clear error is thrown ([#10668](https://github.com/MetaMask/core/pull/10668)) +- **BREAKING:** `startConnectX` no longer accepts a `linkedAddress` parameter and now throws if no profile exists in state +- **BREAKING:** `connectX` now throws if no profile exists in state, and updates controller state (`xProfile`, `profile.connectedToX`) on success instead of deferring persistence to `createProfile`. `createProfile` no longer persists a linked X profile from its response - Bump `@metamask/profile-sync-controller` from `^34.0.0` to `^34.0.3` ([#10752](https://github.com/MetaMask/core/pull/10752), [#10756](https://github.com/MetaMask/core/pull/10756), [#10761](https://github.com/MetaMask/core/pull/10761)) ### Fixed diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 9a922f027b6..3933445a02f 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -27,7 +27,6 @@ export type ProfileControllerGetXProfileAction = { /** * Creates a new MetaMask profile, updates state, and returns the created profile. - * If the user had previously connected X, also updates xProfile in state. * * @param params - The profile creation parameters. * @returns The created MetaMask profile. @@ -81,24 +80,15 @@ export type ProfileControllerCheckUsernameAvailabilityAction = { }; /** - * Completes the X OAuth flow, updates xProfile and profile in state, and - * returns the connect result. The backend creates the profile if it does - * not exist yet (username derived from the X handle), so the profile is - * always fetched from the backend after a successful connect. - * - * The linked X profile is persisted in state immediately after the connect - * succeeds. If the follow-up profile fetch fails, the X link remains - * persisted, the profile in state is left unchanged, and a clear error is - * thrown. `connectX` must not be retried because the OAuth code is - * single-use. + * Completes the X OAuth flow for the profile in state and returns the + * linked X profile. Updates controller state with the linked X profile + * and marks the profile in state as connected to X. * * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. * @param params.state - The state parameter returned by the X redirect. - * @param params.profileId - The canonical profile ID the X account is - * linked to (sourced from the auth session). - * @returns The refreshed profile, the linked X profile, and whether the - * backend created the profile during the connect. + * @returns The linked X profile. + * @throws If no profile has been created yet. */ export type ProfileControllerConnectXAction = { type: `ProfileController:connectX`; @@ -122,14 +112,12 @@ export type ProfileControllerFetchAndUpdateXAccountAction = { * The profile is resolved server-side from the verified bearer token. * Returns session data for the caller to use; nothing is stored in controller state. * - * @param params - Optional parameters for initiating the X OAuth flow. - * @param params.linkedAddress - Optional CAIP-10 account ID to link to the - * profile when it does not exist yet. * @returns The X authorization URL and state parameter. + * @throws If no profile has been created yet. */ -export type ProfileControllerStartXConnectAction = { - type: `ProfileController:startXConnect`; - handler: ProfileController['startXConnect']; +export type ProfileControllerStartConnectXAction = { + type: `ProfileController:startConnectX`; + handler: ProfileController['startConnectX']; }; /** @@ -157,5 +145,5 @@ export type ProfileControllerMethodActions = | ProfileControllerCheckUsernameAvailabilityAction | ProfileControllerConnectXAction | ProfileControllerFetchAndUpdateXAccountAction - | ProfileControllerStartXConnectAction + | ProfileControllerStartConnectXAction | ProfileControllerDisconnectXAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 66c9670d9a3..6b969307cab 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -46,7 +46,7 @@ const mockMappedProfile: Profile = { updatedAt: '2024-01-02T00:00:00Z', }; -const mockXConnectResponse = { +const mockConnectXResponse = { x_user_id: 'x-user-123', x_profile_url: 'https://x.com/degengirl', username: 'degengirl', @@ -71,7 +71,7 @@ const mockXAuthUrlResponse = { state: 'oauth-state-123', }; -const mockXConnectSession = { +const mockConnectXSession = { authorizationUrl: 'https://x.com/i/oauth2/authorize?code_challenge=challenge-123', state: 'oauth-state-123', @@ -110,7 +110,6 @@ function getMessenger( 'ProfileService:deleteProfile', 'ProfileService:checkUsernameAvailability', 'ProfileService:connectX', - 'ProfileService:getProfile', 'ProfileService:getXAccount', 'ProfileService:getXAuthUrl', 'ProfileService:disconnectX', @@ -235,7 +234,7 @@ describe('ProfileController', () => { mockServiceAction( rootMessenger, 'ProfileService:getXAccount', - jest.fn().mockResolvedValue(mockXConnectResponse), + jest.fn().mockResolvedValue(mockConnectXResponse), ); const { controller } = createController({ rootMessenger }); @@ -351,14 +350,14 @@ describe('ProfileController', () => { expect(controller.state.profile.connectedToX).toBe(true); }); - it('also updates xProfile in state when x_profile is included in the response', async () => { + it('does not update xProfile in state even when x_profile is included in the response', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( rootMessenger, 'ProfileService:createProfile', jest.fn().mockResolvedValue({ ...mockProfileResponse, - x_profile: mockXConnectResponse, + x_profile: mockConnectXResponse, }), ); @@ -375,7 +374,8 @@ describe('ProfileController', () => { avatar_url: 'https://example.com/avatar.png', }); - expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); + expect(controller.state.xProfile).toBeUndefined(); }); it('is callable via messenger action', async () => { @@ -502,7 +502,7 @@ describe('ProfileController', () => { mockServiceAction( rootMessenger, 'ProfileService:getXAccount', - jest.fn().mockResolvedValue(mockXConnectResponse), + jest.fn().mockResolvedValue(mockConnectXResponse), ); mockServiceAction( rootMessenger, @@ -569,24 +569,36 @@ describe('ProfileController', () => { }); describe('connectX', () => { - it('connects, fetches the profile, persists both in state, and returns the connect result', async () => { + it('throws if no profile is set in state and does not call the service', async () => { const rootMessenger = getRootMessenger(); - const connectXMock = jest.fn().mockResolvedValue({ - ...mockXConnectResponse, - profile_created: true, - }); + const connectXMock = jest.fn().mockResolvedValue(mockConnectXResponse); mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); - const getProfileMock = jest.fn().mockResolvedValue({ - ...mockProfileResponse, - connected_to_x: true, - }); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - getProfileMock, + + const { controller } = createController({ rootMessenger }); + + await expect( + controller.connectX({ code: 'auth-code-123', state: 'state-xyz' }), + ).rejects.toThrow('ProfileController: no profile found in state'); + expect(connectXMock).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultProfileControllerState(), ); + }); + + it('calls ProfileService:connectX with code and state, returns the X profile, and persists it in state', async () => { + const rootMessenger = getRootMessenger(); + const connectXMock = jest.fn().mockResolvedValue(mockConnectXResponse); + mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); - const { controller, messenger } = createController({ rootMessenger }); + const { controller, messenger } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); const stateChangedListener = jest.fn(); messenger.subscribe( 'ProfileController:stateChanged', @@ -596,176 +608,74 @@ describe('ProfileController', () => { const result = await controller.connectX({ code: 'auth-code-123', state: 'state-xyz', - profileId: 'profile-123', }); expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz', }); - expect(getProfileMock).toHaveBeenCalledWith('profile-123'); - expect(result).toStrictEqual({ - profile: { ...mockMappedProfile, connectedToX: true }, - xProfile: mockMappedXProfile, - profileCreated: true, - }); - expect(controller.state.profile).toStrictEqual({ - ...mockMappedProfile, - connectedToX: true, - }); + expect(result).toStrictEqual(mockMappedXProfile); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); - // xProfile is persisted in its own update before the profile fetch, - // then the profile update emits a second state change. - expect(stateChangedListener).toHaveBeenCalledTimes(2); - }); - - it('defaults profileCreated to false when the backend does not report it', async () => { - const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:connectX', - jest.fn().mockResolvedValue(mockXConnectResponse), - ); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - jest.fn().mockResolvedValue(mockProfileResponse), - ); - - const { controller } = createController({ rootMessenger }); - const result = await controller.connectX({ - code: 'auth-code-123', - state: 'state-xyz', - profileId: 'profile-123', - }); - - expect(result.profileCreated).toBe(false); - }); - - it('persists xProfile and rethrows when the follow-up profile fetch fails', async () => { - const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:connectX', - jest.fn().mockResolvedValue({ - ...mockXConnectResponse, - profile_created: true, - }), - ); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - jest.fn().mockRejectedValue(new Error('503 Service Unavailable')), - ); - - const { controller } = createController({ rootMessenger }); - - await expect( - controller.connectX({ - code: 'auth-code-123', - state: 'state-xyz', - profileId: 'profile-123', - }), - ).rejects.toThrow( - 'ProfileController: connected the X account, but failed to fetch the profile afterwards', - ); - expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); - expect(controller.state.profile).toStrictEqual( - getDefaultProfileControllerState().profile, - ); + expect(controller.state.profile.connectedToX).toBe(true); + expect(stateChangedListener).toHaveBeenCalledTimes(1); }); - it('leaves the existing profile in state unchanged when the follow-up fetch fails', async () => { + it('does not touch state when the connect fails', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( rootMessenger, 'ProfileService:connectX', - jest.fn().mockResolvedValue(mockXConnectResponse), - ); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - jest.fn().mockRejectedValue(new Error('503 Service Unavailable')), + jest.fn().mockRejectedValue(new Error('401 Unauthorized')), ); const { controller } = createController({ rootMessenger, - state: { profile: mockMappedProfile }, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, }); await expect( - controller.connectX({ - code: 'auth-code-123', - state: 'state-xyz', - profileId: mockMappedProfile.profileId, - }), - ).rejects.toThrow('failed to fetch the profile afterwards'); - expect(controller.state.profile).toStrictEqual(mockMappedProfile); - expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); - }); - - it('does not touch state or fetch the profile when the connect itself fails', async () => { - const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:connectX', - jest.fn().mockRejectedValue(new Error('401 Unauthorized')), - ); - const getProfileMock = jest.fn(); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - getProfileMock, - ); - - const { controller } = createController({ rootMessenger }); - - await expect( - controller.connectX({ - code: 'bad-code', - state: 'state-xyz', - profileId: 'profile-123', - }), + controller.connectX({ code: 'bad-code', state: 'state-xyz' }), ).rejects.toThrow('401 Unauthorized'); - expect(getProfileMock).not.toHaveBeenCalled(); - expect(controller.state).toStrictEqual( - getDefaultProfileControllerState(), - ); + expect(controller.state.xProfile).toBeUndefined(); + expect(controller.state.profile.connectedToX).toBe(false); }); - it('is callable via messenger action', async () => { + it('is callable via messenger action and persists state', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( rootMessenger, 'ProfileService:connectX', - jest.fn().mockResolvedValue(mockXConnectResponse), - ); - mockServiceAction( - rootMessenger, - 'ProfileService:getProfile', - jest.fn().mockResolvedValue(mockProfileResponse), + jest.fn().mockResolvedValue(mockConnectXResponse), ); - const { controller } = createController({ rootMessenger }); + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); const result = await rootMessenger.call('ProfileController:connectX', { code: 'auth-code-123', state: 'state-xyz', - profileId: 'profile-123', }); - expect(result).toStrictEqual({ - profile: mockMappedProfile, - xProfile: mockMappedXProfile, - profileCreated: false, - }); - expect(controller.state.profile).toStrictEqual(mockMappedProfile); + expect(result).toStrictEqual(mockMappedXProfile); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + expect(controller.state.profile.connectedToX).toBe(true); }); }); - describe('startXConnect', () => { - it('calls ProfileService:getXAuthUrl and returns the authorization URL and state', async () => { + describe('startConnectX', () => { + it('throws if no profile is set in state and does not call the service', async () => { const rootMessenger = getRootMessenger(); const getXAuthUrlMock = jest.fn().mockResolvedValue(mockXAuthUrlResponse); mockServiceAction( @@ -775,14 +685,17 @@ describe('ProfileController', () => { ); const { controller } = createController({ rootMessenger }); - const result = await controller.startXConnect(); - expect(getXAuthUrlMock).toHaveBeenCalledTimes(1); - expect(getXAuthUrlMock).toHaveBeenCalledWith(); - expect(result).toStrictEqual(mockXConnectSession); + await expect(controller.startConnectX()).rejects.toThrow( + 'ProfileController: no profile found in state', + ); + expect(getXAuthUrlMock).not.toHaveBeenCalled(); + expect(controller.state).toStrictEqual( + getDefaultProfileControllerState(), + ); }); - it('passes the linked address to ProfileService:getXAuthUrl when given', async () => { + it('calls ProfileService:getXAuthUrl with no arguments, returns the authorization URL and state, and does not update controller state', async () => { const rootMessenger = getRootMessenger(); const getXAuthUrlMock = jest.fn().mockResolvedValue(mockXAuthUrlResponse); mockServiceAction( @@ -791,30 +704,21 @@ describe('ProfileController', () => { getXAuthUrlMock, ); - const { controller } = createController({ rootMessenger }); - const result = await controller.startXConnect({ - linkedAddress: 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', - }); - - expect(getXAuthUrlMock).toHaveBeenCalledWith( - 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', - ); - expect(result).toStrictEqual(mockXConnectSession); - expect(controller.state.xProfile).toBeUndefined(); - }); - - it('does not update controller state', async () => { - const rootMessenger = getRootMessenger(); - mockServiceAction( + const { controller } = createController({ rootMessenger, - 'ProfileService:getXAuthUrl', - jest.fn().mockResolvedValue(mockXAuthUrlResponse), - ); - - const { controller } = createController({ rootMessenger }); + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); const stateBefore = controller.state; - await controller.startXConnect(); + const result = await controller.startConnectX(); + expect(getXAuthUrlMock).toHaveBeenCalledTimes(1); + expect(getXAuthUrlMock).toHaveBeenCalledWith(); + expect(result).toStrictEqual(mockConnectXSession); expect(controller.state).toStrictEqual(stateBefore); }); @@ -826,12 +730,20 @@ describe('ProfileController', () => { jest.fn().mockResolvedValue(mockXAuthUrlResponse), ); - const { controller } = createController({ rootMessenger }); + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); const result = await rootMessenger.call( - 'ProfileController:startXConnect', + 'ProfileController:startConnectX', ); - expect(result).toStrictEqual(mockXConnectSession); + expect(result).toStrictEqual(mockConnectXSession); expect(controller.state.xProfile).toBeUndefined(); }); }); @@ -839,7 +751,7 @@ describe('ProfileController', () => { describe('fetchAndUpdateXAccount', () => { it('calls ProfileService:getXAccount and updates xProfile in state', async () => { const rootMessenger = getRootMessenger(); - const getXAccountMock = jest.fn().mockResolvedValue(mockXConnectResponse); + const getXAccountMock = jest.fn().mockResolvedValue(mockConnectXResponse); mockServiceAction( rootMessenger, 'ProfileService:getXAccount', @@ -867,7 +779,7 @@ describe('ProfileController', () => { mockServiceAction( rootMessenger, 'ProfileService:getXAccount', - jest.fn().mockResolvedValue(mockXConnectResponse), + jest.fn().mockResolvedValue(mockConnectXResponse), ); const { controller, messenger } = createController({ diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 40760be89f5..06325f7e7bd 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -14,7 +14,6 @@ import type { ProfileServiceCreateProfileAction, ProfileServiceDeleteProfileAction, ProfileServiceDisconnectXAction, - ProfileServiceGetProfileAction, ProfileServiceGetXAccountAction, ProfileServiceGetXAuthUrlAction, ProfileServiceReplaceProfileAction, @@ -27,7 +26,7 @@ import type { ReplaceProfileParams, UpdateProfileParams, UsernameAvailabilityResponse, - XConnectResponse, + ConnectXResponse, } from './ProfileService.js'; const controllerName = 'ProfileController'; @@ -77,23 +76,13 @@ export type XProfile = { }; /** Ephemeral session data for initiating the X OAuth flow. Not stored in controller state. */ -export type XConnectSession = { +export type ConnectXSession = { /** The X authorization URL to open in a system browser. */ authorizationUrl: string; /** The OAuth state parameter to compare against the X redirect callback. */ state: string; }; -/** Result of completing the X connect flow. */ -export type XConnectResult = { - /** The MetaMask profile, refreshed from the backend after the connect. */ - profile: Profile; - /** The linked X profile. */ - xProfile: XProfile; - /** Whether the backend created the MetaMask profile during the connect. */ - profileCreated: boolean; -}; - /** State managed by ProfileController. */ export type ProfileControllerState = { profile: Profile; @@ -129,7 +118,6 @@ type AllowedActions = | ProfileServiceDeleteProfileAction | ProfileServiceCheckUsernameAvailabilityAction | ProfileServiceConnectXAction - | ProfileServiceGetProfileAction | ProfileServiceGetXAccountAction | ProfileServiceGetXAuthUrlAction | ProfileServiceDisconnectXAction; @@ -192,7 +180,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkUsernameAvailability', 'connectX', 'fetchAndUpdateXAccount', - 'startXConnect', + 'startConnectX', 'disconnectX', ] as const; @@ -284,7 +272,7 @@ export class ProfileController extends BaseController< * @param response - The API response to map. * @returns The mapped X profile. */ - #mapXResponseToXProfile(response: XConnectResponse): XProfile { + #mapXResponseToXProfile(response: ConnectXResponse): XProfile { return { xUserId: response.x_user_id, xProfileUrl: response.x_profile_url, @@ -319,7 +307,6 @@ export class ProfileController extends BaseController< /** * Creates a new MetaMask profile, updates state, and returns the created profile. - * If the user had previously connected X, also updates xProfile in state. * * @param params - The profile creation parameters. * @returns The created MetaMask profile. @@ -332,9 +319,6 @@ export class ProfileController extends BaseController< const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { state.profile = mapped; - if (response.x_profile) { - state.xProfile = this.#mapXResponseToXProfile(response.x_profile); - } }); return mapped; } @@ -405,58 +389,28 @@ export class ProfileController extends BaseController< } /** - * Completes the X OAuth flow, updates xProfile and profile in state, and - * returns the connect result. The backend creates the profile if it does - * not exist yet (username derived from the X handle), so the profile is - * always fetched from the backend after a successful connect. - * - * The linked X profile is persisted in state immediately after the connect - * succeeds. If the follow-up profile fetch fails, the X link remains - * persisted, the profile in state is left unchanged, and a clear error is - * thrown. `connectX` must not be retried because the OAuth code is - * single-use. + * Completes the X OAuth flow for the profile in state and returns the + * linked X profile. Updates controller state with the linked X profile + * and marks the profile in state as connected to X. * * @param params - The parameters for the X OAuth flow. * @param params.code - The OAuth authorization code from the X redirect. * @param params.state - The state parameter returned by the X redirect. - * @param params.profileId - The canonical profile ID the X account is - * linked to (sourced from the auth session). - * @returns The refreshed profile, the linked X profile, and whether the - * backend created the profile during the connect. + * @returns The linked X profile. + * @throws If no profile has been created yet. */ - async connectX(params: { - code: string; - state: string; - profileId: string; - }): Promise { + async connectX(params: { code: string; state: string }): Promise { + this.#getProfileIdOrThrow(); const response = await this.messenger.call('ProfileService:connectX', { code: params.code, state: params.state, }); - const profileCreated = response.profile_created ?? false; - const xProfile = this.#mapXResponseToXProfile(response); - this.update((state) => { - state.xProfile = xProfile; - }); - - let profile: Profile; - try { - const profileResponse = await this.messenger.call( - 'ProfileService:getProfile', - params.profileId, - ); - profile = this.#mapApiResponseToProfile(profileResponse); - } catch (error) { - throw new Error( - 'ProfileController: connected the X account, but failed to fetch the profile afterwards; the X link is persisted and the profile in state was not updated, so do not retry connectX (the OAuth code is single-use)', - { cause: error }, - ); - } + const mapped = this.#mapXResponseToXProfile(response); this.update((state) => { - state.profile = profile; + state.xProfile = mapped; + state.profile.connectedToX = true; }); - - return { profile, xProfile, profileCreated }; + return mapped; } /** @@ -480,22 +434,12 @@ export class ProfileController extends BaseController< * The profile is resolved server-side from the verified bearer token. * Returns session data for the caller to use; nothing is stored in controller state. * - * @param params - Optional parameters for initiating the X OAuth flow. - * @param params.linkedAddress - Optional CAIP-10 account ID to link to the - * profile when it does not exist yet. * @returns The X authorization URL and state parameter. + * @throws If no profile has been created yet. */ - async startXConnect(params?: { - linkedAddress?: CaipAccountId; - }): Promise { - const { linkedAddress } = params ?? {}; - const response = - linkedAddress === undefined - ? await this.messenger.call('ProfileService:getXAuthUrl') - : await this.messenger.call( - 'ProfileService:getXAuthUrl', - linkedAddress, - ); + async startConnectX(): Promise { + this.#getProfileIdOrThrow(); + const response = await this.messenger.call('ProfileService:getXAuthUrl'); return { authorizationUrl: response.url, state: response.state }; } diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 6622c00310a..ee9bba1bc44 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -88,8 +88,6 @@ export type ProfileServiceCheckUsernameAvailabilityAction = { * Fetches the X OAuth PKCE authorization URL and its associated state parameter. * The profile is resolved server-side from the verified bearer token. * - * @param linkedAddress - Optional CAIP-10 account ID to link to the profile - * when it does not exist yet; sent as the `linked_address` query parameter. * @returns An object containing the authorization URL and the state token. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. @@ -101,12 +99,10 @@ export type ProfileServiceGetXAuthUrlAction = { /** * Completes the X OAuth PKCE flow and links the X account to the profile. - * The backend creates the profile if it does not exist yet (username derived - * from the X handle) and reports that via `profile_created`. + * The profile is resolved server-side from the verified bearer token. * * @param params - The OAuth callback code and state from the X redirect. - * @returns The linked X account data, plus `profile_created` when the - * backend created the profile during the connect. + * @returns The linked X account data. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index d0d3afc06dc..f5911af8b37 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -35,7 +35,7 @@ const mockProfileResponse = { updated_at: '2024-01-02T00:00:00Z', }; -const mockXConnectResponse = { +const mockConnectXResponse = { x_user_id: 'x-user-123', x_profile_url: 'https://x.com/degengirl', username: 'degengirl', @@ -448,25 +448,6 @@ describe('ProfileService', () => { ); }); - it('sends the linked_address query parameter when a linked address is given', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: () => Promise.resolve(mockAuthUrlResponse), - }); - - const service = createService(); - const result = await service.getXAuthUrl( - 'eip155:0:0x1234567890abcdef1234567890abcdef12345678', - ); - - expect(result).toStrictEqual(mockAuthUrlResponse); - expect(mockFetch).toHaveBeenCalledWith( - `${V1_URL}/profiles/x/authentication-url?linked_address=eip155%3A0%3A0x1234567890abcdef1234567890abcdef12345678`, - { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, - ); - }); - it('throws HttpError on non-ok response', async () => { mockFetch.mockResolvedValue({ ok: false, status: 500 }); @@ -497,7 +478,7 @@ describe('ProfileService', () => { mockFetch.mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve(mockXConnectResponse), + json: () => Promise.resolve(mockConnectXResponse), }); const params: ConnectXParams = { @@ -507,7 +488,7 @@ describe('ProfileService', () => { const service = createService(); const result = await service.connectX(params); - expect(result).toStrictEqual(mockXConnectResponse); + expect(result).toStrictEqual(mockConnectXResponse); expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/x/connect`, { method: 'POST', headers: { @@ -518,26 +499,6 @@ describe('ProfileService', () => { }); }); - it('parses profile_created when the backend reports it', async () => { - mockFetch.mockResolvedValue({ - ok: true, - status: 200, - json: () => - Promise.resolve({ - ...mockXConnectResponse, - profile_created: true, - }), - }); - - const service = createService(); - const result = await service.connectX({ - code: 'auth-code-123', - state: 'state-xyz', - }); - - expect(result.profile_created).toBe(true); - }); - it('invalidates the cached profile so the next getProfile reflects the connection', async () => { const connectedProfileResponse = { ...mockProfileResponse, @@ -552,7 +513,7 @@ describe('ProfileService', () => { .mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve(mockXConnectResponse), + json: () => Promise.resolve(mockConnectXResponse), }) .mockResolvedValueOnce({ ok: true, @@ -618,13 +579,13 @@ describe('ProfileService', () => { mockFetch.mockResolvedValue({ ok: true, status: 200, - json: () => Promise.resolve(mockXConnectResponse), + json: () => Promise.resolve(mockConnectXResponse), }); const service = createService(); const result = await service.getXAccount(); - expect(result).toStrictEqual(mockXConnectResponse); + expect(result).toStrictEqual(mockConnectXResponse); expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/x/account`, { headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index ad1cc40cf16..1a196bf3179 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -22,7 +22,6 @@ import { import type { Infer } from '@metamask/superstruct'; import type { Json } from '@metamask/utils'; import { CaipAccountIdStruct } from '@metamask/utils'; -import type { CaipAccountId } from '@metamask/utils'; import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; @@ -84,7 +83,7 @@ const XAuthUrlResponseStruct = structType({ state: sensitive(string()), }); -const XConnectResponseStruct = structType({ +const ConnectXResponseStruct = structType({ x_user_id: string(), x_profile_url: string(), username: string(), @@ -94,20 +93,9 @@ const XConnectResponseStruct = structType({ updated_at: string(), }); -/** - * Response shape for POST /v1/profiles/x/connect: the linked X account plus - * `profile_created`, indicating whether the backend created the MetaMask - * profile during the connect. Optional so older backends that do not send - * the field still validate. - */ -const ConnectXResponseStruct = intersection([ - XConnectResponseStruct, - structType({ profile_created: optional(boolean()) }), -]); - const CreateProfileResponseStruct = intersection([ ProfileApiResponseStruct, - structType({ x_profile: optional(XConnectResponseStruct) }), + structType({ x_profile: optional(ConnectXResponseStruct) }), ]); const ConnectXParamsStruct = structType({ @@ -172,16 +160,13 @@ export type UsernameAvailabilityResponse = Infer< >; /** The response shape returned when connecting or fetching an X account. */ -export type XConnectResponse = Infer; - -/** The response shape returned when completing the X connect flow. */ export type ConnectXResponse = Infer; /** The response shape returned when requesting the X OAuth authentication URL. */ export type XAuthUrlResponse = Infer; -/** Alias for {@link XConnectResponse} returned when fetching the linked X account. */ -export type XAccountResponse = XConnectResponse; +/** Alias for {@link ConnectXResponse} returned when fetching the linked X account. */ +export type XAccountResponse = ConnectXResponse; /** Parameters for creating a new MetaMask profile. */ export type CreateProfileParams = Infer; @@ -497,34 +482,27 @@ export class ProfileService extends BaseDataService< * Fetches the X OAuth PKCE authorization URL and its associated state parameter. * The profile is resolved server-side from the verified bearer token. * - * @param linkedAddress - Optional CAIP-10 account ID to link to the profile - * when it does not exist yet; sent as the `linked_address` query parameter. * @returns An object containing the authorization URL and the state token. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ - async getXAuthUrl(linkedAddress?: CaipAccountId): Promise { + async getXAuthUrl(): Promise { return this.executeMutation({ mutationKey: [`${this.name}:getXAuthUrl`], responseStruct: XAuthUrlResponseStruct, mutationFn: async () => this.#fetch('profiles/x/authentication-url', { error: ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, - ...(linkedAddress === undefined - ? {} - : { searchParams: { linked_address: linkedAddress } }), }), }); } /** * Completes the X OAuth PKCE flow and links the X account to the profile. - * The backend creates the profile if it does not exist yet (username derived - * from the X handle) and reports that via `profile_created`. + * The profile is resolved server-side from the verified bearer token. * * @param params - The OAuth callback code and state from the X redirect. - * @returns The linked X account data, plus `profile_created` when the - * backend created the profile during the connect. + * @returns The linked X account data. * @throws {HttpError} If the API returns a non-2xx response. * @throws {StructError} If the response does not match the expected shape. */ @@ -557,9 +535,9 @@ export class ProfileService extends BaseDataService< return this.fetchQuery({ queryKey: [`${this.name}:getXAccount`], staleTime: 0, - responseStruct: XConnectResponseStruct, + responseStruct: ConnectXResponseStruct, queryFn: async () => - this.#fetch('profiles/x/account', { + this.#fetch('profiles/x/account', { error: ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED, }), }); diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 6899fb26a8c..3ed165c2705 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -1,8 +1,7 @@ export type { Profile, XProfile, - XConnectSession, - XConnectResult, + ConnectXSession, ProfileControllerState, ProfileControllerGetStateAction, ProfileControllerActions, @@ -24,7 +23,7 @@ export type { ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXProfileAction, ProfileControllerReplaceProfileAction, - ProfileControllerStartXConnectAction, + ProfileControllerStartConnectXAction, ProfileControllerUpdateProfileAction, } from './ProfileController-method-action-types.js'; export type { @@ -36,7 +35,6 @@ export type { ConnectXParams, UsernameAvailabilityResponse, XAuthUrlResponse, - XConnectResponse, ConnectXResponse, XAccountResponse, ProfileServiceActions, From dae858038c345254227381f89112e6c95fb78d60 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 12 Oct 2026 20:58:47 +0800 Subject: [PATCH 09/11] fix: rename connect and fix conditional --- .../ProfileController-method-action-types.ts | 13 +++++++------ .../src/ProfileController.test.ts | 19 +++++++++++++------ .../src/ProfileController.ts | 11 ++++++----- packages/profile-controller/src/index.ts | 2 +- 4 files changed, 27 insertions(+), 18 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 3933445a02f..08560d15f3f 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -115,14 +115,15 @@ export type ProfileControllerFetchAndUpdateXAccountAction = { * @returns The X authorization URL and state parameter. * @throws If no profile has been created yet. */ -export type ProfileControllerStartConnectXAction = { - type: `ProfileController:startConnectX`; - handler: ProfileController['startConnectX']; +export type ProfileControllerGetXAuthorizationUrlAction = { + type: `ProfileController:getXAuthorizationUrl`; + handler: ProfileController['getXAuthorizationUrl']; }; /** - * Disconnects the X account linked to the given profile, clears xProfile from - * state, and marks the profile in state as no longer connected to X. + * Disconnects the X account linked to the given profile. If the given + * profile matches the profile in state, clears `xProfile` from state and + * marks the profile in state as no longer connected to X. * * @param profileId - The ID of the profile to disconnect the linked X account * from (sourced from {@link getProfile}). @@ -145,5 +146,5 @@ export type ProfileControllerMethodActions = | ProfileControllerCheckUsernameAvailabilityAction | ProfileControllerConnectXAction | ProfileControllerFetchAndUpdateXAccountAction - | ProfileControllerStartConnectXAction + | ProfileControllerGetXAuthorizationUrlAction | ProfileControllerDisconnectXAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 6b969307cab..eb3344b7cb2 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -674,7 +674,7 @@ describe('ProfileController', () => { }); }); - describe('startConnectX', () => { + describe('getXAuthorizationUrl', () => { it('throws if no profile is set in state and does not call the service', async () => { const rootMessenger = getRootMessenger(); const getXAuthUrlMock = jest.fn().mockResolvedValue(mockXAuthUrlResponse); @@ -686,7 +686,7 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger }); - await expect(controller.startConnectX()).rejects.toThrow( + await expect(controller.getXAuthorizationUrl()).rejects.toThrow( 'ProfileController: no profile found in state', ); expect(getXAuthUrlMock).not.toHaveBeenCalled(); @@ -714,7 +714,7 @@ describe('ProfileController', () => { }, }); const stateBefore = controller.state; - const result = await controller.startConnectX(); + const result = await controller.getXAuthorizationUrl(); expect(getXAuthUrlMock).toHaveBeenCalledTimes(1); expect(getXAuthUrlMock).toHaveBeenCalledWith(); @@ -740,7 +740,7 @@ describe('ProfileController', () => { }, }); const result = await rootMessenger.call( - 'ProfileController:startConnectX', + 'ProfileController:getXAuthorizationUrl', ); expect(result).toStrictEqual(mockConnectXSession); @@ -810,13 +810,18 @@ describe('ProfileController', () => { expect(stateChangedListener).toHaveBeenCalledTimes(1); }); - it('does not change profile.connectedToX when the profile ID does not match state', async () => { + it('does not change state when the profile ID does not match state', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( rootMessenger, 'ProfileService:disconnectX', jest.fn().mockResolvedValue(undefined), ); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAccount', + jest.fn().mockResolvedValue(mockConnectXResponse), + ); const { controller } = createController({ rootMessenger, @@ -828,10 +833,12 @@ describe('ProfileController', () => { }, }, }); + await controller.fetchAndUpdateXAccount(); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); await controller.disconnectX('other-profile'); - expect(controller.state.xProfile).toBeUndefined(); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); expect(controller.state.profile.connectedToX).toBe(true); }); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 06325f7e7bd..e10fff39b11 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -180,7 +180,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkUsernameAvailability', 'connectX', 'fetchAndUpdateXAccount', - 'startConnectX', + 'getXAuthorizationUrl', 'disconnectX', ] as const; @@ -437,15 +437,16 @@ export class ProfileController extends BaseController< * @returns The X authorization URL and state parameter. * @throws If no profile has been created yet. */ - async startConnectX(): Promise { + async getXAuthorizationUrl(): Promise { this.#getProfileIdOrThrow(); const response = await this.messenger.call('ProfileService:getXAuthUrl'); return { authorizationUrl: response.url, state: response.state }; } /** - * Disconnects the X account linked to the given profile, clears xProfile from - * state, and marks the profile in state as no longer connected to X. + * Disconnects the X account linked to the given profile. If the given + * profile matches the profile in state, clears `xProfile` from state and + * marks the profile in state as no longer connected to X. * * @param profileId - The ID of the profile to disconnect the linked X account * from (sourced from {@link getProfile}). @@ -453,8 +454,8 @@ export class ProfileController extends BaseController< async disconnectX(profileId: string): Promise { await this.messenger.call('ProfileService:disconnectX', profileId); this.update((state) => { - state.xProfile = undefined; if (state.profile.profileId === profileId) { + state.xProfile = undefined; state.profile.connectedToX = false; } }); diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 3ed165c2705..1a2b269f116 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -23,7 +23,7 @@ export type { ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXProfileAction, ProfileControllerReplaceProfileAction, - ProfileControllerStartConnectXAction, + ProfileControllerGetXAuthorizationUrlAction, ProfileControllerUpdateProfileAction, } from './ProfileController-method-action-types.js'; export type { From 7ec47a5063243b363ba65de1f4e270428057b867 Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 12 Oct 2026 20:59:17 +0800 Subject: [PATCH 10/11] fix: changelog --- packages/profile-controller/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 7720958851a..156d9b9eef0 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -9,14 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `startConnectX` method to `ProfileController` for initiating the X OAuth flow. It requires a profile to exist, fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#10668](https://github.com/MetaMask/core/pull/10668)) -- Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, clears `xProfile` from controller state, and sets `connectedToX` to `false` when the disconnected profile matches the profile in state ([#10668](https://github.com/MetaMask/core/pull/10668)) +- Add `getXAuthorizationUrl` method to `ProfileController` for initiating the X OAuth flow. It requires a profile to exist, fetches the authorization URL via `ProfileService:getXAuthUrl` (the profile is resolved server-side from the verified bearer token), and returns the authorization URL and state parameter as ephemeral session data, without storing them in controller state ([#10668](https://github.com/MetaMask/core/pull/10668)) +- Add `disconnectX` method to `ProfileController` for unlinking the X account from a profile. It delegates to `ProfileService:disconnectX`, and when the disconnected profile matches the profile in state, clears `xProfile` from controller state and sets `connectedToX` to `false` ([#10668](https://github.com/MetaMask/core/pull/10668)) - Add `disconnectX` method to `ProfileService` for sending an authenticated `DELETE` request to the `profiles/{profileId}/x` endpoint; a `404` response is treated as success (the X account is already disconnected) ([#10668](https://github.com/MetaMask/core/pull/10668)) ### Changed -- **BREAKING:** `startConnectX` no longer accepts a `linkedAddress` parameter and now throws if no profile exists in state -- **BREAKING:** `connectX` now throws if no profile exists in state, and updates controller state (`xProfile`, `profile.connectedToX`) on success instead of deferring persistence to `createProfile`. `createProfile` no longer persists a linked X profile from its response +- `getXAuthorizationUrl` no longer accepts a `linkedAddress` parameter and now throws if no profile exists in state +- `connectX` now throws if no profile exists in state, and updates controller state (`xProfile`, `profile.connectedToX`) on success instead of deferring persistence to `createProfile`. `createProfile` no longer persists a linked X profile from its response - Bump `@metamask/profile-sync-controller` from `^34.0.0` to `^34.0.3` ([#10752](https://github.com/MetaMask/core/pull/10752), [#10756](https://github.com/MetaMask/core/pull/10756), [#10761](https://github.com/MetaMask/core/pull/10761)) ### Fixed From 77bd12a115a0632859bdf22ff2eff8fb6106031a Mon Sep 17 00:00:00 2001 From: Monte Lai Date: Mon, 12 Oct 2026 21:14:29 +0800 Subject: [PATCH 11/11] fix: lint --- .../src/ProfileController-method-action-types.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 08560d15f3f..c46a0b09c84 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -121,9 +121,9 @@ export type ProfileControllerGetXAuthorizationUrlAction = { }; /** - * Disconnects the X account linked to the given profile. If the given - * profile matches the profile in state, clears `xProfile` from state and - * marks the profile in state as no longer connected to X. + * Disconnects the X account linked to the given profile. If the given + * profile matches the profile in state, clears `xProfile` from state and + * marks the profile in state as no longer connected to X. * * @param profileId - The ID of the profile to disconnect the linked X account * from (sourced from {@link getProfile}).