diff --git a/packages/kyc-controller/ARCHITECTURE.md b/packages/kyc-controller/ARCHITECTURE.md index c81b079e0d..a8035e8777 100644 --- a/packages/kyc-controller/ARCHITECTURE.md +++ b/packages/kyc-controller/ARCHITECTURE.md @@ -121,8 +121,8 @@ Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): Exposed messenger actions (`MESSENGER_EXPOSED_METHODS`): -`getGeoCountry`, `fetchDisclaimers`, `createSession`, `checkKycRequired`, -`createVendorCustomer`, `submitVendorDisclaimers`, `fetchSessionDisclaimers`, `submitSessionDisclaimers`, +`getGeoCountry`, `fetchVendorDisclaimers`, `createSession`, `checkKycRequired`, +`createVendorCustomer`, `submitVendorDisclaimers`, `fetchDisclaimersCatalog`, `fetchSessionDisclaimers`, `submitSessionDisclaimers`, `fetchKycStatus`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`, `createUkycSession`, `setAuthorizations`, `createJourney`, `getSessionStatus`. @@ -131,11 +131,12 @@ Endpoints: | Method | HTTP | Endpoint | Purpose | | -------------------------- | ------ | -------------------------------------------- | -------------------------------------------------------------------------------------- | | `getGeoCountry` | — | (geolocation action) | Resolve alpha-3 country | -| `fetchDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Vendor T&Cs to accept (`vendor` defaults to `moonpay`) | +| `fetchVendorDisclaimers` | `GET` | `/vendors/{vendor}/disclaimers?country=` | Vendor T&Cs to accept (`vendor` defaults to `moonpay`) | | `createSession` | `POST` | `/vendors/moonpay/sessions` | Create MoonPay vendor session | | `checkKycRequired` | `POST` | `/vendors/{vendor}/kyc-required` | Is KYC required? (normalizes `required` → `kycRequired`) | | `createVendorCustomer` | `POST` | `/vendors/{vendor}/customers` | Create or resume an empty-shell vendor customer | | `submitVendorDisclaimers` | `POST` | `/vendors/{vendor}/disclaimers` | Record vendor T&C signings (`disclaimerIds`) | +| `fetchDisclaimersCatalog` | `GET` | `/disclaimers?country=` | Global idOS + KYC-provider catalog (no credential-reuse flag) | | `fetchSessionDisclaimers` | `GET` | `/sessions/{id}/disclaimers` | Session-scoped idOS + KYC-provider catalog | | `submitSessionDisclaimers` | `POST` | `/sessions/{id}/disclaimers` | Record `{ idOS, kycProvider, credentialReusabilityConsentGiven }` consents | | `fetchKycStatus` | `GET` | `/kyc/status` | User-keyed simplified KYC status | @@ -342,8 +343,8 @@ sequenceDiagram Ctrl->>Svc: getGeoCountry() Svc->>Geo: getGeolocation() Note over Svc: map alpha-2 → alpha-3 locally - Ctrl->>Svc: fetchDisclaimers({ country }) - Svc->>API: GET /disclaimers + Ctrl->>Svc: fetchVendorDisclaimers({ country }) + Svc->>API: GET /vendors/moonpay/disclaimers?country= Ctrl-->>UI: phase = terms (+ disclaimers) User->>Ctrl: acceptTermsAndStartSession({ email, sumsubTncSigned, idosTncSigned }) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 6ab5cc1eba..40bf326665 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -30,6 +30,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- Add `KycService.fetchDisclaimersCatalog` / `KycService:fetchDisclaimersCatalog` (`GET /disclaimers?country=`, ISO 3166-1 alpha-3) for the pre-session idOS + KYC-provider catalog, plus the `KycDisclaimersCatalog` type (no `credentialReusabilityConsentGiven`). `fetchSessionDisclaimers` remains session-scoped (`GET /sessions/{sessionId}/disclaimers` → `KycSessionDisclaimers`). ([#10011](https://github.com/MetaMask/core/pull/10011)) +- **BREAKING:** Rename `KycService.fetchDisclaimers` / `KycService:fetchDisclaimers` / `KycServiceFetchDisclaimersAction` to `fetchVendorDisclaimers` / `KycService:fetchVendorDisclaimers` / `KycServiceFetchVendorDisclaimersAction`. ([#10011](https://github.com/MetaMask/core/pull/10011)) - **BREAKING:** Rename `fractalEncryptionBaseUrl` to `idosEnclaveBaseUrl`, `KycService.fetchJwks` / `KycService:fetchJwks` / `KycServiceFetchJwksAction` to `fetchIdosEnclaveJwks` / `KycService:fetchIdosEnclaveJwks` / `KycServiceFetchIdosEnclaveJwksAction`, and related Fractal encryption naming to idOS enclave. ([#10008](https://github.com/MetaMask/core/pull/10008)) - **BREAKING:** Verify `encryptionDataKey` against idOS enclave JWKS (`KycService:fetchIdosEnclaveJwks` / `idosEnclaveBaseUrl`) and `ukycCapabilityToken` against idOS relay JWKS (`KycService:fetchIdosRelayJwks` / `idosRelayBaseUrl`) when wrapping UKYC authorizations, instead of validating both schemas against Fractal. Hosts must supply `idosRelayBaseUrl` on `KycService` construction (same class of requirement as `idosEnclaveBaseUrl`). ([#10008](https://github.com/MetaMask/core/pull/10008)) - **BREAKING:** Require `sessionClientPublicKey` (unpadded base64url X25519 public key) and `residenceCountry` (ISO 3166-1 alpha-3) on `KycService.createUkycSession` (`POST /sessions`). The controller generates the per-session keypair before creating the session and uses the private half to wrap authorizations; residence country is taken from the resolved geo country. ([#9993](https://github.com/MetaMask/core/pull/9993)) diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index d39fa78ae4..5651378d5e 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -169,7 +169,7 @@ describe('KycController', () => { it('captures the active product for the automatic post-auth continuation', async () => { await withController(async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.initialize({ product: 'card' }); @@ -182,7 +182,7 @@ describe('KycController', () => { { options: { state: { activeProduct: 'card' } } }, async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.initialize({ email: 'a@b.co' }); @@ -239,7 +239,7 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.initialize(); @@ -253,7 +253,7 @@ describe('KycController', () => { 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); + handlers.fetchVendorDisclaimers.mockResolvedValue(disclaimers); await controller.loadDisclaimers({ country: 'USA' }); @@ -264,7 +264,7 @@ describe('KycController', () => { it('caches the provided country override in geoCountry', async () => { await withController(async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.loadDisclaimers({ country: 'USA' }); @@ -276,7 +276,7 @@ describe('KycController', () => { await withController( { options: { state: { accessToken: 'a' } } }, async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); handlers.checkKycRequired.mockResolvedValue({ kycRequired: true }); await controller.loadDisclaimers({ country: 'USA' }); @@ -297,12 +297,12 @@ describe('KycController', () => { await withController( { options: { state: { geoCountry: 'USA' } } }, async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.loadDisclaimers(); expect(handlers.getGeoCountry).not.toHaveBeenCalled(); - expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'moonpay', country: 'USA', }); @@ -313,12 +313,12 @@ describe('KycController', () => { it('resolves the country when neither param nor cache is available', async () => { await withController(async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('FRA'); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.loadDisclaimers(); expect(controller.state.geoCountry).toBe('FRA'); - expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'moonpay', country: 'FRA', }); @@ -327,7 +327,7 @@ describe('KycController', () => { it('records an error when loading fails', async () => { await withController(async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockRejectedValue(new Error('boom')); + handlers.fetchVendorDisclaimers.mockRejectedValue(new Error('boom')); await controller.loadDisclaimers({ country: 'USA' }); @@ -516,7 +516,7 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { handlers.createSession.mockRejectedValue(new Error('nope')); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ sumsubTncSigned: true, @@ -569,7 +569,7 @@ describe('KycController', () => { expect(controller.state.phase).toBe('idle'); expect(controller.state.error).toBeNull(); - expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); }, ); }); @@ -587,7 +587,7 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { handlers.createSession.mockRejectedValue(new Error('nope')); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ product: 'ramps', @@ -2353,7 +2353,7 @@ describe('KycController', () => { await pending; expect(controller.state.phase).toBe('idle'); - expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); }); }); }); @@ -2424,7 +2424,7 @@ describe('KycController', () => { expect(controller.state).toStrictEqual(getDefaultKycControllerState()); // The superseded flow must not resume the terms step either. - expect(handlers.fetchDisclaimers).not.toHaveBeenCalled(); + expect(handlers.fetchVendorDisclaimers).not.toHaveBeenCalled(); }); }); @@ -2530,7 +2530,7 @@ describe('KycController', () => { it('creates an Iron customer and loads Iron disclaimers on initialize', async () => { await withController(async ({ controller, handlers }) => { handlers.getGeoCountry.mockResolvedValue('USA'); - handlers.fetchDisclaimers.mockResolvedValue([ + handlers.fetchVendorDisclaimers.mockResolvedValue([ { id: 'd1', display_name: 'Iron T&C', url: 'https://t' }, ]); @@ -2544,7 +2544,7 @@ describe('KycController', () => { vendor: 'iron', email: 'a@b.co', }); - expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', country: 'USA', }); @@ -2709,7 +2709,7 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockResolvedValue([ + handlers.fetchVendorDisclaimers.mockResolvedValue([ { id: 'iron-d1', display_name: 'T', url: 'u' }, ]); @@ -2719,7 +2719,7 @@ describe('KycController', () => { expect(controller.state.termsAcceptedAt).toBeNull(); expect(controller.state.acceptedDisclaimerIds).toStrictEqual([]); expect(controller.state.termsAcceptedVendor).toBeNull(); - expect(handlers.fetchDisclaimers).toHaveBeenCalledWith({ + expect(handlers.fetchVendorDisclaimers).toHaveBeenCalledWith({ vendor: 'iron', country: 'USA', }); @@ -2743,7 +2743,7 @@ describe('KycController', () => { }, }, async ({ controller, handlers }) => { - handlers.fetchDisclaimers.mockResolvedValue([ + handlers.fetchVendorDisclaimers.mockResolvedValue([ { id: 'd1', display_name: 'T', url: 'u' }, ]); @@ -3255,7 +3255,7 @@ describe('KycController', () => { "Fetching 'disclaimers' failed with status '409'", ), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3292,7 +3292,7 @@ describe('KycController', () => { "Fetching 'disclaimers' failed with status '409'", ), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3553,7 +3553,7 @@ describe('KycController', () => { handlers.createUkycSession.mockRejectedValue( new Error('sumsub down'), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3580,7 +3580,7 @@ describe('KycController', () => { }, async ({ controller, handlers }) => { handlers.createJourney.mockRejectedValue(new Error('journey down')); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3610,7 +3610,7 @@ describe('KycController', () => { onStatusChange?.('idle', 'InProgress'); return { ok: false }; }); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3933,7 +3933,7 @@ describe('KycController', () => { handlers.submitVendorDisclaimers.mockRejectedValue( new Error('iron signings down'), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -3995,7 +3995,7 @@ describe('KycController', () => { "Fetching 'disclaimers' failed with status '500'", ), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4028,7 +4028,7 @@ describe('KycController', () => { kycProvider: MOCK_SESSION_DISCLAIMERS.kycProvider, credentialReusabilityConsentGiven: false, }); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4063,7 +4063,7 @@ describe('KycController', () => { kycProvider: [], credentialReusabilityConsentGiven: false, }); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4108,7 +4108,7 @@ describe('KycController', () => { "Fetching 'disclaimers' failed with status '409'", ), ); - handlers.fetchDisclaimers.mockResolvedValue([]); + handlers.fetchVendorDisclaimers.mockResolvedValue([]); await controller.acceptTermsAndStartSession({ email: 'a@b.co', @@ -4638,7 +4638,7 @@ type RootMessenger = Messenger< type ServiceHandlers = { getGeoCountry: jest.Mock; - fetchDisclaimers: jest.Mock; + fetchVendorDisclaimers: jest.Mock; createSession: jest.Mock; checkKycRequired: jest.Mock; createVendorCustomer: jest.Mock; @@ -4674,7 +4674,7 @@ type WithControllerOptions = { const SERVICE_ACTIONS = [ 'KycService:getGeoCountry', - 'KycService:fetchDisclaimers', + 'KycService:fetchVendorDisclaimers', 'KycService:createSession', 'KycService:checkKycRequired', 'KycService:createVendorCustomer', @@ -4775,7 +4775,7 @@ function withController( const handlers: ServiceHandlers = { getGeoCountry: jest.fn().mockResolvedValue('USA'), - fetchDisclaimers: jest.fn().mockResolvedValue([]), + fetchVendorDisclaimers: jest.fn().mockResolvedValue([]), createSession: jest.fn().mockResolvedValue({ sessionToken: 'sess' }), checkKycRequired: jest.fn().mockResolvedValue({ kycRequired: false }), createVendorCustomer: jest.fn().mockResolvedValue({ @@ -4820,8 +4820,8 @@ function withController( handlers.getGeoCountry, ); rootMessenger.registerActionHandler( - 'KycService:fetchDisclaimers', - handlers.fetchDisclaimers, + 'KycService:fetchVendorDisclaimers', + handlers.fetchVendorDisclaimers, ); rootMessenger.registerActionHandler( 'KycService:createSession', diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index d1c914178b..94a7af58c5 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -185,9 +185,9 @@ export type KycControllerState = { /** Error encountered while loading disclaimers, or `null`. */ disclaimersError: string | null; /** - * Session-scoped idOS / KYC-provider disclaimer catalog from - * `GET /sessions/{sessionId}/disclaimers`. `null` until a UKYC session - * exists and the catalog has been fetched. + * idOS / KYC-provider disclaimer catalog from `GET /disclaimers` or + * `GET /sessions/{sessionId}/disclaimers`. `null` until the catalog has + * been fetched (typically after a UKYC session exists). */ sessionDisclaimers: KycSessionDisclaimers | null; @@ -986,7 +986,7 @@ export class KycController extends BaseController< }); } const disclaimers = await this.messenger.call( - 'KycService:fetchDisclaimers', + 'KycService:fetchVendorDisclaimers', { vendor: this.state.activeVendor, country, diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 4da7d075de..be557552ed 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -26,9 +26,9 @@ export type KycServiceGetGeoCountryAction = { * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ -export type KycServiceFetchDisclaimersAction = { - type: `KycService:fetchDisclaimers`; - handler: KycService['fetchDisclaimers']; +export type KycServiceFetchVendorDisclaimersAction = { + type: `KycService:fetchVendorDisclaimers`; + handler: KycService['fetchVendorDisclaimers']; }; /** @@ -87,10 +87,28 @@ export type KycServiceSubmitVendorDisclaimersAction = { handler: KycService['submitVendorDisclaimers']; }; +/** + * Fetches the global idOS + KYC-provider disclaimer catalog + * (`GET /disclaimers?country=`). Does not include + * `credentialReusabilityConsentGiven` — that is session-scoped via + * {@link fetchSessionDisclaimers}. Vendor T&Cs continue to come from + * {@link fetchVendorDisclaimers}. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The catalog documents. + */ +export type KycServiceFetchDisclaimersCatalogAction = { + type: `KycService:fetchDisclaimersCatalog`; + handler: KycService['fetchDisclaimersCatalog']; +}; + /** * Fetches the session-scoped idOS + KYC-provider disclaimer catalog - * (`GET /sessions/{sessionId}/disclaimers`). Requires an existing UKYC - * session; vendor T&Cs continue to come from {@link fetchDisclaimers}. + * (`GET /sessions/{sessionId}/disclaimers`), including per-session + * `consented` flags and `credentialReusabilityConsentGiven`. For the + * pre-session global catalog use {@link fetchDisclaimersCatalog}. Vendor + * T&Cs continue to come from {@link fetchVendorDisclaimers}. * * @param params - The parameters. * @param params.sessionId - The UKYC session id. @@ -218,11 +236,12 @@ export type KycServiceGetSessionStatusAction = { */ export type KycServiceMethodActions = | KycServiceGetGeoCountryAction - | KycServiceFetchDisclaimersAction + | KycServiceFetchVendorDisclaimersAction | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction | KycServiceCreateVendorCustomerAction | KycServiceSubmitVendorDisclaimersAction + | KycServiceFetchDisclaimersCatalogAction | KycServiceFetchSessionDisclaimersAction | KycServiceSubmitSessionDisclaimersAction | KycServiceFetchKycStatusAction diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index dec4ed331b..d7dee565e1 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -31,9 +31,9 @@ describe('KycService', () => { .reply(200, disclaimers); const { service } = getService({ omitFetch: true }); - expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( - disclaimers, - ); + expect( + await service.fetchVendorDisclaimers({ country: 'USA' }), + ).toStrictEqual(disclaimers); }); it('throws when fetch is not globally available and not provided', () => { @@ -102,7 +102,7 @@ describe('KycService', () => { }); }); - describe('fetchDisclaimers', () => { + describe('fetchVendorDisclaimers', () => { it('returns the disclaimers for a country', async () => { const disclaimers = [ { id: '1', display_name: 'Terms', url: 'https://t' }, @@ -113,9 +113,9 @@ describe('KycService', () => { .reply(200, disclaimers); const { service } = getService(); - expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( - disclaimers, - ); + expect( + await service.fetchVendorDisclaimers({ country: 'USA' }), + ).toStrictEqual(disclaimers); }); it('throws on a malformed response', async () => { @@ -126,14 +126,14 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchDisclaimers({ country: 'USA' }), + service.fetchVendorDisclaimers({ 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' }), + service.fetchVendorDisclaimers({ country: 'USA' }), ).rejects.toThrow(/Unable to obtain an authentication bearer token/u); }); @@ -145,7 +145,7 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchDisclaimers({ country: 'USA' }), + service.fetchVendorDisclaimers({ country: 'USA' }), ).rejects.toThrow(/failed with status '500'/u); }); }); @@ -672,7 +672,7 @@ describe('KycService', () => { }); }); - describe('fetchDisclaimers for a non-MoonPay vendor', () => { + describe('fetchVendorDisclaimers for a non-MoonPay vendor', () => { it('returns Iron disclaimers for a country', async () => { const disclaimers = [ { id: '1', display_name: 'Iron Terms', url: 'https://t' }, @@ -684,7 +684,10 @@ describe('KycService', () => { const { service } = getService(); expect( - await service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), + await service.fetchVendorDisclaimers({ + vendor: 'iron', + country: 'USA', + }), ).toStrictEqual(disclaimers); }); @@ -696,7 +699,7 @@ describe('KycService', () => { const { service } = getService(); await expect( - service.fetchDisclaimers({ vendor: 'iron', country: 'USA' }), + service.fetchVendorDisclaimers({ vendor: 'iron', country: 'USA' }), ).rejects.toThrow(/Malformed response received from disclaimers API/u); }); }); @@ -739,8 +742,87 @@ describe('KycService', () => { }); }); + describe('fetchDisclaimersCatalog', () => { + const documents = { + idOS: [ + { + key: 'idos-tos', + version: '1', + title: 'idOS ToS', + url: 'https://idos.example/tos', + consented: false, + }, + ], + kycProvider: [ + { + key: 'sumsub-tos', + version: '1', + title: 'SumSub ToS', + url: 'https://sumsub.example/tos', + consented: false, + }, + ], + }; + + it('returns the global disclaimer catalog for a country', async () => { + nock(MOCK_API_URL) + .get('/disclaimers') + .query({ country: 'USA' }) + .reply(200, documents); + const { service } = getService(); + + expect( + await service.fetchDisclaimersCatalog({ country: 'USA' }), + ).toStrictEqual(documents); + }); + + it('throws when country is not a 3-character code', async () => { + const { service } = getService(); + + await expect( + service.fetchDisclaimersCatalog({ country: 'US' }), + ).rejects.toThrow(/ISO 3166-1 alpha-3/u); + }); + + it('throws on a malformed global catalog response', async () => { + nock(MOCK_API_URL) + .get('/disclaimers') + .query({ country: 'USA' }) + .reply(200, {}); + const { service } = getService(); + + await expect( + service.fetchDisclaimersCatalog({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws when the global catalog is missing kycProvider', async () => { + nock(MOCK_API_URL) + .get('/disclaimers') + .query({ country: 'USA' }) + .reply(200, { idOS: [] }); + const { service } = getService(); + + await expect( + service.fetchDisclaimersCatalog({ country: 'USA' }), + ).rejects.toThrow(/Malformed response received from disclaimers API/u); + }); + + it('throws an HttpError on a non-ok global catalog response', async () => { + nock(MOCK_API_URL) + .get('/disclaimers') + .query({ country: 'USA' }) + .reply(500); + const { service } = getService(); + + await expect( + service.fetchDisclaimersCatalog({ country: 'USA' }), + ).rejects.toThrow(/failed with status '500'/u); + }); + }); + describe('fetchSessionDisclaimers', () => { - const catalog = { + const documents = { idOS: [ { key: 'idos-tos', @@ -759,6 +841,9 @@ describe('KycService', () => { consented: false, }, ], + }; + const catalog = { + ...documents, credentialReusabilityConsentGiven: false, }; @@ -782,6 +867,19 @@ describe('KycService', () => { ); }); + it('throws when the session catalog omits credentialReusabilityConsentGiven', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid-1/disclaimers') + .reply(200, documents); + const { service } = getService(); + + await expect( + service.fetchSessionDisclaimers({ sessionId: 'sid-1' }), + ).rejects.toThrow( + /Malformed response received from session disclaimers API/u, + ); + }); + it('throws an HttpError on a non-ok response', async () => { nock(MOCK_API_URL).get('/sessions/sid-1/disclaimers').reply(500); const { service } = getService(); @@ -983,9 +1081,9 @@ describe('KycService', () => { .reply(200, disclaimers); const { service } = getService({ baseUrl: customUrl }); - expect(await service.fetchDisclaimers({ country: 'USA' })).toStrictEqual( - disclaimers, - ); + expect( + await service.fetchVendorDisclaimers({ country: 'USA' }), + ).toStrictEqual(disclaimers); }); it('throws when baseUrl is empty', () => { @@ -1004,7 +1102,7 @@ describe('KycService', () => { const { rootMessenger } = getService(); expect( - await rootMessenger.call('KycService:fetchDisclaimers', { + await rootMessenger.call('KycService:fetchVendorDisclaimers', { country: 'USA', }), ).toStrictEqual([]); diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 5d028c34e6..919a41d8bc 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -29,6 +29,7 @@ import type { KycServiceMethodActions } from './KycService-method-action-types.j import type { KycConsentRecord, KycDisclaimer, + KycDisclaimersCatalog, KycSessionDisclaimers, KycSessionStatus, KycUserStatusResponse, @@ -48,11 +49,12 @@ export const serviceName = 'KycService'; const MESSENGER_EXPOSED_METHODS = [ 'getGeoCountry', - 'fetchDisclaimers', + 'fetchVendorDisclaimers', 'createSession', 'checkKycRequired', 'createVendorCustomer', 'submitVendorDisclaimers', + 'fetchDisclaimersCatalog', 'fetchSessionDisclaimers', 'submitSessionDisclaimers', 'fetchKycStatus', @@ -261,9 +263,17 @@ const ConsentDocumentStruct = type({ consented: boolean(), }); -const SessionDisclaimersResponseStruct = type({ +const DisclaimersCatalogFields = { idOS: array(ConsentDocumentStruct), kycProvider: array(ConsentDocumentStruct), +} as const; + +/** Global catalog from `GET /disclaimers` (no credential-reuse flag). */ +const GlobalDisclaimersResponseStruct = type(DisclaimersCatalogFields); + +/** Session catalog from `GET`/`POST /sessions/{id}/disclaimers`. */ +const SessionDisclaimersResponseStruct = type({ + ...DisclaimersCatalogFields, credentialReusabilityConsentGiven: boolean(), }); @@ -300,10 +310,15 @@ export type CreateVendorCustomerParams = { export type SubmitVendorDisclaimersParams = { /** Identity vendor whose T&Cs were accepted (currently `iron`). */ vendor: KycVendor; - /** Disclaimer ids from {@link KycService.fetchDisclaimers}. */ + /** Disclaimer ids from {@link KycService.fetchVendorDisclaimers}. */ disclaimerIds: string[]; }; +export type FetchDisclaimersCatalogParams = { + /** ISO 3166-1 alpha-3 country code for `GET /disclaimers?country=`. */ + country: string; +}; + export type FetchSessionDisclaimersParams = { /** UKYC session id from {@link KycService.createUkycSession}. */ sessionId: string; @@ -381,7 +396,7 @@ export type GetSessionStatusParams = { * It extends {@link BaseDataService}, so every request is routed through * `fetchQuery`: it is wrapped in the shared service policy (retries, circuit * breaker) and its result is exposed via the service's `QueryClient`. Read-only - * endpoints (`fetchDisclaimers`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`) are cached + * endpoints (`fetchVendorDisclaimers`, `fetchIdosEnclaveJwks`, `fetchIdosRelayJwks`) are cached * with a `staleTime`; vendor-disclaimer, session-scoped disclaimer, * session-creating, and status-polling endpoints opt out of caching * (`staleTime`/`gcTime` of `0`) so they never serve a stale result. @@ -497,7 +512,7 @@ export class KycService extends BaseDataService< * @param params.country - ISO 3166-1 alpha-3 country code. * @returns The disclaimers. */ - async fetchDisclaimers({ + async fetchVendorDisclaimers({ vendor = 'moonpay', country, }: { @@ -507,7 +522,7 @@ export class KycService extends BaseDataService< const url = new URL(`/vendors/${vendor}/disclaimers`, this.#baseUrl); url.searchParams.set('country', country); const data = await this.fetchQuery({ - queryKey: [`${this.name}:fetchDisclaimers`, vendor, country], + queryKey: [`${this.name}:fetchVendorDisclaimers`, vendor, country], queryFn: async () => this.#requestJson(url, { method: 'GET' }), staleTime: inMilliseconds(5, Duration.Minute), }); @@ -689,24 +704,61 @@ export class KycService extends BaseDataService< ); } + /** + * Fetches the global idOS + KYC-provider disclaimer catalog + * (`GET /disclaimers?country=`). Does not include + * `credentialReusabilityConsentGiven` — that is session-scoped via + * {@link fetchSessionDisclaimers}. Vendor T&Cs continue to come from + * {@link fetchVendorDisclaimers}. + * + * @param params - The parameters. + * @param params.country - ISO 3166-1 alpha-3 country code. + * @returns The catalog documents. + */ + async fetchDisclaimersCatalog({ + country, + }: FetchDisclaimersCatalogParams): Promise { + if (country.length !== 3) { + throw new Error( + `KycService.fetchDisclaimersCatalog: country must be an ISO 3166-1 alpha-3 code (received "${country}").`, + ); + } + + const url = new URL('/disclaimers', this.#baseUrl); + url.searchParams.set('country', country); + const data = await this.fetchQuery({ + queryKey: [`${this.name}:fetchDisclaimersCatalog`, country], + queryFn: async () => this.#requestJson(url, { method: 'GET' }), + staleTime: 0, + gcTime: 0, + }); + return this.#validateResponse( + data, + GlobalDisclaimersResponseStruct, + 'disclaimers', + ); + } + /** * Fetches the session-scoped idOS + KYC-provider disclaimer catalog - * (`GET /sessions/{sessionId}/disclaimers`). Requires an existing UKYC - * session; vendor T&Cs continue to come from {@link fetchDisclaimers}. + * (`GET /sessions/{sessionId}/disclaimers`), including per-session + * `consented` flags and `credentialReusabilityConsentGiven`. For the + * pre-session global catalog use {@link fetchDisclaimersCatalog}. Vendor + * T&Cs continue to come from {@link fetchVendorDisclaimers}. * * @param params - The parameters. * @param params.sessionId - The UKYC session id. * @returns The catalog, including which documents are already consented. */ - async fetchSessionDisclaimers( - params: FetchSessionDisclaimersParams, - ): Promise { + async fetchSessionDisclaimers({ + sessionId, + }: FetchSessionDisclaimersParams): Promise { const url = new URL( - `/sessions/${encodeURIComponent(params.sessionId)}/disclaimers`, + `/sessions/${encodeURIComponent(sessionId)}/disclaimers`, this.#baseUrl, ); const data = await this.fetchQuery({ - queryKey: [`${this.name}:fetchSessionDisclaimers`, params.sessionId], + queryKey: [`${this.name}:fetchSessionDisclaimers`, sessionId], queryFn: async () => this.#requestJson(url, { method: 'GET' }), // Consent state can change after a POST, so always re-fetch. staleTime: 0, diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 985991a7d3..fdfb9d3fe7 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -42,6 +42,7 @@ export type { CreateSessionParams, CreateUkycSessionParams, EncryptionSchema, + FetchDisclaimersCatalogParams, FetchSessionDisclaimersParams, GetSessionStatusParams, VendorCustomerResponse, @@ -64,7 +65,8 @@ export type { KycServiceCreateJourneyAction, KycServiceCreateSessionAction, KycServiceCreateUkycSessionAction, - KycServiceFetchDisclaimersAction, + KycServiceFetchVendorDisclaimersAction, + KycServiceFetchDisclaimersCatalogAction, KycServiceFetchIdosEnclaveJwksAction, KycServiceFetchIdosRelayJwksAction, KycServiceFetchKycStatusAction, @@ -96,6 +98,7 @@ export type { KycConsentRecord, KycCustomerIdentity, KycDisclaimer, + KycDisclaimersCatalog, KycPhase, KycProduct, KycSessionDisclaimers, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index ef4198ad0d..240f8d6eb0 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -164,8 +164,8 @@ export type KycVendorSigning = { }; /** - * A legal document in the session-scoped idOS / KYC-provider catalog - * (`GET`/`POST /sessions/{sessionId}/disclaimers`). + * A legal document in the idOS / KYC-provider catalog + * (`GET /disclaimers`, or `GET`/`POST /sessions/{sessionId}/disclaimers`). */ export type KycConsentDocument = { /** Stable identifier of the legal document. */ @@ -176,7 +176,10 @@ export type KycConsentDocument = { title: string; /** URL the document body is hosted at. */ url: string; - /** Whether this session already consented to this document version. */ + /** + * Whether the document version has already been consented to (session-scoped + * fetches). For the global catalog this is typically `false`. + */ consented: boolean; }; @@ -190,14 +193,21 @@ export type KycConsentRecord = { }; /** - * Session-scoped disclaimer catalog returned by - * `GET`/`POST /sessions/{sessionId}/disclaimers`. + * idOS / KYC-provider disclaimer catalog returned by + * `GET /disclaimers?country=` (no session — no credential-reuse consent state). */ -export type KycSessionDisclaimers = { +export type KycDisclaimersCatalog = { /** idOS legal documents. */ idOS: KycConsentDocument[]; /** KYC provider (SumSub) legal documents. */ kycProvider: KycConsentDocument[]; +}; + +/** + * Session-scoped disclaimer catalog returned by + * `GET`/`POST /sessions/{sessionId}/disclaimers`. + */ +export type KycSessionDisclaimers = KycDisclaimersCatalog & { /** Whether the user consented to reuse existing idOS credentials. */ credentialReusabilityConsentGiven: boolean; };