From 002c71da862bcf857b35bcdb75067106c5fa9321 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Van=20Eyck?= Date: Mon, 27 Jul 2026 22:49:45 +0200 Subject: [PATCH] feat: wip --- packages/kyc-controller/CHANGELOG.md | 5 + .../src/KycController-method-action-types.ts | 14 + .../kyc-controller/src/KycController.test.ts | 277 ++++++++++++++++++ packages/kyc-controller/src/KycController.ts | 218 +++++++++++++- .../src/KycService-method-action-types.ts | 16 +- .../kyc-controller/src/KycService.test.ts | 57 ++++ packages/kyc-controller/src/KycService.ts | 40 ++- packages/kyc-controller/src/index.ts | 4 + packages/kyc-controller/src/types.ts | 29 ++ 9 files changed, 652 insertions(+), 8 deletions(-) diff --git a/packages/kyc-controller/CHANGELOG.md b/packages/kyc-controller/CHANGELOG.md index 29f5a43661..a8d3fa3569 100644 --- a/packages/kyc-controller/CHANGELOG.md +++ b/packages/kyc-controller/CHANGELOG.md @@ -17,5 +17,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `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)) +- Add UKYC session-status polling to `KycController` ([#9615](https://github.com/MetaMask/core/pull/9615)) + - After the SumSub SDK reports completion, the controller now polls the UKYC backend for the session's final verification decision instead of treating the SDK result as final. Polling stops on a terminal `finalStatus` (`approved`, `completed`, `rejected`, `failed`, `blocked`), resolving the sub-flow to `complete` (for `approved` / `completed`) or `failed` (otherwise). Polling is also cleared on `reset` and when a new sub-flow starts. + - Add a new `polling` value to `KycSumSubStatus` and a new `sumsub.sessionStatus` field (typed as the new `KycSessionStatus`) that holds the latest polled status. + - Add `KycController.getSessionStatus` for a one-off session-status fetch, and add an optional `sessionStatusPollIntervalMs` constructor option (defaults to 15000ms). + - Add `KycService.getSessionStatus`, backed by the `GET /sessions/{id}/status` endpoint. [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/kyc-controller/src/KycController-method-action-types.ts b/packages/kyc-controller/src/KycController-method-action-types.ts index 92e34d45eb..337703c48a 100644 --- a/packages/kyc-controller/src/KycController-method-action-types.ts +++ b/packages/kyc-controller/src/KycController-method-action-types.ts @@ -141,6 +141,19 @@ export type KycControllerStartSumSubAction = { handler: KycController['startSumSub']; }; +/** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ +export type KycControllerGetSessionStatusAction = { + type: `KycController:getSessionStatus`; + handler: KycController['getSessionStatus']; +}; + /** * Resets the flow to idle, clearing session tokens and sub-flow state while * preserving persisted terms acceptance and the per-product cache. @@ -165,4 +178,5 @@ export type KycControllerMethodActions = | KycControllerCheckKycRequiredAction | KycControllerGetKycStatusAction | KycControllerStartSumSubAction + | KycControllerGetSessionStatusAction | KycControllerResetAction; diff --git a/packages/kyc-controller/src/KycController.test.ts b/packages/kyc-controller/src/KycController.test.ts index 1bcd8efd70..35e703cfe9 100644 --- a/packages/kyc-controller/src/KycController.test.ts +++ b/packages/kyc-controller/src/KycController.test.ts @@ -1369,6 +1369,254 @@ describe('KycController', () => { }); }); + describe('session status polling', () => { + afterEach(() => { + jest.useRealTimers(); + }); + + /** + * Makes the launcher report a successful SDK completion so the sub-flow + * proceeds into session-status polling. + * + * @param launcher - The mocked launcher. + */ + function completeSdk(launcher: Launcher): void { + launcher.launch.mockImplementation(async ({ onStatusChange }) => { + onStatusChange?.('InProgress', 'Completed'); + return { ok: true }; + }); + } + + it('polls the session status after completion and completes on an approved status', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }); + }); + + it('maps a rejected terminal status to a failed sub-flow', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('rejected')); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('rejected'), + ); + }); + }); + + it('treats SDK completion as final when the UKYC session has no id to poll', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // A session created without an id leaves nothing to poll against. + handlers.createUkycSession.mockResolvedValue({ + sessionId: '', + wrappingPublicKey: 'wpk', + idosSessionId: 'idos', + }); + completeSdk(launcher); + + await controller.startSumSub(); + + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + expect(controller.state.sumsub.status).toBe('complete'); + }); + }); + + it('does not poll when the SDK did not report completion', async () => { + await withController(async ({ controller, handlers, launcher }) => { + // The applicant abandons the flow: `launch` resolves without ever + // reporting a Completed status. + launcher.launch.mockResolvedValue({ ok: false }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('failed'); + expect(handlers.getSessionStatus).not.toHaveBeenCalled(); + }); + }); + + it('keeps polling on a transient error, preserving the last good status', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus + .mockResolvedValueOnce(sessionStatus('pending')) + .mockRejectedValueOnce(new Error('network blip')) + .mockResolvedValueOnce(sessionStatus('approved')); + + await controller.startSumSub(); + + // First poll: non-terminal, keeps polling. + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Second poll fails transiently: the last good status is preserved + // and the loop keeps going. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('polling'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('pending'), + ); + + // Third poll reaches a terminal status. + await jest.advanceTimersByTimeAsync(1000); + expect(controller.state.sumsub.status).toBe('complete'); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(3); + }, + ); + }); + + it('stops polling once a terminal status is reached', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('approved')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // No further polls after a terminal status. + await jest.advanceTimersByTimeAsync(5000); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + }, + ); + }); + + it('stops polling when reset() is called', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + controller.reset(); + await jest.advanceTimersByTimeAsync(5000); + + // The scheduled poll was cancelled by reset(). + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + expect(controller.state.sumsub.status).toBe('idle'); + }, + ); + }); + + it('discards a poll result when reset() runs while the request is in flight', async () => { + await withController(async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // Simulate a reset() landing while the status request is in flight. + handlers.getSessionStatus.mockImplementation(async () => { + controller.reset(); + return sessionStatus('approved'); + }); + + await controller.startSumSub(); + + expect(controller.state.sumsub.status).toBe('idle'); + expect(controller.state.sumsub.sessionStatus).toBeNull(); + }); + }); + + it('supersedes a prior polling loop when a new sub-flow starts', async () => { + jest.useFakeTimers(); + await withController( + { options: { sessionStatusPollIntervalMs: 1000 } }, + async ({ controller, handlers, launcher }) => { + completeSdk(launcher); + // First sub-flow polls a never-terminal status. + handlers.getSessionStatus.mockResolvedValue(sessionStatus('pending')); + + await controller.startSumSub(); + expect(handlers.getSessionStatus).toHaveBeenCalledTimes(1); + + // A second sub-flow reaches a terminal status on its first poll and + // must cancel the first loop's scheduled poll. + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + await controller.startSumSub(); + expect(controller.state.sumsub.status).toBe('complete'); + + const callsAfterSecondFlow = + handlers.getSessionStatus.mock.calls.length; + await jest.advanceTimersByTimeAsync(5000); + + // No stray polls from the superseded first loop. + expect(handlers.getSessionStatus).toHaveBeenCalledTimes( + callsAfterSecondFlow, + ); + }, + ); + }); + }); + + describe('getSessionStatus', () => { + it('fetches and records the session status on demand', async () => { + await withController( + { + options: { + state: { + sumsub: { + status: 'complete', + result: null, + sessionId: 'sid', + applicantAccessToken: null, + sessionStatus: null, + }, + }, + }, + }, + async ({ controller, handlers }) => { + handlers.getSessionStatus.mockResolvedValue( + sessionStatus('approved'), + ); + + const result = await controller.getSessionStatus(); + + expect(handlers.getSessionStatus).toHaveBeenCalledWith({ + sessionId: 'sid', + }); + expect(result).toStrictEqual(sessionStatus('approved')); + expect(controller.state.sumsub.sessionStatus).toStrictEqual( + sessionStatus('approved'), + ); + }, + ); + }); + + it('throws when there is no active SumSub session', async () => { + await withController(async ({ controller }) => { + await expect(controller.getSessionStatus()).rejects.toThrow( + /no active SumSub session/u, + ); + }); + }); + }); + describe('reset', () => { it('clears session state but preserves persisted terms', async () => { await withController( @@ -1422,6 +1670,7 @@ type ServiceHandlers = { checkKycRequired: jest.Mock; createUkycSession: jest.Mock; submitWrappedKey: jest.Mock; + getSessionStatus: jest.Mock; }; type Launcher = { @@ -1447,8 +1696,31 @@ const SERVICE_ACTIONS = [ 'KycService:checkKycRequired', 'KycService:createUkycSession', 'KycService:submitWrappedKey', + 'KycService:getSessionStatus', ] as const; +/** + * Builds a UKYC session status payload with a given `finalStatus`. + * + * @param finalStatus - The overall session status. + * @returns A complete session status object. + */ +function sessionStatus(finalStatus: string): { + finalStatus: string; + externalUserId: string; + kycStatus: string; + vendor: string; + vendorStatus: string; +} { + return { + finalStatus, + externalUserId: 'ext-1', + kycStatus: finalStatus, + vendor: 'sumsub', + vendorStatus: finalStatus, + }; +} + /** * Wraps a test with a fully-wired controller, mocked service handlers, and a * mocked SumSub launcher. @@ -1491,6 +1763,7 @@ function withController( submitWrappedKey: jest .fn() .mockResolvedValue({ status: 'ok', applicantAccessToken: 'aat' }), + getSessionStatus: jest.fn().mockResolvedValue(sessionStatus('approved')), }; rootMessenger.registerActionHandler( 'KycService:getGeoCountry', @@ -1516,6 +1789,10 @@ function withController( 'KycService:submitWrappedKey', handlers.submitWrappedKey, ); + rootMessenger.registerActionHandler( + 'KycService:getSessionStatus', + handlers.getSessionStatus, + ); const launcher: Launcher = { isAvailable: jest.fn().mockReturnValue(true), diff --git a/packages/kyc-controller/src/KycController.ts b/packages/kyc-controller/src/KycController.ts index a643649267..99a0dd9f51 100644 --- a/packages/kyc-controller/src/KycController.ts +++ b/packages/kyc-controller/src/KycController.ts @@ -15,6 +15,7 @@ import type { KycDisclaimer, KycPhase, KycProduct, + KycSessionStatus, KycSumSubLauncher, KycSumSubStatus, } from './types'; @@ -48,6 +49,26 @@ const IN_PROGRESS_PHASES: KycPhase[] = [ 'submit', ]; +// How often to poll the UKYC session status after the SumSub SDK completes, +// until a terminal status is reached. Overridable via the constructor. +const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000; + +// `finalStatus` values that end the polling loop. Anything else keeps polling. +const TERMINAL_SESSION_STATUSES: ReadonlySet = new Set([ + 'approved', + 'completed', + 'rejected', + 'failed', + 'blocked', +]); + +// Terminal `finalStatus` values that represent a successful verification. Any +// other terminal status resolves the sub-flow to `failed`. +const SUCCESSFUL_SESSION_STATUSES: ReadonlySet = new Set([ + 'approved', + 'completed', +]); + // === STATE === /** @@ -104,6 +125,11 @@ export type KycControllerState = { result: Json | null; sessionId: string | null; applicantAccessToken: string | null; + /** + * The latest UKYC session status, populated while polling after the SDK + * completes. `null` until the first successful poll. + */ + sessionStatus: KycSessionStatus | null; }; }; @@ -233,6 +259,7 @@ export function getDefaultKycControllerState(): KycControllerState { result: null, sessionId: null, applicantAccessToken: null, + sessionStatus: null, }, }; } @@ -251,6 +278,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkKycRequired', 'getKycStatus', 'startSumSub', + 'getSessionStatus', 'reset', ] as const; @@ -291,6 +319,12 @@ export type KycControllerOptions = { * the controller stays platform-agnostic. */ sumsubLauncher: KycSumSubLauncher; + /** + * How often, in milliseconds, to poll the UKYC session status after the + * SumSub SDK completes. Defaults to + * {@link DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS}. + */ + sessionStatusPollIntervalMs?: number; }; /** @@ -341,6 +375,21 @@ export class KycController extends BaseController< */ #generation = 0; + /** Interval, in milliseconds, between session-status polls. */ + readonly #sessionStatusPollIntervalMs: number; + + /** Handle for the scheduled next session-status poll, or `null`. */ + #pollTimer: ReturnType | null = null; + + /** + * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a + * new sub-flow, and once a terminal status is reached) so an in-flight poll + * `tick` can detect it was superseded and neither write state nor schedule a + * follow-up. This closes the gap where clearing the timer alone would still + * let an already-awaiting request finish and reschedule. + */ + #pollToken = 0; + /** * Constructs a new {@link KycController}. * @@ -348,8 +397,15 @@ export class KycController extends BaseController< * @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. + * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC + * session status after the SumSub SDK completes. */ - constructor({ messenger, state, sumsubLauncher }: KycControllerOptions) { + constructor({ + messenger, + state, + sumsubLauncher, + sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, + }: KycControllerOptions) { super({ messenger, metadata: kycControllerMetadata, @@ -358,6 +414,7 @@ export class KycController extends BaseController< }); this.#sumsubLauncher = sumsubLauncher; + this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs; this.#keypair = generateKeyPair(); this.messenger.registerMethodActionHandlers( @@ -917,6 +974,9 @@ export class KycController extends BaseController< locale?: string; debug?: boolean; }): Promise> { + // A new sub-flow supersedes any polling still running from a prior run. + this.#stopPolling(); + if (!this.#sumsubLauncher.isAvailable()) { const error = 'SumSub SDK is not available in this runtime.'; this.#applyUpdate((state) => { @@ -935,6 +995,7 @@ export class KycController extends BaseController< this.#applyUpdate((state) => { state.sumsub.status = 'creatingSession'; state.sumsub.result = null; + state.sumsub.sessionStatus = null; }); const jwtToken = MOCK_JWT_TOKEN; @@ -1015,13 +1076,29 @@ export class KycController extends BaseController< 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'; + // A resolved `launch` alone is not the final outcome: only a SDK-reported + // completion is worth polling for a verification decision. Anything else + // (abandonment, non-success) is `failed` and must not be polled. + const applied = this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = reachedCompletion ? 'polling' : 'failed'; state.sumsub.result = result as Json; }); + + // Once the SDK completes, the authoritative verification decision comes + // from the UKYC backend, not the SDK result. Poll the session status + // until it reaches a terminal decision. Guard on `applied` so a `reset()` + // that landed during `launch` cannot start polling on an idle flow. + if (applied && reachedCompletion) { + if (sessionId) { + await this.#startSessionStatusPolling(sessionId); + } else { + // No session id to poll against; fall back to treating the SDK + // completion as the final outcome. + this.#updateIfCurrent(generation, (state) => { + state.sumsub.status = 'complete'; + }); + } + } return result; } catch (error) { const result = { error: String(error) }; @@ -1033,12 +1110,140 @@ export class KycController extends BaseController< } } + /** + * Fetches the current UKYC session status for the active sub-flow and records + * it on state. Useful for a one-off refresh outside the automatic polling + * loop that {@link startSumSub} runs. + * + * @returns The fetched session status. + * @throws If there is no active SumSub session to query. + */ + async getSessionStatus(): Promise { + const { sessionId } = this.state.sumsub; + if (!sessionId) { + throw new Error( + 'Cannot fetch session status: no active SumSub session.', + ); + } + + // Capture the flow generation so a `reset()` landing while the request is + // in flight cannot write the result onto an idle controller. + const generation = this.#generation; + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + this.#updateIfCurrent(generation, (state) => { + state.sumsub.sessionStatus = sessionStatus; + }); + return sessionStatus; + } + + /** + * Begins polling the UKYC session status until a terminal decision is + * reached. The first poll runs immediately (and is awaited by + * {@link startSumSub}); subsequent polls are scheduled every + * `#sessionStatusPollIntervalMs`. + * + * @param sessionId - The UKYC session id to poll. + * @returns A promise that resolves once the first poll settles. + */ + async #startSessionStatusPolling(sessionId: string): Promise { + // Supersede any prior loop and claim a fresh token for this one. Because + // `#stopPolling` bumps the token, any in-flight poll from a previous loop + // sees a mismatch and neither writes state nor reschedules. + this.#stopPolling(); + const token = this.#pollToken; + + const tick = async (): Promise => { + const shouldStop = await this.#pollSessionStatusOnce(sessionId, token); + if (shouldStop) { + return; + } + this.#pollTimer = setTimeout(() => { + this.#pollTimer = null; + // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and + // therefore never rejects, so this fire-and-forget scheduled poll + // cannot surface as an unhandled rejection. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + tick(); + }, this.#sessionStatusPollIntervalMs); + }; + + await tick(); + } + + /** + * Performs a single session-status poll: fetches the status, records it, and + * resolves the sub-flow when the status is terminal. + * + * Transient errors are swallowed so the loop keeps polling; the last good + * `sessionStatus` is deliberately preserved rather than being overwritten + * with the error. + * + * @param sessionId - The UKYC session id to poll. + * @param token - The polling token captured when the loop started. + * @returns `true` when the loop should stop (terminal status or superseded + * by a reset / new sub-flow), `false` when it should keep polling. + */ + async #pollSessionStatusOnce( + sessionId: string, + token: number, + ): Promise { + try { + const sessionStatus = await this.messenger.call( + 'KycService:getSessionStatus', + { sessionId }, + ); + // Superseded while the request was in flight — drop the result. + if (this.#pollToken !== token) { + return true; + } + const isTerminal = TERMINAL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ); + this.#applyUpdate((state) => { + state.sumsub.sessionStatus = sessionStatus; + if (isTerminal) { + state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has( + sessionStatus.finalStatus, + ) + ? 'complete' + : 'failed'; + } + }); + if (isTerminal) { + this.#stopPolling(); + } + return isTerminal; + } catch { + // Keep polling on transient errors, preserving the last good status. + // Stop only when a reset / new sub-flow superseded this loop. + return this.#pollToken !== token; + } + } + + /** + * Stops the session-status polling loop: bumps the polling token (so any + * in-flight `tick` bows out) and clears any scheduled poll. + */ + #stopPolling(): void { + this.#pollToken += 1; + if (this.#pollTimer !== null) { + clearTimeout(this.#pollTimer); + this.#pollTimer = null; + } + } + /** * 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; + // Stop any session-status polling so a late poll cannot write onto the + // now-idle controller. + this.#stopPolling(); // 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; @@ -1057,6 +1262,7 @@ export class KycController extends BaseController< result: null, sessionId: null, applicantAccessToken: null, + sessionStatus: null, }; }); } diff --git a/packages/kyc-controller/src/KycService-method-action-types.ts b/packages/kyc-controller/src/KycService-method-action-types.ts index 844e36faf4..b0adf57187 100644 --- a/packages/kyc-controller/src/KycService-method-action-types.ts +++ b/packages/kyc-controller/src/KycService-method-action-types.ts @@ -75,6 +75,19 @@ export type KycServiceSubmitWrappedKeyAction = { handler: KycService['submitWrappedKey']; }; +/** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ +export type KycServiceGetSessionStatusAction = { + type: `KycService:getSessionStatus`; + handler: KycService['getSessionStatus']; +}; + /** * Union of all KycService action types. */ @@ -84,4 +97,5 @@ export type KycServiceMethodActions = | KycServiceCreateSessionAction | KycServiceCheckKycRequiredAction | KycServiceCreateUkycSessionAction - | KycServiceSubmitWrappedKeyAction; + | KycServiceSubmitWrappedKeyAction + | KycServiceGetSessionStatusAction; diff --git a/packages/kyc-controller/src/KycService.test.ts b/packages/kyc-controller/src/KycService.test.ts index 86f582538a..30a84690dc 100644 --- a/packages/kyc-controller/src/KycService.test.ts +++ b/packages/kyc-controller/src/KycService.test.ts @@ -252,6 +252,63 @@ describe('KycService', () => { }); }); + describe('getSessionStatus', () => { + it('returns the session status', async () => { + const response = { + finalStatus: 'approved', + statusMessage: 'All good', + externalUserId: 'ext-1', + kycStatus: 'approved', + vendor: 'sumsub', + vendorStatus: 'GREEN', + }; + nock(MOCK_API_URL).get('/sessions/sid/status').reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'sid' }), + ).toStrictEqual(response); + }); + + it('url-encodes the session id', async () => { + const response = { + finalStatus: 'pending', + externalUserId: 'ext-1', + kycStatus: 'pending', + vendor: 'sumsub', + vendorStatus: 'YELLOW', + }; + nock(MOCK_API_URL) + .get('/sessions/a%2Fb/status') + .reply(200, response); + const { service } = getService(); + + expect( + await service.getSessionStatus({ sessionId: 'a/b' }), + ).toStrictEqual(response); + }); + + it('throws on a malformed response', async () => { + nock(MOCK_API_URL) + .get('/sessions/sid/status') + .reply(200, { finalStatus: 'approved' }); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/Malformed response received from session status API/u); + }); + + it('throws an HttpError on a non-ok response', async () => { + nock(MOCK_API_URL).get('/sessions/sid/status').reply(404); + const { service } = getService(); + + await expect( + service.getSessionStatus({ sessionId: 'sid' }), + ).rejects.toThrow(/failed with status '404'/u); + }); + }); + describe('baseUrl override', () => { it('uses the provided baseUrl instead of the env-derived URL', async () => { const customUrl = 'https://kyc-api.local.test'; diff --git a/packages/kyc-controller/src/KycService.ts b/packages/kyc-controller/src/KycService.ts index 98a994c93b..6bd4f0c3c2 100644 --- a/packages/kyc-controller/src/KycService.ts +++ b/packages/kyc-controller/src/KycService.ts @@ -11,6 +11,7 @@ import { array, assert, boolean, + optional, string, StructError, type, @@ -18,7 +19,7 @@ import { import { alpha2ToAlpha3 } from './countryCodes'; import type { KycServiceMethodActions } from './KycService-method-action-types'; -import type { KycDisclaimer } from './types'; +import type { KycDisclaimer, KycSessionStatus } from './types'; // === GENERAL === @@ -46,6 +47,7 @@ const MESSENGER_EXPOSED_METHODS = [ 'checkKycRequired', 'createUkycSession', 'submitWrappedKey', + 'getSessionStatus', ] as const; /** @@ -124,6 +126,15 @@ const WrappedKeyResponseStruct = type({ }); export type WrappedKeyResponse = Infer; +const SessionStatusResponseStruct = type({ + finalStatus: string(), + statusMessage: optional(string()), + externalUserId: string(), + kycStatus: string(), + vendor: string(), + vendorStatus: string(), +}); + // === PARAM TYPES === export type CreateSessionParams = { @@ -150,6 +161,10 @@ export type SubmitWrappedKeyParams = { jwtToken: string; }; +export type GetSessionStatusParams = { + sessionId: string; +}; + // === SERVICE DEFINITION === /** @@ -355,6 +370,29 @@ export class KycService { ); } + /** + * Fetches the current status of a UKYC session. Polled after the SumSub SDK + * completes to determine the final verification decision. + * + * @param params - The parameters. + * @param params.sessionId - The UKYC session id. + * @returns The session status. + */ + async getSessionStatus( + params: GetSessionStatusParams, + ): Promise { + const url = new URL( + `/sessions/${encodeURIComponent(params.sessionId)}/status`, + this.#baseUrl, + ); + const data = await this.#request(url, { method: 'GET' }); + return this.#validateResponse( + data, + SessionStatusResponseStruct, + 'session status', + ); + } + /** * Validates a parsed API response against a superstruct schema, throwing a * descriptive error when the response does not match. diff --git a/packages/kyc-controller/src/index.ts b/packages/kyc-controller/src/index.ts index 816afb882b..4d4b16c260 100644 --- a/packages/kyc-controller/src/index.ts +++ b/packages/kyc-controller/src/index.ts @@ -20,6 +20,7 @@ export type { KycControllerCheckKycRequiredAction, KycControllerClearSavedTermsAction, KycControllerGetKycStatusAction, + KycControllerGetSessionStatusAction, KycControllerHandleFrameMessageAction, KycControllerInitializeAction, KycControllerLoadDisclaimersAction, @@ -32,6 +33,7 @@ export type { CheckKycRequiredParams, CreateSessionParams, CreateUkycSessionParams, + GetSessionStatusParams, KycServiceActions, KycServiceEnvironment, KycServiceEvents, @@ -47,6 +49,7 @@ export type { KycServiceCreateUkycSessionAction, KycServiceFetchDisclaimersAction, KycServiceGetGeoCountryAction, + KycServiceGetSessionStatusAction, KycServiceSubmitWrappedKeyAction, } from './KycService-method-action-types'; @@ -69,6 +72,7 @@ export type { KycDisclaimer, KycPhase, KycProduct, + KycSessionStatus, KycSumSubLaunchParams, KycSumSubLauncher, KycSumSubStatus, diff --git a/packages/kyc-controller/src/types.ts b/packages/kyc-controller/src/types.ts index 7861a29392..5026fbac1b 100644 --- a/packages/kyc-controller/src/types.ts +++ b/packages/kyc-controller/src/types.ts @@ -46,6 +46,10 @@ export type KycPhase = /** * Progress of the SumSub document-verification sub-flow. + * + * - `polling` — the SDK finished and the controller is polling the UKYC + * backend for the session's final decision (see `KycSessionStatus`). The + * sub-flow resolves to `complete` or `failed` once a terminal status arrives. */ export type KycSumSubStatus = | 'idle' @@ -53,9 +57,34 @@ export type KycSumSubStatus = | 'fetchingToken' | 'launching' | 'inProgress' + | 'polling' | 'complete' | 'failed'; +/** + * The status of a UKYC session, returned by the `GET /sessions/{id}/status` + * endpoint and polled after the SumSub SDK completes to determine the final + * verification decision. + */ +export type KycSessionStatus = { + /** + * The overall status of the session. Terminal values (e.g. `approved`, + * `completed`, `rejected`, `failed`, `blocked`) end polling; any other value + * keeps polling. + */ + finalStatus: string; + /** Optional human-readable message describing the status. */ + statusMessage?: string; + /** The vendor-agnostic external user id associated with the session. */ + externalUserId: string; + /** The KYC decision status. */ + kycStatus: string; + /** The identity vendor that handled the session. */ + vendor: string; + /** The vendor-specific status. */ + vendorStatus: string; +}; + /** * A single disclaimer/term the customer must accept before a session is * created.