diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 087bf642e26..50ed0ed16cc 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -536,6 +536,13 @@ /packages/preferences-controller/tsconfig.* @MetaMask/core-platform /packages/preferences-controller/typedoc.json @MetaMask/core-platform +### profile-controller +/packages/profile-controller @MetaMask/accounts-engineers +/packages/profile-controller/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-controller/tsconfig.* @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/profile-controller/typedoc.json @MetaMask/accounts-engineers @MetaMask/core-platform + ### profile-metrics-controller /packages/profile-metrics-controller @MetaMask/extension-platform @MetaMask/mobile-platform /packages/profile-metrics-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform diff --git a/README.md b/README.md index 494b3aaece0..084d826d117 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/platform-api-docs`](packages/platform-api-docs) - [`@metamask/polling-controller`](packages/polling-controller) - [`@metamask/preferences-controller`](packages/preferences-controller) +- [`@metamask/profile-controller`](packages/profile-controller) - [`@metamask/profile-metrics-controller`](packages/profile-metrics-controller) - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/ramps-controller`](packages/ramps-controller) @@ -224,6 +225,7 @@ linkStyle default opacity:0.5 platform_api_docs(["@metamask/platform-api-docs"]); polling_controller(["@metamask/polling-controller"]); preferences_controller(["@metamask/preferences-controller"]); + profile_controller(["@metamask/profile-controller"]); profile_metrics_controller(["@metamask/profile-metrics-controller"]); profile_sync_controller(["@metamask/profile-sync-controller"]); ramps_controller(["@metamask/ramps-controller"]); @@ -611,6 +613,12 @@ linkStyle default opacity:0.5 polling_controller --> messenger; preferences_controller --> base_controller; preferences_controller --> messenger; + profile_controller --> base_controller; + profile_controller --> base_data_service; + profile_controller --> controller_utils; + profile_controller --> messenger; + profile_controller --> profile_sync_controller; + profile_controller --> utils; profile_metrics_controller --> accounts_controller; profile_metrics_controller --> base_controller; profile_metrics_controller --> controller_utils; diff --git a/codeowners.ts b/codeowners.ts index 4ec6d0aaa65..c660cbe15a3 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -269,6 +269,9 @@ const config = { 'preferences-controller': { teams: ['@MetaMask/core-platform'], }, + 'profile-controller': { + teams: ['@MetaMask/accounts-engineers'], + }, 'profile-metrics-controller': { teams: ['@MetaMask/mobile-platform', '@MetaMask/extension-platform'], }, diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 077d811402d..7c201b90745 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4976,6 +4976,11 @@ "count": 1 } }, + "packages/profile-controller/jest.config.cjs": { + "import/unambiguous": { + "count": 1 + } + }, "packages/profile-metrics-controller/jest.config.cjs": { "import/unambiguous": { "count": 1 diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md new file mode 100644 index 00000000000..d72e34e479b --- /dev/null +++ b/packages/profile-controller/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### 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/ diff --git a/packages/profile-controller/LICENSE b/packages/profile-controller/LICENSE new file mode 100644 index 00000000000..9ec4f4514ea --- /dev/null +++ b/packages/profile-controller/LICENSE @@ -0,0 +1,6 @@ +This project is licensed under either of + + * MIT license ([LICENSE.MIT](LICENSE.MIT)) + * Apache License, Version 2.0 ([LICENSE.APACHE2](LICENSE.APACHE2)) + +at your option. diff --git a/packages/profile-controller/LICENSE.APACHE2 b/packages/profile-controller/LICENSE.APACHE2 new file mode 100644 index 00000000000..56752e8ff49 --- /dev/null +++ b/packages/profile-controller/LICENSE.APACHE2 @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 MetaMask + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/profile-controller/LICENSE.MIT b/packages/profile-controller/LICENSE.MIT new file mode 100644 index 00000000000..fe29e78e0fe --- /dev/null +++ b/packages/profile-controller/LICENSE.MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/profile-controller/README.md b/packages/profile-controller/README.md new file mode 100644 index 00000000000..d4f8a9276f2 --- /dev/null +++ b/packages/profile-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/profile-controller` + +Controller for managing user profile data including username, bio, avatar, and linked accounts via the MetaMask Profile API. + +## Installation + +`yarn add @metamask/profile-controller` + +or + +`npm install @metamask/profile-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/profile-controller/jest.config.cjs b/packages/profile-controller/jest.config.cjs new file mode 100644 index 00000000000..6456e074bb0 --- /dev/null +++ b/packages/profile-controller/jest.config.cjs @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages.cjs'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json new file mode 100644 index 00000000000..1bb480e2eb8 --- /dev/null +++ b/packages/profile-controller/package.json @@ -0,0 +1,76 @@ +{ + "name": "@metamask/profile-controller", + "version": "0.0.0", + "description": "Controller for managing user profile data including username, bio, avatar, and linked accounts via the MetaMask Profile API", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "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/MetaMask/core/tree/main/packages/profile-controller#readme", + "bugs": { + "url": "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/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "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/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "type": "module", + "sideEffects": false, + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "tsc --project tsconfig.build.json", + "build:all": "tsc --build tsconfig.build.json --verbose", + "build:clean": "yarn build:only-clean && yarn build", + "build:docs": "typedoc", + "build:only-clean": "rimraf './dist' './tsconfig.build.tsbuildinfo'", + "changelog:update": "../../scripts/update-changelog.sh @metamask/profile-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/profile-controller", + "lint:tsconfigs": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", + "lint:tsconfigs:fix": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", + "messenger-action-types:check": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "node --import ../../scripts/resolver/register.ts --experimental-transform-types ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^10.0.0", + "@metamask/base-data-service": "^2.1.0", + "@metamask/controller-utils": "^13.0.0", + "@metamask/messenger": "^3.0.0", + "@metamask/profile-sync-controller": "^33.0.0", + "@metamask/superstruct": "^3.4.1", + "@metamask/utils": "^12.0.0" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.2.1", + "@types/jest": "^30.0.0", + "@typescript/native": "npm:typescript@^7.0.2", + "deepmerge": "^4.3.1", + "jest": "^30.5.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.14", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2" + }, + "engines": { + "node": "^22.14.0 || ^24" + } +} diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts new file mode 100644 index 00000000000..34e6a57f125 --- /dev/null +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -0,0 +1,118 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProfileController } from './ProfileController.js'; + +/** + * Returns the current MetaMask profile from state, or undefined if none has been created. + * + * @returns The MetaMask profile, or undefined. + */ +export type ProfileControllerGetProfileAction = { + type: `ProfileController:getProfile`; + handler: ProfileController['getProfile']; +}; + +/** + * Returns the currently linked X profile from state, or undefined if none has been connected. + * + * @returns The X profile, or undefined. + */ +export type ProfileControllerGetXProfileAction = { + type: `ProfileController:getXProfile`; + handler: ProfileController['getXProfile']; +}; + +/** + * 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. + */ +export type ProfileControllerCreateProfileAction = { + type: `ProfileController:createProfile`; + handler: ProfileController['createProfile']; +}; + +/** + * Fully replaces the current profile and updates state. + * + * @param input - The replacement profile data. + * @throws If no profile has been created yet. + */ +export type ProfileControllerReplaceProfileAction = { + type: `ProfileController:replaceProfile`; + handler: ProfileController['replaceProfile']; +}; + +/** + * Partially updates the current profile and updates state. + * + * @param input - The fields to update. + * @throws If no profile has been created yet. + */ +export type ProfileControllerUpdateProfileAction = { + type: `ProfileController:updateProfile`; + handler: ProfileController['updateProfile']; +}; + +/** + * Deletes the current profile and resets state, including clearing any linked X profile. + * + * @throws If no profile has been created yet. + */ +export type ProfileControllerDeleteProfileAction = { + type: `ProfileController:deleteProfile`; + handler: ProfileController['deleteProfile']; +}; + +/** + * Checks whether a username is available. + * + * @param username - The username to check. + * @returns Availability details including validity and normalized form. + */ +export type ProfileControllerCheckUsernameAvailabilityAction = { + type: `ProfileController:checkUsernameAvailability`; + handler: ProfileController['checkUsernameAvailability']; +}; + +/** + * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. + * + * @param params - The parameters for the X OAuth PKCE 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. + */ +export type ProfileControllerConnectXAction = { + type: `ProfileController:connectX`; + handler: ProfileController['connectX']; +}; + +/** + * Fetches the X account linked to the current profile, updates state, and returns the X profile. + * + * @returns The linked X profile. + */ +export type ProfileControllerFetchAndUpdateXAccountAction = { + type: `ProfileController:fetchAndUpdateXAccount`; + handler: ProfileController['fetchAndUpdateXAccount']; +}; + +/** + * Union of all ProfileController action types. + */ +export type ProfileControllerMethodActions = + | ProfileControllerGetProfileAction + | ProfileControllerGetXProfileAction + | ProfileControllerCreateProfileAction + | ProfileControllerReplaceProfileAction + | ProfileControllerUpdateProfileAction + | ProfileControllerDeleteProfileAction + | ProfileControllerCheckUsernameAvailabilityAction + | ProfileControllerConnectXAction + | ProfileControllerFetchAndUpdateXAccountAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts new file mode 100644 index 00000000000..64c242358e6 --- /dev/null +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -0,0 +1,593 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { + Profile, + ProfileControllerMessenger, +} from './ProfileController.js'; +import { + ProfileController, + getDefaultProfileControllerState, +} from './ProfileController.js'; +import type { + CreateProfileParams, + ReplaceProfileParams, +} from './ProfileService.js'; + +const controllerName = 'ProfileController'; + +const mockProfileResponse = { + profile_id: 'profile-123', + username: 'alice', + display_name: 'Alice Wonderland', + bio: 'MetaMask user', + linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + avatar_url: 'https://example.com/avatar.png', + trading_privacy: 'public' as const, + connected_to_x: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', +}; + +const mockMappedProfile: Profile = { + profileId: 'profile-123', + username: 'alice', + displayName: 'Alice Wonderland', + bio: 'MetaMask user', + linkedAddresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + avatarUrl: 'https://example.com/avatar.png', + tradingPrivacy: 'public', + connectedToX: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', +}; + +const mockXConnectResponse = { + x_user_id: 'x-user-123', + x_profile_url: 'https://x.com/degengirl', + username: 'degengirl', + display_name: 'Degen Girl', + avatar_url: 'https://pbs.twimg.com/profile_images/degengirl.jpg', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', +}; + +const mockMappedXProfile = { + xUserId: 'x-user-123', + xProfileUrl: 'https://x.com/degengirl', + username: 'degengirl', + displayName: 'Degen Girl', + avatarUrl: 'https://pbs.twimg.com/profile_images/degengirl.jpg', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', +}; + +const mockAvailabilityResponse = { + username: 'alice', + available: true, + valid: true, + normalized: 'alice', + errors: [], +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function getMessenger( + rootMessenger: RootMessenger, +): ProfileControllerMessenger { + const messenger: ProfileControllerMessenger = new Messenger({ + namespace: controllerName, + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'ProfileService:createProfile', + 'ProfileService:replaceProfile', + 'ProfileService:updateProfile', + 'ProfileService:deleteProfile', + 'ProfileService:checkUsernameAvailability', + 'ProfileService:connectX', + 'ProfileService:getXAccount', + ], + events: [], + messenger, + }); + return messenger; +} + +function mockServiceAction( + rootMessenger: RootMessenger, + action: string, + implementation: jest.Mock, +): void { + rootMessenger.registerActionHandler(action as never, implementation as never); +} + +function createController( + options: { + rootMessenger?: RootMessenger; + state?: Partial>; + } = {}, +): { + controller: ProfileController; + rootMessenger: RootMessenger; + messenger: ProfileControllerMessenger; +} { + const rootMessenger = options.rootMessenger ?? getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const controller = new ProfileController({ + messenger, + state: options.state, + }); + return { controller, rootMessenger, messenger }; +} + +describe('ProfileController', () => { + describe('getDefaultProfileControllerState', () => { + it('returns empty default state', () => { + expect(getDefaultProfileControllerState()).toStrictEqual({ + profile: { + profileId: '', + username: '', + displayName: '', + bio: '', + linkedAddresses: [], + avatarUrl: '', + tradingPrivacy: 'public', + connectedToX: false, + createdAt: '', + updatedAt: '', + }, + }); + }); + }); + + describe('constructor', () => { + it('initializes with default state', () => { + const { controller } = createController(); + + expect(controller.state).toStrictEqual( + getDefaultProfileControllerState(), + ); + }); + + it('merges partial initial state with defaults', () => { + const { controller } = createController({ + state: { + profile: { + ...getDefaultProfileControllerState().profile, + username: 'alice', + }, + }, + }); + + expect(controller.state.profile.username).toBe('alice'); + }); + }); + + describe('getProfile', () => { + it('returns undefined when no profile has been set', () => { + const { controller } = createController(); + + expect(controller.getProfile()).toBeUndefined(); + }); + + it('returns the profile when one exists in state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest.fn().mockResolvedValue(mockProfileResponse), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.getProfile()).toStrictEqual(mockMappedProfile); + }); + }); + + describe('getXProfile', () => { + it('returns undefined when no X profile exists', () => { + const { controller } = createController(); + + expect(controller.getXProfile()).toBeUndefined(); + }); + + it('returns the X profile after fetchAndUpdateXAccount', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAccount', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + + const { controller } = createController({ rootMessenger }); + await controller.fetchAndUpdateXAccount(); + + expect(controller.getXProfile()).toStrictEqual(mockMappedXProfile); + }); + }); + + describe('createProfile', () => { + it('calls ProfileService:createProfile and updates state', async () => { + const rootMessenger = getRootMessenger(); + const createProfileMock = jest + .fn() + .mockResolvedValue(mockProfileResponse); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + createProfileMock, + ); + + const input: CreateProfileParams = { + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }; + const { controller } = createController({ rootMessenger }); + await controller.createProfile(input); + + expect(createProfileMock).toHaveBeenCalledWith(input); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); + }); + + it('maps null bio to empty string', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest.fn().mockResolvedValue({ ...mockProfileResponse, bio: null }), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.state.profile.bio).toBe(''); + }); + + it('maps null avatar_url to empty string', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, avatar_url: null }), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.state.profile.avatarUrl).toBe(''); + }); + + it('maps connected_to_x from response', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, connected_to_x: true }), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.state.profile.connectedToX).toBe(true); + }); + + it('also updates xProfile in state when x_profile is included in the response', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest.fn().mockResolvedValue({ + ...mockProfileResponse, + x_profile: mockXConnectResponse, + }), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + + it('is callable via messenger action', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest.fn().mockResolvedValue(mockProfileResponse), + ); + + const { controller } = createController({ rootMessenger }); + + await rootMessenger.call('ProfileController:createProfile', { + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }); + + expect(controller.state.profile).toStrictEqual(mockMappedProfile); + }); + }); + + describe('replaceProfile', () => { + it('calls ProfileService:replaceProfile with profileId from state and updates state', async () => { + const rootMessenger = getRootMessenger(); + const replaceProfileMock = jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, username: 'alice2' }); + mockServiceAction( + rootMessenger, + 'ProfileService:replaceProfile', + replaceProfileMock, + ); + + const input: ReplaceProfileParams = { + username: 'alice2', + display_name: 'Alice 2', + linked_addresses: ['eip155:1:0xabc'], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }; + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); + await controller.replaceProfile(input); + + expect(replaceProfileMock).toHaveBeenCalledWith('profile-123', input); + expect(controller.state.profile.username).toBe('alice2'); + }); + + it('throws if no profile is set in state', async () => { + const { controller } = createController(); + + await expect( + controller.replaceProfile({ + username: 'alice2', + display_name: 'Alice 2', + linked_addresses: [ + 'eip155:1:0x1234567890abcdef1234567890abcdef12345678', + ], + trading_privacy: 'public', + bio: 'MetaMask user', + avatar_url: 'https://example.com/avatar.png', + }), + ).rejects.toThrow('ProfileController: no profile found in state'); + }); + }); + + describe('updateProfile', () => { + it('calls ProfileService:updateProfile with profileId from state and updates state', async () => { + const rootMessenger = getRootMessenger(); + const updateProfileMock = jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, username: 'alice2' }); + mockServiceAction( + rootMessenger, + 'ProfileService:updateProfile', + updateProfileMock, + ); + + const { controller } = createController({ + rootMessenger, + state: { + profile: { + ...getDefaultProfileControllerState().profile, + profileId: 'profile-123', + }, + }, + }); + await controller.updateProfile({ username: 'alice2' }); + + expect(updateProfileMock).toHaveBeenCalledWith('profile-123', { + username: 'alice2', + }); + expect(controller.state.profile.username).toBe('alice2'); + }); + + it('throws if no profile is set in state', async () => { + const { controller } = createController(); + + await expect( + controller.updateProfile({ username: 'alice2' }), + ).rejects.toThrow('ProfileController: no profile found in state'); + }); + }); + + describe('deleteProfile', () => { + it('calls ProfileService:deleteProfile using profileId from state and resets state including xProfile', async () => { + const rootMessenger = getRootMessenger(); + const deleteProfileMock = jest.fn().mockResolvedValue(undefined); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAccount', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:deleteProfile', + deleteProfileMock, + ); + + const { controller } = createController({ + rootMessenger, + state: { profile: mockMappedProfile }, + }); + await controller.fetchAndUpdateXAccount(); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + + await controller.deleteProfile(); + + expect(deleteProfileMock).toHaveBeenCalledWith('profile-123'); + expect(controller.state.profile).toStrictEqual( + getDefaultProfileControllerState().profile, + ); + expect(controller.state.xProfile).toBeUndefined(); + }); + + it('throws if no profile is set in state', async () => { + const { controller } = createController(); + + await expect(controller.deleteProfile()).rejects.toThrow( + 'ProfileController: no profile found in state', + ); + }); + }); + + describe('checkUsernameAvailability', () => { + it('delegates to ProfileService:checkUsernameAvailability', async () => { + const rootMessenger = getRootMessenger(); + const checkMock = jest.fn().mockResolvedValue(mockAvailabilityResponse); + mockServiceAction( + rootMessenger, + 'ProfileService:checkUsernameAvailability', + checkMock, + ); + + const { controller } = createController({ rootMessenger }); + const result = await controller.checkUsernameAvailability('alice'); + + expect(checkMock).toHaveBeenCalledWith('alice'); + expect(result).toStrictEqual(mockAvailabilityResponse); + }); + + it('does not update state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:checkUsernameAvailability', + jest.fn().mockResolvedValue(mockAvailabilityResponse), + ); + + const { controller } = createController({ rootMessenger }); + const stateBefore = controller.state; + await controller.checkUsernameAvailability('alice'); + + expect(controller.state).toStrictEqual(stateBefore); + }); + }); + + describe('connectX', () => { + it('calls ProfileService:connectX with code and state and returns the X profile', async () => { + const rootMessenger = getRootMessenger(); + const connectXMock = jest.fn().mockResolvedValue(mockXConnectResponse); + mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); + + const { controller } = createController({ rootMessenger }); + const result = await controller.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + }); + + expect(connectXMock).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + }); + expect(result).toStrictEqual(mockMappedXProfile); + expect(controller.state.xProfile).toBeUndefined(); + }); + }); + + describe('fetchAndUpdateXAccount', () => { + it('calls ProfileService:getXAccount and updates xProfile in state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAccount', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + + const { controller } = createController({ rootMessenger }); + await controller.fetchAndUpdateXAccount(); + + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + }); +}); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts new file mode 100644 index 00000000000..d27789b0e19 --- /dev/null +++ b/packages/profile-controller/src/ProfileController.ts @@ -0,0 +1,410 @@ +import { BaseController } from '@metamask/base-controller'; +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { CaipAccountId } from '@metamask/utils'; + +import type { ProfileControllerMethodActions } from './ProfileController-method-action-types.js'; +import type { + ProfileServiceCheckUsernameAvailabilityAction, + ProfileServiceConnectXAction, + ProfileServiceCreateProfileAction, + ProfileServiceDeleteProfileAction, + ProfileServiceGetXAccountAction, + ProfileServiceReplaceProfileAction, + ProfileServiceUpdateProfileAction, +} from './ProfileService-method-action-types.js'; +import type { + CreateProfileParams, + CreateProfileResponse, + ProfileApiResponse, + ReplaceProfileParams, + UpdateProfileParams, + UsernameAvailabilityResponse, + XConnectResponse, +} from './ProfileService.js'; + +const controllerName = 'ProfileController'; + +// === TYPES === + +/** Representation of a profile stored in controller state. */ +export type Profile = { + /** The canonical profile ID connected to the profile. */ + profileId: string; + /** The username for the profile. */ + username: string; + /** The display name for the profile. */ + displayName: string; + /** The bio for the profile. */ + bio: string; + /** The linked addresses for the profile. */ + linkedAddresses: CaipAccountId[]; + /** The avatar URL for the profile. */ + avatarUrl: string; + /** Whether the profile's trading activity is visible to the public. */ + tradingPrivacy: 'public' | 'private'; + /** Whether the profile is connected to X. */ + connectedToX: boolean; + /** The date and time the profile was created. */ + createdAt: string; + /** The date and time the profile was updated. */ + updatedAt: string; +}; + +/** Representation of a linked X (Twitter) profile stored in controller state. */ +export type XProfile = { + /** The unique identifier for the X profile. This is the user ID of the X profile. */ + xUserId: string; + /** The URL for the X profile. */ + xProfileUrl: string; + /** The username for the X profile. */ + username: string; + /** The display name for the X profile. */ + displayName: string; + /** The avatar URL for the X profile. */ + avatarUrl: string; + /** The date and time the X profile was created. */ + createdAt: string; + /** The date and time the X profile was updated. */ + updatedAt: string; +}; + +/** State managed by ProfileController. */ +export type ProfileControllerState = { + profile: Profile; + xProfile?: XProfile; +}; + +// === MESSENGER === + +/** The `ProfileController:getState` action type. */ +export type ProfileControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ProfileControllerState +>; + +/** Union of all actions exposed by ProfileController. */ +export type ProfileControllerActions = + | ProfileControllerGetStateAction + | ProfileControllerMethodActions; + +/** The `ProfileController:stateChanged` event type. */ +export type ProfileControllerChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ProfileControllerState +>; + +/** Union of all events emitted by ProfileController. */ +export type ProfileControllerEvents = ProfileControllerChangeEvent; + +type AllowedActions = + | ProfileServiceCreateProfileAction + | ProfileServiceReplaceProfileAction + | ProfileServiceUpdateProfileAction + | ProfileServiceDeleteProfileAction + | ProfileServiceCheckUsernameAvailabilityAction + | ProfileServiceConnectXAction + | ProfileServiceGetXAccountAction; + +export type AllowedEvents = never; + +/** Messenger type for ProfileController, scoped to its actions, events, and allowed service calls. */ +export type ProfileControllerMessenger = Messenger< + typeof controllerName, + ProfileControllerActions | AllowedActions, + ProfileControllerEvents | AllowedEvents +>; + +// === STATE === + +const profileControllerMetadata = { + profile: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + xProfile: { + includeInStateLogs: false, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Returns the default initial state for ProfileController. + * + * @returns A ProfileControllerState with empty profile fields and no X profile. + */ +export function getDefaultProfileControllerState(): ProfileControllerState { + return { + profile: { + profileId: '', + username: '', + displayName: '', + bio: '', + linkedAddresses: [], + avatarUrl: '', + tradingPrivacy: 'public', + connectedToX: false, + createdAt: '', + updatedAt: '', + }, + }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'getProfile', + 'getXProfile', + 'createProfile', + 'replaceProfile', + 'updateProfile', + 'deleteProfile', + 'checkUsernameAvailability', + 'connectX', + 'fetchAndUpdateXAccount', +] as const; + +// === CONTROLLER === + +/** Manages MetaMask profile state and delegates API operations to ProfileService. */ +export class ProfileController extends BaseController< + typeof controllerName, + ProfileControllerState, + ProfileControllerMessenger +> { + /** + * Creates a new ProfileController instance. + * + * @param options - Constructor options. + * @param options.messenger - The messenger scoped to ProfileController. + * @param options.state - Optional partial initial state to merge with defaults. + */ + constructor({ + messenger, + state, + }: { + messenger: ProfileControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: profileControllerMetadata, + state: { + ...getDefaultProfileControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Returns true if a profile has been created and exists in state. + * + * @returns True if a profile exists, false otherwise. + */ + #hasProfile(): boolean { + return this.state.profile.profileId !== ''; + } + + /** + * Returns the profile ID from state, throwing if no profile exists yet. + * + * @returns The current profile ID. + * @throws If no profile has been created. + */ + #getProfileIdOrThrow(): string { + const { profileId } = this.state.profile; + if (!profileId) { + throw new Error('ProfileController: no profile found in state'); + } + return profileId; + } + + /** + * Maps an API response to a MetaMask profile. + * + * @param response - The API response to map. + * @returns The mapped MetaMask profile. + */ + #mapApiResponseToProfile(response: ProfileApiResponse): Profile { + return { + profileId: response.profile_id, + username: response.username, + displayName: response.display_name, + bio: response.bio ?? '', + linkedAddresses: response.linked_addresses, + avatarUrl: response.avatar_url ?? '', + tradingPrivacy: response.trading_privacy, + connectedToX: response.connected_to_x, + createdAt: response.created_at, + updatedAt: response.updated_at, + }; + } + + /** + * Maps an API response to an X profile. + * + * @param response - The API response to map. + * @returns The mapped X profile. + */ + #mapXResponseToXProfile(response: XConnectResponse): XProfile { + return { + xUserId: response.x_user_id, + xProfileUrl: response.x_profile_url, + username: response.username, + displayName: response.display_name, + avatarUrl: response.avatar_url, + createdAt: response.created_at, + updatedAt: response.updated_at, + }; + } + + /** + * Returns the current MetaMask profile from state, or undefined if none has been created. + * + * @returns The MetaMask profile, or undefined. + */ + getProfile(): Profile | undefined { + if (!this.#hasProfile()) { + return undefined; + } + return this.state.profile; + } + + /** + * Returns the currently linked X profile from state, or undefined if none has been connected. + * + * @returns The X profile, or undefined. + */ + getXProfile(): XProfile | undefined { + return this.state.xProfile; + } + + /** + * 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. + */ + async createProfile(params: CreateProfileParams): Promise { + const response: CreateProfileResponse = await this.messenger.call( + 'ProfileService:createProfile', + params, + ); + const mapped = this.#mapApiResponseToProfile(response); + this.update((state) => { + state.profile = mapped; + if (response.x_profile) { + state.xProfile = this.#mapXResponseToXProfile(response.x_profile); + } + }); + return mapped; + } + + /** + * Fully replaces the current profile and updates state. + * + * @param input - The replacement profile data. + * @throws If no profile has been created yet. + */ + async replaceProfile(input: ReplaceProfileParams): Promise { + const profileId = this.#getProfileIdOrThrow(); + const response = await this.messenger.call( + 'ProfileService:replaceProfile', + profileId, + input, + ); + this.update((state) => { + state.profile = this.#mapApiResponseToProfile(response); + }); + } + + /** + * Partially updates the current profile and updates state. + * + * @param input - The fields to update. + * @throws If no profile has been created yet. + */ + async updateProfile(input: UpdateProfileParams): Promise { + const profileId = this.#getProfileIdOrThrow(); + const response = await this.messenger.call( + 'ProfileService:updateProfile', + profileId, + input, + ); + this.update((state) => { + state.profile = this.#mapApiResponseToProfile(response); + }); + } + + /** + * Deletes the current profile and resets state, including clearing any linked X profile. + * + * @throws If no profile has been created yet. + */ + async deleteProfile(): Promise { + const profileId = this.#getProfileIdOrThrow(); + await this.messenger.call('ProfileService:deleteProfile', profileId); + this.update((state) => { + state.profile = getDefaultProfileControllerState().profile; + state.xProfile = undefined; + }); + } + + /** + * Checks whether a username is available. + * + * @param username - The username to check. + * @returns Availability details including validity and normalized form. + */ + async checkUsernameAvailability( + username: string, + ): Promise { + return await this.messenger.call( + 'ProfileService:checkUsernameAvailability', + username, + ); + } + + /** + * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. + * + * @param params - The parameters for the X OAuth PKCE 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. + */ + async connectX(params: { code: string; state: string }): Promise { + const response = await this.messenger.call( + 'ProfileService:connectX', + params, + ); + return this.#mapXResponseToXProfile(response); + } + + /** + * Fetches the X account linked to the current profile, updates state, and returns the X profile. + * + * @returns The linked X profile. + */ + async fetchAndUpdateXAccount(): Promise { + const response = await this.messenger.call('ProfileService:getXAccount'); + const mapped = this.#mapXResponseToXProfile(response); + this.update((state) => { + state.xProfile = mapped; + }); + return mapped; + } +} diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts new file mode 100644 index 00000000000..146dff93f69 --- /dev/null +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -0,0 +1,136 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProfileService } from './ProfileService.js'; + +/** + * Fetches a profile by its identifier. + * + * @param profileId - The profile identifier to fetch. + * @returns The profile data from the API. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response does not match the expected shape. + */ +export type ProfileServiceGetProfileAction = { + type: `ProfileService:getProfile`; + handler: ProfileService['getProfile']; +}; + +/** + * Creates a new MetaMask profile. + * + * @param params - The profile creation parameters. + * @returns The created profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ +export type ProfileServiceCreateProfileAction = { + type: `ProfileService:createProfile`; + handler: ProfileService['createProfile']; +}; + +/** + * Fully replaces an existing profile (PUT). + * + * @param profileId - The identifier of the profile to replace. + * @param params - The replacement profile data. + * @returns The updated profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ +export type ProfileServiceReplaceProfileAction = { + type: `ProfileService:replaceProfile`; + handler: ProfileService['replaceProfile']; +}; + +/** + * Partially updates an existing profile (PATCH). + * + * @param profileId - The identifier of the profile to update. + * @param params - The fields to update. + * @returns The updated profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ +export type ProfileServiceUpdateProfileAction = { + type: `ProfileService:updateProfile`; + handler: ProfileService['updateProfile']; +}; + +/** + * Deletes a profile by its identifier. + * + * @param profileId - The identifier of the profile to delete. + * @returns The result of the mutation. + * @throws {HttpError} If the API returns a non-2xx response. + */ +export type ProfileServiceDeleteProfileAction = { + type: `ProfileService:deleteProfile`; + handler: ProfileService['deleteProfile']; +}; + +/** + * Checks whether a username is available. + * + * @param username - The username to check. + * @returns Availability details including validity and normalized form. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response does not match the expected shape. + */ +export type ProfileServiceCheckUsernameAvailabilityAction = { + type: `ProfileService:checkUsernameAvailability`; + handler: ProfileService['checkUsernameAvailability']; +}; + +/** + * Fetches the X OAuth PKCE authorization URL and its associated state 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. + */ +export type ProfileServiceGetXAuthUrlAction = { + type: `ProfileService:getXAuthUrl`; + handler: ProfileService['getXAuthUrl']; +}; + +/** + * Completes the X OAuth PKCE flow and links the X account to the profile. + * + * @param params - The OAuth callback code and state from the X redirect. + * @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. + */ +export type ProfileServiceConnectXAction = { + type: `ProfileService:connectX`; + handler: ProfileService['connectX']; +}; + +/** + * Fetches the X account currently linked to the authenticated profile. + * + * @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. + */ +export type ProfileServiceGetXAccountAction = { + type: `ProfileService:getXAccount`; + handler: ProfileService['getXAccount']; +}; + +/** + * Union of all ProfileService action types. + */ +export type ProfileServiceMethodActions = + | ProfileServiceGetProfileAction + | ProfileServiceCreateProfileAction + | ProfileServiceReplaceProfileAction + | ProfileServiceUpdateProfileAction + | ProfileServiceDeleteProfileAction + | ProfileServiceCheckUsernameAvailabilityAction + | ProfileServiceGetXAuthUrlAction + | ProfileServiceConnectXAction + | ProfileServiceGetXAccountAction; diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts new file mode 100644 index 00000000000..b12e95d1572 --- /dev/null +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -0,0 +1,553 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { + ProfileService, + ProfileServiceErrorMessage, + serviceName, +} from './ProfileService.js'; +import type { + ConnectXParams, + CreateProfileParams, + ProfileServiceMessenger, + ReplaceProfileParams, + UpdateProfileParams, +} from './ProfileService.js'; + +const BASE_URL = 'https://profile.api.cx.metamask.io'; +const V1_URL = `${BASE_URL}/v1`; +const MOCK_TOKEN = 'mock-bearer-token'; + +const mockProfileResponse = { + profile_id: 'profile-123', + username: 'alice', + display_name: 'Alice Wonderland', + bio: 'MetaMask user', + linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + avatar_url: 'https://example.com/avatar.png', + trading_privacy: 'public' as const, + connected_to_x: false, + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', +}; + +const mockXConnectResponse = { + x_user_id: 'x-user-123', + x_profile_url: 'https://x.com/degengirl', + username: 'degengirl', + display_name: 'Degen Girl', + avatar_url: 'https://pbs.twimg.com/profile_images/degengirl.jpg', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-02T00:00:00Z', +}; + +const mockAvailabilityResponse = { + username: 'alice', + available: true, + valid: true, + normalized: 'alice', + errors: [], +}; + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +function getRootMessenger(): RootMessenger { + return new Messenger({ namespace: MOCK_ANY_NAMESPACE }); +} + +function createMessenger( + rootMessenger?: RootMessenger, +): ProfileServiceMessenger { + const root = rootMessenger ?? getRootMessenger(); + + root.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => MOCK_TOKEN, + ); + + const serviceMessenger: ProfileServiceMessenger = new Messenger({ + namespace: serviceName, + parent: root, + }); + + root.delegate({ + messenger: serviceMessenger, + actions: ['AuthenticationController:getBearerToken'], + }); + + return serviceMessenger; +} + +function createService( + options: { messenger?: ProfileServiceMessenger } = {}, +): ProfileService { + const messenger = options.messenger ?? createMessenger(); + return new ProfileService({ messenger, baseUrl: BASE_URL }); +} + +describe('ProfileService', () => { + const mockFetch = jest.fn(); + const originalFetch = global.fetch; + + beforeEach(() => { + global.fetch = mockFetch; + mockFetch.mockReset(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + describe('getProfile', () => { + it('fetches profile from correct endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + const result = await service.getProfile('profile-123'); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/profile-123`, { + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }); + }); + + it('encodes the identifier in the URL', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + await service.getProfile('user/with/slashes'); + + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/user%2Fwith%2Fslashes`, + expect.anything(), + ); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + + const service = createService(); + + await expect(service.getProfile('profile-123')).rejects.toThrow( + `${ProfileServiceErrorMessage.GET_PROFILE_FAILED}: 404`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.getProfile('profile-123')).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); + + describe('createProfile', () => { + const input: CreateProfileParams = { + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + trading_privacy: 'public', + }; + + it('posts to the profiles endpoint with correct body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 201, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + const result = await service.createProfile(input); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles`, { + method: 'POST', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 400 }); + + const service = createService(); + + await expect(service.createProfile(input)).rejects.toThrow( + `${ProfileServiceErrorMessage.CREATE_PROFILE_FAILED}: 400`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 201, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.createProfile(input)).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); + + describe('replaceProfile', () => { + const input: ReplaceProfileParams = { + username: 'alice2', + display_name: 'Alice 2', + linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + trading_privacy: 'public', + }; + + it('puts to the profile endpoint with correct body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + const result = await service.replaceProfile('profile-123', input); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/profile-123`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 409 }); + + const service = createService(); + + await expect( + service.replaceProfile('profile-123', input), + ).rejects.toThrow( + `${ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED}: 409`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect( + service.replaceProfile('profile-123', input), + ).rejects.toThrow('returned an unexpected response'); + }); + }); + + describe('updateProfile', () => { + const input: UpdateProfileParams = { + username: 'alice2', + display_name: 'Alice 2', + }; + + it('patches the profile endpoint with correct body', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockProfileResponse), + }); + + const service = createService(); + const result = await service.updateProfile('profile-123', input); + + expect(result).toStrictEqual(mockProfileResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/profile-123`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(input), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 422 }); + + const service = createService(); + + await expect(service.updateProfile('profile-123', input)).rejects.toThrow( + `${ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED}: 422`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.updateProfile('profile-123', input)).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); + + describe('deleteProfile', () => { + it('sends DELETE to the profile endpoint', async () => { + mockFetch.mockResolvedValue({ ok: true, status: 204, json: () => null }); + + const service = createService(); + await service.deleteProfile('profile-123'); + + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/profile-123`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 403 }); + + const service = createService(); + + await expect(service.deleteProfile('profile-123')).rejects.toThrow( + `${ProfileServiceErrorMessage.DELETE_PROFILE_FAILED}: 403`, + ); + }); + }); + + describe('checkUsernameAvailability', () => { + it('fetches username availability from correct endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAvailabilityResponse), + }); + + const service = createService(); + const result = await service.checkUsernameAvailability('alice'); + + expect(result).toStrictEqual(mockAvailabilityResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/username/availability?username=alice`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('encodes the username in the URL', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAvailabilityResponse), + }); + + const service = createService(); + await service.checkUsernameAvailability('alice smith'); + + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/username/availability?username=alice+smith`, + expect.anything(), + ); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = createService(); + + await expect(service.checkUsernameAvailability('alice')).rejects.toThrow( + `${ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED}: 500`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.checkUsernameAvailability('alice')).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); + + describe('getXAuthUrl', () => { + const mockAuthUrlResponse = { + url: 'https://twitter.com/i/oauth2/authorize?client_id=abc&state=xyz', + state: 'xyz', + }; + + it('fetches X authentication URL from correct endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockAuthUrlResponse), + }); + + const service = createService(); + const result = await service.getXAuthUrl(); + + expect(result).toStrictEqual(mockAuthUrlResponse); + expect(mockFetch).toHaveBeenCalledWith( + `${V1_URL}/profiles/x/authentication-url`, + { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, + ); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 500 }); + + const service = createService(); + + await expect(service.getXAuthUrl()).rejects.toThrow( + `${ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED}: 500`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.getXAuthUrl()).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); + + describe('connectX', () => { + it('posts code and state to the X connect endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockXConnectResponse), + }); + + const params: ConnectXParams = { + code: 'auth-code-123', + state: 'state-xyz', + }; + const service = createService(); + const result = await service.connectX(params); + + expect(result).toStrictEqual(mockXConnectResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/x/connect`, { + method: 'POST', + headers: { + Authorization: `Bearer ${MOCK_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(params), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + + const service = createService(); + + await expect( + service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), + ).rejects.toThrow(`${ProfileServiceErrorMessage.CONNECT_X_FAILED}: 401`); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect( + service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), + ).rejects.toThrow('returned an unexpected response'); + }); + }); + + describe('getXAccount', () => { + it('fetches X account from correct endpoint', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve(mockXConnectResponse), + }); + + const service = createService(); + const result = await service.getXAccount(); + + expect(result).toStrictEqual(mockXConnectResponse); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/x/account`, { + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 404 }); + + const service = createService(); + + await expect(service.getXAccount()).rejects.toThrow( + `${ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED}: 404`, + ); + }); + + it('throws when response schema is invalid', async () => { + mockFetch.mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.resolve({ invalid: 'shape' }), + }); + + const service = createService(); + + await expect(service.getXAccount()).rejects.toThrow( + 'returned an unexpected response', + ); + }); + }); +}); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts new file mode 100644 index 00000000000..9f66bdcbd88 --- /dev/null +++ b/packages/profile-controller/src/ProfileService.ts @@ -0,0 +1,536 @@ +import { BaseDataService } from '@metamask/base-data-service'; +import type { + CreateServicePolicyOptions, + DataServiceCacheUpdatedEvent, + DataServiceGranularCacheUpdatedEvent, + DataServiceInvalidateQueriesAction, +} from '@metamask/base-data-service'; +import { HttpError } from '@metamask/controller-utils'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationController } from '@metamask/profile-sync-controller'; +import { + array, + boolean, + enums, + intersection, + nullable, + optional, + sensitive, + string, + type as structType, +} from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; +import { CaipAccountIdStruct } from '@metamask/utils'; + +import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; + +export const serviceName = 'ProfileService'; + +// --------------------------------------------------------------------------- +// Error messages +// --------------------------------------------------------------------------- + +/** Human-readable error messages for each ProfileService operation. */ +export const ProfileServiceErrorMessage = { + GET_PROFILE_FAILED: 'ProfileService: failed to fetch profile', + CREATE_PROFILE_FAILED: 'ProfileService: failed to create profile', + REPLACE_PROFILE_FAILED: 'ProfileService: failed to replace profile', + UPDATE_PROFILE_FAILED: 'ProfileService: failed to update profile', + DELETE_PROFILE_FAILED: 'ProfileService: failed to delete profile', + CHECK_USERNAME_AVAILABILITY_FAILED: + 'ProfileService: failed to check username availability', + 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', +} as const; + +// --------------------------------------------------------------------------- +// Superstruct schemas +// --------------------------------------------------------------------------- + +const TradingPrivacyStruct = enums(['public', 'private'] as const); + +const ProfileApiResponseStruct = structType({ + profile_id: string(), + username: string(), + display_name: string(), + bio: nullable(string()), + linked_addresses: array(CaipAccountIdStruct), + avatar_url: nullable(string()), + trading_privacy: TradingPrivacyStruct, + connected_to_x: boolean(), + created_at: string(), + updated_at: string(), +}); + +const UsernameAvailabilityErrorStruct = structType({ + code: string(), + message: string(), +}); + +const UsernameAvailabilityResponseStruct = structType({ + username: string(), + available: boolean(), + valid: boolean(), + normalized: string(), + errors: array(UsernameAvailabilityErrorStruct), +}); + +const XAuthUrlResponseStruct = structType({ + url: sensitive(string()), + state: sensitive(string()), +}); + +const XConnectResponseStruct = structType({ + x_user_id: string(), + x_profile_url: string(), + username: string(), + display_name: string(), + avatar_url: string(), + created_at: string(), + updated_at: string(), +}); + +const CreateProfileResponseStruct = intersection([ + ProfileApiResponseStruct, + structType({ x_profile: optional(XConnectResponseStruct) }), +]); + +const ConnectXParamsStruct = structType({ + code: sensitive(string()), + state: sensitive(string()), +}); + +const CreateProfileParamsStruct = structType({ + profile_id: string(), + username: string(), + display_name: string(), + bio: optional(nullable(string())), + linked_addresses: array(CaipAccountIdStruct), + avatar_url: optional(string()), + trading_privacy: TradingPrivacyStruct, +}); + +const ReplaceProfileParamsStruct = structType({ + username: string(), + display_name: string(), + bio: optional(nullable(string())), + linked_addresses: array(CaipAccountIdStruct), + avatar_url: optional(string()), + trading_privacy: TradingPrivacyStruct, +}); + +const UpdateProfileParamsStruct = structType({ + username: optional(string()), + display_name: optional(string()), + bio: optional(nullable(string())), + linked_addresses: optional(array(CaipAccountIdStruct)), + avatar_url: optional(string()), + trading_privacy: optional(TradingPrivacyStruct), +}); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +const MESSENGER_EXPOSED_METHODS = [ + 'getProfile', + 'createProfile', + 'replaceProfile', + 'updateProfile', + 'deleteProfile', + 'checkUsernameAvailability', + 'getXAuthUrl', + 'connectX', + 'getXAccount', +] as const; + +/** The shape of a profile returned by the MetaMask Profile API. */ +export type ProfileApiResponse = Infer; + +/** The response shape for createProfile — includes an optional linked X profile if the user had already connected X. */ +export type CreateProfileResponse = Infer; + +/** The response shape for a username availability check. */ +export type UsernameAvailabilityResponse = Infer< + typeof UsernameAvailabilityResponseStruct +>; + +/** The response shape returned when connecting or fetching an X account. */ +export type XConnectResponse = 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; + +/** Parameters for creating a new MetaMask profile. */ +export type CreateProfileParams = Infer; + +/** Parameters for fully replacing an existing MetaMask profile (PUT). */ +export type ReplaceProfileParams = Infer; + +/** Parameters for partially updating an existing MetaMask profile (PATCH). */ +export type UpdateProfileParams = Infer; + +/** Parameters for completing the X OAuth PKCE flow. */ +export type ConnectXParams = Infer; + +// --------------------------------------------------------------------------- +// Messenger types +// --------------------------------------------------------------------------- + +/** Union of all actions exposed by ProfileService, including cache invalidation. */ +export type ProfileServiceActions = + | ProfileServiceMethodActions + | DataServiceInvalidateQueriesAction; + +/** Event emitted when the ProfileService query cache is updated. */ +export type ProfileServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +/** Event emitted with per-query granularity when the ProfileService cache is updated. */ +export type ProfileServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +/** Union of all events emitted by ProfileService. */ +export type ProfileServiceEvents = + | ProfileServiceCacheUpdatedEvent + | ProfileServiceGranularCacheUpdatedEvent; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +type AllowedEvents = never; + +/** Messenger type for ProfileService, scoped to its actions and events. */ +export type ProfileServiceMessenger = Messenger< + typeof serviceName, + ProfileServiceActions | AllowedActions, + ProfileServiceEvents | AllowedEvents +>; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +/** Communicates with the MetaMask Profile API and exposes all operations via the messenger. */ +export class ProfileService extends BaseDataService< + typeof serviceName, + ProfileServiceMessenger +> { + readonly #baseUrl: string; + + get #v1Url(): string { + return `${this.#baseUrl}/v1`; + } + + /** + * Creates a new ProfileService instance. + * + * @param options - Constructor options. + * @param options.messenger - The messenger scoped to ProfileService. + * @param options.baseUrl - Base URL for the MetaMask Profile API. + * @param options.policyOptions - Optional service policy configuration. + */ + constructor({ + messenger, + baseUrl, + policyOptions, + }: { + messenger: ProfileServiceMessenger; + baseUrl: string; + policyOptions?: CreateServicePolicyOptions; + }) { + super({ name: serviceName, messenger, policyOptions }); + this.#baseUrl = baseUrl; + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Gets the authentication headers for the request. + * + * @returns The authentication headers. + */ + async #getAuthHeaders(): Promise> { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return { Authorization: `Bearer ${token}` }; + } + + /** + * Executes an authenticated HTTP request and returns the parsed JSON response. + * + * @param endpoint - The path relative to the v1 base URL. + * @param options - Request options. + * @param options.method - The HTTP method. Defaults to 'GET'. + * @param options.error - The error message to use if the response is not OK. + * @param options.json - Optional JSON body. If provided, sets Content-Type and serializes as body. + * @returns The parsed JSON response. + * @throws {HttpError} If the response is not a 2xx status code. + */ + async #fetch( + endpoint: string, + options: { method: 'DELETE'; error: string }, + ): Promise; + + async #fetch( + endpoint: string, + options: { + method?: string; + error: string; + json?: unknown; + searchParams?: Record; + }, + ): Promise; + + async #fetch( + endpoint: string, + { + method = 'GET', + error, + json, + searchParams, + }: { + method?: string; + error: string; + json?: unknown; + searchParams?: Record; + }, + ): Promise { + const authHeaders = await this.#getAuthHeaders(); + const url = new URL(`${this.#v1Url}/${endpoint}`); + if (searchParams) { + for (const [key, value] of Object.entries(searchParams)) { + url.searchParams.append(key, value); + } + } + const response = await fetch(url.toString(), { + ...(method === 'GET' ? {} : { method }), + headers: { + ...authHeaders, + ...(json === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(json === undefined ? {} : { body: JSON.stringify(json) }), + }); + if (!response.ok) { + throw new HttpError(response.status, `${error}: ${response.status}`); + } + if (method === 'DELETE') { + return null; + } + return (await response.json()) as ResponseType; + } + + /** + * Fetches a profile by its identifier. + * + * @param profileId - The profile identifier to fetch. + * @returns The profile data from the API. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response does not match the expected shape. + */ + async getProfile(profileId: string): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getProfile`, profileId], + responseStruct: ProfileApiResponseStruct, + queryFn: async () => + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + error: ProfileServiceErrorMessage.GET_PROFILE_FAILED, + }, + ), + }); + } + + /** + * Creates a new MetaMask profile. + * + * @param params - The profile creation parameters. + * @returns The created profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ + async createProfile( + params: CreateProfileParams, + ): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:createProfile`], + responseStruct: CreateProfileResponseStruct, + mutationFn: async () => + this.#fetch('profiles', { + method: 'POST', + error: ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, + json: params, + }), + }); + } + + /** + * Fully replaces an existing profile (PUT). + * + * @param profileId - The identifier of the profile to replace. + * @param params - The replacement profile data. + * @returns The updated profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ + async replaceProfile( + profileId: string, + params: ReplaceProfileParams, + ): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:replaceProfile`, profileId], + responseStruct: ProfileApiResponseStruct, + mutationFn: async () => + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + method: 'PUT', + error: ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, + json: params, + }, + ), + }); + } + + /** + * Partially updates an existing profile (PATCH). + * + * @param profileId - The identifier of the profile to update. + * @param params - The fields to update. + * @returns The updated profile data. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response do not match the expected shape. + */ + async updateProfile( + profileId: string, + params: UpdateProfileParams, + ): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:updateProfile`, profileId], + responseStruct: ProfileApiResponseStruct, + mutationFn: async () => + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + method: 'PATCH', + error: ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, + json: params, + }, + ), + }); + } + + /** + * Deletes a profile by its identifier. + * + * @param profileId - The identifier of the profile to delete. + * @returns The result of the mutation. + * @throws {HttpError} If the API returns a non-2xx response. + */ + async deleteProfile(profileId: string): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:deleteProfile`, profileId], + mutationFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + method: 'DELETE', + error: ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, + }), + }); + } + + /** + * Checks whether a username is available. + * + * @param username - The username to check. + * @returns Availability details including validity and normalized form. + * @throws {HttpError} If the API returns a non-2xx response. + * @throws {StructError} If the response does not match the expected shape. + */ + async checkUsernameAvailability( + username: string, + ): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:checkUsernameAvailability`, username], + staleTime: 0, + responseStruct: UsernameAvailabilityResponseStruct, + queryFn: async () => + this.#fetch( + 'profiles/username/availability', + { + error: + ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, + searchParams: { username }, + }, + ), + }); + } + + /** + * Fetches the X OAuth PKCE authorization URL and its associated state 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 { + return this.executeMutation({ + mutationKey: [`${this.name}:getXAuthUrl`], + responseStruct: XAuthUrlResponseStruct, + mutationFn: async () => + this.#fetch('profiles/x/authentication-url', { + error: ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, + }), + }); + } + + /** + * Completes the X OAuth PKCE flow and links the X account to the profile. + * + * @param params - The OAuth callback code and state from the X redirect. + * @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. + */ + async connectX(params: ConnectXParams): Promise { + return this.executeMutation({ + mutationKey: [`${this.name}:connectX`], + responseStruct: XConnectResponseStruct, + mutationFn: async () => + this.#fetch('profiles/x/connect', { + method: 'POST', + error: ProfileServiceErrorMessage.CONNECT_X_FAILED, + json: params, + }), + }); + } + + /** + * Fetches the X account currently linked to the authenticated profile. + * + * @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. + */ + async getXAccount(): Promise { + return this.fetchQuery({ + queryKey: [`${this.name}:getXAccount`], + staleTime: 0, + responseStruct: XConnectResponseStruct, + queryFn: async () => + 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 new file mode 100644 index 00000000000..b0193cd03be --- /dev/null +++ b/packages/profile-controller/src/index.ts @@ -0,0 +1,55 @@ +export type { + Profile, + XProfile, + ProfileControllerState, + ProfileControllerGetStateAction, + ProfileControllerActions, + ProfileControllerChangeEvent, + ProfileControllerEvents, + ProfileControllerMessenger, +} from './ProfileController.js'; +export { + ProfileController, + getDefaultProfileControllerState, +} from './ProfileController.js'; +export type { + ProfileControllerCheckUsernameAvailabilityAction, + ProfileControllerConnectXAction, + ProfileControllerCreateProfileAction, + ProfileControllerDeleteProfileAction, + ProfileControllerGetProfileAction, + ProfileControllerFetchAndUpdateXAccountAction, + ProfileControllerGetXProfileAction, + ProfileControllerReplaceProfileAction, + ProfileControllerUpdateProfileAction, +} from './ProfileController-method-action-types.js'; +export type { + ProfileApiResponse, + CreateProfileResponse, + CreateProfileParams, + ReplaceProfileParams, + UpdateProfileParams, + ConnectXParams, + UsernameAvailabilityResponse, + XAuthUrlResponse, + XConnectResponse, + XAccountResponse, + ProfileServiceActions, + ProfileServiceEvents, + ProfileServiceMessenger, +} from './ProfileService.js'; +export { + ProfileService, + ProfileServiceErrorMessage, +} from './ProfileService.js'; +export type { + ProfileServiceCheckUsernameAvailabilityAction, + ProfileServiceConnectXAction, + ProfileServiceCreateProfileAction, + ProfileServiceDeleteProfileAction, + ProfileServiceGetProfileAction, + ProfileServiceGetXAccountAction, + ProfileServiceGetXAuthUrlAction, + ProfileServiceReplaceProfileAction, + ProfileServiceUpdateProfileAction, +} from './ProfileService-method-action-types.js'; diff --git a/packages/profile-controller/tsconfig.build.json b/packages/profile-controller/tsconfig.build.json new file mode 100644 index 00000000000..e2d65e292c6 --- /dev/null +++ b/packages/profile-controller/tsconfig.build.json @@ -0,0 +1,28 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + }, + { + "path": "../utils/tsconfig.build.json" + }, + { + "path": "../base-data-service/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../controller-utils/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/profile-controller/tsconfig.json b/packages/profile-controller/tsconfig.json new file mode 100644 index 00000000000..700990377c7 --- /dev/null +++ b/packages/profile-controller/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.packages.json", + "references": [ + { + "path": "../base-controller/tsconfig.json" + }, + { + "path": "../messenger/tsconfig.json" + }, + { + "path": "../utils/tsconfig.json" + }, + { + "path": "../base-data-service/tsconfig.json" + }, + { + "path": "../profile-sync-controller/tsconfig.json" + }, + { + "path": "../controller-utils/tsconfig.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/profile-controller/tsconfig.lint.json b/packages/profile-controller/tsconfig.lint.json new file mode 100644 index 00000000000..e3f0f45e112 --- /dev/null +++ b/packages/profile-controller/tsconfig.lint.json @@ -0,0 +1,27 @@ +{ + "extends": ["./tsconfig.json", "../../tsconfig.packages.lint.json"], + "compilerOptions": { + "outDir": "./.tsc-lint-cache", + "tsBuildInfoFile": "./.tsc-lint-cache/tsconfig.tsbuildinfo" + }, + "references": [ + { + "path": "../base-controller/tsconfig.lint.json" + }, + { + "path": "../messenger/tsconfig.lint.json" + }, + { + "path": "../utils/tsconfig.lint.json" + }, + { + "path": "../base-data-service/tsconfig.lint.json" + }, + { + "path": "../profile-sync-controller/tsconfig.lint.json" + }, + { + "path": "../controller-utils/tsconfig.lint.json" + } + ] +} diff --git a/packages/profile-controller/typedoc.json b/packages/profile-controller/typedoc.json new file mode 100644 index 00000000000..0373637ee8a --- /dev/null +++ b/packages/profile-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "api-docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index f8d33e0e588..31eb48cef1a 100644 --- a/teams.json +++ b/teams.json @@ -6,6 +6,7 @@ "metamask/multichain-account-service": "team-accounts-framework", "metamask/account-tree-controller": "team-accounts-framework", "metamask/authenticated-user-storage": "team-auth-engineers", + "metamask/profile-controller": "team-accounts-framework", "metamask/profile-sync-controller": "team-accounts-framework", "metamask/ramps-controller": "team-money-movement", "metamask/advanced-chart-core": "team-assets", diff --git a/tsconfig.build.json b/tsconfig.build.json index 92fbb7d574d..5c16a819ebb 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -211,6 +211,9 @@ { "path": "./packages/notification-services-controller/tsconfig.build.json" }, + { + "path": "./packages/package-template/tsconfig.build.json" + }, { "path": "./packages/passkey-controller/tsconfig.build.json" }, @@ -235,6 +238,9 @@ { "path": "./packages/preferences-controller/tsconfig.build.json" }, + { + "path": "./packages/profile-controller/tsconfig.build.json" + }, { "path": "./packages/profile-metrics-controller/tsconfig.build.json" }, @@ -312,9 +318,6 @@ }, { "path": "./packages/wallet/tsconfig.build.json" - }, - { - "path": "./packages/package-template/tsconfig.build.json" } ], "files": [], diff --git a/tsconfig.json b/tsconfig.json index 375f978b249..35878a865d5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -216,6 +216,9 @@ { "path": "./packages/notification-services-controller" }, + { + "path": "./packages/package-template" + }, { "path": "./packages/passkey-controller" }, @@ -240,6 +243,9 @@ { "path": "./packages/preferences-controller" }, + { + "path": "./packages/profile-controller" + }, { "path": "./packages/profile-metrics-controller" }, @@ -317,9 +323,6 @@ }, { "path": "./packages/wallet-framework-docs" - }, - { - "path": "./packages/package-template" } ], "files": [], diff --git a/tsconfig.lint.json b/tsconfig.lint.json index 714bb52953a..46b4fe94304 100644 --- a/tsconfig.lint.json +++ b/tsconfig.lint.json @@ -245,6 +245,9 @@ { "path": "./packages/polling-controller/tsconfig.lint.json" }, + { + "path": "./packages/profile-controller/tsconfig.lint.json" + }, { "path": "./packages/profile-metrics-controller/tsconfig.lint.json" }, diff --git a/yarn.lock b/yarn.lock index 0b70569c4d0..62cc603e7f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8214,6 +8214,30 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-controller@workspace:packages/profile-controller": + version: 0.0.0-use.local + resolution: "@metamask/profile-controller@workspace:packages/profile-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.2.1" + "@metamask/base-controller": "npm:^10.0.0" + "@metamask/base-data-service": "npm:^2.1.0" + "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/profile-sync-controller": "npm:^33.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^12.0.0" + "@types/jest": "npm:^30.0.0" + "@typescript/native": "npm:typescript@^7.0.2" + deepmerge: "npm:^4.3.1" + jest: "npm:^30.5.2" + rimraf: "npm:^6.1.3" + ts-jest: "npm:^29.4.14" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:@typescript/typescript6@^6.0.2" + languageName: unknown + linkType: soft + "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller": version: 0.0.0-use.local resolution: "@metamask/profile-metrics-controller@workspace:packages/profile-metrics-controller"