diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 360764abba..d535abd6be 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -75,6 +75,9 @@ ## Product Safety Team /packages/phishing-controller @MetaMask/product-safety +## Universal KYC Team +/packages/kyc-controller @MetaMask/universal-kyc + ## Swaps-Bridge Team /packages/bridge-controller @MetaMask/swaps-engineers /packages/bridge-status-controller @MetaMask/swaps-engineers @@ -286,4 +289,6 @@ /packages/money-account-upgrade-controller/package.json @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/money-account-upgrade-controller/CHANGELOG.md @MetaMask/earn @MetaMask/delegation @MetaMask/core-platform /packages/snap-account-service/package.json @MetaMask/accounts-engineers @MetaMask/core-platform -/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform \ No newline at end of file +/packages/snap-account-service/CHANGELOG.md @MetaMask/accounts-engineers @MetaMask/core-platform +/packages/kyc-controller/package.json @MetaMask/universal-kyc @MetaMask/core-platform +/packages/kyc-controller/CHANGELOG.md @MetaMask/universal-kyc @MetaMask/core-platform \ No newline at end of file diff --git a/README.md b/README.md index 24c9ede1fe..ea6739a659 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ yarn skills --reset # clear saved local selection - [`@metamask/json-rpc-engine`](packages/json-rpc-engine) - [`@metamask/json-rpc-middleware-stream`](packages/json-rpc-middleware-stream) - [`@metamask/keyring-controller`](packages/keyring-controller) +- [`@metamask/kyc-controller`](packages/kyc-controller) - [`@metamask/local-node-utils`](packages/local-node-utils) - [`@metamask/logging-controller`](packages/logging-controller) - [`@metamask/message-manager`](packages/message-manager) @@ -191,6 +192,7 @@ linkStyle default opacity:0.5 json_rpc_engine(["@metamask/json-rpc-engine"]); json_rpc_middleware_stream(["@metamask/json-rpc-middleware-stream"]); keyring_controller(["@metamask/keyring-controller"]); + kyc_controller(["@metamask/kyc-controller"]); local_node_utils(["@metamask/local-node-utils"]); logging_controller(["@metamask/logging-controller"]); message_manager(["@metamask/message-manager"]); @@ -418,6 +420,11 @@ linkStyle default opacity:0.5 keyring_controller --> base_controller; keyring_controller --> controller_utils; keyring_controller --> messenger; + kyc_controller --> base_controller; + kyc_controller --> controller_utils; + kyc_controller --> geolocation_controller; + kyc_controller --> messenger; + kyc_controller --> profile_sync_controller; logging_controller --> base_controller; logging_controller --> controller_utils; logging_controller --> messenger; diff --git a/codeowners.ts b/codeowners.ts index 2a864d499b..3adf343309 100644 --- a/codeowners.ts +++ b/codeowners.ts @@ -185,6 +185,9 @@ const PACKAGES: Record = { teams: ['@MetaMask/accounts-engineers', '@MetaMask/core-platform'], initializationPath: 'keyring-controller', }, + 'kyc-controller': { + teams: ['@MetaMask/universal-kyc'], + }, 'local-node-utils': { teams: [ '@MetaMask/mobile-platform', @@ -492,6 +495,10 @@ function buildTeamSections(): CodeownersSection[] { title: 'Product Safety Team', rules: [buildRuleForPackage('phishing-controller')], }, + { + title: 'Universal KYC Team', + rules: [buildRuleForPackage('kyc-controller')], + }, { title: 'Swaps-Bridge Team', rules: [ @@ -690,6 +697,7 @@ function buildPackageReleaseSection(): CodeownersSection { 'chomp-api-service', 'money-account-upgrade-controller', 'snap-account-service', + 'kyc-controller', ] as const satisfies (keyof typeof PACKAGES)[]; return { diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md new file mode 100644 index 0000000000..29f5a43661 --- /dev/null +++ b/packages/kyc-controller/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `KycController` and `KycService`, a shared, platform-agnostic KYC / identity-verification controller used across MetaMask clients ([#9615](https://github.com/MetaMask/core/pull/9615)) + - `KycController` (`BaseController`) owns the flow state machine, the Check/Auth frame message protocol, X25519 credential decryption, and SumSub orchestration via an injected `KycSumSubLauncher` adapter. + - `KycService` performs the Universal KYC (UKYC) HTTP calls via an injected `fetch`, sourcing the auth bearer token and geolocation through the messenger. + - Exposes a vendor-neutral, per-product surface (`ramps`, `card`) plus reselect selectors. +- Add automatic post-authentication continuation to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) + - `initialize` and `acceptTermsAndStartSession` now accept an optional `product` (`ramps` | `card`), tracked in new `activeProduct` state. + - When a `product` is set, reaching the `form` phase automatically runs the KYC-required check and, when KYC is required, launches the SumSub document-verification sub-flow — no extra `checkKycRequired` / `startSumSub` calls needed. When no `product` is set, the flow stops at `form` for the consumer to drive manually (unchanged behavior). +- Add optional `baseUrl` option to `KycService` constructor that overrides the base URL derived from `env`, enabling clients to target a custom (e.g. local or staging) KYC API ([#9615](https://github.com/MetaMask/core/pull/9615)) + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/LICENSE b/packages/kyc-controller/LICENSE new file mode 100644 index 0000000000..9ec4f4514e --- /dev/null +++ b/packages/kyc-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/kyc-controller/LICENSE.APACHE2 b/packages/kyc-controller/LICENSE.APACHE2 new file mode 100644 index 0000000000..e6e77b0890 --- /dev/null +++ b/packages/kyc-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/kyc-controller/LICENSE.MIT b/packages/kyc-controller/LICENSE.MIT new file mode 100644 index 0000000000..fe29e78e0f --- /dev/null +++ b/packages/kyc-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/kyc-controller/README.md b/packages/kyc-controller/README.md new file mode 100644 index 0000000000..84906e80e8 --- /dev/null +++ b/packages/kyc-controller/README.md @@ -0,0 +1,679 @@ +# KYC Controller `@metamask/kyc-controller` + +Shared KYC / identity verification controller used across MetaMask clients + +## Installation + +`yarn add @metamask/kyc-controller` + +or + +`npm install @metamask/kyc-controller` + +## Development + +To rebuild the package automatically whenever you change a source file, run the `build:watch` script: + +`yarn workspace @metamask/kyc-controller run build:watch` + +This watches `src/**/*.ts` and re-runs the build on each change (it also performs an initial build on start), which is useful when developing against a client that consumes this package locally. + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). + +## Architecture + +`@metamask/kyc-controller` is a shared, **platform-agnostic** package that owns +the end-to-end KYC / identity-verification flow used across MetaMask clients +(mobile, extension, web). It hides the vendor implementation (currently +**MoonPay** for identity + **SumSub** for document verification) behind a +vendor-neutral, per-product surface consumed by features such as **ramps** and +**card**. + +This document explains: + +- The package's internal building blocks and responsibilities. +- How the pieces communicate (messenger actions, injected adapters). +- The identity flow as a state machine and an end-to-end sequence. +- The encrypted frame message protocol and crypto. +- How the **metamask-mobile** client wires everything together on the client + side. + +--- + +### 1. Design principles + +The package is built around a few deliberate constraints: + +| Principle | How it shows up in the code | +| ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Vendor-neutral surface** | Consumers deal with `KycProduct` (`'ramps' \| 'card'`) and a phase machine, never with MoonPay/SumSub specifics. `KycVendor` is internal. | +| **Platform-agnostic core** | No React, no `Buffer`/`atob`, no native SDK imports. Crypto uses `@noble/*` + `@scure/base`. WebView/iframe presentation and the SumSub SDK are **injected** by each client. | +| **Controller owns orchestration; clients own presentation** | `KycController` owns all state, HTTP orchestration, crypto and the frame protocol. Clients only render frames, forward raw messages, and present the SumSub SDK. | +| **Stateless service** | `KycService` performs HTTP only; it holds no state and derives auth/geolocation from other controllers via the messenger. | +| **Everything through the messenger** | Both classes register their public methods as messenger actions, and reach external capabilities (auth token, geolocation) via delegated actions. | + +--- + +### 2. Component overview + +The package splits cleanly into a **stateful orchestrator** (`KycController`), a +**stateless HTTP client** (`KycService`), and supporting modules (crypto, +selectors, types). + +```mermaid +graph TB + subgraph pkg["@metamask/kyc-controller"] + direction TB + Controller["KycController
(BaseController)
state + orchestration + frame protocol"] + Service["KycService
(stateless)
HTTP + response validation"] + Crypto["crypto.ts
X25519 ECDH + AES-256-GCM"] + Selectors["selectors.ts
memoized reselect selectors"] + Types["types.ts
KycPhase, KycProduct,
KycSumSubLauncher, ..."] + Country["countryCodes.ts
alpha-2 → alpha-3"] + end + + subgraph deps["External MetaMask dependencies"] + Base["@metamask/base-controller"] + Msgr["@metamask/messenger"] + CU["@metamask/controller-utils
createServicePolicy, HttpError"] + Geo["GeolocationController"] + Auth["AuthenticationController
(profile-sync)"] + end + + subgraph vendor["Vendor backends (HTTP / frames)"] + UKYC["Universal KYC API
kyc-api.cx.metamask.io"] + Frames["MoonPay frames
blocks.moonpay.com"] + SumSubSDK["SumSub SDK
(native / web)"] + end + + Controller -->|"decryptCredentials()"| Crypto + Controller -->|"messenger.call(KycService:*)"| Service + Controller -.->|"injected launcher"| SumSubSDK + Controller -->|"builds frame URLs
handles frame messages"| Frames + + Service -->|"createServicePolicy / HttpError"| CU + Service -->|"messenger.call(GeolocationController:getGeolocation)"| Geo + Service -->|"messenger.call(AuthenticationController:getBearerToken)"| Auth + Service -->|"fetch()"| UKYC + + Controller --- Base + Controller --- Msgr + Service --- Msgr + Selectors -.->|"read"| Controller +``` + +#### 2.1 `KycController` + +- Extends `BaseController<'KycController', KycControllerState, KycControllerMessenger>`. +- Holds **all flow state** (see [§3](#3-state-shape)). +- Owns an ephemeral **X25519 keypair** (`#keypair`) generated at construction — + never persisted, used only for the frame key exchange. +- Registers its public methods as messenger actions via + `registerMethodActionHandlers`. +- Calls `KycService` exclusively **through the messenger** (`KycService:*` + actions), never a direct reference. +- Delegates SumSub SDK presentation to an injected `sumsubLauncher` + (`KycSumSubLauncher`). +- When the flow is scoped to a product (passed to `initialize` / + `acceptTermsAndStartSession` and stored as `activeProduct`), automatically + runs the KYC-required check once authenticated and chains into document + verification when KYC is required — no extra consumer calls needed. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`initialize`, `loadDisclaimers`, `acceptTermsAndStartSession`, +`clearSavedTerms`, `handleFrameMessage`, `buildCheckFrameUrl`, +`buildAuthFrameUrl`, `buildResetFrameUrl`, `checkKycRequired`, `getKycStatus`, +`startSumSub`, `reset`. + +#### 2.2 `KycService` + +- **Stateless**, platform-agnostic HTTP client for the Universal KYC (UKYC) + backend. +- Base URL derived from `env` (`production` / `development`) or an explicit + `baseUrl` override. +- Every request is wrapped in a **service policy** (`createServicePolicy`) for + retries/circuit-breaking, and carries a **bearer token** obtained from + `AuthenticationController:getBearerToken`. +- Every response is validated with **superstruct** before being returned; + malformed responses throw a descriptive error. +- Resolves the customer's country from `GeolocationController:getGeolocation` + and maps alpha-2 → alpha-3. + +Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): + +`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, +`createUkycSession`, `submitWrappedKey`. + +Endpoints: + +| Method | HTTP | Endpoint | Purpose | +| ------------------- | ------ | --------------------------------------- | -------------------------------------------------------- | +| `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | +| `fetchDisclaimers` | `GET` | `/vendors/moonpay/disclaimers?country=` | Terms to accept | +| `createSession` | `POST` | `/vendors/moonpay/sessions` | Create vendor session | +| `checkKycRequired` | `POST` | `/vendors/moonpay/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | +| `createUkycSession` | `POST` | `/sessions` | Start SumSub sub-flow | +| `submitWrappedKey` | `POST` | `/sessions/{id}/wrapped-key` | Exchange wrapped key → applicant token | + +### 2.3 `crypto.ts` + +Implements the Check/Auth frame credential decryption: + +1. Client generates an X25519 keypair; the public key (hex) is added to the + frame URL. +2. The frame returns `{ ephemeralPublicKey, iv|nonce, ciphertext }`. +3. Client derives `shared = X25519(ourPriv, theirEphemeralPub)`, then + `key = HKDF-SHA256(shared, 32 bytes)`, then AES-256-GCM decrypts the + ciphertext (which includes the 16-byte tag). IV must be 12 bytes. + +It tolerates envelopes delivered as an object, a JSON string, or base64(JSON), +and hex-or-base64 binary fields. + +#### 2.4 `selectors.ts` + +Memoized `reselect` selectors over `KycControllerState`: +`selectKycPhase`, `selectKycSumSub`, and the parametric +`selectIsKycRequiredForProduct(product)`. + +--- + +### 3. State shape + +```mermaid +classDiagram + class KycControllerState { + +KycPhase phase + +string statusMessage + +string error + +string email + +string termsAcceptedAt [persisted] + +string[] acceptedDisclaimerIds [persisted] + +KycDisclaimer[] disclaimers + +string disclaimersError + +string geoCountry + +string sessionToken [secret] + +string accessToken [secret] + +string moonpayCustomerId + +KycProduct activeProduct + +Record kycRequiredByProduct [persisted] + +string lastCheckedAt [persisted] + +SumSubState sumsub + } + class SumSubState { + +KycSumSubStatus status + +Json result + +string sessionId + +string applicantAccessToken + } + KycControllerState --> SumSubState : sumsub +``` + +> Note: nullable fields (`error`, `email`, `sessionToken`, …) are typed as +> `T | null` in the source; `Record` is `Partial>`. +> Types are simplified above for diagram readability. + +State metadata highlights (`kycControllerMetadata`): + +- **Persisted** (`persist: true`): `termsAcceptedAt`, `acceptedDisclaimerIds`, + `kycRequiredByProduct`, `lastCheckedAt`. These survive restarts so the flow + can skip already-accepted terms and reuse cached results. +- **Secrets, never persisted / never logged**: `sessionToken`, `accessToken`, + `moonpayCustomerId`, `email`, `disclaimers`, and the whole `sumsub` sub-tree. +- Additional non-state secrets kept **off** the state object entirely: the + X25519 private key (`#keypair`) and the Auth-frame client token + (`#authClientToken`). + +--- + +### 4. The identity flow (phase state machine) + +`KycPhase` models the linear identity flow. Each transition is driven by a +controller method or an incoming frame message. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> terms : initialize() (no saved terms) + idle --> session : initialize() (saved terms + email) + + terms --> session : acceptTermsAndStartSession() + session --> check : createSession() ok + session --> terms : createSession() fails
(clears saved terms, activeProduct + stale tokens) + + check --> form : Check frame → active (already authenticated) + check --> auth : Check frame → connectionRequired (needs OTP) + check --> terms : Check frame → termsAcceptanceRequired + + auth --> form : Auth frame → active (OTP verified) + auth --> terms : Auth frame → termsAcceptanceRequired + + form --> submit : checkKycRequired()
(auto when a product is set) + submit --> done : kyc-required response ok + submit --> error : request failed + + check --> error : unexpected status / decrypt failure + auth --> error : unexpected status + done --> [*] + error --> idle : reset() + done --> idle : reset() +``` + +> When the flow is scoped to a product (a `product` is passed to `initialize` +> or `acceptTermsAndStartSession`), reaching `form` **automatically** runs the +> KYC-required check (`form → submit → done`) with no user interaction, and — if +> KYC is required — automatically launches the SumSub document-verification +> sub-flow (see [§7](#7-sumsub-sub-flow)). When no product is set the flow stops +> at `form` and the consumer drives `checkKycRequired` / `startSumSub` manually. + +> **`initialize` never tears down an active flow.** If `phase` is already one of +> the in-progress phases (`session`, `check`, `auth`, `form`, `submit`), a +> repeat `initialize` is a **no-op** — it will not create a new session, clear +> tokens, or reset `activeProduct`. Call `reset()` first to start over. + +> **`reset()` is callable from any phase and supersedes in-flight work.** In +> addition to returning `phase` to `idle` (and clearing tokens, `activeProduct`, +> and the `sumsub` sub-tree), `reset()` bumps an internal flow generation so any +> still-pending async step (geolocation, disclaimers, session creation, the +> KYC-required check, or the SumSub sub-flow) discards its result instead of +> writing it onto the now-idle controller. + +Phase meanings (from `types.ts`): + +| Phase | Meaning | +| --------- | ----------------------------------------------------------------------------------------------------------- | +| `idle` | Nothing started. | +| `terms` | Waiting for the customer to accept vendor terms. | +| `session` | Creating the vendor session. | +| `check` | Running the **invisible** connection-check frame. | +| `auth` | Running the **visible** authentication (email OTP) frame. | +| `form` | Authenticated. Auto-runs the KYC-required check when a product is set; otherwise waits for the consumer. | +| `submit` | Submitting the KYC-required check. | +| `done` | Complete — see `kycRequiredByProduct` / `sumsub`. Document verification auto-launches when KYC is required. | +| `error` | Halted — see `error`. | + +--- + +### 5. End-to-end sequence + +This sequence shows the full happy path including the two frames and the SumSub +hand-off. The **client transport** (WebView on mobile, iframe on web) is +generic — it only forwards raw frame messages to `handleFrameMessage` and posts +back any returned `reply`. + +```mermaid +sequenceDiagram + autonumber + actor User + participant UI as Client UI + transport
(WebView/iframe) + participant Ctrl as KycController + participant Svc as KycService + participant Geo as GeolocationController + participant API as UKYC API + participant Frame as MoonPay Check/Auth frame + participant Launcher as SumSub launcher (injected) + + User->>Ctrl: initialize({ email, product }) + Ctrl->>Svc: getGeoCountry() + Svc->>Geo: getGeolocation() + Note over Svc: map alpha-2 → alpha-3 locally + Ctrl->>Svc: fetchDisclaimers({ country }) + Svc->>API: GET /disclaimers + Ctrl-->>UI: phase = terms (+ disclaimers) + + User->>Ctrl: acceptTermsAndStartSession({ email }) + Ctrl->>Svc: createSession({ email, termsAcceptedAt, disclaimerIds }) + Svc->>API: POST /sessions + Ctrl-->>UI: phase = check (+ sessionToken) + + UI->>Ctrl: buildCheckFrameUrl() + Ctrl-->>UI: URL (sessionToken + publicKey) + UI->>Frame: load Check frame (invisible) + Frame-->>UI: handshake + UI->>Ctrl: handleFrameMessage(handshake) + Ctrl-->>UI: reply = ack + UI->>Frame: post ack + Frame-->>UI: complete (status + encrypted credentials) + UI->>Ctrl: handleFrameMessage(complete) + Note over Ctrl: decryptCredentials() → accessToken / clientToken + + alt Check → connectionRequired + Ctrl-->>UI: phase = auth + UI->>Frame: load Auth frame (visible, OTP) + Frame-->>UI: complete (active + credentials) + UI->>Ctrl: handleFrameMessage(complete) + end + + Ctrl-->>UI: phase = form (accessToken set) + + Note over Ctrl: activeProduct set at initialize →
continue automatically (no user action) + Ctrl->>Svc: checkKycRequired({ accessToken, country, capabilities }) + Svc->>API: POST /kyc-required + Ctrl-->>UI: phase = done (kycRequiredByProduct[product]) + + opt kycRequired === true → auto-launch document verification + Ctrl->>Svc: createUkycSession({ jwtToken, vendorMetadata }) + Svc->>API: POST /sessions + Ctrl->>Svc: submitWrappedKey({ sessionId, wrappedUserKey, ... }) + Svc->>API: POST /sessions/{id}/wrapped-key + Ctrl->>Launcher: launch({ applicantAccessToken, onTokenExpiration, onStatusChange }) + Launcher-->>Ctrl: SDK result + Ctrl-->>UI: sumsub.status = complete (+ result) + end +``` + +> The KYC-required check and the document-verification launch after `form` are +> driven by the controller itself, not the user — the flow captures the +> `product` at `initialize` and continues automatically. If `initialize` is +> called without a `product`, the flow stops at `form` and the consumer triggers +> `checkKycRequired` (and later `startSumSub`) explicitly. + +--- + +### 6. Frame message protocol & crypto + +The Check, Auth and Reset frames all speak a small `postMessage` protocol. +`KycController.handleFrameMessage` implements the identity portion; the client +transport is responsible only for delivering messages and injecting replies. + +```mermaid +sequenceDiagram + autonumber + participant Frame as MoonPay frame + participant UI as Client transport + participant Ctrl as KycController + + Frame->>UI: { kind: "handshake", meta:{channelId} } + UI->>Ctrl: handleFrameMessage({ message }) + Ctrl-->>UI: { reply: { version:2, meta:{channelId}, kind:"ack" } } + UI->>Frame: postMessage(ack) + + Frame->>UI: { kind:"complete", meta:{channelId},
payload:{ status, credentials, customer } } + UI->>Ctrl: handleFrameMessage({ message }) + Note over Ctrl: 1. phase guard: only honor ch_1 in `check`,
ch_2 in `auth` — else drop the message
2. store customer.id (moonpayCustomerId)
3. decryptCredentials(envelope, privKey)
4. route by channelId (ch_1 Check / ch_2 Auth) + Ctrl->>Ctrl: apply outcome → next phase +``` + +Channels: `ch_1` = Check, `ch_2` = Auth, `ch_reset` = Reset. + +> **Phase-guarded intake.** A `complete` is only processed when the flow is +> actually waiting on that frame — `ch_1` while `phase === 'check'`, `ch_2` +> while `phase === 'auth'`. Because both outcome handlers advance `phase` to +> `form` synchronously, a stale, duplicate, or post-`reset()` `complete` +> (delivered once the flow has moved on) is dropped before any state is touched, +> so it cannot resurrect tokens, re-store `customer.id`, or rewind `phase`. +> Frame messages are external input and are not covered by the `#generation` +> guard used for the controller's own async steps, so this boundary check is how +> late frame posts are neutralized. + +Credential decryption (`crypto.ts`): + +```mermaid +graph LR + A["envelope
{ ephemeralPublicKey, iv|nonce, ciphertext }"] --> B["X25519 ECDH
shared = f(ourPriv, theirPub)"] + B --> C["HKDF-SHA256
key (32 bytes)"] + C --> D["AES-256-GCM decrypt
(iv = 12 bytes)"] + D --> E["JSON credentials
{ accessToken?, clientToken? }"] +``` + +Check-frame outcomes (`#handleCheckOutcome`): + +- `active` + `accessToken` → phase `form` (already authenticated). +- `connectionRequired` + `clientToken` → store `#authClientToken`, phase `auth`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +Auth-frame outcomes (`#handleAuthOutcome`): + +- `active` + `accessToken` → phase `form`. +- `termsAcceptanceRequired` → clear saved terms, phase `terms`. +- anything else → `error`. + +--- + +### 7. SumSub sub-flow + +The document-verification sub-flow tracks its own status independently of the +identity `phase`, and delegates the actual SDK presentation to the injected +launcher. + +```mermaid +stateDiagram-v2 + [*] --> idle + idle --> creatingSession : startSumSub() + creatingSession --> fetchingToken : createUkycSession() ok + fetchingToken --> launching : submitWrappedKey() ok + launching --> inProgress : onStatusChange (non-Completed) + launching --> complete : onStatusChange = Completed + inProgress --> complete : onStatusChange = Completed + launching --> failed : resolves without a Completed status + inProgress --> failed : resolves without a Completed status + creatingSession --> failed : error + fetchingToken --> failed : error + launching --> failed : launcher unavailable / error +``` + +> **Completion is status-driven, not resolution-driven.** A resolved `launch` +> is only recorded as `complete` when the SDK reported the `Completed` status +> via `onStatusChange` at least once. If `launch` resolves without ever having +> reported `Completed` (e.g. the applicant abandoned the flow, or a non-success +> outcome), the controller records `failed` — so consumers never mistake an +> unfinished flow for a verified one. + +The `KycSumSubLauncher` interface (injected per client): + +```ts +type KycSumSubLauncher = { + isAvailable(): boolean; + launch(params: KycSumSubLaunchParams): Promise>; +}; +``` + +`launch` receives `applicantAccessToken`, an `onTokenExpiration` callback (the +controller re-runs `submitWrappedKey` to refresh — but **refuses to refresh +after a `reset()`**, throwing instead so a still-open SDK cannot keep an +orphaned UKYC session alive), and an `onStatusChange` callback that the +controller maps into `sumsub.status`. + +--- + +### 8. Messenger wiring + +Both classes are messenger-driven. The controller depends on the service's +actions; the service depends on auth + geolocation actions from other +controllers. + +```mermaid +graph LR + subgraph CtrlMsgr["KycControllerMessenger"] + C_own["Own actions:
KycController:getState + 12 methods"] + C_ext["Allowed (delegated):
KycService:*"] + end + subgraph SvcMsgr["KycServiceMessenger"] + S_own["Own actions:
KycService: 6 methods"] + S_ext["Allowed (delegated):
AuthenticationController:getBearerToken
GeolocationController:getGeolocation"] + end + + C_ext -.delegates.-> S_own + S_ext -.delegates.-> Auth["AuthenticationController"] + S_ext -.delegates.-> Geo["GeolocationController"] +``` + +- `KycController` emits `KycController:stateChange` and exposes + `KycController:getState` plus its method actions. +- `KycController`'s `AllowedActions` = `KycServiceMethodActions` — it can call + the service. +- `KycService`'s `AllowedActions` = the auth bearer-token and geolocation + actions. + +--- + +### 9. Client-side usage (metamask-mobile) + +The mobile app is a reference consumer. It wires the controller/service into the +Engine, injects a React Native SumSub launcher, bridges WebView frame messages, +and reads state through Redux selectors. The **package stays free of any of +this** — all React/native/WebView code lives in the app. + +```mermaid +graph TB + subgraph app["metamask-mobile"] + direction TB + subgraph engine["Engine wiring"] + CInit["kyc-controller-init.ts
new KycController({ messenger, state, sumsubLauncher })"] + SInit["kyc-service-init.ts
new KycService({ fetch, env, messenger, baseUrl })"] + CMsgr["kyc-controller-messenger.ts
delegates KycService:*"] + SMsgr["kyc-service-messenger.ts
delegates Auth + Geolocation"] + Launcher["reactNativeSumSubLauncher.ts
lazy-loads @sumsub/react-native-mobilesdk-module"] + end + subgraph ui["UI layer"] + Hook["useKycFlow.ts
binds controller ↔ React"] + Frame["MoonpayFrame + useMoonpayFrame
WebView postMessage bridge"] + Reset["useMoonpayReset.ts
Reset frame"] + Demo["MoonpayDemo / SumSubDemo / KYCDemo
screens"] + end + subgraph redux["Redux"] + Sel["selectors/kycController.ts
wraps core selectors"] + end + end + + subgraph core["@metamask/kyc-controller"] + KC["KycController"] + KS["KycService"] + end + + CInit --> KC + SInit --> KS + CInit --> Launcher + Launcher -. injected .-> KC + CMsgr --> KC + SMsgr --> KS + + Hook -->|"Engine.context.KycController.*"| KC + Hook -->|"useSelector"| Sel + Sel -->|"state.engine.backgroundState.KycController"| KC + Frame -->|"raw frame message"| Hook + Hook -->|"handleFrameMessage()"| KC + Demo --> Hook + Demo --> Frame + Demo --> Reset +``` + +#### 9.1 Engine wiring + +- **`kyc-controller-init.ts`** constructs `KycController` with the persisted + state slice and injects `reactNativeSumSubLauncher`. +- **`kyc-service-init.ts`** constructs `KycService` with the global `fetch`, an + `env` derived from `isProduction()`, and (currently) a dev `baseUrl` override. +- **`kyc-controller-messenger.ts`** delegates the six `KycService:*` actions to + the controller's messenger. +- **`kyc-service-messenger.ts`** delegates + `AuthenticationController:getBearerToken` and + `GeolocationController:getGeolocation` to the service's messenger. + +#### 9.2 SumSub launcher adapter + +`reactNativeSumSubLauncher` implements `KycSumSubLauncher`: + +- `isAvailable()` checks for the native module (`NativeModules.SNSMobileSDKModule`). +- `launch()` **lazily imports** `@sumsub/react-native-mobilesdk-module` (so + merely wiring the controller never loads the native module — important for + Jest / Expo Go), initializes the SDK with the applicant token, and forwards + `onStatusChanged` / token-expiration callbacks back to the controller. + +#### 9.3 React binding — `useKycFlow` + +A thin hook that: + +- Reads controller state from Redux via the `selectors/kycController.ts` + selectors. +- Forwards user intents to controller actions through + `Engine.context.KycController.*` (`initialize`, `acceptTermsAndStartSession`, + `checkKycRequired`, `startSumSub`, `clearSavedTerms`, `reset`). +- Builds frame URLs on demand (`buildCheckFrameUrl` / `buildAuthFrameUrl`) as + the phase changes. +- Bridges WebView frame messages into `handleFrameMessage` and posts back the + returned `reply`. +- Keeps view-only concerns (email input, debug log, frame visibility) in local + React state. + +#### 9.4 WebView transport — `useMoonpayFrame` / `MoonpayFrame` + +- Injects a `postMessage` bridge into the frame that forwards the frame's + outbound messages to React Native via `window.ReactNativeWebView.postMessage`. +- **Validates the origin** (`https://blocks.moonpay.com`) before handing a + message to the controller. +- Implements `reply()` by dispatching a `MessageEvent` back into the WebView on + both `document` and `window` (platform quirk between iOS WKWebView and Android + System WebView). +- The Check frame is rendered **invisible** (1×1, opacity 0) unless the user + toggles it in the debug panel; the Auth frame is rendered visibly for OTP. + +#### 9.5 Redux selectors + +`selectors/kycController.ts` wraps the package's core selectors and reads the +slice at `state.engine.backgroundState.KycController`, exposing app-friendly +selectors (`selectKycPhase`, `selectKycSumSub`, +`selectIsKycRequiredForProduct(product)`, plus per-field selectors). + +--- + +### 10. Boundaries & responsibilities summary + +```mermaid +graph LR + subgraph shared["Shared package (platform-agnostic)"] + A1["Flow orchestration + state"] + A2["HTTP + response validation"] + A3["Crypto (X25519 / AES-GCM)"] + A4["Frame message protocol"] + A5["Selectors + vendor-neutral types"] + end + subgraph client["Client (per platform)"] + B1["Engine/DI wiring"] + B2["WebView / iframe transport"] + B3["SumSub SDK launcher"] + B4["Auth token + geolocation providers"] + B5["UI + Redux binding"] + end + shared -. injected adapters .- client +``` + +| Concern | Owner | +| ------------------------------------ | ------------------------------------------- | +| Flow phase machine & state | `KycController` (shared) | +| UKYC HTTP + validation + retries | `KycService` (shared) | +| Credential decryption / key exchange | `crypto.ts` (shared) | +| Frame message semantics | `KycController.handleFrameMessage` (shared) | +| Frame **transport** (WebView/iframe) | Client | +| SumSub SDK presentation | Client (via `KycSumSubLauncher`) | +| Auth bearer token / geolocation | Other controllers (via messenger) | +| Persistence of state | Client (base-controller persistence) | + +--- + +### Appendix — key source files + +| File | Responsibility | +| ---------------------- | ----------------------------------------------------- | +| `src/KycController.ts` | Stateful orchestrator, phase machine, frame protocol. | +| `src/KycService.ts` | Stateless UKYC HTTP client + superstruct validation. | +| `src/crypto.ts` | X25519 ECDH + AES-256-GCM credential decryption. | +| `src/selectors.ts` | Memoized selectors over controller state. | +| `src/types.ts` | `KycPhase`, `KycProduct`, `KycSumSubLauncher`, etc. | +| `src/countryCodes.ts` | ISO alpha-2 → alpha-3 mapping. | +| `src/index.ts` | Public exports (no barrel wildcards). | + +Reference client (metamask-mobile): + +| File | Responsibility | +| -------------------------------------------------------------- | --------------------------------------- | +| `app/core/Engine/controllers/kyc/kyc-controller-init.ts` | Construct controller + inject launcher. | +| `app/core/Engine/controllers/kyc/kyc-service-init.ts` | Construct service. | +| `app/core/Engine/controllers/kyc/reactNativeSumSubLauncher.ts` | Native SumSub adapter. | +| `app/core/Engine/messengers/kyc/*.ts` | Messenger delegation. | +| `app/components/Views/MoonpayDemo/useKycFlow.ts` | React ↔ controller binding. | +| `app/components/Views/MoonpayDemo/useMoonpayFrame.ts` | WebView postMessage bridge. | +| `app/selectors/kycController.ts` | Redux selectors. | diff --git a/packages/kyc-controller/jest.config.js b/packages/kyc-controller/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/kyc-controller/jest.config.js @@ -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'); + +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/kyc-controller/package.json b/packages/kyc-controller/package.json new file mode 100644 index 0000000000..2c5b3b1d55 --- /dev/null +++ b/packages/kyc-controller/package.json @@ -0,0 +1,89 @@ +{ + "name": "@metamask/kyc-controller", + "version": "0.0.0", + "description": "Shared KYC / identity verification controller used across MetaMask clients", + "keywords": [ + "Ethereum", + "MetaMask" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/kyc-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "license": "(MIT OR Apache-2.0)", + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "files": [ + "dist/" + ], + "sideEffects": false, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + }, + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:all": "ts-bridge --project tsconfig.build.json --verbose --clean", + "build:docs": "typedoc", + "build:watch": "chokidar 'src/**/*.ts' -c 'ts-bridge --project tsconfig.build.json --verbose --no-references' --initial", + "changelog:update": "../../scripts/update-changelog.sh @metamask/kyc-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/kyc-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": "^9.1.0", + "@metamask/controller-utils": "^12.3.0", + "@metamask/geolocation-controller": "^0.1.3", + "@metamask/messenger": "^2.0.0", + "@metamask/profile-sync-controller": "^28.3.0", + "@metamask/superstruct": "^3.1.0", + "@metamask/utils": "^11.11.0", + "@noble/ciphers": "^1.3.0", + "@noble/curves": "^1.9.2", + "@noble/hashes": "^1.8.0", + "@scure/base": "^1.2.6", + "reselect": "^5.1.1" + }, + "devDependencies": { + "@metamask/auto-changelog": "^6.1.0", + "@ts-bridge/cli": "^0.6.4", + "@types/jest": "^29.5.14", + "chokidar-cli": "^3.0.0", + "deepmerge": "^4.2.2", + "jest": "^29.7.0", + "nock": "^13.3.1", + "ts-jest": "^29.2.5", + "tsx": "^4.20.5", + "typedoc": "^0.25.13", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.3.3" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts new file mode 100644 index 0000000000..92e34d45eb --- /dev/null +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -0,0 +1,168 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycController } from './KycController'; + +/** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + */ +export type KycControllerInitializeAction = { + type: `KycController:initialize`; + handler: KycController['initialize']; +}; + +/** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ +export type KycControllerLoadDisclaimersAction = { + type: `KycController:loadDisclaimers`; + handler: KycController['loadDisclaimers']; +}; + +/** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + */ +export type KycControllerAcceptTermsAndStartSessionAction = { + type: `KycController:acceptTermsAndStartSession`; + handler: KycController['acceptTermsAndStartSession']; +}; + +/** + * Clears the persisted terms acceptance. + */ +export type KycControllerClearSavedTermsAction = { + type: `KycController:clearSavedTerms`; + handler: KycController['clearSavedTerms']; +}; + +/** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ +export type KycControllerHandleFrameMessageAction = { + type: `KycController:handleFrameMessage`; + handler: KycController['handleFrameMessage']; +}; + +/** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ +export type KycControllerBuildCheckFrameUrlAction = { + type: `KycController:buildCheckFrameUrl`; + handler: KycController['buildCheckFrameUrl']; +}; + +/** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ +export type KycControllerBuildAuthFrameUrlAction = { + type: `KycController:buildAuthFrameUrl`; + handler: KycController['buildAuthFrameUrl']; +}; + +/** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ +export type KycControllerBuildResetFrameUrlAction = { + type: `KycController:buildResetFrameUrl`; + handler: KycController['buildResetFrameUrl']; +}; + +/** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ +export type KycControllerCheckKycRequiredAction = { + type: `KycController:checkKycRequired`; + handler: KycController['checkKycRequired']; +}; + +/** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ +export type KycControllerGetKycStatusAction = { + type: `KycController:getKycStatus`; + handler: KycController['getKycStatus']; +}; + +/** + * Runs the SumSub document-verification sub-flow: creates a UKYC session, + * exchanges the wrapped key for an applicant access token, and presents the + * SDK via the injected launcher. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ +export type KycControllerStartSumSubAction = { + type: `KycController:startSumSub`; + handler: KycController['startSumSub']; +}; + +/** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ +export type KycControllerResetAction = { + type: `KycController:reset`; + handler: KycController['reset']; +}; + +/** + * Union of all KycController action types. + */ +export type KycControllerMethodActions = + | KycControllerInitializeAction + | KycControllerLoadDisclaimersAction + | KycControllerAcceptTermsAndStartSessionAction + | KycControllerClearSavedTermsAction + | KycControllerHandleFrameMessageAction + | KycControllerBuildCheckFrameUrlAction + | KycControllerBuildAuthFrameUrlAction + | KycControllerBuildResetFrameUrlAction + | KycControllerCheckKycRequiredAction + | KycControllerGetKycStatusAction + | KycControllerStartSumSubAction + | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts new file mode 100644 index 0000000000..1bcd8efd70 --- /dev/null +++ b/packages/kyc-controller/src/KycController.test.ts @@ -0,0 +1,1532 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/hashes/utils'; + +import { KycController } from './KycController'; +import type { KycControllerMessenger } from './KycController'; +import type { KycSumSubLauncher } from './types'; + +/** + * Builds an encrypted envelope for a recipient's X25519 public key. + * + * @param publicKey - The recipient's public key bytes. + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function makeEnvelope( + publicKey: Uint8Array, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, publicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(12).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + return { + ephemeralPublicKey: bytesToHex(ephemeralPublic), + iv: bytesToHex(iv), + ciphertext: bytesToHex(ciphertext), + }; +} + +/** + * Extracts the controller's ephemeral public key from the Check-frame URL and + * builds a decryptable credentials envelope for it. + * + * @param controller - The controller under test (must have a session token). + * @param credentials - The plaintext credentials to encrypt. + * @returns The encrypted envelope. + */ +function envelopeFor( + controller: KycController, + credentials: Record, +): { ephemeralPublicKey: string; iv: string; ciphertext: string } { + const url = controller.buildCheckFrameUrl(); + const publicKeyHex = new URL(url as string).searchParams.get( + 'publicKey', + ) as string; + return makeEnvelope(hexToBytes(publicKeyHex), credentials); +} + +describe('KycController', () => { + describe('constructor', () => { + it('accepts initial state merged over defaults', async () => { + await withController( + { options: { state: { phase: 'form' } } }, + ({ controller }) => { + expect(controller.state.phase).toBe('form'); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + }); + + describe('initialize', () => { + it('auto-creates a session when terms and email are present', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.geoCountry).toBe('USA'); + expect(controller.state.sessionToken).toBe('sess'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('falls back to the terms phase and loads disclaimers when geo fails and no terms exist', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockRejectedValue(new Error('geo down')); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.disclaimersError).toMatch(/Failed to load/u); + }); + }); + + it('captures the active product for the automatic post-auth continuation', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ product: 'card' }); + + expect(controller.state.activeProduct).toBe('card'); + }); + }); + + it('clears a stale active product when re-initialized without one', async () => { + await withController( + { options: { state: { activeProduct: 'card' } } }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize({ email: 'a@b.co' }); + + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('does not restart an in-progress session flow', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'live-session', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + activeProduct: 'ramps', + }, + }, + }, + async ({ controller, handlers }) => { + await controller.initialize({ email: 'other@b.co', product: 'card' }); + + // A repeat initialize mid-flow must be a no-op: no new session, no + // token/phase teardown, and no clobbering of the active product. + expect(handlers.createSession).not.toHaveBeenCalled(); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(controller.state.phase).toBe('check'); + expect(controller.state.sessionToken).toBe('live-session'); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.email).toBe('a@b.co'); + }, + ); + }); + + it('stays on terms when terms exist but no email is available', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('USA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.initialize(); + + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + }); + + describe('loadDisclaimers', () => { + it('loads disclaimers for a provided country', async () => { + await withController(async ({ controller, handlers }) => { + const disclaimers = [{ id: '1', display_name: 'T', url: 'u' }]; + handlers.fetchDisclaimers.mockResolvedValue(disclaimers); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimers).toStrictEqual(disclaimers); + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + }); + }); + + it('caches the provided country override in geoCountry', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.geoCountry).toBe('USA'); + }); + }); + + it('lets a later checkKycRequired reuse the overridden country without an override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + await controller.loadDisclaimers({ country: 'USA' }); + await controller.checkKycRequired({ product: 'ramps' }); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.error).toBeNull(); + }, + ); + }); + + it('uses the cached geoCountry when no country is provided', async () => { + await withController( + { options: { state: { geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(handlers.getGeoCountry).not.toHaveBeenCalled(); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'USA', + }); + }, + ); + }); + + it('resolves the country when neither param nor cache is available', async () => { + await withController(async ({ controller, handlers }) => { + handlers.getGeoCountry.mockResolvedValue('FRA'); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.loadDisclaimers(); + + expect(controller.state.geoCountry).toBe('FRA'); + expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + country: 'FRA', + }); + }); + }); + + it('records an error when loading fails', async () => { + await withController(async ({ controller, handlers }) => { + handlers.fetchDisclaimers.mockRejectedValue(new Error('boom')); + + await controller.loadDisclaimers({ country: 'USA' }); + + expect(controller.state.disclaimersError).toMatch(/boom/u); + }); + }); + }); + + describe('acceptTermsAndStartSession', () => { + it('captures terms and creates a session', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ sessionToken: 'sess' }); + + await controller.acceptTermsAndStartSession({ + email: 'a@b.co', + product: 'ramps', + }); + + expect(controller.state.acceptedDisclaimerIds).toStrictEqual(['1']); + expect(controller.state.termsAcceptedAt).not.toBeNull(); + expect(controller.state.activeProduct).toBe('ramps'); + expect(controller.state.phase).toBe('check'); + }, + ); + }); + + it('clears stale auth tokens when a new session is created', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'old-session', + accessToken: 'stale-access', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockResolvedValue({ + sessionToken: 'new-session', + }); + + // Establish an auth-frame client token from a prior authentication. + const envelope = envelopeFor(controller, { + clientToken: 'old-client', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'connectionRequired', credentials: envelope }, + }, + }); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=old-client', + ); + + // Creating a new session must invalidate the carried-over auth. + await controller.acceptTermsAndStartSession(); + + expect(controller.state.accessToken).toBeNull(); + expect(controller.buildAuthFrameUrl()).toBeNull(); + expect(controller.state.sessionToken).toBe('new-session'); + }, + ); + }); + + it('clears the old session token while a new session is being created', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let releaseSession: (value: { + sessionToken: string; + }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((resolve) => { + releaseSession = resolve; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // While the request is in flight (phase `session`) the stale token + // must already be gone so no Check frame URL can be built for it. + expect(controller.state.phase).toBe('session'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + + releaseSession({ sessionToken: 'new-session' }); + await pending; + + expect(controller.state.sessionToken).toBe('new-session'); + expect(controller.buildCheckFrameUrl()).toContain( + 'sessionToken=new-session', + ); + }, + ); + }); + + it('reverts to terms when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.error).toMatch(/Session creation failed/u); + // A failed creation must not leave the old session token behind, so + // the Check frame cannot be built against an invalid session. + expect(controller.state.sessionToken).toBeNull(); + expect(controller.buildCheckFrameUrl()).toBeNull(); + }, + ); + }); + + it('leaves the controller idle when reset() runs before session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + sessionToken: 'old-session', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + let rejectSession: (reason: Error) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.createSession.mockReturnValue( + new Promise<{ sessionToken: string }>((_resolve, reject) => { + rejectSession = reject; + }), + ); + + const pending = controller.acceptTermsAndStartSession(); + + // Reset while the create request is in flight, then let it fail. The + // superseded flow must not force the now-idle controller back to + // `terms` or re-run disclaimer loading. + controller.reset(); + rejectSession(new Error('nope')); + await pending; + + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + }, + ); + }); + + it('clears the active product when session creation fails', async () => { + await withController( + { + options: { + state: { + email: 'a@b.co', + activeProduct: 'card', + disclaimers: [{ id: '1', display_name: 'T', url: 'u' }], + }, + }, + }, + async ({ controller, handlers }) => { + handlers.createSession.mockRejectedValue(new Error('nope')); + handlers.fetchDisclaimers.mockResolvedValue([]); + + await controller.acceptTermsAndStartSession({ product: 'ramps' }); + + // The failed flow must not leave a lingering product behind that a + // later product-less `acceptTermsAndStartSession` would auto-run. + expect(controller.state.phase).toBe('terms'); + expect(controller.state.activeProduct).toBeNull(); + }, + ); + }); + + it('fails when no email is available', async () => { + await withController( + { + options: { + state: { disclaimers: [{ id: '1', display_name: 'T', url: 'u' }] }, + }, + }, + async ({ controller }) => { + await controller.acceptTermsAndStartSession(); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing email/u); + }, + ); + }); + + it('fails when no disclaimers were accepted', async () => { + await withController(async ({ controller }) => { + await controller.acceptTermsAndStartSession({ email: 'a@b.co' }); + + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Missing terms acceptance/u); + }); + }); + }); + + describe('clearSavedTerms', () => { + it('clears persisted terms', async () => { + await withController( + { + options: { + state: { termsAcceptedAt: 't', acceptedDisclaimerIds: ['1'] }, + }, + }, + ({ controller }) => { + controller.clearSavedTerms(); + expect(controller.state.termsAcceptedAt).toBeNull(); + expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); + }, + ); + }); + }); + + describe('handleFrameMessage', () => { + it('acks a handshake', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { kind: 'handshake', meta: { channelId: 'ch_1' } }, + }); + expect(result).toStrictEqual({ + reply: { version: 2, meta: { channelId: 'ch_1' }, kind: 'ack' }, + }); + }); + }); + + it('ignores undefined and non-complete messages', async () => { + await withController(async ({ controller }) => { + expect( + await controller.handleFrameMessage({ message: undefined }), + ).toStrictEqual({}); + expect( + await controller.handleFrameMessage({ message: { kind: 'other' } }), + ).toStrictEqual({}); + }); + }); + + it('captures the customer id and ignores a status-less complete message', async () => { + await withController( + { options: { state: { phase: 'check' } } }, + async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { customer: { id: 'cust-1' } }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.moonpayCustomerId).toBe('cust-1'); + }, + ); + }); + + it('ignores messages on an unknown channel', async () => { + await withController(async ({ controller }) => { + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_unknown' }, + payload: { status: 'active' }, + }, + }); + expect(result).toStrictEqual({}); + }); + }); + + it('ignores a stale completion for a frame the flow is no longer waiting on', async () => { + // Phase `done` (e.g. after a completed flow or a `reset()` that returns + // to an idle phase) means the Check frame is no longer active; a late or + // duplicate `ch_1` completion must not resurrect tokens or rewind phase. + await withController( + { options: { state: { phase: 'done', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'active', + credentials: envelope, + customer: { id: 'cust-late' }, + }, + }, + }); + expect(result).toStrictEqual({}); + expect(controller.state.phase).toBe('done'); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.moonpayCustomerId).toBeNull(); + }, + ); + }); + + it('fails when credential decryption throws', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: 'not-decryptable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + expect(controller.state.error).toMatch(/Failed to decrypt/u); + }, + ); + }); + + describe('check frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-1'); + }, + ); + }); + + it('moves to auth on connectionRequired and enables the auth frame URL', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + clientToken: 'client-1', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { + status: 'connectionRequired', + credentials: envelope, + }, + }, + }); + expect(controller.state.phase).toBe('auth'); + expect(controller.buildAuthFrameUrl()).toContain( + 'clientToken=client-1', + ); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + expect(controller.state.termsAcceptedAt).toBeNull(); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'check', sessionToken: 'tok' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'failed' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + + describe('auth frame', () => { + it('moves to form on an active status with an access token', async () => { + await withController( + { options: { state: { phase: 'auth', sessionToken: 'tok' } } }, + async ({ controller }) => { + const envelope = envelopeFor(controller, { + accessToken: 'access-2', + }); + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + expect(controller.state.phase).toBe('form'); + expect(controller.state.accessToken).toBe('access-2'); + }, + ); + }); + + it('requires re-acceptance on termsAcceptanceRequired', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'termsAcceptanceRequired' }, + }, + }); + expect(controller.state.phase).toBe('terms'); + }, + ); + }); + + it('fails on an unexpected status', async () => { + await withController( + { options: { state: { phase: 'auth' } } }, + async ({ controller }) => { + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'unavailable' }, + }, + }); + expect(controller.state.phase).toBe('error'); + }, + ); + }); + }); + }); + + describe('automatic post-authentication continuation', () => { + it('stays at form and does not run the check when no product is set', async () => { + await withController( + { + options: { + state: { phase: 'check', sessionToken: 'tok', geoCountry: 'USA' }, + }, + }, + async ({ controller, handlers }) => { + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('form'); + expect(handlers.checkKycRequired).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-runs the KYC check on reaching form and stops at done when KYC is not required', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }); + expect(controller.state.kycRequiredByProduct.ramps).toBe(false); + expect(controller.state.phase).toBe('done'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + + it('auto-chains into document verification when KYC is required (via the auth frame)', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-2' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.kycRequiredByProduct.card).toBe(true); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('records a failed sub-flow without throwing when verification is required but the SDK is unavailable', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + launcher.isAvailable.mockReturnValue(false); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + const result = await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(result).toStrictEqual({}); + expect(controller.state.sumsub.status).toBe('failed'); + }, + ); + }); + + it('ignores a duplicate completion while a prior continuation is in flight', async () => { + await withController( + { + options: { + state: { + phase: 'auth', + sessionToken: 'tok', + activeProduct: 'card', + geoCountry: 'FRA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + // Hold the KYC-required check open so the first continuation is still + // in flight when the second (duplicate) completion arrives. The first + // completion moves `phase` to `form` synchronously, so the duplicate + // is dropped by the frame-phase guard before it can re-run the check. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValue( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + const message = { + kind: 'complete', + meta: { channelId: 'ch_2' }, + payload: { status: 'active', credentials: envelope }, + }; + + const first = controller.handleFrameMessage({ message }); + const second = controller.handleFrameMessage({ message }); + + releaseCheck({ kycRequired: true }); + await Promise.all([first, second]); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(1); + expect(launcher.launch).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('complete'); + }, + ); + }); + + it('allows a fresh flow to continue after a reset interrupts an in-flight continuation', async () => { + await withController( + { + options: { + state: { + phase: 'check', + email: 'a@b.co', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + // Persisted terms so a post-reset `initialize` auto-recreates the + // session (reaching phase `check`) for the second completion. + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + }, + }, + }, + async ({ controller, handlers }) => { + // The keypair is stable across reset, so both envelopes can be built + // up front while the session token (used only to derive the public + // key here) is still present. + const envelope1 = envelopeFor(controller, { + accessToken: 'access-1', + }); + const envelope2 = envelopeFor(controller, { + accessToken: 'access-2', + }); + const messageFor = ( + credentials: unknown, + ): { + kind: string; + meta: { channelId: string }; + payload: { status: string; credentials: unknown }; + } => ({ + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials }, + }); + + // Hold the first continuation open so a reset can land while it is + // still in flight. + let releaseCheck: (value: { kycRequired: boolean }) => void = () => { + // no-op placeholder until the deferred promise is wired up + }; + handlers.checkKycRequired.mockReturnValueOnce( + new Promise<{ kycRequired: boolean }>((resolve) => { + releaseCheck = resolve; + }), + ); + + const first = controller.handleFrameMessage({ + message: messageFor(envelope1), + }); + + // Reset while the continuation is awaiting the check. Its result is + // discarded by the generation guard (the check belongs to the + // superseded generation) rather than written onto the idle flow. + controller.reset(); + releaseCheck({ kycRequired: false }); + await first; + + // Re-establish a product-scoped flow (auto-creates a session and + // returns to phase `check`) and confirm the next completion continues + // again rather than being blocked forever by a stuck guard. + await controller.initialize({ product: 'ramps' }); + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + await controller.handleFrameMessage({ + message: messageFor(envelope2), + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledTimes(2); + }, + ); + }); + + it('does not launch verification when the auto-run check fails', async () => { + await withController( + { + options: { + state: { + phase: 'check', + sessionToken: 'tok', + activeProduct: 'ramps', + geoCountry: 'USA', + }, + }, + }, + async ({ controller, handlers, launcher }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + const envelope = envelopeFor(controller, { accessToken: 'access-1' }); + + await controller.handleFrameMessage({ + message: { + kind: 'complete', + meta: { channelId: 'ch_1' }, + payload: { status: 'active', credentials: envelope }, + }, + }); + + expect(controller.state.phase).toBe('error'); + expect(launcher.launch).not.toHaveBeenCalled(); + }, + ); + }); + }); + + describe('frame URL builders', () => { + it('returns null for the check frame without a session', async () => { + await withController(({ controller }) => { + expect(controller.buildCheckFrameUrl()).toBeNull(); + }); + }); + + it('builds the check frame URL with a session', async () => { + await withController( + { options: { state: { sessionToken: 'tok' } } }, + ({ controller }) => { + const url = controller.buildCheckFrameUrl() as string; + expect(url).toContain('sessionToken=tok'); + expect(url).toContain('channelId=ch_1'); + expect(url).toContain('skipKyc=true'); + }, + ); + }); + + it('returns null for the auth frame without a client token', async () => { + await withController(({ controller }) => { + expect(controller.buildAuthFrameUrl()).toBeNull(); + }); + }); + + it('builds the reset frame URL', async () => { + await withController(({ controller }) => { + expect(controller.buildResetFrameUrl()).toContain('channelId=ch_reset'); + }); + }); + }); + + describe('checkKycRequired', () => { + it('fails without an access token', async () => { + await withController(async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing accessToken/u); + }); + }); + + it('fails without a country', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller }) => { + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/Missing country/u); + }, + ); + }); + + it('caches the result on success (cached country)', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + true, + ); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + expect(controller.state.phase).toBe('done'); + }, + ); + }); + + it('accepts a country override', async () => { + await withController( + { options: { state: { accessToken: 'a' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockResolvedValue({ kycRequired: false }); + + await controller.checkKycRequired({ + product: 'card', + country: 'FRA', + }); + + expect(handlers.checkKycRequired).toHaveBeenCalledWith({ + accessToken: 'a', + country: 'FRA', + capabilities: [{ product: 'card' }], + }); + }, + ); + }); + + it('fails when the service throws', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockRejectedValue(new Error('down')); + + expect(await controller.checkKycRequired({ product: 'ramps' })).toBe( + false, + ); + expect(controller.state.error).toMatch(/KYC check failed/u); + }, + ); + }); + + it('discards a successful result when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + // Simulate a reset() landing while the HTTP call is in flight. + controller.reset(); + return { kycRequired: true }; + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.kycRequiredByProduct.ramps).toBeUndefined(); + expect(controller.state.lastCheckedAt).toBeNull(); + }, + ); + }); + + it('discards an error when reset() runs while the check is in flight', async () => { + await withController( + { options: { state: { accessToken: 'a', geoCountry: 'USA' } } }, + async ({ controller, handlers }) => { + handlers.checkKycRequired.mockImplementation(async () => { + controller.reset(); + throw new Error('down'); + }); + + const result = await controller.checkKycRequired({ + product: 'ramps', + }); + + expect(result).toBe(false); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.error).toBeNull(); + }, + ); + }); + }); + + describe('getKycStatus', () => { + it('returns the cached value or undefined', async () => { + await withController( + { options: { state: { kycRequiredByProduct: { ramps: true } } } }, + ({ controller }) => { + expect(controller.getKycStatus({ product: 'ramps' })).toBe(true); + expect(controller.getKycStatus({ product: 'card' })).toBeUndefined(); + }, + ); + }); + }); + + describe('startSumSub', () => { + it('throws and marks failed when the SDK is unavailable', async () => { + await withController(async ({ controller, launcher }) => { + launcher.isAvailable.mockReturnValue(false); + + await expect(controller.startSumSub()).rejects.toThrow( + /not available/u, + ); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('runs the full sub-flow and completes', async () => { + await withController(async ({ controller, handlers, launcher }) => { + handlers.createUkycSession.mockResolvedValue({ + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }); + handlers.submitWrappedKey.mockResolvedValue({ + status: 'ok', + applicantAccessToken: 'aat', + }); + launcher.launch.mockImplementation( + async ({ onStatusChange, onTokenExpiration }) => { + onStatusChange?.('idle', 'InProgress'); + onStatusChange?.('InProgress', 'Completed'); + await onTokenExpiration(); + return { ok: true }; + }, + ); + + const result = await controller.startSumSub({ + locale: 'fr', + debug: true, + }); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.applicantAccessToken).toBe('aat'); + // onTokenExpiration re-invokes the exchange. + expect(handlers.submitWrappedKey).toHaveBeenCalledTimes(2); + }); + }); + + it('defaults locale and debug when no params are given', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(launcher.launch).toHaveBeenCalledWith( + expect.objectContaining({ locale: 'en', debug: false }), + ); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('marks failed when launch resolves without a Completed status', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // The applicant abandons the flow: the SDK reports progress but never + // a Completed status, yet `launch` still resolves. + onStatusChange?.('idle', 'InProgress'); + return { ok: false }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: false }); + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.result).toStrictEqual({ ok: false }); + }); + }); + + it('marks failed and returns the error when a step throws', async () => { + await withController(async ({ controller, handlers }) => { + handlers.createUkycSession.mockRejectedValue(new Error('ukyc down')); + + const result = await controller.startSumSub(); + + expect(result).toMatchObject({ + error: expect.stringContaining('ukyc down'), + }); + expect(controller.state.sumsub.status).toBe('failed'); + }); + }); + + it('aborts without launching the SDK when reset() runs while in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // Simulate a reset() landing while the UKYC session is being created. + handlers.createUkycSession.mockImplementation(async () => { + controller.reset(); + return { + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + expect(launcher.launch).not.toHaveBeenCalled(); + // The interrupted step must not write stale sub-flow state. + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionId).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('aborts without launching the SDK when reset() runs just before launch', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A reset() lands during the final token exchange, i.e. after the + // session is prepared but before the SDK is presented. + handlers.submitWrappedKey.mockImplementation(async () => { + controller.reset(); + return { status: 'ok', applicantAccessToken: 'aat' }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({}); + // The SDK must not be opened on a flow that was reset to idle, and the + // `launching` status must not be written. + expect(launcher.launch).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.applicantAccessToken).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + + it('refuses to refresh the token via onTokenExpiration after a reset', async () => { + await withController(async ({ controller, handlers, launcher }) => { + let refreshError: unknown; + launcher.launch.mockImplementation(async ({ onTokenExpiration }) => { + // The SDK stays open across a reset, then asks for a fresh token. + controller.reset(); + // Only the initial submitWrappedKey (session setup) should have run. + const callsBeforeRefresh = + handlers.submitWrappedKey.mock.calls.length; + try { + await onTokenExpiration(); + } catch (error) { + refreshError = error; + } + // The refresh must not hit the stale UKYC session. + expect(handlers.submitWrappedKey.mock.calls).toHaveLength( + callsBeforeRefresh, + ); + return { ok: true }; + }); + + await controller.startSumSub(); + + expect(refreshError).toBeInstanceOf(Error); + expect((refreshError as Error).message).toMatch(/flow was reset/u); + }); + }); + + it('suppresses status and terminal writes when reset() runs during the SDK launch', async () => { + await withController(async ({ controller, launcher }) => { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + // First status arrives on the active flow, then a reset() lands and + // a later status + the resolved result must not resurrect state. + onStatusChange?.('idle', 'InProgress'); + controller.reset(); + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + + const result = await controller.startSumSub(); + + expect(result).toStrictEqual({ ok: true }); + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.result).toBeNull(); + expect(controller.state.phase).toBe('idle'); + }); + }); + }); + + describe('reset', () => { + it('clears session state but preserves persisted terms', async () => { + await withController( + { + options: { + state: { + phase: 'form', + sessionToken: 'tok', + accessToken: 'a', + activeProduct: 'ramps', + termsAcceptedAt: 't', + acceptedDisclaimerIds: ['1'], + kycRequiredByProduct: { ramps: true }, + }, + }, + }, + ({ controller }) => { + controller.reset(); + expect(controller.state.phase).toBe('idle'); + expect(controller.state.sessionToken).toBeNull(); + expect(controller.state.accessToken).toBeNull(); + expect(controller.state.activeProduct).toBeNull(); + expect(controller.state.termsAcceptedAt).toBe('t'); + expect(controller.state.kycRequiredByProduct.ramps).toBe(true); + }, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + await withController(({ rootMessenger }) => { + expect( + rootMessenger.call('KycController:buildResetFrameUrl'), + ).toContain('ch_reset'); + }); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +type ServiceHandlers = { + getGeoCountry: jest.Mock; + fetchDisclaimers: jest.Mock; + createSession: jest.Mock; + checkKycRequired: jest.Mock; + createUkycSession: jest.Mock; + submitWrappedKey: jest.Mock; +}; + +type Launcher = { + isAvailable: jest.Mock; + launch: jest.Mock; +}; + +type WithControllerCallback = (payload: { + controller: KycController; + rootMessenger: RootMessenger; + handlers: ServiceHandlers; + launcher: Launcher; +}) => Promise | ReturnValue; + +type WithControllerOptions = { + options: Partial[0]>; +}; + +const SERVICE_ACTIONS = [ + 'KycService:getGeoCountry', + 'KycService:fetchDisclaimers', + 'KycService:createSession', + 'KycService:checkKycRequired', + 'KycService:createUkycSession', + 'KycService:submitWrappedKey', +] as const; + +/** + * Wraps a test with a fully-wired controller, mocked service handlers, and a + * mocked SumSub launcher. + * + * @param args - Either a callback, or an options bag and a callback. + * @returns The callback's return value. + */ +function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] +): ReturnValue | Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + captureException: jest.fn(), + }); + const messenger: KycControllerMessenger = new Messenger({ + namespace: 'KycController', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: SERVICE_ACTIONS, + events: [], + messenger, + }); + + const handlers: ServiceHandlers = { + getGeoCountry: jest.fn().mockResolvedValue('USA'), + fetchDisclaimers: jest.fn().mockResolvedValue([]), + createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), + checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), + createUkycSession: jest.fn().mockResolvedValue({ + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }), + submitWrappedKey: jest + .fn() + .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + }; + rootMessenger.registerActionHandler( + 'KycService:getGeoCountry', + handlers.getGeoCountry, + ); + rootMessenger.registerActionHandler( + 'KycService:fetchDisclaimers', + handlers.fetchDisclaimers, + ); + rootMessenger.registerActionHandler( + 'KycService:createSession', + handlers.createSession, + ); + rootMessenger.registerActionHandler( + 'KycService:checkKycRequired', + handlers.checkKycRequired, + ); + rootMessenger.registerActionHandler( + 'KycService:createUkycSession', + handlers.createUkycSession, + ); + rootMessenger.registerActionHandler( + 'KycService:submitWrappedKey', + handlers.submitWrappedKey, + ); + + const launcher: Launcher = { + isAvailable: jest.fn().mockReturnValue(true), + launch: jest.fn().mockResolvedValue({ ok: true }), + }; + + const controller = new KycController({ + messenger, + sumsubLauncher: launcher as unknown as KycSumSubLauncher, + ...options, + }); + + return testFunction({ controller, rootMessenger, handlers, launcher }); +} diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts new file mode 100644 index 0000000000..a643649267 --- /dev/null +++ b/packages/kyc-controller/src/KycController.ts @@ -0,0 +1,1117 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + StateMetadata, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { Json } from '@metamask/utils'; + +import { decryptCredentials, generateKeyPair } from './crypto'; +import type { EncryptedCredentialsEnvelope, X25519KeyPair } from './crypto'; +import type { KycControllerMethodActions } from './KycController-method-action-types'; +import type { KycServiceMethodActions } from './KycService-method-action-types'; +import type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSumSubLauncher, + KycSumSubStatus, +} from './types'; + +// === GENERAL === + +export const controllerName = 'KycController'; + +const FRAMES_BASE_URL = 'https://blocks.moonpay.com/platform/v1'; +const CHANNEL_CHECK = 'ch_1'; +const CHANNEL_AUTH = 'ch_2'; +const CHANNEL_RESET = 'ch_reset'; + +// Placeholder credentials for the SumSub sub-flow. These are demo values that +// must be replaced with real UKYC-issued material before production use. +const MOCK_JWT_TOKEN = 'mock-jwt-token'; + +// The SumSub SDK status that signals the applicant finished the flow +// successfully. Any other resolution (abandonment, failure, or a non-success +// outcome) must not be recorded as `complete`. +const SUMSUB_COMPLETED_STATUS = 'Completed'; + +// Phases that represent an active vendor-session flow (tokens issued and/or +// Check/Auth frames in progress). A repeat `initialize` while in one of these +// must not restart the session and disrupt the in-flight flow. +const IN_PROGRESS_PHASES: KycPhase[] = [ + 'session', + 'check', + 'auth', + 'form', + 'submit', +]; + +// === STATE === + +/** + * Describes the shape of the state object for {@link KycController}. + */ +export type KycControllerState = { + /** Current phase of the identity flow. */ + phase: KycPhase; + /** Human-readable status message for the current phase. */ + statusMessage: string; + /** The current error message, or `null`. */ + error: string | null; + + /** Email associated with the session (sourced from the account). */ + email: string | null; + + /** ISO-8601 timestamp of the customer's terms acceptance (persisted). */ + termsAcceptedAt: string | null; + /** IDs of the disclaimers the customer accepted (persisted). */ + acceptedDisclaimerIds: string[]; + + /** Disclaimers fetched for the current country. */ + disclaimers: KycDisclaimer[]; + /** Error encountered while loading disclaimers, or `null`. */ + disclaimersError: string | null; + + /** Resolved ISO 3166-1 alpha-3 country code. */ + geoCountry: string | null; + + /** Vendor session token (not persisted, not logged). */ + sessionToken: string | null; + /** Vendor access token (not persisted, not logged). */ + accessToken: string | null; + /** Vendor customer id, used for the SumSub hand-off. */ + moonpayCustomerId: string | null; + + /** + * The product the current flow is running for. Captured at `initialize` + * (or `acceptTermsAndStartSession`) and used to automatically run the + * KYC-required check once authentication completes. `null` outside a + * product-scoped flow (in which case the flow stops at `form` and the + * consumer drives the check manually). + */ + activeProduct: KycProduct | null; + + /** Cached "is KYC required" result per product (persisted). */ + kycRequiredByProduct: Partial>; + /** ISO-8601 timestamp of the last KYC-required check (persisted). */ + lastCheckedAt: string | null; + + /** SumSub document-verification sub-flow state. */ + sumsub: { + status: KycSumSubStatus; + result: Json | null; + sessionId: string | null; + applicantAccessToken: string | null; + }; +}; + +const kycControllerMetadata = { + phase: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + statusMessage: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + error: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + email: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + termsAcceptedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + acceptedDisclaimerIds: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + disclaimers: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, + disclaimersError: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + geoCountry: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + sessionToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + accessToken: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + moonpayCustomerId: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: false, + }, + activeProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: true, + }, + kycRequiredByProduct: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: true, + }, + lastCheckedAt: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: true, + usedInUi: false, + }, + sumsub: { + includeInDebugSnapshot: false, + includeInStateLogs: false, + persist: false, + usedInUi: true, + }, +} satisfies StateMetadata; + +/** + * Constructs the default {@link KycController} state. + * + * @returns The default state. + */ +export function getDefaultKycControllerState(): KycControllerState { + return { + phase: 'idle', + statusMessage: '', + error: null, + email: null, + termsAcceptedAt: null, + acceptedDisclaimerIds: [], + disclaimers: [], + disclaimersError: null, + geoCountry: null, + sessionToken: null, + accessToken: null, + moonpayCustomerId: null, + activeProduct: null, + kycRequiredByProduct: {}, + lastCheckedAt: null, + sumsub: { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + }, + }; +} + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'initialize', + 'loadDisclaimers', + 'acceptTermsAndStartSession', + 'clearSavedTerms', + 'handleFrameMessage', + 'buildCheckFrameUrl', + 'buildAuthFrameUrl', + 'buildResetFrameUrl', + 'checkKycRequired', + 'getKycStatus', + 'startSumSub', + 'reset', +] as const; + +export type KycControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + KycControllerState +>; + +export type KycControllerActions = + | KycControllerGetStateAction + | KycControllerMethodActions; + +type AllowedActions = KycServiceMethodActions; + +export type KycControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + KycControllerState +>; + +export type KycControllerEvents = KycControllerStateChangeEvent; + +type AllowedEvents = never; + +export type KycControllerMessenger = Messenger< + typeof controllerName, + KycControllerActions | AllowedActions, + KycControllerEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycController}. + */ +export type KycControllerOptions = { + messenger: KycControllerMessenger; + state?: Partial; + /** + * Platform adapter that presents the SumSub SDK. Injected by each client so + * the controller stays platform-agnostic. + */ + sumsubLauncher: KycSumSubLauncher; +}; + +/** + * The shape of a message posted by a Check/Auth frame. + */ +type FrameMessage = { + meta?: { channelId?: string }; + kind?: string; + payload?: { + status?: + | 'active' + | 'connectionRequired' + | 'termsAcceptanceRequired' + | 'pending' + | 'unavailable' + | 'failed'; + credentials?: EncryptedCredentialsEnvelope | string; + customer?: { id?: string }; + }; +}; + +// === CONTROLLER DEFINITION === + +/** + * `KycController` orchestrates the vendor-backed KYC / identity-verification + * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per + * product surface used by ramps and card. It owns all state, HTTP + * orchestration (via `KycService`), crypto, and the frame message protocol; + * platform-specific presentation (WebView/iframe, SumSub SDK) is injected. + */ +export class KycController extends BaseController< + typeof controllerName, + KycControllerState, + KycControllerMessenger +> { + readonly #sumsubLauncher: KycSumSubLauncher; + + /** Ephemeral X25519 keypair for the frame key exchange (never persisted). */ + readonly #keypair: X25519KeyPair; + + /** Auth-frame client token, kept out of state. */ + #authClientToken: string | null = null; + + /** + * Monotonic flow generation. Incremented by {@link reset} so in-flight async + * work (e.g. the KYC-required check) can detect that it was superseded and + * avoid writing stale results onto a reset controller. + */ + #generation = 0; + + /** + * Constructs a new {@link KycController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - Partial initial state; merged over defaults. + * @param options.sumsubLauncher - The platform SumSub launcher adapter. + */ + constructor({ messenger, state, sumsubLauncher }: KycControllerOptions) { + super({ + messenger, + metadata: kycControllerMetadata, + name: controllerName, + state: { ...getDefaultKycControllerState(), ...state }, + }); + + this.#sumsubLauncher = sumsubLauncher; + this.#keypair = generateKeyPair(); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves persisted terms + geolocation, and auto-creates a session when + * terms are already accepted and an email is available. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. When + * provided, the controller automatically runs the KYC-required check once + * authentication completes (and chains into document verification when KYC + * is required). When omitted, the flow stops at `form` and the consumer must + * call `checkKycRequired` manually. + */ + async initialize(params?: { + email?: string; + product?: KycProduct; + }): Promise { + // A repeat `initialize` while a session flow is already in progress must + // not tear it down: creating a new vendor session clears the tokens and + // forces `phase` back through `session`/`check`, breaking an in-flight + // Check/Auth frame flow. Leave the active flow untouched and let the + // consumer drive it (or call `reset` first to start over). + if (IN_PROGRESS_PHASES.includes(this.state.phase)) { + return; + } + + // `initialize` starts a fresh flow, so `activeProduct` is always reset to + // this call's product (or `null`). Otherwise a prior run's product could + // linger and cause `#continueAfterAuthentication` to auto-run the check / + // sub-flow when the caller intended the manual (product-less) flow. + this.#applyUpdate((state) => { + if (params?.email) { + state.email = params.email; + } + state.activeProduct = params?.product ?? null; + }); + + // Capture the flow generation so a `reset()` landing while the async + // geolocation / session steps below are in flight cannot write results + // onto an idle controller. + const generation = this.#generation; + + // Resolve country for display; non-blocking. + try { + const country = await this.messenger.call('KycService:getGeoCountry'); + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } catch { + // Ignore; disclaimers loading will surface a country error if needed. + } + + const hasTerms = + Boolean(this.state.termsAcceptedAt) && + this.state.acceptedDisclaimerIds.length > 0; + + if (hasTerms && this.state.email) { + await this.#createSession(); + return; + } + + this.#applyUpdate((state) => { + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + + /** + * Loads the disclaimers for the resolved (or provided) country. + * + * @param params - Optional parameters. + * @param params.country - ISO 3166-1 alpha-3 country code override. + */ + async loadDisclaimers(params?: { country?: string }): Promise { + // Capture the flow generation so a `reset()` landing while the geo / + // disclaimers requests are in flight cannot write results onto an idle + // controller. + const generation = this.#generation; + try { + const country = + params?.country ?? + this.state.geoCountry ?? + (await this.messenger.call('KycService:getGeoCountry')); + if (country !== this.state.geoCountry) { + this.#updateIfCurrent(generation, (state) => { + state.geoCountry = country; + }); + } + const disclaimers = await this.messenger.call( + 'KycService:fetchDisclaimers', + { country }, + ); + this.#updateIfCurrent(generation, (state) => { + state.disclaimers = disclaimers; + state.disclaimersError = null; + }); + } catch (error) { + this.#updateIfCurrent(generation, (state) => { + state.disclaimersError = `Failed to load disclaimers: ${String(error)}`; + }); + } + } + + /** + * Captures terms acceptance for the currently loaded disclaimers and creates + * a session. + * + * @param params - Optional parameters. + * @param params.email - The account email to associate with the session. + * @param params.product - The consuming feature the flow runs for. See + * {@link initialize} for how the product drives the automatic post + * authentication continuation. + */ + async acceptTermsAndStartSession(params?: { + email?: string; + product?: KycProduct; + }): Promise { + const termsAcceptedAt = new Date().toISOString(); + const disclaimerIds = this.state.disclaimers.map( + (disclaimer) => disclaimer.id, + ); + this.#applyUpdate((state) => { + if (params?.email) { + state.email = params.email; + } + if (params?.product) { + state.activeProduct = params.product; + } + state.termsAcceptedAt = termsAcceptedAt; + state.acceptedDisclaimerIds = disclaimerIds; + }); + await this.#createSession(); + } + + /** + * Creates a vendor session from the currently stored terms + email. + */ + async #createSession(): Promise { + const { email, termsAcceptedAt, acceptedDisclaimerIds } = this.state; + if (!email) { + this.#fail('Missing email for session creation.'); + return; + } + if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) { + this.#fail('Missing terms acceptance for session creation.'); + return; + } + + // A new session invalidates any authentication carried over from a prior + // session. Clear the stale session token, access token, and auth-frame + // client token so `buildCheckFrameUrl` cannot return a URL bound to an old + // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot + // return a URL tied to an old client token, and `checkKycRequired` cannot + // run with an access token from an earlier authentication. The Check/Auth + // frames re-populate these for the new session. Because `sessionToken` is + // cleared here and only re-set on success, a failed creation leaves it + // `null` rather than resurrecting the previous session. + // Capture the flow generation so a `reset()` landing while the create + // request is in flight cannot resurrect a session (success) or overwrite + // the now-idle controller (failure). The synchronous update below runs + // before any `await`, so it needs no guard. + const generation = this.#generation; + this.#authClientToken = null; + this.#applyUpdate((state) => { + state.error = null; + state.phase = 'session'; + state.statusMessage = 'Creating session...'; + state.sessionToken = null; + state.accessToken = null; + }); + + try { + const { sessionToken } = await this.messenger.call( + 'KycService:createSession', + { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sessionToken = sessionToken; + state.phase = 'check'; + state.statusMessage = 'Authenticating via Check frame...'; + }); + } catch (error) { + // A reset() superseded this flow while the request was in flight; leave + // the idle controller alone rather than forcing it back to `terms`. + if (this.#generation !== generation) { + return; + } + // Invalidate the stored acceptance so the customer can retry. Also clear + // `activeProduct` so a later `acceptTermsAndStartSession` that omits a + // product cannot auto-run the KYC check / SumSub chain for this failed + // flow's product — matching how `initialize` starts from a clean product. + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.activeProduct = null; + state.error = `Session creation failed: ${String(error)}`; + state.statusMessage = + 'Session creation failed — accept the terms to try again.'; + state.phase = 'terms'; + }); + await this.loadDisclaimers(); + } + } + + /** + * Clears the persisted terms acceptance. + */ + clearSavedTerms(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + }); + } + + /** + * Clears the stored terms acceptance on the given draft state. Shared by the + * paths that must invalidate acceptance — explicit clear, vendor terms + * update, and session-creation failure — so they stay in sync. This is a + * targeted invalidation and, unlike {@link reset}, deliberately leaves the + * rest of the flow (geolocation, disclaimers, phase) untouched. + * + * @param state - The state to mutate. + */ + #clearAcceptedTerms(state: KycControllerState): void { + state.termsAcceptedAt = null; + state.acceptedDisclaimerIds = []; + } + + /** + * Handles a message posted by a Check/Auth frame and advances the flow. + * + * The transport-agnostic caller (WebView on mobile, iframe on web) forwards + * the raw message and injects the returned `reply` back into the frame. + * + * @param params - The parameters. + * @param params.message - The raw message posted by the frame. + * @returns An object whose optional `reply` should be posted back. + */ + async handleFrameMessage(params: { + message: unknown; + }): Promise<{ reply?: unknown }> { + const payload = params.message as FrameMessage | undefined; + + if (!payload) { + return {}; + } + + if (payload.kind === 'handshake') { + const channelId = payload.meta?.channelId; + return { reply: { version: 2, meta: { channelId }, kind: 'ack' } }; + } + + if (payload.kind !== 'complete') { + return {}; + } + + const channelId = payload.meta?.channelId; + + // Only honor a Check/Auth `complete` for the frame the flow is currently + // waiting on. This drops stale or duplicate messages — e.g. a late post + // after `reset()` (phase `idle`) or after the flow already advanced past + // this frame — so they cannot resurrect tokens or rewind `phase` on a + // controller that has moved on. Frame messages are external input and, + // unlike the async steps, are not covered by the `#generation` guard. + let expectedPhase: KycPhase | null = null; + if (channelId === CHANNEL_CHECK) { + expectedPhase = 'check'; + } else if (channelId === CHANNEL_AUTH) { + expectedPhase = 'auth'; + } + if (!expectedPhase || this.state.phase !== expectedPhase) { + return {}; + } + + const status = payload.payload?.status; + const credsEnvelope = payload.payload?.credentials; + + const customerId = payload.payload?.customer?.id ?? null; + if (customerId) { + this.#applyUpdate((state) => { + state.moonpayCustomerId = customerId; + }); + } + + if (!status) { + return {}; + } + + let accessToken: string | undefined; + let clientToken: string | undefined; + if (credsEnvelope) { + try { + const { credentials } = decryptCredentials( + credsEnvelope, + this.#keypair.privateKey, + ); + accessToken = credentials.accessToken; + clientToken = credentials.clientToken; + } catch (error) { + this.#fail(`Failed to decrypt frame credentials: ${String(error)}`); + return {}; + } + } + + if (channelId === CHANNEL_CHECK) { + await this.#handleCheckOutcome(status, accessToken, clientToken); + return {}; + } + + // channelId === CHANNEL_AUTH, guaranteed by the expectedPhase guard above. + await this.#handleAuthOutcome(status, accessToken); + return {}; + } + + /** + * Applies a Check-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + * @param clientToken - The decrypted client token, if any. + */ + async #handleCheckOutcome( + status: NonNullable['status'], + accessToken?: string, + clientToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Already authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'connectionRequired' && clientToken) { + this.#authClientToken = clientToken; + this.#applyUpdate((state) => { + state.phase = 'auth'; + state.statusMessage = 'Verify your email via OTP in the Auth frame.'; + }); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Check frame returned status: ${status}`); + } + + /** + * Applies an Auth-frame outcome. + * + * @param status - The frame status. + * @param accessToken - The decrypted access token, if any. + */ + async #handleAuthOutcome( + status: NonNullable['status'], + accessToken?: string, + ): Promise { + if (status === 'active' && accessToken) { + this.#applyUpdate((state) => { + state.accessToken = accessToken; + state.phase = 'form'; + state.statusMessage = 'Authenticated. Review to submit.'; + }); + await this.#continueAfterAuthentication(); + return; + } + if (status === 'termsAcceptanceRequired') { + this.#requireTermsReacceptance(); + return; + } + this.#fail(`Auth frame returned status: ${status}`); + } + + /** + * Continues the flow once authentication has completed (phase `form`). + * + * When the flow is scoped to a product (see {@link initialize}), the + * KYC-required check runs automatically, and — when KYC is required — the + * document-verification sub-flow is launched. When no product is set, this is + * a no-op and the flow stays at `form` for the consumer to drive manually. + * + * Errors are already recorded on state by `checkKycRequired` (`error` + * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows + * them so it can be awaited safely from the frame-message handler. + */ + async #continueAfterAuthentication(): Promise { + const product = this.state.activeProduct; + if (!product) { + return; + } + + // Re-entry protection lives at the frame boundary: `handleFrameMessage` + // only honors a Check/Auth `complete` while `phase` matches, and both + // outcome handlers move `phase` to `form` before awaiting this method. A + // duplicate or late `complete` therefore lands after the phase moved on and + // is dropped before it can start a second continuation. Any writes here are + // additionally guarded by `#generation` (see `checkKycRequired` / + // `startSumSub`) so a `reset()` mid-continuation cannot corrupt state. + const kycRequired = await this.checkKycRequired({ product }); + if (!kycRequired) { + return; + } + + try { + await this.startSumSub(); + } catch { + // `startSumSub` already records `sumsub.status = 'failed'`; swallow the + // rethrown error (e.g. SDK unavailable) so the awaited continuation + // resolves cleanly rather than surfacing as an unhandled rejection. + } + } + + /** + * Invalidates stored terms and returns to the terms phase. + */ + #requireTermsReacceptance(): void { + this.#applyUpdate((state) => { + this.#clearAcceptedTerms(state); + state.phase = 'terms'; + state.statusMessage = + 'The vendor updated its Terms of Use — please re-accept.'; + }); + } + + /** + * Builds the Check-frame URL, or `null` when no session exists yet. + * + * @returns The Check-frame URL or `null`. + */ + buildCheckFrameUrl(): string | null { + if (!this.state.sessionToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/check-connection`); + url.searchParams.set('sessionToken', this.state.sessionToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_CHECK); + url.searchParams.set('skipKyc', 'true'); + return url.toString(); + } + + /** + * Builds the Auth-frame URL, or `null` when no client token is available. + * + * @returns The Auth-frame URL or `null`. + */ + buildAuthFrameUrl(): string | null { + if (!this.#authClientToken) { + return null; + } + const url = new URL(`${FRAMES_BASE_URL}/auth`); + url.searchParams.set('clientToken', this.#authClientToken); + url.searchParams.set('publicKey', this.#keypair.publicKeyHex); + url.searchParams.set('channelId', CHANNEL_AUTH); + return url.toString(); + } + + /** + * Builds the Reset-frame URL. + * + * @returns The Reset-frame URL. + */ + buildResetFrameUrl(): string { + const url = new URL(`${FRAMES_BASE_URL}/reset`); + url.searchParams.set('channelId', CHANNEL_RESET); + return url.toString(); + } + + /** + * Checks whether KYC is required for a product and caches the result. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @param params.country - Optional alpha-3 country override. + * @returns Whether KYC is required. + */ + async checkKycRequired(params: { + product: KycProduct; + country?: string; + }): Promise { + const { accessToken } = this.state; + if (!accessToken) { + this.#fail('Missing accessToken — repeat the authentication step.'); + return false; + } + const country = params.country ?? this.state.geoCountry; + if (!country) { + this.#fail('Missing country for KYC-required check.'); + return false; + } + + // Capture the flow generation so we can detect a `reset()` that happens + // while the HTTP call is in flight and avoid writing stale results. + const generation = this.#generation; + + this.#applyUpdate((state) => { + state.phase = 'submit'; + state.statusMessage = 'Checking KYC status...'; + }); + + try { + const { kycRequired } = await this.messenger.call( + 'KycService:checkKycRequired', + { accessToken, country, capabilities: [{ product: params.product }] }, + ); + // The flow was reset while the check was in flight; discard the result + // rather than resurrecting a done/cached state on an idle controller. + const applied = this.#updateIfCurrent(generation, (state) => { + state.kycRequiredByProduct[params.product] = kycRequired; + state.lastCheckedAt = new Date().toISOString(); + state.phase = 'done'; + state.statusMessage = 'KYC check complete.'; + }); + if (!applied) { + return false; + } + return kycRequired; + } catch (error) { + if (this.#generation !== generation) { + return false; + } + this.#fail(`KYC check failed: ${String(error)}`); + return false; + } + } + + /** + * Reads the cached "is KYC required" result for a product. + * + * @param params - The parameters. + * @param params.product - The consuming feature. + * @returns The cached value, or `undefined` if not yet checked. + */ + getKycStatus(params: { product: KycProduct }): boolean | undefined { + return this.state.kycRequiredByProduct[params.product]; + } + + /** + * Runs the SumSub document-verification sub-flow: creates a UKYC session, + * exchanges the wrapped key for an applicant access token, and presents the + * SDK via the injected launcher. + * + * @param params - Optional parameters. + * @param params.locale - BCP-47 locale for the SDK UI. + * @param params.debug - Enables SDK debug logging. + * @returns The SDK result. + */ + async startSumSub(params?: { + locale?: string; + debug?: boolean; + }): Promise> { + if (!this.#sumsubLauncher.isAvailable()) { + const error = 'SumSub SDK is not available in this runtime.'; + this.#applyUpdate((state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = { error }; + }); + throw new Error(error); + } + + // Capture the flow generation so each async step can detect a `reset()` + // that lands mid-flight and avoid writing stale sub-flow state (or, worse, + // presenting the SDK) on a controller that is now idle. + const generation = this.#generation; + + try { + this.#applyUpdate((state) => { + state.sumsub.status = 'creatingSession'; + state.sumsub.result = null; + }); + + const jwtToken = MOCK_JWT_TOKEN; + const { sessionId, wrappingPublicKey, idosSessionId } = + await this.messenger.call('KycService:createUkycSession', { + jwtToken, + vendorMetadata: { + moonPayAccessToken: this.state.accessToken, + moonPayUserId: this.state.moonpayCustomerId, + }, + }); + // Retain the exchange material so the SDK can refresh its token. The + // session's `wrappingPublicKey` is forwarded opaquely as the request's + // `wrappedUserKey` field (the /wrapped-key endpoint's body is unchanged). + const exchange = { + sessionId, + wrappedUserKey: wrappingPublicKey, + idosSessionId, + jwtToken, + }; + + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'fetchingToken'; + state.sumsub.sessionId = sessionId; + }); + + const { applicantAccessToken } = await this.messenger.call( + 'KycService:submitWrappedKey', + exchange, + ); + + // A reset() may have landed while the session/token was being prepared. + // Gate the `launching` write and the decision to open the SDK behind a + // single generation check: `#updateIfCurrent` only writes when still + // current and reports whether it did. Since there is no `await` between + // this check and `launch` below, a successful result guarantees the SDK + // is never presented on a flow that a concurrent reset() returned to idle. + const stillCurrent = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'launching'; + state.sumsub.applicantAccessToken = applicantAccessToken; + }); + if (!stillCurrent) { + return {}; + } + + // Track whether the SDK ever reported a successful completion. A resolved + // `launch` alone does not imply success — the applicant may have + // abandoned the flow or the SDK may have reported a non-success outcome. + let reachedCompletion = false; + + const result = await this.#sumsubLauncher.launch({ + applicantAccessToken, + onTokenExpiration: async () => { + // A reset() may have superseded this flow while the SDK stayed open. + // Refuse to refresh against the now-stale UKYC session rather than + // silently keeping an orphaned SDK alive. + if (this.#generation !== generation) { + throw new Error( + 'KYC flow was reset; SumSub session is no longer active.', + ); + } + const refreshed = await this.messenger.call( + 'KycService:submitWrappedKey', + exchange, + ); + return refreshed.applicantAccessToken; + }, + onStatusChange: (_prev, next) => { + if (next === SUMSUB_COMPLETED_STATUS) { + reachedCompletion = true; + } + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = + next === SUMSUB_COMPLETED_STATUS ? 'complete' : 'inProgress'; + }); + }, + locale: params?.locale ?? 'en', + debug: params?.debug ?? false, + }); + + // Only record `complete` when the SDK actually reported completion; + // otherwise treat the resolved-but-unfinished flow as `failed` so + // consumers and UI do not mistake it for a finished verification. + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = reachedCompletion ? 'complete' : 'failed'; + state.sumsub.result = result as Json; + }); + return result; + } catch (error) { + const result = { error: String(error) }; + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'failed'; + state.sumsub.result = result; + }); + return result; + } + } + + /** + * Resets the flow to idle, clearing session tokens and sub-flow state while + * preserving persisted terms acceptance and the per-product cache. + */ + reset(): void { + this.#authClientToken = null; + // Invalidate any in-flight async work started before this reset so its + // results are discarded rather than written onto the now-idle controller. + this.#generation += 1; + this.#applyUpdate((state) => { + state.phase = 'idle'; + state.statusMessage = ''; + state.error = null; + state.disclaimers = []; + state.disclaimersError = null; + state.sessionToken = null; + state.accessToken = null; + state.moonpayCustomerId = null; + state.activeProduct = null; + state.sumsub = { + status: 'idle', + result: null, + sessionId: null, + applicantAccessToken: null, + }; + }); + } + + /** + * Applies a state update only when the flow has not been reset since + * `generation` was captured. Prevents an in-flight async step from writing + * stale results onto a controller that a concurrent {@link reset} has + * returned to idle. + * + * @param generation - The flow generation captured before the async work. + * @param updater - The state mutation to apply when still current. + * @returns `true` if the update was applied, `false` if it was superseded. + */ + #updateIfCurrent( + generation: number, + updater: (state: KycControllerState) => void, + ): boolean { + if (this.#generation !== generation) { + return false; + } + this.#applyUpdate(updater); + return true; + } + + /** + * The single state-update path for this controller. All mutations go through + * here (rather than calling `this.update` directly) so the mechanism stays + * consistent and one subtlety is handled in a single place: + * + * `sumsub.result` is typed as the recursive `Json`, and expanding + * `Draft` (which happens whenever an updater touches `sumsub.result`) + * trips TypeScript's "type instantiation is excessively deep" guard. By + * typing the callback parameter as the plain {@link KycControllerState} + * instead of Immer's `Draft`, we avoid expanding the draft type while keeping + * the same mutate-in-place semantics (the underlying value is still the Immer + * draft at runtime). + * + * @param updater - The state mutation to apply. + */ + #applyUpdate(updater: (state: KycControllerState) => void): void { + this.update((state) => { + updater(state as unknown as KycControllerState); + }); + } + + /** + * Transitions to the error phase with a message. + * + * @param message - The error message. + */ + #fail(message: string): void { + this.#applyUpdate((state) => { + state.error = message; + state.phase = 'error'; + }); + } +} diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts new file mode 100644 index 0000000000..844e36faf4 --- /dev/null +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -0,0 +1,87 @@ +/** + * This file is auto generated. + * Do not edit manually. + */ + +import type { KycService } from './KycService'; + +/** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ +export type KycServiceGetGeoCountryAction = { + type: `KycService:getGeoCountry`; + handler: KycService['getGeoCountry']; +}; + +/** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ +export type KycServiceFetchDisclaimersAction = { + type: `KycService:fetchDisclaimers`; + handler: KycService['fetchDisclaimers']; +}; + +/** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ +export type KycServiceCreateSessionAction = { + type: `KycService:createSession`; + handler: KycService['createSession']; +}; + +/** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ +export type KycServiceCheckKycRequiredAction = { + type: `KycService:checkKycRequired`; + handler: KycService['checkKycRequired']; +}; + +/** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers and wrapped key. + */ +export type KycServiceCreateUkycSessionAction = { + type: `KycService:createUkycSession`; + handler: KycService['createUkycSession']; +}; + +/** + * Exchanges the wrapped user key for a SumSub applicant access token. + * + * @param params - The exchange parameters. + * @returns The applicant access token and status. + */ +export type KycServiceSubmitWrappedKeyAction = { + type: `KycService:submitWrappedKey`; + handler: KycService['submitWrappedKey']; +}; + +/** + * Union of all KycService action types. + */ +export type KycServiceMethodActions = + | KycServiceGetGeoCountryAction + | KycServiceFetchDisclaimersAction + | KycServiceCreateSessionAction + | KycServiceCheckKycRequiredAction + | KycServiceCreateUkycSessionAction + | KycServiceSubmitWrappedKeyAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts new file mode 100644 index 0000000000..86f582538a --- /dev/null +++ b/packages/kyc-controller/src/KycService.test.ts @@ -0,0 +1,354 @@ +import { Messenger, MOCK_ANY_NAMESPACE } from '@metamask/messenger'; +import type { + MockAnyNamespace, + MessengerActions, + MessengerEvents, +} from '@metamask/messenger'; +import nock, { cleanAll } from 'nock'; + +import type { KycServiceMessenger } from './KycService'; +import { KycService } from './KycService'; + +const MOCK_API_URL = 'https://kyc-api.dev-api.cx.metamask.io'; + +describe('KycService', () => { + afterEach(() => { + cleanAll(); + }); + + describe('getGeoCountry', () => { + it('maps the geolocation to an ISO alpha-3 country code', async () => { + const { service } = getService({ geolocation: 'US-NY' }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + + it('throws when the location is unknown', async () => { + const { service } = getService({ geolocation: 'UNKNOWN' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('throws when the country cannot be mapped to alpha-3', async () => { + const { service } = getService({ geolocation: 'ZZ' }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to map country code "ZZ"/u, + ); + }); + + it('throws when the location resolves to a nullish value', async () => { + const { service } = getService({ geolocation: null }); + await expect(service.getGeoCountry()).rejects.toThrow( + /Unable to determine country/u, + ); + }); + + it('constructs with the default service policy options', async () => { + const { service } = getService({ + defaultPolicy: true, + geolocation: 'US', + }); + expect(await service.getGeoCountry()).toBe('USA'); + }); + }); + + describe('fetchDisclaimers', () => { + it('returns the disclaimers for a country', async () => { + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService(); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, [{ id: 1 }]); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws when no bearer token is available', async () => { + const { service } = getService({ bearerToken: '' }); + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(500); + const { service } = getService(); + + await expect( + service.fetchDisclaimers({ country: 'USA' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + + describe('createSession', () => { + it('creates a session and returns the token', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/sessions') + .reply(200, { sessionToken: 'session-1' }); + const { service } = getService(); + + expect( + await service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).toStrictEqual({ sessionToken: 'session-1' }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/sessions').reply(200, {}); + const { service } = getService(); + + await expect( + service.createSession({ + email: 'a@b.co', + termsAcceptedAt: '2026-01-01T00:00:00.000Z', + disclaimerIds: ['1'], + }), + ).rejects.toThrow(/Malformed response received from sessions API/u); + }); + }); + + describe('checkKycRequired', () => { + it('returns whether KYC is required (default capabilities)', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'ramps' }], + }) + .reply(200, { required: true }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + }), + ).toStrictEqual({ kycRequired: true }); + }); + + it('passes provided capabilities', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required', { + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }) + .reply(200, { required: false }); + const { service } = getService(); + + expect( + await service.checkKycRequired({ + accessToken: 'access-1', + country: 'USA', + capabilities: [{ product: 'card' }], + }), + ).toStrictEqual({ kycRequired: false }); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/vendors/moonpay/kyc-required').reply(200, {}); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow(/Malformed response received from kyc-required API/u); + }); + + it('surfaces the specific field mismatch and payload in the error', async () => { + nock(MOCK_API_URL) + .post('/vendors/moonpay/kyc-required') + .reply(200, { required: 'yes' }); + const { service } = getService(); + + await expect( + service.checkKycRequired({ accessToken: 'access-1', country: 'USA' }), + ).rejects.toThrow( + /Malformed response received from kyc-required API:.*required.*received: \{"required":"yes"\}/su, + ); + }); + }); + + describe('createUkycSession', () => { + it('creates a UKYC session', async () => { + const response = { + sessionId: 'sid', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }; + nock(MOCK_API_URL).post('/sessions').reply(200, response); + const { service } = getService(); + + expect( + await service.createUkycSession({ + jwtToken: 'jwt', + vendorMetadata: { foo: 'bar' }, + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL).post('/sessions').reply(200, { sessionId: 'sid' }); + const { service } = getService(); + + await expect( + service.createUkycSession({ jwtToken: 'jwt', vendorMetadata: {} }), + ).rejects.toThrow(/Malformed response received from UKYC sessions API/u); + }); + }); + + describe('submitWrappedKey', () => { + it('exchanges the wrapped key for an applicant access token', async () => { + const response = { status: 'ok', applicantAccessToken: 'aat' }; + nock(MOCK_API_URL).post('/sessions/sid/wrapped-key').reply(200, response); + const { service } = getService(); + + expect( + await service.submitWrappedKey({ + sessionId: 'sid', + wrappedUserKey: 'wuk', + idosSessionId: 'idos', + jwtToken: 'jwt', + }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .post('/sessions/sid/wrapped-key') + .reply(200, { status: 'ok' }); + const { service } = getService(); + + await expect( + service.submitWrappedKey({ + sessionId: 'sid', + wrappedUserKey: 'wuk', + idosSessionId: 'idos', + jwtToken: 'jwt', + }), + ).rejects.toThrow(/Malformed response received from wrapped-key API/u); + }); + }); + + describe('baseUrl override', () => { + it('uses the provided baseUrl instead of the env-derived URL', async () => { + const customUrl = 'https://kyc-api.local.test'; + const disclaimers = [ + { id: '1', display_name: 'Terms', url: 'https://t' }, + ]; + nock(customUrl) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, disclaimers); + const { service } = getService({ baseUrl: customUrl }); + + expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( + disclaimers, + ); + }); + }); + + describe('messenger actions', () => { + it('exposes methods as messenger actions', async () => { + nock(MOCK_API_URL) + .get('/vendors/moonpay/disclaimers') + .query({ country: 'USA' }) + .reply(200, []); + const { rootMessenger } = getService(); + + expect( + await rootMessenger.call('KycService:fetchDisclaimers', { + country: 'USA', + }), + ).toStrictEqual([]); + }); + }); +}); + +type RootMessenger = Messenger< + MockAnyNamespace, + MessengerActions, + MessengerEvents +>; + +/** + * Constructs the service under test with mocked auth + geo handlers. + * + * @param args - Options. + * @param args.bearerToken - The bearer token the auth handler returns. + * @param args.geolocation - The location the geolocation handler returns. + * @param args.defaultPolicy - When true, omit `policyOptions` to use defaults. + * @param args.baseUrl - When provided, overrides the env-derived base URL. + * @returns The service, root messenger, and service messenger. + */ +function getService({ + bearerToken = 'test-bearer', + geolocation = 'US-NY', + defaultPolicy = false, + baseUrl, +}: { + bearerToken?: string; + geolocation?: string | null; + defaultPolicy?: boolean; + baseUrl?: string; +} = {}): { + service: KycService; + rootMessenger: RootMessenger; + messenger: KycServiceMessenger; +} { + const rootMessenger: RootMessenger = new Messenger({ + namespace: MOCK_ANY_NAMESPACE, + }); + const messenger: KycServiceMessenger = new Messenger({ + namespace: 'KycService', + parent: rootMessenger, + }); + rootMessenger.delegate({ + actions: [ + 'AuthenticationController:getBearerToken', + 'GeolocationController:getGeolocation', + ], + events: [], + messenger, + }); + rootMessenger.registerActionHandler( + 'AuthenticationController:getBearerToken', + async () => bearerToken, + ); + rootMessenger.registerActionHandler( + 'GeolocationController:getGeolocation', + async () => geolocation as string, + ); + + const service = new KycService({ + fetch, + messenger, + env: 'development', + ...(baseUrl ? { baseUrl } : {}), + ...(defaultPolicy ? {} : { policyOptions: { maxRetries: 0 } }), + }); + + return { service, rootMessenger, messenger }; +} diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts new file mode 100644 index 0000000000..98a994c93b --- /dev/null +++ b/packages/kyc-controller/src/KycService.ts @@ -0,0 +1,428 @@ +import type { + CreateServicePolicyOptions, + ServicePolicy, +} from '@metamask/controller-utils'; +import { createServicePolicy, HttpError } from '@metamask/controller-utils'; +import type { GeolocationControllerGetGeolocationAction } from '@metamask/geolocation-controller'; +import type { Messenger } from '@metamask/messenger'; +import type { AuthenticationControllerGetBearerTokenAction } from '@metamask/profile-sync-controller/auth'; +import type { Infer, Struct } from '@metamask/superstruct'; +import { + array, + assert, + boolean, + string, + StructError, + type, +} from '@metamask/superstruct'; + +import { alpha2ToAlpha3 } from './countryCodes'; +import type { KycServiceMethodActions } from './KycService-method-action-types'; +import type { KycDisclaimer } from './types'; + +// === GENERAL === + +/** + * The name of the {@link KycService}, used to namespace the service's actions. + */ +export const serviceName = 'KycService'; + +/** + * The supported environments for the Universal KYC API. + */ +export type KycServiceEnvironment = 'production' | 'development'; + +const KYC_API_URLS: Record = { + production: 'https://kyc-api.cx.metamask.io', + development: 'https://kyc-api.dev-api.cx.metamask.io', +}; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = [ + 'getGeoCountry', + 'fetchDisclaimers', + 'createSession', + 'checkKycRequired', + 'createUkycSession', + 'submitWrappedKey', +] as const; + +/** + * Actions that {@link KycService} exposes to other consumers. + */ +export type KycServiceActions = KycServiceMethodActions; + +/** + * Actions from other messengers that {@link KycService} calls. + */ +type AllowedActions = + | AuthenticationControllerGetBearerTokenAction + | GeolocationControllerGetGeolocationAction; + +/** + * Events that {@link KycService} exposes to other consumers. + */ +export type KycServiceEvents = never; + +/** + * Events from other messengers that {@link KycService} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger restricted to actions and events accessed by + * {@link KycService}. + */ +export type KycServiceMessenger = Messenger< + typeof serviceName, + KycServiceActions | AllowedActions, + KycServiceEvents | AllowedEvents +>; + +/** + * Options for constructing a {@link KycService}. + */ +export type KycServiceOptions = { + messenger: KycServiceMessenger; + fetch: typeof fetch; + env: KycServiceEnvironment; + /** + * Overrides the base URL derived from `env`. When provided, this value is + * used verbatim as the base URL for all requests, which is useful for + * targeting a local or staging KYC API. + */ + baseUrl?: string; + policyOptions?: CreateServicePolicyOptions; +}; + +// === API RESPONSE SCHEMAS === + +const DisclaimerStruct = type({ + id: string(), + display_name: string(), + url: string(), +}); +const DisclaimersResponseStruct = array(DisclaimerStruct); + +const CreateSessionResponseStruct = type({ sessionToken: string() }); + +// The live KYC API returns the flag under `required`; the service normalizes +// this to `kycRequired` for consumers (see `checkKycRequired`). +const KycRequiredResponseStruct = type({ required: boolean() }); + +const UkycSessionResponseStruct = type({ + sessionId: string(), + wrappingPublicKey: string(), + idosSessionId: string(), +}); +export type UkycSessionResponse = Infer; + +const WrappedKeyResponseStruct = type({ + status: string(), + applicantAccessToken: string(), +}); +export type WrappedKeyResponse = Infer; + +// === PARAM TYPES === + +export type CreateSessionParams = { + email: string; + termsAcceptedAt: string; + disclaimerIds: string[]; +}; + +export type CheckKycRequiredParams = { + accessToken: string; + country: string; + capabilities?: { product: string }[]; +}; + +export type CreateUkycSessionParams = { + jwtToken: string; + vendorMetadata: Record; +}; + +export type SubmitWrappedKeyParams = { + sessionId: string; + wrappedUserKey: string; + idosSessionId: string; + jwtToken: string; +}; + +// === SERVICE DEFINITION === + +/** + * `KycService` communicates with the Universal KYC (UKYC) backend to drive the + * identity + document-verification flow. It is stateless and platform-agnostic: + * HTTP is performed through an injected `fetch`, and the auth bearer token and + * geolocation come from other controllers via the messenger. + */ +export class KycService { + readonly name: typeof serviceName; + + readonly #messenger: KycServiceMessenger; + + readonly #fetch: typeof fetch; + + readonly #baseUrl: string; + + readonly #policy: ServicePolicy; + + /** + * Constructs a new KycService. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this service. + * @param options.fetch - A function used to make HTTP requests. + * @param options.env - The environment; determines the base URL. + * @param options.baseUrl - Overrides the base URL derived from `env`. + * @param options.policyOptions - Options for the request service policy. + */ + constructor({ + messenger, + fetch: fetchFunction, + env, + baseUrl, + policyOptions, + }: KycServiceOptions) { + this.name = serviceName; + this.#messenger = messenger; + this.#fetch = fetchFunction; + this.#baseUrl = baseUrl ?? KYC_API_URLS[env]; + this.#policy = createServicePolicy(policyOptions ?? {}); + this.#messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Resolves the customer's country from the geolocation source and converts it + * to an ISO 3166-1 alpha-3 code. + * + * @returns The alpha-3 country code. + * @throws If the country cannot be determined or mapped. + */ + async getGeoCountry(): Promise { + const location = await this.#messenger.call( + 'GeolocationController:getGeolocation', + ); + // Guard nullish/empty geolocation with the documented domain error rather + // than letting `assert(location, string())` surface a superstruct + // assertion error (which would change how the failure reads in + // `disclaimersError`). + const alpha2 = + typeof location === 'string' ? location.split('-')[0].toUpperCase() : ''; + if (!alpha2 || alpha2 === 'UNKNOWN') { + throw new Error( + `Unable to determine country from geolocation (got "${String( + location, + )}").`, + ); + } + const alpha3 = alpha2ToAlpha3(alpha2); + if (!alpha3) { + throw new Error( + `Unable to map country code "${alpha2}" to an ISO 3166-1 alpha-3 code.`, + ); + } + return alpha3; + } + + /** + * Fetches the disclaimers the customer must accept before a session is + * created. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The disclaimers. + */ + async fetchDisclaimers({ + country, + }: { + country: string; + }): Promise { + const url = new URL('/vendors/moonpay/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.#request(url, { method: 'GET' }); + return this.#validateResponse( + data, + DisclaimersResponseStruct, + 'disclaimers', + ) as KycDisclaimer[]; + } + + /** + * Creates a vendor session via the UKYC backend. + * + * @param params - The session parameters. + * @returns The created session token. + */ + async createSession( + params: CreateSessionParams, + ): Promise> { + const url = new URL('/vendors/moonpay/sessions', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify(params), + }); + return this.#validateResponse( + data, + CreateSessionResponseStruct, + 'sessions', + ); + } + + /** + * Checks whether KYC is required for the given access token, country, and + * capabilities. + * + * @param params - The check parameters. + * @returns Whether KYC is required. + */ + async checkKycRequired( + params: CheckKycRequiredParams, + ): Promise<{ kycRequired: boolean }> { + const url = new URL('/vendors/moonpay/kyc-required', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + accessToken: params.accessToken, + country: params.country, + capabilities: params.capabilities ?? [{ product: 'ramps' }], + }), + }); + const { required } = this.#validateResponse( + data, + KycRequiredResponseStruct, + 'kyc-required', + ); + return { kycRequired: required }; + } + + /** + * Creates a UKYC session for the SumSub document-verification sub-flow. + * + * @param params - The session parameters. + * @returns The UKYC session identifiers and wrapped key. + */ + async createUkycSession( + params: CreateUkycSessionParams, + ): Promise { + const url = new URL('/sessions', this.#baseUrl); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + vendorId: 'moonpay', + vendorUserId: 'mockedId', + jwtToken: params.jwtToken, + vendorMetadata: params.vendorMetadata, + }), + }); + return this.#validateResponse( + data, + UkycSessionResponseStruct, + 'UKYC sessions', + ); + } + + /** + * Exchanges the wrapped user key for a SumSub applicant access token. + * + * @param params - The exchange parameters. + * @returns The applicant access token and status. + */ + async submitWrappedKey( + params: SubmitWrappedKeyParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/wrapped-key`, + this.#baseUrl, + ); + const data = await this.#request(url, { + method: 'POST', + body: JSON.stringify({ + wrappedUserKey: params.wrappedUserKey, + jwtToken: params.jwtToken, + idosSessionId: params.idosSessionId, + }), + }); + return this.#validateResponse( + data, + WrappedKeyResponseStruct, + 'wrapped-key', + ); + } + + /** + * Validates a parsed API response against a superstruct schema, throwing a + * descriptive error when the response does not match. + * + * Unlike a bare `Struct.is` check, this surfaces exactly which field was + * missing or had the wrong type, which is essential for diagnosing shape + * mismatches between the client and the live API. + * + * @param data - The parsed response body. + * @param struct - The superstruct schema the body is expected to satisfy. + * @param apiName - A human-readable name of the API, used in the error message. + * @returns The validated, typed response. + * @throws If `data` does not match `struct`. + */ + #validateResponse( + data: unknown, + struct: Struct, + apiName: string, + ): Type { + try { + assert(data, struct); + return data; + } catch (error) { + const detail = + error instanceof StructError + ? `${error.message} (received: ${JSON.stringify(data)})` + : String(error); + throw new Error( + `Malformed response received from ${apiName} API: ${detail}`, + ); + } + } + + /** + * Performs an authenticated JSON request wrapped in the service policy. + * + * @param url - The request URL. + * @param init - The request init (method, body). + * @returns The parsed JSON response. + */ + async #request(url: URL, init: RequestInit): Promise { + const bearerToken = await this.#messenger.call( + 'AuthenticationController:getBearerToken', + ); + assert(bearerToken, string()); + if (!bearerToken) { + throw new Error( + 'Unable to obtain an authentication bearer token — is the wallet signed in?', + ); + } + + const response = await this.#policy.execute(async () => { + const localResponse = await this.#fetch(url.toString(), { + ...init, + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${bearerToken}`, + }, + }); + if (!localResponse.ok) { + throw new HttpError( + localResponse.status, + `Fetching '${url.toString()}' failed with status '${localResponse.status}'`, + ); + } + return localResponse; + }); + + return response.json(); + } +} diff --git a/packages/kyc-controller/src/countryCodes.test.ts b/packages/kyc-controller/src/countryCodes.test.ts new file mode 100644 index 0000000000..5544666319 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.test.ts @@ -0,0 +1,19 @@ +import { ALPHA2_TO_ALPHA3, alpha2ToAlpha3 } from './countryCodes'; + +describe('countryCodes', () => { + it('exposes the alpha-2 to alpha-3 map', () => { + expect(ALPHA2_TO_ALPHA3.US).toBe('USA'); + }); + + it('maps a known uppercase alpha-2 code', () => { + expect(alpha2ToAlpha3('GB')).toBe('GBR'); + }); + + it('is case-insensitive', () => { + expect(alpha2ToAlpha3('fr')).toBe('FRA'); + }); + + it('returns undefined for an unknown code', () => { + expect(alpha2ToAlpha3('ZZ')).toBeUndefined(); + }); +}); diff --git a/packages/kyc-controller/src/countryCodes.ts b/packages/kyc-controller/src/countryCodes.ts new file mode 100644 index 0000000000..a5712d2410 --- /dev/null +++ b/packages/kyc-controller/src/countryCodes.ts @@ -0,0 +1,270 @@ +/** + * ISO 3166-1 alpha-2 to alpha-3 country code mapping. + * + * The geolocation source returns ISO 3166-2 codes whose leading segment is an + * alpha-2 country code (e.g. "US", "US-NY"). The identity vendor APIs + * (disclaimers, kyc-required) expect alpha-3 codes (e.g. "USA"). This map + * bridges the two. + */ +export const ALPHA2_TO_ALPHA3: Record = { + AD: 'AND', + AE: 'ARE', + AF: 'AFG', + AG: 'ATG', + AI: 'AIA', + AL: 'ALB', + AM: 'ARM', + AO: 'AGO', + AQ: 'ATA', + AR: 'ARG', + AS: 'ASM', + AT: 'AUT', + AU: 'AUS', + AW: 'ABW', + AX: 'ALA', + AZ: 'AZE', + BA: 'BIH', + BB: 'BRB', + BD: 'BGD', + BE: 'BEL', + BF: 'BFA', + BG: 'BGR', + BH: 'BHR', + BI: 'BDI', + BJ: 'BEN', + BL: 'BLM', + BM: 'BMU', + BN: 'BRN', + BO: 'BOL', + BQ: 'BES', + BR: 'BRA', + BS: 'BHS', + BT: 'BTN', + BV: 'BVT', + BW: 'BWA', + BY: 'BLR', + BZ: 'BLZ', + CA: 'CAN', + CC: 'CCK', + CD: 'COD', + CF: 'CAF', + CG: 'COG', + CH: 'CHE', + CI: 'CIV', + CK: 'COK', + CL: 'CHL', + CM: 'CMR', + CN: 'CHN', + CO: 'COL', + CR: 'CRI', + CU: 'CUB', + CV: 'CPV', + CW: 'CUW', + CX: 'CXR', + CY: 'CYP', + CZ: 'CZE', + DE: 'DEU', + DJ: 'DJI', + DK: 'DNK', + DM: 'DMA', + DO: 'DOM', + DZ: 'DZA', + EC: 'ECU', + EE: 'EST', + EG: 'EGY', + EH: 'ESH', + ER: 'ERI', + ES: 'ESP', + ET: 'ETH', + FI: 'FIN', + FJ: 'FJI', + FK: 'FLK', + FM: 'FSM', + FO: 'FRO', + FR: 'FRA', + GA: 'GAB', + GB: 'GBR', + GD: 'GRD', + GE: 'GEO', + GF: 'GUF', + GG: 'GGY', + GH: 'GHA', + GI: 'GIB', + GL: 'GRL', + GM: 'GMB', + GN: 'GIN', + GP: 'GLP', + GQ: 'GNQ', + GR: 'GRC', + GS: 'SGS', + GT: 'GTM', + GU: 'GUM', + GW: 'GNB', + GY: 'GUY', + HK: 'HKG', + HM: 'HMD', + HN: 'HND', + HR: 'HRV', + HT: 'HTI', + HU: 'HUN', + ID: 'IDN', + IE: 'IRL', + IL: 'ISR', + IM: 'IMN', + IN: 'IND', + IO: 'IOT', + IQ: 'IRQ', + IR: 'IRN', + IS: 'ISL', + IT: 'ITA', + JE: 'JEY', + JM: 'JAM', + JO: 'JOR', + JP: 'JPN', + KE: 'KEN', + KG: 'KGZ', + KH: 'KHM', + KI: 'KIR', + KM: 'COM', + KN: 'KNA', + KP: 'PRK', + KR: 'KOR', + KW: 'KWT', + KY: 'CYM', + KZ: 'KAZ', + LA: 'LAO', + LB: 'LBN', + LC: 'LCA', + LI: 'LIE', + LK: 'LKA', + LR: 'LBR', + LS: 'LSO', + LT: 'LTU', + LU: 'LUX', + LV: 'LVA', + LY: 'LBY', + MA: 'MAR', + MC: 'MCO', + MD: 'MDA', + ME: 'MNE', + MF: 'MAF', + MG: 'MDG', + MH: 'MHL', + MK: 'MKD', + ML: 'MLI', + MM: 'MMR', + MN: 'MNG', + MO: 'MAC', + MP: 'MNP', + MQ: 'MTQ', + MR: 'MRT', + MS: 'MSR', + MT: 'MLT', + MU: 'MUS', + MV: 'MDV', + MW: 'MWI', + MX: 'MEX', + MY: 'MYS', + MZ: 'MOZ', + NA: 'NAM', + NC: 'NCL', + NE: 'NER', + NF: 'NFK', + NG: 'NGA', + NI: 'NIC', + NL: 'NLD', + NO: 'NOR', + NP: 'NPL', + NR: 'NRU', + NU: 'NIU', + NZ: 'NZL', + OM: 'OMN', + PA: 'PAN', + PE: 'PER', + PF: 'PYF', + PG: 'PNG', + PH: 'PHL', + PK: 'PAK', + PL: 'POL', + PM: 'SPM', + PN: 'PCN', + PR: 'PRI', + PS: 'PSE', + PT: 'PRT', + PW: 'PLW', + PY: 'PRY', + QA: 'QAT', + RE: 'REU', + RO: 'ROU', + RS: 'SRB', + RU: 'RUS', + RW: 'RWA', + SA: 'SAU', + SB: 'SLB', + SC: 'SYC', + SD: 'SDN', + SE: 'SWE', + SG: 'SGP', + SH: 'SHN', + SI: 'SVN', + SJ: 'SJM', + SK: 'SVK', + SL: 'SLE', + SM: 'SMR', + SN: 'SEN', + SO: 'SOM', + SR: 'SUR', + SS: 'SSD', + ST: 'STP', + SV: 'SLV', + SX: 'SXM', + SY: 'SYR', + SZ: 'SWZ', + TC: 'TCA', + TD: 'TCD', + TF: 'ATF', + TG: 'TGO', + TH: 'THA', + TJ: 'TJK', + TK: 'TKL', + TL: 'TLS', + TM: 'TKM', + TN: 'TUN', + TO: 'TON', + TR: 'TUR', + TT: 'TTO', + TV: 'TUV', + TW: 'TWN', + TZ: 'TZA', + UA: 'UKR', + UG: 'UGA', + UM: 'UMI', + US: 'USA', + UY: 'URY', + UZ: 'UZB', + VA: 'VAT', + VC: 'VCT', + VE: 'VEN', + VG: 'VGB', + VI: 'VIR', + VN: 'VNM', + VU: 'VUT', + WF: 'WLF', + WS: 'WSM', + YE: 'YEM', + YT: 'MYT', + ZA: 'ZAF', + ZM: 'ZMB', + ZW: 'ZWE', +}; + +/** + * Converts an ISO 3166-1 alpha-2 country code (e.g. "US") to its alpha-3 + * equivalent (e.g. "USA"). Returns `undefined` for unknown codes. + * + * @param alpha2 - The ISO 3166-1 alpha-2 country code. + * @returns The alpha-3 code, or `undefined` if the input is not recognized. + */ +export function alpha2ToAlpha3(alpha2: string): string | undefined { + return ALPHA2_TO_ALPHA3[alpha2.toUpperCase()]; +} diff --git a/packages/kyc-controller/src/crypto.test.ts b/packages/kyc-controller/src/crypto.test.ts new file mode 100644 index 0000000000..b26d985c9a --- /dev/null +++ b/packages/kyc-controller/src/crypto.test.ts @@ -0,0 +1,204 @@ +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +import type { EncryptedCredentialsEnvelope } from './crypto'; +import { decryptCredentials, generateKeyPair } from './crypto'; + +/** + * Builds an encrypted-credentials envelope that `decryptCredentials` can + * reverse with `ourPublicKey`'s matching private key. + * + * @param ourPublicKey - The recipient's X25519 public key. + * @param credentials - The plaintext credentials to encrypt. + * @param options - Encoding options. + * @param options.encoding - `'hex'` (default) or `'base64'`. + * @param options.ivLength - IV length in bytes (default 12). + * @param options.useNonceField - Emit `nonce` instead of `iv`. + * @returns The encrypted envelope. + */ +function makeEnvelope( + ourPublicKey: Uint8Array, + credentials: Record, + { + encoding = 'hex' as 'hex' | 'base64', + ivLength = 12, + useNonceField = false, + } = {}, +): EncryptedCredentialsEnvelope { + const ephemeralPrivate = x25519.utils.randomSecretKey(); + const ephemeralPublic = x25519.getPublicKey(ephemeralPrivate); + const shared = x25519.getSharedSecret(ephemeralPrivate, ourPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const iv = new Uint8Array(ivLength).fill(7); + const ciphertext = gcm(key, iv).encrypt( + utf8ToBytes(JSON.stringify(credentials)), + ); + const encode = (bytes: Uint8Array): string => + encoding === 'hex' ? bytesToHex(bytes) : base64.encode(bytes); + const envelope: EncryptedCredentialsEnvelope = { + ephemeralPublicKey: encode(ephemeralPublic), + ciphertext: encode(ciphertext), + }; + if (useNonceField) { + envelope.nonce = encode(iv); + } else { + envelope.iv = encode(iv); + } + return envelope; +} + +describe('crypto', () => { + describe('generateKeyPair', () => { + it('produces a 32-byte keypair with a hex public key', () => { + const keypair = generateKeyPair(); + expect(keypair.privateKey).toHaveLength(32); + expect(keypair.publicKey).toHaveLength(32); + expect(keypair.publicKeyHex).toMatch(/^[0-9a-f]{64}$/u); + }); + }); + + describe('decryptCredentials', () => { + it('decrypts a hex-encoded envelope object', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-1', + }); + + const { credentials, method } = decryptCredentials( + envelope, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-1'); + expect(method).toBe('aes-256-gcm/hkdf-sha256'); + }); + + it('decrypts a base64-encoded envelope', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { clientToken: 'client-1' }, + { encoding: 'base64' }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.clientToken).toBe('client-1'); + }); + + it('honors an explicit base64 encoding hint', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-2' }, + { encoding: 'base64' }, + ); + envelope.encoding = 'base64'; + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-2'); + }); + + it('accepts a `nonce` field as an alias for `iv`', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'access-3' }, + { useNonceField: true }, + ); + + const { credentials } = decryptCredentials(envelope, keypair.privateKey); + + expect(credentials.accessToken).toBe('access-3'); + }); + + it('decrypts an envelope delivered as a JSON string', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-4', + }); + + const { credentials } = decryptCredentials( + JSON.stringify(envelope), + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-4'); + }); + + it('decrypts an envelope delivered as base64(JSON)', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope(keypair.publicKey, { + accessToken: 'access-5', + }); + const base64Json = base64.encode(utf8ToBytes(JSON.stringify(envelope))); + + const { credentials } = decryptCredentials( + base64Json, + keypair.privateKey, + ); + + expect(credentials.accessToken).toBe('access-5'); + }); + + it('throws for a JSON string that fails to parse', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials('{ not valid json', keypair.privateKey), + ).toThrow(/looked like JSON but failed to parse/u); + }); + + it('throws for base64 that decodes to non-JSON starting with a brace', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('{ still not json')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /base64-decoded to non-JSON/u, + ); + }); + + it('throws for an opaque string that is neither JSON nor base64(JSON)', () => { + const keypair = generateKeyPair(); + const bad = base64.encode(utf8ToBytes('hello world')); + expect(() => decryptCredentials(bad, keypair.privateKey)).toThrow( + /opaque string/u, + ); + }); + + it('throws for an object missing required fields', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + { ephemeralPublicKey: 'aa' } as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/missing required fields/u); + }); + + it('reports the value type for a non-object input', () => { + const keypair = generateKeyPair(); + expect(() => + decryptCredentials( + 123 as unknown as EncryptedCredentialsEnvelope, + keypair.privateKey, + ), + ).toThrow(/Got: number/u); + }); + + it('throws when the IV length is not 12 bytes', () => { + const keypair = generateKeyPair(); + const envelope = makeEnvelope( + keypair.publicKey, + { accessToken: 'x' }, + { ivLength: 16 }, + ); + expect(() => decryptCredentials(envelope, keypair.privateKey)).toThrow( + /Unexpected IV length 16/u, + ); + }); + }); +}); diff --git a/packages/kyc-controller/src/crypto.ts b/packages/kyc-controller/src/crypto.ts new file mode 100644 index 0000000000..e54ea73091 --- /dev/null +++ b/packages/kyc-controller/src/crypto.ts @@ -0,0 +1,252 @@ +/** + * Check / Auth frame key exchange and credential decryption. + * + * The identity vendor's Check and Auth frames return encrypted credentials. + * The confirmed protocol is X25519 ECDH + AES-256-GCM (an "ECDH-ES" pattern + * signalled by a 12-byte IV): + * + * 1. Client generates an X25519 keypair, sends `publicKey` (hex) into the + * frame as a URL param. + * 2. Frame generates its own ephemeral X25519 keypair and encrypts the + * credentials, returning `{ ephemeralPublicKey, iv, ciphertext }`. + * 3. Client reverses: + * shared = X25519(ourPrivate, theirEphemeralPublic) + * key = HKDF-SHA256(shared, salt=none, info=none, 32 bytes) + * plain = AES-256-GCM.decrypt(key, iv, ciphertext || 16-byte tag) + * + * This module is platform-agnostic: it uses `@noble/*` + `@scure/base` and + * avoids `Buffer` / `atob` so it runs unchanged on mobile, extension, and web. + */ + +import { gcm } from '@noble/ciphers/aes'; +import { x25519 } from '@noble/curves/ed25519'; +import { hkdf } from '@noble/hashes/hkdf'; +import { sha256 } from '@noble/hashes/sha2'; +import { bytesToHex, hexToBytes } from '@noble/hashes/utils'; +import { base64 } from '@scure/base'; + +/** + * An X25519 keypair used for the Check/Auth frame key exchange. + */ +export type X25519KeyPair = { + /** Raw 32-byte X25519 private (scalar) key. Never leaves the device. */ + privateKey: Uint8Array; + /** Raw 32-byte X25519 public key. */ + publicKey: Uint8Array; + /** Hex-encoded public key, ready to drop into a Check/Auth frame URL. */ + publicKeyHex: string; +}; + +/** + * The encrypted-credentials envelope returned by the Check/Auth frames. Binary + * fields may be hex or base64; the IV field may be named `iv` or `nonce`. + */ +export type EncryptedCredentialsEnvelope = { + /** Ephemeral public key produced by the frame for this exchange (32 bytes). */ + ephemeralPublicKey: string; + /** Per-message IV. May be provided as `iv` or `nonce`. */ + iv?: string; + nonce?: string; + /** Ciphertext (plaintext + 16-byte GCM auth tag). */ + ciphertext: string; + /** Optional explicit encoding hint. Defaults to auto-detect. */ + encoding?: 'hex' | 'base64'; +}; + +/** + * Decrypted Check/Auth frame credentials. + * + * - `accessToken` is the Bearer token for the identity API. + * - `clientToken` is the short-lived token consumed by the Auth frame when the + * Check frame returns `connectionRequired`. + */ +export type DecryptedCredentials = { + accessToken?: string; + clientToken?: string; + [key: string]: unknown; +}; + +/** + * Result of a successful decryption — the credentials plus the `method` that + * authenticated. + */ +export type DecryptResult = { + credentials: DecryptedCredentials; + method: string; +}; + +/** + * Generate a fresh X25519 keypair. The private key never leaves the device; + * only `publicKeyHex` is sent to the vendor via the frame URL. + * + * @returns The generated keypair. + */ +export function generateKeyPair(): X25519KeyPair { + const privateKey = x25519.utils.randomSecretKey(); + const publicKey = x25519.getPublicKey(privateKey); + return { + privateKey, + publicKey, + publicKeyHex: bytesToHex(publicKey), + }; +} + +/** + * Decode a base64 / base64url string to bytes without relying on `atob` or + * `Buffer`. + * + * @param value - The (possibly url-safe, possibly unpadded) base64 string. + * @returns The decoded bytes. + */ +function base64ToBytes(value: string): Uint8Array { + const normalized = value.replace(/-/gu, '+').replace(/_/gu, '/'); + const padded = normalized.padEnd( + normalized.length + ((4 - (normalized.length % 4)) % 4), + '=', + ); + return base64.decode(padded); +} + +/** + * Decode a binary envelope field that may be hex or base64. + * + * @param value - The encoded field. + * @param encoding - Optional explicit encoding; auto-detected when omitted. + * @returns The decoded bytes. + */ +function decodeBinary(value: string, encoding?: 'hex' | 'base64'): Uint8Array { + const isHex = + encoding === 'hex' || + (encoding === undefined && /^[0-9a-fA-F]+$/u.test(value)); + if (isHex) { + return hexToBytes(value); + } + return base64ToBytes(value); +} + +/** + * Coerce the `credentials` field into a structured envelope. The frame may + * deliver it as an object, a JSON string, or base64(JSON). + * + * @param input - The raw credentials value. + * @returns The normalized envelope. + * @throws If the value is not a structured or base64(JSON) envelope, or is + * missing required fields. + */ +function normalizeEnvelope( + input: EncryptedCredentialsEnvelope | string, +): EncryptedCredentialsEnvelope { + let value: unknown = input; + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (trimmed.startsWith('{')) { + try { + value = JSON.parse(trimmed); + } catch { + throw new Error( + `credentials looked like JSON but failed to parse (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + let decodedText: string | null = null; + try { + decodedText = new TextDecoder().decode(base64ToBytes(trimmed)); + } catch { + decodedText = null; + } + const decodedTrimmed = decodedText?.trim(); + if (decodedTrimmed?.startsWith('{')) { + try { + value = JSON.parse(decodedTrimmed); + } catch { + throw new Error( + `credentials base64-decoded to non-JSON (preview: "${decodedTrimmed.slice( + 0, + 64, + )}").`, + ); + } + } else { + throw new Error( + `credentials is an opaque string, not a structured or base64(JSON) envelope (preview: "${trimmed.slice( + 0, + 64, + )}").`, + ); + } + } + } + + const env = value as Partial; + if (!env.ephemeralPublicKey || !(env.iv ?? env.nonce) || !env.ciphertext) { + const keys = + value && typeof value === 'object' + ? Object.keys(value).join(', ') + : typeof value; + throw new Error( + `credentials envelope missing required fields (ephemeralPublicKey/iv/ciphertext). Got: ${keys}`, + ); + } + return env as EncryptedCredentialsEnvelope; +} + +/** + * X25519 ECDH to AES-256-GCM decryption. + * + * @param theirPublicKey - The frame's ephemeral public key. + * @param iv - The 12-byte GCM IV. + * @param ciphertext - The ciphertext including the 16-byte auth tag. + * @param ourPrivateKey - Our X25519 private key. + * @returns The decrypted credentials and method. + */ +function aesGcmDecrypt( + theirPublicKey: Uint8Array, + iv: Uint8Array, + ciphertext: Uint8Array, + ourPrivateKey: Uint8Array, +): DecryptResult { + const shared = x25519.getSharedSecret(ourPrivateKey, theirPublicKey); + const key = hkdf(sha256, shared, undefined, undefined, 32); + const plaintext = gcm(key, iv).decrypt(ciphertext); + const text = new TextDecoder().decode(plaintext); + return { + credentials: JSON.parse(text) as DecryptedCredentials, + method: 'aes-256-gcm/hkdf-sha256', + }; +} + +/** + * Decrypt a Check/Auth frame credentials envelope using our X25519 private + * key. + * + * @param rawEnvelope - The raw envelope (object, JSON string, or base64(JSON)). + * @param ourPrivateKey - Our X25519 private key. + * @returns The parsed credentials and the method that authenticated. + * @throws If the envelope is malformed or the IV length is not 12 bytes. + */ +export function decryptCredentials( + rawEnvelope: EncryptedCredentialsEnvelope | string, + ourPrivateKey: Uint8Array, +): DecryptResult { + const envelope = normalizeEnvelope(rawEnvelope); + const theirPublicKey = decodeBinary( + envelope.ephemeralPublicKey, + envelope.encoding, + ); + // `normalizeEnvelope` guarantees one of `iv` / `nonce` is present. + const ivField = (envelope.iv ?? envelope.nonce) as string; + const iv = decodeBinary(ivField, envelope.encoding); + const ciphertext = decodeBinary(envelope.ciphertext, envelope.encoding); + + if (iv.length !== 12) { + throw new Error( + `Unexpected IV length ${iv.length} (expected 12 for AES-256-GCM).`, + ); + } + + return aesGcmDecrypt(theirPublicKey, iv, ciphertext, ourPrivateKey); +} diff --git a/packages/kyc-controller/src/index.test.ts b/packages/kyc-controller/src/index.test.ts new file mode 100644 index 0000000000..b95071359e --- /dev/null +++ b/packages/kyc-controller/src/index.test.ts @@ -0,0 +1,19 @@ +import * as packageExports from '.'; + +describe('@metamask/kyc-controller', () => { + it('exports the controller, service, selectors, and helpers', () => { + expect(packageExports).toMatchObject({ + KycController: expect.any(Function), + KycService: expect.any(Function), + getDefaultKycControllerState: expect.any(Function), + selectKycPhase: expect.any(Function), + selectKycSumSub: expect.any(Function), + selectIsKycRequiredForProduct: expect.any(Function), + alpha2ToAlpha3: expect.any(Function), + generateKeyPair: expect.any(Function), + decryptCredentials: expect.any(Function), + controllerName: 'KycController', + serviceName: 'KycService', + }); + }); +}); diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts new file mode 100644 index 0000000000..816afb882b --- /dev/null +++ b/packages/kyc-controller/src/index.ts @@ -0,0 +1,76 @@ +export { + KycController, + getDefaultKycControllerState, + controllerName, +} from './KycController'; +export type { + KycControllerActions, + KycControllerEvents, + KycControllerGetStateAction, + KycControllerMessenger, + KycControllerOptions, + KycControllerState, + KycControllerStateChangeEvent, +} from './KycController'; +export type { + KycControllerAcceptTermsAndStartSessionAction, + KycControllerBuildAuthFrameUrlAction, + KycControllerBuildCheckFrameUrlAction, + KycControllerBuildResetFrameUrlAction, + KycControllerCheckKycRequiredAction, + KycControllerClearSavedTermsAction, + KycControllerGetKycStatusAction, + KycControllerHandleFrameMessageAction, + KycControllerInitializeAction, + KycControllerLoadDisclaimersAction, + KycControllerResetAction, + KycControllerStartSumSubAction, +} from './KycController-method-action-types'; + +export { KycService, serviceName } from './KycService'; +export type { + CheckKycRequiredParams, + CreateSessionParams, + CreateUkycSessionParams, + KycServiceActions, + KycServiceEnvironment, + KycServiceEvents, + KycServiceMessenger, + KycServiceOptions, + SubmitWrappedKeyParams, + UkycSessionResponse, + WrappedKeyResponse, +} from './KycService'; +export type { + KycServiceCheckKycRequiredAction, + KycServiceCreateSessionAction, + KycServiceCreateUkycSessionAction, + KycServiceFetchDisclaimersAction, + KycServiceGetGeoCountryAction, + KycServiceSubmitWrappedKeyAction, +} from './KycService-method-action-types'; + +export { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors'; + +export { alpha2ToAlpha3, ALPHA2_TO_ALPHA3 } from './countryCodes'; +export { decryptCredentials, generateKeyPair } from './crypto'; +export type { + DecryptedCredentials, + DecryptResult, + EncryptedCredentialsEnvelope, + X25519KeyPair, +} from './crypto'; + +export type { + KycDisclaimer, + KycPhase, + KycProduct, + KycSumSubLaunchParams, + KycSumSubLauncher, + KycSumSubStatus, + KycVendor, +} from './types'; diff --git a/packages/kyc-controller/src/selectors.test.ts b/packages/kyc-controller/src/selectors.test.ts new file mode 100644 index 0000000000..725ec0e592 --- /dev/null +++ b/packages/kyc-controller/src/selectors.test.ts @@ -0,0 +1,33 @@ +import { getDefaultKycControllerState } from './KycController'; +import { + selectIsKycRequiredForProduct, + selectKycPhase, + selectKycSumSub, +} from './selectors'; + +describe('selectors', () => { + it('selectKycPhase returns the current phase', () => { + const state = { ...getDefaultKycControllerState(), phase: 'form' as const }; + expect(selectKycPhase(state)).toBe('form'); + }); + + it('selectKycSumSub returns the sub-flow state', () => { + const state = getDefaultKycControllerState(); + expect(selectKycSumSub(state)).toStrictEqual(state.sumsub); + }); + + describe('selectIsKycRequiredForProduct', () => { + it('returns the cached requirement for a product', () => { + const state = { + ...getDefaultKycControllerState(), + kycRequiredByProduct: { ramps: true }, + }; + expect(selectIsKycRequiredForProduct('ramps')(state)).toBe(true); + }); + + it('returns undefined when the product has not been checked', () => { + const state = getDefaultKycControllerState(); + expect(selectIsKycRequiredForProduct('card')(state)).toBeUndefined(); + }); + }); +}); diff --git a/packages/kyc-controller/src/selectors.ts b/packages/kyc-controller/src/selectors.ts new file mode 100644 index 0000000000..658169a15f --- /dev/null +++ b/packages/kyc-controller/src/selectors.ts @@ -0,0 +1,42 @@ +import { createSelector } from 'reselect'; + +import type { KycControllerState } from './KycController'; +import type { KycProduct } from './types'; + +const selectKycRequiredByProduct = ( + state: KycControllerState, +): KycControllerState['kycRequiredByProduct'] => state.kycRequiredByProduct; + +/** + * Selects the current flow phase. + * + * @param state - The KycController state. + * @returns The current phase. + */ +export const selectKycPhase = ( + state: KycControllerState, +): KycControllerState['phase'] => state.phase; + +/** + * Selects the SumSub sub-flow state. + * + * @param state - The KycController state. + * @returns The SumSub state. + */ +export const selectKycSumSub = ( + state: KycControllerState, +): KycControllerState['sumsub'] => state.sumsub; + +/** + * Creates a selector that returns whether KYC is required for a product. + * + * @param product - The consuming feature. + * @returns A selector returning the cached requirement, or `undefined`. + */ +export const selectIsKycRequiredForProduct = ( + product: KycProduct, +): ((state: KycControllerState) => boolean | undefined) => + createSelector( + [selectKycRequiredByProduct], + (map): boolean | undefined => map[product], + ); diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts new file mode 100644 index 0000000000..7861a29392 --- /dev/null +++ b/packages/kyc-controller/src/types.ts @@ -0,0 +1,122 @@ +/** + * Shared types for the KYC controller and service. + * + * The KYC flow is vendor-backed (currently MoonPay for identity + SumSub for + * document verification) but the surface exposed to consumers (ramps, card) is + * intentionally vendor-neutral so a future vendor swap does not ripple out. + */ + +/** + * A MetaMask feature that consumes KYC. Used to key the per-product + * "is KYC required" cache so ramps and card can share one controller. + */ +export type KycProduct = 'ramps' | 'card'; + +/** + * Identity vendors supported behind the KYC surface. + */ +export type KycVendor = 'moonpay'; + +/** + * Phases of the end-to-end identity flow. + * + * - `idle` — nothing started. + * - `terms` — waiting for the customer to accept the vendor terms. + * - `session` — creating the vendor session. + * - `check` — running the invisible connection-check frame. + * - `auth` — running the visible authentication (OTP) frame. + * - `form` — authenticated. When the flow is scoped to a product, the + * KYC-required check runs automatically from here; otherwise the consumer + * drives it manually via `checkKycRequired`. + * - `submit` — submitting the KYC-required check. + * - `done` — flow complete; see `kycRequiredByProduct` / `sumsub`. When KYC is + * required, the document-verification sub-flow is launched automatically. + * - `error` — flow halted; see `error`. + */ +export type KycPhase = + | 'idle' + | 'terms' + | 'session' + | 'check' + | 'auth' + | 'form' + | 'submit' + | 'done' + | 'error'; + +/** + * Progress of the SumSub document-verification sub-flow. + */ +export type KycSumSubStatus = + | 'idle' + | 'creatingSession' + | 'fetchingToken' + | 'launching' + | 'inProgress' + | 'complete' + | 'failed'; + +/** + * A single disclaimer/term the customer must accept before a session is + * created. + */ +export type KycDisclaimer = { + id: string; + // Mirrors the vendor API response field, which is snake_case. + // eslint-disable-next-line @typescript-eslint/naming-convention + display_name: string; + url: string; +}; + +/** + * Parameters passed to a platform SumSub launcher. + */ +export type KycSumSubLaunchParams = { + /** + * The applicant access token used to initialize the SumSub SDK. + */ + applicantAccessToken: string; + + /** + * Called by the SDK when the access token expires; must resolve with a fresh + * applicant access token. + */ + onTokenExpiration: () => Promise; + + /** + * Called when the SDK reports a status transition. + */ + onStatusChange?: (prevStatus: string, newStatus: string) => void; + + /** + * BCP-47 locale for the SDK UI. + */ + locale?: string; + + /** + * Enables SDK debug logging. + */ + debug?: boolean; +}; + +/** + * Platform adapter that launches the native/web SumSub SDK. + * + * The KYC controller is platform-agnostic and does not import any SDK; each + * client (mobile / extension / web) injects an implementation of this + * interface. The controller owns all orchestration (session creation, token + * exchange, token refresh, state) and only delegates the actual SDK + * presentation to `launch`. + */ +export type KycSumSubLauncher = { + /** + * Whether the underlying SDK is available in the current runtime (e.g. the + * native module is linked). When `false`, `startSumSub` fails fast. + */ + isAvailable(): boolean; + + /** + * Presents the SumSub verification flow and resolves with the SDK result. + */ + launch(params: KycSumSubLaunchParams): Promise>; +}; diff --git a/packages/kyc-controller/tsconfig.build.json b/packages/kyc-controller/tsconfig.build.json new file mode 100644 index 0000000000..b36e81be15 --- /dev/null +++ b/packages/kyc-controller/tsconfig.build.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../geolocation-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" }, + { "path": "../profile-sync-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/kyc-controller/tsconfig.json b/packages/kyc-controller/tsconfig.json new file mode 100644 index 0000000000..be54252e24 --- /dev/null +++ b/packages/kyc-controller/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../geolocation-controller" }, + { "path": "../messenger" }, + { "path": "../profile-sync-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/kyc-controller/typedoc.json b/packages/kyc-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/kyc-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index b20ada66d9..8c95f3e411 100644 --- a/teams.json +++ b/teams.json @@ -94,5 +94,6 @@ "metamask/money-account-upgrade-controller": "team-earn", "metamask/snap-account-service": "team-accounts-framework", "metamask/platform-api-docs": "team-core-platform", - "metamask/smart-transactions-controller": "team-transactions" + "metamask/smart-transactions-controller": "team-transactions", + "metamask/kyc-controller": "team-universal-kyc" } diff --git a/tsconfig.build.json b/tsconfig.build.json index 5e80555006..05d6328610 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -145,6 +145,9 @@ { "path": "./packages/keyring-controller/tsconfig.build.json" }, + { + "path": "./packages/kyc-controller/tsconfig.build.json" + }, { "path": "./packages/local-node-utils/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 8375547799..52cdfd86d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -140,6 +140,9 @@ { "path": "./packages/keyring-controller" }, + { + "path": "./packages/kyc-controller" + }, { "path": "./packages/local-node-utils" }, diff --git a/yarn.lock b/yarn.lock index 8b0f0e77cc..949feb617a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -255,7 +255,7 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": +"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.12.13, @babel/code-frame@npm:^7.27.1, @babel/code-frame@npm:^7.29.7": version: 7.29.7 resolution: "@babel/code-frame@npm:7.29.7" dependencies: @@ -273,7 +273,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.21.3, @babel/core@npm:^7.23.2, @babel/core@npm:^7.23.9, @babel/core@npm:^7.25.9, @babel/core@npm:^7.27.4": version: 7.29.7 resolution: "@babel/core@npm:7.29.7" dependencies: @@ -296,7 +296,7 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7": +"@babel/generator@npm:^7.25.9, @babel/generator@npm:^7.27.5, @babel/generator@npm:^7.29.7, @babel/generator@npm:^7.7.2": version: 7.29.7 resolution: "@babel/generator@npm:7.29.7" dependencies: @@ -510,7 +510,7 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": +"@babel/parser@npm:^7.1.0, @babel/parser@npm:^7.14.7, @babel/parser@npm:^7.20.7, @babel/parser@npm:^7.23.9, @babel/parser@npm:^7.29.7": version: 7.29.7 resolution: "@babel/parser@npm:7.29.7" dependencies: @@ -700,7 +700,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7": +"@babel/plugin-syntax-jsx@npm:^7.27.1, @babel/plugin-syntax-jsx@npm:^7.29.7, @babel/plugin-syntax-jsx@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-jsx@npm:7.29.7" dependencies: @@ -799,7 +799,7 @@ __metadata: languageName: node linkType: hard -"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7": +"@babel/plugin-syntax-typescript@npm:^7.27.1, @babel/plugin-syntax-typescript@npm:^7.29.7, @babel/plugin-syntax-typescript@npm:^7.7.2": version: 7.29.7 resolution: "@babel/plugin-syntax-typescript@npm:7.29.7" dependencies: @@ -1646,7 +1646,7 @@ __metadata: languageName: node linkType: hard -"@babel/template@npm:^7.29.7": +"@babel/template@npm:^7.29.7, @babel/template@npm:^7.3.3": version: 7.29.7 resolution: "@babel/template@npm:7.29.7" dependencies: @@ -1672,7 +1672,7 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.4.4": +"@babel/types@npm:^7.0.0, @babel/types@npm:^7.20.7, @babel/types@npm:^7.21.3, @babel/types@npm:^7.23.0, @babel/types@npm:^7.27.3, @babel/types@npm:^7.28.2, @babel/types@npm:^7.29.7, @babel/types@npm:^7.3.3, @babel/types@npm:^7.4.4": version: 7.29.7 resolution: "@babel/types@npm:7.29.7" dependencies: @@ -4728,6 +4728,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/console@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4a80c750e8a31f344233cb9951dee9b77bf6b89377cb131f8b3cde07ff218f504370133a5963f6a786af4d2ce7f85642db206ff7a15f99fe58df4c38ac04899e + languageName: node + linkType: hard + "@jest/core@npm:30.4.2": version: 30.4.2 resolution: "@jest/core@npm:30.4.2" @@ -4769,6 +4783,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/core@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/reporters": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-changed-files: "npm:^29.7.0" + jest-config: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-resolve-dependencies: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-ansi: "npm:^6.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/ab6ac2e562d083faac7d8152ec1cc4eccc80f62e9579b69ed40aedf7211a6b2d57024a6cd53c4e35fd051c39a236e86257d1d99ebdb122291969a0a04563b51e + languageName: node + linkType: hard + "@jest/diff-sequences@npm:30.4.0": version: 30.4.0 resolution: "@jest/diff-sequences@npm:30.4.0" @@ -4809,6 +4864,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/environment@npm:29.7.0" + dependencies: + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + checksum: 10/90b5844a9a9d8097f2cf107b1b5e57007c552f64315da8c1f51217eeb0a9664889d3f145cdf8acf23a84f4d8309a6675e27d5b059659a004db0ea9546d1c81a8 + languageName: node + linkType: hard + "@jest/expect-utils@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect-utils@npm:30.4.1" @@ -4818,6 +4885,15 @@ __metadata: languageName: node linkType: hard +"@jest/expect-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect-utils@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + checksum: 10/ef8d379778ef574a17bde2801a6f4469f8022a46a5f9e385191dc73bb1fc318996beaed4513fbd7055c2847227a1bed2469977821866534593a6e52a281499ee + languageName: node + linkType: hard + "@jest/expect@npm:30.4.1": version: 30.4.1 resolution: "@jest/expect@npm:30.4.1" @@ -4828,6 +4904,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/expect@npm:29.7.0" + dependencies: + expect: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + checksum: 10/fea6c3317a8da5c840429d90bfe49d928e89c9e89fceee2149b93a11b7e9c73d2f6e4d7cdf647163da938fc4e2169e4490be6bae64952902bc7a701033fd4880 + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.4.1": version: 30.4.1 resolution: "@jest/fake-timers@npm:30.4.1" @@ -4842,6 +4928,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/fake-timers@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@sinonjs/fake-timers": "npm:^10.0.2" + "@types/node": "npm:*" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9b394e04ffc46f91725ecfdff34c4e043eb7a16e1d78964094c9db3fde0b1c8803e45943a980e8c740d0a3d45661906de1416ca5891a538b0660481a3a828c27 + languageName: node + linkType: hard + "@jest/get-type@npm:30.1.0": version: 30.1.0 resolution: "@jest/get-type@npm:30.1.0" @@ -4861,6 +4961,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/globals@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + jest-mock: "npm:^29.7.0" + checksum: 10/97dbb9459135693ad3a422e65ca1c250f03d82b2a77f6207e7fa0edd2c9d2015fbe4346f3dc9ebff1678b9d8da74754d4d440b7837497f8927059c0642a22123 + languageName: node + linkType: hard + "@jest/pattern@npm:30.4.0": version: 30.4.0 resolution: "@jest/pattern@npm:30.4.0" @@ -4907,6 +5019,43 @@ __metadata: languageName: node linkType: hard +"@jest/reporters@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/reporters@npm:29.7.0" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + collect-v8-coverage: "npm:^1.0.0" + exit: "npm:^0.1.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + 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:^4.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.1" + strip-ansi: "npm:^6.0.0" + 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/a17d1644b26dea14445cedd45567f4ba7834f980be2ef74447204e14238f121b50d8b858fde648083d2cd8f305f81ba434ba49e37a5f4237a6f2a61180cc73dc + languageName: node + linkType: hard + "@jest/schemas@npm:30.4.1": version: 30.4.1 resolution: "@jest/schemas@npm:30.4.1" @@ -4948,6 +5097,17 @@ __metadata: languageName: node linkType: hard +"@jest/source-map@npm:^29.6.3": + version: 29.6.3 + resolution: "@jest/source-map@npm:29.6.3" + dependencies: + "@jridgewell/trace-mapping": "npm:^0.3.18" + callsites: "npm:^3.0.0" + graceful-fs: "npm:^4.2.9" + checksum: 10/bcc5a8697d471396c0003b0bfa09722c3cd879ad697eb9c431e6164e2ea7008238a01a07193dfe3cbb48b1d258eb7251f6efcea36f64e1ebc464ea3c03ae2deb + languageName: node + linkType: hard + "@jest/test-result@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-result@npm:30.4.1" @@ -4960,6 +5120,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-result@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/istanbul-lib-coverage": "npm:^2.0.0" + collect-v8-coverage: "npm:^1.0.0" + checksum: 10/c073ab7dfe3c562bff2b8fee6cc724ccc20aa96bcd8ab48ccb2aa309b4c0c1923a9e703cea386bd6ae9b71133e92810475bb9c7c22328fc63f797ad3324ed189 + languageName: node + linkType: hard + "@jest/test-sequencer@npm:30.4.1": version: 30.4.1 resolution: "@jest/test-sequencer@npm:30.4.1" @@ -4972,6 +5144,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/test-sequencer@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + slash: "npm:^3.0.0" + checksum: 10/4420c26a0baa7035c5419b0892ff8ffe9a41b1583ec54a10db3037cd46a7e29dd3d7202f8aa9d376e9e53be5f8b1bc0d16e1de6880a6d319b033b01dc4c8f639 + languageName: node + linkType: hard + "@jest/transform@npm:30.4.1": version: 30.4.1 resolution: "@jest/transform@npm:30.4.1" @@ -4994,6 +5178,29 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:^29.7.0": + version: 29.7.0 + resolution: "@jest/transform@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/types": "npm:^29.6.3" + "@jridgewell/trace-mapping": "npm:^0.3.18" + babel-plugin-istanbul: "npm:^6.1.1" + chalk: "npm:^4.0.0" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + pirates: "npm:^4.0.4" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^4.0.2" + checksum: 10/30f42293545ab037d5799c81d3e12515790bb58513d37f788ce32d53326d0d72ebf5b40f989e6896739aa50a5f77be44686e510966370d58511d5ad2637c68c1 + languageName: node + linkType: hard + "@jest/types@npm:30.4.1": version: 30.4.1 resolution: "@jest/types@npm:30.4.1" @@ -7438,6 +7645,37 @@ __metadata: languageName: node linkType: hard +"@metamask/kyc-controller@workspace:packages/kyc-controller": + version: 0.0.0-use.local + resolution: "@metamask/kyc-controller@workspace:packages/kyc-controller" + dependencies: + "@metamask/auto-changelog": "npm:^6.1.0" + "@metamask/base-controller": "npm:^9.1.0" + "@metamask/controller-utils": "npm:^12.3.0" + "@metamask/geolocation-controller": "npm:^0.1.3" + "@metamask/messenger": "npm:^2.0.0" + "@metamask/profile-sync-controller": "npm:^28.3.0" + "@metamask/superstruct": "npm:^3.1.0" + "@metamask/utils": "npm:^11.11.0" + "@noble/ciphers": "npm:^1.3.0" + "@noble/curves": "npm:^1.9.2" + "@noble/hashes": "npm:^1.8.0" + "@scure/base": "npm:^1.2.6" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^29.5.14" + chokidar-cli: "npm:^3.0.0" + deepmerge: "npm:^4.2.2" + jest: "npm:^29.7.0" + nock: "npm:^13.3.1" + reselect: "npm:^5.1.1" + ts-jest: "npm:^29.2.5" + tsx: "npm:^4.20.5" + typedoc: "npm:^0.25.13" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/local-node-utils@npm:^1.0.0, @metamask/local-node-utils@workspace:packages/local-node-utils": version: 0.0.0-use.local resolution: "@metamask/local-node-utils@workspace:packages/local-node-utils" @@ -10589,7 +10827,7 @@ __metadata: languageName: node linkType: hard -"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:~1.2.5": +"@scure/base@npm:^1.0.0, @scure/base@npm:^1.1.1, @scure/base@npm:^1.1.3, @scure/base@npm:^1.2.6, @scure/base@npm:~1.2.5": version: 1.2.6 resolution: "@scure/base@npm:1.2.6" checksum: 10/c1a7bd5e0b0c8f94c36fbc220f4a67cc832b00e2d2065c7d8a404ed81ab1c94c5443def6d361a70fc382db3496e9487fb9941728f0584782b274c18a4bed4187 @@ -10703,7 +10941,7 @@ __metadata: languageName: node linkType: hard -"@sinonjs/commons@npm:^3.0.1": +"@sinonjs/commons@npm:^3.0.0, @sinonjs/commons@npm:^3.0.1": version: 3.0.1 resolution: "@sinonjs/commons@npm:3.0.1" dependencies: @@ -10712,6 +10950,15 @@ __metadata: languageName: node linkType: hard +"@sinonjs/fake-timers@npm:^10.0.2": + version: 10.3.0 + resolution: "@sinonjs/fake-timers@npm:10.3.0" + dependencies: + "@sinonjs/commons": "npm:^3.0.0" + checksum: 10/78155c7bd866a85df85e22028e046b8d46cf3e840f72260954f5e3ed5bd97d66c595524305a6841ffb3f681a08f6e5cef572a2cce5442a8a232dc29fb409b83e + languageName: node + linkType: hard + "@sinonjs/fake-timers@npm:^15.4.0": version: 15.4.0 resolution: "@sinonjs/fake-timers@npm:15.4.0" @@ -11167,7 +11414,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__core@npm:^7.20.5": +"@types/babel__core@npm:^7.1.14, @types/babel__core@npm:^7.20.5": version: 7.20.5 resolution: "@types/babel__core@npm:7.20.5" dependencies: @@ -11199,7 +11446,7 @@ __metadata: languageName: node linkType: hard -"@types/babel__traverse@npm:*": +"@types/babel__traverse@npm:*, @types/babel__traverse@npm:^7.0.6": version: 7.28.0 resolution: "@types/babel__traverse@npm:7.28.0" dependencies: @@ -11369,6 +11616,15 @@ __metadata: languageName: node linkType: hard +"@types/graceful-fs@npm:^4.1.3": + version: 4.1.9 + resolution: "@types/graceful-fs@npm:4.1.9" + dependencies: + "@types/node": "npm:*" + checksum: 10/79d746a8f053954bba36bd3d94a90c78de995d126289d656fb3271dd9f1229d33f678da04d10bce6be440494a5a73438e2e363e92802d16b8315b051036c5256 + languageName: node + linkType: hard + "@types/gtag.js@npm:^0.0.20": version: 0.0.20 resolution: "@types/gtag.js@npm:0.0.20" @@ -11466,6 +11722,16 @@ __metadata: languageName: node linkType: hard +"@types/jest@npm:^29.5.14": + version: 29.5.14 + resolution: "@types/jest@npm:29.5.14" + dependencies: + expect: "npm:^29.0.0" + pretty-format: "npm:^29.0.0" + checksum: 10/59ec7a9c4688aae8ee529316c43853468b6034f453d08a2e1064b281af9c81234cec986be796288f1bbb29efe943bc950e70c8fa8faae1e460d50e3cf9760f9b + languageName: node + linkType: hard + "@types/jsdom@npm:^21.1.7": version: 21.1.7 resolution: "@types/jsdom@npm:21.1.7" @@ -11755,7 +12021,7 @@ __metadata: languageName: node linkType: hard -"@types/stack-utils@npm:^2.0.3": +"@types/stack-utils@npm:^2.0.0, @types/stack-utils@npm:^2.0.3": version: 2.0.3 resolution: "@types/stack-utils@npm:2.0.3" checksum: 10/72576cc1522090fe497337c2b99d9838e320659ac57fa5560fcbdcbafcf5d0216c6b3a0a8a4ee4fdb3b1f5e3420aa4f6223ab57b82fef3578bec3206425c6cf5 @@ -12542,7 +12808,7 @@ __metadata: languageName: node linkType: hard -"ansi-escapes@npm:^4.3.2": +"ansi-escapes@npm:^4.2.1, ansi-escapes@npm:^4.3.2": version: 4.3.2 resolution: "ansi-escapes@npm:4.3.2" dependencies: @@ -12560,6 +12826,13 @@ __metadata: languageName: node linkType: hard +"ansi-regex@npm:^4.1.0": + version: 4.1.1 + resolution: "ansi-regex@npm:4.1.1" + checksum: 10/b1a6ee44cb6ecdabaa770b2ed500542714d4395d71c7e5c25baa631f680fb2ad322eb9ba697548d498a6fd366949fc8b5bfcf48d49a32803611f648005b01888 + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -12581,6 +12854,15 @@ __metadata: languageName: node linkType: hard +"ansi-styles@npm:^3.2.0": + version: 3.2.1 + resolution: "ansi-styles@npm:3.2.1" + dependencies: + color-convert: "npm:^1.9.0" + checksum: 10/d85ade01c10e5dd77b6c89f34ed7531da5830d2cb5882c645f330079975b716438cd7ebb81d0d6e6b4f9c577f19ae41ab55f07f19786b02f9dfd9e0377395665 + languageName: node + linkType: hard + "ansi-styles@npm:^4.0.0, ansi-styles@npm:^4.1.0": version: 4.3.0 resolution: "ansi-styles@npm:4.3.0" @@ -12590,7 +12872,7 @@ __metadata: languageName: node linkType: hard -"ansi-styles@npm:^5.2.0": +"ansi-styles@npm:^5.0.0, ansi-styles@npm:^5.2.0": version: 5.2.0 resolution: "ansi-styles@npm:5.2.0" checksum: 10/d7f4e97ce0623aea6bc0d90dcd28881ee04cba06c570b97fd3391bd7a268eedfd9d5e2dd4fdcbdd82b8105df5faf6f24aaedc08eaf3da898e702db5948f63469 @@ -12611,7 +12893,7 @@ __metadata: languageName: node linkType: hard -"anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": +"anymatch@npm:^3.0.3, anymatch@npm:^3.1.3, anymatch@npm:~3.1.2": version: 3.1.3 resolution: "anymatch@npm:3.1.3" dependencies: @@ -12797,6 +13079,23 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "babel-jest@npm:29.7.0" + dependencies: + "@jest/transform": "npm:^29.7.0" + "@types/babel__core": "npm:^7.1.14" + babel-plugin-istanbul: "npm:^6.1.1" + babel-preset-jest: "npm:^29.6.3" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.8.0 + checksum: 10/8a0953bd813b3a8926008f7351611055548869e9a53dd36d6e7e96679001f71e65fd7dbfe253265c3ba6a4e630dc7c845cf3e78b17d758ef1880313ce8fba258 + languageName: node + linkType: hard + "babel-loader@npm:^9.2.1": version: 9.2.1 resolution: "babel-loader@npm:9.2.1" @@ -12819,6 +13118,19 @@ __metadata: languageName: node linkType: hard +"babel-plugin-istanbul@npm:^6.1.1": + version: 6.1.1 + resolution: "babel-plugin-istanbul@npm:6.1.1" + dependencies: + "@babel/helper-plugin-utils": "npm:^7.0.0" + "@istanbuljs/load-nyc-config": "npm:^1.0.0" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-instrument: "npm:^5.0.4" + test-exclude: "npm:^6.0.0" + checksum: 10/ffd436bb2a77bbe1942a33245d770506ab2262d9c1b3c1f1da7f0592f78ee7445a95bc2efafe619dd9c1b6ee52c10033d6c7d29ddefe6f5383568e60f31dfe8d + languageName: node + linkType: hard + "babel-plugin-istanbul@npm:^7.0.1": version: 7.0.1 resolution: "babel-plugin-istanbul@npm:7.0.1" @@ -12841,6 +13153,18 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jest-hoist@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-plugin-jest-hoist@npm:29.6.3" + dependencies: + "@babel/template": "npm:^7.3.3" + "@babel/types": "npm:^7.3.3" + "@types/babel__core": "npm:^7.1.14" + "@types/babel__traverse": "npm:^7.0.6" + checksum: 10/9bfa86ec4170bd805ab8ca5001ae50d8afcb30554d236ba4a7ffc156c1a92452e220e4acbd98daefc12bf0216fccd092d0a2efed49e7e384ec59e0597a926d65 + 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" @@ -12889,7 +13213,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.2.0": +"babel-preset-current-node-syntax@npm:^1.0.0, babel-preset-current-node-syntax@npm:^1.2.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -12926,6 +13250,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:^29.6.3": + version: 29.6.3 + resolution: "babel-preset-jest@npm:29.6.3" + dependencies: + babel-plugin-jest-hoist: "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + peerDependencies: + "@babel/core": ^7.0.0 + checksum: 10/aa4ff2a8a728d9d698ed521e3461a109a1e66202b13d3494e41eea30729a5e7cc03b3a2d56c594423a135429c37bf63a9fa8b0b9ce275298be3095a88c69f6fb + languageName: node + linkType: hard + "babel-runtime@npm:^6.26.0": version: 6.26.0 resolution: "babel-runtime@npm:6.26.0" @@ -13485,7 +13821,7 @@ __metadata: languageName: node linkType: hard -"camelcase@npm:^5.3.1": +"camelcase@npm:^5.0.0, camelcase@npm:^5.3.1": version: 5.3.1 resolution: "camelcase@npm:5.3.1" checksum: 10/e6effce26b9404e3c0f301498184f243811c30dfe6d0b9051863bd8e4034d09c8c2923794f280d6827e5aa055f6c434115ff97864a16a963366fb35fd673024b @@ -13639,7 +13975,21 @@ __metadata: languageName: node linkType: hard -"chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": +"chokidar-cli@npm:^3.0.0": + version: 3.0.0 + resolution: "chokidar-cli@npm:3.0.0" + dependencies: + chokidar: "npm:^3.5.2" + lodash.debounce: "npm:^4.0.8" + lodash.throttle: "npm:^4.1.1" + yargs: "npm:^13.3.0" + bin: + chokidar: index.js + checksum: 10/b486205063d3b2cb2edb2dc05d2c21ad6beac4085ca3cf2d66a83af3c1dbaa4570f6101be733d7b03c6d68c64a39262c856688d7eddba758e063cbd2466e4ae9 + languageName: node + linkType: hard + +"chokidar@npm:^3.5.2, chokidar@npm:^3.5.3, chokidar@npm:^3.6.0": version: 3.6.0 resolution: "chokidar@npm:3.6.0" dependencies: @@ -13717,10 +14067,10 @@ __metadata: languageName: node linkType: hard -"cjs-module-lexer@npm:^1.3.1": - version: 1.4.0 - resolution: "cjs-module-lexer@npm:1.4.0" - checksum: 10/b041096749792526120d8b8756929f8ef5dd4596502a0e1013f857e3027acd6091915fea77037921d70ee1a99988a100d994d3d3c2e323b04dd4c5ffd516cf13 +"cjs-module-lexer@npm:^1.0.0, cjs-module-lexer@npm:^1.3.1": + version: 1.4.3 + resolution: "cjs-module-lexer@npm:1.4.3" + checksum: 10/d2b92f919a2dedbfd61d016964fce8da0035f827182ed6839c97cac56e8a8077cfa6a59388adfe2bc588a19cef9bbe830d683a76a6e93c51f65852062cfe2591 languageName: node linkType: hard @@ -13790,6 +14140,17 @@ __metadata: languageName: node linkType: hard +"cliui@npm:^5.0.0": + version: 5.0.0 + resolution: "cliui@npm:5.0.0" + dependencies: + string-width: "npm:^3.1.0" + strip-ansi: "npm:^5.2.0" + wrap-ansi: "npm:^5.1.0" + checksum: 10/381264fcc3c8316b77b378ce5471ff9a1974d1f6217e0be8f4f09788482b3e6f7c0894eb21e0a86eab4ce0c68426653a407226dd51997306cb87f734776f5fdc + languageName: node + linkType: hard + "cliui@npm:^8.0.1": version: 8.0.1 resolution: "cliui@npm:8.0.1" @@ -13854,13 +14215,22 @@ __metadata: languageName: node linkType: hard -"collect-v8-coverage@npm:^1.0.2": +"collect-v8-coverage@npm:^1.0.0, collect-v8-coverage@npm:^1.0.2": version: 1.0.3 resolution: "collect-v8-coverage@npm:1.0.3" checksum: 10/656443261fb7b79cf79e89cba4b55622b07c1d4976c630829d7c5c585c73cda1c2ff101f316bfb19bb9e2c58d724c7db1f70a21e213dcd14099227c5e6019860 languageName: node linkType: hard +"color-convert@npm:^1.9.0": + version: 1.9.3 + resolution: "color-convert@npm:1.9.3" + dependencies: + color-name: "npm:1.1.3" + checksum: 10/ffa319025045f2973919d155f25e7c00d08836b6b33ea2d205418c59bd63a665d713c52d9737a9e0fe467fb194b40fbef1d849bae80d674568ee220a31ef3d10 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -13870,6 +14240,13 @@ __metadata: languageName: node linkType: hard +"color-name@npm:1.1.3": + version: 1.1.3 + resolution: "color-name@npm:1.1.3" + checksum: 10/09c5d3e33d2105850153b14466501f2bfb30324a2f76568a408763a3b7433b0e50e5b4ab1947868e65cb101bb7cb75029553f2c333b6d4b8138a73fcc133d69d + languageName: node + linkType: hard + "color-name@npm:~1.1.4": version: 1.1.4 resolution: "color-name@npm:1.1.4" @@ -14255,6 +14632,23 @@ __metadata: languageName: node linkType: hard +"create-jest@npm:^29.7.0": + version: 29.7.0 + resolution: "create-jest@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + exit: "npm:^0.1.2" + graceful-fs: "npm:^4.2.9" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + prompts: "npm:^2.0.1" + bin: + create-jest: bin/create-jest.js + checksum: 10/847b4764451672b4174be4d5c6d7d63442ec3aa5f3de52af924e4d996d87d7801c18e125504f25232fc75840f6625b3ac85860fac6ce799b5efae7bdcaf4a2b7 + languageName: node + linkType: hard + "cron-parser@npm:^4.5.0": version: 4.9.0 resolution: "cron-parser@npm:4.9.0" @@ -14613,6 +15007,13 @@ __metadata: languageName: node linkType: hard +"decamelize@npm:^1.2.0": + version: 1.2.0 + resolution: "decamelize@npm:1.2.0" + checksum: 10/ad8c51a7e7e0720c70ec2eeb1163b66da03e7616d7b98c9ef43cce2416395e84c1e9548dd94f5f6ffecfee9f8b94251fc57121a8b021f2ff2469b2bae247b8aa + languageName: node + linkType: hard + "decimal.js@npm:^10.5.0, decimal.js@npm:^10.6.0": version: 10.6.0 resolution: "decimal.js@npm:10.6.0" @@ -14638,15 +15039,15 @@ __metadata: languageName: node linkType: hard -"dedent@npm:^1.6.0": - version: 1.7.1 - resolution: "dedent@npm:1.7.1" +"dedent@npm:^1.0.0, dedent@npm:^1.6.0": + version: 1.7.2 + resolution: "dedent@npm:1.7.2" peerDependencies: babel-plugin-macros: ^3.1.0 peerDependenciesMeta: babel-plugin-macros: optional: true - checksum: 10/78785ef592e37e0b1ca7a7a5964c8f3dee1abdff46c5bb49864168579c122328f6bb55c769bc7e005046a7381c3372d3859f0f78ab083950fa146e1c24873f4f + checksum: 10/30b9062290dca72b0f5a6cd3667633448cef8cd0dec602eab61015741269ad49df90cabf0521f9a32d134ceab4e21aa7f097258c55cc3baadef94874686d6480 languageName: node linkType: hard @@ -14794,7 +15195,7 @@ __metadata: languageName: node linkType: hard -"detect-newline@npm:^3.1.0": +"detect-newline@npm:^3.0.0, detect-newline@npm:^3.1.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" checksum: 10/ae6cd429c41ad01b164c59ea36f264a2c479598e61cba7c99da24175a7ab80ddf066420f2bec9a1c57a6bead411b4655ff15ad7d281c000a89791f48cbe939e7 @@ -14830,6 +15231,13 @@ __metadata: languageName: node linkType: hard +"diff-sequences@npm:^29.6.3": + version: 29.6.3 + resolution: "diff-sequences@npm:29.6.3" + checksum: 10/179daf9d2f9af5c57ad66d97cb902a538bcf8ed64963fa7aa0c329b3de3665ce2eb6ffdc2f69f29d445fa4af2517e5e55e5b6e00c00a9ae4f43645f97f7078cb + languageName: node + linkType: hard + "diff@npm:^5.0.0": version: 5.2.0 resolution: "diff@npm:5.2.0" @@ -15042,6 +15450,13 @@ __metadata: languageName: node linkType: hard +"emoji-regex@npm:^7.0.1": + version: 7.0.3 + resolution: "emoji-regex@npm:7.0.3" + checksum: 10/9159b2228b1511f2870ac5920f394c7e041715429a68459ebe531601555f11ea782a8e1718f969df2711d38c66268174407cbca57ce36485544f695c2dfdc96e + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -15999,6 +16414,13 @@ __metadata: languageName: node linkType: hard +"exit@npm:^0.1.2": + version: 0.1.2 + resolution: "exit@npm:0.1.2" + checksum: 10/387555050c5b3c10e7a9e8df5f43194e95d7737c74532c409910e585d5554eaff34960c166643f5e23d042196529daad059c292dcf1fb61b8ca878d3677f4b87 + languageName: node + linkType: hard + "expand-template@npm:^2.0.3": version: 2.0.3 resolution: "expand-template@npm:2.0.3" @@ -16020,6 +16442,19 @@ __metadata: languageName: node linkType: hard +"expect@npm:^29.0.0, expect@npm:^29.7.0": + version: 29.7.0 + resolution: "expect@npm:29.7.0" + dependencies: + "@jest/expect-utils": "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/63f97bc51f56a491950fb525f9ad94f1916e8a014947f8d8445d3847a665b5471b768522d659f5e865db20b6c2033d2ac10f35fcbd881a4d26407a4f6f18451a + languageName: node + linkType: hard + "exponential-backoff@npm:^3.1.1": version: 3.1.1 resolution: "exponential-backoff@npm:3.1.1" @@ -16260,7 +16695,7 @@ __metadata: languageName: node linkType: hard -"fb-watchman@npm:^2.0.2": +"fb-watchman@npm:^2.0.0, fb-watchman@npm:^2.0.2": version: 2.0.2 resolution: "fb-watchman@npm:2.0.2" dependencies: @@ -16370,6 +16805,15 @@ __metadata: languageName: node linkType: hard +"find-up@npm:^3.0.0": + version: 3.0.0 + resolution: "find-up@npm:3.0.0" + dependencies: + locate-path: "npm:^3.0.0" + checksum: 10/38eba3fe7a66e4bc7f0f5a1366dc25508b7cfc349f852640e3678d26ad9a6d7e2c43eff0a472287de4a9753ef58f066a0ea892a256fa3636ad51b3fe1e17fae9 + languageName: node + linkType: hard + "find-up@npm:^4.0.0, find-up@npm:^4.1.0": version: 4.1.0 resolution: "find-up@npm:4.1.0" @@ -16600,7 +17044,7 @@ __metadata: languageName: node linkType: hard -"fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": +"fsevents@npm:^2.3.2, fsevents@npm:^2.3.3, fsevents@npm:~2.3.2, fsevents@npm:~2.3.3": version: 2.3.3 resolution: "fsevents@npm:2.3.3" dependencies: @@ -16610,7 +17054,7 @@ __metadata: languageName: node linkType: hard -"fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": +"fsevents@patch:fsevents@npm%3A^2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A^2.3.3#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.2#optional!builtin, fsevents@patch:fsevents@npm%3A~2.3.3#optional!builtin": version: 2.3.3 resolution: "fsevents@patch:fsevents@npm%3A2.3.3#optional!builtin::version=2.3.3&hash=df0bf1" dependencies: @@ -16640,7 +17084,7 @@ __metadata: languageName: node linkType: hard -"get-caller-file@npm:^2.0.5": +"get-caller-file@npm:^2.0.1, get-caller-file@npm:^2.0.5": version: 2.0.5 resolution: "get-caller-file@npm:2.0.5" checksum: 10/b9769a836d2a98c3ee734a88ba712e62703f1df31b94b784762c433c27a386dd6029ff55c2a920c392e33657d80191edbf18c61487e198844844516f843496b9 @@ -16797,7 +17241,7 @@ __metadata: languageName: node linkType: hard -"glob@npm:^7.1.4, glob@npm:^7.1.7": +"glob@npm:^7.1.3, glob@npm:^7.1.4, glob@npm:^7.1.7": version: 7.2.3 resolution: "glob@npm:7.2.3" dependencies: @@ -17562,7 +18006,7 @@ __metadata: languageName: node linkType: hard -"import-local@npm:^3.2.0": +"import-local@npm:^3.0.2, import-local@npm:^3.2.0": version: 3.2.0 resolution: "import-local@npm:3.2.0" dependencies: @@ -17812,14 +18256,21 @@ __metadata: languageName: node linkType: hard -"is-fullwidth-code-point@npm:^3.0.0": - version: 3.0.0 +"is-fullwidth-code-point@npm:^2.0.0": + version: 2.0.0 + resolution: "is-fullwidth-code-point@npm:2.0.0" + checksum: 10/eef9c6e15f68085fec19ff6a978a6f1b8f48018fd1265035552078ee945573594933b09bbd6f562553e2a241561439f1ef5339276eba68d272001343084cfab8 + languageName: node + linkType: hard + +"is-fullwidth-code-point@npm:^3.0.0": + version: 3.0.0 resolution: "is-fullwidth-code-point@npm:3.0.0" checksum: 10/44a30c29457c7fb8f00297bce733f0a64cd22eca270f83e58c105e0d015e45c019491a4ab2faef91ab51d4738c670daff901c799f6a700e27f7314029e99e348 languageName: node linkType: hard -"is-generator-fn@npm:^2.1.0": +"is-generator-fn@npm:^2.0.0, is-generator-fn@npm:^2.1.0": version: 2.1.0 resolution: "is-generator-fn@npm:2.1.0" checksum: 10/a6ad5492cf9d1746f73b6744e0c43c0020510b59d56ddcb78a91cbc173f09b5e6beff53d75c9c5a29feb618bfef2bf458e025ecf3a57ad2268e2fb2569f56215 @@ -18070,6 +18521,19 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-instrument@npm:^5.0.4": + version: 5.2.1 + resolution: "istanbul-lib-instrument@npm:5.2.1" + dependencies: + "@babel/core": "npm:^7.12.3" + "@babel/parser": "npm:^7.14.7" + "@istanbuljs/schema": "npm:^0.1.2" + istanbul-lib-coverage: "npm:^3.2.0" + semver: "npm:^6.3.0" + checksum: 10/bbc4496c2f304d799f8ec22202ab38c010ac265c441947f075c0f7d46bd440b45c00e46017cf9053453d42182d768b1d6ed0e70a142c95ab00df9843aa5ab80e + languageName: node + linkType: hard + "istanbul-lib-instrument@npm:^6.0.0, istanbul-lib-instrument@npm:^6.0.2": version: 6.0.3 resolution: "istanbul-lib-instrument@npm:6.0.3" @@ -18094,6 +18558,17 @@ __metadata: languageName: node linkType: hard +"istanbul-lib-source-maps@npm:^4.0.0": + version: 4.0.1 + resolution: "istanbul-lib-source-maps@npm:4.0.1" + dependencies: + debug: "npm:^4.1.1" + istanbul-lib-coverage: "npm:^3.0.0" + source-map: "npm:^0.6.1" + checksum: 10/5526983462799aced011d776af166e350191b816821ea7bcf71cab3e5272657b062c47dc30697a22a43656e3ced78893a42de677f9ccf276a28c913190953b82 + languageName: node + linkType: hard + "istanbul-lib-source-maps@npm:^5.0.0": version: 5.0.6 resolution: "istanbul-lib-source-maps@npm:5.0.6" @@ -18152,6 +18627,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-changed-files@npm:29.7.0" + dependencies: + execa: "npm:^5.0.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + checksum: 10/3d93742e56b1a73a145d55b66e96711fbf87ef89b96c2fab7cfdfba8ec06612591a982111ca2b712bb853dbc16831ec8b43585a2a96b83862d6767de59cbf83d + languageName: node + linkType: hard + "jest-circus@npm:30.4.2": version: 30.4.2 resolution: "jest-circus@npm:30.4.2" @@ -18180,6 +18666,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-circus@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/expect": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + co: "npm:^4.6.0" + dedent: "npm:^1.0.0" + is-generator-fn: "npm:^2.0.0" + jest-each: "npm:^29.7.0" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + pure-rand: "npm:^6.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/716a8e3f40572fd0213bcfc1da90274bf30d856e5133af58089a6ce45089b63f4d679bd44e6be9d320e8390483ebc3ae9921981993986d21639d9019b523123d + languageName: node + linkType: hard + "jest-cli@npm:30.4.2": version: 30.4.2 resolution: "jest-cli@npm:30.4.2" @@ -18205,6 +18719,32 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-cli@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + create-jest: "npm:^29.7.0" + exit: "npm:^0.1.2" + import-local: "npm:^3.0.2" + jest-config: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + yargs: "npm:^17.3.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/6cc62b34d002c034203065a31e5e9a19e7c76d9e8ef447a6f70f759c0714cb212c6245f75e270ba458620f9c7b26063cd8cf6cd1f7e3afd659a7cc08add17307 + languageName: node + linkType: hard + "jest-config@npm:30.4.2": version: 30.4.2 resolution: "jest-config@npm:30.4.2" @@ -18247,6 +18787,44 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-config@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@jest/test-sequencer": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-jest: "npm:^29.7.0" + chalk: "npm:^4.0.0" + ci-info: "npm:^3.2.0" + deepmerge: "npm:^4.2.2" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-circus: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-runner: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + parse-json: "npm:^5.2.0" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + ts-node: + optional: true + checksum: 10/6bdf570e9592e7d7dd5124fc0e21f5fe92bd15033513632431b211797e3ab57eaa312f83cc6481b3094b72324e369e876f163579d60016677c117ec4853cf02b + languageName: node + linkType: hard + "jest-diff@npm:30.4.1": version: 30.4.1 resolution: "jest-diff@npm:30.4.1" @@ -18259,6 +18837,18 @@ __metadata: languageName: node linkType: hard +"jest-diff@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-diff@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + diff-sequences: "npm:^29.6.3" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/6f3a7eb9cd9de5ea9e5aa94aed535631fa6f80221832952839b3cb59dd419b91c20b73887deb0b62230d06d02d6b6cf34ebb810b88d904bb4fe1e2e4f0905c98 + languageName: node + linkType: hard + "jest-docblock@npm:30.4.0": version: 30.4.0 resolution: "jest-docblock@npm:30.4.0" @@ -18268,6 +18858,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-docblock@npm:29.7.0" + dependencies: + detect-newline: "npm:^3.0.0" + checksum: 10/8d48818055bc96c9e4ec2e217a5a375623c0d0bfae8d22c26e011074940c202aa2534a3362294c81d981046885c05d304376afba9f2874143025981148f3e96d + languageName: node + linkType: hard + "jest-each@npm:30.4.1": version: 30.4.1 resolution: "jest-each@npm:30.4.1" @@ -18281,6 +18880,19 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-each@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + pretty-format: "npm:^29.7.0" + checksum: 10/bd1a077654bdaa013b590deb5f7e7ade68f2e3289180a8c8f53bc8a49f3b40740c0ec2d3a3c1aee906f682775be2bebbac37491d80b634d15276b0aa0f2e3fda + languageName: node + linkType: hard + "jest-environment-jsdom@npm:^30.4.1": version: 30.4.1 resolution: "jest-environment-jsdom@npm:30.4.1" @@ -18312,6 +18924,27 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-environment-node@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-mock: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + checksum: 10/9cf7045adf2307cc93aed2f8488942e39388bff47ec1df149a997c6f714bfc66b2056768973770d3f8b1bf47396c19aa564877eb10ec978b952c6018ed1bd637 + languageName: node + linkType: hard + +"jest-get-type@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-get-type@npm:29.6.3" + checksum: 10/88ac9102d4679d768accae29f1e75f592b760b44277df288ad76ce5bf038c3f5ce3719dea8aa0f035dac30e9eb034b848ce716b9183ad7cc222d029f03e92205 + languageName: node + linkType: hard + "jest-haste-map@npm:30.4.1": version: 30.4.1 resolution: "jest-haste-map@npm:30.4.1" @@ -18334,6 +18967,29 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-haste-map@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/graceful-fs": "npm:^4.1.3" + "@types/node": "npm:*" + anymatch: "npm:^3.0.3" + fb-watchman: "npm:^2.0.0" + fsevents: "npm:^2.3.2" + graceful-fs: "npm:^4.2.9" + jest-regex-util: "npm:^29.6.3" + jest-util: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + micromatch: "npm:^4.0.4" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10/8531b42003581cb18a69a2774e68c456fb5a5c3280b1b9b77475af9e346b6a457250f9d756bfeeae2fe6cbc9ef28434c205edab9390ee970a919baddfa08bb85 + languageName: node + linkType: hard + "jest-leak-detector@npm:30.4.1": version: 30.4.1 resolution: "jest-leak-detector@npm:30.4.1" @@ -18344,6 +19000,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-leak-detector@npm:29.7.0" + dependencies: + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/e3950e3ddd71e1d0c22924c51a300a1c2db6cf69ec1e51f95ccf424bcc070f78664813bef7aed4b16b96dfbdeea53fe358f8aeaaea84346ae15c3735758f1605 + languageName: node + linkType: hard + "jest-matcher-utils@npm:30.4.1": version: 30.4.1 resolution: "jest-matcher-utils@npm:30.4.1" @@ -18356,6 +19022,18 @@ __metadata: languageName: node linkType: hard +"jest-matcher-utils@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-matcher-utils@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + pretty-format: "npm:^29.7.0" + checksum: 10/981904a494299cf1e3baed352f8a3bd8b50a8c13a662c509b6a53c31461f94ea3bfeffa9d5efcfeb248e384e318c87de7e3baa6af0f79674e987482aa189af40 + languageName: node + linkType: hard + "jest-message-util@npm:30.4.1": version: 30.4.1 resolution: "jest-message-util@npm:30.4.1" @@ -18374,6 +19052,23 @@ __metadata: languageName: node linkType: hard +"jest-message-util@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-message-util@npm:29.7.0" + dependencies: + "@babel/code-frame": "npm:^7.12.13" + "@jest/types": "npm:^29.6.3" + "@types/stack-utils": "npm:^2.0.0" + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + micromatch: "npm:^4.0.4" + pretty-format: "npm:^29.7.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.3" + checksum: 10/31d53c6ed22095d86bab9d14c0fa70c4a92c749ea6ceece82cf30c22c9c0e26407acdfbdb0231435dc85a98d6d65ca0d9cbcd25cd1abb377fe945e843fb770b9 + languageName: node + linkType: hard + "jest-mock@npm:30.4.1": version: 30.4.1 resolution: "jest-mock@npm:30.4.1" @@ -18385,7 +19080,18 @@ __metadata: languageName: node linkType: hard -"jest-pnp-resolver@npm:^1.2.3": +"jest-mock@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-mock@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + jest-util: "npm:^29.7.0" + checksum: 10/ae51d1b4f898724be5e0e52b2268a68fcd876d9b20633c864a6dd6b1994cbc48d62402b0f40f3a1b669b30ebd648821f086c26c08ffde192ced951ff4670d51c + languageName: node + linkType: hard + +"jest-pnp-resolver@npm:^1.2.2, jest-pnp-resolver@npm:^1.2.3": version: 1.2.3 resolution: "jest-pnp-resolver@npm:1.2.3" peerDependencies: @@ -18404,6 +19110,13 @@ __metadata: languageName: node linkType: hard +"jest-regex-util@npm:^29.6.3": + version: 29.6.3 + resolution: "jest-regex-util@npm:29.6.3" + checksum: 10/0518beeb9bf1228261695e54f0feaad3606df26a19764bc19541e0fc6e2a3737191904607fb72f3f2ce85d9c16b28df79b7b1ec9443aa08c3ef0e9efda6f8f2a + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:30.4.2": version: 30.4.2 resolution: "jest-resolve-dependencies@npm:30.4.2" @@ -18414,6 +19127,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve-dependencies@npm:29.7.0" + dependencies: + jest-regex-util: "npm:^29.6.3" + jest-snapshot: "npm:^29.7.0" + checksum: 10/1e206f94a660d81e977bcfb1baae6450cb4a81c92e06fad376cc5ea16b8e8c6ea78c383f39e95591a9eb7f925b6a1021086c38941aa7c1b8a6a813c2f6e93675 + languageName: node + linkType: hard + "jest-resolve@npm:30.4.1": version: 30.4.1 resolution: "jest-resolve@npm:30.4.1" @@ -18430,6 +19153,23 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-resolve@npm:29.7.0" + dependencies: + chalk: "npm:^4.0.0" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-pnp-resolver: "npm:^1.2.2" + jest-util: "npm:^29.7.0" + jest-validate: "npm:^29.7.0" + resolve: "npm:^1.20.0" + resolve.exports: "npm:^2.0.0" + slash: "npm:^3.0.0" + checksum: 10/faa466fd9bc69ea6c37a545a7c6e808e073c66f46ab7d3d8a6ef084f8708f201b85d5fe1799789578b8b47fa1de47b9ee47b414d1863bc117a49e032ba77b7c7 + languageName: node + linkType: hard + "jest-runner@npm:30.4.2": version: 30.4.2 resolution: "jest-runner@npm:30.4.2" @@ -18460,6 +19200,35 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runner@npm:29.7.0" + dependencies: + "@jest/console": "npm:^29.7.0" + "@jest/environment": "npm:^29.7.0" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + graceful-fs: "npm:^4.2.9" + jest-docblock: "npm:^29.7.0" + jest-environment-node: "npm:^29.7.0" + jest-haste-map: "npm:^29.7.0" + jest-leak-detector: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-resolve: "npm:^29.7.0" + jest-runtime: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + jest-watcher: "npm:^29.7.0" + jest-worker: "npm:^29.7.0" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/9d8748a494bd90f5c82acea99be9e99f21358263ce6feae44d3f1b0cd90991b5df5d18d607e73c07be95861ee86d1cbab2a3fc6ca4b21805f07ac29d47c1da1e + languageName: node + linkType: hard + "jest-runtime@npm:30.4.2": version: 30.4.2 resolution: "jest-runtime@npm:30.4.2" @@ -18490,6 +19259,36 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-runtime@npm:29.7.0" + dependencies: + "@jest/environment": "npm:^29.7.0" + "@jest/fake-timers": "npm:^29.7.0" + "@jest/globals": "npm:^29.7.0" + "@jest/source-map": "npm:^29.6.3" + "@jest/test-result": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + chalk: "npm:^4.0.0" + cjs-module-lexer: "npm:^1.0.0" + collect-v8-coverage: "npm:^1.0.0" + glob: "npm:^7.1.3" + graceful-fs: "npm:^4.2.9" + jest-haste-map: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-mock: "npm:^29.7.0" + jest-regex-util: "npm:^29.6.3" + jest-resolve: "npm:^29.7.0" + jest-snapshot: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/59eb58eb7e150e0834a2d0c0d94f2a0b963ae7182cfa6c63f2b49b9c6ef794e5193ef1634e01db41420c36a94cefc512cdd67a055cd3e6fa2f41eaf0f82f5a20 + languageName: node + linkType: hard + "jest-silent-reporter@npm:^0.6.0": version: 0.6.0 resolution: "jest-silent-reporter@npm:0.6.0" @@ -18529,6 +19328,34 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-snapshot@npm:29.7.0" + dependencies: + "@babel/core": "npm:^7.11.6" + "@babel/generator": "npm:^7.7.2" + "@babel/plugin-syntax-jsx": "npm:^7.7.2" + "@babel/plugin-syntax-typescript": "npm:^7.7.2" + "@babel/types": "npm:^7.3.3" + "@jest/expect-utils": "npm:^29.7.0" + "@jest/transform": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + babel-preset-current-node-syntax: "npm:^1.0.0" + chalk: "npm:^4.0.0" + expect: "npm:^29.7.0" + graceful-fs: "npm:^4.2.9" + jest-diff: "npm:^29.7.0" + jest-get-type: "npm:^29.6.3" + jest-matcher-utils: "npm:^29.7.0" + jest-message-util: "npm:^29.7.0" + jest-util: "npm:^29.7.0" + natural-compare: "npm:^1.4.0" + pretty-format: "npm:^29.7.0" + semver: "npm:^7.5.3" + checksum: 10/cb19a3948256de5f922d52f251821f99657339969bf86843bd26cf3332eae94883e8260e3d2fba46129a27c3971c1aa522490e460e16c7fad516e82d10bbf9f8 + languageName: node + linkType: hard + "jest-util@npm:30.4.1": version: 30.4.1 resolution: "jest-util@npm:30.4.1" @@ -18585,6 +19412,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-validate@npm:29.7.0" + dependencies: + "@jest/types": "npm:^29.6.3" + camelcase: "npm:^6.2.0" + chalk: "npm:^4.0.0" + jest-get-type: "npm:^29.6.3" + leven: "npm:^3.1.0" + pretty-format: "npm:^29.7.0" + checksum: 10/8ee1163666d8eaa16d90a989edba2b4a3c8ab0ffaa95ad91b08ca42b015bfb70e164b247a5b17f9de32d096987cada63ed8491ab82761bfb9a28bc34b27ae161 + languageName: node + linkType: hard + "jest-watcher@npm:30.4.1": version: 30.4.1 resolution: "jest-watcher@npm:30.4.1" @@ -18601,6 +19442,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:^29.7.0": + version: 29.7.0 + resolution: "jest-watcher@npm:29.7.0" + dependencies: + "@jest/test-result": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.2.1" + chalk: "npm:^4.0.0" + emittery: "npm:^0.13.1" + jest-util: "npm:^29.7.0" + string-length: "npm:^4.0.1" + checksum: 10/4f616e0345676631a7034b1d94971aaa719f0cd4a6041be2aa299be437ea047afd4fe05c48873b7963f5687a2f6c7cbf51244be8b14e313b97bfe32b1e127e55 + languageName: node + linkType: hard + "jest-when@npm:^3.7.0": version: 3.7.0 resolution: "jest-when@npm:3.7.0" @@ -18634,7 +19491,7 @@ __metadata: languageName: node linkType: hard -"jest-worker@npm:^29.4.3": +"jest-worker@npm:^29.4.3, jest-worker@npm:^29.7.0": version: 29.7.0 resolution: "jest-worker@npm:29.7.0" dependencies: @@ -18646,6 +19503,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:^29.7.0": + version: 29.7.0 + resolution: "jest@npm:29.7.0" + dependencies: + "@jest/core": "npm:^29.7.0" + "@jest/types": "npm:^29.6.3" + import-local: "npm:^3.0.2" + jest-cli: "npm:^29.7.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: bin/jest.js + checksum: 10/97023d78446098c586faaa467fbf2c6b07ff06e2c85a19e3926adb5b0effe9ac60c4913ae03e2719f9c01ae8ffd8d92f6b262cedb9555ceeb5d19263d8c6362a + languageName: node + linkType: hard + "jest@npm:^30.4.2": version: 30.4.2 resolution: "jest@npm:30.4.2" @@ -19054,6 +19930,16 @@ __metadata: languageName: node linkType: hard +"locate-path@npm:^3.0.0": + version: 3.0.0 + resolution: "locate-path@npm:3.0.0" + dependencies: + p-locate: "npm:^3.0.0" + path-exists: "npm:^3.0.0" + checksum: 10/53db3996672f21f8b0bf2a2c645ae2c13ffdae1eeecfcd399a583bce8516c0b88dcb4222ca6efbbbeb6949df7e46860895be2c02e8d3219abd373ace3bfb4e11 + languageName: node + linkType: hard + "locate-path@npm:^5.0.0": version: 5.0.0 resolution: "locate-path@npm:5.0.0" @@ -19130,6 +20016,13 @@ __metadata: languageName: node linkType: hard +"lodash.throttle@npm:^4.1.1": + version: 4.1.1 + resolution: "lodash.throttle@npm:4.1.1" + checksum: 10/9be9fb2ffd686c20543167883305542f4564062a5f712a40e8c6f2f0d9fd8254a6e9d801c2470b1b24e0cdf2ae83c1277b55aa0fb4799a2db6daf545f53820e1 + languageName: node + linkType: hard + "lodash.uniq@npm:^4.5.0": version: 4.5.0 resolution: "lodash.uniq@npm:4.5.0" @@ -20170,7 +21063,7 @@ __metadata: languageName: node linkType: hard -"micromatch@npm:^4.0.2, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": +"micromatch@npm:^4.0.2, micromatch@npm:^4.0.4, micromatch@npm:^4.0.5, micromatch@npm:^4.0.8": version: 4.0.8 resolution: "micromatch@npm:4.0.8" dependencies: @@ -21212,7 +22105,7 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^2.2.0": +"p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" dependencies: @@ -21239,6 +22132,15 @@ __metadata: languageName: node linkType: hard +"p-locate@npm:^3.0.0": + version: 3.0.0 + resolution: "p-locate@npm:3.0.0" + dependencies: + p-limit: "npm:^2.0.0" + checksum: 10/83991734a9854a05fe9dbb29f707ea8a0599391f52daac32b86f08e21415e857ffa60f0e120bfe7ce0cc4faf9274a50239c7895fc0d0579d08411e513b83a4ae + languageName: node + linkType: hard + "p-locate@npm:^4.1.0": version: 4.1.0 resolution: "p-locate@npm:4.1.0" @@ -21453,6 +22355,13 @@ __metadata: languageName: node linkType: hard +"path-exists@npm:^3.0.0": + version: 3.0.0 + resolution: "path-exists@npm:3.0.0" + checksum: 10/96e92643aa34b4b28d0de1cd2eba52a1c5313a90c6542d03f62750d82480e20bfa62bc865d5cfc6165f5fcd5aeb0851043c40a39be5989646f223300021bae0a + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -21600,7 +22509,7 @@ __metadata: languageName: node linkType: hard -"pirates@npm:^4.0.7": +"pirates@npm:^4.0.4, pirates@npm:^4.0.7": version: 4.0.7 resolution: "pirates@npm:4.0.7" checksum: 10/2427f371366081ae42feb58214f04805d6b41d6b84d74480ebcc9e0ddbd7105a139f7c653daeaf83ad8a1a77214cf07f64178e76de048128fec501eab3305a96 @@ -22540,6 +23449,17 @@ __metadata: languageName: node linkType: hard +"pretty-format@npm:^29.0.0, pretty-format@npm:^29.7.0": + version: 29.7.0 + resolution: "pretty-format@npm:29.7.0" + dependencies: + "@jest/schemas": "npm:^29.6.3" + ansi-styles: "npm:^5.0.0" + react-is: "npm:^18.0.0" + checksum: 10/dea96bc83c83cd91b2bfc55757b6b2747edcaac45b568e46de29deee80742f17bc76fe8898135a70d904f4928eafd8bb693cd1da4896e8bdd3c5e82cadf1d2bb + languageName: node + linkType: hard + "pretty-time@npm:^1.1.0": version: 1.1.0 resolution: "pretty-time@npm:1.1.0" @@ -22614,7 +23534,7 @@ __metadata: languageName: node linkType: hard -"prompts@npm:^2.4.2": +"prompts@npm:^2.0.1, prompts@npm:^2.4.2": version: 2.4.2 resolution: "prompts@npm:2.4.2" dependencies: @@ -22726,6 +23646,13 @@ __metadata: languageName: node linkType: hard +"pure-rand@npm:^6.0.0": + version: 6.1.0 + resolution: "pure-rand@npm:6.1.0" + checksum: 10/256aa4bcaf9297256f552914e03cbdb0039c8fe1db11fa1e6d3f80790e16e563eb0a859a1e61082a95e224fc0c608661839439f8ecc6a3db4e48d46d99216ee4 + languageName: node + linkType: hard + "pure-rand@npm:^7.0.0": version: 7.0.1 resolution: "pure-rand@npm:7.0.1" @@ -22874,7 +23801,7 @@ __metadata: languageName: node linkType: hard -"react-is-18@npm:react-is@^18.3.1": +"react-is-18@npm:react-is@^18.3.1, react-is@npm:^18.0.0": version: 18.3.1 resolution: "react-is@npm:18.3.1" checksum: 10/d5f60c87d285af24b1e1e7eaeb123ec256c3c8bdea7061ab3932e3e14685708221bf234ec50b21e10dd07f008f1b966a2730a0ce4ff67905b3872ff2042aec22 @@ -23343,6 +24270,13 @@ __metadata: languageName: node linkType: hard +"require-main-filename@npm:^2.0.0": + version: 2.0.0 + resolution: "require-main-filename@npm:2.0.0" + checksum: 10/8604a570c06a69c9d939275becc33a65676529e1c3e5a9f42d58471674df79357872b96d70bb93a0380a62d60dc9031c98b1a9dad98c946ffdd61b7ac0c8cedd + languageName: node + linkType: hard + "requires-port@npm:^1.0.0": version: 1.0.0 resolution: "requires-port@npm:1.0.0" @@ -23401,6 +24335,13 @@ __metadata: languageName: node linkType: hard +"resolve.exports@npm:^2.0.0": + version: 2.0.3 + resolution: "resolve.exports@npm:2.0.3" + checksum: 10/536efee0f30a10fac8604e6cdc7844dbc3f4313568d09f06db4f7ed8a5b8aeb8585966fe975083d1f2dfbc87cf5f8bc7ab65a5c23385c14acbb535ca79f8398a + languageName: node + linkType: hard + "resolve@npm:1.22.8": version: 1.22.8 resolution: "resolve@npm:1.22.8" @@ -23414,7 +24355,7 @@ __metadata: languageName: node linkType: hard -"resolve@npm:^1.22.11, resolve@npm:^1.22.4": +"resolve@npm:^1.20.0, resolve@npm:^1.22.11, resolve@npm:^1.22.4": version: 1.22.12 resolution: "resolve@npm:1.22.12" dependencies: @@ -23441,7 +24382,7 @@ __metadata: languageName: node linkType: hard -"resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": +"resolve@patch:resolve@npm%3A^1.20.0#optional!builtin, resolve@patch:resolve@npm%3A^1.22.11#optional!builtin, resolve@patch:resolve@npm%3A^1.22.4#optional!builtin": version: 1.22.12 resolution: "resolve@patch:resolve@npm%3A1.22.12#optional!builtin::version=1.22.12&hash=c3c19d" dependencies: @@ -23713,7 +24654,7 @@ __metadata: languageName: node linkType: hard -"semver@npm:^6.3.1": +"semver@npm:^6.3.0, semver@npm:^6.3.1": version: 6.3.1 resolution: "semver@npm:6.3.1" bin: @@ -23814,6 +24755,13 @@ __metadata: languageName: node linkType: hard +"set-blocking@npm:^2.0.0": + version: 2.0.0 + resolution: "set-blocking@npm:2.0.0" + checksum: 10/8980ebf7ae9eb945bb036b6e283c547ee783a1ad557a82babf758a065e2fb6ea337fd82cac30dd565c1e606e423f30024a19fff7afbf4977d784720c4026a8ef + languageName: node + linkType: hard + "set-function-length@npm:^1.2.2": version: 1.2.2 resolution: "set-function-length@npm:1.2.2" @@ -23967,7 +24915,7 @@ __metadata: languageName: node linkType: hard -"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3": +"signal-exit@npm:^3.0.2, signal-exit@npm:^3.0.3, signal-exit@npm:^3.0.7": version: 3.0.7 resolution: "signal-exit@npm:3.0.7" checksum: 10/a2f098f247adc367dffc27845853e9959b9e88b01cb301658cfe4194352d8d2bb32e18467c786a7fe15f1d44b233ea35633d076d5e737870b7139949d1ab6318 @@ -24310,7 +25258,7 @@ __metadata: languageName: node linkType: hard -"stack-utils@npm:^2.0.6": +"stack-utils@npm:^2.0.3, stack-utils@npm:^2.0.6": version: 2.0.6 resolution: "stack-utils@npm:2.0.6" dependencies: @@ -24355,7 +25303,7 @@ __metadata: languageName: node linkType: hard -"string-length@npm:^4.0.2": +"string-length@npm:^4.0.1, string-length@npm:^4.0.2": version: 4.0.2 resolution: "string-length@npm:4.0.2" dependencies: @@ -24376,6 +25324,17 @@ __metadata: languageName: node linkType: hard +"string-width@npm:^3.0.0, string-width@npm:^3.1.0": + version: 3.1.0 + resolution: "string-width@npm:3.1.0" + dependencies: + emoji-regex: "npm:^7.0.1" + is-fullwidth-code-point: "npm:^2.0.0" + strip-ansi: "npm:^5.1.0" + checksum: 10/57f7ca73d201682816d573dc68bd4bb8e1dff8dc9fcf10470fdfc3474135c97175fec12ea6a159e67339b41e86963112355b64529489af6e7e70f94a7caf08b2 + languageName: node + linkType: hard + "string-width@npm:^5.0.1, string-width@npm:^5.1.2": version: 5.1.2 resolution: "string-width@npm:5.1.2" @@ -24435,6 +25394,15 @@ __metadata: languageName: node linkType: hard +"strip-ansi@npm:^5.0.0, strip-ansi@npm:^5.1.0, strip-ansi@npm:^5.2.0": + version: 5.2.0 + resolution: "strip-ansi@npm:5.2.0" + dependencies: + ansi-regex: "npm:^4.1.0" + checksum: 10/bdb5f76ade97062bd88e7723aa019adbfacdcba42223b19ccb528ffb9fb0b89a5be442c663c4a3fb25268eaa3f6ea19c7c3fbae830bd1562d55adccae1fcec46 + languageName: node + linkType: hard + "strip-ansi@npm:^7.0.1": version: 7.1.0 resolution: "strip-ansi@npm:7.1.0" @@ -24917,7 +25885,7 @@ __metadata: languageName: node linkType: hard -"ts-jest@npm:^29.4.11": +"ts-jest@npm:^29.2.5, ts-jest@npm:^29.4.11": version: 29.4.11 resolution: "ts-jest@npm:29.4.11" dependencies: @@ -25997,6 +26965,13 @@ __metadata: languageName: node linkType: hard +"which-module@npm:^2.0.0": + version: 2.0.1 + resolution: "which-module@npm:2.0.1" + checksum: 10/1967b7ce17a2485544a4fdd9063599f0f773959cca24176dbe8f405e55472d748b7c549cd7920ff6abb8f1ab7db0b0f1b36de1a21c57a8ff741f4f1e792c52be + languageName: node + linkType: hard + "which@npm:^1.2.10": version: 1.3.1 resolution: "which@npm:1.3.1" @@ -26091,6 +27066,17 @@ __metadata: languageName: node linkType: hard +"wrap-ansi@npm:^5.1.0": + version: 5.1.0 + resolution: "wrap-ansi@npm:5.1.0" + dependencies: + ansi-styles: "npm:^3.2.0" + string-width: "npm:^3.0.0" + strip-ansi: "npm:^5.0.0" + checksum: 10/f02bbbd13f40169f3d69b8c95126c1d2a340e6f149d04125527c3d501d74a304a434f4329a83bfdc3b9fdb82403e9ae0cdd7b83a99f0da0d5a7e544f6b709914 + languageName: node + linkType: hard + "wrap-ansi@npm:^8.0.1, wrap-ansi@npm:^8.1.0": version: 8.1.0 resolution: "wrap-ansi@npm:8.1.0" @@ -26128,6 +27114,16 @@ __metadata: languageName: node linkType: hard +"write-file-atomic@npm:^4.0.2": + version: 4.0.2 + resolution: "write-file-atomic@npm:4.0.2" + dependencies: + imurmurhash: "npm:^0.1.4" + signal-exit: "npm:^3.0.7" + checksum: 10/3be1f5508a46c190619d5386b1ac8f3af3dbe951ed0f7b0b4a0961eed6fc626bd84b50cf4be768dabc0a05b672f5d0c5ee7f42daa557b14415d18c3a13c7d246 + languageName: node + linkType: hard + "write-file-atomic@npm:^5.0.0, write-file-atomic@npm:^5.0.1": version: 5.0.1 resolution: "write-file-atomic@npm:5.0.1" @@ -26268,6 +27264,13 @@ __metadata: languageName: node linkType: hard +"y18n@npm:^4.0.0": + version: 4.0.3 + resolution: "y18n@npm:4.0.3" + checksum: 10/392870b2a100bbc643bc035fe3a89cef5591b719c7bdc8721bcdb3d27ab39fa4870acdca67b0ee096e146d769f311d68eda6b8195a6d970f227795061923013f + languageName: node + linkType: hard + "y18n@npm:^5.0.5": version: 5.0.8 resolution: "y18n@npm:5.0.8" @@ -26305,6 +27308,16 @@ __metadata: languageName: node linkType: hard +"yargs-parser@npm:^13.1.2": + version: 13.1.2 + resolution: "yargs-parser@npm:13.1.2" + dependencies: + camelcase: "npm:^5.0.0" + decamelize: "npm:^1.2.0" + checksum: 10/89a84fbb32827832a1d34f596f5efe98027c398af731728304a920c2f9ba03071c694418723df16882ebb646ddb72a8fb1c9567552afcbc2f268e86c4faea5a8 + languageName: node + linkType: hard + "yargs-parser@npm:^21.1.1": version: 21.1.1 resolution: "yargs-parser@npm:21.1.1" @@ -26312,7 +27325,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:17.7.2, yargs@npm:^17.0.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": +"yargs@npm:17.7.2": version: 17.7.2 resolution: "yargs@npm:17.7.2" dependencies: @@ -26327,6 +27340,39 @@ __metadata: languageName: node linkType: hard +"yargs@npm:^13.3.0": + version: 13.3.2 + resolution: "yargs@npm:13.3.2" + dependencies: + cliui: "npm:^5.0.0" + find-up: "npm:^3.0.0" + get-caller-file: "npm:^2.0.1" + require-directory: "npm:^2.1.1" + require-main-filename: "npm:^2.0.0" + set-blocking: "npm:^2.0.0" + string-width: "npm:^3.0.0" + which-module: "npm:^2.0.0" + y18n: "npm:^4.0.0" + yargs-parser: "npm:^13.1.2" + checksum: 10/608ba2e62ac2c7c4572b9c6f7a2d3ef76e2deaad8c8082788ed29ae3ef33e9f68e087f07eb804ed5641de2bc4eab977405d3833b1d11ae8dbbaf5847584d96be + languageName: node + linkType: hard + +"yargs@npm:^17.0.1, yargs@npm:^17.3.1, yargs@npm:^17.7.1, yargs@npm:^17.7.2": + version: 17.7.3 + resolution: "yargs@npm:17.7.3" + dependencies: + cliui: "npm:^8.0.1" + escalade: "npm:^3.1.1" + get-caller-file: "npm:^2.0.5" + require-directory: "npm:^2.1.1" + string-width: "npm:^4.2.3" + y18n: "npm:^5.0.5" + yargs-parser: "npm:^21.1.1" + checksum: 10/a3826798c03b159e139d0580a3b2733953889a9a1bac8e4e1ca7a1a249b55315b213c323a6a1dbdb305f6e59496a9eaa810742c87e34abcf1a0584d8f59212a1 + languageName: node + linkType: hard + "yocto-queue@npm:^0.1.0": version: 0.1.0 resolution: "yocto-queue@npm:0.1.0"