From f1e68a04fccf520c22b3c98e084e000a42b4b1f2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 24 Sep 2026 14:27:33 -0400 Subject: [PATCH 01/73] feat(profile-controller): scaffold package --- README.md | 2 + packages/profile-controller/CHANGELOG.md | 10 + packages/profile-controller/LICENSE | 6 + packages/profile-controller/LICENSE.APACHE2 | 201 ++++++++++++++++++ packages/profile-controller/LICENSE.MIT | 21 ++ packages/profile-controller/README.md | 15 ++ packages/profile-controller/jest.config.cjs | 26 +++ packages/profile-controller/package.json | 74 +++++++ .../src/ProfileController.test.ts | 0 .../src/ProfileController.ts | 36 ++++ packages/profile-controller/src/index.test.ts | 9 + packages/profile-controller/src/index.ts | 9 + .../profile-controller/tsconfig.build.json | 19 ++ packages/profile-controller/tsconfig.json | 15 ++ .../profile-controller/tsconfig.lint.json | 18 ++ packages/profile-controller/typedoc.json | 7 + tsconfig.build.json | 9 +- tsconfig.json | 9 +- yarn.lock | 22 ++ 19 files changed, 502 insertions(+), 6 deletions(-) create mode 100644 packages/profile-controller/CHANGELOG.md create mode 100644 packages/profile-controller/LICENSE create mode 100644 packages/profile-controller/LICENSE.APACHE2 create mode 100644 packages/profile-controller/LICENSE.MIT create mode 100644 packages/profile-controller/README.md create mode 100644 packages/profile-controller/jest.config.cjs create mode 100644 packages/profile-controller/package.json create mode 100644 packages/profile-controller/src/ProfileController.test.ts create mode 100644 packages/profile-controller/src/ProfileController.ts create mode 100644 packages/profile-controller/src/index.test.ts create mode 100644 packages/profile-controller/src/index.ts create mode 100644 packages/profile-controller/tsconfig.build.json create mode 100644 packages/profile-controller/tsconfig.json create mode 100644 packages/profile-controller/tsconfig.lint.json create mode 100644 packages/profile-controller/typedoc.json diff --git a/README.md b/README.md index d083a1c795f..04a9bf64f84 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,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) @@ -222,6 +223,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"]); diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md new file mode 100644 index 00000000000..b518709c7b8 --- /dev/null +++ b/packages/profile-controller/CHANGELOG.md @@ -0,0 +1,10 @@ +# 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] + +[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..e6e77b08909 --- /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 [yyyy] [name of copyright owner] + + 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..673d58efe69 --- /dev/null +++ b/packages/profile-controller/package.json @@ -0,0 +1,74 @@ +{ + "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": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", + "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --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/controller-utils": "^13.0.0", + "@metamask/messenger": "^3.0.0", + "@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.2.2", + "jest": "^30.4.2", + "rimraf": "^6.1.3", + "ts-jest": "^29.4.12", + "tsx": "^4.23.13", + "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.test.ts b/packages/profile-controller/src/ProfileController.test.ts new file mode 100644 index 00000000000..e69de29bb2d diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts new file mode 100644 index 00000000000..57cba975d54 --- /dev/null +++ b/packages/profile-controller/src/ProfileController.ts @@ -0,0 +1,36 @@ +import type { CaipAccountAddress } from '@metamask/utils'; + +const controllerName = 'ProfileController'; + +type NetworkName = 'ethereum' | 'stellar' | 'solana' | 'bitcoin' | 'tron'; + +export type MetaMaskProfile = { + profileId: string; + username: string; + displayName: string; + bio?: string; + linkedAddresses: Record; + avatarUrl: string; + tradingPrivacy: 'public' | 'private'; + connectedToX: boolean; + createdAt: string; + updatedAt: string; +}; + +export type XProfile = { + userId: string; + profileId: string; + handle: string; + displayName: string; + profilePictureUrl: string; + verified: boolean; + createdAt: string; + updatedAt: string; +}; + +export type ProfileControllerState = { + metamaskProfile: MetaMaskProfile; + xProfile?: XProfile; +}; + +export type ProfileControllerGetStateAction = ControllerGetState; diff --git a/packages/profile-controller/src/index.test.ts b/packages/profile-controller/src/index.test.ts new file mode 100644 index 00000000000..baa030ac10f --- /dev/null +++ b/packages/profile-controller/src/index.test.ts @@ -0,0 +1,9 @@ +import greeter from './index.js'; + +describe('Test', () => { + it('greets', () => { + const name = 'Huey'; + const result = greeter(name); + expect(result).toBe('Hello, Huey!'); + }); +}); diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts new file mode 100644 index 00000000000..6972c117292 --- /dev/null +++ b/packages/profile-controller/src/index.ts @@ -0,0 +1,9 @@ +/** + * Example function that returns a greeting for the given name. + * + * @param name - The name to greet. + * @returns The greeting. + */ +export default function greeter(name: string): string { + return `Hello, ${name}!`; +} diff --git a/packages/profile-controller/tsconfig.build.json b/packages/profile-controller/tsconfig.build.json new file mode 100644 index 00000000000..c145368e144 --- /dev/null +++ b/packages/profile-controller/tsconfig.build.json @@ -0,0 +1,19 @@ +{ + "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" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/profile-controller/tsconfig.json b/packages/profile-controller/tsconfig.json new file mode 100644 index 00000000000..451a177a3d6 --- /dev/null +++ b/packages/profile-controller/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.packages.json", + "references": [ + { + "path": "../base-controller/tsconfig.json" + }, + { + "path": "../messenger/tsconfig.json" + }, + { + "path": "../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..e5b50698930 --- /dev/null +++ b/packages/profile-controller/tsconfig.lint.json @@ -0,0 +1,18 @@ +{ + "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" + } + ] +} 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/tsconfig.build.json b/tsconfig.build.json index 28646a76d5c..a02b778878b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -208,6 +208,9 @@ { "path": "./packages/notification-services-controller/tsconfig.build.json" }, + { + "path": "./packages/package-template/tsconfig.build.json" + }, { "path": "./packages/passkey-controller/tsconfig.build.json" }, @@ -232,6 +235,9 @@ { "path": "./packages/preferences-controller/tsconfig.build.json" }, + { + "path": "./packages/profile-controller/tsconfig.build.json" + }, { "path": "./packages/profile-metrics-controller/tsconfig.build.json" }, @@ -309,9 +315,6 @@ }, { "path": "./packages/wallet/tsconfig.build.json" - }, - { - "path": "./packages/package-template/tsconfig.build.json" } ], "files": [], diff --git a/tsconfig.json b/tsconfig.json index 2f5fd005c20..27a0bc583f9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -213,6 +213,9 @@ { "path": "./packages/notification-services-controller" }, + { + "path": "./packages/package-template" + }, { "path": "./packages/passkey-controller" }, @@ -237,6 +240,9 @@ { "path": "./packages/preferences-controller" }, + { + "path": "./packages/profile-controller" + }, { "path": "./packages/profile-metrics-controller" }, @@ -314,9 +320,6 @@ }, { "path": "./packages/wallet-framework-docs" - }, - { - "path": "./packages/package-template" } ], "files": [], diff --git a/yarn.lock b/yarn.lock index 23ecd3102c8..b044bd3cd3d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8536,6 +8536,28 @@ __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/controller-utils": "npm:^13.0.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/utils": "npm:^12.0.0" + "@types/jest": "npm:^30.0.0" + "@typescript/native": "npm:typescript@^7.0.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^30.4.2" + rimraf: "npm:^6.1.3" + ts-jest: "npm:^29.4.12" + tsx: "npm:^4.23.13" + 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" From 4f66f5284a33dedab36ad9ca67865352fca1e3d4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 24 Sep 2026 16:18:22 -0400 Subject: [PATCH 02/73] feat(profile-controller): remove index test file --- packages/profile-controller/src/index.test.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 packages/profile-controller/src/index.test.ts diff --git a/packages/profile-controller/src/index.test.ts b/packages/profile-controller/src/index.test.ts deleted file mode 100644 index baa030ac10f..00000000000 --- a/packages/profile-controller/src/index.test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import greeter from './index.js'; - -describe('Test', () => { - it('greets', () => { - const name = 'Huey'; - const result = greeter(name); - expect(result).toBe('Hello, Huey!'); - }); -}); From 712211819ab2991a306b3a0dd13ccbc76875f167 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 24 Sep 2026 16:18:59 -0400 Subject: [PATCH 03/73] feat(profile-controller): add method action types --- .../ProfileController-method-action-types.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 packages/profile-controller/src/ProfileController-method-action-types.ts 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..fea911fa02d --- /dev/null +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -0,0 +1,41 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProfileController } from './ProfileController.js'; + +export type ProfileControllerGetMetaMaskProfileAction = { + type: `ProfileController:getMetaMaskProfile`; + handler: ProfileController['getMetaMaskProfile']; +}; + +export type ProfileControllerGetXprofileAction = { + type: `ProfileController:getXprofile`; + handler: ProfileController['getXprofile']; +}; + +export type ProfileControllerCreateProfileAction = { + type: `ProfileController:createProfile`; + handler: ProfileController['createProfile']; +}; + +export type ProfileControllerUpdateProfileAction = { + type: `ProfileController:updateProfile`; + handler: ProfileController['updateProfile']; +}; + +export type ProfileControllerCheckUsernameAvailabilityAction = { + type: `ProfileController:checkUsernameAvailability`; + handler: ProfileController['checkUsernameAvailability']; +}; + +/** + * Union of all ProfileController action types. + */ +export type ProfileControllerMethodActions = + | ProfileControllerGetMetaMaskProfileAction + | ProfileControllerGetXprofileAction + | ProfileControllerCreateProfileAction + | ProfileControllerUpdateProfileAction + | ProfileControllerCheckUsernameAvailabilityAction; From 098fc11358bb0d42b6fd6c9221588f2d4175b108 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Thu, 24 Sep 2026 16:19:31 -0400 Subject: [PATCH 04/73] feat(profile-controller): add exports to index file --- packages/profile-controller/src/index.ts | 30 +++++++++++++++++------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 6972c117292..7a732b00a2b 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -1,9 +1,21 @@ -/** - * Example function that returns a greeting for the given name. - * - * @param name - The name to greet. - * @returns The greeting. - */ -export default function greeter(name: string): string { - return `Hello, ${name}!`; -} +export type { + MetaMaskProfile, + XProfile, + ProfileControllerState, + ProfileControllerGetStateAction, + ProfileControllerActions, + ProfileControllerChangeEvent, + ProfileControllerEvents, + ProfileControllerMessenger, +} from './ProfileController.js'; +export { + ProfileController, + getDefaultProfileControllerState, +} from './ProfileController.js'; +export type { + ProfileControllerCheckUsernameAvailabilityAction, + ProfileControllerCreateProfileAction, + ProfileControllerGetMetaMaskProfileAction, + ProfileControllerGetXprofileAction, + ProfileControllerUpdateProfileAction, +} from './ProfileController-method-action-types.js'; From 98cd1cec980dfbdc6e3ced55af518fb3da6c16dd Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 13:31:06 -0400 Subject: [PATCH 05/73] feat(profile-controller): add profile controller and service --- packages/profile-controller/CHANGELOG.md | 5 + packages/profile-controller/package.json | 3 + .../ProfileController-method-action-types.ts | 34 +- .../src/ProfileController.test.ts | 503 ++++++++++++++++ .../src/ProfileController.ts | 278 ++++++++- .../src/ProfileService-method-action-types.ts | 65 ++ .../src/ProfileService.test.ts | 563 ++++++++++++++++++ .../profile-controller/src/ProfileService.ts | 471 +++++++++++++++ packages/profile-controller/src/index.ts | 29 + .../profile-controller/tsconfig.build.json | 6 + packages/profile-controller/tsconfig.json | 6 + .../profile-controller/tsconfig.lint.json | 6 + yarn.lock | 3 + 13 files changed, 1960 insertions(+), 12 deletions(-) create mode 100644 packages/profile-controller/src/ProfileService-method-action-types.ts create mode 100644 packages/profile-controller/src/ProfileService.test.ts create mode 100644 packages/profile-controller/src/ProfileService.ts diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index b518709c7b8..a9d659c1683 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -7,4 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `updateProfile`, `deleteProfile`, and `checkUsernameAvailability` via the messenger +- Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `updateProfile`, `deleteProfile`, and `checkUsernameAvailability` via the messenger + [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 673d58efe69..74e75be8d9c 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -51,8 +51,11 @@ }, "dependencies": { "@metamask/base-controller": "^10.0.0", + "@metamask/base-data-service": "^2.0.0", "@metamask/controller-utils": "^13.0.0", "@metamask/messenger": "^3.0.0", + "@metamask/profile-sync-controller": "^32.3.1", + "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0" }, "devDependencies": { diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index fea911fa02d..91893c6fb49 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController.js'; +import type { ProfileController } from './ProfileController'; export type ProfileControllerGetMetaMaskProfileAction = { type: `ProfileController:getMetaMaskProfile`; @@ -20,16 +20,41 @@ export type ProfileControllerCreateProfileAction = { handler: ProfileController['createProfile']; }; +export type ProfileControllerReplaceProfileAction = { + type: `ProfileController:replaceProfile`; + handler: ProfileController['replaceProfile']; +}; + export type ProfileControllerUpdateProfileAction = { type: `ProfileController:updateProfile`; handler: ProfileController['updateProfile']; }; +export type ProfileControllerDeleteProfileAction = { + type: `ProfileController:deleteProfile`; + handler: ProfileController['deleteProfile']; +}; + export type ProfileControllerCheckUsernameAvailabilityAction = { type: `ProfileController:checkUsernameAvailability`; handler: ProfileController['checkUsernameAvailability']; }; +export type ProfileControllerGetXAuthUrlAction = { + type: `ProfileController:getXAuthUrl`; + handler: ProfileController['getXAuthUrl']; +}; + +export type ProfileControllerConnectXAction = { + type: `ProfileController:connectX`; + handler: ProfileController['connectX']; +}; + +export type ProfileControllerGetXAccountAction = { + type: `ProfileController:getXAccount`; + handler: ProfileController['getXAccount']; +}; + /** * Union of all ProfileController action types. */ @@ -37,5 +62,10 @@ export type ProfileControllerMethodActions = | ProfileControllerGetMetaMaskProfileAction | ProfileControllerGetXprofileAction | ProfileControllerCreateProfileAction + | ProfileControllerReplaceProfileAction | ProfileControllerUpdateProfileAction - | ProfileControllerCheckUsernameAvailabilityAction; + | ProfileControllerDeleteProfileAction + | ProfileControllerCheckUsernameAvailabilityAction + | ProfileControllerGetXAuthUrlAction + | ProfileControllerConnectXAction + | ProfileControllerGetXAccountAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index e69de29bb2d..2b1f15e3b73 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -0,0 +1,503 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import type { ProfileControllerMessenger } from './ProfileController.js'; +import { + ProfileController, + getDefaultProfileControllerState, +} from './ProfileController.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 = { + 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:getXAuthUrl', + '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({ + metamaskProfile: { + 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: { + metamaskProfile: { + ...getDefaultProfileControllerState().metamaskProfile, + username: 'alice', + }, + }, + }); + + expect(controller.state.metamaskProfile.username).toBe('alice'); + }); + }); + + describe('getMetaMaskProfile', () => { + it('returns undefined when no profile has been set', () => { + const { controller } = createController(); + + expect(controller.getMetaMaskProfile()).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', + }); + + expect(controller.getMetaMaskProfile()).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 connectX', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + + const { controller } = createController({ rootMessenger }); + await controller.connectX('auth-code', 'state-xyz'); + + 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 = { + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + }; + const { controller } = createController({ rootMessenger }); + await controller.createProfile(input); + + expect(createProfileMock).toHaveBeenCalledWith(input); + expect(controller.state.metamaskProfile).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', + }); + + expect(controller.state.metamaskProfile.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', + }); + + expect(controller.state.metamaskProfile.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', + }); + + expect(controller.state.metamaskProfile.connectedToX).toBe(true); + }); + + 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', + }); + + expect(controller.state.metamaskProfile).toStrictEqual(mockMappedProfile); + }); + }); + + describe('replaceProfile', () => { + it('calls ProfileService:replaceProfile and updates state', async () => { + const rootMessenger = getRootMessenger(); + const replaceProfileMock = jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, username: 'alice2' }); + mockServiceAction( + rootMessenger, + 'ProfileService:replaceProfile', + replaceProfileMock, + ); + + const input = { + username: 'alice2', + display_name: 'Alice 2', + linked_addresses: ['eip155:1:0xabc'], + }; + const { controller } = createController({ rootMessenger }); + await controller.replaceProfile('profile-123', input); + + expect(replaceProfileMock).toHaveBeenCalledWith('profile-123', input); + expect(controller.state.metamaskProfile.username).toBe('alice2'); + }); + }); + + describe('updateProfile', () => { + it('calls ProfileService:updateProfile and updates state', async () => { + const rootMessenger = getRootMessenger(); + const updateProfileMock = jest + .fn() + .mockResolvedValue({ ...mockProfileResponse, username: 'alice2' }); + mockServiceAction( + rootMessenger, + 'ProfileService:updateProfile', + updateProfileMock, + ); + + const { controller } = createController({ rootMessenger }); + await controller.updateProfile('profile-123', { username: 'alice2' }); + + expect(updateProfileMock).toHaveBeenCalledWith('profile-123', { + username: 'alice2', + }); + expect(controller.state.metamaskProfile.username).toBe('alice2'); + }); + }); + + describe('deleteProfile', () => { + it('calls ProfileService:deleteProfile and resets state including xProfile', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:createProfile', + jest.fn().mockResolvedValue(mockProfileResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:connectX', + jest.fn().mockResolvedValue(mockXConnectResponse), + ); + mockServiceAction( + rootMessenger, + 'ProfileService:deleteProfile', + jest.fn().mockResolvedValue(undefined), + ); + + const { controller } = createController({ rootMessenger }); + await controller.createProfile({ + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + }); + await controller.connectX('auth-code', 'state-xyz'); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + + await controller.deleteProfile('profile-123'); + + expect(controller.state.metamaskProfile).toStrictEqual( + getDefaultProfileControllerState().metamaskProfile, + ); + expect(controller.state.xProfile).toBeUndefined(); + }); + }); + + 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('getXAuthUrl', () => { + it('delegates to ProfileService:getXAuthUrl and returns result', async () => { + const mockAuthUrl = { + url: 'https://twitter.com/i/oauth2/authorize?state=xyz', + state: 'xyz', + }; + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + jest.fn().mockResolvedValue(mockAuthUrl), + ); + + const { controller } = createController({ rootMessenger }); + const result = await controller.getXAuthUrl(); + + expect(result).toStrictEqual(mockAuthUrl); + }); + + it('does not update state', async () => { + const rootMessenger = getRootMessenger(); + mockServiceAction( + rootMessenger, + 'ProfileService:getXAuthUrl', + jest.fn().mockResolvedValue({ url: 'https://x.com', state: 'abc' }), + ); + + const { controller } = createController({ rootMessenger }); + const stateBefore = controller.state; + await controller.getXAuthUrl(); + + expect(controller.state).toStrictEqual(stateBefore); + }); + }); + + describe('connectX', () => { + it('calls ProfileService:connectX with code and state, updates xProfile', async () => { + const rootMessenger = getRootMessenger(); + const connectXMock = jest.fn().mockResolvedValue(mockXConnectResponse); + mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); + + const { controller } = createController({ rootMessenger }); + await controller.connectX('auth-code-123', 'state-xyz'); + + expect(connectXMock).toHaveBeenCalledWith('auth-code-123', 'state-xyz'); + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + }); + + describe('getXAccount', () => { + 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.getXAccount(); + + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + }); +}); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 57cba975d54..caf5102ef71 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -1,15 +1,42 @@ -import type { CaipAccountAddress } from '@metamask/utils'; +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, + ProfileServiceGetXAuthUrlAction, + ProfileServiceReplaceProfileAction, + ProfileServiceUpdateProfileAction, +} from './ProfileService-method-action-types.js'; +import type { + CreateProfileInput, + ProfileApiResponse, + ReplaceProfileInput, + UpdateProfileInput, + UsernameAvailabilityResponse, + XConnectResponse, +} from './ProfileService.js'; const controllerName = 'ProfileController'; -type NetworkName = 'ethereum' | 'stellar' | 'solana' | 'bitcoin' | 'tron'; +// === TYPES === export type MetaMaskProfile = { profileId: string; username: string; displayName: string; - bio?: string; - linkedAddresses: Record; + bio: string; + linkedAddresses: CaipAccountId[]; avatarUrl: string; tradingPrivacy: 'public' | 'private'; connectedToX: boolean; @@ -18,12 +45,11 @@ export type MetaMaskProfile = { }; export type XProfile = { - userId: string; - profileId: string; - handle: string; + xUserId: string; + xProfileUrl: string; + username: string; displayName: string; - profilePictureUrl: string; - verified: boolean; + avatarUrl: string; createdAt: string; updatedAt: string; }; @@ -33,4 +59,236 @@ export type ProfileControllerState = { xProfile?: XProfile; }; -export type ProfileControllerGetStateAction = ControllerGetState; +// === MESSENGER === + +export type ProfileControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ProfileControllerState +>; + +export type ProfileControllerActions = + | ProfileControllerGetStateAction + | ProfileControllerMethodActions; + +export type ProfileControllerChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ProfileControllerState +>; + +export type ProfileControllerEvents = ProfileControllerChangeEvent; + +type AllowedActions = + | ProfileServiceCreateProfileAction + | ProfileServiceReplaceProfileAction + | ProfileServiceUpdateProfileAction + | ProfileServiceDeleteProfileAction + | ProfileServiceCheckUsernameAvailabilityAction + | ProfileServiceGetXAuthUrlAction + | ProfileServiceConnectXAction + | ProfileServiceGetXAccountAction; + +export type AllowedEvents = never; + +export type ProfileControllerMessenger = Messenger< + typeof controllerName, + ProfileControllerActions | AllowedActions, + ProfileControllerEvents | AllowedEvents +>; + +// === STATE === + +const profileControllerMetadata = { + metamaskProfile: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, + xProfile: { + includeInStateLogs: true, + persist: true, + includeInDebugSnapshot: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +export function getDefaultProfileControllerState(): ProfileControllerState { + return { + metamaskProfile: { + profileId: '', + username: '', + displayName: '', + bio: '', + linkedAddresses: [], + avatarUrl: '', + tradingPrivacy: 'public', + connectedToX: false, + createdAt: '', + updatedAt: '', + }, + }; +} + +const MESSENGER_EXPOSED_METHODS = [ + 'getMetaMaskProfile', + 'getXprofile', + 'createProfile', + 'replaceProfile', + 'updateProfile', + 'deleteProfile', + 'checkUsernameAvailability', + 'getXAuthUrl', + 'connectX', + 'getXAccount', +] as const; + +// === CONTROLLER === + +export class ProfileController extends BaseController< + typeof controllerName, + ProfileControllerState, + ProfileControllerMessenger +> { + constructor({ + messenger, + state, + }: { + messenger: ProfileControllerMessenger; + state?: Partial; + }) { + super({ + messenger, + name: controllerName, + metadata: profileControllerMetadata, + state: { + ...getDefaultProfileControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + #hasProfile(): boolean { + return this.state.metamaskProfile.profileId !== ''; + } + + #mapApiResponseToProfile(response: ProfileApiResponse): MetaMaskProfile { + return { + profileId: response.profile_id, + username: response.username, + displayName: response.display_name, + bio: response.bio ?? '', + linkedAddresses: response.linked_addresses as CaipAccountId[], + avatarUrl: response.avatar_url ?? '', + tradingPrivacy: response.trading_privacy, + connectedToX: response.connected_to_x, + createdAt: response.created_at, + updatedAt: response.updated_at, + }; + } + + #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, + }; + } + + getMetaMaskProfile(): MetaMaskProfile | undefined { + if (!this.#hasProfile()) { + return undefined; + } + return this.state.metamaskProfile; + } + + getXprofile(): XProfile | undefined { + return this.state.xProfile; + } + + async createProfile(input: CreateProfileInput): Promise { + const response = await this.messenger.call( + 'ProfileService:createProfile', + input, + ); + this.update((state) => { + state.metamaskProfile = this.#mapApiResponseToProfile(response); + }); + } + + async replaceProfile( + identifier: string, + input: ReplaceProfileInput, + ): Promise { + const response = await this.messenger.call( + 'ProfileService:replaceProfile', + identifier, + input, + ); + this.update((state) => { + state.metamaskProfile = this.#mapApiResponseToProfile(response); + }); + } + + async updateProfile( + identifier: string, + input: UpdateProfileInput, + ): Promise { + const response = await this.messenger.call( + 'ProfileService:updateProfile', + identifier, + input, + ); + this.update((state) => { + state.metamaskProfile = this.#mapApiResponseToProfile(response); + }); + } + + async deleteProfile(identifier: string): Promise { + await this.messenger.call('ProfileService:deleteProfile', identifier); + this.update((state) => { + state.metamaskProfile = + getDefaultProfileControllerState().metamaskProfile; + state.xProfile = undefined; + }); + } + + async checkUsernameAvailability( + username: string, + ): Promise { + return this.messenger.call( + 'ProfileService:checkUsernameAvailability', + username, + ); + } + + async getXAuthUrl(): Promise<{ url: string; state: string }> { + return this.messenger.call('ProfileService:getXAuthUrl'); + } + + async connectX(code: string, xState: string): Promise { + const response = await this.messenger.call( + 'ProfileService:connectX', + code, + xState, + ); + this.update((state) => { + state.xProfile = this.#mapXResponseToXProfile(response); + }); + } + + async getXAccount(): Promise { + const response = await this.messenger.call('ProfileService:getXAccount'); + this.update((state) => { + state.xProfile = this.#mapXResponseToXProfile(response); + }); + } +} 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..bba3ad8aeb6 --- /dev/null +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -0,0 +1,65 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { ProfileService } from './ProfileService'; + +export type ProfileServiceGetProfileAction = { + type: `ProfileService:getProfile`; + handler: ProfileService['getProfile']; +}; + +export type ProfileServiceCreateProfileAction = { + type: `ProfileService:createProfile`; + handler: ProfileService['createProfile']; +}; + +export type ProfileServiceReplaceProfileAction = { + type: `ProfileService:replaceProfile`; + handler: ProfileService['replaceProfile']; +}; + +export type ProfileServiceUpdateProfileAction = { + type: `ProfileService:updateProfile`; + handler: ProfileService['updateProfile']; +}; + +export type ProfileServiceDeleteProfileAction = { + type: `ProfileService:deleteProfile`; + handler: ProfileService['deleteProfile']; +}; + +export type ProfileServiceCheckUsernameAvailabilityAction = { + type: `ProfileService:checkUsernameAvailability`; + handler: ProfileService['checkUsernameAvailability']; +}; + +export type ProfileServiceGetXAuthUrlAction = { + type: `ProfileService:getXAuthUrl`; + handler: ProfileService['getXAuthUrl']; +}; + +export type ProfileServiceConnectXAction = { + type: `ProfileService:connectX`; + handler: ProfileService['connectX']; +}; + +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..7b373fe6e23 --- /dev/null +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -0,0 +1,563 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; + +import { ProfileServiceErrorMessage, serviceName } from './ProfileService.js'; +import type { + CreateProfileInput, + ProfileServiceMessenger, + ReplaceProfileInput, + UpdateProfileInput, +} from './ProfileService.js'; +import { ProfileService } 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( + ProfileServiceErrorMessage.GET_PROFILE_INVALID_RESPONSE, + ); + }); + }); + + describe('createProfile', () => { + const input: CreateProfileInput = { + profile_id: 'canonical-123', + username: 'alice', + display_name: 'Alice Wonderland', + }; + + 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( + ProfileServiceErrorMessage.CREATE_PROFILE_INVALID_RESPONSE, + ); + }); + }); + + describe('replaceProfile', () => { + const input: ReplaceProfileInput = { + username: 'alice2', + display_name: 'Alice 2', + linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + }; + + 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( + ProfileServiceErrorMessage.REPLACE_PROFILE_INVALID_RESPONSE, + ); + }); + }); + + describe('updateProfile', () => { + const input: UpdateProfileInput = { + 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( + ProfileServiceErrorMessage.UPDATE_PROFILE_INVALID_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/alice/availability`, + { 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/alice%20smith/availability`, + 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( + ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_INVALID_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( + ProfileServiceErrorMessage.GET_X_AUTH_URL_INVALID_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 service = createService(); + const result = await service.connectX('auth-code-123', 'state-xyz'); + + 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({ code: 'auth-code-123', state: 'state-xyz' }), + }); + }); + + it('throws HttpError on non-ok response', async () => { + mockFetch.mockResolvedValue({ ok: false, status: 401 }); + + const service = createService(); + + await expect( + service.connectX('auth-code-123', '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('auth-code-123', 'state-xyz'), + ).rejects.toThrow(ProfileServiceErrorMessage.CONNECT_X_INVALID_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( + ProfileServiceErrorMessage.GET_X_ACCOUNT_INVALID_RESPONSE, + ); + }); + }); +}); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts new file mode 100644 index 00000000000..c1683643f85 --- /dev/null +++ b/packages/profile-controller/src/ProfileService.ts @@ -0,0 +1,471 @@ +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, + is, + nullable, + string, + type as structType, +} from '@metamask/superstruct'; + +import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; + +export const serviceName = 'ProfileService'; + +// --------------------------------------------------------------------------- +// Error messages +// --------------------------------------------------------------------------- + +export const ProfileServiceErrorMessage = { + GET_PROFILE_FAILED: 'ProfileService: failed to fetch profile', + GET_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for getProfile', + CREATE_PROFILE_FAILED: 'ProfileService: failed to create profile', + CREATE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for createProfile', + REPLACE_PROFILE_FAILED: 'ProfileService: failed to replace profile', + REPLACE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for replaceProfile', + UPDATE_PROFILE_FAILED: 'ProfileService: failed to update profile', + UPDATE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for updateProfile', + DELETE_PROFILE_FAILED: 'ProfileService: failed to delete profile', + CHECK_USERNAME_AVAILABILITY_FAILED: 'ProfileService: failed to check username availability', + CHECK_USERNAME_AVAILABILITY_INVALID_RESPONSE: 'ProfileService: invalid response for checkUsernameAvailability', + GET_X_AUTH_URL_FAILED: 'ProfileService: failed to get X authentication URL', + GET_X_AUTH_URL_INVALID_RESPONSE: 'ProfileService: invalid response for getXAuthUrl', + CONNECT_X_FAILED: 'ProfileService: failed to connect X account', + CONNECT_X_INVALID_RESPONSE: 'ProfileService: invalid response for connectX', + GET_X_ACCOUNT_FAILED: 'ProfileService: failed to get X account', + GET_X_ACCOUNT_INVALID_RESPONSE: 'ProfileService: invalid response for getXAccount', +} as const; + +// --------------------------------------------------------------------------- +// Superstruct schemas +// --------------------------------------------------------------------------- + +const ProfileApiResponseStruct = structType({ + profile_id: string(), + username: string(), + display_name: string(), + bio: nullable(string()), + linked_addresses: array(string()), + avatar_url: nullable(string()), + trading_privacy: enums(['public', 'private'] as const), + 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: string(), + state: 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(), +}); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +const MESSENGER_EXPOSED_METHODS = [ + 'getProfile', + 'createProfile', + 'replaceProfile', + 'updateProfile', + 'deleteProfile', + 'checkUsernameAvailability', + 'getXAuthUrl', + 'connectX', + 'getXAccount', +] as const; + +export type ProfileApiResponse = { + profile_id: string; + username: string; + display_name: string; + bio: string | null; + linked_addresses: string[]; + avatar_url: string | null; + trading_privacy: 'public' | 'private'; + connected_to_x: boolean; + created_at: string; + updated_at: string; +}; + +export type UsernameAvailabilityResponse = { + username: string; + available: boolean; + valid: boolean; + normalized: string; + errors: { code: string; message: string }[]; +}; + +export type CreateProfileInput = { + profile_id: string; + username: string; + display_name: string; + bio?: string | null; + linked_addresses?: string[]; + avatar_url?: string; +}; + +export type ReplaceProfileInput = { + username: string; + display_name: string; + linked_addresses: string[]; +}; + +export type UpdateProfileInput = { + username?: string; + display_name?: string; + bio?: string; + linked_addresses?: string[]; + avatar_url?: string; + trading_privacy?: 'public' | 'private'; +}; + +export type XConnectResponse = { + x_user_id: string; + x_profile_url: string; + username: string; + display_name: string; + avatar_url: string; + created_at: string; + updated_at: string; +}; + +export type XAccountResponse = XConnectResponse; + +// --------------------------------------------------------------------------- +// Messenger types +// --------------------------------------------------------------------------- + +export type ProfileServiceActions = + | ProfileServiceMethodActions + | DataServiceInvalidateQueriesAction; + +export type ProfileServiceCacheUpdatedEvent = DataServiceCacheUpdatedEvent< + typeof serviceName +>; + +export type ProfileServiceGranularCacheUpdatedEvent = + DataServiceGranularCacheUpdatedEvent; + +export type ProfileServiceEvents = + | ProfileServiceCacheUpdatedEvent + | ProfileServiceGranularCacheUpdatedEvent; + +type AllowedActions = + AuthenticationController.AuthenticationControllerGetBearerTokenAction; + +type AllowedEvents = never; + +export type ProfileServiceMessenger = Messenger< + typeof serviceName, + ProfileServiceActions | AllowedActions, + ProfileServiceEvents | AllowedEvents +>; + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class ProfileService extends BaseDataService< + typeof serviceName, + ProfileServiceMessenger +> { + readonly #baseUrl: string; + + get #v1Url(): string { + return `${this.#baseUrl}/v1`; + } + + 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, + ); + } + + async #getAuthHeaders(): Promise> { + const token = await this.messenger.call( + 'AuthenticationController:getBearerToken', + ); + return { Authorization: `Bearer ${token}` }; + } + + async getProfile(identifier: string): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.fetchQuery({ + queryKey: [`${this.name}:getProfile`, identifier], + queryFn: async () => { + const url = new URL( + `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + ); + const response = await fetch(url.toString(), { headers: authHeaders }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.GET_PROFILE_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, ProfileApiResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.GET_PROFILE_INVALID_RESPONSE); + } + return data as ProfileApiResponse; + }, + }); + } + + async createProfile(input: CreateProfileInput): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.executeMutation({ + mutationKey: [`${this.name}:createProfile`], + mutationFn: async () => { + const url = new URL(`${this.#v1Url}/profiles`); + const response = await fetch(url.toString(), { + method: 'POST', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.CREATE_PROFILE_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, ProfileApiResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.CREATE_PROFILE_INVALID_RESPONSE); + } + return data as ProfileApiResponse; + }, + }); + } + + async replaceProfile( + identifier: string, + input: ReplaceProfileInput, + ): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.executeMutation({ + mutationKey: [`${this.name}:replaceProfile`, identifier], + mutationFn: async () => { + const url = new URL( + `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + ); + const response = await fetch(url.toString(), { + method: 'PUT', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, ProfileApiResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.REPLACE_PROFILE_INVALID_RESPONSE); + } + return data as ProfileApiResponse; + }, + }); + } + + async updateProfile( + identifier: string, + input: UpdateProfileInput, + ): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.executeMutation({ + mutationKey: [`${this.name}:updateProfile`, identifier], + mutationFn: async () => { + const url = new URL( + `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + ); + const response = await fetch(url.toString(), { + method: 'PATCH', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify(input), + }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, ProfileApiResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.UPDATE_PROFILE_INVALID_RESPONSE); + } + return data as ProfileApiResponse; + }, + }); + } + + async deleteProfile(identifier: string): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.executeMutation({ + mutationKey: [`${this.name}:deleteProfile`, identifier], + mutationFn: async () => { + const url = new URL( + `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + ); + const response = await fetch(url.toString(), { + method: 'DELETE', + headers: authHeaders, + }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.DELETE_PROFILE_FAILED}: ${response.status}`, + ); + } + return null; + }, + }); + } + + async checkUsernameAvailability( + username: string, + ): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.fetchQuery({ + queryKey: [`${this.name}:checkUsernameAvailability`, username], + staleTime: 0, + queryFn: async () => { + const url = new URL( + `${this.#v1Url}/profiles/username/${encodeURIComponent(username)}/availability`, + ); + const response = await fetch(url.toString(), { headers: authHeaders }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, UsernameAvailabilityResponseStruct)) { + throw new Error( + ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_INVALID_RESPONSE, + ); + } + return data as UsernameAvailabilityResponse; + }, + }); + } + + async getXAuthUrl(): Promise<{ url: string; state: string }> { + const authHeaders = await this.#getAuthHeaders(); + return this.fetchQuery({ + queryKey: [`${this.name}:getXAuthUrl`], + staleTime: 0, + queryFn: async () => { + const url = new URL(`${this.#v1Url}/profiles/x/authentication-url`); + const response = await fetch(url.toString(), { headers: authHeaders }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, XAuthUrlResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.GET_X_AUTH_URL_INVALID_RESPONSE); + } + return data as { url: string; state: string }; + }, + }); + } + + async connectX(code: string, xState: string): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.executeMutation({ + mutationKey: [`${this.name}:connectX`], + mutationFn: async () => { + const url = new URL(`${this.#v1Url}/profiles/x/connect`); + const response = await fetch(url.toString(), { + method: 'POST', + headers: { ...authHeaders, 'Content-Type': 'application/json' }, + body: JSON.stringify({ code, state: xState }), + }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.CONNECT_X_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, XConnectResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.CONNECT_X_INVALID_RESPONSE); + } + return data as XConnectResponse; + }, + }); + } + + async getXAccount(): Promise { + const authHeaders = await this.#getAuthHeaders(); + return this.fetchQuery({ + queryKey: [`${this.name}:getXAccount`], + staleTime: 0, + queryFn: async () => { + const url = new URL(`${this.#v1Url}/profiles/x/account`); + const response = await fetch(url.toString(), { headers: authHeaders }); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED}: ${response.status}`, + ); + } + const data = await response.json(); + if (!is(data, XConnectResponseStruct)) { + throw new Error(ProfileServiceErrorMessage.GET_X_ACCOUNT_INVALID_RESPONSE); + } + return data as XAccountResponse; + }, + }); + } +} diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 7a732b00a2b..9fcf8eebd98 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -14,8 +14,37 @@ export { } from './ProfileController.js'; export type { ProfileControllerCheckUsernameAvailabilityAction, + ProfileControllerConnectXAction, ProfileControllerCreateProfileAction, + ProfileControllerDeleteProfileAction, ProfileControllerGetMetaMaskProfileAction, + ProfileControllerGetXAccountAction, + ProfileControllerGetXAuthUrlAction, ProfileControllerGetXprofileAction, + ProfileControllerReplaceProfileAction, ProfileControllerUpdateProfileAction, } from './ProfileController-method-action-types.js'; +export type { + ProfileApiResponse, + CreateProfileInput, + ReplaceProfileInput, + UpdateProfileInput, + UsernameAvailabilityResponse, + 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 index c145368e144..41005abda6a 100644 --- a/packages/profile-controller/tsconfig.build.json +++ b/packages/profile-controller/tsconfig.build.json @@ -13,6 +13,12 @@ }, { "path": "../utils/tsconfig.build.json" + }, + { + "path": "../base-data-service/tsconfig.build.json" + }, + { + "path": "../profile-sync-controller/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/profile-controller/tsconfig.json b/packages/profile-controller/tsconfig.json index 451a177a3d6..3ae8efea5d6 100644 --- a/packages/profile-controller/tsconfig.json +++ b/packages/profile-controller/tsconfig.json @@ -9,6 +9,12 @@ }, { "path": "../utils/tsconfig.json" + }, + { + "path": "../base-data-service/tsconfig.json" + }, + { + "path": "../profile-sync-controller/tsconfig.json" } ], "include": ["../../types", "./src"] diff --git a/packages/profile-controller/tsconfig.lint.json b/packages/profile-controller/tsconfig.lint.json index e5b50698930..78f9ef7b0ae 100644 --- a/packages/profile-controller/tsconfig.lint.json +++ b/packages/profile-controller/tsconfig.lint.json @@ -13,6 +13,12 @@ }, { "path": "../utils/tsconfig.lint.json" + }, + { + "path": "../base-data-service/tsconfig.lint.json" + }, + { + "path": "../profile-sync-controller/tsconfig.lint.json" } ] } diff --git a/yarn.lock b/yarn.lock index b044bd3cd3d..34897c3c51b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8542,8 +8542,11 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" + "@metamask/base-data-service": "npm:^2.0.0" "@metamask/controller-utils": "npm:^13.0.0" "@metamask/messenger": "npm:^3.0.0" + "@metamask/profile-sync-controller": "npm:^32.3.1" + "@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" From 58c1be7ebf690c41307abcd92bf9c2e0f36d99c6 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 18:23:54 -0400 Subject: [PATCH 06/73] feat(profile-controller): add input validation, fix types and tsconfig references --- packages/profile-controller/CHANGELOG.md | 5 +- packages/profile-controller/package.json | 1 + .../ProfileController-method-action-types.ts | 2 +- .../src/ProfileController.test.ts | 2 +- .../src/ProfileController.ts | 22 +- .../src/ProfileService-method-action-types.ts | 2 +- .../src/ProfileService.test.ts | 51 ++- .../profile-controller/src/ProfileService.ts | 323 ++++++++---------- packages/profile-controller/src/index.ts | 9 +- .../profile-controller/tsconfig.build.json | 3 + packages/profile-controller/tsconfig.json | 3 + .../profile-controller/tsconfig.lint.json | 3 + yarn.lock | 3 +- 13 files changed, 203 insertions(+), 226 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index a9d659c1683..c43014f191a 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `updateProfile`, `deleteProfile`, and `checkUsernameAvailability` via the messenger -- Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `updateProfile`, `deleteProfile`, and `checkUsernameAvailability` via the messenger +- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger +- 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 +- Add `useGetProfile` and `useCheckUsernameAvailability` React query hooks for reading profile data in UI components via `@metamask/react-data-query` [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 74e75be8d9c..010f647c994 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -55,6 +55,7 @@ "@metamask/controller-utils": "^13.0.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^32.3.1", + "@metamask/react-data-query": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0" }, diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 91893c6fb49..0024e122a17 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController'; +import type { ProfileController } from './ProfileController.js'; export type ProfileControllerGetMetaMaskProfileAction = { type: `ProfileController:getMetaMaskProfile`; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 2b1f15e3b73..ef4fb2df27e 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -480,7 +480,7 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger }); await controller.connectX('auth-code-123', 'state-xyz'); - expect(connectXMock).toHaveBeenCalledWith('auth-code-123', 'state-xyz'); + expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz' }); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); }); }); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index caf5102ef71..ca0815f613b 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -19,11 +19,12 @@ import type { ProfileServiceUpdateProfileAction, } from './ProfileService-method-action-types.js'; import type { - CreateProfileInput, + CreateProfileParams, ProfileApiResponse, - ReplaceProfileInput, - UpdateProfileInput, + ReplaceProfileParams, + UpdateProfileParams, UsernameAvailabilityResponse, + XAuthUrlResponse, XConnectResponse, } from './ProfileService.js'; @@ -214,7 +215,7 @@ export class ProfileController extends BaseController< return this.state.xProfile; } - async createProfile(input: CreateProfileInput): Promise { + async createProfile(input: CreateProfileParams): Promise { const response = await this.messenger.call( 'ProfileService:createProfile', input, @@ -226,7 +227,7 @@ export class ProfileController extends BaseController< async replaceProfile( identifier: string, - input: ReplaceProfileInput, + input: ReplaceProfileParams, ): Promise { const response = await this.messenger.call( 'ProfileService:replaceProfile', @@ -240,7 +241,7 @@ export class ProfileController extends BaseController< async updateProfile( identifier: string, - input: UpdateProfileInput, + input: UpdateProfileParams, ): Promise { const response = await this.messenger.call( 'ProfileService:updateProfile', @@ -270,16 +271,15 @@ export class ProfileController extends BaseController< ); } - async getXAuthUrl(): Promise<{ url: string; state: string }> { + async getXAuthUrl(): Promise { return this.messenger.call('ProfileService:getXAuthUrl'); } async connectX(code: string, xState: string): Promise { - const response = await this.messenger.call( - 'ProfileService:connectX', + const response = await this.messenger.call('ProfileService:connectX', { code, - xState, - ); + state: xState, + }); this.update((state) => { state.xProfile = this.#mapXResponseToXProfile(response); }); diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index bba3ad8aeb6..91b3baa8d24 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileService } from './ProfileService'; +import type { ProfileService } from './ProfileService.js'; export type ProfileServiceGetProfileAction = { type: `ProfileService:getProfile`; diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 7b373fe6e23..3992125bd37 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -5,14 +5,18 @@ import type { MessengerEvents, } from '@metamask/messenger'; -import { ProfileServiceErrorMessage, serviceName } from './ProfileService.js'; +import { + ProfileService, + ProfileServiceErrorMessage, + serviceName, +} from './ProfileService.js'; import type { - CreateProfileInput, + ConnectXParams, + CreateProfileParams, ProfileServiceMessenger, - ReplaceProfileInput, - UpdateProfileInput, + ReplaceProfileParams, + UpdateProfileParams, } from './ProfileService.js'; -import { ProfileService } from './ProfileService.js'; const BASE_URL = 'https://profile.api.cx.metamask.io'; const V1_URL = `${BASE_URL}/v1`; @@ -155,13 +159,13 @@ describe('ProfileService', () => { const service = createService(); await expect(service.getProfile('profile-123')).rejects.toThrow( - ProfileServiceErrorMessage.GET_PROFILE_INVALID_RESPONSE, + 'returned an unexpected response', ); }); }); describe('createProfile', () => { - const input: CreateProfileInput = { + const input: CreateProfileParams = { profile_id: 'canonical-123', username: 'alice', display_name: 'Alice Wonderland', @@ -208,13 +212,13 @@ describe('ProfileService', () => { const service = createService(); await expect(service.createProfile(input)).rejects.toThrow( - ProfileServiceErrorMessage.CREATE_PROFILE_INVALID_RESPONSE, + 'returned an unexpected response', ); }); }); describe('replaceProfile', () => { - const input: ReplaceProfileInput = { + const input: ReplaceProfileParams = { username: 'alice2', display_name: 'Alice 2', linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], @@ -267,14 +271,12 @@ describe('ProfileService', () => { await expect( service.replaceProfile('profile-123', input), - ).rejects.toThrow( - ProfileServiceErrorMessage.REPLACE_PROFILE_INVALID_RESPONSE, - ); + ).rejects.toThrow('returned an unexpected response'); }); }); describe('updateProfile', () => { - const input: UpdateProfileInput = { + const input: UpdateProfileParams = { username: 'alice2', display_name: 'Alice 2', }; @@ -326,9 +328,7 @@ describe('ProfileService', () => { await expect( service.updateProfile('profile-123', input), - ).rejects.toThrow( - ProfileServiceErrorMessage.UPDATE_PROFILE_INVALID_RESPONSE, - ); + ).rejects.toThrow('returned an unexpected response'); }); }); @@ -416,9 +416,7 @@ describe('ProfileService', () => { await expect( service.checkUsernameAvailability('alice'), - ).rejects.toThrow( - ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_INVALID_RESPONSE, - ); + ).rejects.toThrow('returned an unexpected response'); }); }); @@ -465,7 +463,7 @@ describe('ProfileService', () => { const service = createService(); await expect(service.getXAuthUrl()).rejects.toThrow( - ProfileServiceErrorMessage.GET_X_AUTH_URL_INVALID_RESPONSE, + 'returned an unexpected response', ); }); }); @@ -478,8 +476,9 @@ describe('ProfileService', () => { json: () => Promise.resolve(mockXConnectResponse), }); + const params: ConnectXParams = { code: 'auth-code-123', state: 'state-xyz' }; const service = createService(); - const result = await service.connectX('auth-code-123', 'state-xyz'); + const result = await service.connectX(params); expect(result).toStrictEqual(mockXConnectResponse); expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/x/connect`, { @@ -488,7 +487,7 @@ describe('ProfileService', () => { Authorization: `Bearer ${MOCK_TOKEN}`, 'Content-Type': 'application/json', }, - body: JSON.stringify({ code: 'auth-code-123', state: 'state-xyz' }), + body: JSON.stringify(params), }); }); @@ -498,7 +497,7 @@ describe('ProfileService', () => { const service = createService(); await expect( - service.connectX('auth-code-123', 'state-xyz'), + service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), ).rejects.toThrow( `${ProfileServiceErrorMessage.CONNECT_X_FAILED}: 401`, ); @@ -514,8 +513,8 @@ describe('ProfileService', () => { const service = createService(); await expect( - service.connectX('auth-code-123', 'state-xyz'), - ).rejects.toThrow(ProfileServiceErrorMessage.CONNECT_X_INVALID_RESPONSE); + service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), + ).rejects.toThrow('returned an unexpected response'); }); }); @@ -556,7 +555,7 @@ describe('ProfileService', () => { const service = createService(); await expect(service.getXAccount()).rejects.toThrow( - ProfileServiceErrorMessage.GET_X_ACCOUNT_INVALID_RESPONSE, + 'returned an unexpected response', ); }); }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index c1683643f85..f7532f1f363 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -10,13 +10,15 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { array, + assert, boolean, enums, - is, nullable, + optional, string, type as structType, } from '@metamask/superstruct'; +import type { Infer } from '@metamask/superstruct'; import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; @@ -28,22 +30,15 @@ export const serviceName = 'ProfileService'; export const ProfileServiceErrorMessage = { GET_PROFILE_FAILED: 'ProfileService: failed to fetch profile', - GET_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for getProfile', CREATE_PROFILE_FAILED: 'ProfileService: failed to create profile', - CREATE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for createProfile', REPLACE_PROFILE_FAILED: 'ProfileService: failed to replace profile', - REPLACE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for replaceProfile', UPDATE_PROFILE_FAILED: 'ProfileService: failed to update profile', - UPDATE_PROFILE_INVALID_RESPONSE: 'ProfileService: invalid response for updateProfile', DELETE_PROFILE_FAILED: 'ProfileService: failed to delete profile', - CHECK_USERNAME_AVAILABILITY_FAILED: 'ProfileService: failed to check username availability', - CHECK_USERNAME_AVAILABILITY_INVALID_RESPONSE: 'ProfileService: invalid response for checkUsernameAvailability', + CHECK_USERNAME_AVAILABILITY_FAILED: + 'ProfileService: failed to check username availability', GET_X_AUTH_URL_FAILED: 'ProfileService: failed to get X authentication URL', - GET_X_AUTH_URL_INVALID_RESPONSE: 'ProfileService: invalid response for getXAuthUrl', CONNECT_X_FAILED: 'ProfileService: failed to connect X account', - CONNECT_X_INVALID_RESPONSE: 'ProfileService: invalid response for connectX', GET_X_ACCOUNT_FAILED: 'ProfileService: failed to get X account', - GET_X_ACCOUNT_INVALID_RESPONSE: 'ProfileService: invalid response for getXAccount', } as const; // --------------------------------------------------------------------------- @@ -91,6 +86,38 @@ const XConnectResponseStruct = structType({ updated_at: string(), }); +const ConnectXParamsStruct = structType({ + code: string(), + state: string(), +}); + +const CreateProfileParamsStruct = structType({ + profile_id: string(), + username: string(), + display_name: string(), + bio: optional(nullable(string())), + linked_addresses: optional(array(string())), + avatar_url: optional(string()), +}); + +const ReplaceProfileParamsStruct = structType({ + username: string(), + display_name: string(), + bio: optional(nullable(string())), + linked_addresses: array(string()), + avatar_url: optional(string()), + trading_privacy: optional(enums(['public', 'private'] as const)), +}); + +const UpdateProfileParamsStruct = structType({ + username: optional(string()), + display_name: optional(string()), + bio: optional(string()), + linked_addresses: optional(array(string())), + avatar_url: optional(string()), + trading_privacy: optional(enums(['public', 'private'] as const)), +}); + // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- @@ -107,63 +134,26 @@ const MESSENGER_EXPOSED_METHODS = [ 'getXAccount', ] as const; -export type ProfileApiResponse = { - profile_id: string; - username: string; - display_name: string; - bio: string | null; - linked_addresses: string[]; - avatar_url: string | null; - trading_privacy: 'public' | 'private'; - connected_to_x: boolean; - created_at: string; - updated_at: string; -}; - -export type UsernameAvailabilityResponse = { - username: string; - available: boolean; - valid: boolean; - normalized: string; - errors: { code: string; message: string }[]; -}; - -export type CreateProfileInput = { - profile_id: string; - username: string; - display_name: string; - bio?: string | null; - linked_addresses?: string[]; - avatar_url?: string; -}; - -export type ReplaceProfileInput = { - username: string; - display_name: string; - linked_addresses: string[]; -}; - -export type UpdateProfileInput = { - username?: string; - display_name?: string; - bio?: string; - linked_addresses?: string[]; - avatar_url?: string; - trading_privacy?: 'public' | 'private'; -}; - -export type XConnectResponse = { - x_user_id: string; - x_profile_url: string; - username: string; - display_name: string; - avatar_url: string; - created_at: string; - updated_at: string; -}; +export type ProfileApiResponse = Infer; + +export type UsernameAvailabilityResponse = Infer< + typeof UsernameAvailabilityResponseStruct +>; + +export type XConnectResponse = Infer; + +export type XAuthUrlResponse = Infer; export type XAccountResponse = XConnectResponse; +export type CreateProfileParams = Infer; + +export type ReplaceProfileParams = Infer; + +export type UpdateProfileParams = Infer; + +export type ConnectXParams = Infer; + // --------------------------------------------------------------------------- // Messenger types // --------------------------------------------------------------------------- @@ -226,6 +216,12 @@ export class ProfileService extends BaseDataService< ); } + #throwIfNotOk(response: Response, message: string): void { + if (!response.ok) { + throw new HttpError(response.status, `${message}: ${response.status}`); + } + } + async #getAuthHeaders(): Promise> { const token = await this.messenger.call( 'AuthenticationController:getBearerToken', @@ -233,136 +229,123 @@ export class ProfileService extends BaseDataService< return { Authorization: `Bearer ${token}` }; } - async getProfile(identifier: string): Promise { + async getProfile(profileId: string): Promise { + assert(profileId, string()); const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ - queryKey: [`${this.name}:getProfile`, identifier], + queryKey: [`${this.name}:getProfile`, profileId], + responseStruct: ProfileApiResponseStruct, queryFn: async () => { const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); const response = await fetch(url.toString(), { headers: authHeaders }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.GET_PROFILE_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, ProfileApiResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.GET_PROFILE_INVALID_RESPONSE); - } - return data as ProfileApiResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.GET_PROFILE_FAILED, + ); + return response.json(); }, }); } - async createProfile(input: CreateProfileInput): Promise { + async createProfile( + params: CreateProfileParams, + ): Promise { + assert(params, CreateProfileParamsStruct); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:createProfile`], + responseStruct: ProfileApiResponseStruct, mutationFn: async () => { const url = new URL(`${this.#v1Url}/profiles`); const response = await fetch(url.toString(), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(input), + body: JSON.stringify(params), }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.CREATE_PROFILE_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, ProfileApiResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.CREATE_PROFILE_INVALID_RESPONSE); - } - return data as ProfileApiResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, + ); + return response.json(); }, }); } async replaceProfile( - identifier: string, - input: ReplaceProfileInput, + profileId: string, + params: ReplaceProfileParams, ): Promise { + assert(profileId, string()); + assert(params, ReplaceProfileParamsStruct); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ - mutationKey: [`${this.name}:replaceProfile`, identifier], + mutationKey: [`${this.name}:replaceProfile`, profileId], + responseStruct: ProfileApiResponseStruct, mutationFn: async () => { const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); const response = await fetch(url.toString(), { method: 'PUT', headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(input), + body: JSON.stringify(params), }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, ProfileApiResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.REPLACE_PROFILE_INVALID_RESPONSE); - } - return data as ProfileApiResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, + ); + return response.json(); }, }); } async updateProfile( - identifier: string, - input: UpdateProfileInput, + profileId: string, + params: UpdateProfileParams, ): Promise { + assert(profileId, string()); + assert(params, UpdateProfileParamsStruct); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ - mutationKey: [`${this.name}:updateProfile`, identifier], + mutationKey: [`${this.name}:updateProfile`, profileId], + responseStruct: ProfileApiResponseStruct, mutationFn: async () => { const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); const response = await fetch(url.toString(), { method: 'PATCH', headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(input), + body: JSON.stringify(params), }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, ProfileApiResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.UPDATE_PROFILE_INVALID_RESPONSE); - } - return data as ProfileApiResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, + ); + return response.json(); }, }); } - async deleteProfile(identifier: string): Promise { + async deleteProfile(profileId: string): Promise { + assert(profileId, string()); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ - mutationKey: [`${this.name}:deleteProfile`, identifier], + mutationKey: [`${this.name}:deleteProfile`, profileId], mutationFn: async () => { const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(identifier)}`, + `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); const response = await fetch(url.toString(), { method: 'DELETE', headers: authHeaders, }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.DELETE_PROFILE_FAILED}: ${response.status}`, - ); - } + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, + ); return null; }, }); @@ -371,28 +354,22 @@ export class ProfileService extends BaseDataService< async checkUsernameAvailability( username: string, ): Promise { + assert(username, string()); const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ queryKey: [`${this.name}:checkUsernameAvailability`, username], staleTime: 0, + responseStruct: UsernameAvailabilityResponseStruct, queryFn: async () => { const url = new URL( `${this.#v1Url}/profiles/username/${encodeURIComponent(username)}/availability`, ); const response = await fetch(url.toString(), { headers: authHeaders }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, UsernameAvailabilityResponseStruct)) { - throw new Error( - ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_INVALID_RESPONSE, - ); - } - return data as UsernameAvailabilityResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, + ); + return response.json(); }, }); } @@ -402,46 +379,37 @@ export class ProfileService extends BaseDataService< return this.fetchQuery({ queryKey: [`${this.name}:getXAuthUrl`], staleTime: 0, + responseStruct: XAuthUrlResponseStruct, queryFn: async () => { const url = new URL(`${this.#v1Url}/profiles/x/authentication-url`); const response = await fetch(url.toString(), { headers: authHeaders }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, XAuthUrlResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.GET_X_AUTH_URL_INVALID_RESPONSE); - } - return data as { url: string; state: string }; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, + ); + return response.json(); }, }); } - async connectX(code: string, xState: string): Promise { + async connectX(params: ConnectXParams): Promise { + assert(params, ConnectXParamsStruct); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:connectX`], + responseStruct: XConnectResponseStruct, mutationFn: async () => { const url = new URL(`${this.#v1Url}/profiles/x/connect`); const response = await fetch(url.toString(), { method: 'POST', headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify({ code, state: xState }), + body: JSON.stringify(params), }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.CONNECT_X_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, XConnectResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.CONNECT_X_INVALID_RESPONSE); - } - return data as XConnectResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.CONNECT_X_FAILED, + ); + return response.json(); }, }); } @@ -451,20 +419,15 @@ export class ProfileService extends BaseDataService< return this.fetchQuery({ queryKey: [`${this.name}:getXAccount`], staleTime: 0, + responseStruct: XConnectResponseStruct, queryFn: async () => { const url = new URL(`${this.#v1Url}/profiles/x/account`); const response = await fetch(url.toString(), { headers: authHeaders }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED}: ${response.status}`, - ); - } - const data = await response.json(); - if (!is(data, XConnectResponseStruct)) { - throw new Error(ProfileServiceErrorMessage.GET_X_ACCOUNT_INVALID_RESPONSE); - } - return data as XAccountResponse; + this.#throwIfNotOk( + response, + ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED, + ); + return response.json(); }, }); } diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 9fcf8eebd98..376cda1c346 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -26,10 +26,12 @@ export type { } from './ProfileController-method-action-types.js'; export type { ProfileApiResponse, - CreateProfileInput, - ReplaceProfileInput, - UpdateProfileInput, + CreateProfileParams, + ReplaceProfileParams, + UpdateProfileParams, + ConnectXParams, UsernameAvailabilityResponse, + XAuthUrlResponse, XConnectResponse, XAccountResponse, ProfileServiceActions, @@ -37,6 +39,7 @@ export type { ProfileServiceMessenger, } from './ProfileService.js'; export { ProfileService, ProfileServiceErrorMessage } from './ProfileService.js'; +export { useGetProfile, useCheckUsernameAvailability } from './ProfileServiceHooks.js'; export type { ProfileServiceCheckUsernameAvailabilityAction, ProfileServiceConnectXAction, diff --git a/packages/profile-controller/tsconfig.build.json b/packages/profile-controller/tsconfig.build.json index 41005abda6a..924e449bf4d 100644 --- a/packages/profile-controller/tsconfig.build.json +++ b/packages/profile-controller/tsconfig.build.json @@ -19,6 +19,9 @@ }, { "path": "../profile-sync-controller/tsconfig.build.json" + }, + { + "path": "../react-data-query/tsconfig.build.json" } ], "include": ["../../types", "./src"] diff --git a/packages/profile-controller/tsconfig.json b/packages/profile-controller/tsconfig.json index 3ae8efea5d6..55fe44dd312 100644 --- a/packages/profile-controller/tsconfig.json +++ b/packages/profile-controller/tsconfig.json @@ -15,6 +15,9 @@ }, { "path": "../profile-sync-controller/tsconfig.json" + }, + { + "path": "../react-data-query/tsconfig.json" } ], "include": ["../../types", "./src"] diff --git a/packages/profile-controller/tsconfig.lint.json b/packages/profile-controller/tsconfig.lint.json index 78f9ef7b0ae..ca5367ba2c7 100644 --- a/packages/profile-controller/tsconfig.lint.json +++ b/packages/profile-controller/tsconfig.lint.json @@ -19,6 +19,9 @@ }, { "path": "../profile-sync-controller/tsconfig.lint.json" + }, + { + "path": "../react-data-query/tsconfig.lint.json" } ] } diff --git a/yarn.lock b/yarn.lock index 34897c3c51b..2961e7f4201 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8546,6 +8546,7 @@ __metadata: "@metamask/controller-utils": "npm:^13.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^32.3.1" + "@metamask/react-data-query": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.0" "@types/jest": "npm:^30.0.0" @@ -8712,7 +8713,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/react-data-query@workspace:packages/react-data-query": +"@metamask/react-data-query@npm:^2.0.0, @metamask/react-data-query@workspace:packages/react-data-query": version: 0.0.0-use.local resolution: "@metamask/react-data-query@workspace:packages/react-data-query" dependencies: From da7da8719d69d7a67a5e5ffb73fa20d4dc6f0736 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 18:23:56 -0400 Subject: [PATCH 07/73] feat(profile-controller): add useGetProfile and useCheckUsernameAvailability hooks --- .../src/ProfileServiceHooks.test.ts | 31 +++++++++++++++++++ .../src/ProfileServiceHooks.ts | 20 ++++++++++++ 2 files changed, 51 insertions(+) create mode 100644 packages/profile-controller/src/ProfileServiceHooks.test.ts create mode 100644 packages/profile-controller/src/ProfileServiceHooks.ts diff --git a/packages/profile-controller/src/ProfileServiceHooks.test.ts b/packages/profile-controller/src/ProfileServiceHooks.test.ts new file mode 100644 index 00000000000..516dd7b53e2 --- /dev/null +++ b/packages/profile-controller/src/ProfileServiceHooks.test.ts @@ -0,0 +1,31 @@ +import { useQuery as useQueryFromReactDataQuery } from '@metamask/react-data-query'; + +import { serviceName } from './ProfileService.js'; +import { + useCheckUsernameAvailability, + useGetProfile, +} from './ProfileServiceHooks.js'; + +jest.mock('@metamask/react-data-query', () => ({ + useQuery: jest.fn(), +})); + +describe('useGetProfile', () => { + it('calls useQuery with the correct query key for the given identifier', () => { + useGetProfile('profile-123'); + + expect(useQueryFromReactDataQuery).toHaveBeenCalledWith({ + queryKey: [`${serviceName}:getProfile`, 'profile-123'], + }); + }); +}); + +describe('useCheckUsernameAvailability', () => { + it('calls useQuery with the correct query key for the given username', () => { + useCheckUsernameAvailability('alice'); + + expect(useQueryFromReactDataQuery).toHaveBeenCalledWith({ + queryKey: [`${serviceName}:checkUsernameAvailability`, 'alice'], + }); + }); +}); diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts new file mode 100644 index 00000000000..156ea07d710 --- /dev/null +++ b/packages/profile-controller/src/ProfileServiceHooks.ts @@ -0,0 +1,20 @@ +import { useQuery } from '@metamask/react-data-query'; + +import type { ProfileApiResponse, UsernameAvailabilityResponse } from './ProfileService.js'; +import { serviceName } from './ProfileService.js'; + +export function useGetProfile( + identifier: string, +): ReturnType> { + return useQuery({ + queryKey: [`${serviceName}:getProfile`, identifier], + }); +} + +export function useCheckUsernameAvailability( + username: string, +): ReturnType> { + return useQuery({ + queryKey: [`${serviceName}:checkUsernameAvailability`, username], + }); +} From 8af5baae66726376783808954f1e1c3d89197c1c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 18:40:05 -0400 Subject: [PATCH 08/73] docs(profile-controller): add JSDoc to public API --- .../src/ProfileController.ts | 107 +++++++++++++++++ .../profile-controller/src/ProfileService.ts | 110 +++++++++++++++++- .../src/ProfileServiceHooks.ts | 12 ++ 3 files changed, 228 insertions(+), 1 deletion(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index ca0815f613b..356f16073c1 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -32,29 +32,49 @@ const controllerName = 'ProfileController'; // === TYPES === +/** Representation of a MetaMask profile stored in controller state. */ export type MetaMaskProfile = { + /** 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 = { metamaskProfile: MetaMaskProfile; xProfile?: XProfile; @@ -62,20 +82,24 @@ export type ProfileControllerState = { // === 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 = @@ -90,6 +114,7 @@ type AllowedActions = 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, @@ -113,6 +138,11 @@ const profileControllerMetadata = { }, } 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 { metamaskProfile: { @@ -145,11 +175,19 @@ const MESSENGER_EXPOSED_METHODS = [ // === 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, @@ -173,10 +211,21 @@ export class ProfileController extends BaseController< ); } + /** + * Checks if a profile has been created. + * + * @returns True if a profile has been created, false otherwise. + */ #hasProfile(): boolean { return this.state.metamaskProfile.profileId !== ''; } + /** + * Maps an API response to a MetaMask profile. + * + * @param response - The API response to map. + * @returns The mapped MetaMask profile. + */ #mapApiResponseToProfile(response: ProfileApiResponse): MetaMaskProfile { return { profileId: response.profile_id, @@ -192,6 +241,12 @@ export class ProfileController extends BaseController< }; } + /** + * 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, @@ -204,6 +259,11 @@ export class ProfileController extends BaseController< }; } + /** + * Returns the current MetaMask profile from state, or undefined if none has been created. + * + * @returns The MetaMask profile, or undefined. + */ getMetaMaskProfile(): MetaMaskProfile | undefined { if (!this.#hasProfile()) { return undefined; @@ -211,10 +271,20 @@ export class ProfileController extends BaseController< return this.state.metamaskProfile; } + /** + * 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 and updates state. + * + * @param input - The profile creation parameters. + */ async createProfile(input: CreateProfileParams): Promise { const response = await this.messenger.call( 'ProfileService:createProfile', @@ -225,6 +295,12 @@ export class ProfileController extends BaseController< }); } + /** + * Fully replaces an existing profile and updates state. + * + * @param identifier - The profile identifier. + * @param input - The replacement profile data. + */ async replaceProfile( identifier: string, input: ReplaceProfileParams, @@ -239,6 +315,12 @@ export class ProfileController extends BaseController< }); } + /** + * Partially updates an existing profile and updates state. + * + * @param identifier - The profile identifier. + * @param input - The fields to update. + */ async updateProfile( identifier: string, input: UpdateProfileParams, @@ -253,6 +335,11 @@ export class ProfileController extends BaseController< }); } + /** + * Deletes a profile and resets state, including clearing any linked X profile. + * + * @param identifier - The profile identifier to delete. + */ async deleteProfile(identifier: string): Promise { await this.messenger.call('ProfileService:deleteProfile', identifier); this.update((state) => { @@ -262,6 +349,12 @@ export class ProfileController extends BaseController< }); } + /** + * 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 { @@ -271,10 +364,21 @@ export class ProfileController extends BaseController< ); } + /** + * Fetches the X OAuth authorization URL to begin the PKCE flow. + * + * @returns An object containing the authorization URL and its associated state token. + */ async getXAuthUrl(): Promise { return this.messenger.call('ProfileService:getXAuthUrl'); } + /** + * Completes the X OAuth PKCE flow and updates the X profile in state. + * + * @param code - The OAuth authorization code from the X redirect. + * @param xState - The state parameter returned by the X redirect. + */ async connectX(code: string, xState: string): Promise { const response = await this.messenger.call('ProfileService:connectX', { code, @@ -285,6 +389,9 @@ export class ProfileController extends BaseController< }); } + /** + * Fetches the X account linked to the current profile and updates state. + */ async getXAccount(): Promise { const response = await this.messenger.call('ProfileService:getXAccount'); this.update((state) => { diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index f7532f1f363..fd39a410c8f 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -28,6 +28,7 @@ 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', @@ -134,41 +135,54 @@ const MESSENGER_EXPOSED_METHODS = [ 'getXAccount', ] as const; +/** The shape of a profile returned by the MetaMask Profile API. */ export type ProfileApiResponse = 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; @@ -178,6 +192,7 @@ type AllowedActions = type AllowedEvents = never; +/** Messenger type for ProfileService, scoped to its actions and events. */ export type ProfileServiceMessenger = Messenger< typeof serviceName, ProfileServiceActions | AllowedActions, @@ -188,6 +203,7 @@ export type ProfileServiceMessenger = Messenger< // Service // --------------------------------------------------------------------------- +/** Communicates with the MetaMask Profile API and exposes all operations via the messenger. */ export class ProfileService extends BaseDataService< typeof serviceName, ProfileServiceMessenger @@ -198,6 +214,14 @@ export class ProfileService extends BaseDataService< 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, @@ -216,12 +240,24 @@ export class ProfileService extends BaseDataService< ); } + /** + * Throws an HttpError if the response is not a 2xx status code. + * + * @param response - The response to check. + * @param message - The message to include in the error. + * @throws {HttpError} If the response is not a 2xx status code. + */ #throwIfNotOk(response: Response, message: string): void { if (!response.ok) { throw new HttpError(response.status, `${message}: ${response.status}`); } } + /** + * Gets the authentication headers for the request. + * + * @returns The authentication headers. + */ async #getAuthHeaders(): Promise> { const token = await this.messenger.call( 'AuthenticationController:getBearerToken', @@ -229,6 +265,14 @@ export class ProfileService extends BaseDataService< return { Authorization: `Bearer ${token}` }; } + /** + * 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 { assert(profileId, string()); const authHeaders = await this.#getAuthHeaders(); @@ -249,6 +293,14 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 params or the response do not match the expected shape. + */ async createProfile( params: CreateProfileParams, ): Promise { @@ -273,6 +325,15 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 params or the response do not match the expected shape. + */ async replaceProfile( profileId: string, params: ReplaceProfileParams, @@ -301,6 +362,15 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 params or the response do not match the expected shape. + */ async updateProfile( profileId: string, params: UpdateProfileParams, @@ -329,6 +399,14 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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. + * @throws {StructError} If the profileId is not a string. + */ async deleteProfile(profileId: string): Promise { assert(profileId, string()); const authHeaders = await this.#getAuthHeaders(); @@ -351,6 +429,14 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 username or response do not match the expected shape. + */ async checkUsernameAvailability( username: string, ): Promise { @@ -374,7 +460,14 @@ export class ProfileService extends BaseDataService< }); } - async getXAuthUrl(): Promise<{ url: string; state: string }> { + /** + * 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 { const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ queryKey: [`${this.name}:getXAuthUrl`], @@ -392,6 +485,14 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 params or the response do not match the expected shape. + */ async connectX(params: ConnectXParams): Promise { assert(params, ConnectXParamsStruct); const authHeaders = await this.#getAuthHeaders(); @@ -414,6 +515,13 @@ export class ProfileService extends BaseDataService< }); } + /** + * 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 { const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts index 156ea07d710..e239ff33d06 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.ts @@ -3,6 +3,12 @@ import { useQuery } from '@metamask/react-data-query'; import type { ProfileApiResponse, UsernameAvailabilityResponse } from './ProfileService.js'; import { serviceName } from './ProfileService.js'; +/** + * React hook that reads a profile by identifier from the ProfileService query cache. + * + * @param identifier - The profile identifier to fetch. + * @returns A TanStack Query result containing the profile data. + */ export function useGetProfile( identifier: string, ): ReturnType> { @@ -11,6 +17,12 @@ export function useGetProfile( }); } +/** + * React hook that reads username availability from the ProfileService query cache. + * + * @param username - The username to check availability for. + * @returns A TanStack Query result containing the availability details. + */ export function useCheckUsernameAvailability( username: string, ): ReturnType> { From a83725577adff81bbcce0171b7c5664a52be2185 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 19:37:10 -0400 Subject: [PATCH 09/73] refactor(profile-controller): rename vars and update logic --- .../src/ProfileController.ts | 57 +++++++++++-------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 356f16073c1..1a7730bf55d 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -283,31 +283,34 @@ export class ProfileController extends BaseController< /** * Creates a new MetaMask profile and updates state. * - * @param input - The profile creation parameters. + * @param params - The profile creation parameters. + * @returns The created MetaMask profile. */ - async createProfile(input: CreateProfileParams): Promise { + async createProfile(params: CreateProfileParams): Promise { const response = await this.messenger.call( 'ProfileService:createProfile', - input, + params, ); + const mappedProfile = this.#mapApiResponseToProfile(response); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.metamaskProfile = mappedProfile; }); + return mappedProfile; } /** * Fully replaces an existing profile and updates state. * - * @param identifier - The profile identifier. + * @param profileId - The profile identifier (the canonical profile ID). * @param input - The replacement profile data. */ async replaceProfile( - identifier: string, + profileId: string, input: ReplaceProfileParams, ): Promise { const response = await this.messenger.call( 'ProfileService:replaceProfile', - identifier, + profileId, input, ); this.update((state) => { @@ -318,16 +321,16 @@ export class ProfileController extends BaseController< /** * Partially updates an existing profile and updates state. * - * @param identifier - The profile identifier. + * @param profileId - The profile identifier (the canonical profile ID). * @param input - The fields to update. */ async updateProfile( - identifier: string, + profileId: string, input: UpdateProfileParams, ): Promise { const response = await this.messenger.call( 'ProfileService:updateProfile', - identifier, + profileId, input, ); this.update((state) => { @@ -338,10 +341,10 @@ export class ProfileController extends BaseController< /** * Deletes a profile and resets state, including clearing any linked X profile. * - * @param identifier - The profile identifier to delete. + * @param profileId - The profile identifier (the canonical profile ID) to delete. */ - async deleteProfile(identifier: string): Promise { - await this.messenger.call('ProfileService:deleteProfile', identifier); + async deleteProfile(profileId: string): Promise { + await this.messenger.call('ProfileService:deleteProfile', profileId); this.update((state) => { state.metamaskProfile = getDefaultProfileControllerState().metamaskProfile; @@ -376,26 +379,30 @@ export class ProfileController extends BaseController< /** * Completes the X OAuth PKCE flow and updates the X profile in state. * - * @param code - The OAuth authorization code from the X redirect. - * @param xState - The state parameter returned by the X redirect. + * @param params - The parameters for the X OA uth 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 X profile. */ - async connectX(code: string, xState: string): Promise { - const response = await this.messenger.call('ProfileService:connectX', { - code, - state: xState, - }); - this.update((state) => { - state.xProfile = this.#mapXResponseToXProfile(response); - }); + 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 and updates state. + * + * @returns The X profile. */ - async getXAccount(): Promise { + async fetchAndUpdateXAccount(): Promise { const response = await this.messenger.call('ProfileService:getXAccount'); + const mappedXProfile = this.#mapXResponseToXProfile(response); this.update((state) => { - state.xProfile = this.#mapXResponseToXProfile(response); + state.xProfile = mappedXProfile; }); + return mappedXProfile; } } From ce8fe23f910b0cad0a71b65257cd89776eaf3268 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 19:45:10 -0400 Subject: [PATCH 10/73] chore(profile-controller): update action types --- .../ProfileController-method-action-types.ts | 52 ++++++++++++++ .../src/ProfileService-method-action-types.ts | 72 +++++++++++++++++++ 2 files changed, 124 insertions(+) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 0024e122a17..c2e7f325a12 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -5,46 +5,98 @@ 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 ProfileControllerGetMetaMaskProfileAction = { type: `ProfileController:getMetaMaskProfile`; handler: ProfileController['getMetaMaskProfile']; }; +/** + * 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 and updates state. + * + * @param params - The profile creation parameters. + * @returns The created MetaMask profile. + */ export type ProfileControllerCreateProfileAction = { type: `ProfileController:createProfile`; handler: ProfileController['createProfile']; }; +/** + * Fully replaces an existing profile and updates state. + * + * @param profileId - The profile identifier (the canonical profile ID). + * @param input - The replacement profile data. + */ export type ProfileControllerReplaceProfileAction = { type: `ProfileController:replaceProfile`; handler: ProfileController['replaceProfile']; }; +/** + * Partially updates an existing profile and updates state. + * + * @param profileId - The profile identifier (the canonical profile ID). + * @param input - The fields to update. + */ export type ProfileControllerUpdateProfileAction = { type: `ProfileController:updateProfile`; handler: ProfileController['updateProfile']; }; +/** + * Deletes a profile and resets state, including clearing any linked X profile. + * + * @param profileId - The profile identifier (the canonical profile ID) to delete. + */ 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']; }; +/** + * Fetches the X OAuth authorization URL to begin the PKCE flow. + * + * @returns An object containing the authorization URL and its associated state token. + */ export type ProfileControllerGetXAuthUrlAction = { type: `ProfileController:getXAuthUrl`; handler: ProfileController['getXAuthUrl']; }; +/** + * Completes the X OAuth PKCE flow and updates the X profile in state. + * + * @param params - The parameters for the X OA uth 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 X profile. + */ export type ProfileControllerConnectXAction = { type: `ProfileController:connectX`; handler: ProfileController['connectX']; diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 91b3baa8d24..bdd5ca13d28 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -5,46 +5,118 @@ 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 params or 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 params or 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 params or 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. + * @throws {StructError} If the profileId is not a string. + */ 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 username or response do 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 params or the response do 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']; From 34bb9970bc51d4e1dde866e45a630b2d73b98654 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 19:50:46 -0400 Subject: [PATCH 11/73] feat(profile-controller): remove profileId params from controller mutations and rename getXAccount --- .../ProfileController-method-action-types.ts | 9 +- .../src/ProfileController.test.ts | 81 +++++++++++++----- .../src/ProfileController.ts | 83 +++++++++---------- packages/profile-controller/src/index.ts | 2 +- 4 files changed, 107 insertions(+), 68 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index c2e7f325a12..9e14281984e 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -102,9 +102,10 @@ export type ProfileControllerConnectXAction = { handler: ProfileController['connectX']; }; -export type ProfileControllerGetXAccountAction = { - type: `ProfileController:getXAccount`; - handler: ProfileController['getXAccount']; +/** Fetches the X account linked to the current profile and updates state. */ +export type ProfileControllerFetchAndUpdateXAccountAction = { + type: `ProfileController:fetchAndUpdateXAccount`; + handler: ProfileController['fetchAndUpdateXAccount']; }; /** @@ -120,4 +121,4 @@ export type ProfileControllerMethodActions = | ProfileControllerCheckUsernameAvailabilityAction | ProfileControllerGetXAuthUrlAction | ProfileControllerConnectXAction - | ProfileControllerGetXAccountAction; + | ProfileControllerFetchAndUpdateXAccountAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index ef4fb2df27e..d3cd8e98211 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -320,7 +320,7 @@ describe('ProfileController', () => { }); describe('replaceProfile', () => { - it('calls ProfileService:replaceProfile and updates state', async () => { + it('calls ProfileService:replaceProfile with profileId from state and updates state', async () => { const rootMessenger = getRootMessenger(); const replaceProfileMock = jest .fn() @@ -336,16 +336,36 @@ describe('ProfileController', () => { display_name: 'Alice 2', linked_addresses: ['eip155:1:0xabc'], }; - const { controller } = createController({ rootMessenger }); - await controller.replaceProfile('profile-123', input); + const { controller } = createController({ + rootMessenger, + state: { + metamaskProfile: { + ...getDefaultProfileControllerState().metamaskProfile, + profileId: 'profile-123', + }, + }, + }); + await controller.replaceProfile(input); expect(replaceProfileMock).toHaveBeenCalledWith('profile-123', input); expect(controller.state.metamaskProfile.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: [], + }), + ).rejects.toThrow('ProfileController: no profile found in state'); + }); }); describe('updateProfile', () => { - it('calls ProfileService:updateProfile and updates state', async () => { + it('calls ProfileService:updateProfile with profileId from state and updates state', async () => { const rootMessenger = getRootMessenger(); const updateProfileMock = jest .fn() @@ -356,24 +376,36 @@ describe('ProfileController', () => { updateProfileMock, ); - const { controller } = createController({ rootMessenger }); - await controller.updateProfile('profile-123', { username: 'alice2' }); + const { controller } = createController({ + rootMessenger, + state: { + metamaskProfile: { + ...getDefaultProfileControllerState().metamaskProfile, + profileId: 'profile-123', + }, + }, + }); + await controller.updateProfile({ username: 'alice2' }); expect(updateProfileMock).toHaveBeenCalledWith('profile-123', { username: 'alice2', }); expect(controller.state.metamaskProfile.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 and resets state including xProfile', async () => { + it('calls ProfileService:deleteProfile using profileId from state and resets state including xProfile', async () => { const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:createProfile', - jest.fn().mockResolvedValue(mockProfileResponse), - ); + const deleteProfileMock = jest.fn().mockResolvedValue(undefined); mockServiceAction( rootMessenger, 'ProfileService:connectX', @@ -382,25 +414,32 @@ describe('ProfileController', () => { mockServiceAction( rootMessenger, 'ProfileService:deleteProfile', - jest.fn().mockResolvedValue(undefined), + deleteProfileMock, ); - const { controller } = createController({ rootMessenger }); - await controller.createProfile({ - profile_id: 'canonical-123', - username: 'alice', - display_name: 'Alice Wonderland', + const { controller } = createController({ + rootMessenger, + state: { metamaskProfile: mockMappedProfile }, }); await controller.connectX('auth-code', 'state-xyz'); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); - await controller.deleteProfile('profile-123'); + await controller.deleteProfile(); + expect(deleteProfileMock).toHaveBeenCalledWith('profile-123'); expect(controller.state.metamaskProfile).toStrictEqual( getDefaultProfileControllerState().metamaskProfile, ); 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', () => { @@ -485,7 +524,7 @@ describe('ProfileController', () => { }); }); - describe('getXAccount', () => { + describe('fetchAndUpdateXAccount', () => { it('calls ProfileService:getXAccount and updates xProfile in state', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( @@ -495,7 +534,7 @@ describe('ProfileController', () => { ); const { controller } = createController({ rootMessenger }); - await controller.getXAccount(); + 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 index 1a7730bf55d..6d16b19ae84 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -170,7 +170,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkUsernameAvailability', 'getXAuthUrl', 'connectX', - 'getXAccount', + 'fetchAndUpdateXAccount', ] as const; // === CONTROLLER === @@ -211,13 +211,22 @@ export class ProfileController extends BaseController< ); } + #hasProfile(): boolean { + return this.state.metamaskProfile.profileId !== ''; + } + /** - * Checks if a profile has been created. + * Returns the profile ID from state, throwing if no profile exists yet. * - * @returns True if a profile has been created, false otherwise. + * @returns The current profile ID. + * @throws If no profile has been created. */ - #hasProfile(): boolean { - return this.state.metamaskProfile.profileId !== ''; + #getProfileIdOrThrow(): string { + const { profileId } = this.state.metamaskProfile; + if (!profileId) { + throw new Error('ProfileController: no profile found in state'); + } + return profileId; } /** @@ -284,30 +293,25 @@ export class ProfileController extends BaseController< * Creates a new MetaMask profile and updates state. * * @param params - The profile creation parameters. - * @returns The created MetaMask profile. */ - async createProfile(params: CreateProfileParams): Promise { + async createProfile(params: CreateProfileParams): Promise { const response = await this.messenger.call( 'ProfileService:createProfile', params, ); - const mappedProfile = this.#mapApiResponseToProfile(response); this.update((state) => { - state.metamaskProfile = mappedProfile; + state.metamaskProfile = this.#mapApiResponseToProfile(response); }); - return mappedProfile; } /** - * Fully replaces an existing profile and updates state. + * Fully replaces the current profile and updates state. * - * @param profileId - The profile identifier (the canonical profile ID). * @param input - The replacement profile data. + * @throws If no profile has been created yet. */ - async replaceProfile( - profileId: string, - input: ReplaceProfileParams, - ): Promise { + async replaceProfile(input: ReplaceProfileParams): Promise { + const profileId = this.#getProfileIdOrThrow(); const response = await this.messenger.call( 'ProfileService:replaceProfile', profileId, @@ -319,15 +323,13 @@ export class ProfileController extends BaseController< } /** - * Partially updates an existing profile and updates state. + * Partially updates the current profile and updates state. * - * @param profileId - The profile identifier (the canonical profile ID). * @param input - The fields to update. + * @throws If no profile has been created yet. */ - async updateProfile( - profileId: string, - input: UpdateProfileParams, - ): Promise { + async updateProfile(input: UpdateProfileParams): Promise { + const profileId = this.#getProfileIdOrThrow(); const response = await this.messenger.call( 'ProfileService:updateProfile', profileId, @@ -339,11 +341,12 @@ export class ProfileController extends BaseController< } /** - * Deletes a profile and resets state, including clearing any linked X profile. + * Deletes the current profile and resets state, including clearing any linked X profile. * - * @param profileId - The profile identifier (the canonical profile ID) to delete. + * @throws If no profile has been created yet. */ - async deleteProfile(profileId: string): Promise { + async deleteProfile(): Promise { + const profileId = this.#getProfileIdOrThrow(); await this.messenger.call('ProfileService:deleteProfile', profileId); this.update((state) => { state.metamaskProfile = @@ -377,32 +380,28 @@ export class ProfileController extends BaseController< } /** - * Completes the X OAuth PKCE flow and updates the X profile in state. + * Completes the X OAuth PKCE flow and updates xProfile in state. * - * @param params - The parameters for the X OA uth 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 X profile. + * @param code - The OAuth authorization code from the X redirect. + * @param xState - The state parameter returned by the X redirect. */ - async connectX(params: { code: string; state: string }): Promise { - const response = await this.messenger.call( - 'ProfileService:connectX', - params, - ); - return this.#mapXResponseToXProfile(response); + async connectX(code: string, xState: string): Promise { + const response = await this.messenger.call('ProfileService:connectX', { + code, + state: xState, + }); + this.update((state) => { + state.xProfile = this.#mapXResponseToXProfile(response); + }); } /** * Fetches the X account linked to the current profile and updates state. - * - * @returns The X profile. */ - async fetchAndUpdateXAccount(): Promise { + async fetchAndUpdateXAccount(): Promise { const response = await this.messenger.call('ProfileService:getXAccount'); - const mappedXProfile = this.#mapXResponseToXProfile(response); this.update((state) => { - state.xProfile = mappedXProfile; + state.xProfile = this.#mapXResponseToXProfile(response); }); - return mappedXProfile; } } diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 376cda1c346..050a10adec7 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -18,7 +18,7 @@ export type { ProfileControllerCreateProfileAction, ProfileControllerDeleteProfileAction, ProfileControllerGetMetaMaskProfileAction, - ProfileControllerGetXAccountAction, + ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXAuthUrlAction, ProfileControllerGetXprofileAction, ProfileControllerReplaceProfileAction, From 439ac53d2f5f7bada22dcbe55e08944bab342ba8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 20:00:29 -0400 Subject: [PATCH 12/73] fix(profile-controller): restore JSDoc on #hasProfile, correct return types and connectX signature --- .../src/ProfileController.test.ts | 6 +-- .../src/ProfileController.ts | 46 +++++++++++++------ 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index d3cd8e98211..072c01200b3 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -211,7 +211,7 @@ describe('ProfileController', () => { ); const { controller } = createController({ rootMessenger }); - await controller.connectX('auth-code', 'state-xyz'); + await controller.connectX({ code: 'auth-code', state: 'state-xyz' }); expect(controller.getXprofile()).toStrictEqual(mockMappedXProfile); }); @@ -421,7 +421,7 @@ describe('ProfileController', () => { rootMessenger, state: { metamaskProfile: mockMappedProfile }, }); - await controller.connectX('auth-code', 'state-xyz'); + await controller.connectX({ code: 'auth-code', state: 'state-xyz' }); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); await controller.deleteProfile(); @@ -517,7 +517,7 @@ describe('ProfileController', () => { mockServiceAction(rootMessenger, 'ProfileService:connectX', connectXMock); const { controller } = createController({ rootMessenger }); - await controller.connectX('auth-code-123', 'state-xyz'); + await controller.connectX({ code: 'auth-code-123', state: 'state-xyz' }); expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz' }); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 6d16b19ae84..5343618f90a 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -211,6 +211,11 @@ export class ProfileController extends BaseController< ); } + /** + * 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.metamaskProfile.profileId !== ''; } @@ -290,18 +295,21 @@ export class ProfileController extends BaseController< } /** - * Creates a new MetaMask profile and updates state. + * Creates a new MetaMask profile, updates state, and returns the created profile. * * @param params - The profile creation parameters. + * @returns The created MetaMask profile. */ - async createProfile(params: CreateProfileParams): Promise { + async createProfile(params: CreateProfileParams): Promise { const response = await this.messenger.call( 'ProfileService:createProfile', params, ); + const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.metamaskProfile = mapped; }); + return mapped; } /** @@ -380,28 +388,36 @@ export class ProfileController extends BaseController< } /** - * Completes the X OAuth PKCE flow and updates xProfile in state. + * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. * - * @param code - The OAuth authorization code from the X redirect. - * @param xState - The state parameter returned by the X redirect. + * @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(code: string, xState: string): Promise { - const response = await this.messenger.call('ProfileService:connectX', { - code, - state: xState, - }); + async connectX(params: { code: string; state: string }): Promise { + const response = await this.messenger.call( + 'ProfileService:connectX', + params, + ); + const mapped = this.#mapXResponseToXProfile(response); this.update((state) => { - state.xProfile = this.#mapXResponseToXProfile(response); + state.xProfile = mapped; }); + return mapped; } /** - * Fetches the X account linked to the current profile and updates state. + * Fetches the X account linked to the current profile, updates state, and returns the X profile. + * + * @returns The linked X profile. */ - async fetchAndUpdateXAccount(): Promise { + async fetchAndUpdateXAccount(): Promise { const response = await this.messenger.call('ProfileService:getXAccount'); + const mapped = this.#mapXResponseToXProfile(response); this.update((state) => { - state.xProfile = this.#mapXResponseToXProfile(response); + state.xProfile = mapped; }); + return mapped; } } From fa1d4b534b7e326dc3ead67629998c7ced3439de Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 20:27:05 -0400 Subject: [PATCH 13/73] feat(profile-controller): include x_profile in createProfile response and update state --- .../src/ProfileController.test.ts | 21 +++++++++++++++++++ .../src/ProfileController.ts | 7 +++++++ .../profile-controller/src/ProfileService.ts | 17 +++++++++++++-- packages/profile-controller/src/index.ts | 1 + 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 072c01200b3..b1aa3b8da6b 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -299,6 +299,27 @@ describe('ProfileController', () => { expect(controller.state.metamaskProfile.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', + }); + + expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + }); + it('is callable via messenger action', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 5343618f90a..c0040b8ac45 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -6,6 +6,7 @@ import type { } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { CaipAccountId } from '@metamask/utils'; +import { hasProperty } from '@metamask/utils'; import type { ProfileControllerMethodActions } from './ProfileController-method-action-types.js'; import type { @@ -296,6 +297,7 @@ export class ProfileController extends BaseController< /** * Creates a new MetaMask profile, updates state, and returns the created profile. + * If the user had previously connected X, also updates xProfile in state. * * @param params - The profile creation parameters. * @returns The created MetaMask profile. @@ -308,6 +310,11 @@ export class ProfileController extends BaseController< const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { state.metamaskProfile = mapped; + if (hasProperty(response, 'x_profile')) { + state.xProfile = this.#mapXResponseToXProfile( + response.x_profile as XConnectResponse, + ); + } }); return mapped; } diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index fd39a410c8f..f52b9c825d8 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -13,10 +13,12 @@ import { assert, boolean, enums, + intersection, nullable, optional, string, type as structType, + union, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; @@ -87,6 +89,14 @@ const XConnectResponseStruct = structType({ updated_at: string(), }); +const CreateProfileResponseStruct = union([ + ProfileApiResponseStruct, + intersection([ + ProfileApiResponseStruct, + structType({ x_profile: XConnectResponseStruct }), + ]), +]); + const ConnectXParamsStruct = structType({ code: string(), state: string(), @@ -138,6 +148,9 @@ const MESSENGER_EXPOSED_METHODS = [ /** 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 @@ -303,12 +316,12 @@ export class ProfileService extends BaseDataService< */ async createProfile( params: CreateProfileParams, - ): Promise { + ): Promise { assert(params, CreateProfileParamsStruct); const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:createProfile`], - responseStruct: ProfileApiResponseStruct, + responseStruct: CreateProfileResponseStruct, mutationFn: async () => { const url = new URL(`${this.#v1Url}/profiles`); const response = await fetch(url.toString(), { diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 050a10adec7..f7116ff1d2c 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -26,6 +26,7 @@ export type { } from './ProfileController-method-action-types.js'; export type { ProfileApiResponse, + CreateProfileResponse, CreateProfileParams, ReplaceProfileParams, UpdateProfileParams, From 1704a05a1437d94effb0ac188a129d3f3c2d197e Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 20:53:31 -0400 Subject: [PATCH 14/73] chore(profile-controller): update changelog entries --- packages/profile-controller/CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index c43014f191a..72ef360ac8e 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -9,8 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger -- 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 -- Add `useGetProfile` and `useCheckUsernameAvailability` React query hooks for reading profile data in UI components via `@metamask/react-data-query` +- Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) +- Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) +- Add `useGetProfile` and `useCheckUsernameAvailability` React query hooks for reading profile data in UI components via `@metamask/react-data-query` ([#10558](https://github.com/MetaMask/core/pull/10558)) [Unreleased]: https://github.com/MetaMask/core/ From 442943f57e4027125b9206f07d0f9a809a848a2c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:00:04 -0400 Subject: [PATCH 15/73] fix(profile-controller): apply code review fixes to service and controller --- .../src/ProfileController.ts | 7 ++---- .../profile-controller/src/ProfileService.ts | 24 +++++++------------ 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index c0040b8ac45..f8db47aa03c 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -6,7 +6,6 @@ import type { } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; import type { CaipAccountId } from '@metamask/utils'; -import { hasProperty } from '@metamask/utils'; import type { ProfileControllerMethodActions } from './ProfileController-method-action-types.js'; import type { @@ -310,10 +309,8 @@ export class ProfileController extends BaseController< const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { state.metamaskProfile = mapped; - if (hasProperty(response, 'x_profile')) { - state.xProfile = this.#mapXResponseToXProfile( - response.x_profile as XConnectResponse, - ); + if (response.x_profile) { + state.xProfile = this.#mapXResponseToXProfile(response.x_profile); } }); return mapped; diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index f52b9c825d8..44feabc199b 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -18,7 +18,6 @@ import { optional, string, type as structType, - union, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; @@ -89,12 +88,9 @@ const XConnectResponseStruct = structType({ updated_at: string(), }); -const CreateProfileResponseStruct = union([ +const CreateProfileResponseStruct = intersection([ ProfileApiResponseStruct, - intersection([ - ProfileApiResponseStruct, - structType({ x_profile: XConnectResponseStruct }), - ]), + structType({ x_profile: optional(XConnectResponseStruct) }), ]); const ConnectXParamsStruct = structType({ @@ -123,7 +119,7 @@ const ReplaceProfileParamsStruct = structType({ const UpdateProfileParamsStruct = structType({ username: optional(string()), display_name: optional(string()), - bio: optional(string()), + bio: optional(nullable(string())), linked_addresses: optional(array(string())), avatar_url: optional(string()), trading_privacy: optional(enums(['public', 'private'] as const)), @@ -288,11 +284,11 @@ export class ProfileService extends BaseDataService< */ async getProfile(profileId: string): Promise { assert(profileId, string()); - const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ queryKey: [`${this.name}:getProfile`, profileId], responseStruct: ProfileApiResponseStruct, queryFn: async () => { + const authHeaders = await this.#getAuthHeaders(); const url = new URL( `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); @@ -437,7 +433,6 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, ); - return null; }, }); } @@ -454,12 +449,12 @@ export class ProfileService extends BaseDataService< username: string, ): Promise { assert(username, string()); - const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ queryKey: [`${this.name}:checkUsernameAvailability`, username], staleTime: 0, responseStruct: UsernameAvailabilityResponseStruct, queryFn: async () => { + const authHeaders = await this.#getAuthHeaders(); const url = new URL( `${this.#v1Url}/profiles/username/${encodeURIComponent(username)}/availability`, ); @@ -482,11 +477,10 @@ export class ProfileService extends BaseDataService< */ async getXAuthUrl(): Promise { const authHeaders = await this.#getAuthHeaders(); - return this.fetchQuery({ - queryKey: [`${this.name}:getXAuthUrl`], - staleTime: 0, + return this.executeMutation({ + mutationKey: [`${this.name}:getXAuthUrl`], responseStruct: XAuthUrlResponseStruct, - queryFn: async () => { + mutationFn: async () => { const url = new URL(`${this.#v1Url}/profiles/x/authentication-url`); const response = await fetch(url.toString(), { headers: authHeaders }); this.#throwIfNotOk( @@ -536,12 +530,12 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If the response does not match the expected shape. */ async getXAccount(): Promise { - const authHeaders = await this.#getAuthHeaders(); return this.fetchQuery({ queryKey: [`${this.name}:getXAccount`], staleTime: 0, responseStruct: XConnectResponseStruct, queryFn: async () => { + const authHeaders = await this.#getAuthHeaders(); const url = new URL(`${this.#v1Url}/profiles/x/account`); const response = await fetch(url.toString(), { headers: authHeaders }); this.#throwIfNotOk( From 19ce0e8601ac527bc4477d3f5a3177322857ef49 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:02:58 -0400 Subject: [PATCH 16/73] chore: update lockfile for profile-controller dependencies --- yarn.lock | 36 +++++++++++++++++++++++++++++++----- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/yarn.lock b/yarn.lock index 32c2f366333..3707032105c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5970,7 +5970,7 @@ __metadata: languageName: node linkType: hard -"@metamask/base-data-service@npm:^2.1.0, @metamask/base-data-service@workspace:packages/base-data-service": +"@metamask/base-data-service@npm:^2.0.0, @metamask/base-data-service@npm:^2.1.0, @metamask/base-data-service@workspace:packages/base-data-service": version: 0.0.0-use.local resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: @@ -8491,6 +8491,32 @@ __metadata: languageName: unknown linkType: soft +"@metamask/profile-sync-controller@npm:^32.3.1": + version: 32.3.1 + resolution: "@metamask/profile-sync-controller@npm:32.3.1" + dependencies: + "@metamask/address-book-controller": "npm:^8.0.0" + "@metamask/base-controller": "npm:^10.0.0" + "@metamask/controller-utils": "npm:^13.0.0" + "@metamask/key-tree": "npm:^10.1.1" + "@metamask/keyring-controller": "npm:^28.1.0" + "@metamask/messenger": "npm:^3.0.0" + "@metamask/seedless-onboarding-controller": "npm:^11.0.0" + "@metamask/superstruct": "npm:^3.4.1" + "@metamask/utils": "npm:^12.0.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@signinwithethereum/siwe": "npm:^4.2.1" + immer: "npm:^9.0.21" + loglevel: "npm:^1.8.1" + peerDependencies: + "@metamask/providers": ^22.0.0 + webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 + checksum: 10/bf591e1d30ec84a5d9e3a1acb88f93b682cf0bf075534c580bb5498fa7ff275dd3af83ff9d010d6aafe88b4eee39c83279c3ce79f552178706b5e80c74b7e46c + languageName: node + linkType: hard + "@metamask/profile-sync-controller@npm:^33.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -8720,7 +8746,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^11.0.0, @metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -16173,7 +16199,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -26751,7 +26777,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.4.14": +"ts-jest@npm:^29.4.12, ts-jest@npm:^29.4.14": version: 29.4.14 resolution: "ts-jest@npm:29.4.14" dependencies: @@ -26836,7 +26862,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.23.15": +"tsx@npm:^4.23.13, tsx@npm:^4.23.15": version: 4.23.15 resolution: "tsx@npm:4.23.15" dependencies: From b18887a9a9c754171ad3ffe22131f5f5b311aa85 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:10:10 -0400 Subject: [PATCH 17/73] chore: update README --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index cb59eff87f6..ed3a8a2d2a1 100644 --- a/README.md +++ b/README.md @@ -613,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 --> react_data_query; + profile_controller --> utils; profile_metrics_controller --> accounts_controller; profile_metrics_controller --> base_controller; profile_metrics_controller --> controller_utils; From 5637f4eccb108166b525a0df58ee2f648aa8c427 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:11:26 -0400 Subject: [PATCH 18/73] fix(profile-controller): update action types --- .../ProfileController-method-action-types.ts | 29 +++++++++++-------- .../src/ProfileService-method-action-types.ts | 2 +- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 9e14281984e..4bc958d3b8f 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController.js'; +import type { ProfileController } from './ProfileController'; /** * Returns the current MetaMask profile from state, or undefined if none has been created. @@ -26,7 +26,8 @@ export type ProfileControllerGetXprofileAction = { }; /** - * Creates a new MetaMask profile and updates state. + * 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. @@ -37,10 +38,10 @@ export type ProfileControllerCreateProfileAction = { }; /** - * Fully replaces an existing profile and updates state. + * Fully replaces the current profile and updates state. * - * @param profileId - The profile identifier (the canonical profile ID). * @param input - The replacement profile data. + * @throws If no profile has been created yet. */ export type ProfileControllerReplaceProfileAction = { type: `ProfileController:replaceProfile`; @@ -48,10 +49,10 @@ export type ProfileControllerReplaceProfileAction = { }; /** - * Partially updates an existing profile and updates state. + * Partially updates the current profile and updates state. * - * @param profileId - The profile identifier (the canonical profile ID). * @param input - The fields to update. + * @throws If no profile has been created yet. */ export type ProfileControllerUpdateProfileAction = { type: `ProfileController:updateProfile`; @@ -59,9 +60,9 @@ export type ProfileControllerUpdateProfileAction = { }; /** - * Deletes a profile and resets state, including clearing any linked X profile. + * Deletes the current profile and resets state, including clearing any linked X profile. * - * @param profileId - The profile identifier (the canonical profile ID) to delete. + * @throws If no profile has been created yet. */ export type ProfileControllerDeleteProfileAction = { type: `ProfileController:deleteProfile`; @@ -90,19 +91,23 @@ export type ProfileControllerGetXAuthUrlAction = { }; /** - * Completes the X OAuth PKCE flow and updates the X profile in state. + * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. * - * @param params - The parameters for the X OA uth PKCE flow. + * @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 X profile. + * @returns The linked X profile. */ export type ProfileControllerConnectXAction = { type: `ProfileController:connectX`; handler: ProfileController['connectX']; }; -/** Fetches the X account linked to the current profile and updates state. */ +/** + * 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']; diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index bdd5ca13d28..3328b6c1663 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileService } from './ProfileService.js'; +import type { ProfileService } from './ProfileService'; /** * Fetches a profile by its identifier. From 17680a94ee216407d7addfd4abaf9d61f87dd6da Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:13:43 -0400 Subject: [PATCH 19/73] fix(profile-controller): update tsconfigs --- packages/profile-controller/tsconfig.build.json | 3 +++ packages/profile-controller/tsconfig.json | 3 +++ packages/profile-controller/tsconfig.lint.json | 3 +++ 3 files changed, 9 insertions(+) diff --git a/packages/profile-controller/tsconfig.build.json b/packages/profile-controller/tsconfig.build.json index 924e449bf4d..3b5f79ed123 100644 --- a/packages/profile-controller/tsconfig.build.json +++ b/packages/profile-controller/tsconfig.build.json @@ -22,6 +22,9 @@ }, { "path": "../react-data-query/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 index 55fe44dd312..ad4a671baaf 100644 --- a/packages/profile-controller/tsconfig.json +++ b/packages/profile-controller/tsconfig.json @@ -18,6 +18,9 @@ }, { "path": "../react-data-query/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 index ca5367ba2c7..244b9c090d3 100644 --- a/packages/profile-controller/tsconfig.lint.json +++ b/packages/profile-controller/tsconfig.lint.json @@ -22,6 +22,9 @@ }, { "path": "../react-data-query/tsconfig.lint.json" + }, + { + "path": "../controller-utils/tsconfig.lint.json" } ] } From f13cf5a0cbf053198390103566a6e06a32810063 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:14:35 -0400 Subject: [PATCH 20/73] chore: add profile-controller to teams.json --- teams.json | 1 + 1 file changed, 1 insertion(+) 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", From 3e21f9d3e8b20e6fd69f8dce43813a257a5f3062 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:19:14 -0400 Subject: [PATCH 21/73] fix(profile-controller): resolve lint errors --- oxlint-suppressions.json | 5 +++++ .../profile-controller/src/ProfileService.ts | 16 ++++++++-------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/oxlint-suppressions.json b/oxlint-suppressions.json index 6bbc53115f0..cc6c3b6c76c 100644 --- a/oxlint-suppressions.json +++ b/oxlint-suppressions.json @@ -4670,6 +4670,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/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 44feabc199b..c17a7f15bdc 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -297,7 +297,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.GET_PROFILE_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -329,7 +329,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -366,7 +366,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -403,7 +403,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -463,7 +463,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -487,7 +487,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -517,7 +517,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.CONNECT_X_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } @@ -542,7 +542,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED, ); - return response.json(); + return response.json() as Promise; }, }); } From f0f42a0f5aee40a4ce8dd5c523ea84c441f16cc5 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:21:51 -0400 Subject: [PATCH 22/73] fix(profile-controller): return null from deleteProfile mutationFn to satisfy Json return type --- packages/profile-controller/src/ProfileService.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index c17a7f15bdc..4adecd194be 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -433,6 +433,7 @@ export class ProfileService extends BaseDataService< response, ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, ); + return null; }, }); } From e9687d605769e1d797c6f5ed7c8d571020a1474f Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:25:47 -0400 Subject: [PATCH 23/73] fix(profile-controller): lint fix --- .../src/ProfileController.test.ts | 9 ++- .../src/ProfileService.test.ts | 74 ++++++++----------- .../src/ProfileServiceHooks.ts | 5 +- packages/profile-controller/src/index.ts | 10 ++- 4 files changed, 50 insertions(+), 48 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index b1aa3b8da6b..7b479943c4a 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -152,7 +152,9 @@ describe('ProfileController', () => { it('initializes with default state', () => { const { controller } = createController(); - expect(controller.state).toStrictEqual(getDefaultProfileControllerState()); + expect(controller.state).toStrictEqual( + getDefaultProfileControllerState(), + ); }); it('merges partial initial state with defaults', () => { @@ -540,7 +542,10 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger }); await controller.connectX({ code: 'auth-code-123', state: 'state-xyz' }); - expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz' }); + expect(connectXMock).toHaveBeenCalledWith({ + code: 'auth-code-123', + state: 'state-xyz', + }); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); }); }); diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 3992125bd37..723924ff86c 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -235,17 +235,14 @@ describe('ProfileService', () => { 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), + 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 () => { @@ -292,17 +289,14 @@ describe('ProfileService', () => { 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), + 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 () => { @@ -310,9 +304,7 @@ describe('ProfileService', () => { const service = createService(); - await expect( - service.updateProfile('profile-123', input), - ).rejects.toThrow( + await expect(service.updateProfile('profile-123', input)).rejects.toThrow( `${ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED}: 422`, ); }); @@ -326,9 +318,9 @@ describe('ProfileService', () => { const service = createService(); - await expect( - service.updateProfile('profile-123', input), - ).rejects.toThrow('returned an unexpected response'); + await expect(service.updateProfile('profile-123', input)).rejects.toThrow( + 'returned an unexpected response', + ); }); }); @@ -339,13 +331,10 @@ describe('ProfileService', () => { const service = createService(); await service.deleteProfile('profile-123'); - expect(mockFetch).toHaveBeenCalledWith( - `${V1_URL}/profiles/profile-123`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, - }, - ); + expect(mockFetch).toHaveBeenCalledWith(`${V1_URL}/profiles/profile-123`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${MOCK_TOKEN}` }, + }); }); it('throws HttpError on non-ok response', async () => { @@ -398,9 +387,7 @@ describe('ProfileService', () => { const service = createService(); - await expect( - service.checkUsernameAvailability('alice'), - ).rejects.toThrow( + await expect(service.checkUsernameAvailability('alice')).rejects.toThrow( `${ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED}: 500`, ); }); @@ -414,9 +401,9 @@ describe('ProfileService', () => { const service = createService(); - await expect( - service.checkUsernameAvailability('alice'), - ).rejects.toThrow('returned an unexpected response'); + await expect(service.checkUsernameAvailability('alice')).rejects.toThrow( + 'returned an unexpected response', + ); }); }); @@ -476,7 +463,10 @@ describe('ProfileService', () => { json: () => Promise.resolve(mockXConnectResponse), }); - const params: ConnectXParams = { code: 'auth-code-123', state: 'state-xyz' }; + const params: ConnectXParams = { + code: 'auth-code-123', + state: 'state-xyz', + }; const service = createService(); const result = await service.connectX(params); @@ -498,9 +488,7 @@ describe('ProfileService', () => { await expect( service.connectX({ code: 'auth-code-123', state: 'state-xyz' }), - ).rejects.toThrow( - `${ProfileServiceErrorMessage.CONNECT_X_FAILED}: 401`, - ); + ).rejects.toThrow(`${ProfileServiceErrorMessage.CONNECT_X_FAILED}: 401`); }); it('throws when response schema is invalid', async () => { diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts index e239ff33d06..d92678c5762 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.ts @@ -1,6 +1,9 @@ import { useQuery } from '@metamask/react-data-query'; -import type { ProfileApiResponse, UsernameAvailabilityResponse } from './ProfileService.js'; +import type { + ProfileApiResponse, + UsernameAvailabilityResponse, +} from './ProfileService.js'; import { serviceName } from './ProfileService.js'; /** diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index f7116ff1d2c..c3aaab9931e 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -39,8 +39,14 @@ export type { ProfileServiceEvents, ProfileServiceMessenger, } from './ProfileService.js'; -export { ProfileService, ProfileServiceErrorMessage } from './ProfileService.js'; -export { useGetProfile, useCheckUsernameAvailability } from './ProfileServiceHooks.js'; +export { + ProfileService, + ProfileServiceErrorMessage, +} from './ProfileService.js'; +export { + useGetProfile, + useCheckUsernameAvailability, +} from './ProfileServiceHooks.js'; export type { ProfileServiceCheckUsernameAvailabilityAction, ProfileServiceConnectXAction, From 0b5426bc09b860f93db1ed29605d6dc7f1ab01e4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:49:33 -0400 Subject: [PATCH 24/73] chore: update CODEOWNERS --- .github/CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 71768c2eb432e3e23044e2916d0e41e23e127eff Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:49:56 -0400 Subject: [PATCH 25/73] chore(profile-controller): align deps --- packages/profile-controller/package.json | 12 +++---- yarn.lock | 46 ++++++------------------ 2 files changed, 16 insertions(+), 42 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 010f647c994..ac4a22e5bf8 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -1,7 +1,7 @@ { "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.", + "description": "Controller for managing user profile data including username, bio, avatar, and linked accounts via the MetaMask Profile API", "keywords": [ "Ethereum", "MetaMask" @@ -51,10 +51,10 @@ }, "dependencies": { "@metamask/base-controller": "^10.0.0", - "@metamask/base-data-service": "^2.0.0", + "@metamask/base-data-service": "^2.1.0", "@metamask/controller-utils": "^13.0.0", "@metamask/messenger": "^3.0.0", - "@metamask/profile-sync-controller": "^32.3.1", + "@metamask/profile-sync-controller": "^33.0.0", "@metamask/react-data-query": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0" @@ -63,11 +63,11 @@ "@metamask/auto-changelog": "^6.2.1", "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", - "deepmerge": "^4.2.2", + "deepmerge": "^4.3.1", "jest": "^30.4.2", "rimraf": "^6.1.3", - "ts-jest": "^29.4.12", - "tsx": "^4.23.13", + "ts-jest": "^29.4.14", + "tsx": "^4.23.15", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2" diff --git a/yarn.lock b/yarn.lock index 3707032105c..f7d333f0130 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5970,7 +5970,7 @@ __metadata: languageName: node linkType: hard -"@metamask/base-data-service@npm:^2.0.0, @metamask/base-data-service@npm:^2.1.0, @metamask/base-data-service@workspace:packages/base-data-service": +"@metamask/base-data-service@npm:^2.1.0, @metamask/base-data-service@workspace:packages/base-data-service": version: 0.0.0-use.local resolution: "@metamask/base-data-service@workspace:packages/base-data-service" dependencies: @@ -8436,20 +8436,20 @@ __metadata: dependencies: "@metamask/auto-changelog": "npm:^6.2.1" "@metamask/base-controller": "npm:^10.0.0" - "@metamask/base-data-service": "npm:^2.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:^32.3.1" + "@metamask/profile-sync-controller": "npm:^33.0.0" "@metamask/react-data-query": "npm:^2.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.2.2" + deepmerge: "npm:^4.3.1" jest: "npm:^30.4.2" rimraf: "npm:^6.1.3" - ts-jest: "npm:^29.4.12" - tsx: "npm:^4.23.13" + ts-jest: "npm:^29.4.14" + tsx: "npm:^4.23.15" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.2" @@ -8491,32 +8491,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/profile-sync-controller@npm:^32.3.1": - version: 32.3.1 - resolution: "@metamask/profile-sync-controller@npm:32.3.1" - dependencies: - "@metamask/address-book-controller": "npm:^8.0.0" - "@metamask/base-controller": "npm:^10.0.0" - "@metamask/controller-utils": "npm:^13.0.0" - "@metamask/key-tree": "npm:^10.1.1" - "@metamask/keyring-controller": "npm:^28.1.0" - "@metamask/messenger": "npm:^3.0.0" - "@metamask/seedless-onboarding-controller": "npm:^11.0.0" - "@metamask/superstruct": "npm:^3.4.1" - "@metamask/utils": "npm:^12.0.0" - "@noble/ciphers": "npm:^1.3.0" - "@noble/curves": "npm:^1.9.2" - "@noble/hashes": "npm:^1.8.0" - "@signinwithethereum/siwe": "npm:^4.2.1" - immer: "npm:^9.0.21" - loglevel: "npm:^1.8.1" - peerDependencies: - "@metamask/providers": ^22.0.0 - webextension-polyfill: ^0.10.0 || ^0.11.0 || ^0.12.0 - checksum: 10/bf591e1d30ec84a5d9e3a1acb88f93b682cf0bf075534c580bb5498fa7ff275dd3af83ff9d010d6aafe88b4eee39c83279c3ce79f552178706b5e80c74b7e46c - languageName: node - linkType: hard - "@metamask/profile-sync-controller@npm:^33.0.0, @metamask/profile-sync-controller@workspace:packages/profile-sync-controller": version: 0.0.0-use.local resolution: "@metamask/profile-sync-controller@workspace:packages/profile-sync-controller" @@ -8746,7 +8720,7 @@ __metadata: languageName: node linkType: hard -"@metamask/seedless-onboarding-controller@npm:^11.0.0, @metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": +"@metamask/seedless-onboarding-controller@npm:^11.0.1, @metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller": version: 0.0.0-use.local resolution: "@metamask/seedless-onboarding-controller@workspace:packages/seedless-onboarding-controller" dependencies: @@ -16199,7 +16173,7 @@ __metadata: languageName: node linkType: hard -"deepmerge@npm:^4.2.2, deepmerge@npm:^4.3.1": +"deepmerge@npm:^4.3.1": version: 4.3.1 resolution: "deepmerge@npm:4.3.1" checksum: 10/058d9e1b0ff1a154468bf3837aea436abcfea1ba1d165ddaaf48ca93765fdd01a30d33c36173da8fbbed951dd0a267602bc782fe288b0fc4b7e1e7091afc4529 @@ -26777,7 +26751,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.4.12, ts-jest@npm:^29.4.14": +"ts-jest@npm:^29.4.14": version: 29.4.14 resolution: "ts-jest@npm:29.4.14" dependencies: @@ -26862,7 +26836,7 @@ __metadata: languageName: node linkType: hard -"tsx@npm:^4.23.13, tsx@npm:^4.23.15": +"tsx@npm:^4.23.15": version: 4.23.15 resolution: "tsx@npm:4.23.15" dependencies: From defbe71b073bed39606d742dc68dc8783fecbea2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:50:28 -0400 Subject: [PATCH 26/73] fix(profile-controller): add casts --- .../src/ProfileController.ts | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index f8db47aa03c..185ee867c64 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -20,6 +20,7 @@ import type { } from './ProfileService-method-action-types.js'; import type { CreateProfileParams, + CreateProfileResponse, ProfileApiResponse, ReplaceProfileParams, UpdateProfileParams, @@ -302,10 +303,10 @@ export class ProfileController extends BaseController< * @returns The created MetaMask profile. */ async createProfile(params: CreateProfileParams): Promise { - const response = await this.messenger.call( + const response = (await this.messenger.call( 'ProfileService:createProfile', params, - ); + )) as CreateProfileResponse; const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { state.metamaskProfile = mapped; @@ -330,7 +331,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.metamaskProfile = this.#mapApiResponseToProfile(response as ProfileApiResponse); }); } @@ -348,7 +349,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.metamaskProfile = this.#mapApiResponseToProfile(response as ProfileApiResponse); }); } @@ -379,7 +380,7 @@ export class ProfileController extends BaseController< return this.messenger.call( 'ProfileService:checkUsernameAvailability', username, - ); + ) as UsernameAvailabilityResponse; } /** @@ -388,7 +389,9 @@ export class ProfileController extends BaseController< * @returns An object containing the authorization URL and its associated state token. */ async getXAuthUrl(): Promise { - return this.messenger.call('ProfileService:getXAuthUrl'); + return this.messenger.call( + 'ProfileService:getXAuthUrl', + ) as XAuthUrlResponse; } /** @@ -404,11 +407,7 @@ export class ProfileController extends BaseController< 'ProfileService:connectX', params, ); - const mapped = this.#mapXResponseToXProfile(response); - this.update((state) => { - state.xProfile = mapped; - }); - return mapped; + return this.#mapXResponseToXProfile(response as XConnectResponse); } /** @@ -417,7 +416,9 @@ export class ProfileController extends BaseController< * @returns The linked X profile. */ async fetchAndUpdateXAccount(): Promise { - const response = await this.messenger.call('ProfileService:getXAccount'); + const response = (await this.messenger.call( + 'ProfileService:getXAccount', + )) as XConnectResponse; const mapped = this.#mapXResponseToXProfile(response); this.update((state) => { state.xProfile = mapped; From dcd8b7ead0a5383060db141faa6c3e4abfe6ff02 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:53:08 -0400 Subject: [PATCH 27/73] chore: update CODEOWNERS --- .github/CODEOWNERS | 7 ------- 1 file changed, 7 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 50ed0ed16cc..087bf642e26 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -536,13 +536,6 @@ /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 From 4ca4f6419c932b2a9daceed9a92dea7a90c60dca Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:54:23 -0400 Subject: [PATCH 28/73] chore: update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ed3a8a2d2a1..6e003b695e3 100644 --- a/README.md +++ b/README.md @@ -617,6 +617,7 @@ linkStyle default opacity:0.5 profile_controller --> base_data_service; profile_controller --> controller_utils; profile_controller --> messenger; + profile_controller --> profile_sync_controller; profile_controller --> react_data_query; profile_controller --> utils; profile_metrics_controller --> accounts_controller; From b673db49818aecec5251bdad7e4b5f04a9ca00b5 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 21:54:50 -0400 Subject: [PATCH 29/73] fix(profile-controller): lint fix --- packages/profile-controller/src/ProfileController.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 185ee867c64..36dd3d58185 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -331,7 +331,9 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response as ProfileApiResponse); + state.metamaskProfile = this.#mapApiResponseToProfile( + response as ProfileApiResponse, + ); }); } @@ -349,7 +351,9 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response as ProfileApiResponse); + state.metamaskProfile = this.#mapApiResponseToProfile( + response as ProfileApiResponse, + ); }); } From 05b41fe7ce9585335a6381ac796f5e31558071aa Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:27:38 -0400 Subject: [PATCH 30/73] chore: add profile controller to core tsconfig --- tsconfig.lint.json | 3 +++ 1 file changed, 3 insertions(+) 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" }, From 9a49480977161b9f1a5e14044d89f1acad7b4f7b Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:28:01 -0400 Subject: [PATCH 31/73] fix(profile-controller): fix import in action types --- .../src/ProfileController-method-action-types.ts | 2 +- .../src/ProfileService-method-action-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 4bc958d3b8f..5a0ac9ba3a2 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController'; +import type { ProfileController } from './ProfileController.js'; /** * Returns the current MetaMask profile from state, or undefined if none has been created. diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 3328b6c1663..bdd5ca13d28 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileService } from './ProfileService'; +import type { ProfileService } from './ProfileService.js'; /** * Fetches a profile by its identifier. From 97184642a77740d2b5126eff05f598f93ef9daf1 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:36:30 -0400 Subject: [PATCH 32/73] refactor(profile-controller): remove casts --- .../src/ProfileController.ts | 27 +++++++------------ 1 file changed, 9 insertions(+), 18 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 36dd3d58185..2c00987f3be 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -20,7 +20,6 @@ import type { } from './ProfileService-method-action-types.js'; import type { CreateProfileParams, - CreateProfileResponse, ProfileApiResponse, ReplaceProfileParams, UpdateProfileParams, @@ -303,10 +302,10 @@ export class ProfileController extends BaseController< * @returns The created MetaMask profile. */ async createProfile(params: CreateProfileParams): Promise { - const response = (await this.messenger.call( + const response = await this.messenger.call( 'ProfileService:createProfile', params, - )) as CreateProfileResponse; + ); const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { state.metamaskProfile = mapped; @@ -331,9 +330,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile( - response as ProfileApiResponse, - ); + state.metamaskProfile = this.#mapApiResponseToProfile(response); }); } @@ -351,9 +348,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile( - response as ProfileApiResponse, - ); + state.metamaskProfile = this.#mapApiResponseToProfile(response); }); } @@ -381,10 +376,10 @@ export class ProfileController extends BaseController< async checkUsernameAvailability( username: string, ): Promise { - return this.messenger.call( + return await this.messenger.call( 'ProfileService:checkUsernameAvailability', username, - ) as UsernameAvailabilityResponse; + ); } /** @@ -393,9 +388,7 @@ export class ProfileController extends BaseController< * @returns An object containing the authorization URL and its associated state token. */ async getXAuthUrl(): Promise { - return this.messenger.call( - 'ProfileService:getXAuthUrl', - ) as XAuthUrlResponse; + return await this.messenger.call('ProfileService:getXAuthUrl'); } /** @@ -411,7 +404,7 @@ export class ProfileController extends BaseController< 'ProfileService:connectX', params, ); - return this.#mapXResponseToXProfile(response as XConnectResponse); + return this.#mapXResponseToXProfile(response); } /** @@ -420,9 +413,7 @@ export class ProfileController extends BaseController< * @returns The linked X profile. */ async fetchAndUpdateXAccount(): Promise { - const response = (await this.messenger.call( - 'ProfileService:getXAccount', - )) as XConnectResponse; + const response = await this.messenger.call('ProfileService:getXAccount'); const mapped = this.#mapXResponseToXProfile(response); this.update((state) => { state.xProfile = mapped; From 367b3e9438fc8e02124fc562f34715fb89eed223 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:38:25 -0400 Subject: [PATCH 33/73] chore(profile-controller): fix constraints, CODEOWNERS, and update tests for connectX --- .../src/ProfileController.test.ts | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 7b479943c4a..8190c5d4f1c 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -204,16 +204,16 @@ describe('ProfileController', () => { expect(controller.getXprofile()).toBeUndefined(); }); - it('returns the X profile after connectX', async () => { + it('returns the X profile after fetchAndUpdateXAccount', async () => { const rootMessenger = getRootMessenger(); mockServiceAction( rootMessenger, - 'ProfileService:connectX', + 'ProfileService:getXAccount', jest.fn().mockResolvedValue(mockXConnectResponse), ); const { controller } = createController({ rootMessenger }); - await controller.connectX({ code: 'auth-code', state: 'state-xyz' }); + await controller.fetchAndUpdateXAccount(); expect(controller.getXprofile()).toStrictEqual(mockMappedXProfile); }); @@ -431,7 +431,7 @@ describe('ProfileController', () => { const deleteProfileMock = jest.fn().mockResolvedValue(undefined); mockServiceAction( rootMessenger, - 'ProfileService:connectX', + 'ProfileService:getXAccount', jest.fn().mockResolvedValue(mockXConnectResponse), ); mockServiceAction( @@ -444,7 +444,7 @@ describe('ProfileController', () => { rootMessenger, state: { metamaskProfile: mockMappedProfile }, }); - await controller.connectX({ code: 'auth-code', state: 'state-xyz' }); + await controller.fetchAndUpdateXAccount(); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); await controller.deleteProfile(); @@ -534,19 +534,23 @@ describe('ProfileController', () => { }); describe('connectX', () => { - it('calls ProfileService:connectX with code and state, updates xProfile', async () => { + 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 }); - await controller.connectX({ code: 'auth-code-123', state: 'state-xyz' }); + const result = await controller.connectX({ + code: 'auth-code-123', + state: 'state-xyz', + }); expect(connectXMock).toHaveBeenCalledWith({ code: 'auth-code-123', state: 'state-xyz', }); - expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); + expect(result).toStrictEqual(mockMappedXProfile); + expect(controller.state.xProfile).toBeUndefined(); }); }); From 5d511a69fe95b5a56f5912b6494bc731a2dbb882 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:44:15 -0400 Subject: [PATCH 34/73] chore: add codeowners --- .github/CODEOWNERS | 7 +++++++ 1 file changed, 7 insertions(+) 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 From 5feed3c286825ce3dacbbbe02b7b591e3d6d027d Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:46:02 -0400 Subject: [PATCH 35/73] fix(profile-controller): regenerate action types --- .../src/ProfileController-method-action-types.ts | 2 +- .../src/ProfileService-method-action-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 5a0ac9ba3a2..4bc958d3b8f 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController.js'; +import type { ProfileController } from './ProfileController'; /** * Returns the current MetaMask profile from state, or undefined if none has been created. diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index bdd5ca13d28..3328b6c1663 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileService } from './ProfileService.js'; +import type { ProfileService } from './ProfileService'; /** * Fetches a profile by its identifier. From 98ec92092c883709b7761897948b92feeb97913a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 22:47:39 -0400 Subject: [PATCH 36/73] chore: add profile-controller to codeowners.ts --- codeowners.ts | 3 +++ 1 file changed, 3 insertions(+) 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'], }, From f775851c66ef5761e1813fc1fd184d7f62c0c7a4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Mon, 28 Sep 2026 23:51:51 -0400 Subject: [PATCH 37/73] fix(profile-controller): update imports --- .../src/ProfileController-method-action-types.ts | 2 +- .../src/ProfileService-method-action-types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 4bc958d3b8f..5a0ac9ba3a2 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileController } from './ProfileController'; +import type { ProfileController } from './ProfileController.js'; /** * Returns the current MetaMask profile from state, or undefined if none has been created. diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index 3328b6c1663..bdd5ca13d28 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { ProfileService } from './ProfileService'; +import type { ProfileService } from './ProfileService.js'; /** * Fetches a profile by its identifier. From e8e38518f1d3e067228d10e7509bce87c06ef692 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 00:09:13 -0400 Subject: [PATCH 38/73] refactor(profile-controller): update method name --- .../src/ProfileController-method-action-types.ts | 8 ++++---- packages/profile-controller/src/ProfileController.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 5a0ac9ba3a2..f3c082d9c86 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -20,9 +20,9 @@ export type ProfileControllerGetMetaMaskProfileAction = { * * @returns The X profile, or undefined. */ -export type ProfileControllerGetXprofileAction = { - type: `ProfileController:getXprofile`; - handler: ProfileController['getXprofile']; +export type ProfileControllerGetXProfileAction = { + type: `ProfileController:getXProfile`; + handler: ProfileController['getXProfile']; }; /** @@ -118,7 +118,7 @@ export type ProfileControllerFetchAndUpdateXAccountAction = { */ export type ProfileControllerMethodActions = | ProfileControllerGetMetaMaskProfileAction - | ProfileControllerGetXprofileAction + | ProfileControllerGetXProfileAction | ProfileControllerCreateProfileAction | ProfileControllerReplaceProfileAction | ProfileControllerUpdateProfileAction diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 2c00987f3be..a873e57e81e 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -162,7 +162,7 @@ export function getDefaultProfileControllerState(): ProfileControllerState { const MESSENGER_EXPOSED_METHODS = [ 'getMetaMaskProfile', - 'getXprofile', + 'getXProfile', 'createProfile', 'replaceProfile', 'updateProfile', @@ -290,7 +290,7 @@ export class ProfileController extends BaseController< * * @returns The X profile, or undefined. */ - getXprofile(): XProfile | undefined { + getXProfile(): XProfile | undefined { return this.state.xProfile; } From 8008b458c85a7b94b59ae852571e9f78a728b365 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 00:15:25 -0400 Subject: [PATCH 39/73] test(profile-controller): fix method names --- packages/profile-controller/src/ProfileController.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 8190c5d4f1c..831e182861c 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -197,11 +197,11 @@ describe('ProfileController', () => { }); }); - describe('getXprofile', () => { + describe('getXProfile', () => { it('returns undefined when no X profile exists', () => { const { controller } = createController(); - expect(controller.getXprofile()).toBeUndefined(); + expect(controller.getXProfile()).toBeUndefined(); }); it('returns the X profile after fetchAndUpdateXAccount', async () => { @@ -215,7 +215,7 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger }); await controller.fetchAndUpdateXAccount(); - expect(controller.getXprofile()).toStrictEqual(mockMappedXProfile); + expect(controller.getXProfile()).toStrictEqual(mockMappedXProfile); }); }); From db05174719daffc8caca44a8da3561b31fa6adc3 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 08:42:33 -0400 Subject: [PATCH 40/73] chore(profile-controller): bump jest to match monorepo version --- packages/profile-controller/package.json | 2 +- yarn.lock | 1228 +++++++++++++++++++++- 2 files changed, 1214 insertions(+), 16 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index ac4a22e5bf8..890c17feee4 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -64,7 +64,7 @@ "@types/jest": "^30.0.0", "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.3.1", - "jest": "^30.4.2", + "jest": "^30.5.2", "rimraf": "^6.1.3", "ts-jest": "^29.4.14", "tsx": "^4.23.15", diff --git a/yarn.lock b/yarn.lock index f7d333f0130..c12e8940ccf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3231,6 +3231,16 @@ __metadata: languageName: node linkType: hard +"@emnapi/core@npm:1.10.0": + version: 1.10.0 + resolution: "@emnapi/core@npm:1.10.0" + dependencies: + "@emnapi/wasi-threads": "npm:1.2.1" + tslib: "npm:^2.4.0" + checksum: 10/d32f386084e64deaf2609aabb8295d1ad5af6144d0f46d2060b76cc53f1f3b486df54bec9b0f33c37d85a3822e1193ebcd4e3deb4a5f0e4cd650aa2ffc631715 + languageName: node + linkType: hard + "@emnapi/core@npm:1.11.2, @emnapi/core@npm:^1.4.3": version: 1.11.2 resolution: "@emnapi/core@npm:1.11.2" @@ -3241,6 +3251,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/runtime@npm:1.10.0": + version: 1.10.0 + resolution: "@emnapi/runtime@npm:1.10.0" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/d21083d07fa0c2da171c142e78ef986b66b07d45b06accc0bcaf49fcc61bb4dbc10e1c1760813070165b9f49b054376a931045347f21c0f42ff1eb2d2040faac + languageName: node + linkType: hard + "@emnapi/runtime@npm:1.11.2, @emnapi/runtime@npm:^1.4.3": version: 1.11.2 resolution: "@emnapi/runtime@npm:1.11.2" @@ -3250,6 +3269,15 @@ __metadata: languageName: node linkType: hard +"@emnapi/wasi-threads@npm:1.2.1": + version: 1.2.1 + resolution: "@emnapi/wasi-threads@npm:1.2.1" + dependencies: + tslib: "npm:^2.4.0" + checksum: 10/57cd4292be81c05d26aa886d68a9e4c449ff666e8503fed6463dfc6b64a4e4213f03c152d53296b7cda32840271e38cd33347332070658f01befeb9bf4e59f36 + languageName: node + linkType: hard + "@emnapi/wasi-threads@npm:1.2.2": version: 1.2.2 resolution: "@emnapi/wasi-threads@npm:1.2.2" @@ -4659,6 +4687,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/console@npm:30.5.2" + dependencies: + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + jest-message-util: "npm:30.5.1" + jest-util: "npm:30.5.1" + slash: "npm:^3.0.0" + checksum: 10/2dea07a84013f9642dbb07b88b6278057d5dab9e2dc172c905434e7917baffd4af5a0a63a5b45f03f9065171f6ed176091ac78019f6f2c06d95d78ecb98194bd + languageName: node + linkType: hard + "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -4700,6 +4742,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/core@npm:30.5.2" + dependencies: + "@jest/console": "npm:30.5.2" + "@jest/pattern": "npm:30.5.0" + "@jest/reporters": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + exit-x: "npm:^0.2.2" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-changed-files: "npm:30.5.1" + jest-config: "npm:30.5.2" + jest-haste-map: "npm:30.5.1" + jest-message-util: "npm:30.5.1" + jest-regex-util: "npm:30.5.0" + jest-resolve: "npm:30.5.1" + jest-resolve-dependencies: "npm:30.5.2" + jest-runner: "npm:30.5.2" + jest-runtime: "npm:30.5.2" + jest-snapshot: "npm:30.5.2" + jest-util: "npm:30.5.1" + jest-validate: "npm:30.5.1" + jest-watcher: "npm:30.5.2" + pretty-format: "npm:30.5.1" + slash: "npm:^3.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/2a7b17651410feae891ca7aa54b017de45dcaf52d02b93dc68b77c2cc56f97b9a2eedd878d5ed1d1ef6aebcdc9f13d272ec19705077ab531a9a17a8fbe353dfa + languageName: node + linkType: hard + "@jest/diff-sequences@npm:30.4.0": version: 30.4.0 resolution: "@jest/diff-sequences@npm:30.4.0" @@ -4707,6 +4790,13 @@ __metadata: languageName: node linkType: hard +"@jest/diff-sequences@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/diff-sequences@npm:30.5.0" + checksum: 10/6fdba657e811790850c68beeef9a9d435b77733242c0ff94226cd210bbc11320a39a687bb3037089be7ffc6a2d98f5527d987327a2a18f83ebc87a8fe1e97e2a + languageName: node + linkType: hard + "@jest/environment-jsdom-abstract@npm:30.4.1": version: 30.4.1 resolution: "@jest/environment-jsdom-abstract@npm:30.4.1" @@ -4740,6 +4830,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/environment@npm:30.5.2" + dependencies: + "@jest/fake-timers": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + jest-mock: "npm:30.5.2" + checksum: 10/c033375dec4c6bacb4ad6ebb6602ace7d87d8c674e4f25aa729a33bc469306105d760b7d7d2a63363b039b364f7e85989e604123c20bc52a32eba302e16cdb9c + languageName: node + linkType: hard + "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -4749,6 +4851,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/expect-utils@npm:30.5.2" + dependencies: + "@jest/get-type": "npm:30.5.0" + checksum: 10/ca45ae83d0901241e7b561164719c20daae7e23fb7433f7d7c6762d5f825e61dcce062a6191972b00ef72c6d49be6ce2c8a227aeb8777df8dbcca02177d08935 + languageName: node + linkType: hard + "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -4759,6 +4870,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/expect@npm:30.5.2" + dependencies: + expect: "npm:30.5.2" + jest-snapshot: "npm:30.5.2" + checksum: 10/5d05a885ce180ed007eed09dcb9308a78ea58f6a8924cc1cab0426512abb8ccfe7ed735e3a2307f425460dcd95b67692d2590cbbdebfa84049767b43ba72f2f7 + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -4773,6 +4894,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/fake-timers@npm:30.5.2" + dependencies: + "@jest/types": "npm:30.5.1" + "@sinonjs/fake-timers": "npm:^15.4.0" + "@types/node": "npm:*" + jest-message-util: "npm:30.5.1" + jest-mock: "npm:30.5.2" + jest-util: "npm:30.5.1" + checksum: 10/53a8a093e4191f93c9184fb94820afb062c503dd362c9bd74c785b4c62aad3a10b6a0ab44f258c28125039bbcd5b102aa3006ee5ff39e6f053b903398f6785cc + languageName: node + linkType: hard + "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -4780,6 +4915,13 @@ __metadata: languageName: node linkType: hard +"@jest/get-type@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/get-type@npm:30.5.0" + checksum: 10/6b67e70f6c1cca6da153814542903b4c385447c7c5e029f8911402ca8a75fbd33a14cc0fff4c6ed7c6c629f6425fade605d6f32eb9516b52146a8640fd899773 + languageName: node + linkType: hard + "@jest/globals@npm:30.4.1, @jest/globals@npm:^30.4.1": version: 30.4.1 resolution: "@jest/globals@npm:30.4.1" @@ -4792,6 +4934,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/globals@npm:30.5.2" + dependencies: + "@jest/environment": "npm:30.5.2" + "@jest/expect": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + jest-mock: "npm:30.5.2" + checksum: 10/664efe47ae27baf20e1d99e71e44628c0c15b331f55eba66f158a14b51a6a93c20a1e8c190e681ec377d6e98442f3a4855191c599ad156a79cab8cc6c82fda9c + languageName: node + linkType: hard + "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -4802,6 +4956,30 @@ __metadata: languageName: node linkType: hard +"@jest/pattern@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/pattern@npm:30.5.0" + dependencies: + "@types/node": "npm:*" + jest-regex-util: "npm:30.5.0" + checksum: 10/3a4afb55901cb442ce15d70b46aa59f43c6f39055c92aafd8a3b7982df7a95a9403ed257da2a1585fe5f76df9dd45bdb44994d712994a3c6e6f3ff43e1f37c9b + languageName: node + linkType: hard + +"@jest/react-is-18@npm:react-is@^18.3.1, react-is-18@npm:react-is@^18.3.1": + version: 18.3.1 + resolution: "react-is@npm:18.3.1" + checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 + languageName: node + linkType: hard + +"@jest/react-is-19@npm:react-is@^19.2.5": + version: 19.3.0 + resolution: "react-is@npm:19.3.0" + checksum: 10/69cfafcab48caac03da35fb17e640a2a2de8299a3cd699da720e671a22231fc7179c77c82e379826728857f9cc45cb56ff1e7f5d607acdd48fe151417a2a388f + languageName: node + linkType: hard + "@jest/reporters@npm:30.4.1": version: 30.4.1 resolution: "@jest/reporters@npm:30.4.1" @@ -4838,6 +5016,42 @@ __metadata: languageName: node linkType: hard +"@jest/reporters@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/reporters@npm:30.5.2" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@jridgewell/trace-mapping": "npm:^0.3.31" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + collect-v8-coverage: "npm:^1.0.2" + exit-x: "npm:^0.2.2" + glob: "npm:^13.0.6" + graceful-fs: "npm:^4.2.11" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^5.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:30.5.1" + jest-util: "npm:30.5.1" + jest-worker: "npm:30.5.1" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.2" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/b5221fb70ae0a379fb8a4dcc593d38004655a5d5e6d20fccac5d717b143503af07c39a4f5007878270cde44373046b9857755151fa03efeb0d6859d3ad3dab81 + languageName: node + linkType: hard + "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -4847,6 +5061,15 @@ __metadata: languageName: node linkType: hard +"@jest/schemas@npm:30.5.0": + version: 30.5.0 + resolution: "@jest/schemas@npm:30.5.0" + dependencies: + "@sinclair/typebox": "npm:^0.34.0" + checksum: 10/8b882db2f056f96f4ef82cafca6270cbc0ab3a6201cd76f43214bc17cc3e16df3e5a10ffc03e5667c2381c8141f7dd79f49d03cb85354b38c33c228c832408e5 + languageName: node + linkType: hard + "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -4868,6 +5091,18 @@ __metadata: languageName: node linkType: hard +"@jest/snapshot-utils@npm:30.5.1": + version: 30.5.1 + resolution: "@jest/snapshot-utils@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + natural-compare: "npm:^1.4.0" + checksum: 10/9244bb420a48b707697f5d577628f1ce3f151477687dfa7bf7eec788fac9c8eec260d37873bd1283619ad45508b8ea42e97895b49ab825c14572b93223adb7e0 + languageName: node + linkType: hard + "@jest/source-map@npm:30.0.1": version: 30.0.1 resolution: "@jest/source-map@npm:30.0.1" @@ -4879,6 +5114,18 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/source-map@npm:30.5.2" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.31" + callsites: "npm:^3.1.0" + convert-source-map: "npm:^2.0.0" + graceful-fs: "npm:^4.2.11" + checksum: 10/c0d76d8537cd167d2c7d43b486254dd2172bfd3c939996e2decf3e34c77cfa38536ddd2de6a315a88b9d08066b18c8324e2b19e6301cebc033ddb3a6b1f8bd0b + languageName: node + linkType: hard + "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -4891,6 +5138,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/test-result@npm:30.5.2" + dependencies: + "@jest/console": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + collect-v8-coverage: "npm:^1.0.2" + checksum: 10/a72b61ab6e23bf63d1eacb83ed5d0c756a8eebb41f0e4133098ecb0fc3e336f93019cac20924aa347cb25f92fefa0393d118205b57b18da4925ca1ffd1c7cd8f + languageName: node + linkType: hard + "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -4903,6 +5162,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/test-sequencer@npm:30.5.2" + dependencies: + "@jest/test-result": "npm:30.5.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.5.1" + slash: "npm:^3.0.0" + checksum: 10/f4696a7dcdc5b19e8c1f80701fc5fc3eee8b03ee516a3ccfa0192f73c0f7bd2c36b7c7e7e666afbe7b75a25744423dad7307cb3d5c8a243d438b627efd21afe8 + languageName: node + linkType: hard + "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -4925,6 +5196,28 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:30.5.2": + version: 30.5.2 + resolution: "@jest/transform@npm:30.5.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/types": "npm:30.5.1" + "@jridgewell/trace-mapping": "npm:^0.3.31" + babel-plugin-istanbul: "npm:^8.0.0" + chalk: "npm:^4.1.2" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.5.1" + jest-regex-util: "npm:30.5.0" + jest-util: "npm:30.5.1" + pirates: "npm:^4.0.7" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^5.0.1" + checksum: 10/5b2d6ce6e0c96ddec62be35c7924f6cb6550340508b63299bb0b88bef822bf9ba43e10baba6b956a0102a664060cc06cb5ae294b6b333cd94ea177c8a22b36e7 + languageName: node + linkType: hard + "@jest/types@npm:30.4.1": version: 30.4.1 resolution: "@jest/types@npm:30.4.1" @@ -4940,6 +5233,21 @@ __metadata: languageName: node linkType: hard +"@jest/types@npm:30.5.1": + version: 30.5.1 + resolution: "@jest/types@npm:30.5.1" + dependencies: + "@jest/pattern": "npm:30.5.0" + "@jest/schemas": "npm:30.5.0" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + "@types/istanbul-reports": "npm:^3.0.4" + "@types/node": "npm:*" + "@types/yargs": "npm:^17.0.33" + chalk: "npm:^4.1.2" + checksum: 10/721d25d6a9f92d972feca03ec3b3a7200f7c1d12c30a91956fffc42516b80068291aa1e195f14dab8ffe56d73298edbe42a47e8924a1380ecf8acc0cc7a01e9d + languageName: node + linkType: hard + "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -5011,7 +5319,7 @@ __metadata: languageName: node linkType: hard -"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28": +"@jridgewell/trace-mapping@npm:^0.3.12, @jridgewell/trace-mapping@npm:^0.3.18, @jridgewell/trace-mapping@npm:^0.3.23, @jridgewell/trace-mapping@npm:^0.3.24, @jridgewell/trace-mapping@npm:^0.3.25, @jridgewell/trace-mapping@npm:^0.3.28, @jridgewell/trace-mapping@npm:^0.3.31": version: 0.3.31 resolution: "@jridgewell/trace-mapping@npm:0.3.31" dependencies: @@ -8446,7 +8754,7 @@ __metadata: "@types/jest": "npm:^30.0.0" "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.3.1" - jest: "npm:^30.4.2" + jest: "npm:^30.5.2" rimraf: "npm:^6.1.3" ts-jest: "npm:^29.4.14" tsx: "npm:^4.23.15" @@ -9577,7 +9885,7 @@ __metadata: languageName: node linkType: hard -"@napi-rs/wasm-runtime@npm:^1.1.6": +"@napi-rs/wasm-runtime@npm:^1.1.4, @napi-rs/wasm-runtime@npm:^1.1.6": version: 1.2.4 resolution: "@napi-rs/wasm-runtime@npm:1.2.4" dependencies: @@ -10845,6 +11153,140 @@ __metadata: languageName: node linkType: hard +"@parcel/watcher-android-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-android-arm64@npm:2.6.0" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-arm64@npm:2.6.0" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-darwin-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-darwin-x64@npm:2.6.0" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-freebsd-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-freebsd-x64@npm:2.6.0" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm-musl@npm:2.6.0" + conditions: os=linux & cpu=arm & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-glibc@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-arm64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-arm64-musl@npm:2.6.0" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-glibc@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-glibc@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@parcel/watcher-linux-x64-musl@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-linux-x64-musl@npm:2.6.0" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@parcel/watcher-win32-arm64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-arm64@npm:2.6.0" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@parcel/watcher-win32-x64@npm:2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher-win32-x64@npm:2.6.0" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"@parcel/watcher@npm:^2.6.0": + version: 2.6.0 + resolution: "@parcel/watcher@npm:2.6.0" + dependencies: + "@parcel/watcher-android-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-arm64": "npm:2.6.0" + "@parcel/watcher-darwin-x64": "npm:2.6.0" + "@parcel/watcher-freebsd-x64": "npm:2.6.0" + "@parcel/watcher-linux-arm-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm-musl": "npm:2.6.0" + "@parcel/watcher-linux-arm64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-arm64-musl": "npm:2.6.0" + "@parcel/watcher-linux-x64-glibc": "npm:2.6.0" + "@parcel/watcher-linux-x64-musl": "npm:2.6.0" + "@parcel/watcher-win32-arm64": "npm:2.6.0" + "@parcel/watcher-win32-x64": "npm:2.6.0" + detect-libc: "npm:^2.0.3" + is-glob: "npm:^4.0.3" + node-addon-api: "npm:^7.0.0" + node-gyp: "npm:latest" + picomatch: "npm:^4.0.4" + dependenciesMeta: + "@parcel/watcher-android-arm64": + optional: true + "@parcel/watcher-darwin-arm64": + optional: true + "@parcel/watcher-darwin-x64": + optional: true + "@parcel/watcher-freebsd-x64": + optional: true + "@parcel/watcher-linux-arm-glibc": + optional: true + "@parcel/watcher-linux-arm-musl": + optional: true + "@parcel/watcher-linux-arm64-glibc": + optional: true + "@parcel/watcher-linux-arm64-musl": + optional: true + "@parcel/watcher-linux-x64-glibc": + optional: true + "@parcel/watcher-linux-x64-musl": + optional: true + "@parcel/watcher-win32-arm64": + optional: true + "@parcel/watcher-win32-x64": + optional: true + checksum: 10/0f5f8efcd963aa4c9a2edd8f542cb339075fc1d3dd5f09b1fbfdff82f2e0a6b7da29aa5e600710045eb8d9f0f92923e4ad1b1a38cbdcb455a3a40e5618cb11c3 + languageName: node + linkType: hard + "@peculiar/asn1-cms@npm:^2.6.0, @peculiar/asn1-cms@npm:^2.7.0": version: 2.7.0 resolution: "@peculiar/asn1-cms@npm:2.7.0" @@ -12715,6 +13157,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-android-arm-eabi@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-android-arm-eabi@npm:1.12.2" + conditions: os=android & cpu=arm + languageName: node + linkType: hard + "@unrs/resolver-binding-android-arm64@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-android-arm64@npm:1.11.1" @@ -12722,6 +13171,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-android-arm64@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-android-arm64@npm:1.12.2" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + "@unrs/resolver-binding-darwin-arm64@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.11.1" @@ -12729,6 +13185,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-darwin-arm64@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-darwin-arm64@npm:1.12.2" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + "@unrs/resolver-binding-darwin-x64@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-darwin-x64@npm:1.11.1" @@ -12736,6 +13199,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-darwin-x64@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-darwin-x64@npm:1.12.2" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + "@unrs/resolver-binding-freebsd-x64@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.11.1" @@ -12743,13 +13213,27 @@ __metadata: languageName: node linkType: hard -"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": +"@unrs/resolver-binding-freebsd-x64@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-freebsd-x64@npm:1.12.2" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.11.1" conditions: os=linux & cpu=arm languageName: node linkType: hard +"@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-arm-gnueabihf@npm:1.12.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.11.1" @@ -12757,6 +13241,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-arm-musleabihf@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-arm-musleabihf@npm:1.12.2" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.11.1" @@ -12764,6 +13255,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-arm64-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-arm64-gnu@npm:1.12.2" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.11.1" @@ -12771,6 +13269,27 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-arm64-musl@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-arm64-musl@npm:1.12.2" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-loong64-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-loong64-gnu@npm:1.12.2" + conditions: os=linux & cpu=loong64 & libc=glibc + languageName: node + linkType: hard + +"@unrs/resolver-binding-linux-loong64-musl@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-loong64-musl@npm:1.12.2" + conditions: os=linux & cpu=loong64 & libc=musl + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.11.1" @@ -12778,6 +13297,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-ppc64-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-ppc64-gnu@npm:1.12.2" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.11.1" @@ -12785,6 +13311,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-riscv64-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-riscv64-gnu@npm:1.12.2" + conditions: os=linux & cpu=riscv64 & libc=glibc + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.11.1" @@ -12792,6 +13325,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-riscv64-musl@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-riscv64-musl@npm:1.12.2" + conditions: os=linux & cpu=riscv64 & libc=musl + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.11.1" @@ -12799,6 +13339,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-s390x-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-s390x-gnu@npm:1.12.2" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.11.1" @@ -12806,6 +13353,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-x64-gnu@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-x64-gnu@npm:1.12.2" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.11.1" @@ -12813,6 +13367,20 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-linux-x64-musl@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-linux-x64-musl@npm:1.12.2" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@unrs/resolver-binding-openharmony-arm64@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-openharmony-arm64@npm:1.12.2" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.11.1" @@ -12822,6 +13390,17 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-wasm32-wasi@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-wasm32-wasi@npm:1.12.2" + dependencies: + "@emnapi/core": "npm:1.10.0" + "@emnapi/runtime": "npm:1.10.0" + "@napi-rs/wasm-runtime": "npm:^1.1.4" + conditions: cpu=wasm32 + languageName: node + linkType: hard + "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.11.1" @@ -12829,6 +13408,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-win32-arm64-msvc@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-win32-arm64-msvc@npm:1.12.2" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.11.1" @@ -12836,6 +13422,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-win32-ia32-msvc@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-win32-ia32-msvc@npm:1.12.2" + conditions: os=win32 & cpu=ia32 + languageName: node + linkType: hard + "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1": version: 1.11.1 resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.11.1" @@ -12843,6 +13436,13 @@ __metadata: languageName: node linkType: hard +"@unrs/resolver-binding-win32-x64-msvc@npm:1.12.2": + version: 1.12.2 + resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.12.2" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + "@vercel/stega@npm:^0.1.2": version: 0.1.2 resolution: "@vercel/stega@npm:0.1.2" @@ -14185,6 +14785,23 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:30.5.2": + version: 30.5.2 + resolution: "babel-jest@npm:30.5.2" + dependencies: + "@jest/transform": "npm:30.5.2" + "@types/babel__core": "npm:^7.20.5" + babel-plugin-istanbul: "npm:^8.0.0" + babel-preset-jest: "npm:30.5.0" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.11.0 || ^8.0.0-0 + checksum: 10/52d08ac1a3130e94127e51892e3df8d61df647d0e6e66fdb24ac3c3ad5fb2466bf33d905f034a42056cf61e283874c1ef8c140e4e30728ad2da7ffc80b751473 + languageName: node + linkType: hard + "babel-loader@npm:^9.2.1": version: 9.2.1 resolution: "babel-loader@npm:9.2.1" @@ -14220,6 +14837,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-istanbul@npm:^8.0.0": + version: 8.0.2 + resolution: "babel-plugin-istanbul@npm:8.0.2" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.3" + istanbul-lib-instrument: "npm:^6.0.2" + test-exclude: "npm:^7.0.1" + checksum: 10/b2bd017e4001caca7c82d50a1a832f5d7b692f07eb62feae8fb17af026468bffb918284a3f5bc8d98ec2b2b1f8bb4c253e6bb9a1a07431f26bf308ab5202fb05 + languageName: node + linkType: hard + "babel-plugin-jest-hoist@npm:30.4.0": version: 30.4.0 resolution: "babel-plugin-jest-hoist@npm:30.4.0" @@ -14229,6 +14859,15 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jest-hoist@npm:30.5.0": + version: 30.5.0 + resolution: "babel-plugin-jest-hoist@npm:30.5.0" + dependencies: + "@types/babel__core": "npm:^7.20.5" + checksum: 10/e92ea04044909d0596c46742b2b0df1fc8cb91601768adb83de5b25d948ca88d979023e855499aa52a975411c2fabbaf0601d946f4227548f962a0fac23f7258 + languageName: node + linkType: hard + "babel-plugin-polyfill-corejs2@npm:^0.4.14, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" @@ -14314,6 +14953,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:30.5.0": + version: 30.5.0 + resolution: "babel-preset-jest@npm:30.5.0" + dependencies: + babel-plugin-jest-hoist: "npm:30.5.0" + babel-preset-current-node-syntax: "npm:^1.2.0" + peerDependencies: + "@babel/core": ^7.11.0 || ^8.0.0-beta.1 || ^8.0.0 + checksum: 10/3bbac1f6adf0e56338c8897b17a15846cea81436335aa39de3e2bc3e39cc5483dcc755bdcdd0a6f980bda91bee3e9cce5c0f5e52c2f7084f0d3d3189a60c9834 + languageName: node + linkType: hard + "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2" @@ -15158,6 +15809,13 @@ __metadata: languageName: node linkType: hard +"cjs-module-lexer@npm:^2.2.0": + version: 2.2.1 + resolution: "cjs-module-lexer@npm:2.2.1" + checksum: 10/16601d6fc3a9bb6a5aba4fcc1fd4c194317f94c2bc869a0dfb0f64d01b6ff0c9011b3ba6c89805b3d4de246be371a102d95d0dad5e944a4a23ca87f5381fbd03 + languageName: node + linkType: hard + "clean-css@npm:^5.2.2, clean-css@npm:^5.3.3, clean-css@npm:~5.3.2": version: 5.3.3 resolution: "clean-css@npm:5.3.3" @@ -16289,7 +16947,7 @@ __metadata: languageName: node linkType: hard -"detect-libc@npm:^2.0.0": +"detect-libc@npm:^2.0.0, detect-libc@npm:^2.0.3": version: 2.1.2 resolution: "detect-libc@npm:2.1.2" checksum: 10/b736c8d97d5d46164c0d1bed53eb4e6a3b1d8530d460211e2d52f1c552875e706c58a5376854e4e54f8b828c9cada58c855288c968522eb93ac7696d65970766 @@ -17349,6 +18007,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:30.5.2": + version: 30.5.2 + resolution: "expect@npm:30.5.2" + dependencies: + "@jest/expect-utils": "npm:30.5.2" + "@jest/get-type": "npm:30.5.0" + jest-matcher-utils: "npm:30.5.2" + jest-message-util: "npm:30.5.1" + jest-mock: "npm:30.5.2" + jest-util: "npm:30.5.1" + checksum: 10/92599f21f468172e4be9d6fadc6d373c5d838d1b5fdcc6fc72c9d3a83dafb6c4356bc4142b46622182b9450ab4f2978f19f701f2063ceffc9e02982386d296bf + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -18115,7 +18787,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.5.0": +"glob@npm:^10.2.2, glob@npm:^10.3.10, glob@npm:^10.4.1, glob@npm:^10.5.0": version: 10.5.0 resolution: "glob@npm:10.5.0" dependencies: @@ -18131,7 +18803,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^13.0.0, glob@npm:^13.0.3": +"glob@npm:^13.0.0, glob@npm:^13.0.3, glob@npm:^13.0.6": version: 13.0.6 resolution: "glob@npm:13.0.6" dependencies: @@ -19667,6 +20339,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:30.5.1": + version: 30.5.1 + resolution: "jest-changed-files@npm:30.5.1" + dependencies: + execa: "npm:^5.1.1" + jest-util: "npm:30.5.1" + p-limit: "npm:^3.1.0" + checksum: 10/e39d03d698b8f062c50b1efa06c46ad274202ed8b3a01fa84c712ee70b1698301cb047176e6b5e9c38e07a15237b9af91874c8e30fcd0107c64bd35b7d4738a5 + languageName: node + linkType: hard + "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -19695,6 +20378,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:30.5.2": + version: 30.5.2 + resolution: "jest-circus@npm:30.5.2" + dependencies: + "@jest/environment": "npm:30.5.2" + "@jest/expect": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + co: "npm:^4.6.0" + dedent: "npm:^1.6.0" + is-generator-fn: "npm:^2.1.0" + jest-each: "npm:30.5.2" + jest-matcher-utils: "npm:30.5.2" + jest-message-util: "npm:30.5.1" + jest-runtime: "npm:30.5.2" + jest-snapshot: "npm:30.5.2" + jest-util: "npm:30.5.1" + p-limit: "npm:^3.1.0" + pretty-format: "npm:30.5.1" + pure-rand: "npm:^7.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/c203c9d5a18a70fb58ec402b538a8a47f01a73c050abdbca14fed15b2a3b3fe1d7efe166ec2450fecf032a4e016b371153489a0d8575ac0943e7ffbccf328462 + languageName: node + linkType: hard + "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" @@ -19720,6 +20431,31 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:30.5.2": + version: 30.5.2 + resolution: "jest-cli@npm:30.5.2" + dependencies: + "@jest/core": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + chalk: "npm:^4.1.2" + exit-x: "npm:^0.2.2" + import-local: "npm:^3.2.0" + jest-config: "npm:30.5.2" + jest-util: "npm:30.5.1" + jest-validate: "npm:30.5.1" + yargs: "npm:^17.7.2" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/0ad4941a840534caf0be7c13a1900270463b91c69de0ad25fa62e0f5a6b00dc76481bbcf7432d9219d37eff84a706f7dd86919f00d0e3cc3cfef7a086ba14972 + languageName: node + linkType: hard + "jest-config@npm:30.4.2": version: 30.4.2 resolution: "jest-config@npm:30.4.2" @@ -19762,6 +20498,48 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:30.5.2": + version: 30.5.2 + resolution: "jest-config@npm:30.5.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/get-type": "npm:30.5.0" + "@jest/pattern": "npm:30.5.0" + "@jest/test-sequencer": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + babel-jest: "npm:30.5.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + deepmerge: "npm:^4.3.1" + glob: "npm:^13.0.6" + graceful-fs: "npm:^4.2.11" + jest-circus: "npm:30.5.2" + jest-docblock: "npm:30.5.0" + jest-environment-node: "npm:30.5.2" + jest-regex-util: "npm:30.5.0" + jest-resolve: "npm:30.5.1" + jest-runner: "npm:30.5.2" + jest-util: "npm:30.5.1" + jest-validate: "npm:30.5.1" + parse-json: "npm:^5.2.0" + pretty-format: "npm:30.5.1" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + esbuild-register: ">=3.4.0" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + checksum: 10/3d8f20e6c8d42be7e5ba6df720f34039c8c0d0687b5a96f01c59a618075d295f1da0f36a22c49cf080db1b26c12c8d4618dcb9a51fe7df03692fc6232bc1a22c + languageName: node + linkType: hard + "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -19774,6 +20552,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:30.5.2": + version: 30.5.2 + resolution: "jest-diff@npm:30.5.2" + dependencies: + "@jest/diff-sequences": "npm:30.5.0" + "@jest/get-type": "npm:30.5.0" + chalk: "npm:^4.1.2" + pretty-format: "npm:30.5.1" + checksum: 10/42c2f228f605438e2f6c128290365fce29914508fbb96a8bfda41000a2437d8de9718b77ad97d40e65d0e36ea05a4b06227137f669869e11f2fa08c92df5a4be + languageName: node + linkType: hard + "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -19783,6 +20573,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:30.5.0": + version: 30.5.0 + resolution: "jest-docblock@npm:30.5.0" + dependencies: + detect-newline: "npm:^3.1.0" + checksum: 10/892645b5ac2eb39fa237e59ed31e9bc90e4284f2a705b0ff3aca513caf9db3f9e95c9126b213a6cce67d0a94bb56f1be3b4a06e61f1a0e3b4486aaa5e20b6354 + languageName: node + linkType: hard + "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -19796,6 +20595,20 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:30.5.2": + version: 30.5.2 + resolution: "jest-each@npm:30.5.2" + dependencies: + "@jest/get-type": "npm:30.5.0" + "@jest/types": "npm:30.5.1" + chalk: "npm:^4.1.2" + jest-regex-util: "npm:30.5.0" + jest-util: "npm:30.5.1" + pretty-format: "npm:30.5.1" + checksum: 10/c5f1ee8707950f295709101757a9fd5ee21ac0ff5b827b6e04ce4a39a956a66e4d358487478050266f56ffe9697673fb2b5ae6665c1732435d44ea1f962eec60 + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^30.4.1": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -19827,6 +20640,21 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:30.5.2": + version: 30.5.2 + resolution: "jest-environment-node@npm:30.5.2" + dependencies: + "@jest/environment": "npm:30.5.2" + "@jest/fake-timers": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + jest-mock: "npm:30.5.2" + jest-util: "npm:30.5.1" + jest-validate: "npm:30.5.1" + checksum: 10/6ddf88441fd085c3ea29b0b8dec4fc72974ac403fe114986b47d7831517aae0bded95e92b128c0d4a34bdacfa0101cb63a0c8d15c251937308b8266f1c76a9ba + languageName: node + linkType: hard + "jest-haste-map@npm:30.4.1": version: 30.4.1 resolution: "jest-haste-map@npm:30.4.1" @@ -19849,6 +20677,25 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:30.5.1": + version: 30.5.1 + resolution: "jest-haste-map@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" + "@parcel/watcher": "npm:^2.6.0" + "@types/node": "npm:*" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fdir: "npm:^6.5.0" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.5.0" + jest-util: "npm:30.5.1" + jest-worker: "npm:30.5.1" + picomatch: "npm:^4.0.3" + checksum: 10/0804f1436ca9d561716a16345722673050772d525337f24288950973c225cd5554ff5841c976f16e932edda7d53f5128f9413bac45dd5a7e94d895eed79636d3 + languageName: node + linkType: hard + "jest-leak-detector@npm:30.4.1": version: 30.4.1 resolution: "jest-leak-detector@npm:30.4.1" @@ -19859,6 +20706,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:30.5.1": + version: 30.5.1 + resolution: "jest-leak-detector@npm:30.5.1" + dependencies: + "@jest/get-type": "npm:30.5.0" + pretty-format: "npm:30.5.1" + checksum: 10/1e35b3e30dda79d7583b8c62b487fc4e0a7ca2d25c7e2699a2aee830d6b440af2f059fdb8dda843dec094b90cbb030b0eb571e3d55effa3b14bb4dd550d59f7a + languageName: node + linkType: hard + "jest-matcher-utils@npm:30.4.1": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -19871,6 +20728,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:30.5.2": + version: 30.5.2 + resolution: "jest-matcher-utils@npm:30.5.2" + dependencies: + "@jest/get-type": "npm:30.5.0" + chalk: "npm:^4.1.2" + jest-diff: "npm:30.5.2" + pretty-format: "npm:30.5.1" + checksum: 10/c3a772f30d3cddb8bf32bad82cc87104dcfc853694fcd212fe2e0fadda3bedebb8a99cb8688f780e1118c12275395e90b27fd53f83c74560cc8eb2974e6a2463 + languageName: node + linkType: hard + "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -19889,6 +20758,24 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:30.5.1": + version: 30.5.1 + resolution: "jest-message-util@npm:30.5.1" + dependencies: + "@babel/code-frame": "npm:^7.27.1" + "@jest/types": "npm:30.5.1" + "@types/stack-utils": "npm:^2.0.3" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-util: "npm:30.5.1" + picomatch: "npm:^4.0.3" + pretty-format: "npm:30.5.1" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/bb25344137d71b2e36a24077b0391b78bce07731e70522e2e4f91deb1c0cc08d4e80371985c359e78e91201a7e76936e631aea4b6a0e5271f9fe1dd34f340e9b + languageName: node + linkType: hard + "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -19900,6 +20787,18 @@ __metadata: languageName: node linkType: hard +"jest-mock@npm:30.5.2": + version: 30.5.2 + resolution: "jest-mock@npm:30.5.2" + dependencies: + "@jest/expect-utils": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + jest-util: "npm:30.5.1" + checksum: 10/4a605ae054b317afef942a886285c65afe30fb2de7f883d0fd889bbbe01da2cdd3cebbbdd5b6fba57a3309c80d32105d1418bb4893e6f3ccf4529b61d8bc62d2 + languageName: node + linkType: hard + "jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" @@ -19919,6 +20818,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:30.5.0": + version: 30.5.0 + resolution: "jest-regex-util@npm:30.5.0" + checksum: 10/2236cb0f3bc7f61690500d779afab57fbcb2a35925f416ab32e68e5c1efae6d916efa9c3ee49c94d4d0990e1316e6581a1545209b08715c848b919364e89e66f + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:30.4.2": version: 30.4.2 resolution: "jest-resolve-dependencies@npm:30.4.2" @@ -19929,6 +20835,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:30.5.2": + version: 30.5.2 + resolution: "jest-resolve-dependencies@npm:30.5.2" + dependencies: + jest-regex-util: "npm:30.5.0" + jest-snapshot: "npm:30.5.2" + checksum: 10/cc6fd7cc877bbcc38c4b3fa7ca0eaa07cc6d9356096cd1e9c23a5f2ec1b9b3ad6cdca9833c5f9316162d862fc30140d9ff9e8ffe594f76685bae1d1f105347f1 + languageName: node + linkType: hard + "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -19945,6 +20861,21 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:30.5.1": + version: 30.5.1 + resolution: "jest-resolve@npm:30.5.1" + dependencies: + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.5.1" + jest-util: "npm:30.5.1" + jest-validate: "npm:30.5.1" + slash: "npm:^3.0.0" + unrs-resolver: "npm:^1.12.1" + checksum: 10/00c219e17fee4999569cd61f3971ee367add89d46734b8b18e3cf007c580e7477f2a30dc579201f65e9428971e4583627f8955bf0a0af4b2537b491186bb1323 + languageName: node + linkType: hard + "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -19975,6 +20906,36 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:30.5.2": + version: 30.5.2 + resolution: "jest-runner@npm:30.5.2" + dependencies: + "@jest/console": "npm:30.5.2" + "@jest/environment": "npm:30.5.2" + "@jest/source-map": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-docblock: "npm:30.5.0" + jest-environment-node: "npm:30.5.2" + jest-haste-map: "npm:30.5.1" + jest-leak-detector: "npm:30.5.1" + jest-message-util: "npm:30.5.1" + jest-resolve: "npm:30.5.1" + jest-runtime: "npm:30.5.2" + jest-util: "npm:30.5.1" + jest-watcher: "npm:30.5.2" + jest-worker: "npm:30.5.1" + p-limit: "npm:^3.1.0" + checksum: 10/936c50973724d26daa8eb4152e7a5e853dc60a6ff22f14239b9f53349787a81e9e075232bcecaa8062ae27f0de603dce79894da8095d178538c28bf51da3ed89 + languageName: node + linkType: hard + "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -20005,6 +20966,37 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:30.5.2": + version: 30.5.2 + resolution: "jest-runtime@npm:30.5.2" + dependencies: + "@jest/environment": "npm:30.5.2" + "@jest/fake-timers": "npm:30.5.2" + "@jest/globals": "npm:30.5.2" + "@jest/source-map": "npm:30.5.2" + "@jest/test-result": "npm:30.5.2" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + cjs-module-lexer: "npm:^2.2.0" + collect-v8-coverage: "npm:^1.0.2" + es-module-lexer: "npm:^2.1.0" + glob: "npm:^13.0.6" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.5.1" + jest-message-util: "npm:30.5.1" + jest-mock: "npm:30.5.2" + jest-regex-util: "npm:30.5.0" + jest-resolve: "npm:30.5.1" + jest-snapshot: "npm:30.5.2" + jest-util: "npm:30.5.1" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/d44355c4157bddca99e924a98f48fdc158c07ff1539b2231f2f593fae2a22ce31ae22845b9f5cd2b15a2f5f39870ed7313a99b096420f02b15984de833566287 + languageName: node + linkType: hard + "jest-silent-reporter@npm:^0.6.0": version: 0.6.0 resolution: "jest-silent-reporter@npm:0.6.0" @@ -20044,6 +21036,35 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:30.5.2": + version: 30.5.2 + resolution: "jest-snapshot@npm:30.5.2" + dependencies: + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/types": "npm:^7.27.3" + "@jest/expect-utils": "npm:30.5.2" + "@jest/get-type": "npm:30.5.0" + "@jest/snapshot-utils": "npm:30.5.1" + "@jest/transform": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + babel-preset-current-node-syntax: "npm:^1.2.0" + chalk: "npm:^4.1.2" + expect: "npm:30.5.2" + graceful-fs: "npm:^4.2.11" + jest-diff: "npm:30.5.2" + jest-matcher-utils: "npm:30.5.2" + jest-message-util: "npm:30.5.1" + jest-util: "npm:30.5.1" + pretty-format: "npm:30.5.1" + semver: "npm:^7.7.2" + synckit: "npm:^0.11.8" + checksum: 10/64be725cd1171c940831c30cef6f349a9b5e331d064ee6454607e59da98d2a4141cb66eb5d208346e775f8ca3c199114f8fb1853218b9112a2b37cd7d572a648 + languageName: node + linkType: hard + "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -20058,6 +21079,20 @@ __metadata: languageName: node linkType: hard +"jest-util@npm:30.5.1": + version: 30.5.1 + resolution: "jest-util@npm:30.5.1" + dependencies: + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + graceful-fs: "npm:^4.2.11" + picomatch: "npm:^4.0.3" + checksum: 10/87e7fa2be0c90b1c28ab7747688c02544f7622b628f12f1c9fe5b23e70dd0f04cb2d41cb122d64f16c22427f94a2443d2374d8ea113839c9c33ab9c4555c480c + languageName: node + linkType: hard + "jest-util@npm:^26.0.0": version: 26.6.2 resolution: "jest-util@npm:26.6.2" @@ -20100,6 +21135,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:30.5.1": + version: 30.5.1 + resolution: "jest-validate@npm:30.5.1" + dependencies: + "@jest/get-type": "npm:30.5.0" + "@jest/types": "npm:30.5.1" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" + leven: "npm:^3.1.0" + pretty-format: "npm:30.5.1" + checksum: 10/a78ce1fee1b7f445c394efb283190446b454118b458fd835025b555a79f62cd2f0840ce1619ef0259753382e4dbb534d8107071a978c8d845928abdbb6d599d3 + languageName: node + linkType: hard + "jest-watcher@npm:30.4.1": version: 30.4.1 resolution: "jest-watcher@npm:30.4.1" @@ -20116,6 +21165,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:30.5.2": + version: 30.5.2 + resolution: "jest-watcher@npm:30.5.2" + dependencies: + "@jest/test-result": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + jest-util: "npm:30.5.1" + string-length: "npm:^4.0.2" + checksum: 10/aec43f7cf8db5e640a2a3f63676da77cd55d8b6bbc7cc0b563a03fc6c263bd1ec88eba02f0cdd639dfe4114f516c7c55911984e20570325eb77cc22cdb720871 + languageName: node + linkType: hard + "jest-when@npm:^4.0.3": version: 4.0.3 resolution: "jest-when@npm:4.0.3" @@ -20138,6 +21203,19 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:30.5.1": + version: 30.5.1 + resolution: "jest-worker@npm:30.5.1" + dependencies: + "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.5.1" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.1.1" + checksum: 10/cff8638859f661a7f43ce56e9901ba8c42a3ac909ee3fedd254ede78fd93c034ca1e57f4f74f520d4d663b52cb3432e614362d52dc698e1a09778a235ce1af58 + languageName: node + linkType: hard + "jest-worker@npm:^27.4.5": version: 27.5.1 resolution: "jest-worker@npm:27.5.1" @@ -20180,6 +21258,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^30.5.2": + version: 30.5.2 + resolution: "jest@npm:30.5.2" + dependencies: + "@jest/core": "npm:30.5.2" + "@jest/types": "npm:30.5.1" + import-local: "npm:^3.2.0" + jest-cli: "npm:30.5.2" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/687b2f290e25093c061878a1ca861ea8b6552dd3542c01b04764f84294bfd8697f716abe106a0f8ea3a6fb85c567f569e567e1cd1ca4d0a0f1e54b3291ce67c2 + languageName: node + linkType: hard + "jiti@npm:^1.20.0": version: 1.21.7 resolution: "jiti@npm:1.21.7" @@ -22060,7 +23157,7 @@ __metadata: languageName: node linkType: hard -"napi-postinstall@npm:^0.3.0": +"napi-postinstall@npm:^0.3.0, napi-postinstall@npm:^0.3.4": version: 0.3.4 resolution: "napi-postinstall@npm:0.3.4" bin: @@ -22152,6 +23249,15 @@ __metadata: languageName: node linkType: hard +"node-addon-api@npm:^7.0.0": + version: 7.1.1 + resolution: "node-addon-api@npm:7.1.1" + dependencies: + node-gyp: "npm:latest" + checksum: 10/ee1e1ed6284a2f8cd1d59ac6175ecbabf8978dcf570345e9a8095a9d0a2b9ced591074ae77f9009287b00c402352b38aa9322a34f2199cdc9f567b842a636b94 + languageName: node + linkType: hard + "node-emoji@npm:^2.1.0": version: 2.2.0 resolution: "node-emoji@npm:2.2.0" @@ -24265,6 +25371,18 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:30.5.1": + version: 30.5.1 + resolution: "pretty-format@npm:30.5.1" + dependencies: + "@jest/react-is-18": "npm:react-is@^18.3.1" + "@jest/react-is-19": "npm:react-is@^19.2.5" + "@jest/schemas": "npm:30.5.0" + ansi-styles: "npm:^5.2.0" + checksum: 10/bf86d7eb192ef2a456ded06fbe5d387c92b09dc4473331dbc7130e79128fd90f66c662717df8a52431060c1ddb8288a0a78858e9c203974de1b8d1107054b6e8 + languageName: node + linkType: hard + "pretty-ms@npm:^9.3.0": version: 9.3.1 resolution: "pretty-ms@npm:9.3.1" @@ -24645,13 +25763,6 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1": - version: 18.3.1 - resolution: "react-is@npm:18.3.1" - checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 - languageName: node - linkType: hard - "react-is-19@npm:react-is@^19.2.5": version: 19.2.7 resolution: "react-is@npm:19.2.7" @@ -26568,6 +27679,17 @@ __metadata: languageName: node linkType: hard +"test-exclude@npm:^7.0.1": + version: 7.0.2 + resolution: "test-exclude@npm:7.0.2" + dependencies: + "@istanbuljs/schema": "npm:^0.1.2" + glob: "npm:^10.4.1" + minimatch: "npm:^10.2.2" + checksum: 10/45920af7556a58333c75c3d5011b4087f7ac41366319fb0f6ccc4ac8d0e9d2c81442d7ebf6efd13813dcf18c135eda3c52e4ad3e00407a3ee041d8dec6bfd753 + languageName: node + linkType: hard + "text-decoder@npm:^1.1.0": version: 1.1.1 resolution: "text-decoder@npm:1.1.1" @@ -27283,6 +28405,82 @@ __metadata: languageName: node linkType: hard +"unrs-resolver@npm:^1.12.1": + version: 1.12.2 + resolution: "unrs-resolver@npm:1.12.2" + dependencies: + "@unrs/resolver-binding-android-arm-eabi": "npm:1.12.2" + "@unrs/resolver-binding-android-arm64": "npm:1.12.2" + "@unrs/resolver-binding-darwin-arm64": "npm:1.12.2" + "@unrs/resolver-binding-darwin-x64": "npm:1.12.2" + "@unrs/resolver-binding-freebsd-x64": "npm:1.12.2" + "@unrs/resolver-binding-linux-arm-gnueabihf": "npm:1.12.2" + "@unrs/resolver-binding-linux-arm-musleabihf": "npm:1.12.2" + "@unrs/resolver-binding-linux-arm64-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-arm64-musl": "npm:1.12.2" + "@unrs/resolver-binding-linux-loong64-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-loong64-musl": "npm:1.12.2" + "@unrs/resolver-binding-linux-ppc64-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-riscv64-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-riscv64-musl": "npm:1.12.2" + "@unrs/resolver-binding-linux-s390x-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-x64-gnu": "npm:1.12.2" + "@unrs/resolver-binding-linux-x64-musl": "npm:1.12.2" + "@unrs/resolver-binding-openharmony-arm64": "npm:1.12.2" + "@unrs/resolver-binding-wasm32-wasi": "npm:1.12.2" + "@unrs/resolver-binding-win32-arm64-msvc": "npm:1.12.2" + "@unrs/resolver-binding-win32-ia32-msvc": "npm:1.12.2" + "@unrs/resolver-binding-win32-x64-msvc": "npm:1.12.2" + napi-postinstall: "npm:^0.3.4" + dependenciesMeta: + "@unrs/resolver-binding-android-arm-eabi": + optional: true + "@unrs/resolver-binding-android-arm64": + optional: true + "@unrs/resolver-binding-darwin-arm64": + optional: true + "@unrs/resolver-binding-darwin-x64": + optional: true + "@unrs/resolver-binding-freebsd-x64": + optional: true + "@unrs/resolver-binding-linux-arm-gnueabihf": + optional: true + "@unrs/resolver-binding-linux-arm-musleabihf": + optional: true + "@unrs/resolver-binding-linux-arm64-gnu": + optional: true + "@unrs/resolver-binding-linux-arm64-musl": + optional: true + "@unrs/resolver-binding-linux-loong64-gnu": + optional: true + "@unrs/resolver-binding-linux-loong64-musl": + optional: true + "@unrs/resolver-binding-linux-ppc64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-gnu": + optional: true + "@unrs/resolver-binding-linux-riscv64-musl": + optional: true + "@unrs/resolver-binding-linux-s390x-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-gnu": + optional: true + "@unrs/resolver-binding-linux-x64-musl": + optional: true + "@unrs/resolver-binding-openharmony-arm64": + optional: true + "@unrs/resolver-binding-wasm32-wasi": + optional: true + "@unrs/resolver-binding-win32-arm64-msvc": + optional: true + "@unrs/resolver-binding-win32-ia32-msvc": + optional: true + "@unrs/resolver-binding-win32-x64-msvc": + optional: true + checksum: 10/ef2fc38e7a7c5e0cad9a5fb3a70abead3c8073955b444908f35a55f54ced1c08433324220810b04d9ef5adeff62982ade136793dcde8297505ca3556ee0bbb82 + languageName: node + linkType: hard + "unrs-resolver@npm:^1.7.11": version: 1.11.1 resolution: "unrs-resolver@npm:1.11.1" From 34e5f91108b47920d10c4dd98f0298faf0db6098 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 08:50:10 -0400 Subject: [PATCH 41/73] fix(profile-controller): fix errors --- packages/profile-controller/src/ProfileController.test.ts | 7 +++++-- packages/profile-controller/src/ProfileController.ts | 3 ++- packages/profile-controller/src/index.ts | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 831e182861c..18256f295bd 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -5,7 +5,10 @@ import type { MessengerEvents, } from '@metamask/messenger'; -import type { ProfileControllerMessenger } from './ProfileController.js'; +import type { + MetaMaskProfile, + ProfileControllerMessenger, +} from './ProfileController.js'; import { ProfileController, getDefaultProfileControllerState, @@ -26,7 +29,7 @@ const mockProfileResponse = { updated_at: '2024-01-02T00:00:00Z', }; -const mockMappedProfile = { +const mockMappedProfile: MetaMaskProfile = { profileId: 'profile-123', username: 'alice', displayName: 'Alice Wonderland', diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index a873e57e81e..58e8302b3b8 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -20,6 +20,7 @@ import type { } from './ProfileService-method-action-types.js'; import type { CreateProfileParams, + CreateProfileResponse, ProfileApiResponse, ReplaceProfileParams, UpdateProfileParams, @@ -302,7 +303,7 @@ export class ProfileController extends BaseController< * @returns The created MetaMask profile. */ async createProfile(params: CreateProfileParams): Promise { - const response = await this.messenger.call( + const response: CreateProfileResponse = await this.messenger.call( 'ProfileService:createProfile', params, ); diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index c3aaab9931e..d46c01b2070 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -20,7 +20,7 @@ export type { ProfileControllerGetMetaMaskProfileAction, ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXAuthUrlAction, - ProfileControllerGetXprofileAction, + ProfileControllerGetXProfileAction, ProfileControllerReplaceProfileAction, ProfileControllerUpdateProfileAction, } from './ProfileController-method-action-types.js'; From 0306318cb4400fdf60ecf68df099826b48512258 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 08:58:07 -0400 Subject: [PATCH 42/73] fix: update yarn.lock --- yarn.lock | 206 ------------------------------------------------------ 1 file changed, 206 deletions(-) diff --git a/yarn.lock b/yarn.lock index 1c0b09f21d3..f2784013e8a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4889,15 +4889,6 @@ __metadata: languageName: node linkType: hard -"@jest/schemas@npm:30.5.0": - version: 30.5.0 - resolution: "@jest/schemas@npm:30.5.0" - dependencies: - "@sinclair/typebox": "npm:^0.34.0" - checksum: 10/8b882db2f056f96f4ef82cafca6270cbc0ab3a6201cd76f43214bc17cc3e16df3e5a10ffc03e5667c2381c8141f7dd79f49d03cb85354b38c33c228c832408e5 - languageName: node - linkType: hard - "@jest/schemas@npm:^29.6.3": version: 29.6.3 resolution: "@jest/schemas@npm:29.6.3" @@ -4992,21 +4983,6 @@ __metadata: languageName: node linkType: hard -"@jest/types@npm:30.5.1": - version: 30.5.1 - resolution: "@jest/types@npm:30.5.1" - dependencies: - "@jest/pattern": "npm:30.5.0" - "@jest/schemas": "npm:30.5.0" - "@types/istanbul-lib-coverage": "npm:^2.0.6" - "@types/istanbul-reports": "npm:^3.0.4" - "@types/node": "npm:*" - "@types/yargs": "npm:^17.0.33" - chalk: "npm:^4.1.2" - checksum: 10/721d25d6a9f92d972feca03ec3b3a7200f7c1d12c30a91956fffc42516b80068291aa1e195f14dab8ffe56d73298edbe42a47e8924a1380ecf8acc0cc7a01e9d - languageName: node - linkType: hard - "@jest/types@npm:^26.6.2": version: 26.6.2 resolution: "@jest/types@npm:26.6.2" @@ -13070,13 +13046,6 @@ __metadata: languageName: node linkType: hard -"@unrs/resolver-binding-win32-x64-msvc@npm:1.12.2": - version: 1.12.2 - resolution: "@unrs/resolver-binding-win32-x64-msvc@npm:1.12.2" - conditions: os=win32 & cpu=x64 - languageName: node - linkType: hard - "@vercel/stega@npm:^0.1.2": version: 0.1.2 resolution: "@vercel/stega@npm:0.1.2" @@ -14419,23 +14388,6 @@ __metadata: languageName: node linkType: hard -"babel-jest@npm:30.5.2": - version: 30.5.2 - resolution: "babel-jest@npm:30.5.2" - dependencies: - "@jest/transform": "npm:30.5.2" - "@types/babel__core": "npm:^7.20.5" - babel-plugin-istanbul: "npm:^8.0.0" - babel-preset-jest: "npm:30.5.0" - chalk: "npm:^4.1.2" - graceful-fs: "npm:^4.2.11" - slash: "npm:^3.0.0" - peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-0 - checksum: 10/52d08ac1a3130e94127e51892e3df8d61df647d0e6e66fdb24ac3c3ad5fb2466bf33d905f034a42056cf61e283874c1ef8c140e4e30728ad2da7ffc80b751473 - languageName: node - linkType: hard - "babel-loader@npm:^9.2.1": version: 9.2.1 resolution: "babel-loader@npm:9.2.1" @@ -14480,15 +14432,6 @@ __metadata: languageName: node linkType: hard -"babel-plugin-jest-hoist@npm:30.5.0": - version: 30.5.0 - resolution: "babel-plugin-jest-hoist@npm:30.5.0" - dependencies: - "@types/babel__core": "npm:^7.20.5" - checksum: 10/e92ea04044909d0596c46742b2b0df1fc8cb91601768adb83de5b25d948ca88d979023e855499aa52a975411c2fabbaf0601d946f4227548f962a0fac23f7258 - languageName: node - linkType: hard - "babel-plugin-polyfill-corejs2@npm:^0.4.14, babel-plugin-polyfill-corejs2@npm:^0.4.15": version: 0.4.17 resolution: "babel-plugin-polyfill-corejs2@npm:0.4.17" @@ -14574,18 +14517,6 @@ __metadata: languageName: node linkType: hard -"babel-preset-jest@npm:30.5.0": - version: 30.5.0 - resolution: "babel-preset-jest@npm:30.5.0" - dependencies: - babel-plugin-jest-hoist: "npm:30.5.0" - babel-preset-current-node-syntax: "npm:^1.2.0" - peerDependencies: - "@babel/core": ^7.11.0 || ^8.0.0-beta.1 || ^8.0.0 - checksum: 10/3bbac1f6adf0e56338c8897b17a15846cea81436335aa39de3e2bc3e39cc5483dcc755bdcdd0a6f980bda91bee3e9cce5c0f5e52c2f7084f0d3d3189a60c9834 - languageName: node - linkType: hard - "bail@npm:^2.0.0": version: 2.0.2 resolution: "bail@npm:2.0.2" @@ -15430,13 +15361,6 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^2.2.0": - version: 2.2.1 - resolution: "cjs-module-lexer@npm:2.2.1" - checksum: 10/16601d6fc3a9bb6a5aba4fcc1fd4c194317f94c2bc869a0dfb0f64d01b6ff0c9011b3ba6c89805b3d4de246be371a102d95d0dad5e944a4a23ca87f5381fbd03 - languageName: node - linkType: hard - "clean-css@npm:^5.2.2, clean-css@npm:^5.3.3, clean-css@npm:~5.3.2": version: 5.3.3 resolution: "clean-css@npm:5.3.3" @@ -17628,20 +17552,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:30.5.2": - version: 30.5.2 - resolution: "expect@npm:30.5.2" - dependencies: - "@jest/expect-utils": "npm:30.5.2" - "@jest/get-type": "npm:30.5.0" - jest-matcher-utils: "npm:30.5.2" - jest-message-util: "npm:30.5.1" - jest-mock: "npm:30.5.2" - jest-util: "npm:30.5.1" - checksum: 10/92599f21f468172e4be9d6fadc6d373c5d838d1b5fdcc6fc72c9d3a83dafb6c4356bc4142b46622182b9450ab4f2978f19f701f2063ceffc9e02982386d296bf - languageName: node - linkType: hard - "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -20286,37 +20196,6 @@ __metadata: languageName: node linkType: hard -"jest-runtime@npm:30.5.2": - version: 30.5.2 - resolution: "jest-runtime@npm:30.5.2" - dependencies: - "@jest/environment": "npm:30.5.2" - "@jest/fake-timers": "npm:30.5.2" - "@jest/globals": "npm:30.5.2" - "@jest/source-map": "npm:30.5.2" - "@jest/test-result": "npm:30.5.2" - "@jest/transform": "npm:30.5.2" - "@jest/types": "npm:30.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - cjs-module-lexer: "npm:^2.2.0" - collect-v8-coverage: "npm:^1.0.2" - es-module-lexer: "npm:^2.1.0" - glob: "npm:^13.0.6" - graceful-fs: "npm:^4.2.11" - jest-haste-map: "npm:30.5.1" - jest-message-util: "npm:30.5.1" - jest-mock: "npm:30.5.2" - jest-regex-util: "npm:30.5.0" - jest-resolve: "npm:30.5.1" - jest-snapshot: "npm:30.5.2" - jest-util: "npm:30.5.1" - slash: "npm:^3.0.0" - strip-bom: "npm:^4.0.0" - checksum: 10/d44355c4157bddca99e924a98f48fdc158c07ff1539b2231f2f593fae2a22ce31ae22845b9f5cd2b15a2f5f39870ed7313a99b096420f02b15984de833566287 - languageName: node - linkType: hard - "jest-silent-reporter@npm:^0.6.0": version: 0.6.0 resolution: "jest-silent-reporter@npm:0.6.0" @@ -20370,20 +20249,6 @@ __metadata: languageName: node linkType: hard -"jest-util@npm:30.5.1": - version: 30.5.1 - resolution: "jest-util@npm:30.5.1" - dependencies: - "@jest/types": "npm:30.5.1" - "@types/node": "npm:*" - chalk: "npm:^4.1.2" - ci-info: "npm:^4.2.0" - graceful-fs: "npm:^4.2.11" - picomatch: "npm:^4.0.3" - checksum: 10/87e7fa2be0c90b1c28ab7747688c02544f7622b628f12f1c9fe5b23e70dd0f04cb2d41cb122d64f16c22427f94a2443d2374d8ea113839c9c33ab9c4555c480c - languageName: node - linkType: hard - "jest-util@npm:^26.0.0": version: 26.6.2 resolution: "jest-util@npm:26.6.2" @@ -20442,22 +20307,6 @@ __metadata: languageName: node linkType: hard -"jest-watcher@npm:30.5.2": - version: 30.5.2 - resolution: "jest-watcher@npm:30.5.2" - dependencies: - "@jest/test-result": "npm:30.5.2" - "@jest/types": "npm:30.5.1" - "@types/node": "npm:*" - ansi-escapes: "npm:^4.3.2" - chalk: "npm:^4.1.2" - emittery: "npm:^0.13.1" - jest-util: "npm:30.5.1" - string-length: "npm:^4.0.2" - checksum: 10/aec43f7cf8db5e640a2a3f63676da77cd55d8b6bbc7cc0b563a03fc6c263bd1ec88eba02f0cdd639dfe4114f516c7c55911984e20570325eb77cc22cdb720871 - languageName: node - linkType: hard - "jest-when@npm:^4.0.3": version: 4.0.3 resolution: "jest-when@npm:4.0.3" @@ -20480,19 +20329,6 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:30.5.1": - version: 30.5.1 - resolution: "jest-worker@npm:30.5.1" - dependencies: - "@types/node": "npm:*" - "@ungap/structured-clone": "npm:^1.3.0" - jest-util: "npm:30.5.1" - merge-stream: "npm:^2.0.0" - supports-color: "npm:^8.1.1" - checksum: 10/cff8638859f661a7f43ce56e9901ba8c42a3ac909ee3fedd254ede78fd93c034ca1e57f4f74f520d4d663b52cb3432e614362d52dc698e1a09778a235ce1af58 - languageName: node - linkType: hard - "jest-worker@npm:^27.4.5": version: 27.5.1 resolution: "jest-worker@npm:27.5.1" @@ -20535,25 +20371,6 @@ __metadata: languageName: node linkType: hard -"jest@npm:^30.5.2": - version: 30.5.2 - resolution: "jest@npm:30.5.2" - dependencies: - "@jest/core": "npm:30.5.2" - "@jest/types": "npm:30.5.1" - import-local: "npm:^3.2.0" - jest-cli: "npm:30.5.2" - peerDependencies: - node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 - peerDependenciesMeta: - node-notifier: - optional: true - bin: - jest: bin/jest.js - checksum: 10/687b2f290e25093c061878a1ca861ea8b6552dd3542c01b04764f84294bfd8697f716abe106a0f8ea3a6fb85c567f569e567e1cd1ca4d0a0f1e54b3291ce67c2 - languageName: node - linkType: hard - "jiti@npm:^1.20.0": version: 1.21.7 resolution: "jiti@npm:1.21.7" @@ -24639,18 +24456,6 @@ __metadata: languageName: node linkType: hard -"pretty-format@npm:30.5.1": - version: 30.5.1 - resolution: "pretty-format@npm:30.5.1" - dependencies: - "@jest/react-is-18": "npm:react-is@^18.3.1" - "@jest/react-is-19": "npm:react-is@^19.2.5" - "@jest/schemas": "npm:30.5.0" - ansi-styles: "npm:^5.2.0" - checksum: 10/bf86d7eb192ef2a456ded06fbe5d387c92b09dc4473331dbc7130e79128fd90f66c662717df8a52431060c1ddb8288a0a78858e9c203974de1b8d1107054b6e8 - languageName: node - linkType: hard - "pretty-ms@npm:^9.3.0": version: 9.3.1 resolution: "pretty-ms@npm:9.3.1" @@ -26930,17 +26735,6 @@ __metadata: languageName: node linkType: hard -"test-exclude@npm:^7.0.1": - version: 7.0.2 - resolution: "test-exclude@npm:7.0.2" - dependencies: - "@istanbuljs/schema": "npm:^0.1.2" - glob: "npm:^10.4.1" - minimatch: "npm:^10.2.2" - checksum: 10/45920af7556a58333c75c3d5011b4087f7ac41366319fb0f6ccc4ac8d0e9d2c81442d7ebf6efd13813dcf18c135eda3c52e4ad3e00407a3ee041d8dec6bfd753 - languageName: node - linkType: hard - "text-decoder@npm:^1.1.0": version: 1.1.1 resolution: "text-decoder@npm:1.1.1" From dc2bec6193cd6b6d44c969d31a7a8b51ae3d63fd Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 09:07:24 -0400 Subject: [PATCH 43/73] fix(profile-controller): correct lint:tsconfigs script extension --- packages/profile-controller/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 890c17feee4..18c5b05be60 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -39,8 +39,8 @@ "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": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts", - "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.mts --fix", + "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", + "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", "since-latest-release": "../../scripts/since-latest-release.sh", From b365183c15015b40fcffd1adbbf25e63bb01b596 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 09:16:14 -0400 Subject: [PATCH 44/73] fix(profile-controller): add --esm flag to messenger-action-types scripts --- packages/profile-controller/package.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 18c5b05be60..2111bc2e67f 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -41,8 +41,8 @@ "changelog:validate": "../../scripts/validate-changelog.sh @metamask/profile-controller", "lint:tsconfigs": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --generate", + "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", + "messenger-action-types:generate": "tsx ../../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", From ec421d13a904db1b32ee63b916f3fdcf35c146a6 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:06:04 -0400 Subject: [PATCH 45/73] refactor(profile-controller): apply code review --- .../src/ProfileController.test.ts | 40 ++-- .../src/ProfileController.ts | 33 ++- .../src/ProfileService.test.ts | 4 +- .../profile-controller/src/ProfileService.ts | 210 ++++++++---------- packages/profile-controller/src/index.ts | 2 +- 5 files changed, 127 insertions(+), 162 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 18256f295bd..532e2494a6f 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -6,7 +6,7 @@ import type { } from '@metamask/messenger'; import type { - MetaMaskProfile, + Profile, ProfileControllerMessenger, } from './ProfileController.js'; import { @@ -29,7 +29,7 @@ const mockProfileResponse = { updated_at: '2024-01-02T00:00:00Z', }; -const mockMappedProfile: MetaMaskProfile = { +const mockMappedProfile: Profile = { profileId: 'profile-123', username: 'alice', displayName: 'Alice Wonderland', @@ -135,7 +135,7 @@ describe('ProfileController', () => { describe('getDefaultProfileControllerState', () => { it('returns empty default state', () => { expect(getDefaultProfileControllerState()).toStrictEqual({ - metamaskProfile: { + profile: { profileId: '', username: '', displayName: '', @@ -163,14 +163,14 @@ describe('ProfileController', () => { it('merges partial initial state with defaults', () => { const { controller } = createController({ state: { - metamaskProfile: { - ...getDefaultProfileControllerState().metamaskProfile, + profile: { + ...getDefaultProfileControllerState().profile, username: 'alice', }, }, }); - expect(controller.state.metamaskProfile.username).toBe('alice'); + expect(controller.state.profile.username).toBe('alice'); }); }); @@ -243,7 +243,7 @@ describe('ProfileController', () => { await controller.createProfile(input); expect(createProfileMock).toHaveBeenCalledWith(input); - expect(controller.state.metamaskProfile).toStrictEqual(mockMappedProfile); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); }); it('maps null bio to empty string', async () => { @@ -261,7 +261,7 @@ describe('ProfileController', () => { display_name: 'Alice Wonderland', }); - expect(controller.state.metamaskProfile.bio).toBe(''); + expect(controller.state.profile.bio).toBe(''); }); it('maps null avatar_url to empty string', async () => { @@ -281,7 +281,7 @@ describe('ProfileController', () => { display_name: 'Alice Wonderland', }); - expect(controller.state.metamaskProfile.avatarUrl).toBe(''); + expect(controller.state.profile.avatarUrl).toBe(''); }); it('maps connected_to_x from response', async () => { @@ -301,7 +301,7 @@ describe('ProfileController', () => { display_name: 'Alice Wonderland', }); - expect(controller.state.metamaskProfile.connectedToX).toBe(true); + expect(controller.state.profile.connectedToX).toBe(true); }); it('also updates xProfile in state when x_profile is included in the response', async () => { @@ -341,7 +341,7 @@ describe('ProfileController', () => { display_name: 'Alice Wonderland', }); - expect(controller.state.metamaskProfile).toStrictEqual(mockMappedProfile); + expect(controller.state.profile).toStrictEqual(mockMappedProfile); }); }); @@ -365,8 +365,8 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger, state: { - metamaskProfile: { - ...getDefaultProfileControllerState().metamaskProfile, + profile: { + ...getDefaultProfileControllerState().profile, profileId: 'profile-123', }, }, @@ -374,7 +374,7 @@ describe('ProfileController', () => { await controller.replaceProfile(input); expect(replaceProfileMock).toHaveBeenCalledWith('profile-123', input); - expect(controller.state.metamaskProfile.username).toBe('alice2'); + expect(controller.state.profile.username).toBe('alice2'); }); it('throws if no profile is set in state', async () => { @@ -405,8 +405,8 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger, state: { - metamaskProfile: { - ...getDefaultProfileControllerState().metamaskProfile, + profile: { + ...getDefaultProfileControllerState().profile, profileId: 'profile-123', }, }, @@ -416,7 +416,7 @@ describe('ProfileController', () => { expect(updateProfileMock).toHaveBeenCalledWith('profile-123', { username: 'alice2', }); - expect(controller.state.metamaskProfile.username).toBe('alice2'); + expect(controller.state.profile.username).toBe('alice2'); }); it('throws if no profile is set in state', async () => { @@ -445,7 +445,7 @@ describe('ProfileController', () => { const { controller } = createController({ rootMessenger, - state: { metamaskProfile: mockMappedProfile }, + state: { profile: mockMappedProfile }, }); await controller.fetchAndUpdateXAccount(); expect(controller.state.xProfile).toStrictEqual(mockMappedXProfile); @@ -453,8 +453,8 @@ describe('ProfileController', () => { await controller.deleteProfile(); expect(deleteProfileMock).toHaveBeenCalledWith('profile-123'); - expect(controller.state.metamaskProfile).toStrictEqual( - getDefaultProfileControllerState().metamaskProfile, + expect(controller.state.profile).toStrictEqual( + getDefaultProfileControllerState().profile, ); expect(controller.state.xProfile).toBeUndefined(); }); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 58e8302b3b8..77750fa6d5d 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -33,8 +33,8 @@ const controllerName = 'ProfileController'; // === TYPES === -/** Representation of a MetaMask profile stored in controller state. */ -export type MetaMaskProfile = { +/** 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. */ @@ -77,7 +77,7 @@ export type XProfile = { /** State managed by ProfileController. */ export type ProfileControllerState = { - metamaskProfile: MetaMaskProfile; + profile: Profile; xProfile?: XProfile; }; @@ -125,14 +125,14 @@ export type ProfileControllerMessenger = Messenger< // === STATE === const profileControllerMetadata = { - metamaskProfile: { + profile: { includeInStateLogs: true, persist: true, includeInDebugSnapshot: false, usedInUi: true, }, xProfile: { - includeInStateLogs: true, + includeInStateLogs: false, persist: true, includeInDebugSnapshot: false, usedInUi: true, @@ -146,7 +146,7 @@ const profileControllerMetadata = { */ export function getDefaultProfileControllerState(): ProfileControllerState { return { - metamaskProfile: { + profile: { profileId: '', username: '', displayName: '', @@ -218,7 +218,7 @@ export class ProfileController extends BaseController< * @returns True if a profile exists, false otherwise. */ #hasProfile(): boolean { - return this.state.metamaskProfile.profileId !== ''; + return this.state.profile.profileId !== ''; } /** @@ -228,7 +228,7 @@ export class ProfileController extends BaseController< * @throws If no profile has been created. */ #getProfileIdOrThrow(): string { - const { profileId } = this.state.metamaskProfile; + const { profileId } = this.state.profile; if (!profileId) { throw new Error('ProfileController: no profile found in state'); } @@ -241,7 +241,7 @@ export class ProfileController extends BaseController< * @param response - The API response to map. * @returns The mapped MetaMask profile. */ - #mapApiResponseToProfile(response: ProfileApiResponse): MetaMaskProfile { + #mapApiResponseToProfile(response: ProfileApiResponse): Profile { return { profileId: response.profile_id, username: response.username, @@ -279,11 +279,11 @@ export class ProfileController extends BaseController< * * @returns The MetaMask profile, or undefined. */ - getMetaMaskProfile(): MetaMaskProfile | undefined { + getMetaMaskProfile(): Profile | undefined { if (!this.#hasProfile()) { return undefined; } - return this.state.metamaskProfile; + return this.state.profile; } /** @@ -302,14 +302,14 @@ export class ProfileController extends BaseController< * @param params - The profile creation parameters. * @returns The created MetaMask profile. */ - async createProfile(params: CreateProfileParams): Promise { + async createProfile(params: CreateProfileParams): Promise { const response: CreateProfileResponse = await this.messenger.call( 'ProfileService:createProfile', params, ); const mapped = this.#mapApiResponseToProfile(response); this.update((state) => { - state.metamaskProfile = mapped; + state.profile = mapped; if (response.x_profile) { state.xProfile = this.#mapXResponseToXProfile(response.x_profile); } @@ -331,7 +331,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.profile = this.#mapApiResponseToProfile(response); }); } @@ -349,7 +349,7 @@ export class ProfileController extends BaseController< input, ); this.update((state) => { - state.metamaskProfile = this.#mapApiResponseToProfile(response); + state.profile = this.#mapApiResponseToProfile(response); }); } @@ -362,8 +362,7 @@ export class ProfileController extends BaseController< const profileId = this.#getProfileIdOrThrow(); await this.messenger.call('ProfileService:deleteProfile', profileId); this.update((state) => { - state.metamaskProfile = - getDefaultProfileControllerState().metamaskProfile; + state.profile = getDefaultProfileControllerState().profile; state.xProfile = undefined; }); } diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 723924ff86c..6529eae8795 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -361,7 +361,7 @@ describe('ProfileService', () => { expect(result).toStrictEqual(mockAvailabilityResponse); expect(mockFetch).toHaveBeenCalledWith( - `${V1_URL}/profiles/username/alice/availability`, + `${V1_URL}/profiles/username/availability?username=alice`, { headers: { Authorization: `Bearer ${MOCK_TOKEN}` } }, ); }); @@ -377,7 +377,7 @@ describe('ProfileService', () => { await service.checkUsernameAvailability('alice smith'); expect(mockFetch).toHaveBeenCalledWith( - `${V1_URL}/profiles/username/alice%20smith/availability`, + `${V1_URL}/profiles/username/availability?username=alice%20smith`, expect.anything(), ); }); diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 4adecd194be..b76fbd71d6b 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -47,6 +47,8 @@ export const ProfileServiceErrorMessage = { // Superstruct schemas // --------------------------------------------------------------------------- +const TradingPrivacyStruct = enums(['public', 'private'] as const); + const ProfileApiResponseStruct = structType({ profile_id: string(), username: string(), @@ -54,7 +56,7 @@ const ProfileApiResponseStruct = structType({ bio: nullable(string()), linked_addresses: array(string()), avatar_url: nullable(string()), - trading_privacy: enums(['public', 'private'] as const), + trading_privacy: TradingPrivacyStruct, connected_to_x: boolean(), created_at: string(), updated_at: string(), @@ -113,7 +115,7 @@ const ReplaceProfileParamsStruct = structType({ bio: optional(nullable(string())), linked_addresses: array(string()), avatar_url: optional(string()), - trading_privacy: optional(enums(['public', 'private'] as const)), + trading_privacy: optional(TradingPrivacyStruct), }); const UpdateProfileParamsStruct = structType({ @@ -122,7 +124,7 @@ const UpdateProfileParamsStruct = structType({ bio: optional(nullable(string())), linked_addresses: optional(array(string())), avatar_url: optional(string()), - trading_privacy: optional(enums(['public', 'private'] as const)), + trading_privacy: optional(TradingPrivacyStruct), }); // --------------------------------------------------------------------------- @@ -249,19 +251,6 @@ export class ProfileService extends BaseDataService< ); } - /** - * Throws an HttpError if the response is not a 2xx status code. - * - * @param response - The response to check. - * @param message - The message to include in the error. - * @throws {HttpError} If the response is not a 2xx status code. - */ - #throwIfNotOk(response: Response, message: string): void { - if (!response.ok) { - throw new HttpError(response.status, `${message}: ${response.status}`); - } - } - /** * Gets the authentication headers for the request. * @@ -274,6 +263,45 @@ export class ProfileService extends BaseDataService< 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, + { + method = 'GET', + error, + json, + }: { + method?: string; + error: string; + json?: unknown; + }, + ): Promise { + const authHeaders = await this.#getAuthHeaders(); + const url = new URL(`${this.#v1Url}/${endpoint}`); + 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}`); + } + return response.json() as unknown; + } + /** * Fetches a profile by its identifier. * @@ -283,22 +311,13 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If the response does not match the expected shape. */ async getProfile(profileId: string): Promise { - assert(profileId, string()); return this.fetchQuery({ queryKey: [`${this.name}:getProfile`, profileId], responseStruct: ProfileApiResponseStruct, - queryFn: async () => { - const authHeaders = await this.#getAuthHeaders(); - const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, - ); - const response = await fetch(url.toString(), { headers: authHeaders }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.GET_PROFILE_FAILED, - ); - return response.json() as Promise; - }, + queryFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + error: ProfileServiceErrorMessage.GET_PROFILE_FAILED, + }), }); } @@ -314,23 +333,15 @@ export class ProfileService extends BaseDataService< params: CreateProfileParams, ): Promise { assert(params, CreateProfileParamsStruct); - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:createProfile`], responseStruct: CreateProfileResponseStruct, - mutationFn: async () => { - const url = new URL(`${this.#v1Url}/profiles`); - const response = await fetch(url.toString(), { + mutationFn: async () => + this.#fetch('profiles', { method: 'POST', - headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, - ); - return response.json() as Promise; - }, + error: ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, + json: params, + }), }); } @@ -349,25 +360,15 @@ export class ProfileService extends BaseDataService< ): Promise { assert(profileId, string()); assert(params, ReplaceProfileParamsStruct); - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:replaceProfile`, profileId], responseStruct: ProfileApiResponseStruct, - mutationFn: async () => { - const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, - ); - const response = await fetch(url.toString(), { + mutationFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'PUT', - headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, - ); - return response.json() as Promise; - }, + error: ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, + json: params, + }), }); } @@ -386,25 +387,15 @@ export class ProfileService extends BaseDataService< ): Promise { assert(profileId, string()); assert(params, UpdateProfileParamsStruct); - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:updateProfile`, profileId], responseStruct: ProfileApiResponseStruct, - mutationFn: async () => { - const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, - ); - const response = await fetch(url.toString(), { + mutationFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'PATCH', - headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, - ); - return response.json() as Promise; - }, + error: ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, + json: params, + }), }); } @@ -418,10 +409,10 @@ export class ProfileService extends BaseDataService< */ async deleteProfile(profileId: string): Promise { assert(profileId, string()); - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:deleteProfile`, profileId], mutationFn: async () => { + const authHeaders = await this.#getAuthHeaders(); const url = new URL( `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, ); @@ -429,10 +420,12 @@ export class ProfileService extends BaseDataService< method: 'DELETE', headers: authHeaders, }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, - ); + if (!response.ok) { + throw new HttpError( + response.status, + `${ProfileServiceErrorMessage.DELETE_PROFILE_FAILED}: ${response.status}`, + ); + } return null; }, }); @@ -454,18 +447,11 @@ export class ProfileService extends BaseDataService< queryKey: [`${this.name}:checkUsernameAvailability`, username], staleTime: 0, responseStruct: UsernameAvailabilityResponseStruct, - queryFn: async () => { - const authHeaders = await this.#getAuthHeaders(); - const url = new URL( - `${this.#v1Url}/profiles/username/${encodeURIComponent(username)}/availability`, - ); - const response = await fetch(url.toString(), { headers: authHeaders }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, - ); - return response.json() as Promise; - }, + queryFn: async () => + this.#fetch( + `profiles/username/availability?username=${encodeURIComponent(username)}`, + { error: ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED }, + ), }); } @@ -477,19 +463,13 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If the response does not match the expected shape. */ async getXAuthUrl(): Promise { - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:getXAuthUrl`], responseStruct: XAuthUrlResponseStruct, - mutationFn: async () => { - const url = new URL(`${this.#v1Url}/profiles/x/authentication-url`); - const response = await fetch(url.toString(), { headers: authHeaders }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, - ); - return response.json() as Promise; - }, + mutationFn: async () => + this.#fetch('profiles/x/authentication-url', { + error: ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, + }), }); } @@ -503,23 +483,15 @@ export class ProfileService extends BaseDataService< */ async connectX(params: ConnectXParams): Promise { assert(params, ConnectXParamsStruct); - const authHeaders = await this.#getAuthHeaders(); return this.executeMutation({ mutationKey: [`${this.name}:connectX`], responseStruct: XConnectResponseStruct, - mutationFn: async () => { - const url = new URL(`${this.#v1Url}/profiles/x/connect`); - const response = await fetch(url.toString(), { + mutationFn: async () => + this.#fetch('profiles/x/connect', { method: 'POST', - headers: { ...authHeaders, 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.CONNECT_X_FAILED, - ); - return response.json() as Promise; - }, + error: ProfileServiceErrorMessage.CONNECT_X_FAILED, + json: params, + }), }); } @@ -535,16 +507,10 @@ export class ProfileService extends BaseDataService< queryKey: [`${this.name}:getXAccount`], staleTime: 0, responseStruct: XConnectResponseStruct, - queryFn: async () => { - const authHeaders = await this.#getAuthHeaders(); - const url = new URL(`${this.#v1Url}/profiles/x/account`); - const response = await fetch(url.toString(), { headers: authHeaders }); - this.#throwIfNotOk( - response, - ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED, - ); - return response.json() as Promise; - }, + 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 index d46c01b2070..90aa8bc391e 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -1,5 +1,5 @@ export type { - MetaMaskProfile, + Profile, XProfile, ProfileControllerState, ProfileControllerGetStateAction, From 2464e367272e9cd70eead2db041939ade5e5bb9e Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:17:29 -0400 Subject: [PATCH 46/73] fix(profile-controller): remove negated conditions --- packages/profile-controller/src/ProfileService.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index b76fbd71d6b..4979958f6fb 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -289,12 +289,12 @@ export class ProfileService extends BaseDataService< const authHeaders = await this.#getAuthHeaders(); const url = new URL(`${this.#v1Url}/${endpoint}`); const response = await fetch(url.toString(), { - ...(method !== 'GET' ? { method } : {}), + ...(method === 'GET' ? {} : { method }), headers: { ...authHeaders, - ...(json !== undefined ? { 'Content-Type': 'application/json' } : {}), + ...(json === undefined ? {} : { 'Content-Type': 'application/json' }), }, - ...(json !== undefined ? { body: JSON.stringify(json) } : {}), + ...(json === undefined ? {} : { body: JSON.stringify(json) }), }); if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); @@ -450,7 +450,10 @@ export class ProfileService extends BaseDataService< queryFn: async () => this.#fetch( `profiles/username/availability?username=${encodeURIComponent(username)}`, - { error: ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED }, + { + error: + ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, + }, ), }); } From 6ebdcf77e71944ccf7153f393dfc49b165ded717 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:21:32 -0400 Subject: [PATCH 47/73] fix(profile-controller): make #fetch generic --- .../profile-controller/src/ProfileService.ts | 23 ++++++++++--------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 4979958f6fb..f93a0f57cd4 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -20,6 +20,7 @@ import { type as structType, } from '@metamask/superstruct'; import type { Infer } from '@metamask/superstruct'; +import type { Json } from '@metamask/utils'; import type { ProfileServiceMethodActions } from './ProfileService-method-action-types.js'; @@ -274,7 +275,7 @@ export class ProfileService extends BaseDataService< * @returns The parsed JSON response. * @throws {HttpError} If the response is not a 2xx status code. */ - async #fetch( + async #fetch( endpoint: string, { method = 'GET', @@ -285,7 +286,7 @@ export class ProfileService extends BaseDataService< error: string; json?: unknown; }, - ): Promise { + ): Promise { const authHeaders = await this.#getAuthHeaders(); const url = new URL(`${this.#v1Url}/${endpoint}`); const response = await fetch(url.toString(), { @@ -299,7 +300,7 @@ export class ProfileService extends BaseDataService< if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); } - return response.json() as unknown; + return response.json() as T; } /** @@ -315,7 +316,7 @@ export class ProfileService extends BaseDataService< queryKey: [`${this.name}:getProfile`, profileId], responseStruct: ProfileApiResponseStruct, queryFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { error: ProfileServiceErrorMessage.GET_PROFILE_FAILED, }), }); @@ -337,7 +338,7 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:createProfile`], responseStruct: CreateProfileResponseStruct, mutationFn: async () => - this.#fetch('profiles', { + this.#fetch('profiles', { method: 'POST', error: ProfileServiceErrorMessage.CREATE_PROFILE_FAILED, json: params, @@ -364,7 +365,7 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:replaceProfile`, profileId], responseStruct: ProfileApiResponseStruct, mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'PUT', error: ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, json: params, @@ -391,7 +392,7 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:updateProfile`, profileId], responseStruct: ProfileApiResponseStruct, mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'PATCH', error: ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, json: params, @@ -448,7 +449,7 @@ export class ProfileService extends BaseDataService< staleTime: 0, responseStruct: UsernameAvailabilityResponseStruct, queryFn: async () => - this.#fetch( + this.#fetch( `profiles/username/availability?username=${encodeURIComponent(username)}`, { error: @@ -470,7 +471,7 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:getXAuthUrl`], responseStruct: XAuthUrlResponseStruct, mutationFn: async () => - this.#fetch('profiles/x/authentication-url', { + this.#fetch('profiles/x/authentication-url', { error: ProfileServiceErrorMessage.GET_X_AUTH_URL_FAILED, }), }); @@ -490,7 +491,7 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:connectX`], responseStruct: XConnectResponseStruct, mutationFn: async () => - this.#fetch('profiles/x/connect', { + this.#fetch('profiles/x/connect', { method: 'POST', error: ProfileServiceErrorMessage.CONNECT_X_FAILED, json: params, @@ -511,7 +512,7 @@ export class ProfileService extends BaseDataService< staleTime: 0, responseStruct: XConnectResponseStruct, queryFn: async () => - this.#fetch('profiles/x/account', { + this.#fetch('profiles/x/account', { error: ProfileServiceErrorMessage.GET_X_ACCOUNT_FAILED, }), }); From f2f8406757041cb76592daa47d97914640f115fb Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:27:58 -0400 Subject: [PATCH 48/73] fix(profile-controller): fix cast --- packages/profile-controller/src/ProfileService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index f93a0f57cd4..93fa5949edb 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -300,7 +300,7 @@ export class ProfileService extends BaseDataService< if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); } - return response.json() as T; + return (await response.json()) as T; } /** From 225a4835c4e254712d0041ea03883ad391a1c15a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:31:34 -0400 Subject: [PATCH 49/73] fix(profile-controller): lint fix --- .../profile-controller/src/ProfileService.ts | 41 +++++++++++-------- 1 file changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 93fa5949edb..1b641f3561b 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -275,7 +275,7 @@ export class ProfileService extends BaseDataService< * @returns The parsed JSON response. * @throws {HttpError} If the response is not a 2xx status code. */ - async #fetch( + async #fetch( endpoint: string, { method = 'GET', @@ -286,7 +286,7 @@ export class ProfileService extends BaseDataService< error: string; json?: unknown; }, - ): Promise { + ): Promise { const authHeaders = await this.#getAuthHeaders(); const url = new URL(`${this.#v1Url}/${endpoint}`); const response = await fetch(url.toString(), { @@ -300,7 +300,7 @@ export class ProfileService extends BaseDataService< if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); } - return (await response.json()) as T; + return (await response.json()) as ResponseType; } /** @@ -316,9 +316,12 @@ export class ProfileService extends BaseDataService< queryKey: [`${this.name}:getProfile`, profileId], responseStruct: ProfileApiResponseStruct, queryFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { - error: ProfileServiceErrorMessage.GET_PROFILE_FAILED, - }), + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + error: ProfileServiceErrorMessage.GET_PROFILE_FAILED, + }, + ), }); } @@ -365,11 +368,14 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:replaceProfile`, profileId], responseStruct: ProfileApiResponseStruct, mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { - method: 'PUT', - error: ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, - json: params, - }), + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + method: 'PUT', + error: ProfileServiceErrorMessage.REPLACE_PROFILE_FAILED, + json: params, + }, + ), }); } @@ -392,11 +398,14 @@ export class ProfileService extends BaseDataService< mutationKey: [`${this.name}:updateProfile`, profileId], responseStruct: ProfileApiResponseStruct, mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { - method: 'PATCH', - error: ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, - json: params, - }), + this.#fetch( + `profiles/${encodeURIComponent(profileId)}`, + { + method: 'PATCH', + error: ProfileServiceErrorMessage.UPDATE_PROFILE_FAILED, + json: params, + }, + ), }); } From c24a08994d6fa8fa448f07563ede84471a3389cf Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:34:13 -0400 Subject: [PATCH 50/73] refactor(profile-controller): use #fetch in deleteProfile --- .../profile-controller/src/ProfileService.ts | 21 +++++-------------- 1 file changed, 5 insertions(+), 16 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 1b641f3561b..1198f2e23fd 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -300,6 +300,7 @@ export class ProfileService extends BaseDataService< if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); } + if (method === 'DELETE') return null as ResponseType; return (await response.json()) as ResponseType; } @@ -421,23 +422,11 @@ export class ProfileService extends BaseDataService< assert(profileId, string()); return this.executeMutation({ mutationKey: [`${this.name}:deleteProfile`, profileId], - mutationFn: async () => { - const authHeaders = await this.#getAuthHeaders(); - const url = new URL( - `${this.#v1Url}/profiles/${encodeURIComponent(profileId)}`, - ); - const response = await fetch(url.toString(), { + mutationFn: async () => + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE', - headers: authHeaders, - }); - if (!response.ok) { - throw new HttpError( - response.status, - `${ProfileServiceErrorMessage.DELETE_PROFILE_FAILED}: ${response.status}`, - ); - } - return null; - }, + error: ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, + }), }); } From 93bc4967f7fcf7a2a9fa23acd7ade10b3feee259 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:40:43 -0400 Subject: [PATCH 51/73] fix(profile-controller): add bracket after if --- packages/profile-controller/src/ProfileService.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 1198f2e23fd..49298d7fd38 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -300,7 +300,9 @@ export class ProfileService extends BaseDataService< if (!response.ok) { throw new HttpError(response.status, `${error}: ${response.status}`); } - if (method === 'DELETE') return null as ResponseType; + if (method === 'DELETE') { + return null as ResponseType; + } return (await response.json()) as ResponseType; } From a7fa0226ab3c6db2ffd577eaad0ee27075bb4df8 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 11:46:23 -0400 Subject: [PATCH 52/73] refactor(profile-controller): add debounce to react query hook --- packages/profile-controller/package.json | 4 ++ .../src/ProfileServiceHooks.test.ts | 43 ++++++++++++++++++- .../src/ProfileServiceHooks.ts | 13 +++++- yarn.lock | 3 ++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 2111bc2e67f..48b0b39f717 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -49,6 +49,9 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.1.0", @@ -62,6 +65,7 @@ "devDependencies": { "@metamask/auto-changelog": "^6.2.1", "@types/jest": "^30.0.0", + "@types/react": "^19.0.0", "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.3.1", "jest": "^30.5.2", diff --git a/packages/profile-controller/src/ProfileServiceHooks.test.ts b/packages/profile-controller/src/ProfileServiceHooks.test.ts index 516dd7b53e2..5c658ab8902 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.test.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.test.ts @@ -10,6 +10,14 @@ jest.mock('@metamask/react-data-query', () => ({ useQuery: jest.fn(), })); +const mockSetState = jest.fn(); +const mockUseEffect = jest.fn(); + +jest.mock('react', () => ({ + useState: (initial: T): [T, jest.Mock] => [initial, mockSetState], + useEffect: (...args: unknown[]) => mockUseEffect(...args), +})); + describe('useGetProfile', () => { it('calls useQuery with the correct query key for the given identifier', () => { useGetProfile('profile-123'); @@ -21,11 +29,44 @@ describe('useGetProfile', () => { }); describe('useCheckUsernameAvailability', () => { - it('calls useQuery with the correct query key for the given username', () => { + beforeEach(() => { + jest.useFakeTimers(); + mockUseEffect.mockImplementation((fn: () => (() => void) | void) => fn()); + }); + + afterEach(() => { + jest.useRealTimers(); + mockSetState.mockClear(); + }); + + it('calls useQuery with the initial username immediately', () => { useCheckUsernameAvailability('alice'); expect(useQueryFromReactDataQuery).toHaveBeenCalledWith({ queryKey: [`${serviceName}:checkUsernameAvailability`, 'alice'], }); }); + + it('calls setState with the updated username after debounce delay', () => { + useCheckUsernameAvailability('alice', 500); + + jest.advanceTimersByTime(500); + + expect(mockSetState).toHaveBeenCalledWith('alice'); + }); + + it('cleanup cancels pending state update', () => { + let capturedCleanup: (() => void) | void; + mockUseEffect.mockImplementationOnce( + (fn: () => (() => void) | void) => { + capturedCleanup = fn(); + }, + ); + + useCheckUsernameAvailability('alice', 500); + capturedCleanup?.(); + jest.advanceTimersByTime(500); + + expect(mockSetState).not.toHaveBeenCalledWith('alice'); + }); }); diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts index d92678c5762..d45dd9605ef 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.ts @@ -1,4 +1,5 @@ import { useQuery } from '@metamask/react-data-query'; +import { useEffect, useState } from 'react'; import type { ProfileApiResponse, @@ -22,14 +23,24 @@ export function useGetProfile( /** * React hook that reads username availability from the ProfileService query cache. + * Debounces the username before querying to avoid firing a request on every keystroke. * * @param username - The username to check availability for. + * @param debounceMs - How long to wait after the last change before querying. Defaults to 300ms. * @returns A TanStack Query result containing the availability details. */ export function useCheckUsernameAvailability( username: string, + debounceMs = 300, ): ReturnType> { + const [debouncedUsername, setDebouncedUsername] = useState(username); + + useEffect(() => { + const timer = setTimeout(() => setDebouncedUsername(username), debounceMs); + return () => clearTimeout(timer); + }, [username, debounceMs]); + return useQuery({ - queryKey: [`${serviceName}:checkUsernameAvailability`, username], + queryKey: [`${serviceName}:checkUsernameAvailability`, debouncedUsername], }); } diff --git a/yarn.lock b/yarn.lock index 78ca76586f9..0673e86ac97 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8487,6 +8487,7 @@ __metadata: "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.0" "@types/jest": "npm:^30.0.0" + "@types/react": "npm:^19.0.0" "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.3.1" jest: "npm:^30.5.2" @@ -8496,6 +8497,8 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.2" + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 languageName: unknown linkType: soft From 65d14eb744a902574cfe4ae1e9eeb34a2f32a8c2 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:07:54 -0400 Subject: [PATCH 53/73] fix(profile-controller): make linked_addresses and trading_privacy required --- packages/profile-controller/src/ProfileService.test.ts | 3 +++ packages/profile-controller/src/ProfileService.ts | 5 +++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 6529eae8795..493d617b2b1 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -169,6 +169,8 @@ describe('ProfileService', () => { 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 () => { @@ -222,6 +224,7 @@ describe('ProfileService', () => { username: 'alice2', display_name: 'Alice 2', linked_addresses: ['eip155:1:0x1234567890abcdef1234567890abcdef12345678'], + trading_privacy: 'public', }; it('puts to the profile endpoint with correct body', async () => { diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 49298d7fd38..3f6e58144cb 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -106,8 +106,9 @@ const CreateProfileParamsStruct = structType({ username: string(), display_name: string(), bio: optional(nullable(string())), - linked_addresses: optional(array(string())), + linked_addresses: array(string()), avatar_url: optional(string()), + trading_privacy: TradingPrivacyStruct, }); const ReplaceProfileParamsStruct = structType({ @@ -116,7 +117,7 @@ const ReplaceProfileParamsStruct = structType({ bio: optional(nullable(string())), linked_addresses: array(string()), avatar_url: optional(string()), - trading_privacy: optional(TradingPrivacyStruct), + trading_privacy: TradingPrivacyStruct, }); const UpdateProfileParamsStruct = structType({ From ae0ff8a5a83bc739d49a9ab643732fd85e15eed9 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:17:06 -0400 Subject: [PATCH 54/73] refactor(profile-controller): apply code review --- .../ProfileController-method-action-types.ts | 4 ++-- .../src/ProfileController.test.ts | 6 +++--- .../profile-controller/src/ProfileController.ts | 6 +++--- .../profile-controller/src/ProfileService.ts | 17 +++++------------ 4 files changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index f3c082d9c86..aa993779cbb 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -11,8 +11,8 @@ import type { ProfileController } from './ProfileController.js'; * @returns The MetaMask profile, or undefined. */ export type ProfileControllerGetMetaMaskProfileAction = { - type: `ProfileController:getMetaMaskProfile`; - handler: ProfileController['getMetaMaskProfile']; + type: `ProfileController:getProfile`; + handler: ProfileController['getProfile']; }; /** diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 532e2494a6f..8d7fccf7f38 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -174,11 +174,11 @@ describe('ProfileController', () => { }); }); - describe('getMetaMaskProfile', () => { + describe('getProfile', () => { it('returns undefined when no profile has been set', () => { const { controller } = createController(); - expect(controller.getMetaMaskProfile()).toBeUndefined(); + expect(controller.getProfile()).toBeUndefined(); }); it('returns the profile when one exists in state', async () => { @@ -196,7 +196,7 @@ describe('ProfileController', () => { display_name: 'Alice Wonderland', }); - expect(controller.getMetaMaskProfile()).toStrictEqual(mockMappedProfile); + expect(controller.getProfile()).toStrictEqual(mockMappedProfile); }); }); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index 77750fa6d5d..b7212730d42 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -162,7 +162,7 @@ export function getDefaultProfileControllerState(): ProfileControllerState { } const MESSENGER_EXPOSED_METHODS = [ - 'getMetaMaskProfile', + 'getProfile', 'getXProfile', 'createProfile', 'replaceProfile', @@ -247,7 +247,7 @@ export class ProfileController extends BaseController< username: response.username, displayName: response.display_name, bio: response.bio ?? '', - linkedAddresses: response.linked_addresses as CaipAccountId[], + linkedAddresses: response.linked_addresses, avatarUrl: response.avatar_url ?? '', tradingPrivacy: response.trading_privacy, connectedToX: response.connected_to_x, @@ -279,7 +279,7 @@ export class ProfileController extends BaseController< * * @returns The MetaMask profile, or undefined. */ - getMetaMaskProfile(): Profile | undefined { + getProfile(): Profile | undefined { if (!this.#hasProfile()) { return undefined; } diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 3f6e58144cb..5a16caa51aa 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -21,6 +21,7 @@ import { } 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'; @@ -55,7 +56,7 @@ const ProfileApiResponseStruct = structType({ username: string(), display_name: string(), bio: nullable(string()), - linked_addresses: array(string()), + linked_addresses: array(CaipAccountIdStruct), avatar_url: nullable(string()), trading_privacy: TradingPrivacyStruct, connected_to_x: boolean(), @@ -106,7 +107,7 @@ const CreateProfileParamsStruct = structType({ username: string(), display_name: string(), bio: optional(nullable(string())), - linked_addresses: array(string()), + linked_addresses: array(CaipAccountIdStruct), avatar_url: optional(string()), trading_privacy: TradingPrivacyStruct, }); @@ -115,7 +116,7 @@ const ReplaceProfileParamsStruct = structType({ username: string(), display_name: string(), bio: optional(nullable(string())), - linked_addresses: array(string()), + linked_addresses: array(CaipAccountIdStruct), avatar_url: optional(string()), trading_privacy: TradingPrivacyStruct, }); @@ -124,7 +125,7 @@ const UpdateProfileParamsStruct = structType({ username: optional(string()), display_name: optional(string()), bio: optional(nullable(string())), - linked_addresses: optional(array(string())), + linked_addresses: optional(array(CaipAccountIdStruct)), avatar_url: optional(string()), trading_privacy: optional(TradingPrivacyStruct), }); @@ -340,7 +341,6 @@ export class ProfileService extends BaseDataService< async createProfile( params: CreateProfileParams, ): Promise { - assert(params, CreateProfileParamsStruct); return this.executeMutation({ mutationKey: [`${this.name}:createProfile`], responseStruct: CreateProfileResponseStruct, @@ -366,8 +366,6 @@ export class ProfileService extends BaseDataService< profileId: string, params: ReplaceProfileParams, ): Promise { - assert(profileId, string()); - assert(params, ReplaceProfileParamsStruct); return this.executeMutation({ mutationKey: [`${this.name}:replaceProfile`, profileId], responseStruct: ProfileApiResponseStruct, @@ -396,8 +394,6 @@ export class ProfileService extends BaseDataService< profileId: string, params: UpdateProfileParams, ): Promise { - assert(profileId, string()); - assert(params, UpdateProfileParamsStruct); return this.executeMutation({ mutationKey: [`${this.name}:updateProfile`, profileId], responseStruct: ProfileApiResponseStruct, @@ -422,7 +418,6 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If the profileId is not a string. */ async deleteProfile(profileId: string): Promise { - assert(profileId, string()); return this.executeMutation({ mutationKey: [`${this.name}:deleteProfile`, profileId], mutationFn: async () => @@ -444,7 +439,6 @@ export class ProfileService extends BaseDataService< async checkUsernameAvailability( username: string, ): Promise { - assert(username, string()); return this.fetchQuery({ queryKey: [`${this.name}:checkUsernameAvailability`, username], staleTime: 0, @@ -487,7 +481,6 @@ export class ProfileService extends BaseDataService< * @throws {StructError} If params or the response do not match the expected shape. */ async connectX(params: ConnectXParams): Promise { - assert(params, ConnectXParamsStruct); return this.executeMutation({ mutationKey: [`${this.name}:connectX`], responseStruct: XConnectResponseStruct, From 2e048289cfc60c0d396ec1f6d0b337f64c949ca3 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:22:25 -0400 Subject: [PATCH 55/73] fix(profile-controller): regenerate action type --- .../src/ProfileController-method-action-types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index aa993779cbb..2049f825605 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -10,7 +10,7 @@ import type { ProfileController } from './ProfileController.js'; * * @returns The MetaMask profile, or undefined. */ -export type ProfileControllerGetMetaMaskProfileAction = { +export type ProfileControllerGetProfileAction = { type: `ProfileController:getProfile`; handler: ProfileController['getProfile']; }; @@ -117,7 +117,7 @@ export type ProfileControllerFetchAndUpdateXAccountAction = { * Union of all ProfileController action types. */ export type ProfileControllerMethodActions = - | ProfileControllerGetMetaMaskProfileAction + | ProfileControllerGetProfileAction | ProfileControllerGetXProfileAction | ProfileControllerCreateProfileAction | ProfileControllerReplaceProfileAction From b94a42d33c3409b7fbb6978c47bb570f4031d0f4 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:26:12 -0400 Subject: [PATCH 56/73] refactor(profile-controller): use sensitive struct --- packages/profile-controller/src/ProfileService.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 5a16caa51aa..d06c5e795a9 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -10,12 +10,12 @@ import type { Messenger } from '@metamask/messenger'; import type { AuthenticationController } from '@metamask/profile-sync-controller'; import { array, - assert, boolean, enums, intersection, nullable, optional, + sensitive, string, type as structType, } from '@metamask/superstruct'; @@ -78,8 +78,8 @@ const UsernameAvailabilityResponseStruct = structType({ }); const XAuthUrlResponseStruct = structType({ - url: string(), - state: string(), + url: sensitive(string()), + state: sensitive(string()), }); const XConnectResponseStruct = structType({ @@ -98,8 +98,8 @@ const CreateProfileResponseStruct = intersection([ ]); const ConnectXParamsStruct = structType({ - code: string(), - state: string(), + code: sensitive(string()), + state: sensitive(string()), }); const CreateProfileParamsStruct = structType({ From 78d5608b3afb649c53165260b7db630eaabdf785 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:26:44 -0400 Subject: [PATCH 57/73] fix(profile-controller): fix lint issues --- packages/profile-controller/package.json | 6 +++--- .../src/ProfileServiceHooks.test.ts | 15 ++++++++------- .../profile-controller/src/ProfileServiceHooks.ts | 2 +- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 48b0b39f717..fc1e43f75cd 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -49,9 +49,6 @@ "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, "dependencies": { "@metamask/base-controller": "^10.0.0", "@metamask/base-data-service": "^2.1.0", @@ -76,6 +73,9 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2" }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, "engines": { "node": "^22.14.0 || ^24" } diff --git a/packages/profile-controller/src/ProfileServiceHooks.test.ts b/packages/profile-controller/src/ProfileServiceHooks.test.ts index 5c658ab8902..0876bfed669 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.test.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.test.ts @@ -14,8 +14,11 @@ const mockSetState = jest.fn(); const mockUseEffect = jest.fn(); jest.mock('react', () => ({ - useState: (initial: T): [T, jest.Mock] => [initial, mockSetState], - useEffect: (...args: unknown[]) => mockUseEffect(...args), + useState: (initial: TValue): [TValue, jest.Mock] => [ + initial, + mockSetState, + ], + useEffect: (...args: unknown[]): void => mockUseEffect(...args), })); describe('useGetProfile', () => { @@ -57,11 +60,9 @@ describe('useCheckUsernameAvailability', () => { it('cleanup cancels pending state update', () => { let capturedCleanup: (() => void) | void; - mockUseEffect.mockImplementationOnce( - (fn: () => (() => void) | void) => { - capturedCleanup = fn(); - }, - ); + mockUseEffect.mockImplementationOnce((fn: () => (() => void) | void) => { + capturedCleanup = fn(); + }); useCheckUsernameAvailability('alice', 500); capturedCleanup?.(); diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts index d45dd9605ef..49786666768 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.ts @@ -37,7 +37,7 @@ export function useCheckUsernameAvailability( useEffect(() => { const timer = setTimeout(() => setDebouncedUsername(username), debounceMs); - return () => clearTimeout(timer); + return (): void => clearTimeout(timer); }, [username, debounceMs]); return useQuery({ From 63002e881bae4e6e9f61846e9f2ed8e2c575c7a1 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:33:15 -0400 Subject: [PATCH 58/73] fix(profile-controller): more lint fixes --- packages/profile-controller/src/ProfileServiceHooks.test.ts | 4 +++- packages/profile-controller/src/index.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileServiceHooks.test.ts b/packages/profile-controller/src/ProfileServiceHooks.test.ts index 0876bfed669..354eef0ac6d 100644 --- a/packages/profile-controller/src/ProfileServiceHooks.test.ts +++ b/packages/profile-controller/src/ProfileServiceHooks.test.ts @@ -18,7 +18,9 @@ jest.mock('react', () => ({ initial, mockSetState, ], - useEffect: (...args: unknown[]): void => mockUseEffect(...args), + useEffect: (...args: unknown[]): void => { + mockUseEffect(...args); + }, })); describe('useGetProfile', () => { diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index 90aa8bc391e..e3f1f80f115 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -17,7 +17,7 @@ export type { ProfileControllerConnectXAction, ProfileControllerCreateProfileAction, ProfileControllerDeleteProfileAction, - ProfileControllerGetMetaMaskProfileAction, + ProfileControllerGetProfileAction, ProfileControllerFetchAndUpdateXAccountAction, ProfileControllerGetXAuthUrlAction, ProfileControllerGetXProfileAction, From d6ca5be6da401b632f1c121668b0e3e93601f11d Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:41:24 -0400 Subject: [PATCH 59/73] fix(profile-controller): update JSDocs --- packages/profile-controller/src/ProfileService.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index d06c5e795a9..f3d7a0b1df1 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -336,7 +336,7 @@ export class ProfileService extends BaseDataService< * @param params - The profile creation parameters. * @returns The created profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ async createProfile( params: CreateProfileParams, @@ -360,7 +360,7 @@ export class ProfileService extends BaseDataService< * @param params - The replacement profile data. * @returns The updated profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ async replaceProfile( profileId: string, @@ -388,7 +388,7 @@ export class ProfileService extends BaseDataService< * @param params - The fields to update. * @returns The updated profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ async updateProfile( profileId: string, @@ -415,7 +415,6 @@ export class ProfileService extends BaseDataService< * @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. - * @throws {StructError} If the profileId is not a string. */ async deleteProfile(profileId: string): Promise { return this.executeMutation({ @@ -434,7 +433,7 @@ export class ProfileService extends BaseDataService< * @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 username or response do not match the expected shape. + * @throws {StructError} If the response does not match the expected shape. */ async checkUsernameAvailability( username: string, @@ -478,7 +477,7 @@ export class ProfileService extends BaseDataService< * @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 params or the response do not match the expected shape. + * @throws {StructError} If the response does not match the expected shape. */ async connectX(params: ConnectXParams): Promise { return this.executeMutation({ From a887801124e9cb64da9ffbd1a145fd36050b9e96 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 12:45:03 -0400 Subject: [PATCH 60/73] fix(profile-controller): fix action types --- .../src/ProfileService-method-action-types.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/profile-controller/src/ProfileService-method-action-types.ts b/packages/profile-controller/src/ProfileService-method-action-types.ts index bdd5ca13d28..146dff93f69 100644 --- a/packages/profile-controller/src/ProfileService-method-action-types.ts +++ b/packages/profile-controller/src/ProfileService-method-action-types.ts @@ -24,7 +24,7 @@ export type ProfileServiceGetProfileAction = { * @param params - The profile creation parameters. * @returns The created profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ export type ProfileServiceCreateProfileAction = { type: `ProfileService:createProfile`; @@ -38,7 +38,7 @@ export type ProfileServiceCreateProfileAction = { * @param params - The replacement profile data. * @returns The updated profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ export type ProfileServiceReplaceProfileAction = { type: `ProfileService:replaceProfile`; @@ -52,7 +52,7 @@ export type ProfileServiceReplaceProfileAction = { * @param params - The fields to update. * @returns The updated profile data. * @throws {HttpError} If the API returns a non-2xx response. - * @throws {StructError} If params or the response do not match the expected shape. + * @throws {StructError} If the response do not match the expected shape. */ export type ProfileServiceUpdateProfileAction = { type: `ProfileService:updateProfile`; @@ -65,7 +65,6 @@ export type ProfileServiceUpdateProfileAction = { * @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. - * @throws {StructError} If the profileId is not a string. */ export type ProfileServiceDeleteProfileAction = { type: `ProfileService:deleteProfile`; @@ -78,7 +77,7 @@ export type ProfileServiceDeleteProfileAction = { * @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 username or response do not match the expected shape. + * @throws {StructError} If the response does not match the expected shape. */ export type ProfileServiceCheckUsernameAvailabilityAction = { type: `ProfileService:checkUsernameAvailability`; @@ -103,7 +102,7 @@ export type ProfileServiceGetXAuthUrlAction = { * @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 params or the response do not match the expected shape. + * @throws {StructError} If the response does not match the expected shape. */ export type ProfileServiceConnectXAction = { type: `ProfileService:connectX`; From 5307c106b2e20e23f404ad51d5bcd5da206c5997 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 15:51:17 -0400 Subject: [PATCH 61/73] chore(profile-controller): replace tsx --- packages/profile-controller/package.json | 9 ++++----- yarn.lock | 1 - 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index fc1e43f75cd..4f67b4e89f4 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -39,10 +39,10 @@ "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": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts", - "lint:tsconfigs:fix": "tsx ../../scripts/lint-tsconfigs/lint-tsconfigs.ts --fix", - "messenger-action-types:check": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --check", - "messenger-action-types:generate": "tsx ../../packages/messenger-cli/src/cli.ts --formatter oxfmt --esm --generate", + "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", @@ -68,7 +68,6 @@ "jest": "^30.5.2", "rimraf": "^6.1.3", "ts-jest": "^29.4.14", - "tsx": "^4.23.15", "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2" diff --git a/yarn.lock b/yarn.lock index 26b4bdea107..51e2c9a40b1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8234,7 +8234,6 @@ __metadata: jest: "npm:^30.5.2" rimraf: "npm:^6.1.3" ts-jest: "npm:^29.4.14" - tsx: "npm:^4.23.15" typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.2" From 7e139aa303c7cdb6d89e5a4024e11f787dfda159 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 15:59:06 -0400 Subject: [PATCH 62/73] fix(profile-controller): fix syntax --- packages/profile-controller/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index 4f67b4e89f4..ac030147088 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -41,7 +41,7 @@ "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: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", From f2cff72192739a7e427e515d19809baaebd96a3c Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:00:31 -0400 Subject: [PATCH 63/73] chore(profile-controller): update license --- packages/profile-controller/LICENSE.APACHE2 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-controller/LICENSE.APACHE2 b/packages/profile-controller/LICENSE.APACHE2 index e6e77b08909..56752e8ff49 100644 --- a/packages/profile-controller/LICENSE.APACHE2 +++ b/packages/profile-controller/LICENSE.APACHE2 @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + 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. From 55e6c19e271ff346906eb28cc288b976b0112a17 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:05:42 -0400 Subject: [PATCH 64/73] refactor(profile-controller): remove getXAuthUrl method --- .../ProfileController-method-action-types.ts | 11 ------ .../src/ProfileController.test.ts | 36 ------------------- .../src/ProfileController.ts | 13 ------- 3 files changed, 60 deletions(-) diff --git a/packages/profile-controller/src/ProfileController-method-action-types.ts b/packages/profile-controller/src/ProfileController-method-action-types.ts index 2049f825605..34e6a57f125 100644 --- a/packages/profile-controller/src/ProfileController-method-action-types.ts +++ b/packages/profile-controller/src/ProfileController-method-action-types.ts @@ -80,16 +80,6 @@ export type ProfileControllerCheckUsernameAvailabilityAction = { handler: ProfileController['checkUsernameAvailability']; }; -/** - * Fetches the X OAuth authorization URL to begin the PKCE flow. - * - * @returns An object containing the authorization URL and its associated state token. - */ -export type ProfileControllerGetXAuthUrlAction = { - type: `ProfileController:getXAuthUrl`; - handler: ProfileController['getXAuthUrl']; -}; - /** * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. * @@ -124,6 +114,5 @@ export type ProfileControllerMethodActions = | ProfileControllerUpdateProfileAction | ProfileControllerDeleteProfileAction | ProfileControllerCheckUsernameAvailabilityAction - | ProfileControllerGetXAuthUrlAction | ProfileControllerConnectXAction | ProfileControllerFetchAndUpdateXAccountAction; diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 8d7fccf7f38..2552a58fe11 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -94,7 +94,6 @@ function getMessenger( 'ProfileService:updateProfile', 'ProfileService:deleteProfile', 'ProfileService:checkUsernameAvailability', - 'ProfileService:getXAuthUrl', 'ProfileService:connectX', 'ProfileService:getXAccount', ], @@ -501,41 +500,6 @@ describe('ProfileController', () => { }); }); - describe('getXAuthUrl', () => { - it('delegates to ProfileService:getXAuthUrl and returns result', async () => { - const mockAuthUrl = { - url: 'https://twitter.com/i/oauth2/authorize?state=xyz', - state: 'xyz', - }; - const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:getXAuthUrl', - jest.fn().mockResolvedValue(mockAuthUrl), - ); - - const { controller } = createController({ rootMessenger }); - const result = await controller.getXAuthUrl(); - - expect(result).toStrictEqual(mockAuthUrl); - }); - - it('does not update state', async () => { - const rootMessenger = getRootMessenger(); - mockServiceAction( - rootMessenger, - 'ProfileService:getXAuthUrl', - jest.fn().mockResolvedValue({ url: 'https://x.com', state: 'abc' }), - ); - - const { controller } = createController({ rootMessenger }); - const stateBefore = controller.state; - await controller.getXAuthUrl(); - - expect(controller.state).toStrictEqual(stateBefore); - }); - }); - describe('connectX', () => { it('calls ProfileService:connectX with code and state and returns the X profile', async () => { const rootMessenger = getRootMessenger(); diff --git a/packages/profile-controller/src/ProfileController.ts b/packages/profile-controller/src/ProfileController.ts index b7212730d42..d27789b0e19 100644 --- a/packages/profile-controller/src/ProfileController.ts +++ b/packages/profile-controller/src/ProfileController.ts @@ -14,7 +14,6 @@ import type { ProfileServiceCreateProfileAction, ProfileServiceDeleteProfileAction, ProfileServiceGetXAccountAction, - ProfileServiceGetXAuthUrlAction, ProfileServiceReplaceProfileAction, ProfileServiceUpdateProfileAction, } from './ProfileService-method-action-types.js'; @@ -25,7 +24,6 @@ import type { ReplaceProfileParams, UpdateProfileParams, UsernameAvailabilityResponse, - XAuthUrlResponse, XConnectResponse, } from './ProfileService.js'; @@ -109,7 +107,6 @@ type AllowedActions = | ProfileServiceUpdateProfileAction | ProfileServiceDeleteProfileAction | ProfileServiceCheckUsernameAvailabilityAction - | ProfileServiceGetXAuthUrlAction | ProfileServiceConnectXAction | ProfileServiceGetXAccountAction; @@ -169,7 +166,6 @@ const MESSENGER_EXPOSED_METHODS = [ 'updateProfile', 'deleteProfile', 'checkUsernameAvailability', - 'getXAuthUrl', 'connectX', 'fetchAndUpdateXAccount', ] as const; @@ -382,15 +378,6 @@ export class ProfileController extends BaseController< ); } - /** - * Fetches the X OAuth authorization URL to begin the PKCE flow. - * - * @returns An object containing the authorization URL and its associated state token. - */ - async getXAuthUrl(): Promise { - return await this.messenger.call('ProfileService:getXAuthUrl'); - } - /** * Completes the X OAuth PKCE flow, updates xProfile in state, and returns the X profile. * From b72723a54558c2aff8306b5acc7541030779c488 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:21:30 -0400 Subject: [PATCH 65/73] refactor(profile-controller): add overloads for #fetch --- .../profile-controller/src/ProfileService.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index f3d7a0b1df1..6143ebaa451 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -277,6 +277,16 @@ export class ProfileService extends BaseDataService< * @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 }, + ): Promise; + async #fetch( endpoint: string, { @@ -288,7 +298,7 @@ export class ProfileService extends BaseDataService< error: string; json?: unknown; }, - ): Promise { + ): Promise { const authHeaders = await this.#getAuthHeaders(); const url = new URL(`${this.#v1Url}/${endpoint}`); const response = await fetch(url.toString(), { @@ -303,7 +313,7 @@ export class ProfileService extends BaseDataService< throw new HttpError(response.status, `${error}: ${response.status}`); } if (method === 'DELETE') { - return null as ResponseType; + return null; } return (await response.json()) as ResponseType; } @@ -420,7 +430,7 @@ export class ProfileService extends BaseDataService< return this.executeMutation({ mutationKey: [`${this.name}:deleteProfile`, profileId], mutationFn: async () => - this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { + this.#fetch(`profiles/${encodeURIComponent(profileId)}`, { method: 'DELETE', error: ProfileServiceErrorMessage.DELETE_PROFILE_FAILED, }), From 210abed96ab1782aff7c21b332a39649bc1a22ea Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:25:32 -0400 Subject: [PATCH 66/73] refactor(profile-controller): use searchParams --- .../profile-controller/src/ProfileService.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/packages/profile-controller/src/ProfileService.ts b/packages/profile-controller/src/ProfileService.ts index 6143ebaa451..9f66bdcbd88 100644 --- a/packages/profile-controller/src/ProfileService.ts +++ b/packages/profile-controller/src/ProfileService.ts @@ -284,7 +284,12 @@ export class ProfileService extends BaseDataService< async #fetch( endpoint: string, - options: { method?: string; error: string; json?: unknown }, + options: { + method?: string; + error: string; + json?: unknown; + searchParams?: Record; + }, ): Promise; async #fetch( @@ -293,14 +298,21 @@ export class ProfileService extends BaseDataService< 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: { @@ -454,10 +466,11 @@ export class ProfileService extends BaseDataService< responseStruct: UsernameAvailabilityResponseStruct, queryFn: async () => this.#fetch( - `profiles/username/availability?username=${encodeURIComponent(username)}`, + 'profiles/username/availability', { error: ProfileServiceErrorMessage.CHECK_USERNAME_AVAILABILITY_FAILED, + searchParams: { username }, }, ), }); From dc4683255bd80914b144ce686576c38f1ace9ac7 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:44:46 -0400 Subject: [PATCH 67/73] refactor(profile-controller): remove hooks --- packages/profile-controller/package.json | 5 -- .../src/ProfileServiceHooks.test.ts | 75 ------------------- .../src/ProfileServiceHooks.ts | 46 ------------ packages/profile-controller/src/index.ts | 4 - yarn.lock | 6 +- 5 files changed, 1 insertion(+), 135 deletions(-) delete mode 100644 packages/profile-controller/src/ProfileServiceHooks.test.ts delete mode 100644 packages/profile-controller/src/ProfileServiceHooks.ts diff --git a/packages/profile-controller/package.json b/packages/profile-controller/package.json index ac030147088..1bb480e2eb8 100644 --- a/packages/profile-controller/package.json +++ b/packages/profile-controller/package.json @@ -55,14 +55,12 @@ "@metamask/controller-utils": "^13.0.0", "@metamask/messenger": "^3.0.0", "@metamask/profile-sync-controller": "^33.0.0", - "@metamask/react-data-query": "^2.0.0", "@metamask/superstruct": "^3.4.1", "@metamask/utils": "^12.0.0" }, "devDependencies": { "@metamask/auto-changelog": "^6.2.1", "@types/jest": "^30.0.0", - "@types/react": "^19.0.0", "@typescript/native": "npm:typescript@^7.0.2", "deepmerge": "^4.3.1", "jest": "^30.5.2", @@ -72,9 +70,6 @@ "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "npm:@typescript/typescript6@^6.0.2" }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - }, "engines": { "node": "^22.14.0 || ^24" } diff --git a/packages/profile-controller/src/ProfileServiceHooks.test.ts b/packages/profile-controller/src/ProfileServiceHooks.test.ts deleted file mode 100644 index 354eef0ac6d..00000000000 --- a/packages/profile-controller/src/ProfileServiceHooks.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { useQuery as useQueryFromReactDataQuery } from '@metamask/react-data-query'; - -import { serviceName } from './ProfileService.js'; -import { - useCheckUsernameAvailability, - useGetProfile, -} from './ProfileServiceHooks.js'; - -jest.mock('@metamask/react-data-query', () => ({ - useQuery: jest.fn(), -})); - -const mockSetState = jest.fn(); -const mockUseEffect = jest.fn(); - -jest.mock('react', () => ({ - useState: (initial: TValue): [TValue, jest.Mock] => [ - initial, - mockSetState, - ], - useEffect: (...args: unknown[]): void => { - mockUseEffect(...args); - }, -})); - -describe('useGetProfile', () => { - it('calls useQuery with the correct query key for the given identifier', () => { - useGetProfile('profile-123'); - - expect(useQueryFromReactDataQuery).toHaveBeenCalledWith({ - queryKey: [`${serviceName}:getProfile`, 'profile-123'], - }); - }); -}); - -describe('useCheckUsernameAvailability', () => { - beforeEach(() => { - jest.useFakeTimers(); - mockUseEffect.mockImplementation((fn: () => (() => void) | void) => fn()); - }); - - afterEach(() => { - jest.useRealTimers(); - mockSetState.mockClear(); - }); - - it('calls useQuery with the initial username immediately', () => { - useCheckUsernameAvailability('alice'); - - expect(useQueryFromReactDataQuery).toHaveBeenCalledWith({ - queryKey: [`${serviceName}:checkUsernameAvailability`, 'alice'], - }); - }); - - it('calls setState with the updated username after debounce delay', () => { - useCheckUsernameAvailability('alice', 500); - - jest.advanceTimersByTime(500); - - expect(mockSetState).toHaveBeenCalledWith('alice'); - }); - - it('cleanup cancels pending state update', () => { - let capturedCleanup: (() => void) | void; - mockUseEffect.mockImplementationOnce((fn: () => (() => void) | void) => { - capturedCleanup = fn(); - }); - - useCheckUsernameAvailability('alice', 500); - capturedCleanup?.(); - jest.advanceTimersByTime(500); - - expect(mockSetState).not.toHaveBeenCalledWith('alice'); - }); -}); diff --git a/packages/profile-controller/src/ProfileServiceHooks.ts b/packages/profile-controller/src/ProfileServiceHooks.ts deleted file mode 100644 index 49786666768..00000000000 --- a/packages/profile-controller/src/ProfileServiceHooks.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { useQuery } from '@metamask/react-data-query'; -import { useEffect, useState } from 'react'; - -import type { - ProfileApiResponse, - UsernameAvailabilityResponse, -} from './ProfileService.js'; -import { serviceName } from './ProfileService.js'; - -/** - * React hook that reads a profile by identifier from the ProfileService query cache. - * - * @param identifier - The profile identifier to fetch. - * @returns A TanStack Query result containing the profile data. - */ -export function useGetProfile( - identifier: string, -): ReturnType> { - return useQuery({ - queryKey: [`${serviceName}:getProfile`, identifier], - }); -} - -/** - * React hook that reads username availability from the ProfileService query cache. - * Debounces the username before querying to avoid firing a request on every keystroke. - * - * @param username - The username to check availability for. - * @param debounceMs - How long to wait after the last change before querying. Defaults to 300ms. - * @returns A TanStack Query result containing the availability details. - */ -export function useCheckUsernameAvailability( - username: string, - debounceMs = 300, -): ReturnType> { - const [debouncedUsername, setDebouncedUsername] = useState(username); - - useEffect(() => { - const timer = setTimeout(() => setDebouncedUsername(username), debounceMs); - return (): void => clearTimeout(timer); - }, [username, debounceMs]); - - return useQuery({ - queryKey: [`${serviceName}:checkUsernameAvailability`, debouncedUsername], - }); -} diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index e3f1f80f115..e7b24f2a660 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -43,10 +43,6 @@ export { ProfileService, ProfileServiceErrorMessage, } from './ProfileService.js'; -export { - useGetProfile, - useCheckUsernameAvailability, -} from './ProfileServiceHooks.js'; export type { ProfileServiceCheckUsernameAvailabilityAction, ProfileServiceConnectXAction, diff --git a/yarn.lock b/yarn.lock index 51e2c9a40b1..62cc603e7f2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8224,11 +8224,9 @@ __metadata: "@metamask/controller-utils": "npm:^13.0.0" "@metamask/messenger": "npm:^3.0.0" "@metamask/profile-sync-controller": "npm:^33.0.0" - "@metamask/react-data-query": "npm:^2.0.0" "@metamask/superstruct": "npm:^3.4.1" "@metamask/utils": "npm:^12.0.0" "@types/jest": "npm:^30.0.0" - "@types/react": "npm:^19.0.0" "@typescript/native": "npm:typescript@^7.0.2" deepmerge: "npm:^4.3.1" jest: "npm:^30.5.2" @@ -8237,8 +8235,6 @@ __metadata: typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:@typescript/typescript6@^6.0.2" - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 languageName: unknown linkType: soft @@ -8389,7 +8385,7 @@ __metadata: languageName: unknown linkType: soft -"@metamask/react-data-query@npm:^2.0.0, @metamask/react-data-query@workspace:packages/react-data-query": +"@metamask/react-data-query@workspace:packages/react-data-query": version: 0.0.0-use.local resolution: "@metamask/react-data-query@workspace:packages/react-data-query" dependencies: From 519973ece6818c4f0979fac743522543d11e7e58 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:50:58 -0400 Subject: [PATCH 68/73] fix(profile-controller): update test --- packages/profile-controller/src/ProfileService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/profile-controller/src/ProfileService.test.ts b/packages/profile-controller/src/ProfileService.test.ts index 493d617b2b1..b12e95d1572 100644 --- a/packages/profile-controller/src/ProfileService.test.ts +++ b/packages/profile-controller/src/ProfileService.test.ts @@ -380,7 +380,7 @@ describe('ProfileService', () => { await service.checkUsernameAvailability('alice smith'); expect(mockFetch).toHaveBeenCalledWith( - `${V1_URL}/profiles/username/availability?username=alice%20smith`, + `${V1_URL}/profiles/username/availability?username=alice+smith`, expect.anything(), ); }); From 6f2d3f6a61733b8ba457c5d21ff86b68c7d5a183 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 16:51:40 -0400 Subject: [PATCH 69/73] chore: update README --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 9fd09c81882..084d826d117 100644 --- a/README.md +++ b/README.md @@ -618,7 +618,6 @@ linkStyle default opacity:0.5 profile_controller --> controller_utils; profile_controller --> messenger; profile_controller --> profile_sync_controller; - profile_controller --> react_data_query; profile_controller --> utils; profile_metrics_controller --> accounts_controller; profile_metrics_controller --> base_controller; From f73fd0ae68d5c1e019d310bec33ef34a0917aa04 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 17:18:24 -0400 Subject: [PATCH 70/73] fix(profile-controller): remove deleted action from index.ts --- packages/profile-controller/src/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/profile-controller/src/index.ts b/packages/profile-controller/src/index.ts index e7b24f2a660..b0193cd03be 100644 --- a/packages/profile-controller/src/index.ts +++ b/packages/profile-controller/src/index.ts @@ -19,7 +19,6 @@ export type { ProfileControllerDeleteProfileAction, ProfileControllerGetProfileAction, ProfileControllerFetchAndUpdateXAccountAction, - ProfileControllerGetXAuthUrlAction, ProfileControllerGetXProfileAction, ProfileControllerReplaceProfileAction, ProfileControllerUpdateProfileAction, From 116467219f3547154f598354c2e974addce1863a Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 17:19:00 -0400 Subject: [PATCH 71/73] fix(profile-controller): fix lint issues in controller test file --- .../src/ProfileController.test.ts | 60 ++++++++++++++++++- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/profile-controller/src/ProfileController.test.ts b/packages/profile-controller/src/ProfileController.test.ts index 2552a58fe11..64c242358e6 100644 --- a/packages/profile-controller/src/ProfileController.test.ts +++ b/packages/profile-controller/src/ProfileController.test.ts @@ -13,6 +13,10 @@ import { ProfileController, getDefaultProfileControllerState, } from './ProfileController.js'; +import type { + CreateProfileParams, + ReplaceProfileParams, +} from './ProfileService.js'; const controllerName = 'ProfileController'; @@ -193,6 +197,12 @@ describe('ProfileController', () => { 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); @@ -233,10 +243,16 @@ describe('ProfileController', () => { createProfileMock, ); - const input = { + 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); @@ -258,6 +274,12 @@ describe('ProfileController', () => { 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(''); @@ -278,6 +300,12 @@ describe('ProfileController', () => { 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(''); @@ -298,6 +326,12 @@ describe('ProfileController', () => { 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); @@ -319,6 +353,12 @@ describe('ProfileController', () => { 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); @@ -338,6 +378,12 @@ describe('ProfileController', () => { 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); @@ -356,10 +402,13 @@ describe('ProfileController', () => { replaceProfileMock, ); - const input = { + 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, @@ -383,7 +432,12 @@ describe('ProfileController', () => { controller.replaceProfile({ username: 'alice2', display_name: 'Alice 2', - linked_addresses: [], + 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'); }); From 07b479afd5318e57d2300d287c909c14f326e2cb Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 17:19:56 -0400 Subject: [PATCH 72/73] fix(profile-controller): remove react data query from tsconfig --- packages/profile-controller/tsconfig.build.json | 3 --- packages/profile-controller/tsconfig.json | 3 --- packages/profile-controller/tsconfig.lint.json | 3 --- 3 files changed, 9 deletions(-) diff --git a/packages/profile-controller/tsconfig.build.json b/packages/profile-controller/tsconfig.build.json index 3b5f79ed123..e2d65e292c6 100644 --- a/packages/profile-controller/tsconfig.build.json +++ b/packages/profile-controller/tsconfig.build.json @@ -20,9 +20,6 @@ { "path": "../profile-sync-controller/tsconfig.build.json" }, - { - "path": "../react-data-query/tsconfig.build.json" - }, { "path": "../controller-utils/tsconfig.build.json" } diff --git a/packages/profile-controller/tsconfig.json b/packages/profile-controller/tsconfig.json index ad4a671baaf..700990377c7 100644 --- a/packages/profile-controller/tsconfig.json +++ b/packages/profile-controller/tsconfig.json @@ -16,9 +16,6 @@ { "path": "../profile-sync-controller/tsconfig.json" }, - { - "path": "../react-data-query/tsconfig.json" - }, { "path": "../controller-utils/tsconfig.json" } diff --git a/packages/profile-controller/tsconfig.lint.json b/packages/profile-controller/tsconfig.lint.json index 244b9c090d3..e3f0f45e112 100644 --- a/packages/profile-controller/tsconfig.lint.json +++ b/packages/profile-controller/tsconfig.lint.json @@ -20,9 +20,6 @@ { "path": "../profile-sync-controller/tsconfig.lint.json" }, - { - "path": "../react-data-query/tsconfig.lint.json" - }, { "path": "../controller-utils/tsconfig.lint.json" } From c3f38aebd6b6456ba4a106de9c2f0267d9f7f220 Mon Sep 17 00:00:00 2001 From: Hassan Malik Date: Tue, 29 Sep 2026 18:05:23 -0400 Subject: [PATCH 73/73] fix(profile-controller): remove stale changelog entry --- packages/profile-controller/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/profile-controller/CHANGELOG.md b/packages/profile-controller/CHANGELOG.md index 72ef360ac8e..d72e34e479b 100644 --- a/packages/profile-controller/CHANGELOG.md +++ b/packages/profile-controller/CHANGELOG.md @@ -11,6 +11,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `ProfileController` for managing user profile state, exposing `getMetaMaskProfile`, `getXprofile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger ([#10558](https://github.com/MetaMask/core/pull/10558)) - Add `ProfileService` for communicating with the MetaMask Profile API, exposing `getProfile`, `createProfile`, `replaceProfile`, `updateProfile`, `deleteProfile`, `checkUsernameAvailability`, `getXAuthUrl`, `connectX`, and `getXAccount` via the messenger, with superstruct validation on all inputs and responses ([#10558](https://github.com/MetaMask/core/pull/10558)) -- Add `useGetProfile` and `useCheckUsernameAvailability` React query hooks for reading profile data in UI components via `@metamask/react-data-query` ([#10558](https://github.com/MetaMask/core/pull/10558)) [Unreleased]: https://github.com/MetaMask/core/