@metamask-previews/kyc-controller 0.0.0-preview-8bfa290fb → 0.2.0-preview-0a30e47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/CHANGELOG.md +30 -49
  2. package/dist/{KycController-method-action-types.d.mts → KycController-method-action-types.d.ts} +35 -7
  3. package/dist/KycController-method-action-types.d.ts.map +1 -0
  4. package/dist/{KycController-method-action-types.mjs → KycController-method-action-types.js} +1 -1
  5. package/dist/KycController-method-action-types.js.map +1 -0
  6. package/dist/{KycController.d.mts → KycController.d.ts} +87 -45
  7. package/dist/KycController.d.ts.map +1 -0
  8. package/dist/KycController.js +1991 -0
  9. package/dist/KycController.js.map +1 -0
  10. package/dist/{KycService-method-action-types.d.mts → KycService-method-action-types.d.ts} +16 -16
  11. package/dist/KycService-method-action-types.d.ts.map +1 -0
  12. package/dist/{KycService-method-action-types.mjs → KycService-method-action-types.js} +1 -1
  13. package/dist/KycService-method-action-types.js.map +1 -0
  14. package/dist/{KycService.d.mts → KycService.d.ts} +23 -23
  15. package/dist/KycService.d.ts.map +1 -0
  16. package/dist/{KycService.mjs → KycService.js} +194 -185
  17. package/dist/KycService.js.map +1 -0
  18. package/dist/{countryCodes.d.cts → countryCodes.d.ts} +1 -1
  19. package/dist/countryCodes.d.ts.map +1 -0
  20. package/dist/{countryCodes.mjs → countryCodes.js} +1 -1
  21. package/dist/countryCodes.js.map +1 -0
  22. package/dist/{crypto.d.mts → crypto.d.ts} +1 -1
  23. package/dist/crypto.d.ts.map +1 -0
  24. package/dist/{crypto.mjs → crypto.js} +7 -7
  25. package/dist/crypto.js.map +1 -0
  26. package/dist/{encoding.d.mts → encoding.d.ts} +1 -1
  27. package/dist/encoding.d.ts.map +1 -0
  28. package/dist/{encoding.mjs → encoding.js} +2 -2
  29. package/dist/encoding.js.map +1 -0
  30. package/dist/index.d.ts +19 -0
  31. package/dist/index.d.ts.map +1 -0
  32. package/dist/index.js +12 -0
  33. package/dist/index.js.map +1 -0
  34. package/dist/{logger.d.cts → logger.d.ts} +2 -3
  35. package/dist/logger.d.ts.map +1 -0
  36. package/dist/{logger.mjs → logger.js} +2 -2
  37. package/dist/logger.js.map +1 -0
  38. package/dist/{selectors.d.mts → selectors.d.ts} +4 -4
  39. package/dist/selectors.d.ts.map +1 -0
  40. package/dist/{selectors.mjs → selectors.js} +2 -2
  41. package/dist/selectors.js.map +1 -0
  42. package/dist/{types.d.cts → types.d.ts} +72 -13
  43. package/dist/types.d.ts.map +1 -0
  44. package/dist/{types.mjs → types.js} +1 -1
  45. package/dist/types.js.map +1 -0
  46. package/dist/ukyc/{constants.d.mts → constants.d.ts} +9 -9
  47. package/dist/ukyc/constants.d.ts.map +1 -0
  48. package/dist/ukyc/{constants.mjs → constants.js} +1 -1
  49. package/dist/ukyc/constants.js.map +1 -0
  50. package/dist/ukyc/{deriveClientMaterial.d.cts → deriveClientMaterial.d.ts} +1 -1
  51. package/dist/ukyc/deriveClientMaterial.d.ts.map +1 -0
  52. package/dist/ukyc/{deriveClientMaterial.mjs → deriveClientMaterial.js} +7 -7
  53. package/dist/ukyc/deriveClientMaterial.js.map +1 -0
  54. package/dist/ukyc/{jwtChain.d.mts → jwtChain.d.ts} +1 -1
  55. package/dist/ukyc/jwtChain.d.ts.map +1 -0
  56. package/dist/ukyc/{jwtChain.mjs → jwtChain.js} +4 -4
  57. package/dist/ukyc/jwtChain.js.map +1 -0
  58. package/dist/ukyc/{localUserSecret.d.mts → localUserSecret.d.ts} +1 -1
  59. package/dist/ukyc/localUserSecret.d.ts.map +1 -0
  60. package/dist/ukyc/{localUserSecret.mjs → localUserSecret.js} +4 -4
  61. package/dist/ukyc/localUserSecret.js.map +1 -0
  62. package/dist/ukyc/{storageAccessToken.d.mts → storageAccessToken.d.ts} +2 -2
  63. package/dist/ukyc/storageAccessToken.d.ts.map +1 -0
  64. package/dist/ukyc/{storageAccessToken.mjs → storageAccessToken.js} +5 -5
  65. package/dist/ukyc/storageAccessToken.js.map +1 -0
  66. package/dist/ukyc/{testToken.d.mts → testToken.d.ts} +2 -2
  67. package/dist/ukyc/testToken.d.ts.map +1 -0
  68. package/dist/ukyc/{testToken.mjs → testToken.js} +5 -5
  69. package/dist/ukyc/testToken.js.map +1 -0
  70. package/dist/ukyc/{wrapEncryptionKey.d.mts → wrapEncryptionKey.d.ts} +1 -1
  71. package/dist/ukyc/wrapEncryptionKey.d.ts.map +1 -0
  72. package/dist/ukyc/{wrapEncryptionKey.mjs → wrapEncryptionKey.js} +3 -4
  73. package/dist/ukyc/wrapEncryptionKey.js.map +1 -0
  74. package/dist/ukyc/{wrappedRelayPayload.d.mts → wrappedRelayPayload.d.ts} +3 -3
  75. package/dist/ukyc/wrappedRelayPayload.d.ts.map +1 -0
  76. package/dist/ukyc/{wrappedRelayPayload.mjs → wrappedRelayPayload.js} +2 -2
  77. package/dist/ukyc/wrappedRelayPayload.js.map +1 -0
  78. package/dist/vendorDisclaimerAcceptance.d.ts +39 -0
  79. package/dist/vendorDisclaimerAcceptance.d.ts.map +1 -0
  80. package/dist/vendorDisclaimerAcceptance.js +67 -0
  81. package/dist/vendorDisclaimerAcceptance.js.map +1 -0
  82. package/dist/vendors/MoonPayFrameHandler.d.ts +74 -0
  83. package/dist/vendors/MoonPayFrameHandler.d.ts.map +1 -0
  84. package/dist/vendors/MoonPayFrameHandler.js +218 -0
  85. package/dist/vendors/MoonPayFrameHandler.js.map +1 -0
  86. package/package.json +19 -23
  87. package/dist/KycController-method-action-types.cjs +0 -7
  88. package/dist/KycController-method-action-types.cjs.map +0 -1
  89. package/dist/KycController-method-action-types.d.cts +0 -239
  90. package/dist/KycController-method-action-types.d.cts.map +0 -1
  91. package/dist/KycController-method-action-types.d.mts.map +0 -1
  92. package/dist/KycController-method-action-types.mjs.map +0 -1
  93. package/dist/KycController.cjs +0 -1882
  94. package/dist/KycController.cjs.map +0 -1
  95. package/dist/KycController.d.cts +0 -395
  96. package/dist/KycController.d.cts.map +0 -1
  97. package/dist/KycController.d.mts.map +0 -1
  98. package/dist/KycController.mjs +0 -1877
  99. package/dist/KycController.mjs.map +0 -1
  100. package/dist/KycService-method-action-types.cjs +0 -7
  101. package/dist/KycService-method-action-types.cjs.map +0 -1
  102. package/dist/KycService-method-action-types.d.cts +0 -220
  103. package/dist/KycService-method-action-types.d.cts.map +0 -1
  104. package/dist/KycService-method-action-types.d.mts.map +0 -1
  105. package/dist/KycService-method-action-types.mjs.map +0 -1
  106. package/dist/KycService.cjs +0 -637
  107. package/dist/KycService.cjs.map +0 -1
  108. package/dist/KycService.d.cts +0 -552
  109. package/dist/KycService.d.cts.map +0 -1
  110. package/dist/KycService.d.mts.map +0 -1
  111. package/dist/KycService.mjs.map +0 -1
  112. package/dist/countryCodes.cjs +0 -274
  113. package/dist/countryCodes.cjs.map +0 -1
  114. package/dist/countryCodes.d.cts.map +0 -1
  115. package/dist/countryCodes.d.mts +0 -18
  116. package/dist/countryCodes.d.mts.map +0 -1
  117. package/dist/countryCodes.mjs.map +0 -1
  118. package/dist/crypto.cjs +0 -154
  119. package/dist/crypto.cjs.map +0 -1
  120. package/dist/crypto.d.cts +0 -84
  121. package/dist/crypto.d.cts.map +0 -1
  122. package/dist/crypto.d.mts.map +0 -1
  123. package/dist/crypto.mjs.map +0 -1
  124. package/dist/encoding.cjs +0 -40
  125. package/dist/encoding.cjs.map +0 -1
  126. package/dist/encoding.d.cts +0 -24
  127. package/dist/encoding.d.cts.map +0 -1
  128. package/dist/encoding.d.mts.map +0 -1
  129. package/dist/encoding.mjs.map +0 -1
  130. package/dist/index.cjs +0 -37
  131. package/dist/index.cjs.map +0 -1
  132. package/dist/index.d.cts +0 -19
  133. package/dist/index.d.cts.map +0 -1
  134. package/dist/index.d.mts +0 -19
  135. package/dist/index.d.mts.map +0 -1
  136. package/dist/index.mjs +0 -12
  137. package/dist/index.mjs.map +0 -1
  138. package/dist/logger.cjs +0 -9
  139. package/dist/logger.cjs.map +0 -1
  140. package/dist/logger.d.cts.map +0 -1
  141. package/dist/logger.d.mts +0 -6
  142. package/dist/logger.d.mts.map +0 -1
  143. package/dist/logger.mjs.map +0 -1
  144. package/dist/selectors.cjs +0 -30
  145. package/dist/selectors.cjs.map +0 -1
  146. package/dist/selectors.d.cts +0 -24
  147. package/dist/selectors.d.cts.map +0 -1
  148. package/dist/selectors.d.mts.map +0 -1
  149. package/dist/selectors.mjs.map +0 -1
  150. package/dist/types.cjs +0 -10
  151. package/dist/types.cjs.map +0 -1
  152. package/dist/types.d.cts.map +0 -1
  153. package/dist/types.d.mts +0 -217
  154. package/dist/types.d.mts.map +0 -1
  155. package/dist/types.mjs.map +0 -1
  156. package/dist/ukyc/constants.cjs +0 -75
  157. package/dist/ukyc/constants.cjs.map +0 -1
  158. package/dist/ukyc/constants.d.cts +0 -69
  159. package/dist/ukyc/constants.d.cts.map +0 -1
  160. package/dist/ukyc/constants.d.mts.map +0 -1
  161. package/dist/ukyc/constants.mjs.map +0 -1
  162. package/dist/ukyc/deriveClientMaterial.cjs +0 -65
  163. package/dist/ukyc/deriveClientMaterial.cjs.map +0 -1
  164. package/dist/ukyc/deriveClientMaterial.d.cts.map +0 -1
  165. package/dist/ukyc/deriveClientMaterial.d.mts +0 -54
  166. package/dist/ukyc/deriveClientMaterial.d.mts.map +0 -1
  167. package/dist/ukyc/deriveClientMaterial.mjs.map +0 -1
  168. package/dist/ukyc/jwtChain.cjs +0 -54
  169. package/dist/ukyc/jwtChain.cjs.map +0 -1
  170. package/dist/ukyc/jwtChain.d.cts +0 -40
  171. package/dist/ukyc/jwtChain.d.cts.map +0 -1
  172. package/dist/ukyc/jwtChain.d.mts.map +0 -1
  173. package/dist/ukyc/jwtChain.mjs.map +0 -1
  174. package/dist/ukyc/localUserSecret.cjs +0 -98
  175. package/dist/ukyc/localUserSecret.cjs.map +0 -1
  176. package/dist/ukyc/localUserSecret.d.cts +0 -65
  177. package/dist/ukyc/localUserSecret.d.cts.map +0 -1
  178. package/dist/ukyc/localUserSecret.d.mts.map +0 -1
  179. package/dist/ukyc/localUserSecret.mjs.map +0 -1
  180. package/dist/ukyc/storageAccessToken.cjs +0 -139
  181. package/dist/ukyc/storageAccessToken.cjs.map +0 -1
  182. package/dist/ukyc/storageAccessToken.d.cts +0 -99
  183. package/dist/ukyc/storageAccessToken.d.cts.map +0 -1
  184. package/dist/ukyc/storageAccessToken.d.mts.map +0 -1
  185. package/dist/ukyc/storageAccessToken.mjs.map +0 -1
  186. package/dist/ukyc/testToken.cjs +0 -61
  187. package/dist/ukyc/testToken.cjs.map +0 -1
  188. package/dist/ukyc/testToken.d.cts +0 -50
  189. package/dist/ukyc/testToken.d.cts.map +0 -1
  190. package/dist/ukyc/testToken.d.mts.map +0 -1
  191. package/dist/ukyc/testToken.mjs.map +0 -1
  192. package/dist/ukyc/wrapEncryptionKey.cjs +0 -31
  193. package/dist/ukyc/wrapEncryptionKey.cjs.map +0 -1
  194. package/dist/ukyc/wrapEncryptionKey.d.cts +0 -36
  195. package/dist/ukyc/wrapEncryptionKey.d.cts.map +0 -1
  196. package/dist/ukyc/wrapEncryptionKey.d.mts.map +0 -1
  197. package/dist/ukyc/wrapEncryptionKey.mjs.map +0 -1
  198. package/dist/ukyc/wrappedRelayPayload.cjs +0 -32
  199. package/dist/ukyc/wrappedRelayPayload.cjs.map +0 -1
  200. package/dist/ukyc/wrappedRelayPayload.d.cts +0 -35
  201. package/dist/ukyc/wrappedRelayPayload.d.cts.map +0 -1
  202. package/dist/ukyc/wrappedRelayPayload.d.mts.map +0 -1
  203. package/dist/ukyc/wrappedRelayPayload.mjs.map +0 -1
