@myazahq/kyc-sdk-react-native 2.4.0 → 2.6.0

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 (246) hide show
  1. package/README.md +151 -5
  2. package/android/build.gradle +35 -3
  3. package/android/src/main/java/com/margelo/nitro/myazakyc/HybridMyazaFaceDetector.kt +77 -0
  4. package/app.plugin.js +89 -8
  5. package/ios/HybridMyazaFaceDetector.swift +16 -3
  6. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.cpp +23 -0
  7. package/nitrogen/generated/android/c++/JHybridMyazaFaceDetectorSpec.hpp +2 -0
  8. package/nitrogen/generated/android/kotlin/com/margelo/nitro/myazakyc/HybridMyazaFaceDetectorSpec.kt +9 -0
  9. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.cpp +8 -0
  10. package/nitrogen/generated/ios/KycSdkReactNative-Swift-Cxx-Bridge.hpp +43 -0
  11. package/nitrogen/generated/ios/c++/HybridMyazaFaceDetectorSpecSwift.hpp +17 -0
  12. package/nitrogen/generated/ios/swift/Func_void_bool.swift +46 -0
  13. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec.swift +2 -0
  14. package/nitrogen/generated/ios/swift/HybridMyazaFaceDetectorSpec_cxx.swift +31 -0
  15. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.cpp +2 -0
  16. package/nitrogen/generated/shared/c++/HybridMyazaFaceDetectorSpec.hpp +3 -0
  17. package/package.json +14 -2
  18. package/src/MyazaBiometricAuth.tsx +119 -0
  19. package/src/MyazaKYC.tsx +12 -0
  20. package/src/components/CountryField.tsx +8 -0
  21. package/src/components/CountryRegionPicker.tsx +184 -0
  22. package/src/components/DashedBorder.tsx +55 -0
  23. package/src/components/DialCodePicker.tsx +53 -67
  24. package/src/components/DialCodeRow.tsx +102 -0
  25. package/src/components/FramedMapPicker.tsx +122 -0
  26. package/src/components/GeoBadge.tsx +34 -0
  27. package/src/components/Icon.tsx +2 -176
  28. package/src/components/KycFlow.tsx +60 -32
  29. package/src/components/LineSkeleton.tsx +100 -0
  30. package/src/components/MapChrome.tsx +67 -0
  31. package/src/components/MapPinMarker.tsx +54 -0
  32. package/src/components/MapPinPicker.tsx +179 -0
  33. package/src/components/MilestoneTrack.tsx +155 -0
  34. package/src/components/MultiIdProgress.tsx +107 -0
  35. package/src/components/MyazaInput.tsx +39 -4
  36. package/src/components/MyazaSelect.tsx +2 -1
  37. package/src/components/OptionRow.tsx +22 -3
  38. package/src/components/OwnershipSlider.tsx +102 -0
  39. package/src/components/PhoneNumberInput.tsx +45 -4
  40. package/src/components/PoweredBy.tsx +5 -5
  41. package/src/components/PresenceBlocks.tsx +153 -0
  42. package/src/components/StaggerIn.tsx +63 -0
  43. package/src/components/StepView.tsx +43 -0
  44. package/src/components/StickyActions.tsx +56 -0
  45. package/src/components/dialCodeRows.ts +64 -0
  46. package/src/components/glass/FloatingSheet.tsx +73 -21
  47. package/src/components/glass/GlassSheet.tsx +14 -2
  48. package/src/components/icon-map.ts +176 -0
  49. package/src/components/stepHeaderMeta.tsx +45 -5
  50. package/src/config/addressCollection.ts +125 -0
  51. package/src/config/biometricOptions.ts +100 -0
  52. package/src/config/business.ts +26 -7
  53. package/src/config/businessDetailsValidity.ts +52 -0
  54. package/src/config/businessPrefill.ts +72 -0
  55. package/src/config/businessSteps.ts +20 -3
  56. package/src/config/consentStep.ts +19 -0
  57. package/src/config/keyPeople.ts +111 -7
  58. package/src/config/keyPeopleCorporate.ts +19 -0
  59. package/src/config/keyPeopleOwnerTypes.ts +12 -0
  60. package/src/config/keyPeopleOwners.ts +26 -0
  61. package/src/config/keyPeoplePrefill.ts +138 -0
  62. package/src/config/keyPeopleSectionDefs.ts +65 -0
  63. package/src/config/keyPeopleSections.ts +167 -0
  64. package/src/config/proofOfAddress.ts +54 -11
  65. package/src/config/regions.ts +24 -0
  66. package/src/config/registrationHint.ts +1 -1
  67. package/src/config/stepOrder.ts +100 -6
  68. package/src/config/theme.ts +8 -2
  69. package/src/config/uploadLimits.ts +35 -0
  70. package/src/config/website.ts +36 -0
  71. package/src/config/workflowMerge.ts +16 -5
  72. package/src/emrtd/activeAuth.ts +100 -0
  73. package/src/emrtd/extras.ts +76 -0
  74. package/src/emrtd/files.ts +5 -0
  75. package/src/emrtd/index.ts +1 -0
  76. package/src/emrtd/read.ts +8 -1
  77. package/src/emrtd/session.ts +41 -0
  78. package/src/emrtd/stages.ts +11 -0
  79. package/src/index.ts +54 -1
  80. package/src/lib/address-current-location.ts +171 -0
  81. package/src/lib/address-field-modes.ts +119 -0
  82. package/src/lib/address-flow.ts +172 -0
  83. package/src/lib/address-helpers.ts +48 -0
  84. package/src/lib/address-line.ts +76 -0
  85. package/src/lib/address-pin-move.ts +97 -0
  86. package/src/lib/address-step-recovery.ts +63 -0
  87. package/src/lib/authed-image.ts +78 -0
  88. package/src/lib/biometric-auth.ts +52 -0
  89. package/src/lib/biometric-copy.ts +51 -0
  90. package/src/lib/captureRing.ts +83 -0
  91. package/src/lib/contact-recovery.ts +44 -0
  92. package/src/lib/country-adoption.ts +89 -0
  93. package/src/lib/inferred-country.ts +65 -0
  94. package/src/lib/livenessLayout.ts +49 -0
  95. package/src/lib/map-frame.ts +159 -0
  96. package/src/lib/map-tiles.ts +143 -0
  97. package/src/lib/multi-id.ts +210 -0
  98. package/src/lib/poa-country-gate.ts +27 -0
  99. package/src/lib/resubmit.ts +130 -0
  100. package/src/lib/result-copy.ts +116 -0
  101. package/src/lib/result-wait.ts +53 -0
  102. package/src/lib/review-map-surface.ts +26 -0
  103. package/src/lib/scope.ts +31 -0
  104. package/src/lib/screen-corners.ts +50 -0
  105. package/src/lib/selfie-upload-wait.ts +79 -0
  106. package/src/lib/step-log.ts +24 -3
  107. package/src/lib/street-view-fov.ts +42 -0
  108. package/src/lib/use-multi-id-plan.ts +29 -0
  109. package/src/lib/webview-available.ts +47 -0
  110. package/src/liveness/useLiveness.ts +6 -1
  111. package/src/liveness/useModelReady.ts +65 -0
  112. package/src/liveness/visionCameraFaceDetector.ts +35 -0
  113. package/src/presence/background-math.ts +100 -0
  114. package/src/presence/background-store.ts +82 -0
  115. package/src/presence/background.ts +165 -0
  116. package/src/presence/foreground-service.ts +193 -0
  117. package/src/presence/fs.ts +51 -0
  118. package/src/presence/geofence.ts +41 -0
  119. package/src/presence/math.ts +44 -0
  120. package/src/presence/post.ts +40 -0
  121. package/src/presence/report.ts +87 -0
  122. package/src/presence/sampler.ts +110 -0
  123. package/src/presence/status.ts +92 -0
  124. package/src/presence/store.ts +94 -0
  125. package/src/presence/tier.ts +36 -0
  126. package/src/presence/watch-wait.ts +81 -0
  127. package/src/screens/AddressCountryControl.tsx +115 -0
  128. package/src/screens/ApplicantRoleStep.tsx +9 -5
  129. package/src/screens/BusinessCheckPanel.tsx +89 -0
  130. package/src/screens/BusinessDetailsFields.tsx +112 -0
  131. package/src/screens/BusinessDetailsStep.tsx +133 -145
  132. package/src/screens/BusinessDocumentSlot.tsx +5 -5
  133. package/src/screens/BusinessDocumentsStep.tsx +5 -56
  134. package/src/screens/BusinessKeyPeopleStep.tsx +103 -71
  135. package/src/screens/BusinessPickedCard.tsx +86 -0
  136. package/src/screens/BusinessPickedSection.tsx +51 -0
  137. package/src/screens/BusinessRegistryPickers.tsx +67 -0
  138. package/src/screens/BusinessSandboxToggle.tsx +114 -0
  139. package/src/screens/BusinessSearch.tsx +199 -0
  140. package/src/screens/BusinessSearchResults.tsx +115 -0
  141. package/src/screens/CompanyInfoFields.tsx +91 -51
  142. package/src/screens/ConsentStep.tsx +37 -17
  143. package/src/screens/ContactActions.tsx +54 -0
  144. package/src/screens/ContactDestinationField.tsx +5 -0
  145. package/src/screens/ContactEntryPanel.tsx +75 -0
  146. package/src/screens/ContactVerificationStep.tsx +43 -42
  147. package/src/screens/CountrySelectStep.tsx +13 -147
  148. package/src/screens/IdTypeStep.tsx +12 -5
  149. package/src/screens/KeepLinksSheet.tsx +88 -0
  150. package/src/screens/KeyPeopleAwaitCard.tsx +204 -0
  151. package/src/screens/KeyPeopleAwaitList.tsx +126 -0
  152. package/src/screens/KeyPeoplePending.tsx +154 -0
  153. package/src/screens/KeyPeopleSection.tsx +162 -0
  154. package/src/screens/KeyPeopleSectionsList.tsx +103 -0
  155. package/src/screens/KeyPeopleUboExemption.tsx +72 -0
  156. package/src/screens/KeyPersonCard.tsx +36 -4
  157. package/src/screens/KeyPersonForm.tsx +195 -36
  158. package/src/screens/KeyPersonKindToggle.tsx +68 -0
  159. package/src/screens/KeyPersonOwners.tsx +109 -0
  160. package/src/screens/KeyPersonRoleChips.tsx +68 -0
  161. package/src/screens/KeyPersonSheet.tsx +92 -108
  162. package/src/screens/LivenessAvatar.tsx +15 -3
  163. package/src/screens/LivenessStep.tsx +172 -52
  164. package/src/screens/NfcStep.tsx +14 -0
  165. package/src/screens/PoaDocumentTypeList.tsx +66 -0
  166. package/src/screens/ProofOfAddressParts.tsx +151 -0
  167. package/src/screens/ProofOfAddressStep.tsx +58 -96
  168. package/src/screens/SubmittedBadge.tsx +25 -0
  169. package/src/screens/SubmittedError.tsx +64 -0
  170. package/src/screens/SubmittedResult.tsx +117 -0
  171. package/src/screens/SubmittedStep.tsx +82 -119
  172. package/src/screens/SubmittedSuccess.tsx +127 -0
  173. package/src/screens/SubmittedWaiting.tsx +45 -0
  174. package/src/screens/address/AddressEntranceStep.tsx +161 -0
  175. package/src/screens/address/AddressIntroGate.tsx +148 -0
  176. package/src/screens/address/AddressMapStub.tsx +50 -0
  177. package/src/screens/address/AddressPinStep.tsx +178 -0
  178. package/src/screens/address/AddressReviewStep.tsx +162 -0
  179. package/src/screens/address/AddressSandboxTabs.tsx +158 -0
  180. package/src/screens/address/AddressSearchField.tsx +115 -0
  181. package/src/screens/address/AddressSearchStep.tsx +94 -0
  182. package/src/screens/address/CurrentLocationRow.tsx +135 -0
  183. package/src/screens/address/DetailsSheet.tsx +175 -0
  184. package/src/screens/address/DetailsSheetFields.tsx +184 -0
  185. package/src/screens/address/EntranceDropzone.tsx +178 -0
  186. package/src/screens/address/EntranceFraming.tsx +66 -0
  187. package/src/screens/address/EntrancePills.tsx +60 -0
  188. package/src/screens/address/FramedStreetView.tsx +155 -0
  189. package/src/screens/address/IntroDisclosures.tsx +210 -0
  190. package/src/screens/address/LabelDecisionRow.tsx +113 -0
  191. package/src/screens/address/PinSummaryRow.tsx +105 -0
  192. package/src/screens/address/ReviewAddressBand.tsx +111 -0
  193. package/src/screens/address/ReviewEntranceThumbs.tsx +64 -0
  194. package/src/screens/address/ReviewMapPicture.tsx +106 -0
  195. package/src/screens/address/SearchResults.tsx +105 -0
  196. package/src/screens/address/SearchScreen.tsx +176 -0
  197. package/src/screens/address/SkipForNow.tsx +43 -0
  198. package/src/screens/address/StreetViewChrome.tsx +81 -0
  199. package/src/screens/address/detail-values.ts +22 -0
  200. package/src/screens/address/fix-source.ts +27 -0
  201. package/src/screens/address/index.ts +8 -0
  202. package/src/screens/address/meta.ts +44 -0
  203. package/src/screens/address/use-address-flow.ts +200 -0
  204. package/src/screens/address/use-label-pin.ts +80 -0
  205. package/src/screens/address/use-pin-actions.ts +192 -0
  206. package/src/screens/biometric/BiometricAuthFlow.tsx +170 -0
  207. package/src/screens/consent/model.ts +79 -10
  208. package/src/screens/liveness/CaptureRing.tsx +91 -0
  209. package/src/screens/liveness/LivenessCamera.tsx +63 -0
  210. package/src/screens/liveness/LivenessHandover.tsx +43 -0
  211. package/src/screens/liveness/LivenessOutcome.tsx +4 -3
  212. package/src/screens/liveness/SelfiePreview.tsx +13 -4
  213. package/src/screens/liveness/ShutterFlash.tsx +25 -0
  214. package/src/screens/liveness/index.ts +4 -0
  215. package/src/screens/liveness/useSelfieUpload.ts +23 -3
  216. package/src/screens/nfc/NfcReadProgress.tsx +1 -0
  217. package/src/screens/nfc/NfcScanIllustration.tsx +7 -18
  218. package/src/screens/useAddressPhotoAttach.ts +77 -0
  219. package/src/screens/useAwaitingPeople.ts +90 -0
  220. package/src/screens/useBusinessDocumentAttach.ts +93 -0
  221. package/src/screens/usePoaAttach.ts +12 -15
  222. package/src/services/api-biometric.ts +37 -0
  223. package/src/services/api-types-biometric.ts +41 -0
  224. package/src/services/api-types.ts +251 -3
  225. package/src/services/api-verify-types.ts +90 -1
  226. package/src/services/api.ts +237 -3
  227. package/src/services/deviceMetadata.ts +1 -1
  228. package/src/services/errors.ts +10 -2
  229. package/src/services/location.ts +234 -0
  230. package/src/services/uploadErrors.ts +30 -0
  231. package/src/services/workflowGate.ts +8 -0
  232. package/src/specs/MyazaFaceDetector.nitro.ts +29 -0
  233. package/src/store/address.ts +97 -0
  234. package/src/store/businessCheck.ts +113 -0
  235. package/src/store/derive.ts +44 -4
  236. package/src/store/kycStore.ts +352 -16
  237. package/src/store/serverConfig.ts +20 -0
  238. package/src/store/session.ts +227 -0
  239. package/src/store/state.ts +289 -2
  240. package/src/store/submit.ts +80 -13
  241. package/src/store/submitApplicant.ts +5 -11
  242. package/src/types/business.ts +23 -2
  243. package/src/types/config.ts +70 -2
  244. package/src/types/verification.ts +13 -1
  245. package/src/types/workflow.ts +112 -1
  246. package/src/screens/KeyPeopleInviteLinks.tsx +0 -239