@@ -0,0 +1,1991 @@
1
+ import { BaseController } from '@metamask/base-controller';
2
+ import { stringToBytes } from '@metamask/utils';
3
+ import { x25519 } from '@noble/curves/ed25519';
4
+ import { toBase64Url } from './encoding.js';
5
+ import { controllerLog } from './logger.js';
6
+ import { deriveClientMaterial } from './ukyc/deriveClientMaterial.js';
7
+ import { verifyJwtChain } from './ukyc/jwtChain.js';
8
+ import { getOrCreateLocalUserSecret } from './ukyc/localUserSecret.js';
9
+ import { encodeStorageAccessTokenForHeader, signStorageAccessToken, } from './ukyc/storageAccessToken.js';
10
+ import { wrapEncryptionKey } from './ukyc/wrapEncryptionKey.js';
11
+ import { clearVendorDisclaimerAcceptance, hasVendorDisclaimerAcceptance, ironDisclaimerIds, recordVendorDisclaimerAcceptance, } from './vendorDisclaimerAcceptance.js';
12
+ import { clearMoonPaySession, MoonPayFrameHandler, } from './vendors/MoonPayFrameHandler.js';
13
+ // === GENERAL ===
14
+ export const controllerName = 'KycController';
15
+ // Placeholder credentials for the SumSub sub-flow. These are demo values that
16
+ // must be replaced with real UKYC-issued material before production use.
17
+ const MOCK_JWT_TOKEN = 'mock-jwt-token';
18
+ // Lifetime of the read-only `ukyc_capability_token` minted when creating a
19
+ // UKYC session. The storage-and-auth spec requires the token's `expires_at` to
20
+ // cover the KYC session's expected lifetime — including the provider journey —
21
+ // rather than a fixed short window, so this is a session-scoped window.
22
+ const UKYC_CAPABILITY_TOKEN_TTL_MS = 4 * 60 * 60 * 1000;
23
+ // SumSub statuses that mean the applicant submitted (see `KycSumSubSdkStatus`
24
+ // for what each one reports). `Completed` covers launchers that normalize the
25
+ // platform status before forwarding it. Review decisions (`Approved`,
26
+ // `FinallyRejected`, `TemporarilyDeclined`) are post-submission outcomes: the
27
+ // applicant finished the SDK, so UKYC is polled for the authoritative
28
+ // decision. Pre-submission statuses (`Ready`, `Initial`, `Incomplete`) must
29
+ // not be recorded as a completed verification.
30
+ const SUMSUB_COMPLETED_STATUSES = new Set([
31
+ 'Completed',
32
+ 'Pending',
33
+ 'Approved',
34
+ 'ActionCompleted',
35
+ 'FinallyRejected',
36
+ 'TemporarilyDeclined',
37
+ ]);
38
+ // The only status meaning the SDK could not run, rather than reporting how far
39
+ // the applicant got before closing it.
40
+ const SUMSUB_FAILED_STATUS = 'Failed';
41
+ const SUMSUB_ABANDONED_MESSAGE = 'Identity verification was not finished — accept the terms to try again.';
42
+ /**
43
+ * Checks whether a SumSub status means the applicant submitted the flow.
44
+ *
45
+ * @param status - Status from a launcher callback or launch result.
46
+ * @returns Whether the applicant submitted, including a review decision.
47
+ */
48
+ function isSumSubFlowCompleted(status) {
49
+ return typeof status === 'string' && SUMSUB_COMPLETED_STATUSES.has(status);
50
+ }
51
+ /**
52
+ * Checks whether the SDK failed to run, as opposed to the applicant closing it
53
+ * early. Only the former is worth reporting as an error.
54
+ *
55
+ * @param result - The result the launcher resolved with.
56
+ * @returns Whether the SDK failed to run.
57
+ */
58
+ function isSumSubLaunchFailure(result) {
59
+ return (result.status === SUMSUB_FAILED_STATUS || typeof result.error === 'string');
60
+ }
61
+ // Phases that represent an active vendor-session flow (tokens issued and/or
62
+ // Check/Auth frames in progress). A repeat `initialize` while in one of these
63
+ // must not restart the session and disrupt the in-flight flow.
64
+ const IN_PROGRESS_PHASES = [
65
+ 'session',
66
+ 'check',
67
+ 'auth',
68
+ 'form',
69
+ 'submit',
70
+ ];
71
+ // How often to poll the UKYC session status after the SumSub SDK completes,
72
+ // until a terminal status is reached. Overridable via the constructor.
73
+ const DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS = 15_000;
74
+ // UKYC status values. `kycStatus` (the relay-side decision) and `finalStatus`
75
+ // (the vendor-side outcome) draw from the same vocabulary, so they are defined
76
+ // once here and composed into the sets/checks below rather than repeated as
77
+ // literals.
78
+ const KYC_STATUSES = {
79
+ approved: 'approved',
80
+ completed: 'completed',
81
+ rejected: 'rejected',
82
+ failed: 'failed',
83
+ blocked: 'blocked',
84
+ pending: 'pending',
85
+ };
86
+ // `finalStatus` values that end the polling loop. Anything else (e.g.
87
+ // `KYC_STATUSES.pending`) keeps polling.
88
+ const TERMINAL_SESSION_STATUSES = new Set([
89
+ KYC_STATUSES.approved,
90
+ KYC_STATUSES.completed,
91
+ KYC_STATUSES.rejected,
92
+ KYC_STATUSES.failed,
93
+ KYC_STATUSES.blocked,
94
+ ]);
95
+ // Terminal `finalStatus` values that represent a successful verification. Any
96
+ // other terminal status resolves the sub-flow to `failed`.
97
+ const SUCCESSFUL_SESSION_STATUSES = new Set([
98
+ KYC_STATUSES.approved,
99
+ KYC_STATUSES.completed,
100
+ ]);
101
+ // Session creation can report that the applicant is already approved on the
102
+ // relay (`kycStatus === KYC_STATUSES.approved`) while the vendor is still
103
+ // finalizing its decision (`finalStatus === KYC_STATUSES.pending`, a
104
+ // non-terminal status). In that case there is nothing left for the applicant
105
+ // to do, so the sub-flow stops before launching the SDK and surfaces this
106
+ // message.
107
+ const VENDOR_PROCESSING_MESSAGE = 'Your KYC has been submitted and is being processed by the vendor.';
108
+ // UKYC / relay error indicating the applicant already finished KYC. Mapped to
109
+ // the simplified `completed` user status for the Money toast surface.
110
+ const SESSION_NOT_IN_VALID_STATE = 'session_not_in_valid_state';
111
+ // How often to refresh the user-keyed `GET /kyc/status` while the simplified
112
+ // status is still `pending`. Overridable via the constructor.
113
+ const DEFAULT_USER_STATUS_POLL_INTERVAL_MS = 15_000;
114
+ const kycControllerMetadata = {
115
+ phase: {
116
+ includeInDebugSnapshot: true,
117
+ includeInStateLogs: true,
118
+ persist: false,
119
+ usedInUi: true,
120
+ },
121
+ statusMessage: {
122
+ includeInDebugSnapshot: true,
123
+ includeInStateLogs: true,
124
+ persist: false,
125
+ usedInUi: true,
126
+ },
127
+ error: {
128
+ includeInDebugSnapshot: true,
129
+ includeInStateLogs: true,
130
+ persist: false,
131
+ usedInUi: true,
132
+ },
133
+ email: {
134
+ includeInDebugSnapshot: false,
135
+ includeInStateLogs: false,
136
+ persist: false,
137
+ usedInUi: false,
138
+ },
139
+ vendorDisclaimersAccepted: {
140
+ includeInDebugSnapshot: true,
141
+ includeInStateLogs: true,
142
+ persist: true,
143
+ usedInUi: false,
144
+ },
145
+ providerDisclaimersAccepted: {
146
+ includeInDebugSnapshot: true,
147
+ includeInStateLogs: true,
148
+ persist: true,
149
+ usedInUi: false,
150
+ },
151
+ idosDisclaimersAccepted: {
152
+ includeInDebugSnapshot: true,
153
+ includeInStateLogs: true,
154
+ persist: true,
155
+ usedInUi: false,
156
+ },
157
+ credentialReusabilityConsentGiven: {
158
+ includeInDebugSnapshot: true,
159
+ includeInStateLogs: true,
160
+ persist: false,
161
+ usedInUi: false,
162
+ },
163
+ vendorDisclaimers: {
164
+ includeInDebugSnapshot: false,
165
+ includeInStateLogs: false,
166
+ persist: false,
167
+ usedInUi: true,
168
+ },
169
+ vendorError: {
170
+ includeInDebugSnapshot: true,
171
+ includeInStateLogs: true,
172
+ persist: false,
173
+ usedInUi: true,
174
+ },
175
+ sessionDisclaimers: {
176
+ includeInDebugSnapshot: false,
177
+ includeInStateLogs: false,
178
+ persist: false,
179
+ usedInUi: true,
180
+ },
181
+ geoCountry: {
182
+ includeInDebugSnapshot: true,
183
+ includeInStateLogs: true,
184
+ persist: false,
185
+ usedInUi: true,
186
+ },
187
+ moonpaySessionToken: {
188
+ includeInDebugSnapshot: false,
189
+ includeInStateLogs: false,
190
+ persist: false,
191
+ usedInUi: false,
192
+ },
193
+ moonpayAccessToken: {
194
+ includeInDebugSnapshot: false,
195
+ includeInStateLogs: false,
196
+ persist: false,
197
+ usedInUi: false,
198
+ },
199
+ moonpayCustomerId: {
200
+ includeInDebugSnapshot: false,
201
+ includeInStateLogs: false,
202
+ persist: false,
203
+ usedInUi: false,
204
+ },
205
+ activeVendor: {
206
+ includeInDebugSnapshot: true,
207
+ includeInStateLogs: true,
208
+ persist: false,
209
+ usedInUi: true,
210
+ },
211
+ activeProduct: {
212
+ includeInDebugSnapshot: true,
213
+ includeInStateLogs: true,
214
+ persist: false,
215
+ usedInUi: true,
216
+ },
217
+ kycRequiredByProduct: {
218
+ includeInDebugSnapshot: true,
219
+ includeInStateLogs: true,
220
+ persist: true,
221
+ usedInUi: true,
222
+ },
223
+ lastCheckedAt: {
224
+ includeInDebugSnapshot: true,
225
+ includeInStateLogs: true,
226
+ persist: true,
227
+ usedInUi: false,
228
+ },
229
+ userStatus: {
230
+ includeInDebugSnapshot: true,
231
+ includeInStateLogs: true,
232
+ persist: true,
233
+ usedInUi: true,
234
+ },
235
+ userStatusSumsubSessionId: {
236
+ includeInDebugSnapshot: false,
237
+ includeInStateLogs: false,
238
+ persist: true,
239
+ usedInUi: true,
240
+ },
241
+ userStatusErrorCode: {
242
+ includeInDebugSnapshot: true,
243
+ includeInStateLogs: true,
244
+ persist: true,
245
+ usedInUi: true,
246
+ },
247
+ sumsub: {
248
+ includeInDebugSnapshot: false,
249
+ includeInStateLogs: false,
250
+ persist: false,
251
+ usedInUi: true,
252
+ },
253
+ };
254
+ /**
255
+ * Constructs the default {@link KycVendorDisclaimersAccepted} value.
256
+ *
257
+ * @returns The default vendor-disclaimer acceptance map.
258
+ */
259
+ export function getDefaultKycVendorDisclaimersAccepted() {
260
+ return { moonpay: null, iron: null };
261
+ }
262
+ export function getDefaultKycProviderDisclaimersAccepted() {
263
+ return { sumsub: null };
264
+ }
265
+ /**
266
+ * Constructs the default {@link KycController} state.
267
+ *
268
+ * @returns The default state.
269
+ */
270
+ export function getDefaultKycControllerState() {
271
+ return {
272
+ phase: 'idle',
273
+ statusMessage: '',
274
+ error: null,
275
+ email: null,
276
+ vendorDisclaimersAccepted: getDefaultKycVendorDisclaimersAccepted(),
277
+ providerDisclaimersAccepted: getDefaultKycProviderDisclaimersAccepted(),
278
+ idosDisclaimersAccepted: null,
279
+ credentialReusabilityConsentGiven: null,
280
+ vendorDisclaimers: [],
281
+ vendorError: null,
282
+ sessionDisclaimers: null,
283
+ geoCountry: null,
284
+ moonpaySessionToken: null,
285
+ moonpayAccessToken: null,
286
+ moonpayCustomerId: null,
287
+ activeVendor: 'moonpay',
288
+ activeProduct: null,
289
+ kycRequiredByProduct: {},
290
+ lastCheckedAt: null,
291
+ userStatus: null,
292
+ userStatusSumsubSessionId: null,
293
+ userStatusErrorCode: null,
294
+ sumsub: {
295
+ status: 'idle',
296
+ result: null,
297
+ sessionId: null,
298
+ applicantAccessToken: null,
299
+ sessionStatus: null,
300
+ },
301
+ };
302
+ }
303
+ /**
304
+ * Whether an error indicates the applicant already finished KYC — the UKYC /
305
+ * relay `session_not_in_valid_state` signal — which the controller maps to the
306
+ * simplified `completed` user status.
307
+ *
308
+ * @param error - The caught error.
309
+ * @returns `true` when the error carries the `session_not_in_valid_state`
310
+ * marker.
311
+ */
312
+ function isSessionAlreadyCompletedError(error) {
313
+ return String(error).includes(SESSION_NOT_IN_VALID_STATE);
314
+ }
315
+ /**
316
+ * Whether recording session disclaimers failed because those document
317
+ * versions were already consented for the session (`409 Conflict`).
318
+ *
319
+ * @param error - The caught error.
320
+ * @returns `true` when the error is an HTTP 409.
321
+ */
322
+ function isConsentConflictError(error) {
323
+ return (typeof error === 'object' &&
324
+ error !== null &&
325
+ typeof error.httpStatus === 'number' &&
326
+ error.httpStatus === 409);
327
+ }
328
+ /**
329
+ *
330
+ * @param value - The value to validate.
331
+ * @returns `true` when `value` is a valid consent record list.
332
+ */
333
+ function isValidConsentRecordList(value) {
334
+ return (Array.isArray(value) &&
335
+ value.every((item) => typeof item === 'object' &&
336
+ item !== null &&
337
+ typeof item.key === 'string' &&
338
+ typeof item.version === 'string'));
339
+ }
340
+ /**
341
+ * Maps accepted disclaimer records onto unconsented catalog documents.
342
+ *
343
+ * @param documents - Catalog documents for one consent category.
344
+ * @param accepted - Accepted `{ key, version }` records from the caller.
345
+ * @returns Consent records to POST, omitting already-consented documents.
346
+ */
347
+ function consentRecordsFromAcceptedList(documents, accepted) {
348
+ if (accepted.length === 0) {
349
+ return [];
350
+ }
351
+ const acceptedKeys = new Set(accepted.map((record) => `${record.key}:${record.version}`));
352
+ return documents
353
+ .filter((document) => !document.consented &&
354
+ acceptedKeys.has(`${document.key}:${document.version}`))
355
+ .map(({ key, version }) => ({ key, version }));
356
+ }
357
+ /**
358
+ * Whether accepted disclaimers reference a missing catalog category.
359
+ *
360
+ * @param documents - Catalog documents for one consent category.
361
+ * @param accepted - Accepted `{ key, version }` records from the caller.
362
+ * @returns `true` when the caller accepted docs but the catalog is empty.
363
+ */
364
+ function isAcceptedCategoryEmpty(documents, accepted) {
365
+ return accepted.length > 0 && documents.length === 0;
366
+ }
367
+ /**
368
+ * Whether accepted disclaimers are still missing consent after a 409 re-GET:
369
+ * empty catalog or any accepted document still unconsented.
370
+ *
371
+ * @param documents - Latest catalog documents for one consent category.
372
+ * @param accepted - Accepted `{ key, version }` records from the caller.
373
+ * @returns `true` when accepted documents are not fully consented.
374
+ */
375
+ function acceptedCategoryStillMissing(documents, accepted) {
376
+ if (accepted.length === 0) {
377
+ return false;
378
+ }
379
+ if (documents.length === 0) {
380
+ return true;
381
+ }
382
+ const acceptedKeys = new Set(accepted.map((record) => `${record.key}:${record.version}`));
383
+ const relevant = documents.filter((document) => acceptedKeys.has(`${document.key}:${document.version}`));
384
+ return (relevant.length === 0 || relevant.some((document) => !document.consented));
385
+ }
386
+ /**
387
+ * Vendors other than MoonPay skip Check/Auth frames and use the empty-shell
388
+ * customer + consents path instead.
389
+ *
390
+ * @param vendor - The identity vendor for the current flow.
391
+ * @returns `true` when the vendor uses the consents session path.
392
+ */
393
+ function usesConsentsFlow(vendor) {
394
+ return vendor !== 'moonpay';
395
+ }
396
+ // === MESSENGER ===
397
+ const MESSENGER_EXPOSED_METHODS = [
398
+ 'initialize',
399
+ 'loadDisclaimers',
400
+ 'fetchSessionDisclaimers',
401
+ 'acceptTermsAndStartSession',
402
+ 'createVendorCustomer',
403
+ 'clearSavedTerms',
404
+ 'handleFrameMessage',
405
+ 'buildCheckFrameUrl',
406
+ 'buildAuthFrameUrl',
407
+ 'buildResetFrameUrl',
408
+ 'checkKycRequired',
409
+ 'getKycStatus',
410
+ 'getCustomerIdentity',
411
+ 'refreshKycStatus',
412
+ 'startSumSub',
413
+ 'getSessionStatus',
414
+ 'reset',
415
+ 'clearState',
416
+ ];
417
+ // === CONTROLLER DEFINITION ===
418
+ /**
419
+ * `KycController` orchestrates the vendor-backed KYC / identity-verification
420
+ * flow (MoonPay identity + SumSub documents) behind a vendor-neutral, per
421
+ * product surface used by ramps and card. It owns all state and HTTP
422
+ * orchestration (via `KycService`), while vendor protocol handling and
423
+ * platform-specific presentation (WebView/iframe, SumSub SDK) are delegated.
424
+ */
425
+ export class KycController extends BaseController {
426
+ #sumsubLauncher;
427
+ /** MoonPay-specific frame protocol and non-persisted credentials. */
428
+ #moonPayFrames;
429
+ /**
430
+ * Monotonic flow generation. Incremented by {@link reset} and
431
+ * {@link clearState} so in-flight async work (e.g. the KYC-required check)
432
+ * can detect that it was superseded and avoid writing stale results onto a
433
+ * reset controller.
434
+ */
435
+ #generation = 0;
436
+ /** Interval, in milliseconds, between session-status polls. */
437
+ #sessionStatusPollIntervalMs;
438
+ /** Handle for the scheduled next session-status poll, or `null`. */
439
+ #pollTimer = null;
440
+ /**
441
+ * Monotonic polling token. Bumped by {@link #stopPolling} (called on reset, a
442
+ * new sub-flow, and once a terminal status is reached) so an in-flight poll
443
+ * `tick` can detect it was superseded and neither write state nor schedule a
444
+ * follow-up. This closes the gap where clearing the timer alone would still
445
+ * let an already-awaiting request finish and reschedule.
446
+ */
447
+ #pollToken = 0;
448
+ /** Interval, in milliseconds, between user-keyed status polls. */
449
+ #userStatusPollIntervalMs;
450
+ /** Handle for the scheduled next user-status poll, or `null`. */
451
+ #userStatusPollTimer = null;
452
+ /**
453
+ * Whether a user-status poll loop is currently active. Tracked separately
454
+ * from {@link #userStatusPollTimer} because a scheduled tick clears the timer
455
+ * handle before awaiting `fetchKycStatus`; relying on the handle alone would
456
+ * let a concurrent {@link refreshKycStatus} start a second loop on the same
457
+ * token during that in-flight window.
458
+ */
459
+ #userStatusPolling = false;
460
+ /** Monotonic token for the user-status poll loop (see `#pollToken`). */
461
+ #userStatusPollToken = 0;
462
+ /**
463
+ * Constructs a new {@link KycController}.
464
+ *
465
+ * @param options - The constructor options.
466
+ * @param options.messenger - The messenger suited for this controller.
467
+ * @param options.state - Partial initial state; merged over defaults.
468
+ * @param options.sumsubLauncher - The platform SumSub launcher adapter.
469
+ * @param options.sessionStatusPollIntervalMs - How often to poll the UKYC
470
+ * session status after the SumSub SDK completes.
471
+ * @param options.userStatusPollIntervalMs - How often to refresh the
472
+ * user-keyed KYC status while it is still `pending`.
473
+ */
474
+ constructor({ messenger, state, sumsubLauncher, sessionStatusPollIntervalMs = DEFAULT_SESSION_STATUS_POLL_INTERVAL_MS, userStatusPollIntervalMs = DEFAULT_USER_STATUS_POLL_INTERVAL_MS, }) {
475
+ super({
476
+ messenger,
477
+ metadata: kycControllerMetadata,
478
+ name: controllerName,
479
+ state: { ...getDefaultKycControllerState(), ...state },
480
+ });
481
+ this.#sumsubLauncher = sumsubLauncher;
482
+ this.#sessionStatusPollIntervalMs = sessionStatusPollIntervalMs;
483
+ this.#userStatusPollIntervalMs = userStatusPollIntervalMs;
484
+ this.#moonPayFrames = new MoonPayFrameHandler({
485
+ getState: () => this.state,
486
+ update: (updater) => this.#applyUpdate(updater),
487
+ fail: (message) => this.#fail(message),
488
+ onAuthenticated: async () => this.#continueAfterAuthentication(),
489
+ requireTermsReacceptance: () => this.#requireTermsReacceptance(),
490
+ });
491
+ this.messenger.registerMethodActionHandlers(this, MESSENGER_EXPOSED_METHODS);
492
+ }
493
+ /**
494
+ * Builds an adapter over `UserStorageController` that the platform-agnostic
495
+ * `getOrCreateLocalUserSecret` helper uses to persist/load the UKYC
496
+ * `local_user_secret`.
497
+ *
498
+ * @returns The Encrypted User Storage adapter.
499
+ */
500
+ #localUserSecretStore() {
501
+ return {
502
+ get: async (path, entropySourceId) => this.messenger.call('UserStorageController:performGetStorage', path, entropySourceId),
503
+ set: async (path, value, entropySourceId) => this.messenger.call('UserStorageController:performSetStorage', path, value, entropySourceId),
504
+ };
505
+ }
506
+ /**
507
+ * Resolves persisted terms + geolocation, and auto-creates a session when
508
+ * terms are already accepted and an email is available.
509
+ *
510
+ * @param params - Optional parameters.
511
+ * @param params.email - The account email to associate with the session.
512
+ * @param params.product - The consuming feature the flow runs for. When
513
+ * provided, the controller automatically runs the KYC-required check once
514
+ * authentication completes (and chains into document verification when KYC
515
+ * is required). When omitted, the flow stops at `form` and the consumer must
516
+ * call `checkKycRequired` manually.
517
+ * @param params.vendor - Identity vendor for this flow. Non-MoonPay vendors
518
+ * skip Check/Auth frames and use the consents path. Defaults to `moonpay`.
519
+ */
520
+ async initialize(params) {
521
+ // A repeat `initialize` while a session flow is already in progress must
522
+ // not tear it down: creating a new vendor session clears the tokens and
523
+ // forces `phase` back through `session`/`check`, breaking an in-flight
524
+ // Check/Auth frame flow. Leave the active flow untouched and let the
525
+ // consumer drive it (or call `reset` first to start over).
526
+ const vendor = params?.vendor ?? 'moonpay';
527
+ if (IN_PROGRESS_PHASES.includes(this.state.phase)) {
528
+ if (vendor === 'moonpay') {
529
+ this.#moonPayFrames.ensureKeypair();
530
+ }
531
+ return;
532
+ }
533
+ if (vendor === 'moonpay') {
534
+ this.#moonPayFrames.startFlow();
535
+ }
536
+ else {
537
+ this.#moonPayFrames.clear();
538
+ }
539
+ // `initialize` starts a fresh flow, so `activeProduct` is always reset to
540
+ // this call's product (or `null`). Otherwise a prior run's product could
541
+ // linger and cause `#continueAfterAuthentication` to auto-run the check /
542
+ // sub-flow when the caller intended the manual (product-less) flow.
543
+ this.#applyUpdate((state) => {
544
+ if (params?.email) {
545
+ state.email = params.email;
546
+ }
547
+ state.activeVendor = vendor;
548
+ // MoonPay Check/Auth artifacts must not survive a switch to another vendor
549
+ if (vendor !== 'moonpay') {
550
+ clearMoonPaySession(state);
551
+ }
552
+ state.activeProduct = params?.product ?? null;
553
+ });
554
+ // Capture the flow generation so a `reset()` landing while the async
555
+ // geolocation / session steps below are in flight cannot write results
556
+ // onto an idle controller.
557
+ const generation = this.#generation;
558
+ // Resolve country for display; non-blocking.
559
+ try {
560
+ const country = await this.messenger.call('KycService:getGeoCountry');
561
+ this.#updateIfCurrent(generation, (state) => {
562
+ state.geoCountry = country;
563
+ });
564
+ }
565
+ catch {
566
+ // Ignore; disclaimers loading will surface a country error if needed.
567
+ }
568
+ // A `reset()` / `clearState()` that landed while the geolocation request
569
+ // was in flight supersedes this flow. Stop here rather than driving the
570
+ // controller on into `terms` (or a new session): the steps below write
571
+ // unconditionally, and `loadDisclaimers` captures the post-reset
572
+ // generation, so its own guard would not catch this.
573
+ if (this.#generation !== generation) {
574
+ return;
575
+ }
576
+ if (usesConsentsFlow(vendor) && this.state.email) {
577
+ try {
578
+ await this.messenger.call('KycService:createVendorCustomer', {
579
+ vendor,
580
+ email: this.state.email,
581
+ });
582
+ }
583
+ catch (error) {
584
+ if (this.#generation !== generation) {
585
+ return;
586
+ }
587
+ this.#fail(`Vendor customer creation failed: ${String(error)}`);
588
+ return;
589
+ }
590
+ }
591
+ // Drop another vendor's persisted acceptance only after this flow has
592
+ // committed (customer creation succeeded, or there was none to wait for).
593
+ // Clearing earlier would permanently lose ramps/card terms if Iron
594
+ // customer creation failed or a reset landed while it was in flight.
595
+ if (this.#generation !== generation) {
596
+ return;
597
+ }
598
+ const hasTerms = hasVendorDisclaimerAcceptance(this.state.vendorDisclaimersAccepted, vendor);
599
+ if (hasTerms && this.state.email) {
600
+ if (usesConsentsFlow(vendor)) {
601
+ // Consents-path vendors require T&C2 flags; if they weren't persisted
602
+ // (i.e. null from pre-migration state), require reacceptance.
603
+ const { providerDisclaimersAccepted, idosDisclaimersAccepted } = this.state;
604
+ if (providerDisclaimersAccepted.sumsub === null ||
605
+ idosDisclaimersAccepted === null) {
606
+ this.#applyUpdate((state) => {
607
+ this.#clearAcceptedTerms(state);
608
+ state.phase = 'terms';
609
+ });
610
+ await this.loadDisclaimers();
611
+ return;
612
+ }
613
+ await this.#startConsentsSession({
614
+ providerDisclaimersAccepted: providerDisclaimersAccepted.sumsub,
615
+ idosDisclaimersAccepted,
616
+ credentialReusabilityConsentGiven: this.state.credentialReusabilityConsentGiven ?? false,
617
+ });
618
+ }
619
+ else {
620
+ // TODO: should this be here? or should it exist at all?
621
+ if (vendor === 'moonpay' && this.state.vendorDisclaimers.length === 0) {
622
+ await this.loadDisclaimers();
623
+ if (this.#generation !== generation) {
624
+ return;
625
+ }
626
+ }
627
+ await this.#createSession();
628
+ }
629
+ return;
630
+ }
631
+ this.#applyUpdate((state) => {
632
+ state.phase = 'terms';
633
+ });
634
+ await this.loadDisclaimers();
635
+ }
636
+ /**
637
+ * Creates (or resumes) an empty-shell customer for the given identity
638
+ * vendor. Exposed so a consumer can ensure the customer exists before
639
+ * showing T&C screens independently of {@link initialize}.
640
+ *
641
+ * A call while a session flow is already in progress is a no-op — matching
642
+ * {@link initialize} — so a vendor switch cannot leave Check/Auth frames
643
+ * attached to the wrong vendor. Call {@link reset} first to start over.
644
+ *
645
+ * @param params - The parameters.
646
+ * @param params.vendor - Identity vendor for the customer.
647
+ * @param params.email - Email for the vendor customer.
648
+ */
649
+ async createVendorCustomer(params) {
650
+ if (IN_PROGRESS_PHASES.includes(this.state.phase)) {
651
+ return;
652
+ }
653
+ this.#applyUpdate((state) => {
654
+ state.email = params.email;
655
+ // MoonPay Check/Auth artifacts must not survive a switch to another
656
+ // vendor — see `initialize`. Terms for another vendor are dropped only
657
+ // after this request succeeds.
658
+ state.activeVendor = params.vendor;
659
+ if (params.vendor !== 'moonpay') {
660
+ this.#moonPayFrames.clear();
661
+ clearMoonPaySession(state);
662
+ }
663
+ });
664
+ const generation = this.#generation;
665
+ try {
666
+ await this.messenger.call('KycService:createVendorCustomer', {
667
+ vendor: params.vendor,
668
+ email: params.email,
669
+ });
670
+ }
671
+ catch (error) {
672
+ if (this.#generation !== generation) {
673
+ return;
674
+ }
675
+ this.#fail(`Vendor customer creation failed: ${String(error)}`);
676
+ }
677
+ }
678
+ /**
679
+ * Loads the disclaimers for the resolved (or provided) country.
680
+ *
681
+ * @param params - Optional parameters.
682
+ * @param params.country - ISO 3166-1 alpha-3 country code override.
683
+ */
684
+ async loadDisclaimers(params) {
685
+ // Capture the flow generation so a `reset()` landing while the geo /
686
+ // disclaimers requests are in flight cannot write results onto an idle
687
+ // controller.
688
+ const generation = this.#generation;
689
+ try {
690
+ const country = params?.country ??
691
+ this.state.geoCountry ??
692
+ (await this.messenger.call('KycService:getGeoCountry'));
693
+ if (country !== this.state.geoCountry) {
694
+ this.#updateIfCurrent(generation, (state) => {
695
+ state.geoCountry = country;
696
+ });
697
+ }
698
+ const disclaimers = await this.messenger.call('KycService:fetchVendorDisclaimers', {
699
+ vendor: this.state.activeVendor,
700
+ country,
701
+ });
702
+ this.#updateIfCurrent(generation, (state) => {
703
+ state.vendorDisclaimers = disclaimers;
704
+ state.vendorError = null;
705
+ });
706
+ }
707
+ catch (error) {
708
+ this.#updateIfCurrent(generation, (state) => {
709
+ state.vendorError = `Failed to load disclaimers: ${String(error)}`;
710
+ });
711
+ }
712
+ }
713
+ /**
714
+ * Fetches the idOS + KYC-provider disclaimer catalog. Pass exactly one of
715
+ * `sessionId` or `country`:
716
+ *
717
+ * - `{ sessionId }` → {@link KycService.fetchSessionDisclaimersBySessionId}
718
+ * (`GET /sessions/{sessionId}/disclaimers`)
719
+ * - `{ country }` → {@link KycService.fetchSessionDisclaimersByCountry}
720
+ * (`GET /disclaimers?country=`)
721
+ *
722
+ * A session-id fetch also writes the catalog to `sessionDisclaimers`.
723
+ *
724
+ * @param params - The parameters. Provide exactly one of `sessionId` or
725
+ * `country`.
726
+ * @param params.sessionId - The UKYC session id.
727
+ * @param params.country - ISO 3166-1 alpha-3 country code.
728
+ * @returns The catalog. Session fetches include consent state; country
729
+ * fetches do not.
730
+ */
731
+ async fetchSessionDisclaimers(params) {
732
+ const sessionId = 'sessionId' in params ? params.sessionId : undefined;
733
+ const country = 'country' in params ? params.country : undefined;
734
+ if (sessionId && country) {
735
+ throw new Error('KycController.fetchSessionDisclaimers: provide exactly one of sessionId or country.');
736
+ }
737
+ const generation = this.#generation;
738
+ if (country) {
739
+ return this.messenger.call('KycService:fetchSessionDisclaimersByCountry', { country });
740
+ }
741
+ if (!sessionId) {
742
+ throw new Error('KycController.fetchSessionDisclaimers: provide exactly one of sessionId or country.');
743
+ }
744
+ const catalog = await this.messenger.call('KycService:fetchSessionDisclaimersBySessionId', { sessionId });
745
+ this.#updateIfCurrent(generation, (state) => {
746
+ state.sessionDisclaimers = catalog;
747
+ });
748
+ return catalog;
749
+ }
750
+ /**
751
+ * Captures terms acceptance for the currently loaded disclaimers and creates
752
+ * a session.
753
+ *
754
+ * @param params - The parameters.
755
+ * @param params.email - The account email to associate with the session.
756
+ * @param params.product - The consuming feature the flow runs for. See
757
+ * {@link initialize} for how the product drives the automatic post
758
+ * authentication continuation.
759
+ * @param params.providerDisclaimersAccepted - Sumsub disclaimer documents the
760
+ * customer accepted (`{ key, version }` records). Required for every vendor
761
+ * so callers explicitly declare acceptance.
762
+ * @param params.idosDisclaimersAccepted - idOS disclaimer documents the
763
+ * customer accepted (`{ key, version }` records). Required for every vendor
764
+ * so callers explicitly declare acceptance.
765
+ * @param params.credentialReusabilityConsentGiven - Whether the customer
766
+ * consented to reuse existing idOS credentials. Used when recording
767
+ * session-scoped disclaimers on the consents path. Defaults to `false`.
768
+ */
769
+ async acceptTermsAndStartSession(params) {
770
+ const providerDisclaimersAccepted = params?.providerDisclaimersAccepted;
771
+ const idosDisclaimersAccepted = params?.idosDisclaimersAccepted;
772
+ if (!isValidConsentRecordList(providerDisclaimersAccepted) ||
773
+ !isValidConsentRecordList(idosDisclaimersAccepted)) {
774
+ this.#fail('Missing T&C2 acceptance flags.');
775
+ return;
776
+ }
777
+ const credentialReusabilityConsentGiven = params?.credentialReusabilityConsentGiven ?? false;
778
+ const termsAcceptedAt = new Date().toISOString();
779
+ const disclaimerIds = this.state.vendorDisclaimers.map((disclaimer) => disclaimer.id);
780
+ this.#applyUpdate((state) => {
781
+ if (params?.email) {
782
+ state.email = params.email;
783
+ }
784
+ if (params?.product) {
785
+ state.activeProduct = params.product;
786
+ }
787
+ state.vendorDisclaimersAccepted = recordVendorDisclaimerAcceptance(state.vendorDisclaimersAccepted, state.activeVendor, { termsAcceptedAt, disclaimerIds });
788
+ state.providerDisclaimersAccepted = {
789
+ ...state.providerDisclaimersAccepted,
790
+ sumsub: providerDisclaimersAccepted,
791
+ };
792
+ state.idosDisclaimersAccepted = idosDisclaimersAccepted;
793
+ state.credentialReusabilityConsentGiven =
794
+ credentialReusabilityConsentGiven;
795
+ });
796
+ if (usesConsentsFlow(this.state.activeVendor)) {
797
+ await this.#startConsentsSession({
798
+ providerDisclaimersAccepted,
799
+ idosDisclaimersAccepted,
800
+ credentialReusabilityConsentGiven,
801
+ });
802
+ return;
803
+ }
804
+ await this.#createSession();
805
+ }
806
+ /**
807
+ * Consents-path vendors: record vendor T&Cs, create a UKYC session,
808
+ * record session-scoped idOS / KYC-provider disclaimers, then launch
809
+ * SumSub — skipping MoonPay Check/Auth frames.
810
+ *
811
+ * @param consents - T&C2 flags mapped onto the session disclaimer catalog.
812
+ * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records.
813
+ * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records.
814
+ * @param consents.credentialReusabilityConsentGiven - Whether credential
815
+ * reuse was accepted.
816
+ */
817
+ async #startConsentsSession(consents) {
818
+ const { email } = this.state;
819
+ const acceptedDisclaimerIds = ironDisclaimerIds(this.state.vendorDisclaimersAccepted);
820
+ if (!email) {
821
+ this.#fail('Missing email for consents session.');
822
+ return;
823
+ }
824
+ if (acceptedDisclaimerIds.length === 0) {
825
+ this.#fail('Missing disclaimer acceptance.');
826
+ return;
827
+ }
828
+ const generation = this.#generation;
829
+ this.#applyUpdate((state) => {
830
+ state.error = null;
831
+ state.phase = 'session';
832
+ state.statusMessage = 'Submitting consents...';
833
+ state.sumsub.status = 'creatingSession';
834
+ state.sumsub.result = null;
835
+ state.sumsub.sessionStatus = null;
836
+ // Consents-path vendors have no MoonPay session/access tokens.
837
+ clearMoonPaySession(state);
838
+ });
839
+ try {
840
+ await this.messenger.call('KycService:submitVendorDisclaimers', {
841
+ vendor: this.state.activeVendor,
842
+ disclaimerIds: acceptedDisclaimerIds,
843
+ });
844
+ if (this.#generation !== generation) {
845
+ return;
846
+ }
847
+ this.#updateIfCurrent(generation, (state) => {
848
+ state.statusMessage = 'Creating session...';
849
+ });
850
+ const created = await this.#createUkycSession(generation);
851
+ if (!created) {
852
+ return;
853
+ }
854
+ await this.#recordSessionDisclaimers(created.sessionId, consents, generation);
855
+ if (this.#generation !== generation) {
856
+ return;
857
+ }
858
+ if (created.vendorProcessing) {
859
+ try {
860
+ await this.refreshKycStatus();
861
+ }
862
+ catch (statusError) {
863
+ controllerLog('KYC status refresh failed:', statusError);
864
+ }
865
+ this.#updateIfCurrent(generation, (state) => {
866
+ state.phase = 'done';
867
+ state.statusMessage = VENDOR_PROCESSING_MESSAGE;
868
+ });
869
+ return;
870
+ }
871
+ this.#applyUpdate((state) => {
872
+ state.phase = 'submit';
873
+ state.statusMessage = 'Starting document verification...';
874
+ });
875
+ const sumsubResult = await this.startSumSub();
876
+ if (this.#generation !== generation) {
877
+ return;
878
+ }
879
+ // The applicant closed the SDK without submitting. Nothing failed, so
880
+ // rewind with `error` unset and let consumers offer a retry.
881
+ if (this.state.sumsub.status === 'abandoned') {
882
+ await this.#rewindConsentsFlow({
883
+ error: null,
884
+ statusMessage: SUMSUB_ABANDONED_MESSAGE,
885
+ keepSumSubStatus: 'abandoned',
886
+ });
887
+ return;
888
+ }
889
+ // `startSumSub` records `failed` for thrown steps, an SDK that could not
890
+ // run, *and* a terminal UKYC rejection after a submission. Only the first
891
+ // two rewind. A rejection writes `sessionStatus` and is a finished flow:
892
+ // refresh user status and land on `done`.
893
+ if (this.state.sumsub.status === 'failed' &&
894
+ this.state.sumsub.sessionStatus === null) {
895
+ const sumsubError = sumsubResult?.error;
896
+ throw new Error(typeof sumsubError === 'string'
897
+ ? sumsubError
898
+ : 'SumSub verification could not run.');
899
+ }
900
+ // After SumSub, refresh user-keyed status for the Money toast and start
901
+ // polling while still pending. Soft-fail: toast refresh must not rewind
902
+ // the consent / SumSub outcome.
903
+ try {
904
+ await this.refreshKycStatus();
905
+ }
906
+ catch (statusError) {
907
+ controllerLog('KYC status refresh failed:', statusError);
908
+ }
909
+ this.#updateIfCurrent(generation, (state) => {
910
+ if (state.phase !== 'error' && state.phase !== 'done') {
911
+ state.phase = 'done';
912
+ state.statusMessage = 'KYC submitted.';
913
+ }
914
+ });
915
+ }
916
+ catch (error) {
917
+ if (isSessionAlreadyCompletedError(error)) {
918
+ if (this.#generation !== generation) {
919
+ return;
920
+ }
921
+ this.#applyUserStatus({
922
+ status: 'completed',
923
+ sumsubSessionId: null,
924
+ errorCode: null,
925
+ });
926
+ this.#updateIfCurrent(generation, (state) => {
927
+ state.sumsub.status = 'complete';
928
+ state.sumsub.result = { alreadyCompleted: true };
929
+ state.statusMessage = 'KYC already completed.';
930
+ state.phase = 'done';
931
+ state.error = null;
932
+ });
933
+ return;
934
+ }
935
+ controllerLog('Consents session failed:', error);
936
+ if (this.#generation !== generation) {
937
+ return;
938
+ }
939
+ await this.#rewindConsentsFlow({
940
+ error: `Consents session failed: ${String(error)}`,
941
+ statusMessage: 'Consent / verification failed — accept the terms to try again.',
942
+ });
943
+ }
944
+ }
945
+ /**
946
+ * Returns the consents path to the terms phase after a SumSub sub-flow that
947
+ * produced no verification decision, and reloads the disclaimers the next
948
+ * attempt has to re-accept.
949
+ *
950
+ * @param options - Rewind options.
951
+ * @param options.error - Message for `error`, or `null` when the rewind is a
952
+ * normal outcome rather than a failure.
953
+ * @param options.statusMessage - Message for `statusMessage`.
954
+ * @param options.keepSumSubStatus - Sub-flow status to survive the rewind,
955
+ * for an outcome consumers still need once the call resolves. Defaults to the
956
+ * reset `idle`.
957
+ */
958
+ async #rewindConsentsFlow({ error, statusMessage, keepSumSubStatus, }) {
959
+ this.#applyUpdate((state) => {
960
+ this.#clearAcceptedTerms(state);
961
+ state.activeProduct = null;
962
+ state.sessionDisclaimers = null;
963
+ // Session create ran before recording disclaimers. Drop the leftover
964
+ // UKYC session so a later `startSumSub` cannot skip consent recording.
965
+ state.sumsub = { ...getDefaultKycControllerState().sumsub };
966
+ if (keepSumSubStatus) {
967
+ state.sumsub.status = keepSumSubStatus;
968
+ }
969
+ state.error = error;
970
+ state.statusMessage = statusMessage;
971
+ state.phase = 'terms';
972
+ });
973
+ await this.loadDisclaimers();
974
+ }
975
+ /**
976
+ * Fetches the session-scoped disclaimer catalog and records consents
977
+ * derived from the T&C2 flags. Already-consented catalog rows are omitted
978
+ * from the POST. A 409 is re-checked with a GET: continue only when every
979
+ * accepted document is now consented, otherwise fail closed.
980
+ *
981
+ * @param sessionId - The UKYC session id.
982
+ * @param consents - T&C2 flags mapped onto catalog documents.
983
+ * @param consents.providerDisclaimersAccepted - Accepted Sumsub disclaimer records.
984
+ * @param consents.idosDisclaimersAccepted - Accepted idOS disclaimer records.
985
+ * @param consents.credentialReusabilityConsentGiven - Whether credential
986
+ * reuse was accepted.
987
+ * @param generation - Flow generation captured by the caller.
988
+ */
989
+ async #recordSessionDisclaimers(sessionId, consents, generation) {
990
+ const catalog = await this.messenger.call('KycService:fetchSessionDisclaimersBySessionId', { sessionId });
991
+ if (this.#generation !== generation) {
992
+ return;
993
+ }
994
+ this.#applyUpdate((state) => {
995
+ state.sessionDisclaimers = catalog;
996
+ state.statusMessage = 'Submitting consents...';
997
+ });
998
+ if (isAcceptedCategoryEmpty(catalog.idOS, consents.idosDisclaimersAccepted) ||
999
+ isAcceptedCategoryEmpty(catalog.kycProvider, consents.providerDisclaimersAccepted)) {
1000
+ throw new Error('Session disclaimer catalog is missing documents for an accepted category.');
1001
+ }
1002
+ const idOS = consentRecordsFromAcceptedList(catalog.idOS, consents.idosDisclaimersAccepted);
1003
+ const kycProvider = consentRecordsFromAcceptedList(catalog.kycProvider, consents.providerDisclaimersAccepted);
1004
+ const reuseUnchanged = catalog.credentialReusabilityConsentGiven ===
1005
+ consents.credentialReusabilityConsentGiven;
1006
+ if (idOS.length === 0 && kycProvider.length === 0 && reuseUnchanged) {
1007
+ return;
1008
+ }
1009
+ try {
1010
+ const recorded = await this.messenger.call('KycService:submitSessionDisclaimers', {
1011
+ sessionId,
1012
+ idOS,
1013
+ kycProvider,
1014
+ credentialReusabilityConsentGiven: consents.credentialReusabilityConsentGiven,
1015
+ });
1016
+ this.#updateIfCurrent(generation, (state) => {
1017
+ state.sessionDisclaimers = recorded;
1018
+ });
1019
+ }
1020
+ catch (error) {
1021
+ if (!isConsentConflictError(error)) {
1022
+ throw error;
1023
+ }
1024
+ // 409 means some document version was already recorded. Re-fetch and
1025
+ // continue only when every document the user accepted is now consented;
1026
+ // otherwise fail closed so a version bump cannot skip new docs.
1027
+ const latest = await this.messenger.call('KycService:fetchSessionDisclaimersBySessionId', { sessionId });
1028
+ if (this.#generation !== generation) {
1029
+ return;
1030
+ }
1031
+ this.#applyUpdate((state) => {
1032
+ state.sessionDisclaimers = latest;
1033
+ });
1034
+ // TODO: Should we really be doing client side validation of these?
1035
+ const stillMissingIdos = acceptedCategoryStillMissing(latest.idOS, consents.idosDisclaimersAccepted);
1036
+ const stillMissingProvider = acceptedCategoryStillMissing(latest.kycProvider, consents.providerDisclaimersAccepted);
1037
+ const stillMissingReuse = consents.credentialReusabilityConsentGiven &&
1038
+ !latest.credentialReusabilityConsentGiven;
1039
+ if (stillMissingIdos || stillMissingProvider || stillMissingReuse) {
1040
+ throw error;
1041
+ }
1042
+ }
1043
+ }
1044
+ /**
1045
+ * Creates a vendor session from the currently stored terms + email.
1046
+ */
1047
+ async #createSession() {
1048
+ const { email } = this.state;
1049
+ const termsAcceptedAt = this.state.vendorDisclaimersAccepted.moonpay?.termsAcceptedAt;
1050
+ const acceptedDisclaimerIds = this.state.vendorDisclaimers.map((disclaimer) => disclaimer.id);
1051
+ if (!email) {
1052
+ this.#fail('Missing email for session creation.');
1053
+ return;
1054
+ }
1055
+ if (!termsAcceptedAt || acceptedDisclaimerIds.length === 0) {
1056
+ this.#fail('Missing terms acceptance for session creation.');
1057
+ return;
1058
+ }
1059
+ // A new session invalidates any authentication carried over from a prior
1060
+ // session. Clear the stale session token, access token, and auth-frame
1061
+ // client token so `buildCheckFrameUrl` cannot return a URL bound to an old
1062
+ // (or, on failure, invalid) session token, `buildAuthFrameUrl` cannot
1063
+ // return a URL tied to an old client token, and `checkKycRequired` cannot
1064
+ // run with an access token from an earlier authentication. The Check/Auth
1065
+ // frames re-populate these for the new session. Because `moonpaySessionToken` is
1066
+ // cleared here and only re-set on success, a failed creation leaves it
1067
+ // `null` rather than resurrecting the previous session.
1068
+ // Capture the flow generation so a `reset()` landing while the create
1069
+ // request is in flight cannot resurrect a session (success) or overwrite
1070
+ // the now-idle controller (failure). The synchronous update below runs
1071
+ // before any `await`, so it needs no guard.
1072
+ const generation = this.#generation;
1073
+ this.#moonPayFrames.clearAuthentication();
1074
+ this.#applyUpdate((state) => {
1075
+ state.error = null;
1076
+ state.phase = 'session';
1077
+ state.statusMessage = 'Creating session...';
1078
+ state.moonpaySessionToken = null;
1079
+ state.moonpayAccessToken = null;
1080
+ });
1081
+ try {
1082
+ const { sessionToken } = await this.messenger.call('KycService:createSession', { email, termsAcceptedAt, disclaimerIds: acceptedDisclaimerIds });
1083
+ this.#updateIfCurrent(generation, (state) => {
1084
+ state.moonpaySessionToken = sessionToken;
1085
+ state.phase = 'check';
1086
+ state.statusMessage = 'Authenticating via Check frame...';
1087
+ });
1088
+ }
1089
+ catch (error) {
1090
+ controllerLog('Session creation failed:', error);
1091
+ // A reset() superseded this flow while the request was in flight; leave
1092
+ // the idle controller alone rather than forcing it back to `terms`.
1093
+ if (this.#generation !== generation) {
1094
+ return;
1095
+ }
1096
+ // Invalidate the stored acceptance so the customer can retry. Also clear
1097
+ // `activeProduct` so a later `acceptTermsAndStartSession` that omits a
1098
+ // product cannot auto-run the KYC check / SumSub chain for this failed
1099
+ // flow's product — matching how `initialize` starts from a clean product.
1100
+ this.#applyUpdate((state) => {
1101
+ this.#clearAcceptedTerms(state);
1102
+ state.activeProduct = null;
1103
+ state.error = `Session creation failed: ${String(error)}`;
1104
+ state.statusMessage =
1105
+ 'Session creation failed — accept the terms to try again.';
1106
+ state.phase = 'terms';
1107
+ });
1108
+ await this.loadDisclaimers();
1109
+ }
1110
+ }
1111
+ /**
1112
+ * Clears the persisted terms acceptance.
1113
+ */
1114
+ clearSavedTerms() {
1115
+ this.#applyUpdate((state) => {
1116
+ state.vendorDisclaimersAccepted =
1117
+ getDefaultKycVendorDisclaimersAccepted();
1118
+ state.providerDisclaimersAccepted =
1119
+ getDefaultKycProviderDisclaimersAccepted();
1120
+ state.idosDisclaimersAccepted = null;
1121
+ state.credentialReusabilityConsentGiven = null;
1122
+ });
1123
+ }
1124
+ /**
1125
+ * Clears the stored terms acceptance on the given draft state. Shared by the
1126
+ * paths that must invalidate acceptance — explicit clear, vendor terms
1127
+ * update, and session-creation failure — so they stay in sync. This is a
1128
+ * targeted invalidation and, unlike {@link reset}, deliberately leaves the
1129
+ * rest of the flow (geolocation, disclaimers, phase) untouched.
1130
+ *
1131
+ * @param state - The state to mutate.
1132
+ * @param vendor - Vendor whose acceptance to clear. Defaults to
1133
+ * `state.activeVendor`.
1134
+ */
1135
+ #clearAcceptedTerms(state, vendor) {
1136
+ const targetVendor = vendor ?? state.activeVendor;
1137
+ state.vendorDisclaimersAccepted = clearVendorDisclaimerAcceptance(state.vendorDisclaimersAccepted, targetVendor);
1138
+ state.providerDisclaimersAccepted =
1139
+ getDefaultKycProviderDisclaimersAccepted();
1140
+ state.idosDisclaimersAccepted = null;
1141
+ state.credentialReusabilityConsentGiven = null;
1142
+ }
1143
+ /**
1144
+ * Handles a message posted by a Check/Auth frame and advances the flow.
1145
+ *
1146
+ * The transport-agnostic caller (WebView on mobile, iframe on web) forwards
1147
+ * the raw message and injects the returned `reply` back into the frame.
1148
+ *
1149
+ * @param params - The parameters.
1150
+ * @param params.message - The raw message posted by the frame.
1151
+ * @returns An object whose optional `reply` should be posted back.
1152
+ */
1153
+ async handleFrameMessage(params) {
1154
+ return await this.#moonPayFrames.handleMessage(params.message);
1155
+ }
1156
+ /**
1157
+ * Continues the flow once authentication has completed (phase `form`).
1158
+ *
1159
+ * When the flow is scoped to a product (see {@link initialize}), the
1160
+ * KYC-required check runs automatically, and — when KYC is required — the
1161
+ * document-verification sub-flow is launched. When no product is set, this is
1162
+ * a no-op and the flow stays at `form` for the consumer to drive manually.
1163
+ *
1164
+ * Errors are already recorded on state by `checkKycRequired` (`error`
1165
+ * phase) and `startSumSub` (`sumsub.status = 'failed'`); this method swallows
1166
+ * them so it can be awaited safely from the frame-message handler.
1167
+ */
1168
+ async #continueAfterAuthentication() {
1169
+ const product = this.state.activeProduct;
1170
+ if (!product) {
1171
+ return;
1172
+ }
1173
+ // Re-entry protection lives at the frame boundary: `handleFrameMessage`
1174
+ // only honors a Check/Auth `complete` while `phase` matches and
1175
+ // `activeVendor` is MoonPay, and both outcome handlers move `phase` to
1176
+ // `form` before awaiting this method. A duplicate, late, or cross-vendor
1177
+ // `complete` therefore lands after the phase moved on (or on the wrong
1178
+ // vendor) and is dropped before it can start a second continuation. Any
1179
+ // writes here are additionally guarded by `#generation` (see
1180
+ // `checkKycRequired` / `startSumSub`) so a `reset()` mid-continuation
1181
+ // cannot corrupt state.
1182
+ const kycRequired = await this.checkKycRequired({ product });
1183
+ if (!kycRequired) {
1184
+ return;
1185
+ }
1186
+ try {
1187
+ await this.startSumSub();
1188
+ }
1189
+ catch {
1190
+ // `startSumSub` already records `sumsub.status = 'failed'`; swallow the
1191
+ // rethrown error (e.g. SDK unavailable) so the awaited continuation
1192
+ // resolves cleanly rather than surfacing as an unhandled rejection.
1193
+ }
1194
+ }
1195
+ /**
1196
+ * Invalidates stored terms and returns to the terms phase.
1197
+ */
1198
+ #requireTermsReacceptance() {
1199
+ this.#applyUpdate((state) => {
1200
+ this.#clearAcceptedTerms(state);
1201
+ state.phase = 'terms';
1202
+ state.statusMessage =
1203
+ 'The vendor updated its Terms of Use — please re-accept.';
1204
+ });
1205
+ }
1206
+ /**
1207
+ * Builds the Check-frame URL, or `null` when no session exists yet.
1208
+ *
1209
+ * @returns The Check-frame URL or `null`.
1210
+ */
1211
+ buildCheckFrameUrl() {
1212
+ return this.#moonPayFrames.buildCheckFrameUrl();
1213
+ }
1214
+ /**
1215
+ * Builds the Auth-frame URL, or `null` when no client token is available.
1216
+ *
1217
+ * @returns The Auth-frame URL or `null`.
1218
+ */
1219
+ buildAuthFrameUrl() {
1220
+ return this.#moonPayFrames.buildAuthFrameUrl();
1221
+ }
1222
+ /**
1223
+ * Builds the Reset-frame URL.
1224
+ *
1225
+ * @returns The Reset-frame URL.
1226
+ */
1227
+ buildResetFrameUrl() {
1228
+ return this.#moonPayFrames.buildResetFrameUrl();
1229
+ }
1230
+ /**
1231
+ * Checks whether KYC is required for a product and caches the result.
1232
+ *
1233
+ * @param params - The parameters.
1234
+ * @param params.product - The consuming feature.
1235
+ * @param params.country - Optional alpha-3 country override.
1236
+ * @returns Whether KYC is required.
1237
+ */
1238
+ async checkKycRequired(params) {
1239
+ const { moonpayAccessToken } = this.state;
1240
+ if (!moonpayAccessToken) {
1241
+ this.#fail('Missing moonpayAccessToken — repeat the authentication step.');
1242
+ return false;
1243
+ }
1244
+ const country = params.country ?? this.state.geoCountry;
1245
+ if (!country) {
1246
+ this.#fail('Missing country for KYC-required check.');
1247
+ return false;
1248
+ }
1249
+ // Capture the flow generation so we can detect a `reset()` that happens
1250
+ // while the HTTP call is in flight and avoid writing stale results.
1251
+ const generation = this.#generation;
1252
+ this.#applyUpdate((state) => {
1253
+ state.phase = 'submit';
1254
+ state.statusMessage = 'Checking KYC status...';
1255
+ });
1256
+ try {
1257
+ const { kycRequired } = await this.messenger.call('KycService:checkKycRequired', {
1258
+ accessToken: moonpayAccessToken,
1259
+ country,
1260
+ capabilities: [{ product: params.product }],
1261
+ });
1262
+ // The flow was reset while the check was in flight; discard the result
1263
+ // rather than resurrecting a done/cached state on an idle controller.
1264
+ const applied = this.#updateIfCurrent(generation, (state) => {
1265
+ state.kycRequiredByProduct[params.product] = kycRequired;
1266
+ state.lastCheckedAt = new Date().toISOString();
1267
+ state.phase = 'done';
1268
+ state.statusMessage = 'KYC check complete.';
1269
+ });
1270
+ if (!applied) {
1271
+ return false;
1272
+ }
1273
+ return kycRequired;
1274
+ }
1275
+ catch (error) {
1276
+ if (this.#generation !== generation) {
1277
+ return false;
1278
+ }
1279
+ this.#fail(`KYC check failed: ${String(error)}`);
1280
+ return false;
1281
+ }
1282
+ }
1283
+ /**
1284
+ * Reads the cached "is KYC required" result for a product.
1285
+ *
1286
+ * @param params - The parameters.
1287
+ * @param params.product - The consuming feature.
1288
+ * @returns The cached value, or `undefined` if not yet checked.
1289
+ */
1290
+ getKycStatus(params) {
1291
+ return this.state.kycRequiredByProduct[params.product];
1292
+ }
1293
+ /**
1294
+ * Returns the vendor-scoped identity for the currently authenticated
1295
+ * customer, or `null` when the flow has not yet captured a vendor customer
1296
+ * id (before authentication or after {@link reset}), or when a MoonPay id
1297
+ * is present under a different `activeVendor`.
1298
+ *
1299
+ * Exposed so consumers (e.g. ramps autoramp creation) can attach the vendor
1300
+ * customer id to downstream calls without reading the full KYC state, which
1301
+ * also holds session/access tokens. The id is session-scoped and never
1302
+ * persisted.
1303
+ *
1304
+ * @returns The current {@link KycCustomerIdentity}, or `null`.
1305
+ */
1306
+ getCustomerIdentity() {
1307
+ const { moonpayCustomerId, activeVendor } = this.state;
1308
+ // `moonpayCustomerId` is issued only by MoonPay Check/Auth frames. Never
1309
+ // pair it with another vendor, even if a switch left the fields out of
1310
+ // sync, so consumers cannot attach a MoonPay id to an Iron (or other)
1311
+ // downstream call.
1312
+ if (!moonpayCustomerId || activeVendor !== 'moonpay') {
1313
+ return null;
1314
+ }
1315
+ return { vendor: 'moonpay', id: moonpayCustomerId };
1316
+ }
1317
+ /**
1318
+ * Builds the vendor-specific fields spread into a
1319
+ * `KycService:createUkycSession` call, derived from the active vendor and the
1320
+ * currently captured auth state.
1321
+ *
1322
+ * MoonPay sessions must carry the access token and customer id in
1323
+ * `vendorMetadata`; other vendors carry no vendor metadata.
1324
+ *
1325
+ * @returns The vendor-specific subset of the `createUkycSession` params.
1326
+ */
1327
+ #buildUkycSessionVendorFields() {
1328
+ if (this.state.activeVendor === 'moonpay') {
1329
+ return {
1330
+ vendor: 'moonpay',
1331
+ vendorMetadata: {
1332
+ moonPayAccessToken: this.state.moonpayAccessToken,
1333
+ moonPayUserId: this.state.moonpayCustomerId,
1334
+ },
1335
+ };
1336
+ }
1337
+ return { vendor: this.state.activeVendor };
1338
+ }
1339
+ /**
1340
+ * Creates a UKYC session, wraps the `data_encryption_key` and
1341
+ * `ukyc_capability_token` against the returned encryption schemas, and
1342
+ * submits both via authorizations. Stores `sumsub.sessionId`. Returns `null`
1343
+ * when a `reset()` superseded the flow.
1344
+ *
1345
+ * @param generation - Flow generation captured by the caller.
1346
+ * @returns The created session, or `null` if superseded.
1347
+ */
1348
+ async #createUkycSession(generation) {
1349
+ const jwtToken = MOCK_JWT_TOKEN;
1350
+ // Establish a per-session X25519 keypair used to seal both secrets. The
1351
+ // private half stays on the device; the public half is registered on the
1352
+ // session so the server can open later authorizations. Each encryption
1353
+ // schema from session creation supplies the matching server public key.
1354
+ const sessionClientPrivateKey = x25519.utils.randomSecretKey();
1355
+ const sessionClientPublicKey = toBase64Url(x25519.getPublicKey(sessionClientPrivateKey));
1356
+ // Residence is the ISO 3166-1 alpha-3 country already resolved for
1357
+ // disclaimers / KYC-required; fetch it if this sub-flow started without
1358
+ // that earlier step.
1359
+ const residenceCountry = this.state.geoCountry ??
1360
+ (await this.messenger.call('KycService:getGeoCountry'));
1361
+ if (this.#generation !== generation) {
1362
+ return null;
1363
+ }
1364
+ if (residenceCountry !== this.state.geoCountry) {
1365
+ this.#updateIfCurrent(generation, (state) => {
1366
+ state.geoCountry = residenceCountry;
1367
+ });
1368
+ }
1369
+ const { sessionId, encryptionDataKey, ukycCapabilityToken: capabilityTokenSchema, } = await this.messenger.call('KycService:createUkycSession', {
1370
+ jwtToken,
1371
+ sessionClientPublicKey,
1372
+ residenceCountry,
1373
+ ...this.#buildUkycSessionVendorFields(),
1374
+ });
1375
+ if (this.#generation !== generation) {
1376
+ return null;
1377
+ }
1378
+ // Verify each schema's jwtChain against the matching issuer JWKS, then
1379
+ // confirm the returned server public key matches the value attested inside
1380
+ // the verified JWT payload before trusting it for wrapping.
1381
+ // `encryptionDataKey` is attested by the idOS enclave; `ukycCapabilityToken` by the
1382
+ // idOS relay.
1383
+ const [{ keys: idosEnclaveKeys }, { keys: idosRelayKeys }] = await Promise.all([
1384
+ this.messenger.call('KycService:fetchIdosEnclaveJwks'),
1385
+ this.messenger.call('KycService:fetchIdosRelayJwks'),
1386
+ ]);
1387
+ this.#assertAttestedServerPublicKey(idosEnclaveKeys, encryptionDataKey);
1388
+ this.#assertAttestedServerPublicKey(idosRelayKeys, capabilityTokenSchema);
1389
+ // Derive the data_encryption_key from the local_user_secret, mint a
1390
+ // read-only capability token, and wrap both for the session server. Only
1391
+ // the wrapped (encrypted) material ever leaves the device.
1392
+ const localUserSecret = await getOrCreateLocalUserSecret(this.#localUserSecretStore());
1393
+ const clientMaterial = deriveClientMaterial(localUserSecret);
1394
+ const wrappedEncryptionDataKey = wrapEncryptionKey(sessionClientPrivateKey, encryptionDataKey.serverPublicKey.x, clientMaterial.dataEncryptionKey);
1395
+ // Only the client holds the signing key derived from `local_user_secret`,
1396
+ // so only the client can mint the token; scoping it to `read` means it
1397
+ // authorizes later storage reads without granting write or delete access.
1398
+ const ukycCapabilityToken = signStorageAccessToken({
1399
+ material: clientMaterial,
1400
+ // TODO: Confirm with idOS when this can be switched back to read and a separate token is sent for write
1401
+ operations: ['read', 'write'],
1402
+ expiresAt: new Date(Date.now() + UKYC_CAPABILITY_TOKEN_TTL_MS),
1403
+ });
1404
+ const wrappedUkycCapabilityToken = wrapEncryptionKey(sessionClientPrivateKey, capabilityTokenSchema.serverPublicKey.x, stringToBytes(encodeStorageAccessTokenForHeader(ukycCapabilityToken)));
1405
+ if (this.#generation !== generation) {
1406
+ return null;
1407
+ }
1408
+ const { kycStatus, finalStatus } = await this.messenger.call('KycService:setAuthorizations', {
1409
+ sessionId,
1410
+ wrappedEncryptionDataKey,
1411
+ wrappedUkycCapabilityToken,
1412
+ });
1413
+ const vendorProcessing = kycStatus === KYC_STATUSES.approved &&
1414
+ finalStatus === KYC_STATUSES.pending;
1415
+ const stillCurrent = this.#updateIfCurrent(generation, (state) => {
1416
+ state.sumsub.sessionId = sessionId;
1417
+ if (vendorProcessing) {
1418
+ state.sumsub.status = 'vendorProcessing';
1419
+ state.statusMessage = VENDOR_PROCESSING_MESSAGE;
1420
+ }
1421
+ });
1422
+ if (!stillCurrent) {
1423
+ return null;
1424
+ }
1425
+ return { sessionId, kycStatus, finalStatus, vendorProcessing };
1426
+ }
1427
+ /**
1428
+ * Runs the SumSub document-verification sub-flow end to end:
1429
+ *
1430
+ * 1. creates a UKYC session, receiving per-secret encryption schemas;
1431
+ * 2. verifies the `encryptionDataKey` schema's `jwtChain` against the
1432
+ * idOS enclave JWKS and the `ukycCapabilityToken` schema's `jwtChain` against
1433
+ * the idOS relay JWKS, then confirms each attested session server public
1434
+ * key;
1435
+ * 3. derives the `data_encryption_key` from the wallet's UKYC
1436
+ * `local_user_secret` and wraps it for the session server;
1437
+ * 4. mints a client-signed, read-only `ukyc_capability_token`, wraps it the
1438
+ * same way as the encryption key, and submits both via authorizations;
1439
+ * 5. fetches the SumSub applicant access token; and
1440
+ * 6. presents the SDK via the injected launcher.
1441
+ *
1442
+ * If a UKYC session already exists (the consents path creates it before
1443
+ * recording session disclaimers), steps 1–4 are skipped.
1444
+ *
1445
+ * If authorizations report the applicant is already approved on the relay
1446
+ * while the vendor is still finalizing (`kycStatus: approved`,
1447
+ * `finalStatus: pending`), the sub-flow stops at step 4 with a
1448
+ * `vendorProcessing` status and a message rather than launching the SDK.
1449
+ *
1450
+ * @param params - Optional parameters.
1451
+ * @param params.locale - BCP-47 locale for the SDK UI.
1452
+ * @param params.debug - Enables SDK debug logging.
1453
+ * @returns The SDK result.
1454
+ */
1455
+ async startSumSub(params) {
1456
+ // A new sub-flow supersedes any polling still running from a prior run.
1457
+ this.#stopPolling();
1458
+ // Paused for the whole sub-flow: a tick landing while the SDK is on screen
1459
+ // publishes `statusChanged`, pulling consumers (and their signing prompts)
1460
+ // in front of a flow the applicant has not finished. Resumed in `finally`
1461
+ // so abandonment, SDK failure, and callers that do not run
1462
+ // `refreshKycStatus` (MoonPay post-auth) still restore the loop.
1463
+ this.#stopUserStatusPolling();
1464
+ // Capture the flow generation so each async step can detect a `reset()`
1465
+ // that lands mid-flight and avoid writing stale sub-flow state (or, worse,
1466
+ // presenting the SDK) on a controller that is now idle.
1467
+ const generation = this.#generation;
1468
+ try {
1469
+ if (!this.#sumsubLauncher.isAvailable()) {
1470
+ const error = 'SumSub SDK is not available in this runtime.';
1471
+ this.#applyUpdate((state) => {
1472
+ state.sumsub.status = 'failed';
1473
+ state.sumsub.result = { error };
1474
+ });
1475
+ throw new Error(error);
1476
+ }
1477
+ try {
1478
+ if (!this.state.sumsub.sessionId) {
1479
+ this.#applyUpdate((state) => {
1480
+ state.sumsub.status = 'creatingSession';
1481
+ state.sumsub.result = null;
1482
+ state.sumsub.sessionStatus = null;
1483
+ });
1484
+ const created = await this.#createUkycSession(generation);
1485
+ if (!created) {
1486
+ return {};
1487
+ }
1488
+ // A user who already finished the journey can return to a session the
1489
+ // relay has already approved (`kycStatus`) while the vendor is still
1490
+ // finalizing its own decision (`finalStatus`). There is nothing left to
1491
+ // verify, so stop here and surface a message rather than launching the
1492
+ // SDK again.
1493
+ if (created.vendorProcessing) {
1494
+ return {
1495
+ kycStatus: created.kycStatus,
1496
+ finalStatus: created.finalStatus,
1497
+ };
1498
+ }
1499
+ }
1500
+ // Empty string is a valid "no id to poll" session id used by tests and
1501
+ // must not be coalesced away as missing.
1502
+ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1503
+ const sessionId = this.state.sumsub.sessionId || '';
1504
+ this.#updateIfCurrent(generation, (state) => {
1505
+ state.sumsub.status = 'fetchingToken';
1506
+ state.sumsub.sessionId = sessionId;
1507
+ });
1508
+ const { applicantAccessToken } = await this.messenger.call('KycService:createJourney', sessionId);
1509
+ // A reset() may have landed while the session/token was being prepared.
1510
+ // Gate the `launching` write and the decision to open the SDK behind a
1511
+ // single generation check: `#updateIfCurrent` only writes when still
1512
+ // current and reports whether it did. Since there is no `await` between
1513
+ // this check and `launch` below, a successful result guarantees the SDK
1514
+ // is never presented on a flow that a concurrent reset() returned to idle.
1515
+ const stillCurrent = this.#updateIfCurrent(generation, (state) => {
1516
+ state.sumsub.status = 'launching';
1517
+ state.sumsub.applicantAccessToken = applicantAccessToken;
1518
+ });
1519
+ if (!stillCurrent) {
1520
+ return {};
1521
+ }
1522
+ // Track whether the SDK ever reported a successful completion. A resolved
1523
+ // `launch` alone does not imply success — the applicant may have
1524
+ // abandoned the flow or the SDK may have reported a non-success outcome.
1525
+ let reachedCompletion = false;
1526
+ const result = await this.#sumsubLauncher.launch({
1527
+ applicantAccessToken,
1528
+ onTokenExpiration: async () => {
1529
+ // A reset() may have superseded this flow while the SDK stayed open.
1530
+ // Refuse to refresh against the now-stale UKYC session rather than
1531
+ // silently keeping an orphaned SDK alive.
1532
+ if (this.#generation !== generation) {
1533
+ throw new Error('KYC flow was reset; SumSub session is no longer active.');
1534
+ }
1535
+ const refreshed = await this.messenger.call('KycService:createJourney', sessionId);
1536
+ return refreshed.applicantAccessToken;
1537
+ },
1538
+ onStatusChange: (_prev, next) => {
1539
+ if (isSumSubFlowCompleted(next)) {
1540
+ reachedCompletion = true;
1541
+ }
1542
+ this.#updateIfCurrent(generation, (state) => {
1543
+ state.sumsub.status = isSumSubFlowCompleted(next)
1544
+ ? 'complete'
1545
+ : 'inProgress';
1546
+ });
1547
+ },
1548
+ locale: params?.locale ?? 'en',
1549
+ debug: params?.debug ?? false,
1550
+ });
1551
+ // Some native SDKs resolve with their final status without first
1552
+ // delivering the corresponding state-change callback.
1553
+ reachedCompletion ||= isSumSubFlowCompleted(result.status);
1554
+ // A resolved `launch` alone is not the final outcome: only a submission
1555
+ // is worth polling for a decision. Without one, the SDK status is the
1556
+ // only way to tell a failure from an applicant who closed the SDK early
1557
+ // — the UKYC session leaves its initial state as soon as the journey is
1558
+ // created, so it cannot stand in for "the applicant finished".
1559
+ let settledStatus = 'abandoned';
1560
+ if (reachedCompletion) {
1561
+ settledStatus = 'polling';
1562
+ }
1563
+ else if (isSumSubLaunchFailure(result)) {
1564
+ settledStatus = 'failed';
1565
+ }
1566
+ const applied = this.#updateIfCurrent(generation, (state) => {
1567
+ state.sumsub.status = settledStatus;
1568
+ state.sumsub.result = result;
1569
+ });
1570
+ // Once the SDK completes, the authoritative verification decision comes
1571
+ // from the UKYC backend, not the SDK result. Poll the session status
1572
+ // until it reaches a terminal decision. Guard on `applied` so a `reset()`
1573
+ // that landed during `launch` cannot start polling on an idle flow.
1574
+ if (applied && reachedCompletion) {
1575
+ if (sessionId) {
1576
+ await this.#startSessionStatusPolling(sessionId);
1577
+ }
1578
+ else {
1579
+ // No session id to poll against; fall back to treating the SDK
1580
+ // completion as the final outcome.
1581
+ this.#updateIfCurrent(generation, (state) => {
1582
+ state.sumsub.status = 'complete';
1583
+ });
1584
+ }
1585
+ }
1586
+ return result;
1587
+ }
1588
+ catch (error) {
1589
+ // Applicant already finished KYC — treat as completed for Money toast.
1590
+ if (isSessionAlreadyCompletedError(error)) {
1591
+ // A reset() may have landed while `launch` was in flight; forcing
1592
+ // `completed` (and publishing `statusChanged`) on an idle controller
1593
+ // would resurrect a flow the consumer already tore down.
1594
+ if (this.#generation !== generation) {
1595
+ return { alreadyCompleted: true };
1596
+ }
1597
+ this.#applyUserStatus({
1598
+ status: 'completed',
1599
+ sumsubSessionId: null,
1600
+ errorCode: null,
1601
+ });
1602
+ this.#updateIfCurrent(generation, (state) => {
1603
+ state.sumsub.status = 'complete';
1604
+ state.sumsub.result = { alreadyCompleted: true };
1605
+ state.statusMessage = 'KYC already completed.';
1606
+ state.phase = 'done';
1607
+ state.error = null;
1608
+ });
1609
+ return { alreadyCompleted: true };
1610
+ }
1611
+ const result = { error: String(error) };
1612
+ this.#updateIfCurrent(generation, (state) => {
1613
+ state.sumsub.status = 'failed';
1614
+ state.sumsub.result = result;
1615
+ });
1616
+ return result;
1617
+ }
1618
+ }
1619
+ finally {
1620
+ if (this.#generation === generation) {
1621
+ try {
1622
+ await this.refreshKycStatus();
1623
+ }
1624
+ catch (error) {
1625
+ controllerLog('KYC status refresh failed:', error);
1626
+ }
1627
+ }
1628
+ }
1629
+ }
1630
+ /**
1631
+ * Refreshes the user-keyed simplified KYC status from `GET /kyc/status`,
1632
+ * stores it on state, publishes {@link KycControllerStatusChangedEvent}, and
1633
+ * schedules short-interval polling while the status is `pending`.
1634
+ *
1635
+ * Skipped when `userStatus` is already `completed`: a follow-up
1636
+ * `GET /kyc/status` can still read a stale `pending` (for example after
1637
+ * `session_not_in_valid_state`) and must not undo that decision.
1638
+ *
1639
+ * @returns The latest status payload.
1640
+ */
1641
+ async refreshKycStatus() {
1642
+ if (this.state.userStatus === 'completed') {
1643
+ return {
1644
+ status: 'completed',
1645
+ sumsubSessionId: this.state.userStatusSumsubSessionId,
1646
+ errorCode: this.state.userStatusErrorCode,
1647
+ };
1648
+ }
1649
+ const generation = this.#generation;
1650
+ const payload = await this.#fetchAndApplyUserStatus();
1651
+ // A `reset()` landing while the request was in flight already stopped
1652
+ // polling and left the flow idle, and the payload above is the pre-reset
1653
+ // cached status. Starting a loop from it would poll — and publish
1654
+ // `statusChanged` — on a torn-down flow.
1655
+ if (this.#generation !== generation) {
1656
+ return payload;
1657
+ }
1658
+ if (payload.status === 'pending') {
1659
+ this.#ensureUserStatusPolling();
1660
+ }
1661
+ else {
1662
+ this.#stopUserStatusPolling();
1663
+ }
1664
+ return payload;
1665
+ }
1666
+ /**
1667
+ * Fetches `GET /kyc/status` and applies it to state without managing the
1668
+ * poll loop (used by both {@link refreshKycStatus} and the poll tick).
1669
+ *
1670
+ * @returns The latest status payload.
1671
+ */
1672
+ async #fetchAndApplyUserStatus() {
1673
+ const generation = this.#generation;
1674
+ const response = await this.messenger.call('KycService:fetchKycStatus');
1675
+ if (this.#generation !== generation) {
1676
+ return {
1677
+ status: this.state.userStatus ?? 'not-started',
1678
+ sumsubSessionId: this.state.userStatusSumsubSessionId,
1679
+ errorCode: this.state.userStatusErrorCode,
1680
+ };
1681
+ }
1682
+ const payload = {
1683
+ status: response.status,
1684
+ sumsubSessionId: response.sumsubSessionId ?? null,
1685
+ errorCode: response.errorCode ?? null,
1686
+ };
1687
+ this.#applyUserStatus(payload);
1688
+ return payload;
1689
+ }
1690
+ /**
1691
+ * Writes user-keyed status onto state and publishes `statusChanged` when the
1692
+ * value actually changes.
1693
+ *
1694
+ * @param payload - The status payload to apply.
1695
+ * @param payload.status - User-keyed KYC status from `GET /kyc/status`.
1696
+ * @param payload.sumsubSessionId - Optional SumSub session id from status.
1697
+ * @param payload.errorCode - Optional error code from status.
1698
+ */
1699
+ #applyUserStatus(payload) {
1700
+ const previous = this.state.userStatus;
1701
+ this.#applyUpdate((state) => {
1702
+ state.userStatus = payload.status;
1703
+ state.userStatusSumsubSessionId = payload.sumsubSessionId;
1704
+ state.userStatusErrorCode = payload.errorCode;
1705
+ });
1706
+ if (previous !== payload.status) {
1707
+ this.messenger.publish(`${controllerName}:statusChanged`, payload);
1708
+ }
1709
+ }
1710
+ /**
1711
+ * Starts the user-status poll loop when not already running and status is
1712
+ * still `pending`.
1713
+ */
1714
+ #ensureUserStatusPolling() {
1715
+ if (this.#userStatusPolling) {
1716
+ return;
1717
+ }
1718
+ this.#userStatusPolling = true;
1719
+ const token = this.#userStatusPollToken;
1720
+ const tick = async () => {
1721
+ try {
1722
+ const payload = await this.#fetchAndApplyUserStatus();
1723
+ // Race with `reset()` / `#stopUserStatusPolling` while the request was
1724
+ // in flight — do not reschedule onto an idle controller.
1725
+ /* istanbul ignore next */
1726
+ if (this.#userStatusPollToken !== token) {
1727
+ return;
1728
+ }
1729
+ if (payload.status !== 'pending') {
1730
+ this.#stopUserStatusPolling();
1731
+ return;
1732
+ }
1733
+ }
1734
+ catch {
1735
+ // Keep polling on transient errors, unless the loop was superseded.
1736
+ /* istanbul ignore next */
1737
+ if (this.#userStatusPollToken !== token) {
1738
+ return;
1739
+ }
1740
+ }
1741
+ this.#userStatusPollTimer = setTimeout(() => {
1742
+ this.#userStatusPollTimer = null;
1743
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1744
+ tick();
1745
+ }, this.#userStatusPollIntervalMs);
1746
+ // Allow the process to exit while a pending-status poll is scheduled.
1747
+ // React Native / browser timers are numbers with no `unref`, hence the
1748
+ // optional call.
1749
+ this.#userStatusPollTimer.unref?.();
1750
+ };
1751
+ this.#userStatusPollTimer = setTimeout(() => {
1752
+ this.#userStatusPollTimer = null;
1753
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1754
+ tick();
1755
+ }, this.#userStatusPollIntervalMs);
1756
+ this.#userStatusPollTimer.unref?.();
1757
+ }
1758
+ /**
1759
+ * Stops the user-keyed status poll loop.
1760
+ */
1761
+ #stopUserStatusPolling() {
1762
+ this.#userStatusPollToken += 1;
1763
+ this.#userStatusPolling = false;
1764
+ if (this.#userStatusPollTimer !== null) {
1765
+ clearTimeout(this.#userStatusPollTimer);
1766
+ this.#userStatusPollTimer = null;
1767
+ }
1768
+ }
1769
+ /**
1770
+ * Fetches the current UKYC session status for the active sub-flow and records
1771
+ * it on state. Useful for a one-off refresh outside the automatic polling
1772
+ * loop that {@link startSumSub} runs.
1773
+ *
1774
+ * @returns The fetched session status.
1775
+ * @throws If there is no active SumSub session to query.
1776
+ */
1777
+ async getSessionStatus() {
1778
+ const { sessionId } = this.state.sumsub;
1779
+ if (!sessionId) {
1780
+ throw new Error('Cannot fetch session status: no active SumSub session.');
1781
+ }
1782
+ // Capture the flow generation so a `reset()` landing while the request is
1783
+ // in flight cannot write the result onto an idle controller.
1784
+ const generation = this.#generation;
1785
+ const sessionStatus = await this.messenger.call('KycService:getSessionStatus', { sessionId });
1786
+ this.#updateIfCurrent(generation, (state) => {
1787
+ state.sumsub.sessionStatus = sessionStatus;
1788
+ });
1789
+ return sessionStatus;
1790
+ }
1791
+ /**
1792
+ * Begins polling the UKYC session status until a terminal decision is
1793
+ * reached. The first poll runs immediately (and is awaited by
1794
+ * {@link startSumSub}); subsequent polls are scheduled every
1795
+ * `#sessionStatusPollIntervalMs`.
1796
+ *
1797
+ * @param sessionId - The UKYC session id to poll.
1798
+ * @returns A promise that resolves once the first poll settles.
1799
+ */
1800
+ async #startSessionStatusPolling(sessionId) {
1801
+ // Supersede any prior loop and claim a fresh token for this one. Because
1802
+ // `#stopPolling` bumps the token, any in-flight poll from a previous loop
1803
+ // sees a mismatch and neither writes state nor reschedules.
1804
+ this.#stopPolling();
1805
+ const token = this.#pollToken;
1806
+ const tick = async () => {
1807
+ const shouldStop = await this.#pollSessionStatusOnce(sessionId, token);
1808
+ if (shouldStop) {
1809
+ return;
1810
+ }
1811
+ this.#pollTimer = setTimeout(() => {
1812
+ this.#pollTimer = null;
1813
+ // `tick` swallows its own errors (see `#pollSessionStatusOnce`) and
1814
+ // therefore never rejects, so this fire-and-forget scheduled poll
1815
+ // cannot surface as an unhandled rejection.
1816
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
1817
+ tick();
1818
+ }, this.#sessionStatusPollIntervalMs);
1819
+ };
1820
+ await tick();
1821
+ }
1822
+ /**
1823
+ * Performs a single session-status poll: fetches the status, records it, and
1824
+ * resolves the sub-flow when the status is terminal.
1825
+ *
1826
+ * Transient errors are swallowed so the loop keeps polling; the last good
1827
+ * `sessionStatus` is deliberately preserved rather than being overwritten
1828
+ * with the error.
1829
+ *
1830
+ * @param sessionId - The UKYC session id to poll.
1831
+ * @param token - The polling token captured when the loop started.
1832
+ * @returns `true` when the loop should stop (terminal status or superseded
1833
+ * by a reset / new sub-flow), `false` when it should keep polling.
1834
+ */
1835
+ async #pollSessionStatusOnce(sessionId, token) {
1836
+ try {
1837
+ const sessionStatus = await this.messenger.call('KycService:getSessionStatus', { sessionId });
1838
+ // Superseded while the request was in flight — drop the result.
1839
+ if (this.#pollToken !== token) {
1840
+ return true;
1841
+ }
1842
+ const isTerminal = TERMINAL_SESSION_STATUSES.has(sessionStatus.finalStatus);
1843
+ this.#applyUpdate((state) => {
1844
+ state.sumsub.sessionStatus = sessionStatus;
1845
+ if (isTerminal) {
1846
+ state.sumsub.status = SUCCESSFUL_SESSION_STATUSES.has(sessionStatus.finalStatus)
1847
+ ? 'complete'
1848
+ : 'failed';
1849
+ }
1850
+ });
1851
+ if (isTerminal) {
1852
+ this.#stopPolling();
1853
+ }
1854
+ return isTerminal;
1855
+ }
1856
+ catch {
1857
+ // Keep polling on transient errors, preserving the last good status.
1858
+ // Stop only when a reset / new sub-flow superseded this loop.
1859
+ return this.#pollToken !== token;
1860
+ }
1861
+ }
1862
+ /**
1863
+ * Stops the session-status polling loop: bumps the polling token (so any
1864
+ * in-flight `tick` bows out) and clears any scheduled poll.
1865
+ */
1866
+ #stopPolling() {
1867
+ this.#pollToken += 1;
1868
+ if (this.#pollTimer !== null) {
1869
+ clearTimeout(this.#pollTimer);
1870
+ this.#pollTimer = null;
1871
+ }
1872
+ }
1873
+ /**
1874
+ * Resets the flow to idle, clearing session tokens and sub-flow state while
1875
+ * preserving persisted terms acceptance and the per-product cache.
1876
+ */
1877
+ reset() {
1878
+ this.#cancelPendingSession();
1879
+ this.#applyUpdate((state) => {
1880
+ state.phase = 'idle';
1881
+ state.statusMessage = '';
1882
+ state.error = null;
1883
+ state.vendorDisclaimers = [];
1884
+ state.vendorError = null;
1885
+ state.sessionDisclaimers = null;
1886
+ state.credentialReusabilityConsentGiven = null;
1887
+ clearMoonPaySession(state);
1888
+ state.activeVendor = 'moonpay';
1889
+ state.activeProduct = null;
1890
+ state.sumsub = {
1891
+ status: 'idle',
1892
+ result: null,
1893
+ sessionId: null,
1894
+ applicantAccessToken: null,
1895
+ sessionStatus: null,
1896
+ };
1897
+ });
1898
+ }
1899
+ /**
1900
+ * Restores the controller to its default state, discarding everything
1901
+ * {@link reset} deliberately keeps: the session email, the persisted terms
1902
+ * acceptance, the per-product KYC-required cache and the user-keyed status.
1903
+ *
1904
+ * Intended for a full wallet reset, where no trace of the previous
1905
+ * customer may survive into the next wallet.
1906
+ */
1907
+ clearState() {
1908
+ this.#cancelPendingSession();
1909
+ this.#applyUpdate((state) => {
1910
+ Object.assign(state, getDefaultKycControllerState());
1911
+ });
1912
+ }
1913
+ /**
1914
+ * Tears down everything that lives outside state: drops the MoonPay frame
1915
+ * keypair and auth client token, stops both polling loops, and bumps the flow
1916
+ * generation so async steps started earlier discard their results instead
1917
+ * of writing them onto the controller. Shared by {@link reset} and
1918
+ * {@link clearState}.
1919
+ */
1920
+ #cancelPendingSession() {
1921
+ this.#moonPayFrames.clear();
1922
+ this.#stopPolling();
1923
+ this.#stopUserStatusPolling();
1924
+ this.#generation += 1;
1925
+ }
1926
+ /**
1927
+ * Applies a state update only when the flow has not been reset since
1928
+ * `generation` was captured. Prevents an in-flight async step from writing
1929
+ * stale results onto a controller that a concurrent {@link reset} has
1930
+ * returned to idle.
1931
+ *
1932
+ * @param generation - The flow generation captured before the async work.
1933
+ * @param updater - The state mutation to apply when still current.
1934
+ * @returns `true` if the update was applied, `false` if it was superseded.
1935
+ */
1936
+ #updateIfCurrent(generation, updater) {
1937
+ if (this.#generation !== generation) {
1938
+ return false;
1939
+ }
1940
+ this.#applyUpdate(updater);
1941
+ return true;
1942
+ }
1943
+ /**
1944
+ * The single state-update path for this controller. All mutations go through
1945
+ * here (rather than calling `this.update` directly) so the mechanism stays
1946
+ * consistent and one subtlety is handled in a single place:
1947
+ *
1948
+ * `sumsub.result` is typed as the recursive `Json`, and expanding
1949
+ * `Draft<Json>` (which happens whenever an updater touches `sumsub.result`)
1950
+ * can trip TypeScript's "type instantiation is excessively deep" guard. By
1951
+ * typing the callback parameter as the plain {@link KycControllerState}
1952
+ * instead of Immer's `Draft`, we avoid expanding the draft type while keeping
1953
+ * the same mutate-in-place semantics (the underlying value is still the Immer
1954
+ * draft at runtime).
1955
+ *
1956
+ * @param updater - The state mutation to apply.
1957
+ */
1958
+ #applyUpdate(updater) {
1959
+ this.update((state) => {
1960
+ // @ts-expect-error Avoid "type instantiation is excessively deep".
1961
+ updater(state);
1962
+ });
1963
+ }
1964
+ /**
1965
+ * Confirms that an encryption schema's `serverPublicKey.x` matches the
1966
+ * `sessionServerPublicKeyX` attested inside its verified `jwtChain`. Rejects
1967
+ * a key that was swapped out-of-band after the chain was signed.
1968
+ *
1969
+ * @param keys - The issuer JWKS used to verify the chain (idOS enclave for
1970
+ * `encryptionDataKey`, idOS relay for `ukycCapabilityToken`).
1971
+ * @param schema - The encryption schema returned by session creation.
1972
+ */
1973
+ #assertAttestedServerPublicKey(keys, schema) {
1974
+ const jwtChainPayload = verifyJwtChain(keys, schema.jwtChain);
1975
+ if (jwtChainPayload.sessionServerPublicKeyX !== schema.serverPublicKey.x) {
1976
+ throw new Error('sessionServerPublicKey does not match the verified jwtChain payload (sessionServerPublicKeyX).');
1977
+ }
1978
+ }
1979
+ /**
1980
+ * Transitions to the error phase with a message.
1981
+ *
1982
+ * @param message - The error message.
1983
+ */
1984
+ #fail(message) {
1985
+ this.#applyUpdate((state) => {
1986
+ state.error = message;
1987
+ state.phase = 'error';
1988
+ });
1989
+ }
1990
+ }
1991
+ //# sourceMappingURL=KycController.js.map