@@ -10,7 +10,26 @@
10
10
  export interface VerifyRequest {
11
11
  country: string;
12
12
  idType: string;
13
+ /** The attempt session this run happened under — the verification adopts its
14
+ * id, and a registry check paid at selection is not paid again at submit. */
15
+ sessionId?: string;
13
16
  idNumber?: string;
17
+ /**
18
+ * Multi-ID: every check in the run, in pick order (2–3). ONE verification
19
+ * comes back, judged by the workflow's pass policy. The top-level
20
+ * idType/idNumber mirror the first entry.
21
+ */
22
+ idChecks?: Array<{
23
+ idType: string;
24
+ idNumber?: string;
25
+ documentFront?: string;
26
+ documentBack?: string;
27
+ /** Each check's OWN document recording. */
28
+ documentFrontVideo?: string;
29
+ documentBackVideo?: string;
30
+ /** This check's own chip read — the chip belongs to a PARTICULAR document. */
31
+ nfc?: VerifyRequest['nfc'];
32
+ }>;
14
33
  /** The org's user reference → Entity.externalUserId at the seam (not matched). */
15
34
  userId?: string;
16
35
  userData?: {
@@ -26,9 +45,46 @@ export interface VerifyRequest {
26
45
  documentBackVideo?: string;
27
46
  livenessVideo?: string;
28
47
  proofOfAddress?: string;
48
+ /** Address Intelligence door photo (individual flows only). */
49
+ addressPhoto?: string;
29
50
  };
30
51
  /** Which kind of document the user said they uploaded as proof of address. */
31
52
  proofOfAddressType?: string;
53
+ /**
54
+ * Address Intelligence: the map pin the applicant dropped (+ the optional
55
+ * one-shot device fix taken at Continue). The server corroborates it; the
56
+ * SDK only collects. Sent only when the step ran and a pin was confirmed.
57
+ */
58
+ address?: {
59
+ lat: number;
60
+ lng: number;
61
+ accuracy?: number;
62
+ /**
63
+ * The line the applicant CONFIRMED (a search pick, or a reverse geocode
64
+ * they accepted). The server prefers it for the composed address over its
65
+ * own derivation, whose OSM coverage drops whole streets in our markets.
66
+ */
67
+ label?: string;
68
+ directions?: string;
69
+ propertyName?: string;
70
+ propertyNumber?: string;
71
+ /** A street the applicant typed because no map source knew it. */
72
+ street?: string;
73
+ /** The edit-details claims: unit + area/region corrections. */
74
+ unit?: string;
75
+ neighbourhood?: string;
76
+ city?: string;
77
+ state?: string;
78
+ postcode?: string;
79
+ /** Street View entrance frame — coordinates only; the server fetches the
80
+ * image with its own key. Never written by this SDK (no panorama on
81
+ * mobile), but a session begun on a hosted page can carry one. */
82
+ streetView?: { panoId: string; heading: number; pitch: number; fov: number };
83
+ deviceLat?: number;
84
+ deviceLng?: number;
85
+ deviceAccuracy?: number;
86
+ capturedAt?: string;
87
+ };
32
88
  /**
33
89
  * Business (KYB) registry details. Its presence is what makes this a business
34
90
  * submission — and the server REQUIRES a published KYB workflow for one, so
@@ -50,6 +106,13 @@ export interface VerifyRequest {
50
106
  email?: string;
51
107
  phone?: string;
52
108
  website?: string;
109
+ /** Registry facts the applicant states (the extended collectCompanyInfo
110
+ * fields) — where they differ from the register, that is the finding. */
111
+ dateOfIncorporation?: string;
112
+ taxId?: string;
113
+ vatNumber?: string;
114
+ companyType?: string;
115
+ natureOfBusiness?: string;
53
116
  /** Uploaded supporting documents (honoured only when the workflow's
54
117
  * `business.documents` block configures them). */
55
118
  documents?: Array<{ type: string; mediaId: string }>;
@@ -77,7 +140,33 @@ export interface VerifyRequest {
77
140
  * signer — and only the server may conclude a chip is genuine. `chipAuth`
78
141
  * reports how the chip was unlocked and is informational.
79
142
  */
80
- nfc?: { dg1: string; sod?: string; dg2?: string; chipAuth?: string };
143
+ nfc?: {
144
+ dg1: string;
145
+ sod?: string;
146
+ dg2?: string;
147
+ dg7?: string;
148
+ dg11?: string;
149
+ dg12?: string;
150
+ /**
151
+ * DG15 (the chip's Active-Authentication public key) and its signature over
152
+ * the challenge the server issued — the ANTI-CLONE proof. Passive
153
+ * authentication proves the issuing state signed this data; only these
154
+ * prove it is the chip they signed it onto. Verified server-side against a
155
+ * SOD-bound DG15: a client that checked its own chip could be patched.
156
+ */
157
+ dg15?: string;
158
+ aaChallengeId?: string;
159
+ aaSignature?: string;
160
+ chipAuth?: string;
161
+ /**
162
+ * WHY the session is on that protocol. A chip reading over BAC because it
163
+ * offers no PACE is nothing to act on; one reading over BAC because our
164
+ * PACE broke is a bug, and `chipAuth` alone cannot tell them apart.
165
+ * Diagnostic only — the server records it and never judges on it.
166
+ */
167
+ paceOutcome?: string;
168
+ paceDetail?: string;
169
+ };
81
170
  /** The workflow that drove this flow — attributes the submission to it. */
82
171
  workflowId?: string;
83
172
  /**
@@ -1,10 +1,20 @@
1
1
  import { SDK_VERSION } from './deviceMetadata';
2
2
  import type {
3
+ AddressReverseResult,
4
+ AddressSearchHit,
5
+ BusinessRegionsResponse,
6
+ BusinessSearchResponse,
7
+ BusinessSelectResponse,
3
8
  ContactCheckResponse,
9
+ NfcChallengeResponse,
4
10
  ContactSendResponse,
5
11
  HealthResponse,
6
12
  MediaUploadType,
13
+ PlaceSuggestion,
14
+ ResolvedPlace,
7
15
  SdkConfigResponse,
16
+ SessionStartResponse,
17
+ SessionSummaryResponse,
8
18
  UploadFile,
9
19
  UploadResponse,
10
20
  VerificationStatusResponse,
@@ -12,6 +22,7 @@ import type {
12
22
  VerifyResponse,
13
23
  WorkflowResolutionResponse,
14
24
  } from './api-types';
25
+ import { biometricCalls } from './api-biometric';
15
26
 
16
27
  // The HTTP contract lives in ./api-types and is re-exported here, so importers
17
28
  // keep a single entry point for both the client and the shapes it exchanges.
@@ -64,9 +75,14 @@ function uriToBlob(uri: string, mimeType: string): Promise<Blob> {
64
75
  xhr.onload = () => {
65
76
  const blob = xhr.response as Blob | null;
66
77
  if (blob) {
67
- // Ensure the part carries the right content-type even if the platform
68
- // didn't infer one from the URI.
69
- resolve(blob.type ? blob : blob.slice(0, blob.size, mimeType));
78
+ // ALWAYS re-type the part, never only when the platform inferred
79
+ // nothing. Its guess comes from the URI's extension and describes the
80
+ // file we PICKED, not the one we are sending: an iPhone gallery photo
81
+ // arrives as HEIC, gets transcoded to JPEG on the way here, and the
82
+ // part still went out labelled image/heic — which the server refuses,
83
+ // so most camera-roll uploads failed with a message that blamed the
84
+ // document. `mimeType` is the normalised type the bytes actually are.
85
+ resolve(blob.type === mimeType ? blob : blob.slice(0, blob.size, mimeType));
70
86
  } else {
71
87
  reject(new Error(`Could not read file at ${uri}`));
72
88
  }
@@ -151,6 +167,8 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
151
167
  }
152
168
 
153
169
  return {
170
+ ...biometricCalls(request),
171
+
154
172
  /**
155
173
  * Single multipart upload: the local file is POSTed to our server, which
156
174
  * stores it and returns the `mediaId` referenced later by /verify.
@@ -234,6 +252,18 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
234
252
  });
235
253
  },
236
254
 
255
+ /**
256
+ * A fresh Active-Authentication challenge for a chip read — the nonce the
257
+ * chip signs to prove it is the original document rather than a copy of
258
+ * one. It has to come from the SERVER: a nonce the client chose would let
259
+ * a captured signature be replayed forever, which is the clone the check
260
+ * exists to catch. Best-effort by contract at every call site — a chip
261
+ * read without one is exactly the read we did before.
262
+ */
263
+ async nfcChallenge(): Promise<NfcChallengeResponse> {
264
+ return request<NfcChallengeResponse>('/nfc/challenge', { method: 'POST' });
265
+ },
266
+
237
267
  async config(signal?: AbortSignal): Promise<SdkConfigResponse> {
238
268
  return request<SdkConfigResponse>('/config', signal ? { signal } : {});
239
269
  },
@@ -250,6 +280,210 @@ export function createKYCApi(baseUrl: string, apiKey: string) {
250
280
  );
251
281
  },
252
282
 
283
+ /**
284
+ * Begin (or resume) a verification ATTEMPT session. Best-effort by
285
+ * contract: sessions power resumability, the dashboard's live attempt
286
+ * view, and the registry check at selection — verifying is never
287
+ * conditional on one existing.
288
+ */
289
+ async startSession(input: {
290
+ externalUserId?: string;
291
+ workflowId?: string;
292
+ /** Persistent device id — the anonymous-mount resume fallback. */
293
+ deviceRef?: string;
294
+ /** The same device block the submission sends, so the dashboard's
295
+ * in-progress row shows the device and SDK from the moment the SDK
296
+ * loads rather than after the applicant finishes (2026-09-08). */
297
+ device?: Record<string, unknown>;
298
+ }): Promise<SessionStartResponse> {
299
+ return request<SessionStartResponse>('/session/start', {
300
+ method: 'POST',
301
+ body: JSON.stringify(input),
302
+ });
303
+ },
304
+
305
+ /**
306
+ * A submitted session, rebuilt server-side — the reconciled key-people
307
+ * list the success screen polls. Same body a hosted link reads by token,
308
+ * so mobile and hosted success screens cannot tell different stories
309
+ * about one application.
310
+ */
311
+ async sessionSummary(sessionId: string): Promise<SessionSummaryResponse> {
312
+ return request<SessionSummaryResponse>(`/session/${sessionId}/summary`);
313
+ },
314
+
315
+ /**
316
+ * Save where the user has got to. Losing a save costs some re-typing on
317
+ * resume and must never interrupt them now — callers swallow failures.
318
+ */
319
+ async saveProgress(sessionId: string, progress: unknown): Promise<void> {
320
+ await request(`/session/${encodeURIComponent(sessionId)}/progress`, {
321
+ method: 'PUT',
322
+ body: JSON.stringify(progress),
323
+ });
324
+ },
325
+
326
+ /**
327
+ * Find a business by name. FREE — no provider charge here or upstream, so
328
+ * the applicant may look as many times as they need. Throws on a provider
329
+ * failure so the caller can show "unavailable" rather than an empty list,
330
+ * which would read as "this business is not registered".
331
+ */
332
+ async businessSearch(params: {
333
+ country: string;
334
+ subdivisionCode?: string;
335
+ query: string;
336
+ limit?: number;
337
+ }): Promise<BusinessSearchResponse> {
338
+ const qs = new URLSearchParams({ country: params.country, query: params.query });
339
+ if (params.subdivisionCode) qs.set('subdivisionCode', params.subdivisionCode);
340
+ if (params.limit) qs.set('limit', String(params.limit));
341
+ return request<BusinessSearchResponse>(`/business/search?${qs.toString()}`);
342
+ },
343
+
344
+ /** Registry regions for a country. Empty when it has a single register. */
345
+ async businessRegions(country: string): Promise<BusinessRegionsResponse> {
346
+ return request<BusinessRegionsResponse>(
347
+ `/business/regions?country=${encodeURIComponent(country)}`,
348
+ );
349
+ },
350
+
351
+ /**
352
+ * The PAID registry check for the company the applicant identified, run at
353
+ * selection so the register's key people come back BEFORE the form asks
354
+ * for them. Never fails the flow: a short balance, unconfigured pricing or
355
+ * a spent lookup budget returns `checked: false` and the lookup happens at
356
+ * submit as before.
357
+ */
358
+ async businessSelect(body: {
359
+ sessionId: string;
360
+ country: string;
361
+ subdivisionCode?: string;
362
+ product?: string;
363
+ registrationNumber: string;
364
+ registrationName?: string;
365
+ sandboxOutcome?: string;
366
+ }): Promise<BusinessSelectResponse> {
367
+ return request<BusinessSelectResponse>('/business/select', {
368
+ method: 'POST',
369
+ body: JSON.stringify(body),
370
+ });
371
+ },
372
+
373
+ /**
374
+ * Forward address search for the address step's search box.
375
+ *
376
+ * EXPLICIT SUBMIT ONLY — never call this per keystroke. The server's map
377
+ * source forbids autocomplete, and the request budget has to be spent on
378
+ * the query the person actually meant rather than on every prefix of it.
379
+ */
380
+ async addressSearch(
381
+ query: string,
382
+ country?: string | null,
383
+ ): Promise<{ results: AddressSearchHit[] }> {
384
+ const qs = new URLSearchParams({ q: query });
385
+ if (country) qs.set('country', country);
386
+ return request<{ results: AddressSearchHit[] }>(`/address/search?${qs.toString()}`);
387
+ },
388
+
389
+ /**
390
+ * The framed Street View entrance, as an image SOURCE the review card can
391
+ * hand straight to <Image>.
392
+ *
393
+ * The browser key never reaches this SDK (the framed page holds it), so
394
+ * the picture comes through the server, which does. Returned as a URL plus
395
+ * the auth header rather than fetched bytes: React Native's Image carries
396
+ * headers itself, which keeps Blob, FileReader and base64 out of a path
397
+ * that only has to draw a thumbnail. Mirrors the web SDK's
398
+ * addressStreetViewPreview, which returns a Blob because a browser <img>
399
+ * cannot send an Authorization header.
400
+ */
401
+ /**
402
+ * The pinned location as a PICTURE, for the review card.
403
+ *
404
+ * A confirmation screen wants a photograph of the place, not a second
405
+ * instrument: a live map there invites a drag that goes nowhere and
406
+ * carries the vendor's own controls over the SDK's chrome. Same shape as
407
+ * the Street View source above, and the same reason for it: the key lives
408
+ * on the server. lib/authed-image fetches it with the header — an <Image>
409
+ * given `source.headers` drops them on Android.
410
+ */
411
+ staticMapSource(view: {
412
+ lat: number;
413
+ lng: number;
414
+ zoom?: number;
415
+ width?: number;
416
+ height?: number;
417
+ }): { uri: string; headers: Record<string, string> } {
418
+ const qs = new URLSearchParams({
419
+ lat: String(view.lat),
420
+ lng: String(view.lng),
421
+ zoom: String(view.zoom ?? 16),
422
+ width: String(Math.round(view.width ?? 640)),
423
+ height: String(Math.round(view.height ?? 360)),
424
+ });
425
+ return { uri: `${base}/address/static-map?${qs.toString()}`, headers };
426
+ },
427
+
428
+ streetViewPreviewSource(frame: {
429
+ panoId: string;
430
+ heading: number;
431
+ pitch: number;
432
+ fov: number;
433
+ }): { uri: string; headers: Record<string, string> } {
434
+ const qs = new URLSearchParams({
435
+ panoId: frame.panoId,
436
+ heading: String(frame.heading),
437
+ pitch: String(frame.pitch),
438
+ fov: String(frame.fov),
439
+ });
440
+ return {
441
+ uri: `${base}/address/street-view-preview?${qs.toString()}`,
442
+ headers,
443
+ };
444
+ },
445
+
446
+ /** The street line for a pin, for the summary card after a locate or a
447
+ * drag. Display only — it never decides anything. */
448
+ async addressReverse(lat: number, lng: number): Promise<AddressReverseResult> {
449
+ const qs = new URLSearchParams({ lat: String(lat), lng: String(lng) });
450
+ return request<AddressReverseResult>(`/address/reverse?${qs.toString()}`);
451
+ },
452
+
453
+ /**
454
+ * Places-backed as-you-type suggestions.
455
+ *
456
+ * `session` is ONE token per typing session: it is the billing unit, so
457
+ * Google bills per session rather than per keystroke. Mint it when the
458
+ * search screen opens, reuse it for every keystroke, and mint a fresh one
459
+ * after a details call — that call closes the session.
460
+ */
461
+ async addressAutocomplete(
462
+ query: string,
463
+ session: string,
464
+ country?: string | null,
465
+ near?: { lat: number; lng: number } | null,
466
+ ): Promise<{ suggestions: PlaceSuggestion[] }> {
467
+ const qs = new URLSearchParams({ q: query, session });
468
+ if (country) qs.set('country', country);
469
+ // The device fix, a RANKING bias so nearby streets come first: without
470
+ // it "Awolowo Road" in Calabar ranks against every Awolowo Road in the
471
+ // country. Mirrors the web SDK's api.addressAutocomplete.
472
+ if (near) {
473
+ qs.set('lat', String(near.lat));
474
+ qs.set('lng', String(near.lng));
475
+ }
476
+ return request<{ suggestions: PlaceSuggestion[] }>(`/address/autocomplete?${qs.toString()}`);
477
+ },
478
+
479
+ /** Resolve a picked suggestion to coordinates + structured pieces. */
480
+ async addressPlace(placeId: string, session: string): Promise<{ place: ResolvedPlace }> {
481
+ const qs = new URLSearchParams({ session });
482
+ return request<{ place: ResolvedPlace }>(
483
+ `/address/place/${encodeURIComponent(placeId)}?${qs.toString()}`,
484
+ );
485
+ },
486
+
253
487
  async health(): Promise<HealthResponse> {
254
488
  // Public endpoint — no auth needed, but the shared headers are harmless.
255
489
  return request<HealthResponse>('/health');
@@ -15,7 +15,7 @@ export type DeviceType = 'mobile' | 'tablet' | 'desktop' | 'unknown';
15
15
  * Single source of truth for the SDK version — also used by `services/api.ts`
16
16
  * for the `X-SDK-Version` header. Keep in sync with `package.json`.
17
17
  */
18
- export const SDK_VERSION = '2.4.0';
18
+ export const SDK_VERSION = '2.6.0';
19
19
 
20
20
  export interface ReactNativeDeviceMetadata {
21
21
  sdkType: 'react-native';
@@ -74,8 +74,16 @@ export function mapToKycError(err: unknown, context: ErrorContext): KYCError {
74
74
  : err.message || 'This verification feature is currently disabled for your organization.';
75
75
  return new KYCError('feature_disabled', message);
76
76
  }
77
- if (err.statusCode >= 500 || err.statusCode === 0) {
78
- // Transient server error that survived retries.
77
+ // Nothing came back at all — DNS, routing, a refused connection. Kept
78
+ // SEPARATE from a 5xx: telling somebody the server errored when their
79
+ // request never arrived sends them looking through server logs for a
80
+ // request that was never made.
81
+ if (err.statusCode === 0) {
82
+ const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'network_error';
83
+ return new KYCError(code, "Couldn't reach the server. Check your connection and try again.");
84
+ }
85
+ if (err.statusCode >= 500) {
86
+ // Transient server error that survived retries — the request DID arrive.
79
87
  const code: KYCErrorCode = context === 'upload' ? 'upload_failed' : 'network_error';
80
88
  return new KYCError(code, 'A server error occurred. Please try again in a moment.');
81
89
  }
@@ -0,0 +1,234 @@
1
+ // ---------------------------------------------------------------------------
2
+ // One-shot geolocation for the address-collection step.
3
+ //
4
+ // Best-effort BY CONTRACT, mirroring the web SDK's address-helpers: a denied
5
+ // permission, a device with location off, or a slow fix costs the `attested`
6
+ // tier (or the recentre convenience) — never the flow. The attest fix resolves
7
+ // to null / {} on any failure; the PIN's fix says WHY it failed, so the copy
8
+ // can send the person to the right remedy (see lib/address-current-location).
9
+ // ---------------------------------------------------------------------------
10
+
11
+ import * as Location from 'expo-location';
12
+
13
+ const FIX_TIMEOUT_MS = 8_000;
14
+
15
+ /** Accuracy at which a fix is good enough to stop waiting for the GPS. */
16
+ const PRECISE_ENOUGH_M = 25;
17
+ const PRECISE_WINDOW_MS = 8_000;
18
+ /**
19
+ * Once ANY fix exists, wait only this much longer for a better one. An indoor
20
+ * or wifi-derived fix never reaches 25m, and sitting out the whole window for
21
+ * an accuracy that is not coming reads as "it keeps loading".
22
+ */
23
+ const FIRST_FIX_GRACE_MS = 3_000;
24
+
25
+ export interface DeviceFix {
26
+ lat: number;
27
+ lng: number;
28
+ accuracy: number | null;
29
+ timestamp: number;
30
+ /** Android reports a mock-location provider; iOS has no equivalent (null). */
31
+ mocked: boolean | null;
32
+ }
33
+
34
+ /**
35
+ * Why a fix could not be taken. A refused permission, a phone that cannot
36
+ * place itself (location switched off, no provider), and a fix that took
37
+ * longer than the window are three different problems with three different
38
+ * remedies. Mirrors the web SDK's LocationFailure and Flutter's enum.
39
+ */
40
+ export type LocationFailure = 'denied' | 'unavailable' | 'timeout' | 'unsupported';
41
+
42
+ export type PreciseFixOutcome = { fix: DeviceFix } | { failure: LocationFailure };
43
+
44
+ function toFix(pos: Location.LocationObject): DeviceFix {
45
+ return {
46
+ lat: pos.coords.latitude,
47
+ lng: pos.coords.longitude,
48
+ accuracy: typeof pos.coords.accuracy === 'number' ? pos.coords.accuracy : null,
49
+ timestamp: pos.timestamp || Date.now(),
50
+ mocked: typeof pos.mocked === 'boolean' ? pos.mocked : null,
51
+ };
52
+ }
53
+
54
+ function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
55
+ return new Promise<T>((resolve, reject) => {
56
+ const timer = setTimeout(() => reject(new Error('location timeout')), ms);
57
+ promise.then(
58
+ (v) => {
59
+ clearTimeout(timer);
60
+ resolve(v);
61
+ },
62
+ (e) => {
63
+ clearTimeout(timer);
64
+ reject(e);
65
+ },
66
+ );
67
+ });
68
+ }
69
+
70
+ // The most recent fix ANY read produced, kept so the confirm-time attest read
71
+ // can fall back on it (see deviceFixFields).
72
+ let lastGoodFix: DeviceFix | null = null;
73
+ function remember(fix: DeviceFix): DeviceFix {
74
+ lastGoodFix = fix;
75
+ return fix;
76
+ }
77
+
78
+ /** A fix older than this no longer says where the phone is NOW. */
79
+ export const LAST_KNOWN_MAX_AGE_MS = 10 * 60_000;
80
+
81
+ /**
82
+ * One fix for a presence report, or null. Asks for foreground permission on
83
+ * first use.
84
+ *
85
+ * Resolves the way the pin step's precise read learned to, in three rungs:
86
+ * a fresh read at BALANCED accuracy (the fence is 250m or wider, so a 100m
87
+ * fix is plenty; Expo's iOS read waits for a fix that MEETS the requested
88
+ * accuracy, and a High request indoors never delivered one inside the
89
+ * window, so every RN report on the iPhone came back `no_fix` while
90
+ * Flutter's Geolocator, which returns the first update, reported fine,
91
+ * 2026-09-08), then the platform's last known position, then the last fix
92
+ * this module itself took (the pin step's, minutes earlier), each accepted
93
+ * only within LAST_KNOWN_MAX_AGE_MS. Flutter's reporter carries the same
94
+ * last-known fallback.
95
+ */
96
+ export async function currentPosition(): Promise<DeviceFix | null> {
97
+ try {
98
+ const { status } = await Location.requestForegroundPermissionsAsync();
99
+ if (status !== 'granted') return null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ const fresh = await withTimeout(
104
+ Location.getCurrentPositionAsync({ accuracy: Location.Accuracy.Balanced }),
105
+ FIX_TIMEOUT_MS,
106
+ ).then(toFix, () => null);
107
+ if (fresh) return remember(fresh);
108
+ const known = await Location.getLastKnownPositionAsync({ maxAge: LAST_KNOWN_MAX_AGE_MS }).then(
109
+ (pos) => (pos ? toFix(pos) : null),
110
+ () => null,
111
+ );
112
+ if (known) return remember(known);
113
+ if (lastGoodFix && Date.now() - lastGoodFix.timestamp <= LAST_KNOWN_MAX_AGE_MS) return lastGoodFix;
114
+ return null;
115
+ }
116
+
117
+ /**
118
+ * A PRECISE fix: watch the position for up to ~8s, keep the most accurate
119
+ * reading, and resolve early once it is within 25m.
120
+ *
121
+ * A single `getCurrentPositionAsync` routinely answers with the first coarse
122
+ * wifi/cell reading — hundreds of metres out, before the GPS has warmed up —
123
+ * which is exactly the pin landing on the wrong compound. A WATCH also never
124
+ * hands back a cached fix, which is the other half of what a pin needs.
125
+ *
126
+ * Never throws: every failure is CLASSIFIED, and every caller falls back to
127
+ * placing the pin by hand.
128
+ */
129
+ export async function precisePositionOutcome(): Promise<PreciseFixOutcome> {
130
+ let granted = false;
131
+ try {
132
+ const { status } = await Location.requestForegroundPermissionsAsync();
133
+ granted = status === 'granted';
134
+ } catch {
135
+ // No location module behind the call: nothing on this device can answer.
136
+ return { failure: 'unsupported' };
137
+ }
138
+ if (!granted) return { failure: 'denied' };
139
+ try {
140
+ // Permission granted with the toggle OFF is the failure people hit most,
141
+ // and it is the one "allow location access" sends them the wrong way on.
142
+ if (!(await Location.hasServicesEnabledAsync())) return { failure: 'unavailable' };
143
+ } catch {
144
+ // An older module without the probe: let the watch decide.
145
+ }
146
+
147
+ return new Promise<PreciseFixOutcome>((resolve) => {
148
+ let best: Location.LocationObject | null = null;
149
+ let settled = false;
150
+ let watchFailed = false;
151
+ let sub: Location.LocationSubscription | null = null;
152
+ let grace: ReturnType<typeof setTimeout> | null = null;
153
+
154
+ const finish = (): void => {
155
+ if (settled) return;
156
+ settled = true;
157
+ clearTimeout(windowTimer);
158
+ if (grace) clearTimeout(grace);
159
+ sub?.remove();
160
+ if (best) resolve({ fix: remember(toFix(best)) });
161
+ else resolve({ failure: watchFailed ? 'unavailable' : 'timeout' });
162
+ };
163
+
164
+ const windowTimer = setTimeout(finish, PRECISE_WINDOW_MS);
165
+
166
+ Location.watchPositionAsync(
167
+ { accuracy: Location.Accuracy.BestForNavigation, distanceInterval: 0 },
168
+ (pos) => {
169
+ const acc = pos.coords.accuracy ?? Infinity;
170
+ if (!best || acc < (best.coords.accuracy ?? Infinity)) best = pos;
171
+ if (acc <= PRECISE_ENOUGH_M) {
172
+ finish();
173
+ return;
174
+ }
175
+ if (!grace) grace = setTimeout(finish, FIRST_FIX_GRACE_MS);
176
+ },
177
+ )
178
+ .then((subscription) => {
179
+ // The window may already have elapsed while the watch was starting —
180
+ // hold no subscription nobody will ever remove.
181
+ if (settled) subscription.remove();
182
+ else sub = subscription;
183
+ })
184
+ .catch(() => {
185
+ watchFailed = true;
186
+ finish();
187
+ });
188
+ });
189
+ }
190
+
191
+ /**
192
+ * The attest-presence device fix, as the fields the verify body carries.
193
+ * Empty when no fix could be taken — the submission simply goes without the
194
+ * `attested` tier.
195
+ */
196
+ /** How old a fix from earlier in the SAME address flow may be and still stand
197
+ * in for the confirm-time read. Placing a pin takes a minute or two; a fix
198
+ * from that window still says the device was here, and the server judges it
199
+ * by its own `capturedAt` anyway. */
200
+ export const RECENT_FIX_MAX_AGE_MS = 3 * 60_000;
201
+
202
+ /** The reading the attest step should send: a fresh one when the read
203
+ * answered, else the recent one the flow already took, else nothing. Pure,
204
+ * so the rule is testable without a GPS. */
205
+ export function pickDeviceFix(
206
+ fresh: DeviceFix | null,
207
+ recent: DeviceFix | null,
208
+ now: number = Date.now(),
209
+ ): DeviceFix | null {
210
+ if (fresh) return fresh;
211
+ if (recent && now - recent.timestamp <= RECENT_FIX_MAX_AGE_MS && recent.mocked !== true) return recent;
212
+ return null;
213
+ }
214
+
215
+ export async function deviceFixFields(): Promise<{
216
+ deviceLat?: number;
217
+ deviceLng?: number;
218
+ deviceAccuracy?: number;
219
+ capturedAt?: string;
220
+ }> {
221
+ // A single getCurrentPositionAsync at confirm routinely times out on iOS
222
+ // while the GPS is still settling, and the submission then went out with
223
+ // no fix at all even though "Use my location" had just placed the pin on
224
+ // one (iPhone 16 Pro Max, 2026-09-07: every address run read "No device
225
+ // fix taken"). The fix the flow already holds is the fallback.
226
+ const fix = pickDeviceFix(await currentPosition(), lastGoodFix);
227
+ if (!fix) return {};
228
+ return {
229
+ deviceLat: fix.lat,
230
+ deviceLng: fix.lng,
231
+ ...(fix.accuracy != null ? { deviceAccuracy: fix.accuracy } : {}),
232
+ capturedAt: new Date(fix.timestamp).toISOString(),
233
+ };
234
+ }